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 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378
|
# frozen_string_literal: true
module GraphQL
module Execution
# Lookahead creates a uniform interface to inspect the forthcoming selections.
#
# It assumes that the AST it's working with is valid. (So, it's safe to use
# during execution, but if you're using it directly, be sure to validate first.)
#
# A field may get access to its lookahead by adding `extras: [:lookahead]`
# to its configuration.
#
# @example looking ahead in a field
# field :articles, [Types::Article], null: false,
# extras: [:lookahead]
#
# # For example, imagine a faster database call
# # may be issued when only some fields are requested.
# #
# # Imagine that _full_ fetch must be made to satisfy `fullContent`,
# # we can look ahead to see if we need that field. If we do,
# # we make the expensive database call instead of the cheap one.
# def articles(lookahead:)
# if lookahead.selects?(:full_content)
# fetch_full_articles(object)
# else
# fetch_preview_articles(object)
# end
# end
class Lookahead
# @param query [GraphQL::Query]
# @param ast_nodes [Array<GraphQL::Language::Nodes::Field>, Array<GraphQL::Language::Nodes::OperationDefinition>]
# @param field [GraphQL::Schema::Field] if `ast_nodes` are fields, this is the field definition matching those nodes
# @param root_type [Class] if `ast_nodes` are operation definition, this is the root type for that operation
def initialize(query:, ast_nodes:, field: nil, root_type: nil, owner_type: nil)
@ast_nodes = ast_nodes.freeze
@field = field
@root_type = root_type
@query = query
@selected_type = @field ? @field.type.unwrap : root_type
@owner_type = owner_type
end
# @return [Array<GraphQL::Language::Nodes::Field>]
attr_reader :ast_nodes
# @return [GraphQL::Schema::Field]
attr_reader :field
# @return [GraphQL::Schema::Object, GraphQL::Schema::Union, GraphQL::Schema::Interface]
attr_reader :owner_type
# @return [Hash<Symbol, Object>]
def arguments
if defined?(@arguments)
@arguments
else
@arguments = if @field
@query.after_lazy(@query.arguments_for(@ast_nodes.first, @field)) do |args|
args.is_a?(Execution::Interpreter::Arguments) ? args.keyword_arguments : args
end
else
nil
end
end
end
# True if this node has a selection on `field_name`.
# If `field_name` is a String, it is treated as a GraphQL-style (camelized)
# field name and used verbatim. If `field_name` is a Symbol, it is
# treated as a Ruby-style (underscored) name and camelized before comparing.
#
# If `arguments:` is provided, each provided key/value will be matched
# against the arguments in the next selection. This method will return false
# if any of the given `arguments:` are not present and matching in the next selection.
# (But, the next selection may contain _more_ than the given arguments.)
# @param field_name [String, Symbol]
# @param arguments [Hash] Arguments which must match in the selection
# @return [Boolean]
def selects?(field_name, selected_type: @selected_type, arguments: nil)
selection(field_name, selected_type: selected_type, arguments: arguments).selected?
end
# True if this node has a selection with alias matching `alias_name`.
# If `alias_name` is a String, it is treated as a GraphQL-style (camelized)
# field name and used verbatim. If `alias_name` is a Symbol, it is
# treated as a Ruby-style (underscored) name and camelized before comparing.
#
# If `arguments:` is provided, each provided key/value will be matched
# against the arguments in the next selection. This method will return false
# if any of the given `arguments:` are not present and matching in the next selection.
# (But, the next selection may contain _more_ than the given arguments.)
# @param alias_name [String, Symbol]
# @param arguments [Hash] Arguments which must match in the selection
# @return [Boolean]
def selects_alias?(alias_name, arguments: nil)
alias_selection(alias_name, arguments: arguments).selected?
end
# @return [Boolean] True if this lookahead represents a field that was requested
def selected?
true
end
# Like {#selects?}, but can be used for chaining.
# It returns a null object (check with {#selected?})
# @param field_name [String, Symbol]
# @return [GraphQL::Execution::Lookahead]
def selection(field_name, selected_type: @selected_type, arguments: nil)
next_field_defn = case field_name
when String
@query.get_field(selected_type, field_name)
when Symbol
# Try to avoid the `.to_s` below, if possible
all_fields = if selected_type.kind.fields?
@query.warden.fields(selected_type)
else
# Handle unions by checking possible
@query.warden
.possible_types(selected_type)
.map { |t| @query.warden.fields(t) }
.tap(&:flatten!)
end
if (match_by_orig_name = all_fields.find { |f| f.original_name == field_name })
match_by_orig_name
else
# Symbol#name is only present on 3.0+
sym_s = field_name.respond_to?(:name) ? field_name.name : field_name.to_s
guessed_name = Schema::Member::BuildType.camelize(sym_s)
@query.get_field(selected_type, guessed_name)
end
end
lookahead_for_selection(next_field_defn, selected_type, arguments)
end
# Like {#selection}, but for aliases.
# It returns a null object (check with {#selected?})
# @return [GraphQL::Execution::Lookahead]
def alias_selection(alias_name, selected_type: @selected_type, arguments: nil)
alias_cache_key = [alias_name, arguments]
return alias_selections[key] if alias_selections.key?(alias_name)
alias_node = lookup_alias_node(ast_nodes, alias_name)
return NULL_LOOKAHEAD unless alias_node
next_field_defn = @query.get_field(selected_type, alias_node.name)
alias_arguments = @query.arguments_for(alias_node, next_field_defn)
if alias_arguments.is_a?(::GraphQL::Execution::Interpreter::Arguments)
alias_arguments = alias_arguments.keyword_arguments
end
return NULL_LOOKAHEAD if arguments && arguments != alias_arguments
alias_selections[alias_cache_key] = lookahead_for_selection(next_field_defn, selected_type, alias_arguments, alias_name)
end
# Like {#selection}, but for all nodes.
# It returns a list of Lookaheads for all Selections
#
# If `arguments:` is provided, each provided key/value will be matched
# against the arguments in each selection. This method will filter the selections
# if any of the given `arguments:` do not match the given selection.
#
# @example getting the name of a selection
# def articles(lookahead:)
# next_lookaheads = lookahead.selections # => [#<GraphQL::Execution::Lookahead ...>, ...]
# next_lookaheads.map(&:name) #=> [:full_content, :title]
# end
#
# @param arguments [Hash] Arguments which must match in the selection
# @return [Array<GraphQL::Execution::Lookahead>]
def selections(arguments: nil)
subselections_by_type = {}
subselections_on_type = subselections_by_type[@selected_type] = {}
@ast_nodes.each do |node|
find_selections(subselections_by_type, subselections_on_type, @selected_type, node.selections, arguments)
end
subselections = []
subselections_by_type.each do |type, ast_nodes_by_response_key|
ast_nodes_by_response_key.each do |response_key, ast_nodes|
field_defn = @query.get_field(type, ast_nodes.first.name)
lookahead = Lookahead.new(query: @query, ast_nodes: ast_nodes, field: field_defn, owner_type: type)
subselections.push(lookahead)
end
end
subselections
end
# The method name of the field.
# It returns the method_sym of the Lookahead's field.
#
# @example getting the name of a selection
# def articles(lookahead:)
# article.selection(:full_content).name # => :full_content
# # ...
# end
#
# @return [Symbol]
def name
@field && @field.original_name
end
def inspect
"#<GraphQL::Execution::Lookahead #{@field ? "@field=#{@field.path.inspect}": "@root_type=#{@root_type}"} @ast_nodes.size=#{@ast_nodes.size}>"
end
# This is returned for {Lookahead#selection} when a non-existent field is passed
class NullLookahead < Lookahead
# No inputs required here.
def initialize
end
def selected?
false
end
def selects?(*)
false
end
def selection(*)
NULL_LOOKAHEAD
end
def selections(*)
[]
end
def inspect
"#<GraphQL::Execution::Lookahead::NullLookahead>"
end
end
# A singleton, so that misses don't come with overhead.
NULL_LOOKAHEAD = NullLookahead.new
private
def skipped_by_directive?(ast_selection)
ast_selection.directives.each do |directive|
dir_defn = @query.schema.directives.fetch(directive.name)
directive_class = dir_defn
if directive_class
dir_args = @query.arguments_for(directive, dir_defn)
return true unless directive_class.static_include?(dir_args, @query.context)
end
end
false
end
def find_selections(subselections_by_type, selections_on_type, selected_type, ast_selections, arguments)
ast_selections.each do |ast_selection|
next if skipped_by_directive?(ast_selection)
case ast_selection
when GraphQL::Language::Nodes::Field
response_key = ast_selection.alias || ast_selection.name
if selections_on_type.key?(response_key)
selections_on_type[response_key] << ast_selection
elsif arguments.nil? || arguments.empty?
selections_on_type[response_key] = [ast_selection]
else
field_defn = @query.get_field(selected_type, ast_selection.name)
if arguments_match?(arguments, field_defn, ast_selection)
selections_on_type[response_key] = [ast_selection]
end
end
when GraphQL::Language::Nodes::InlineFragment
on_type = selected_type
subselections_on_type = selections_on_type
if (t = ast_selection.type)
# Assuming this is valid, that `t` will be found.
on_type = @query.get_type(t.name)
subselections_on_type = subselections_by_type[on_type] ||= {}
end
find_selections(subselections_by_type, subselections_on_type, on_type, ast_selection.selections, arguments)
when GraphQL::Language::Nodes::FragmentSpread
frag_defn = lookup_fragment(ast_selection)
# Again, assuming a valid AST
on_type = @query.get_type(frag_defn.type.name)
subselections_on_type = subselections_by_type[on_type] ||= {}
find_selections(subselections_by_type, subselections_on_type, on_type, frag_defn.selections, arguments)
else
raise "Invariant: Unexpected selection type: #{ast_selection.class}"
end
end
end
# If a selection on `node` matches `field_name` (which is backed by `field_defn`)
# and matches the `arguments:` constraints, then add that node to `matches`
def find_selected_nodes(node, field_name, field_defn, arguments:, matches:, alias_name: NOT_CONFIGURED)
return if skipped_by_directive?(node)
case node
when GraphQL::Language::Nodes::Field
if node.name == field_name && (NOT_CONFIGURED.equal?(alias_name) || node.alias == alias_name)
if arguments.nil? || arguments.empty?
# No constraint applied
matches << node
elsif arguments_match?(arguments, field_defn, node)
matches << node
end
end
when GraphQL::Language::Nodes::InlineFragment
node.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches, alias_name: alias_name) }
when GraphQL::Language::Nodes::FragmentSpread
frag_defn = lookup_fragment(node)
frag_defn.selections.each { |s| find_selected_nodes(s, field_name, field_defn, arguments: arguments, matches: matches, alias_name: alias_name) }
else
raise "Unexpected selection comparison on #{node.class.name} (#{node})"
end
end
def arguments_match?(arguments, field_defn, field_node)
query_kwargs = @query.arguments_for(field_node, field_defn)
arguments.all? do |arg_name, arg_value|
arg_name_sym = if arg_name.is_a?(String)
Schema::Member::BuildType.underscore(arg_name).to_sym
else
arg_name
end
# Make sure the constraint is present with a matching value
query_kwargs.key?(arg_name_sym) && query_kwargs[arg_name_sym] == arg_value
end
end
def lookahead_for_selection(field_defn, selected_type, arguments, alias_name = NOT_CONFIGURED)
return NULL_LOOKAHEAD unless field_defn
next_nodes = []
field_name = field_defn.name
@ast_nodes.each do |ast_node|
ast_node.selections.each do |selection|
find_selected_nodes(selection, field_name, field_defn, arguments: arguments, matches: next_nodes, alias_name: alias_name)
end
end
return NULL_LOOKAHEAD if next_nodes.empty?
Lookahead.new(query: @query, ast_nodes: next_nodes, field: field_defn, owner_type: selected_type)
end
def alias_selections
return @alias_selections if defined?(@alias_selections)
@alias_selections ||= {}
end
def lookup_alias_node(nodes, name)
return if nodes.empty?
nodes.flat_map(&:children)
.flat_map { |child| unwrap_fragments(child) }
.find { |child| child.is_a?(GraphQL::Language::Nodes::Field) && child.alias == name }
end
def unwrap_fragments(node)
case node
when GraphQL::Language::Nodes::InlineFragment
node.children
when GraphQL::Language::Nodes::FragmentSpread
lookup_fragment(node).children
else
[node]
end
end
def lookup_fragment(ast_selection)
@query.fragments[ast_selection.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{ast_selection.name} (found: #{@query.fragments.keys})")
end
end
end
end
|