graphql 1.10.10 → 1.11.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/graphql/object_generator.rb +50 -8
  3. data/lib/graphql.rb +3 -3
  4. data/lib/graphql/execution/interpreter.rb +1 -1
  5. data/lib/graphql/execution/interpreter/arguments.rb +1 -5
  6. data/lib/graphql/execution/interpreter/runtime.rb +20 -24
  7. data/lib/graphql/execution/multiplex.rb +1 -2
  8. data/lib/graphql/introspection/schema_type.rb +3 -3
  9. data/lib/graphql/invalid_null_error.rb +18 -0
  10. data/lib/graphql/pagination/connection.rb +13 -6
  11. data/lib/graphql/pagination/connections.rb +5 -4
  12. data/lib/graphql/query.rb +1 -2
  13. data/lib/graphql/schema.rb +26 -3
  14. data/lib/graphql/schema/argument.rb +6 -0
  15. data/lib/graphql/schema/build_from_definition.rb +7 -12
  16. data/lib/graphql/schema/enum.rb +9 -1
  17. data/lib/graphql/schema/field.rb +68 -80
  18. data/lib/graphql/schema/field/connection_extension.rb +2 -1
  19. data/lib/graphql/schema/input_object.rb +1 -3
  20. data/lib/graphql/schema/interface.rb +5 -0
  21. data/lib/graphql/schema/member.rb +1 -0
  22. data/lib/graphql/schema/member/has_arguments.rb +6 -0
  23. data/lib/graphql/schema/member/has_fields.rb +1 -1
  24. data/lib/graphql/schema/member/has_unresolved_type_error.rb +15 -0
  25. data/lib/graphql/schema/object.rb +7 -0
  26. data/lib/graphql/schema/resolver.rb +14 -0
  27. data/lib/graphql/schema/subscription.rb +1 -1
  28. data/lib/graphql/schema/union.rb +6 -0
  29. data/lib/graphql/schema/warden.rb +0 -1
  30. data/lib/graphql/subscriptions.rb +41 -8
  31. data/lib/graphql/subscriptions/action_cable_subscriptions.rb +49 -5
  32. data/lib/graphql/subscriptions/broadcast_analyzer.rb +84 -0
  33. data/lib/graphql/subscriptions/default_subscription_resolve_extension.rb +21 -0
  34. data/lib/graphql/subscriptions/event.rb +16 -1
  35. data/lib/graphql/subscriptions/subscription_root.rb +13 -3
  36. data/lib/graphql/tracing.rb +1 -27
  37. data/lib/graphql/tracing/new_relic_tracing.rb +1 -12
  38. data/lib/graphql/tracing/platform_tracing.rb +39 -15
  39. data/lib/graphql/tracing/scout_tracing.rb +11 -0
  40. data/lib/graphql/tracing/statsd_tracing.rb +42 -0
  41. data/lib/graphql/types/iso_8601_date.rb +1 -1
  42. data/lib/graphql/types/iso_8601_date_time.rb +17 -13
  43. data/lib/graphql/version.rb +1 -1
  44. metadata +6 -2
@@ -3,8 +3,14 @@ module GraphQL
3
3
  class Schema
4
4
  class Union < GraphQL::Schema::Member
5
5
  extend GraphQL::Schema::Member::AcceptsDefinition
6
+ extend GraphQL::Schema::Member::HasUnresolvedTypeError
6
7
 
7
8
  class << self
9
+ def inherited(child_class)
10
+ add_unresolved_type_error(child_class)
11
+ super
12
+ end
13
+
8
14
  def possible_types(*types, context: GraphQL::Query::NullContext, **options)
9
15
  if types.any?
10
16
  types.each do |t|
@@ -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)
@@ -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,10 +121,44 @@ 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
 
@@ -140,7 +175,16 @@ module GraphQL
140
175
 
141
176
  # The channel was closed, forget about it.
142
177
  def delete_subscription(subscription_id)
143
- @subscriptions.delete(subscription_id)
178
+ query = @subscriptions.delete(subscription_id)
179
+ events = query.context.namespace(:subscriptions)[:events]
180
+ events.each do |event|
181
+ ev_by_fingerprint = @events[event.topic]
182
+ ev_for_fingerprint = ev_by_fingerprint[event.fingerprint]
183
+ ev_for_fingerprint.delete(event)
184
+ if ev_for_fingerprint.empty?
185
+ ev_by_fingerprint.delete(event.fingerprint)
186
+ end
187
+ end
144
188
  end
145
189
  end
146
190
  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
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+ module GraphQL
3
+ class Subscriptions
4
+ class DefaultSubscriptionResolveExtension < GraphQL::Subscriptions::SubscriptionRoot::Extension
5
+ def resolve(context:, object:, arguments:)
6
+ has_override_implementation = @field.resolver ||
7
+ object.respond_to?(@field.resolver_method)
8
+
9
+ if !has_override_implementation
10
+ if context.query.subscription_update?
11
+ object.object
12
+ else
13
+ context.skip
14
+ end
15
+ else
16
+ yield(object, arguments)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -6,7 +6,6 @@ module GraphQL
6
6
  # - Subscribed to by `subscription { ... }`
