graphql 2.5.11 → 2.5.19

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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/graphql/detailed_trace_generator.rb +77 -0
  3. data/lib/generators/graphql/templates/create_graphql_detailed_traces.erb +10 -0
  4. data/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/_form.html.erb +1 -0
  5. data/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/edit.html.erb +2 -2
  6. data/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/index.html.erb +1 -1
  7. data/lib/graphql/dashboard/views/graphql/dashboard/operation_store/clients/new.html.erb +1 -1
  8. data/lib/graphql/dashboard/views/graphql/dashboard/operation_store/index_entries/index.html.erb +1 -1
  9. data/lib/graphql/dashboard/views/graphql/dashboard/operation_store/operations/show.html.erb +1 -1
  10. data/lib/graphql/dashboard/views/graphql/dashboard/subscriptions/topics/show.html.erb +1 -1
  11. data/lib/graphql/dashboard/views/layouts/graphql/dashboard/application.html.erb +7 -7
  12. data/lib/graphql/dashboard.rb +5 -2
  13. data/lib/graphql/dataloader/async_dataloader.rb +22 -11
  14. data/lib/graphql/dataloader/null_dataloader.rb +44 -10
  15. data/lib/graphql/dataloader.rb +75 -23
  16. data/lib/graphql/date_encoding_error.rb +1 -1
  17. data/lib/graphql/execution/interpreter/resolve.rb +7 -13
  18. data/lib/graphql/execution/interpreter/runtime/graphql_result.rb +13 -0
  19. data/lib/graphql/execution/interpreter/runtime.rb +21 -16
  20. data/lib/graphql/execution/interpreter.rb +2 -13
  21. data/lib/graphql/language/document_from_schema_definition.rb +2 -1
  22. data/lib/graphql/language.rb +21 -12
  23. data/lib/graphql/schema/argument.rb +7 -0
  24. data/lib/graphql/schema/build_from_definition.rb +3 -1
  25. data/lib/graphql/schema/directive.rb +22 -4
  26. data/lib/graphql/schema/field.rb +6 -47
  27. data/lib/graphql/schema/member/has_arguments.rb +43 -14
  28. data/lib/graphql/schema/member/has_fields.rb +76 -4
  29. data/lib/graphql/schema/validator/required_validator.rb +33 -2
  30. data/lib/graphql/schema/visibility.rb +2 -2
  31. data/lib/graphql/schema.rb +20 -3
  32. data/lib/graphql/static_validation/rules/fields_have_appropriate_selections.rb +2 -2
  33. data/lib/graphql/subscriptions/action_cable_subscriptions.rb +1 -0
  34. data/lib/graphql/testing/helpers.rb +12 -9
  35. data/lib/graphql/testing/mock_action_cable.rb +111 -0
  36. data/lib/graphql/testing.rb +1 -0
  37. data/lib/graphql/tracing/detailed_trace/active_record_backend.rb +74 -0
  38. data/lib/graphql/tracing/detailed_trace.rb +70 -7
  39. data/lib/graphql/tracing/perfetto_trace.rb +208 -78
  40. data/lib/graphql/tracing/sentry_trace.rb +3 -1
  41. data/lib/graphql/version.rb +1 -1
  42. data/lib/graphql.rb +5 -2
  43. metadata +7 -3
@@ -1708,7 +1708,7 @@ module GraphQL
1708
1708
  # If you need to support previous, non-spec behavior which allowed selecting union fields
1709
1709
  # but *not* selecting any fields on that union, set this to `true` to continue allowing that behavior.
1710
1710
  #
1711
- # If this is `true`, then {.legacy_invalid_empty_selections_on_union} will be called with {Query} objects
1711
+ # If this is `true`, then {.legacy_invalid_empty_selections_on_union_with_type} will be called with {Query} objects
1712
1712
  # with that kind of selections. You must implement that method
1713
1713
  # @param new_value [Boolean]
1714
1714
  # @return [true, false, nil]
@@ -1724,6 +1724,22 @@ module GraphQL
1724
1724
  end
