graphql 1.10.12 → 1.11.2

Sign up to get free protection for your applications and to get access to all the features.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/graphql/templates/graphql_controller.erb +11 -9
  3. data/lib/graphql.rb +3 -3
  4. data/lib/graphql/directive.rb +4 -0
  5. data/lib/graphql/execution/interpreter.rb +1 -1
  6. data/lib/graphql/execution/interpreter/runtime.rb +6 -4
  7. data/lib/graphql/execution/multiplex.rb +1 -2
  8. data/lib/graphql/field.rb +4 -0
  9. data/lib/graphql/input_object_type.rb +4 -0
  10. data/lib/graphql/introspection/schema_type.rb +3 -3
  11. data/lib/graphql/invalid_null_error.rb +18 -0
  12. data/lib/graphql/language/nodes.rb +1 -0
  13. data/lib/graphql/language/visitor.rb +2 -2
  14. data/lib/graphql/pagination/connection.rb +18 -13
  15. data/lib/graphql/pagination/connections.rb +17 -4
  16. data/lib/graphql/query.rb +1 -2
  17. data/lib/graphql/schema.rb +22 -16
  18. data/lib/graphql/schema/argument.rb +5 -0
  19. data/lib/graphql/schema/build_from_definition.rb +7 -12
  20. data/lib/graphql/schema/enum_value.rb +1 -0
  21. data/lib/graphql/schema/field.rb +63 -77
  22. data/lib/graphql/schema/field/connection_extension.rb +5 -1
  23. data/lib/graphql/schema/input_object.rb +16 -15
  24. data/lib/graphql/schema/loader.rb +19 -1
  25. data/lib/graphql/schema/member/has_arguments.rb +16 -0
  26. data/lib/graphql/schema/object.rb +1 -1
  27. data/lib/graphql/schema/resolver.rb +14 -0
  28. data/lib/graphql/schema/subscription.rb +1 -1
  29. data/lib/graphql/schema/union.rb +29 -0
  30. data/lib/graphql/schema/warden.rb +6 -1
  31. data/lib/graphql/static_validation/literal_validator.rb +7 -7
  32. data/lib/graphql/static_validation/rules/arguments_are_defined.rb +1 -1
  33. data/lib/graphql/static_validation/rules/required_arguments_are_present.rb +2 -2
  34. data/lib/graphql/static_validation/rules/required_input_object_attributes_are_present.rb +1 -2
  35. data/lib/graphql/static_validation/rules/variables_are_used_and_defined.rb +4 -2
  36. data/lib/graphql/subscriptions.rb +41 -8
  37. data/lib/graphql/subscriptions/action_cable_subscriptions.rb +66 -11
  38. data/lib/graphql/subscriptions/broadcast_analyzer.rb +84 -0
  39. data/lib/graphql/subscriptions/default_subscription_resolve_extension.rb +21 -0
  40. data/lib/graphql/subscriptions/event.rb +16 -1
  41. data/lib/graphql/subscriptions/serialize.rb +22 -4
  42. data/lib/graphql/subscriptions/subscription_root.rb +3 -1
  43. data/lib/graphql/tracing.rb +1 -27
  44. data/lib/graphql/tracing/platform_tracing.rb +25 -15
  45. data/lib/graphql/tracing/statsd_tracing.rb +42 -0
  46. data/lib/graphql/version.rb +1 -1
  47. metadata +5 -2
@@ -93,11 +93,11 @@ module GraphQL
93
93
  raise UnsubscribedError
94
94
  end
95
95
 
96
+ READING_SCOPE = ::Object.new
96
97
  # Call this method to provide a new subscription_scope; OR
97
98
  # call it without an argument to get the subscription_scope
98
99
  # @param new_scope [Symbol]
99
100
  # @return [Symbol]
100
- READING_SCOPE = ::Object.new
101
101
  def self.subscription_scope(new_scope = READING_SCOPE)
102
102
  if new_scope != READING_SCOPE
103
103
  @subscription_scope = new_scope
@@ -14,6 +14,7 @@ module GraphQL
14
14
  def possible_types(*types, context: GraphQL::Query::NullContext, **options)
15
15
  if types.any?
16
16
  types.each do |t|