7
7
  # - Triggered by `MySchema.subscriber.trigger(name, arguments, obj)`
8
8
  #
9
- # An array of `Event`s are passed to `store.register(query, events)`.
10
9
  class Event
11
10
  # @return [String] Corresponds to the Subscription root field name
12
11
  attr_reader :name
@@ -53,6 +52,22 @@ module GraphQL
53
52
  Serialize.dump_recursive([scope, name, sorted_h])
54
53
  end
55
54
 
55
+ # @return [String] a logical identifier for this event. (Stable when the query is broadcastable.)
56
+ def fingerprint
57
+ @fingerprint ||= begin
58
+ # When this query has been flagged as broadcastable,
59
+ # use a generalized, stable fingerprint so that
60
+ # duplicate subscriptions can be evaluated and distributed in bulk.
61
+ # (`@topic` includes field, args, and subscription scope already.)
62
+ if @context.namespace(:subscriptions)[:subscription_broadcastable]
63
+ "#{@topic}/#{@context.query.fingerprint}"
64
+ else
65
+ # not broadcastable, build a unique ID for this event
66
+ @context.schema.subscriptions.build_id
67
+ end
68
+ end
69
+ end
70
+
56
71
  class << self
57
72
  private
58
73
  def stringify_args(arg_owner, args)
@@ -2,9 +2,11 @@
2
2
 
3
3
  module GraphQL
4
4
  class Subscriptions
5
- # Extend this module in your subscription root when using {GraphQL::Execution::Interpreter}.
5
+ # @api private
6
+ # @deprecated This module is no longer needed.
6
7
  module SubscriptionRoot
7
8
  def self.extended(child_cls)
9
+ warn "`extend GraphQL::Subscriptions::SubscriptionRoot` is no longer required; you can remove it from your Subscription type (#{child_cls})"
8
10
  child_cls.include(InstanceMethods)
9
11
  end
10
12
 
@@ -40,7 +42,7 @@ module GraphQL
40
42
  # for the backend to register:
41
43
  event = Subscriptions::Event.new(
42
44
  name: field.name,
43
- arguments: arguments,
45
+ arguments: arguments_without_field_extras(arguments: arguments),
44
46
  context: context,
45
47
  field: field,
46
48
  )
@@ -48,7 +50,7 @@ module GraphQL
48
50
  value
49
51
  elsif context.query.subscription_topic == Subscriptions::Event.serialize(
50
52
  field.name,
51
- arguments,
53
+ arguments_without_field_extras(arguments: arguments),
52
54
  field,
53
55
  scope: (field.subscription_scope ? context[field.subscription_scope] : nil),
54
56
  )
@@ -60,6 +62,14 @@ module GraphQL
60
62
  context.skip
61
63
  end
62
64
  end
65
+
66
+ private
67
+
68
+ def arguments_without_field_extras(arguments:)
69
+ arguments.dup.tap do |event_args|
70
+ field.extras.each { |k| event_args.delete(k) }
71
+ end
72
+ end
63
73
  end
64
74
  end
65
75
  end
@@ -7,6 +7,7 @@ require "graphql/tracing/data_dog_tracing"
7
7
  require "graphql/tracing/new_relic_tracing"
8
8
  require "graphql/tracing/scout_tracing"
9
9
  require "graphql/tracing/skylight_tracing"
10
+ require "graphql/tracing/statsd_tracing"
10
11
  require "graphql/tracing/prometheus_tracing"
11
12
 
12
13
  if defined?(PrometheusExporter::Server)
@@ -16,8 +17,6 @@ end
16
17
  module GraphQL
17
18
  # Library entry point for performance metric reporting.
18
19
  #
19
- # __Warning:__ Installing/uninstalling tracers is not thread-safe. Do it during application boot only.
20
- #
21
20
  # @example Sending custom events
22
21
  # query.trace("my_custom_event", { ... }) do
23
22
  # # do stuff ...
@@ -86,31 +85,6 @@ module GraphQL
86
85
  end
87
86
  end
88
87
 
89
- class << self
90
- # Install a tracer to receive events.
91
- # @param tracer [<#trace(key, metadata)>]
92
- # @return [void]
93
- # @deprecated See {Schema#tracer} or use `context: { tracers: [...] }`
94
- def install(tracer)
95
- warn("GraphQL::Tracing.install is deprecated, add it to the schema with `tracer(my_tracer)` instead.")
96
- if !tracers.include?(tracer)
97
- @tracers << tracer
98
- end
99
- end
100
-
101
- # @deprecated See {Schema#tracer} or use `context: { tracers: [...] }`
102
- def uninstall(tracer)
103
- @tracers.delete(tracer)
104
- end
105
-
106
- # @deprecated See {Schema#tracer} or use `context: { tracers: [...] }`
107
- def tracers
108
- @tracers ||= []
109
- end
110
- end
111
- # Initialize the array
112
- tracers
113
-
114
88
  module NullTracer
115
89
  module_function
116
90
  def trace(k, v)