graphql 1.10.14 → 1.11.4

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/graphql/core.rb +8 -0
  3. data/lib/generators/graphql/templates/base_argument.erb +2 -0
  4. data/lib/generators/graphql/templates/base_enum.erb +2 -0
  5. data/lib/generators/graphql/templates/base_field.erb +2 -0
  6. data/lib/generators/graphql/templates/base_input_object.erb +2 -0
  7. data/lib/generators/graphql/templates/base_interface.erb +2 -0
  8. data/lib/generators/graphql/templates/base_mutation.erb +2 -0
  9. data/lib/generators/graphql/templates/base_object.erb +2 -0
  10. data/lib/generators/graphql/templates/base_scalar.erb +2 -0
  11. data/lib/generators/graphql/templates/base_union.erb +2 -0
  12. data/lib/generators/graphql/templates/enum.erb +2 -0
  13. data/lib/generators/graphql/templates/graphql_controller.erb +13 -9
  14. data/lib/generators/graphql/templates/interface.erb +2 -0
  15. data/lib/generators/graphql/templates/loader.erb +2 -0
  16. data/lib/generators/graphql/templates/mutation.erb +2 -0
  17. data/lib/generators/graphql/templates/mutation_type.erb +2 -0
  18. data/lib/generators/graphql/templates/object.erb +2 -0
  19. data/lib/generators/graphql/templates/query_type.erb +2 -0
  20. data/lib/generators/graphql/templates/scalar.erb +2 -0
  21. data/lib/generators/graphql/templates/schema.erb +2 -0
  22. data/lib/generators/graphql/templates/union.erb +2 -0
  23. data/lib/graphql.rb +3 -3
  24. data/lib/graphql/execution/interpreter.rb +1 -1
  25. data/lib/graphql/execution/interpreter/runtime.rb +26 -26
  26. data/lib/graphql/execution/multiplex.rb +1 -2
  27. data/lib/graphql/introspection/schema_type.rb +3 -3
  28. data/lib/graphql/invalid_null_error.rb +18 -0
  29. data/lib/graphql/language/nodes.rb +1 -0
  30. data/lib/graphql/language/visitor.rb +2 -2
  31. data/lib/graphql/pagination/connection.rb +18 -13
  32. data/lib/graphql/pagination/connections.rb +17 -4
  33. data/lib/graphql/query.rb +1 -2
  34. data/lib/graphql/query/context.rb +20 -1
  35. data/lib/graphql/query/fingerprint.rb +2 -0
  36. data/lib/graphql/query/validation_pipeline.rb +3 -0
  37. data/lib/graphql/schema.rb +26 -16
  38. data/lib/graphql/schema/build_from_definition.rb +7 -12
  39. data/lib/graphql/schema/build_from_definition/resolve_map.rb +3 -1
  40. data/lib/graphql/schema/enum_value.rb +1 -0
  41. data/lib/graphql/schema/field.rb +63 -77
  42. data/lib/graphql/schema/field/connection_extension.rb +42 -32
  43. data/lib/graphql/schema/loader.rb +19 -1
  44. data/lib/graphql/schema/member/has_fields.rb +15 -5
  45. data/lib/graphql/schema/mutation.rb +4 -0
  46. data/lib/graphql/schema/object.rb +1 -1
  47. data/lib/graphql/schema/resolver.rb +20 -0
  48. data/lib/graphql/schema/resolver/has_payload_type.rb +2 -1
  49. data/lib/graphql/schema/subscription.rb +3 -13
  50. data/lib/graphql/schema/union.rb +29 -0
  51. data/lib/graphql/schema/warden.rb +2 -4
  52. data/lib/graphql/static_validation/rules/variables_are_used_and_defined.rb +4 -2
  53. data/lib/graphql/subscriptions.rb +69 -24
  54. data/lib/graphql/subscriptions/action_cable_subscriptions.rb +66 -11
  55. data/lib/graphql/subscriptions/broadcast_analyzer.rb +84 -0
  56. data/lib/graphql/subscriptions/default_subscription_resolve_extension.rb +21 -0
  57. data/lib/graphql/subscriptions/event.rb +16 -1
  58. data/lib/graphql/subscriptions/serialize.rb +22 -4
  59. data/lib/graphql/subscriptions/subscription_root.rb +3 -1
  60. data/lib/graphql/tracing.rb +1 -27
  61. data/lib/graphql/tracing/appoptics_tracing.rb +10 -2
  62. data/lib/graphql/tracing/platform_tracing.rb +25 -15
  63. data/lib/graphql/tracing/statsd_tracing.rb +42 -0
  64. data/lib/graphql/types/iso_8601_date_time.rb +2 -1
  65. data/lib/graphql/types/relay/base_connection.rb +6 -5
  66. data/lib/graphql/version.rb +1 -1
  67. metadata +5 -2
@@ -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
@@ -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)
@@ -9,6 +9,9 @@ module GraphQL
9
9
  GLOBALID_KEY = "__gid__"
10
10
  SYMBOL_KEY = "__sym__"
11
11
  SYMBOL_KEYS_KEY = "__sym_keys__"
12
+ TIMESTAMP_KEY = "__timestamp__"
13
+ TIMESTAMP_FORMAT = "%Y-%m-%d %H:%M:%S.%N%Z" # eg '2020-01-01 23:59:59.123456789+05:00'
14
+ OPEN_STRUCT_KEY = "__ostruct__"
12
15
 
13
16
  module_function
14
17
 
@@ -55,10 +58,20 @@ module GraphQL
55
58
  if value.is_a?(Array)
56
59
  value.map{|item| load_value(item)}
57
60
  elsif value.is_a?(Hash)