17
+ assert_valid_union_member(t)
17
18
  type_memberships << type_membership_class.new(self, t, **options)
18
19
  end
19
20
  else
@@ -55,6 +56,34 @@ module GraphQL
55
56
  def type_memberships
56
57
  @type_memberships ||= []
57
58
  end
59
+
60
+ # Update a type membership whose `.object_type` is a string or late-bound type
61
+ # so that the type membership's `.object_type` is the given `object_type`.
62
+ # (This is used for updating the union after the schema as lazily loaded the union member.)
63
+ # @api private
64
+ def assign_type_membership_object_type(object_type)
65
+ assert_valid_union_member(object_type)
66
+ type_memberships.each { |tm|
67
+ possible_type = tm.object_type
68
+ if possible_type.is_a?(String) && (possible_type == object_type.name)
69
+ # This is a match of Ruby class names, not graphql names,
70
+ # since strings are used to refer to constants.
71
+ tm.object_type = object_type
72
+ elsif possible_type.is_a?(LateBoundType) && possible_type.graphql_name == object_type.graphql_name
73
+ tm.object_type = object_type
74
+ end
75
+ }
76
+ nil
77
+ end
78
+
79
+ private
80
+
81
+ def assert_valid_union_member(type_defn)
82
+ if type_defn.is_a?(Module) && !type_defn.is_a?(Class)
83
+ # it's an interface type, defined as a module
84
+ raise ArgumentError, "Union possible_types can only be object types (not interface types), remove #{type_defn.graphql_name} (#{type_defn.inspect})"
85
+ end
86
+ end
58
87
  end
59
88
  end
60
89
  end
@@ -91,7 +91,6 @@ module GraphQL
91
91
 
92
92
  # @return [GraphQL::Field, nil] The field named `field_name` on `parent_type`, if it exists
93
93
  def get_field(parent_type, field_name)
94
-
95
94
  @visible_parent_fields ||= read_through do |type|
96
95
  read_through do |f_name|
97
96
  field_defn = @schema.get_field(type, f_name)
@@ -106,6 +105,12 @@ module GraphQL
106
105
  @visible_parent_fields[parent_type][field_name]
107
106
  end
108
107
 
108
+ # @return [GraphQL::Argument, nil] The argument named `argument_name` on `parent_type`, if it exists and is visible
109
+ def get_argument(parent_type, argument_name)
110
+ argument = parent_type.get_argument(argument_name)
111
+ return argument if argument && visible_argument?(argument)
112
+ end
113
+
109
114
  # @return [Array<GraphQL::BaseType>] The types which may be member of `type_defn`
110
115
  def possible_types(type_defn)
