graphql 1.10.8 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/graphql/object_generator.rb +50 -8
  3. data/lib/graphql.rb +4 -4
  4. data/lib/graphql/execution/instrumentation.rb +1 -1
  5. data/lib/graphql/execution/interpreter.rb +1 -1
  6. data/lib/graphql/execution/interpreter/arguments.rb +2 -5
  7. data/lib/graphql/execution/interpreter/runtime.rb +18 -23
  8. data/lib/graphql/execution/multiplex.rb +1 -2
  9. data/lib/graphql/introspection/schema_type.rb +3 -3
  10. data/lib/graphql/object_type.rb +1 -1
  11. data/lib/graphql/pagination/connection.rb +13 -6
  12. data/lib/graphql/pagination/connections.rb +5 -4
  13. data/lib/graphql/query.rb +1 -2
  14. data/lib/graphql/schema.rb +26 -3
  15. data/lib/graphql/schema/argument.rb +6 -0
  16. data/lib/graphql/schema/build_from_definition.rb +7 -12
  17. data/lib/graphql/schema/enum.rb +9 -1
  18. data/lib/graphql/schema/field.rb +60 -79
  19. data/lib/graphql/schema/field/connection_extension.rb +2 -1
  20. data/lib/graphql/schema/input_object.rb +1 -3
  21. data/lib/graphql/schema/interface.rb +5 -0
  22. data/lib/graphql/schema/list.rb +2 -1
  23. data/lib/graphql/schema/loader.rb +3 -0
  24. data/lib/graphql/schema/member.rb +1 -0
  25. data/lib/graphql/schema/member/has_arguments.rb +6 -0
  26. data/lib/graphql/schema/member/has_fields.rb +1 -1
  27. data/lib/graphql/schema/member/has_unresolved_type_error.rb +15 -0
  28. data/lib/graphql/schema/object.rb +7 -0
  29. data/lib/graphql/schema/resolver.rb +14 -0
  30. data/lib/graphql/schema/subscription.rb +1 -1
  31. data/lib/graphql/schema/union.rb +6 -0
  32. data/lib/graphql/schema/warden.rb +7 -2
  33. data/lib/graphql/subscriptions.rb +41 -8
  34. data/lib/graphql/subscriptions/action_cable_subscriptions.rb +49 -4
  35. data/lib/graphql/subscriptions/broadcast_analyzer.rb +84 -0
  36. data/lib/graphql/subscriptions/default_subscription_resolve_extension.rb +21 -0
  37. data/lib/graphql/subscriptions/event.rb +16 -1
  38. data/lib/graphql/subscriptions/subscription_root.rb +13 -3
  39. data/lib/graphql/tracing.rb +0 -27
  40. data/lib/graphql/tracing/new_relic_tracing.rb +1 -12
  41. data/lib/graphql/tracing/platform_tracing.rb +14 -0
  42. data/lib/graphql/tracing/scout_tracing.rb +11 -0
  43. data/lib/graphql/types/iso_8601_date.rb +2 -2
  44. data/lib/graphql/types/iso_8601_date_time.rb +19 -15
  45. data/lib/graphql/version.rb +1 -1
  46. 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
@@ -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)
@@ -166,7 +165,13 @@ module GraphQL
166
165
  end
167
166
 
168
167
  def visible_field?(owner_type, field_defn)
169
- visible?(field_defn) && visible_type?(field_defn.type.unwrap) && field_on_visible_interface?(field_defn, owner_type)
168
+ # This field is visible in its own right
169
+ visible?(field_defn) &&
170
+ # This field's return type is visible
171
+ visible_type?(field_defn.type.unwrap) &&
172
+ # This field is either defined on this object type,
173
+ # or the interface it's inherited from is also visible
174
+ ((field_defn.respond_to?(:owner) && field_defn.owner == owner_type) || field_on_visible_interface?(field_defn, owner_type))
170
175
  end
171
176
 
172
177
  # We need this to tell whether a field was inherited by an interface
@@ -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,6 +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
12
  #
12
13
  # @example Adding ActionCableSubscriptions to your schema
13
14
  # class MySchema < GraphQL::Schema
@@ -89,6 +90,7 @@ module GraphQL
89
90
  # A per-process map of subscriptions to deliver.
90
91
  # This is provided by Rails, so let's use it
91
92
  @subscriptions = Concurrent::Map.new
93
+ @events = Concurrent::Map.new { |h, k| h[k] = Concurrent::Map.new { |h2, k2| h2[k2] = Concurrent::Array.new } }
92
94
  @serializer = serializer
93
95
  super
94
96
  end
@@ -119,10 +121,44 @@ module GraphQL
119
121
  channel.stream_from(stream)
120
122
  @subscriptions[subscription_id] = query
121
123
  events.each do |event|
122
- channel.stream_from(EVENT_PREFIX + event.topic, coder: ActiveSupport::JSON) do |message|
123
- execute(subscription_id, event, @serializer.load(message))
124
- 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
125
160
  end
161
+ nil
126
162
  end
127
163
  end
128
164
 
@@ -139,7 +175,16 @@ module GraphQL
139
175
 
140
176
  # The channel was closed, forget about it.
141
177
  def delete_subscription(subscription_id)
142
- @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
143
188
  end
144
189
  end
145
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
@@ -16,8 +16,6 @@ end
16
16
  module GraphQL
17
17
  # Library entry point for performance metric reporting.
18
18
  #
19
- # __Warning:__ Installing/uninstalling tracers is not thread-safe. Do it during application boot only.
20
- #
21
19
  # @example Sending custom events
22
20
  # query.trace("my_custom_event", { ... }) do
23
21
  # # do stuff ...
@@ -86,31 +84,6 @@ module GraphQL
86
84
  end
87
85
  end
88
86
 
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
87
  module NullTracer
115
88
  module_function
116
89
  def trace(k, v)