1# frozen_string_literal: true
2
3require 'find'
4
5module Gitlab
6  module Graphql
7    module Queries
8      IMPORT_RE = /^#\s*import "(?<path>[^"]+)"$/m.freeze
9      EE_ELSE_CE = /^ee_else_ce/.freeze
10      HOME_RE = /^~/.freeze
11      HOME_EE = %r{^ee/}.freeze
12      DOTS_RE = %r{^(\.\./)+}.freeze
13      DOT_RE = %r{^\./}.freeze
14      IMPLICIT_ROOT = %r{^app/}.freeze
15      CONN_DIRECTIVE = /@connection\(key: "\w+"\)/.freeze
16
17      class WrappedError
18        delegate :message, to: :@error
19
20        def initialize(error)
21          @error = error
22        end
23
24        def path
25          []
26        end
27      end
28
29      class FileNotFound
30        def initialize(file)
31          @file = file
32        end
33
34        def message
35          "File not found: #{@file}"
36        end
37
38        def path
39          []
40        end
41      end
42
43      # We need to re-write queries to remove all @client fields. Ideally we
44      # would do that as a source-to-source transformation of the AST, but doing it using a
45      # printer is much simpler.
46      class ClientFieldRedactor < GraphQL::Language::Printer
47        attr_reader :fields_printed, :skipped_arguments, :printed_arguments, :used_fragments
48
49        def initialize(skips = true)
50          @skips = skips
51          @fields_printed = 0
52          @in_operation = false
53          @skipped_arguments = [].to_set
54          @printed_arguments = [].to_set
55          @used_fragments = [].to_set
56          @skipped_fragments = [].to_set
57          @used_fragments = [].to_set
58        end
59
60        def print_variable_identifier(variable_identifier)
61          @printed_arguments << variable_identifier.name
62          super
63        end
64
65        def print_fragment_spread(fragment_spread, indent: "")
66          @used_fragments << fragment_spread.name
67          super
68        end
69
70        def print_operation_definition(op, indent: "")
71          @in_operation = true
72          out = +"#{indent}#{op.operation_type}"
73          out << " #{op.name}" if op.name
74
75          # Do these first, so that we detect any skipped arguments
76          dirs = print_directives(op.directives)
77          sels = print_selections(op.selections, indent: indent)
78
79          # remove variable definitions only used in skipped (client) fields
80          vars = op.variables.reject do |v|
81            @skipped_arguments.include?(v.name) && !@printed_arguments.include?(v.name)
82          end
83
84          if vars.any?
85            out << "(#{vars.map { |v| print_variable_definition(v) }.join(", ")})"
86          end
87
88          out + dirs + sels
89        ensure
90          @in_operation = false
91        end
92
93        def print_field(field, indent: '')
94          if skips? && field.directives.any? { |d| d.name == 'client' }
95            skipped = self.class.new(false)
96
97            skipped.print_node(field)
98            @skipped_fragments |= skipped.used_fragments
99            @skipped_arguments |= skipped.printed_arguments
100
101            return ''
102          end
103
104          ret = super
105
106          @fields_printed += 1 if @in_operation && ret != ''
107
108          ret
109        end
110
111        def print_fragment_definition(fragment_def, indent: "")
112          if skips? && @skipped_fragments.include?(fragment_def.name) && !@used_fragments.include?(fragment_def.name)
113            return ''
114          end
115
116          super
117        end
118
119        def skips?
120          @skips
121        end
122      end
123
124      class Definition
125        attr_reader :file, :imports
126
127        def initialize(path, fragments)
128          @file = path
129          @fragments = fragments
130          @imports = []
131          @errors = []
132          @ee_else_ce = []
133        end
134
135        def text(mode: :ce)
136          qs = [query] + all_imports(mode: mode).uniq.sort.map { |p| fragment(p).query }
137          t = qs.join("\n\n").gsub(/\n\n+/, "\n\n")
138
139          return t unless /@client/.match?(t)
140
141          doc = ::GraphQL.parse(t)
142          printer = ClientFieldRedactor.new
143          redacted = doc.dup.to_query_string(printer: printer)
144
145          return redacted if printer.fields_printed > 0
146        end
147
148        def complexity(schema)
149          # See BaseResolver::resolver_complexity
150          # we want to see the max possible complexity.
151          fake_args = Struct
152            .new(:if, :keyword_arguments)
153            .new(nil, { sort: true, search: true })
154
155          query = GraphQL::Query.new(schema, text)
156          # We have no arguments, so fake them.
157          query.define_singleton_method(:arguments_for) { |_x, _y| fake_args }
158
159          GraphQL::Analysis::AST.analyze_query(query, [GraphQL::Analysis::AST::QueryComplexity]).first
160        end
161
162        def query
163          return @query if defined?(@query)
164
165          # CONN_DIRECTIVEs are purely client-side constructs
166          @query = File.read(file).gsub(CONN_DIRECTIVE, '').gsub(IMPORT_RE) do
167            path = $~[:path]
168
169            if EE_ELSE_CE.match?(path)
170              @ee_else_ce << path.gsub(EE_ELSE_CE, '')
171            else
172              @imports << fragment_path(path)
173            end
174
175            ''
176          end
177        rescue Errno::ENOENT
178          @errors << FileNotFound.new(file)
179          @query = nil
180        end
181
182        def all_imports(mode: :ce)
183          return [] if query.nil?
184
185          home = mode == :ee ? @fragments.home_ee : @fragments.home
186          eithers = @ee_else_ce.map { |p| home + p }
187
188          (imports + eithers).flat_map { |p| [p] + @fragments.get(p).all_imports(mode: mode) }
189        end
190
191        def all_errors
192          return @errors.to_set if query.nil?
193
194          paths = imports + @ee_else_ce.flat_map { |p| [@fragments.home + p, @fragments.home_ee + p] }
195
196          paths.map { |p| fragment(p).all_errors }.reduce(@errors.to_set) { |a, b| a | b }
197        end
198
199        def validate(schema)
200          return [:client_query, []] if query.present? && text.nil?
201
202          errs = all_errors.presence || schema.validate(text)
203          if @ee_else_ce.present?
204            errs += schema.validate(text(mode: :ee))
205          end
206
207          [:validated, errs]
208        rescue ::GraphQL::ParseError => e
209          [:validated, [WrappedError.new(e)]]
210        end
211
212        private
213
214        def fragment(path)
215          @fragments.get(path)
216        end
217
218        def fragment_path(import_path)
219          frag_path = import_path.gsub(HOME_RE, @fragments.home)
220          frag_path = frag_path.gsub(HOME_EE, @fragments.home_ee + '/')
221          frag_path = frag_path.gsub(DOT_RE) do
222            Pathname.new(file).parent.to_s + '/'
223          end
224          frag_path = frag_path.gsub(DOTS_RE) do |dots|
225            rel_dir(dots.split('/').count)
226          end
227          frag_path.gsub(IMPLICIT_ROOT) do
228            (Rails.root / 'app').to_s + '/'
229          end
230        end
231
232        def rel_dir(n_steps_up)
233          path = Pathname.new(file).parent
234          while n_steps_up > 0
235            path = path.parent
236            n_steps_up -= 1
237          end
238
239          path.to_s + '/'
240        end
241      end
242
243      class Fragments
244        def initialize(root, dir = 'app/assets/javascripts')
245          @root = root
246          @store = {}
247          @dir = dir
248        end
249
250        def home
251          @home ||= (@root / @dir).to_s
252        end
253
254        def home_ee
255          @home_ee ||= (@root / 'ee' / @dir).to_s
256        end
257
258        def get(frag_path)
259          @store[frag_path] ||= Definition.new(frag_path, self)
260        end
261      end
262
263      def self.find(root)
264        definitions = []
265
266        ::Find.find(root.to_s) do |path|
267          definitions << Definition.new(path, fragments) if query_for_gitlab_schema?(path)
268        end
269
270        definitions
271      rescue Errno::ENOENT
272        [] # root does not exist
273      end
274
275      def self.fragments
276        @fragments ||= Fragments.new(Rails.root)
277      end
278
279      def self.all
280        ['.', 'ee'].flat_map do |prefix|
281          find(Rails.root / prefix / 'app/assets/javascripts')
282        end
283      end
284
285      def self.known_failure?(path)
286        @known_failures ||= YAML.safe_load(File.read(Rails.root.join('config', 'known_invalid_graphql_queries.yml')))
287
288        @known_failures.fetch('filenames', []).any? { |known_failure| path.to_s.ends_with?(known_failure) }
289      end
290
291      def self.query_for_gitlab_schema?(path)
292        path.ends_with?('.graphql') &&
293          !path.ends_with?('.fragment.graphql') &&
294          !path.ends_with?('typedefs.graphql') &&
295          !/.*\.customer\.(query|mutation)\.graphql$/.match?(path)
296      end
297    end
298  end
299end
300