111
116
  @visible_possible_types ||= read_through { |type_defn|
@@ -95,16 +95,17 @@ module GraphQL
95
95
  def required_input_fields_are_present(type, ast_node)
96
96
  # TODO - would be nice to use these to create an error message so the caller knows
97
97
  # that required fields are missing
98
- required_field_names = @warden.arguments(type)
99
- .select { |f| f.type.kind.non_null? }
98
+ required_field_names = type.arguments.each_value
99
+ .select { |argument| argument.type.kind.non_null? && @warden.get_argument(type, argument.name) }
100
100
  .map(&:name)
101
+
101
102
  present_field_names = ast_node.arguments.map(&:name)
102
103
  missing_required_field_names = required_field_names - present_field_names
103
104
  if @context.schema.error_bubbling
104
105
  missing_required_field_names.empty? ? @valid_response : @invalid_response
105
106
  else
106
107
  results = missing_required_field_names.map do |name|
107
- arg_type = @warden.arguments(type).find { |f| f.name == name }.type
108
+ arg_type = @warden.get_argument(type, name).type
108
109
  recursively_validate(GraphQL::Language::Nodes::NullValue.new(name: name), arg_type)
109
110
  end
110
111
  merge_results(results)
@@ -112,13 +113,12 @@ module GraphQL
112
113
  end
113
114
 
114
115
  def present_input_field_values_are_valid(type, ast_node)
115
- field_map = @warden.arguments(type).reduce({}) { |m, f| m[f.name] = f; m}
116
116
  results = ast_node.arguments.map do |value|
117
- field = field_map[value.name]
117
+ field = @warden.get_argument(type, value.name)
118
118
  # we want to call validate on an argument even if it's an invalid one
119
119
  # so that our raise exception is on it instead of the entire InputObject
120
- type = field && field.type
121
- recursively_validate(value.value, type)
120
+ field_type = field && field.type
121
+ recursively_validate(value.value, field_type)
122
122
  end
123
123
  merge_results(results)
124
124
  end
@@ -5,7 +5,7 @@ module GraphQL
5
5
  def on_argument(node, parent)
6
6
  parent_defn = parent_definition(parent)
7
7
 
8
- if parent_defn && context.warden.arguments(parent_defn).any? { |arg| arg.name == node.name }
8
+ if parent_defn && context.warden.get_argument(parent_defn, node.name)
9
9
  super
10
10
  elsif parent_defn
11
11
  kind_of_node = node_type(parent)
@@ -17,8 +17,8 @@ module GraphQL
17
17
 
18
18
  def assert_required_args(ast_node, defn)
19
19
  present_argument_names = ast_node.arguments.map(&:name)
20
- required_argument_names = context.warden.arguments(defn)
21
- .select { |a| a.type.kind.non_null? && !a.default_value? }
20
+ required_argument_names = defn.arguments.each_value
21
+ .select { |a| a.type.kind.non_null? && !a.default_value? && context.warden.get_argument(defn, a.name) }
22
22
  .map(&:name)
23
23
 
24
24
  missing_names = required_argument_names - present_argument_names
@@ -26,8 +26,7 @@ module GraphQL
26
26
  context.field_definition
27
27
  end
28
28
 
29
- parent_type = context.warden.arguments(defn)
30
- .find{|f| f.name == parent_name(parent, defn) }
29
+ parent_type = context.warden.get_argument(defn, parent_name(parent, defn))
31
30
  parent_type ? parent_type.type.unwrap : nil
32
31
  end
33
32
 
@@ -126,8 +126,9 @@ module GraphQL
126
126
  node_variables
127
127
  .select { |name, usage| usage.declared? && !usage.used? }
128
128
  .each { |var_name, usage|
129
+ declared_by_error_name = usage.declared_by.name || "anonymous #{usage.declared_by.operation_type}"
129
130
  add_error(GraphQL::StaticValidation::VariablesAreUsedAndDefinedError.new(
130
- "Variable $#{var_name} is declared by #{usage.declared_by.name} but not used",
131
+ "Variable $#{var_name} is declared by #{declared_by_error_name} but not used",
131
132
  nodes: usage.declared_by,
132
133
  path: usage.path,
133
134
  name: var_name,
@@ -139,8 +140,9 @@ module GraphQL
139
140
  node_variables
140
141
  .select { |name, usage| usage.used? && !usage.declared? }
141
142
  .each { |var_name, usage|
143
+ used_by_error_name = usage.used_by.name || "anonymous #{usage.used_by.operation_type}"
142
144
  add_error(GraphQL::StaticValidation::VariablesAreUsedAndDefinedError.new(
143
- "Variable $#{var_name} is used by #{usage.used_by.name} but not declared",
145
+ "Variable $#{var_name} is used by #{used_by_error_name} but not declared",
144
146
  nodes: usage.ast_node,
145
147
  path: usage.path,
146
148
  name: var_name,
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
  require "securerandom"
3
+ require "graphql/subscriptions/broadcast_analyzer"
3
4
  require "graphql/subscriptions/event"
4
5
  require "graphql/subscriptions/instrumentation"
5
6
  require "graphql/subscriptions/serialize"
@@ -7,6 +8,7 @@ if defined?(ActionCable)
7
8
  require "graphql/subscriptions/action_cable_subscriptions"
8
9
  end
9
10
  require "graphql/subscriptions/subscription_root"
11
+ require "graphql/subscriptions/default_subscription_resolve_extension"
10
12
 
11
13
  module GraphQL
12
14
  class Subscriptions
@@ -29,14 +31,25 @@ module GraphQL
29
31
  defn.instrument(:field, instrumentation)
30
32
  options[:schema] = schema
31
33
  schema.subscriptions = self.new(**options)
34
+ schema.add_subscription_extension_if_necessary
32
35
  nil
33
36
  end
34
37
 
35
38
  # @param schema [Class] the GraphQL schema this manager belongs to
36
- def initialize(schema:, **rest)
39
+ def initialize(schema:, broadcast: false, default_broadcastable: false, **rest)
40
+ if broadcast
41
+ if !schema.using_ast_analysis?
42
+ raise ArgumentError, "`broadcast: true` requires AST analysis, add `using GraphQL::Analysis::AST` to your schema or see https://graphql-ruby.org/queries/ast_analysis.html."
43
+ end
44
+ schema.query_analyzer(Subscriptions::BroadcastAnalyzer)
45
+ end
46
+ @default_broadcastable = default_broadcastable
37
47
  @schema = schema
38
48
  end
39
49
 
50
+ # @return [Boolean] Used when fields don't have `broadcastable:` explicitly set
51
+ attr_reader :default_broadcastable
52
+
40
53
  # Fetch subscriptions matching this field + arguments pair
41
54
  # And pass them off to the queue.
42
55
  # @param event_name [String]
@@ -77,15 +90,13 @@ module GraphQL
77
90
  # `event` was triggered on `object`, and `subscription_id` was subscribed,
78
91
  # so it should be updated.
79
92
  #
80
- # Load `subscription_id`'s GraphQL data, re-evaluate the query, and deliver the result.
81
- #
82
- # This is where a queue may be inserted to push updates in the background.
93
+ # Load `subscription_id`'s GraphQL data, re-evaluate the query and return the result.
83
94
  #
84
95
  # @param subscription_id [String]
85
96
  # @param event [GraphQL::Subscriptions::Event] The event which was triggered
86
97
  # @param object [Object] The value for the subscription field
87
- # @return [void]
88
- def execute(subscription_id, event, object)
98
+ # @return [GraphQL::Query::Result]
99
+ def execute_update(subscription_id, event, object)
89
100
  # Lookup the saved data for this subscription
90
101
  query_data = read_subscription(subscription_id)
91
102
  if query_data.nil?
@@ -98,7 +109,7 @@ module GraphQL
98
109
  context = query_data.fetch(:context)
99
110
  operation_name = query_data.fetch(:operation_name)
100
111
  # Re-evaluate the saved query
101
- result = @schema.execute(
112
+ @schema.execute(
102
113
  query: query_string,
103
114
  context: context,
104
115
  subscription_topic: event.topic,
@@ -106,13 +117,25 @@ module GraphQL
106
117
  variables: variables,
107
118
  root_value: object,
108
119
  )
109
- deliver(subscription_id, result)
110
120
  rescue GraphQL::Schema::Subscription::NoUpdateError
111
121
  # This update was skipped in user code; do nothing.
122
+ nil
112
123
  rescue GraphQL::Schema::Subscription::UnsubscribedError
113
124
  # `unsubscribe` was called, clean up on our side
114
125
  # TODO also send `{more: false}` to client?
115
126
  delete_subscription(subscription_id)
127
+ nil
128
+ end
129
+
130
+ # Run the update query for this subscription and deliver it
131
+ # @see {#execute_update}
132
+ # @see {#deliver}
133
+ # @return [void]
134
+ def execute(subscription_id, event, object)
135
+ res = execute_update(subscription_id, event, object)
136
+ if !res.nil?
137
+ deliver(subscription_id, res)
138
+ end
116
139
  end
117
140
 
118
141
  # Event `event` occurred on `object`,
@@ -185,6 +208,16 @@ module GraphQL
185
208
  Schema::Member::BuildType.camelize(event_or_arg_name.to_s)
186
209
  end
187
210
 
211
+ # @return [Boolean] if true, then a query like this one would be broadcasted
212
+ def broadcastable?(query_str, **query_options)
213
+ query = GraphQL::Query.new(@schema, query_str, **query_options)
214
+ if !query.valid?
215
+ raise "Invalid query: #{query.validation_errors.map(&:to_h).inspect}"
216
+ end
217
+ GraphQL::Analysis::AST.analyze_query(query, @schema.query_analyzers)
218
+ query.context.namespace(:subscriptions)[:subscription_broadcastable]
219
+ end
220
+
188
221
  private
189
222
 
190
223
  # Recursively normalize `args` as belonging to `arg_owner`:
@@ -8,7 +8,7 @@ module GraphQL
8
8
  #
9
9
  # - No queueing system; ActiveJob should be added
10
10
  # - Take care to reload context when re-delivering the subscription. (see {Query#subscription_update?})
11
- # - Avoid the async ActionCable adapter and use the redis or PostgreSQL adapters instead. Otherwise calling #trigger won't work from background jobs or the Rails console.
11
+ # - Avoid the async ActionCable adapter and use the redis or PostgreSQL adapters instead. Otherwise calling #trigger won't work from background jobs or the Rails console.
12
12
  #
13
13
  # @example Adding ActionCableSubscriptions to your schema
14
14
  # class MySchema < GraphQL::Schema
@@ -90,6 +90,7 @@ module GraphQL
90
90
  # A per-process map of subscriptions to deliver.
91
91
  # This is provided by Rails, so let's use it
92
92
  @subscriptions = Concurrent::Map.new
93
+ @events = Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new { |h2, k2| h2[k2] = Concurrent::Array.new } }
93
94
  @serializer = serializer
94
95
  super
95
96
  end
@@ -120,27 +121,81 @@ module GraphQL
120
121
  channel.stream_from(stream)
121
122
  @subscriptions[subscription_id] = query
122
123
  events.each do |event|
123
- channel.stream_from(EVENT_PREFIX + event.topic, coder: ActiveSupport::JSON) do |message|
124
- execute(subscription_id, event, @serializer.load(message))
125
- nil
124
+ # Setup a new listener to run all events with this topic in this process
125
+ setup_stream(channel, event)
126
+ # Add this event to the list of events to be updated
127
+ @events[event.topic][event.fingerprint] << event
128
+ end
129
+ end
130
+
131
+ # Every subscribing channel is listening here, but only one of them takes any action.
132
+ # This is so we can reuse payloads when possible, and make one payload to send to
133
+ # all subscribers.
134
+ #
135
+ # But the problem is, any channel could close at any time, so each channel has to
136
+ # be ready to take over the primary position.
137
+ #
138
+ # To make sure there's always one-and-only-one channel building payloads,
139
+ # let the listener belonging to the first event on the list be
140
+ # the one to build and publish payloads.
141
+ #
142
+ def setup_stream(channel, initial_event)
143
+ topic = initial_event.topic
144
+ channel.stream_from(EVENT_PREFIX + topic, coder: ActiveSupport::JSON) do |message|
145
+ object = @serializer.load(message)
146
+ events_by_fingerprint = @events[topic]
147
+ events_by_fingerprint.each do |_fingerprint, events|
148
+ if events.any? && events.first == initial_event
149
+ # The fingerprint has told us that this response should be shared by all subscribers,
150
+ # so just run it once, then deliver the result to every subscriber
151
+ first_event = events.first
152
+ first_subscription_id = first_event.context.fetch(:subscription_id)
153
+ result = execute_update(first_subscription_id, first_event, object)
154
+ # Having calculated the result _once_, send the same payload to all subscribers
155
+ events.each do |event|
156
+ subscription_id = event.context.fetch(:subscription_id)
157
+ deliver(subscription_id, result)
158
+ end
159
+ end
126
160
  end
161
+ nil
127
162
  end
128
163
  end
129
164
 
130
165
  # Return the query from "storage" (in memory)
131
166
  def read_subscription(subscription_id)
132
167
  query = @subscriptions[subscription_id]
133
- {
134
- query_string: query.query_string,
135
- variables: query.provided_variables,
136
- context: query.context.to_h,
137
- operation_name: query.operation_name,
138
- }
168
+ if query.nil?
169
+ # This can happen when a subscription is triggered from an unsubscribed channel,
170
+ # see https://github.com/rmosolgo/graphql-ruby/issues/2478.
171
+ # (This `nil` is handled by `#execute_update`)
172
+ nil
173
+ else
174
+ {
175
+ query_string: query.query_string,
176
+ variables: query.provided_variables,
177
+ context: query.context.to_h,
178
+ operation_name: query.operation_name,
179
+ }
180
+ end
139
181
  end
140
182
 
141
183
  # The channel was closed, forget about it.
142
184
  def delete_subscription(subscription_id)
143
- @subscriptions.delete(subscription_id)
185
+ query = @subscriptions.delete(subscription_id)
186
+ # This can be `nil` when `.trigger` happens inside an unsubscribed ActionCable channel,
187
+ # see https://github.com/rmosolgo/graphql-ruby/issues/2478
188
+ if query
189
+ events = query.context.namespace(:subscriptions)[:events]
190
+ events.each do |event|
191
+ ev_by_fingerprint = @events[event.topic]
192
+ ev_for_fingerprint = ev_by_fingerprint[event.fingerprint]
193
+ ev_for_fingerprint.delete(event)
194
+ if ev_for_fingerprint.empty?
195
+ ev_by_fingerprint.delete(event.fingerprint)
196
+ end
197
+ end
198
+ end
144
199
  end
145
200
  end
146
201
  end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ class Subscriptions
5
+ # Detect whether the current operation:
6
+ # - Is a subscription operation
7
+ # - Is completely broadcastable
8
+ #
9
+ # Assign the result to `context.namespace(:subscriptions)[:subscription_broadcastable]`
10
+ # @api private
11
+ # @see Subscriptions#broadcastable? for a public API
12
+ class BroadcastAnalyzer < GraphQL::Analysis::AST::Analyzer
13
+ def initialize(subject)
14
+ super
15
+ @default_broadcastable = subject.schema.subscriptions.default_broadcastable
16
+ # Maybe this will get set to false while analyzing
17
+ @subscription_broadcastable = true
18
+ end
19
+
20
+ # Only analyze subscription operations
21
+ def analyze?
22
+ @query.subscription?
23
+ end
24
+
25
+ def on_enter_field(node, parent, visitor)
26
+ if (@subscription_broadcastable == false) || visitor.skipping?
27
+ return
28
+ end
29
+
30
+ current_field = visitor.field_definition
31
+ apply_broadcastable(current_field)
32
+
33
+ current_type = visitor.parent_type_definition
34
+ if current_type.kind.interface?
35
+ pt = @query.possible_types(current_type)
36
+ pt.each do |object_type|
37
+ ot_field = @query.get_field(object_type, current_field.graphql_name)
38
+ if !ot_field
39
+ binding.pry
40
+ end
41
+ # Inherited fields would be exactly the same object;
42
+ # only check fields that are overrides of the inherited one
43
+ if ot_field && ot_field != current_field
44
+ apply_broadcastable(ot_field)
45
+ end
46
+ end
47
+ end
48
+ end
49
+
50
+ # Assign the result to context.
51
+ # (This method is allowed to return an error, but we don't need to)
52
+ # @return [void]
53
+ def result
54
+ query.context.namespace(:subscriptions)[:subscription_broadcastable] = @subscription_broadcastable
55
+ nil
56
+ end
57
+
58
+ private
59
+
60
+ # Modify `@subscription_broadcastable` based on `field_defn`'s configuration (and/or the default value)
61
+ def apply_broadcastable(field_defn)
62
+ current_field_broadcastable = field_defn.introspection? || field_defn.broadcastable?
63
+ case current_field_broadcastable
64
+ when nil
65
+ # If the value wasn't set, mix in the default value:
66
+ # - If the default is false and the current value is true, make it false
67
+ # - If the default is true and the current value is true, it stays true
68
+ # - If the default is false and the current value is false, keep it false
69
+ # - If the default is true and the current value is false, keep it false
70
+ @subscription_broadcastable = @subscription_broadcastable && @default_broadcastable
71
+ when false
72
+ # One non-broadcastable field is enough to make the whole subscription non-broadcastable
73
+ @subscription_broadcastable = false
74
+ when true
75
+ # Leave `@broadcastable_query` true if it's already true,
76
+ # but don't _set_ it to true if it was set to false by something else.
77
+ # Actually, just leave it!
78
+ else
79
+ raise ArgumentError, "Unexpected `.broadcastable?` value for #{field_defn.path}: #{current_field_broadcastable}"
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end