graphql 1.10.14 → 1.11.4

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 (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