1725
1725
  end
1726
1726
 
1727
+ # This method is called during validation when a previously-allowed, but non-spec
1728
+ # query is encountered where a union field has no child selections on it.
1729
+ #
1730
+ # If `legacy_invalid_empty_selections_on_union_with_type` is overridden, this method will not be called.
1731
+ #
1732
+ # You should implement this method or `legacy_invalid_empty_selections_on_union_with_type`
1733
+ # to log the violation so that you can contact clients and notify them about changing their queries.
1734
+ # Then return a suitable value to tell GraphQL-Ruby how to continue.
1735
+ # @param query [GraphQL::Query]
1736
+ # @return [:return_validation_error] Let GraphQL-Ruby return the (new) normal validation error for this query
1737
+ # @return [String] A validation error to return for this query
1738
+ # @return [nil] Don't send the client an error, continue the legacy behavior (allow this query to execute)
1739
+ def legacy_invalid_empty_selections_on_union(query)
1740
+ raise "Implement `def self.legacy_invalid_empty_selections_on_union_with_type(query, type)` or `def self.legacy_invalid_empty_selections_on_union(query)` to handle this scenario"
1741
+ end
1742
+
1727
1743
  # This method is called during validation when a previously-allowed, but non-spec
1728
1744
  # query is encountered where a union field has no child selections on it.
1729
1745
  #
@@ -1731,11 +1747,12 @@ module GraphQL
1731
1747
  # and notify them about changing their queries. Then return a suitable value to
1732
1748
  # tell GraphQL-Ruby how to continue.
1733
1749
  # @param query [GraphQL::Query]
1750
+ # @param type [Module] A GraphQL type definition
1734
1751
  # @return [:return_validation_error] Let GraphQL-Ruby return the (new) normal validation error for this query
1735
1752
  # @return [String] A validation error to return for this query
1736
1753
  # @return [nil] Don't send the client an error, continue the legacy behavior (allow this query to execute)
1737
- def legacy_invalid_empty_selections_on_union(query)
1738
- raise "Implement `def self.legacy_invalid_empty_selections_on_union(query)` to handle this scenario"
1754
+ def legacy_invalid_empty_selections_on_union_with_type(query, type)
1755
+ legacy_invalid_empty_selections_on_union(query)
1739
1756
  end
1740
1757
 
1741
1758
  # This setting controls how GraphQL-Ruby handles overlapping selections on scalar types when the types
@@ -49,7 +49,7 @@ module GraphQL
49
49
  if !resolved_type.kind.fields?
50
50
  case @schema.allow_legacy_invalid_empty_selections_on_union
51
51
  when true
52
- legacy_invalid_empty_selection_result = @schema.legacy_invalid_empty_selections_on_union(@context.query)
52
+ legacy_invalid_empty_selection_result = @schema.legacy_invalid_empty_selections_on_union_with_type(@context.query, resolved_type)
53
53
  case legacy_invalid_empty_selection_result
54
54
  when :return_validation_error
55
55
  # keep `return_validation_error = true`
@@ -61,7 +61,7 @@ module GraphQL
61
61
  return_validation_error = false
62
62
  legacy_invalid_empty_selection_result = nil
63
63
  else
64
- raise GraphQL::InvariantError, "Unexpected return value from legacy_invalid_empty_selections_on_union, must be `:return_validation_error`, String, or nil (got: #{legacy_invalid_empty_selection_result.inspect})"
64
+ raise GraphQL::InvariantError, "Unexpected return value from legacy_invalid_empty_selections_on_union_with_type, must be `:return_validation_error`, String, or nil (got: #{legacy_invalid_empty_selection_result.inspect})"
65
65
  end
66
66
  when false
67
67
  # pass -- error below
@@ -81,6 +81,7 @@ module GraphQL
81
81
  # end
82
82
  # end
83
83
  #
84
+ # @see GraphQL::Testing::MockActionCable for test helpers
84
85
  class ActionCableSubscriptions < GraphQL::Subscriptions