58
- if value.size == 1 && value.key?(GLOBALID_KEY)
59
- GlobalID::Locator.locate(value[GLOBALID_KEY])
60
- elsif value.size == 1 && value.key?(SYMBOL_KEY)
61
- value[SYMBOL_KEY].to_sym
61
+ if value.size == 1
62
+ case value.keys.first # there's only 1 key
63
+ when GLOBALID_KEY
64
+ GlobalID::Locator.locate(value[GLOBALID_KEY])
65
+ when SYMBOL_KEY
66
+ value[SYMBOL_KEY].to_sym
67
+ when TIMESTAMP_KEY
68
+ timestamp_class_name, timestamp_s = value[TIMESTAMP_KEY]
69
+ timestamp_class = Object.const_get(timestamp_class_name)
70
+ timestamp_class.strptime(timestamp_s, TIMESTAMP_FORMAT)
71
+ when OPEN_STRUCT_KEY
72
+ ostruct_values = load_value(value[OPEN_STRUCT_KEY])
73
+ OpenStruct.new(ostruct_values)
74
+ end
62
75
  else
63
76
  loaded_h = {}
64
77
  sym_keys = value.fetch(SYMBOL_KEYS_KEY, [])
@@ -101,6 +114,11 @@ module GraphQL
101
114
  { SYMBOL_KEY => obj.to_s }
102
115
  elsif obj.respond_to?(:to_gid_param)
103
116
  {GLOBALID_KEY => obj.to_gid_param}
117
+ elsif obj.is_a?(Date) || obj.is_a?(Time)
118
+ # DateTime extends Date; for TimeWithZone, call `.utc` first.
119
+ { TIMESTAMP_KEY => [obj.class.name, obj.strftime(TIMESTAMP_FORMAT)] }
120
+ elsif obj.is_a?(OpenStruct)
121
+ { OPEN_STRUCT_KEY => dump_value(obj.to_h) }
104
122
  else
105
123
  obj
106
124
  end
@@ -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
 
@@ -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)
@@ -55,7 +55,15 @@ module GraphQL
55
55
  end
56
56
 
57
57
  def platform_field_key(type, field)
58
- "graphql.#{type.name}.#{field.name}"
58
+ "graphql.#{type.graphql_name}.#{field.graphql_name}"
59
+ end
60
+
61
+ def platform_authorized_key(type)
62
+ "graphql.authorized.#{type.graphql_name}"
63
+ end
64
+
65
+ def platform_resolve_type_key(type)
66
+ "graphql.resolve_type.#{type.graphql_name}"
59
67
  end
60
68
 
61
69
  private
@@ -107,7 +115,7 @@ module GraphQL
107
115
  else
108
116
  [key, data[key]]
109
117
  end
110
- end.flatten.each_slice(2).to_h.merge(Spec: 'graphql')
118
+ end.flatten(2).each_slice(2).to_h.merge(Spec: 'graphql')
111
119
  end
112
120
  # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
113
121
 
@@ -32,17 +32,19 @@ module GraphQL
32
32
  trace_field = true # implemented with instrumenter
33
33
  else
34
34
  field = data[:field]
35
- cache = platform_key_cache(data.fetch(:query).context)
36
- platform_key = cache.fetch(field) do
37
- cache[field] = platform_field_key(data[:owner], field)
38
- end
39
-
40
35
  return_type = field.type.unwrap
41
36
  trace_field = if return_type.kind.scalar? || return_type.kind.enum?
42
37
  (field.trace.nil? && @trace_scalars) || field.trace
43
38
  else
44
39
  true
45
40
  end
41
+
42
+ platform_key = if trace_field
43
+ context = data.fetch(:query).context
44
+ cached_platform_key(context, field) { platform_field_key(data[:owner], field) }
45
+ else
46
+ nil
47
+ end
46
48
  end
47
49
 
48
50
  if platform_key && trace_field
@@ -53,20 +55,16 @@ module GraphQL
53
55
  yield
54
56
  end
55
57
  when "authorized", "authorized_lazy"
56
- cache = platform_key_cache(data.fetch(:context))
57
58
  type = data.fetch(:type)
58
- platform_key = cache.fetch(type) do
59
- cache[type] = platform_authorized_key(type)
60
- end
59
+ context = data.fetch(:context)
60
+ platform_key = cached_platform_key(context, type) { platform_authorized_key(type) }
61
61
  platform_trace(platform_key, key, data) do
62
62
  yield
63
63
  end
64
64
  when "resolve_type", "resolve_type_lazy"
65
- cache = platform_key_cache(data.fetch(:context))
66
65
  type = data.fetch(:type)
67
- platform_key = cache.fetch(type) do
68
- cache[type] = platform_resolve_type_key(type)
69
- end
66
+ context = data.fetch(:context)
67
+ platform_key = cached_platform_key(context, type) { platform_resolve_type_key(type) }
70
68
  platform_trace(platform_key, key, data) do
71
69
  yield
72
70
  end
@@ -119,8 +117,20 @@ module GraphQL
119
117
 
120
118
  attr_reader :options
121
119
 
122
- def platform_key_cache(ctx)
123
- ctx.namespace(self.class)[:platform_key_cache] ||= {}
120
+ # Different kind of schema objects have different kinds of keys:
121
+ #
122
+ # - Object types: `.authorized`
123
+ # - Union/Interface types: `.resolve_type`
124
+ # - Fields: execution
125
+ #
126
+ # So, they can all share one cache.
127
+ #
128
+ # If the key isn't present, the given block is called and the result is cached for `key`.
129
+ #
130
+ # @return [String]
131
+ def cached_platform_key(ctx, key)
132
+ cache = ctx.namespace(self.class)[:platform_key_cache] ||= {}
133
+ cache.fetch(key) { cache[key] = yield }
124
134
  end
125
135
  end
126
136
  end