85
86
  SUBSCRIPTION_PREFIX = "graphql-subscription:"
86
87
  EVENT_PREFIX = "graphql-event:"
@@ -39,9 +39,9 @@ module GraphQL
39
39
  end
40
40
  end
41
41
 
42
- def run_graphql_field(schema, field_path, object, arguments: {}, context: {}, ast_node: nil, lookahead: nil)
42
+ def run_graphql_field(schema, field_path, object, arguments: {}, context: {}, ast_node: nil, lookahead: nil, visibility_profile: nil)
43
43
  type_name, *field_names = field_path.split(".")
44
- dummy_query = GraphQL::Query.new(schema, "{ __typename }", context: context)
44
+ dummy_query = GraphQL::Query.new(schema, "{ __typename }", context: context, visibility_profile: visibility_profile)
45
45
  query_context = dummy_query.context
46
46
  dataloader = query_context.dataloader
47
47
  object_type = dummy_query.types.type(type_name) # rubocop:disable Development/ContextIsPassedCop
@@ -104,32 +104,35 @@ module GraphQL
104
104
  end
105
105
  end
106
106
 
107
- def with_resolution_context(schema, type:, object:, context:{})
107
+ def with_resolution_context(schema, type:, object:, context:{}, visibility_profile: nil)
108
108
  resolution_context = ResolutionAssertionContext.new(
109
109
  self,
110
110
  schema: schema,
111
111
  type_name: type,
112
112
  object: object,
113
- context: context
113
+ context: context,
114
+ visibility_profile: visibility_profile,
114
115
  )
115
116
  yield(resolution_context)
116
117
  end
117
118
 
118
119
  class ResolutionAssertionContext
119
- def initialize(test, type_name:, object:, schema:, context:)
120
+ def initialize(test, type_name:, object:, schema:, context:, visibility_profile:)
120
121
  @test = test
121
122
  @type_name = type_name
122
123
  @object = object
123
124
  @schema = schema
124
125
  @context = context
126
+ @visibility_profile = visibility_profile
125
127
  end
126
128
 
129
+ attr_reader :visibility_profile
127
130
 
128
131
  def run_graphql_field(field_name, arguments: {})
129
132
  if @schema
130
- @test.run_graphql_field(@schema, "#{@type_name}.#{field_name}", @object, arguments: arguments, context: @context)
133
+ @test.run_graphql_field(@schema, "#{@type_name}.#{field_name}", @object, arguments: arguments, context: @context, visibility_profile: @visibility_profile)
131
134
  else
132
- @test.run_graphql_field("#{@type_name}.#{field_name}", @object, arguments: arguments, context: @context)
135
+ @test.run_graphql_field("#{@type_name}.#{field_name}", @object, arguments: arguments, context: @context, visibility_profile: @visibility_profile)
133
136
  end
134
137
  end
135
138
  end
@@ -137,8 +140,8 @@ module GraphQL
137
140
  module SchemaHelpers
138
141
  include Helpers
139
142
 
140
- def run_graphql_field(field_path, object, arguments: {}, context: {})
141
- super(@@schema_class_for_helpers, field_path, object, arguments: arguments, context: context)
143
+ def run_graphql_field(field_path, object, arguments: {}, context: {}, visibility_profile: nil)
144
+ super(@@schema_class_for_helpers, field_path, object, arguments: arguments, context: context, visibility_profile: visibility_profile)
142
145
  end
143
146
 
144
147
  def with_resolution_context(*args, **kwargs, &block)
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+ module GraphQL
3
+ module Testing
4
+ # A stub implementation of ActionCable.
5
+ # Any methods to support the mock backend have `mock` in the name.
6
+ #
7
+ # @example Configuring your schema to use MockActionCable in the test environment
8
+ # class MySchema < GraphQL::Schema
9
+ # # Use MockActionCable in test:
10
+ # use GraphQL::Subscriptions::ActionCableSubscriptions,
11
+ # action_cable: Rails.env.test? ? GraphQL::Testing::MockActionCable : ActionCable
12
+ # end
13
+ #
14
+ # @example Clearing old data before each test
15
+ # setup do
16
+ # GraphQL::Testing::MockActionCable.clear_mocks
17
+ # end
18
+ #
19
+ # @example Using MockActionCable in a test case
20
+ # # Create a channel to use in the test, pass it to GraphQL
21
+ # mock_channel = GraphQL::Testing::MockActionCable.get_mock_channel
22
+ # ActionCableTestSchema.execute("subscription { newsFlash { text } }", context: { channel: mock_channel })
23
+ #
24
+ # # Trigger a subscription update
25
+ # ActionCableTestSchema.subscriptions.trigger(:news_flash, {}, {text: "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"})
26
+ #
27
+ # # Check messages on the channel
28
+ # expected_msg = {
29
+ # result: {
30
+ # "data" => {
31
+ # "newsFlash" => {
32
+ # "text" => "After yesterday's rain, someone stopped on Rio Road to help a box turtle across five lanes of traffic"
33
+ # }
34
+ # }
35
+ # },
36
+ # more: true,
37
+ # }
38
+ # assert_equal [expected_msg], mock_channel.mock_broadcasted_messages
39
+ #
40
+ class MockActionCable
41
+ class MockChannel
42
+ def initialize
43
+ @mock_broadcasted_messages = []
44
+ end
45
+
46
+ # @return [Array<Hash>] Payloads "sent" to this channel by GraphQL-Ruby
47
+ attr_reader :mock_broadcasted_messages
48
+
49
+ # Called by ActionCableSubscriptions. Implements a Rails API.
50
+ def stream_from(stream_name, coder: nil, &block)
51
+ # Rails uses `coder`, we don't
52
+ block ||= ->(msg) { @mock_broadcasted_messages << msg }
53
+ MockActionCable.mock_stream_for(stream_name).add_mock_channel(self, block)
54
+ end
55
+ end
56
+
57
+ # Used by mock code
58
+ # @api private
59
+ class MockStream
60
+ def initialize
61
+ @mock_channels = {}
62
+ end
63
+
64
+ def add_mock_channel(channel, handler)
65
+ @mock_channels[channel] = handler
66
+ end
67
+
68
+ def mock_broadcast(message)
69
+ @mock_channels.each do |channel, handler|
70
+ handler && handler.call(message)
71
+ end
72
+ end
73
+ end
74
+
75
+ class << self
76
+ # Call this before each test run to make sure that MockActionCable's data is empty
77
+ def clear_mocks
78
+ @mock_streams = {}
79
+ end
80
+
81
+ # Implements Rails API
82
+ def server
83
+ self
84
+ end
85
+
86
+ # Implements Rails API
87
+ def broadcast(stream_name, message)
88
+ stream = @mock_streams[stream_name]
89
+ stream && stream.mock_broadcast(message)
90
+ end
91
+
92
+ # Used by mock code
93
+ def mock_stream_for(stream_name)
94
+ @mock_streams[stream_name] ||= MockStream.new
95
+ end
96
+
97
+ # Use this as `context[:channel]` to simulate an ActionCable channel
98
+ #
99
+ # @return [GraphQL::Testing::MockActionCable::MockChannel]
100
+ def get_mock_channel
101
+ MockChannel.new
102
+ end
103
+
104
+ # @return [Array<String>] Streams that currently have subscribers
105
+ def mock_stream_names
106
+ @mock_streams.keys
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -1,2 +1,3 @@
1
1
  # frozen_string_literal: true
2
2
  require "graphql/testing/helpers"
3
+ require "graphql/testing/mock_action_cable"
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Tracing
5
+ class DetailedTrace
6
+ class ActiveRecordBackend
7
+ class GraphqlDetailedTrace < ActiveRecord::Base
8
+ end
9
+
10
+ def initialize(limit: nil, model_class: nil)
11
+ @limit = limit
12
+ @model_class = model_class || GraphqlDetailedTrace
13
+ end
14
+
15
+ def traces(last:, before:)
16
+ gdts = @model_class.all.order("begin_ms DESC")
17
+ if before
18
+ gdts = gdts.where("begin_ms < ?", before)
19
+ end
20
+ if last
21
+ gdts = gdts.limit(last)
22
+ end
23
+ gdts.map { |gdt| record_to_stored_trace(gdt) }
24
+ end
25
+
26
+ def delete_trace(id)
27
+ @model_class.where(id: id).destroy_all
28
+ nil
29
+ end
30
+
31
+ def delete_all_traces
32
+ @model_class.all.destroy_all
33
+ end
34
+
35
+ def find_trace(id)
36
+ gdt = @model_class.find_by(id: id)
37
+ if gdt
38
+ record_to_stored_trace(gdt)
39
+ else
40
+ nil
41
+ end
42
+ end
43
+
44
+ def save_trace(operation_name, duration_ms, begin_ms, trace_data)
45
+ gdt = @model_class.create!(
46
+ begin_ms: begin_ms,
47
+ operation_name: operation_name,
48
+ duration_ms: duration_ms,
49
+ trace_data: trace_data,
50
+ )
51
+ if @limit
52
+ @model_class
53
+ .where("id NOT IN(SELECT id FROM graphql_detailed_traces ORDER BY begin_ms DESC LIMIT ?)", @limit)
54
+ .delete_all
55
+ end
56
+ gdt.id
57
+ end
58
+
59
+ private
60
+
61
+ def record_to_stored_trace(gdt)
62
+ StoredTrace.new(
63
+ id: gdt.id,
64
+ begin_ms: gdt.begin_ms,
65
+ operation_name: gdt.operation_name,
66
+ duration_ms: gdt.duration_ms,
67
+ trace_data: gdt.trace_data
68
+ )
69
+
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,18 +1,34 @@
1
1
  # frozen_string_literal: true
2
+ if defined?(ActiveRecord)
3
+ require "graphql/tracing/detailed_trace/active_record_backend"
4
+ end
2
5
  require "graphql/tracing/detailed_trace/memory_backend"
3
6
  require "graphql/tracing/detailed_trace/redis_backend"
4
7
 
5
8
  module GraphQL
6
9
  module Tracing
7
- # `DetailedTrace` can make detailed profiles for a subset of production traffic.
10
+ # `DetailedTrace` can make detailed profiles for a subset of production traffic. Install it in Rails with `rails generate graphql:detailed_trace`.
8
11
  #
9
12
  # When `MySchema.detailed_trace?(query)` returns `true`, a profiler-specific `trace_mode: ...` will be used for the query,
10
13
  # overriding the one in `context[:trace_mode]`.
11
14
  #
12
- # __Redis__: The sampler stores its results in a provided Redis database. Depending on your needs,
13
- # You can configure this database to retail all data (persistent) or to expire data according to your rules.
15
+ # By default, the detailed tracer calls `.inspect` on application objects returned from fields. You can customize
16
+ # this behavior by extending {DetailedTrace} and overriding {#inspect_object}. You can opt out of debug annotations
17
+ # entirely with `use ..., debug: false` or for a single query with `context: { detailed_trace_debug: false }`.
18
+ #
19
+ # You can store saved traces in two ways:
20
+ #
21
+ # - __ActiveRecord__: With `rails generate graphql:detailed_trace`, a new migration will be added to your app.
22
+ # That table will be used to store trace data.
23
+ #
24
+ # - __Redis__: Pass `redis: ...` to save trace data to a Redis database. Depending on your needs,
25
+ # you can configure this database to retain all data (persistent) or to expire data according to your rules.
26
+ #
14
27
  # If you need to save traces indefinitely, you can download them from Perfetto after opening them there.
15
28
  #
29
+ # @example Installing with Rails
30
+ # rails generate graphql:detailed_trace # optional: --redis
31
+ #
16
32
  # @example Adding the sampler to your schema
17
33
  # class MySchema < GraphQL::Schema
18
34
  # # Add the sampler:
@@ -27,24 +43,48 @@ module GraphQL
27
43
  # end
28
44
  #
29
45
  # @see Graphql::Dashboard GraphQL::Dashboard for viewing stored results
46
+ #
47
+ # @example Customizing debug output in traces
48
+ # class CustomDetailedTrace < GraphQL::Tracing::DetailedTrace
49
+ # def inspect_object(object)
50
+ # if object.is_a?(SomeThing)
51
+ # # handle it specially ...
52
+ # else
53
+ # super
54
+ # end
55
+ # end
56
+ # end
57
+ #
58
+ # @example disabling debug annotations completely
59
+ # use DetailedTrace, debug: false, ...
60
+ #
61
+ # @example disabling debug annotations for one query
62
+ # MySchema.execute(query_str, context: { detailed_trace_debug: false })
63
+ #
30
64
  class DetailedTrace
31
65
  # @param redis [Redis] If provided, profiles will be stored in Redis for later review
32
66
  # @param limit [Integer] A maximum number of profiles to store
33
- def self.use(schema, trace_mode: :profile_sample, memory: false, redis: nil, limit: nil)
67
+ # @param debug [Boolean] if `false`, it won't create `debug` annotations in Perfetto traces (reduces overhead)
68
+ # @param model_class [Class<ActiveRecord::Base>] Overrides {ActiveRecordBackend::GraphqlDetailedTrace} if present
69
+ def self.use(schema, trace_mode: :profile_sample, memory: false, debug: debug?, redis: nil, limit: nil, model_class: nil)
34
70
  storage = if redis
35
71
  RedisBackend.new(redis: redis, limit: limit)
36
72
  elsif memory
37
73
  MemoryBackend.new(limit: limit)
74
+ elsif defined?(ActiveRecord)
75
+ ActiveRecordBackend.new(limit: limit, model_class: model_class)
38
76
  else
39
- raise ArgumentError, "Pass `redis: ...` to store traces in Redis for later review"
77
+ raise ArgumentError, "To store traces, install ActiveRecord or provide `redis: ...`"
40
78
  end
41
- schema.detailed_trace = self.new(storage: storage, trace_mode: trace_mode)
79
+ detailed_trace = self.new(storage: storage, trace_mode: trace_mode, debug: debug)
80
+ schema.detailed_trace = detailed_trace
42
81
  schema.trace_with(PerfettoTrace, mode: trace_mode, save_profile: true)
43
82
  end
44
83
 
45
- def initialize(storage:, trace_mode:)
84
+ def initialize(storage:, trace_mode:, debug:)
46
85
  @storage = storage
47
86
  @trace_mode = trace_mode
87
+ @debug = debug
48
88
  end
49
89
 
50
90
  # @return [Symbol] The trace mode to use when {Schema.detailed_trace?} returns `true`
@@ -55,6 +95,11 @@ module GraphQL
55
95
  @storage.save_trace(operation_name, duration_ms, begin_ms, trace_data)
56
96
  end
57
97
 
98
+ # @return [Boolean]
99
+ def debug?
100
+ @debug
101
+ end
102
+
58
103
  # @param last [Integer]
59
104
  # @param before [Integer] Timestamp in milliseconds since epoch
60
105
  # @return [Enumerable<StoredTrace>]
@@ -77,6 +122,24 @@ module GraphQL
77
122
  @storage.delete_all_traces
78
123
  end
79
124
 
125
+ def inspect_object(object)
126
+ self.class.inspect_object(object)
127
+ end
128
+
129
+ def self.inspect_object(object)
130
+ if defined?(ActiveRecord::Relation) && object.is_a?(ActiveRecord::Relation)
131
+ "#{object.class}, .to_sql=#{object.to_sql.inspect}"
132
+ else
133
+ object.inspect
134
+ end
135
+ end
136
+
137
+ # Default debug setting
138
+ # @return [true]
139
+ def self.debug?
140
+ true
141
+ end
142
+
80
143
  class StoredTrace
81
144
  def initialize(id:, operation_name:, duration_ms:, begin_ms:, trace_data:)
82
145
  @id = id