graphql 1.6.8 → 1.7.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/graphql.rb +5 -0
  3. data/lib/graphql/analysis/analyze_query.rb +21 -17
  4. data/lib/graphql/argument.rb +6 -2
  5. data/lib/graphql/backtrace.rb +50 -0
  6. data/lib/graphql/backtrace/inspect_result.rb +51 -0
  7. data/lib/graphql/backtrace/table.rb +120 -0
  8. data/lib/graphql/backtrace/traced_error.rb +55 -0
  9. data/lib/graphql/backtrace/tracer.rb +50 -0
  10. data/lib/graphql/enum_type.rb +1 -10
  11. data/lib/graphql/execution.rb +1 -2
  12. data/lib/graphql/execution/execute.rb +98 -89
  13. data/lib/graphql/execution/flatten.rb +40 -0
  14. data/lib/graphql/execution/lazy/resolve.rb +7 -7
  15. data/lib/graphql/execution/multiplex.rb +29 -29
  16. data/lib/graphql/field.rb +5 -1
  17. data/lib/graphql/internal_representation/node.rb +16 -0
  18. data/lib/graphql/invalid_name_error.rb +11 -0
  19. data/lib/graphql/language/parser.rb +11 -5
  20. data/lib/graphql/language/parser.y +11 -5
  21. data/lib/graphql/name_validator.rb +16 -0
  22. data/lib/graphql/object_type.rb +5 -0
  23. data/lib/graphql/query.rb +28 -7
  24. data/lib/graphql/query/context.rb +155 -52
  25. data/lib/graphql/query/literal_input.rb +36 -9
  26. data/lib/graphql/query/null_context.rb +7 -1
  27. data/lib/graphql/query/result.rb +63 -0
  28. data/lib/graphql/query/serial_execution/field_resolution.rb +3 -4
  29. data/lib/graphql/query/serial_execution/value_resolution.rb +3 -4
  30. data/lib/graphql/query/variables.rb +1 -1
  31. data/lib/graphql/schema.rb +31 -0
  32. data/lib/graphql/schema/traversal.rb +16 -1
  33. data/lib/graphql/schema/warden.rb +40 -4
  34. data/lib/graphql/static_validation/validator.rb +20 -18
  35. data/lib/graphql/subscriptions.rb +129 -0
  36. data/lib/graphql/subscriptions/action_cable_subscriptions.rb +122 -0
  37. data/lib/graphql/subscriptions/event.rb +52 -0
  38. data/lib/graphql/subscriptions/instrumentation.rb +68 -0
  39. data/lib/graphql/tracing.rb +80 -0
  40. data/lib/graphql/tracing/active_support_notifications_tracing.rb +31 -0
  41. data/lib/graphql/version.rb +1 -1
  42. data/readme.md +1 -1
  43. data/spec/graphql/analysis/analyze_query_spec.rb +19 -0
  44. data/spec/graphql/argument_spec.rb +28 -0
  45. data/spec/graphql/backtrace_spec.rb +144 -0
  46. data/spec/graphql/define/assign_argument_spec.rb +12 -0
  47. data/spec/graphql/enum_type_spec.rb +1 -1
  48. data/spec/graphql/execution/execute_spec.rb +66 -0
  49. data/spec/graphql/execution/lazy_spec.rb +4 -3
  50. data/spec/graphql/language/parser_spec.rb +16 -0
  51. data/spec/graphql/object_type_spec.rb +14 -0
  52. data/spec/graphql/query/context_spec.rb +134 -27
  53. data/spec/graphql/query/result_spec.rb +29 -0
  54. data/spec/graphql/query/variables_spec.rb +13 -0
  55. data/spec/graphql/query_spec.rb +22 -0
  56. data/spec/graphql/schema/build_from_definition_spec.rb +2 -0
  57. data/spec/graphql/schema/traversal_spec.rb +70 -12
  58. data/spec/graphql/schema/warden_spec.rb +67 -1
  59. data/spec/graphql/schema_spec.rb +29 -0
  60. data/spec/graphql/static_validation/validator_spec.rb +16 -0
  61. data/spec/graphql/subscriptions_spec.rb +331 -0
  62. data/spec/graphql/tracing/active_support_notifications_tracing_spec.rb +57 -0
  63. data/spec/graphql/tracing_spec.rb +47 -0
  64. data/spec/spec_helper.rb +32 -0
  65. data/spec/support/star_wars/schema.rb +39 -0
  66. metadata +27 -4
  67. data/lib/graphql/execution/field_result.rb +0 -54
  68. data/lib/graphql/execution/selection_result.rb +0 -90
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+ require "securerandom"
3
+
4
+ module GraphQL
5
+ class Subscriptions
6
+ # A subscriptions implementation that sends data
7
+ # as ActionCable broadcastings.
8
+ #
9
+ # Experimental, some things to keep in mind:
10
+ #
11
+ # - No queueing system; ActiveJob should be added
12
+ # - Take care to reload context when re-delivering the subscription. (see {Query#subscription_update?})
13
+ #
14
+ # @example Adding ActionCableSubscriptions to your schema
15
+ # MySchema = GraphQL::Schema.define do
16
+ # # ...
17
+ # use GraphQL::Subscriptions::ActionCableSubscriptions
18
+ # end
19
+ #
20
+ # @example Implementing a channel for GraphQL Subscriptions
21
+ # class GraphqlChannel < ApplicationCable::Channel
22
+ # def subscribed
23
+ # @subscription_ids = []
24
+ # end
25
+ #
26
+ # def execute(data)
27
+ # query = data["query"]
28
+ # variables = ensure_hash(data["variables"])
29
+ # operation_name = data["operationName"]
30
+ # context = {
31
+ # current_user: current_user,
32
+ # # Make sure the channel is in the context
33
+ # channel: self,
34
+ # }
35
+ #
36
+ # result = MySchema.execute({
37
+ # query: query,
38
+ # context: context,
39
+ # variables: variables,
40
+ # operation_name: operation_name
41
+ # })
42
+ #
43
+ # payload = {
44
+ # result: result.to_h,
45
+ # more: result.subscription?,
46
+ # }
47
+ #
48
+ # # Track the subscription here so we can remove it
49
+ # # on unsubscribe.
50
+ # if result.context[:subscription_id]
51
+ # @subscription_ids << context[:subscription_id]
52
+ # end
53
+ #
54
+ # transmit(payload)
55
+ # end
56
+ #
57
+ # def unsubscribed
58
+ # @subscription_ids.each { |sid|
59
+ # CardsSchema.subscriptions.delete_subscription(sid)
60
+ # }
61
+ # end
62
+ # end
63
+ #
64
+ class ActionCableSubscriptions < GraphQL::Subscriptions
65
+ SUBSCRIPTION_PREFIX = "graphql-subscription:"
66
+ EVENT_PREFIX = "graphql-event:"
67
+ def initialize(**rest)
68
+ # A per-process map of subscriptions to deliver.
69
+ # This is provided by Rails, so let's use it
70
+ @subscriptions = Concurrent::Map.new
71
+ super
72
+ end
73
+
74
+ # An event was triggered; Push the data over ActionCable.
75
+ # Subscribers will re-evaluate locally.
76
+ # TODO: this method name is a smell
77
+ def execute_all(event, object)
78
+ ActionCable.server.broadcast(EVENT_PREFIX + event.topic, object)
79
+ end
80
+
81
+ # This subscription was re-evaluated.
82
+ # Send it to the specific stream where this client was waiting.
83
+ def deliver(subscription_id, result)
84
+ payload = { result: result.to_h, more: true }
85
+ ActionCable.server.broadcast(SUBSCRIPTION_PREFIX + subscription_id, payload)
86
+ end
87
+
88
+ # A query was run where these events were subscribed to.
89
+ # Store them in memory in _this_ ActionCable frontend.
90
+ # It will receive notifications when events come in
91
+ # and re-evaluate the query locally.
92
+ def write_subscription(query, events)
93
+ channel = query.context[:channel]
94
+ subscription_id = query.context[:subscription_id] ||= SecureRandom.uuid
95
+ channel.stream_from(SUBSCRIPTION_PREFIX + subscription_id)
96
+ @subscriptions[subscription_id] = query
97
+ events.each do |event|
98
+ channel.stream_from(EVENT_PREFIX + event.topic, coder: ActiveSupport::JSON) do |message|
99
+ execute(subscription_id, event, message)
100
+ nil
101
+ end
102
+ end
103
+ end
104
+
105
+ # Return the query from "storage" (in memory)
106
+ def read_subscription(subscription_id)
107
+ query = @subscriptions[subscription_id]
108
+ {
109
+ query_string: query.query_string,
110
+ variables: query.provided_variables,
111
+ context: query.context.to_h,
112
+ operation_name: query.operation_name,
113
+ }
114
+ end
115
+
116
+ # The channel was closed, forget about it.
117
+ def delete_subscription(subscription_id)
118
+ @subscriptions.delete(subscription_id)
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+ module GraphQL
3
+ class Subscriptions
4
+ # This thing can be:
5
+ # - Subscribed to by `subscription { ... }`
6
+ # - Triggered by `MySchema.subscriber.trigger(name, arguments, obj)`
7
+ #
8
+ # An array of `Event`s are passed to `store.register(query, events)`.
9
+ class Event
10
+ # @return [String] Corresponds to the Subscription root field name
11
+ attr_reader :name
12
+
13
+ # @return [GraphQL::Query::Arguments]
14
+ attr_reader :arguments
15
+
16
+ # @return [GraphQL::Query::Context]
17
+ attr_reader :context
18
+
19
+ # @return [String] An opaque string which identifies this event, derived from `name` and `arguments`
20
+ attr_reader :topic
21
+
22
+ def initialize(name:, arguments:, field: nil, context: nil, scope: nil)
23
+ @name = name
24
+ @arguments = arguments
25
+ @context = context
26
+ field ||= context.field
27
+ scope_val = scope || (context && field.subscription_scope && context[field.subscription_scope])
28
+
29
+ @topic = self.class.serialize(name, arguments, field, scope: scope_val)
30
+ end
31
+
32
+ # @return [String] an identifier for this unit of subscription
33
+ def self.serialize(name, arguments, field, scope:)
34
+ normalized_args = case arguments
35
+ when GraphQL::Query::Arguments
36
+ arguments
37
+ when Hash
38
+ GraphQL::Query::LiteralInput.from_arguments(
39
+ arguments,
40
+ field.arguments,
41
+ nil,
42
+ )
43
+ else
44
+ raise ArgumentError, "Unexpected arguments: #{arguments}, must be Hash or GraphQL::Arguments"
45
+ end
46
+
47
+ sorted_h = normalized_args.to_h.sort.to_h
48
+ JSON.dump([scope, name, sorted_h])
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+ # test_via: ../subscriptions.rb
3
+ module GraphQL
4
+ class Subscriptions
5
+ # Wrap the root fields of the subscription type with special logic for:
6
+ # - Registering the subscription during the first execution
7
+ # - Evaluating the triggered portion(s) of the subscription during later execution
8
+ class Instrumentation
9
+ def initialize(schema:)
10
+ @schema = schema
11
+ end
12
+
13
+ def instrument(type, field)
14
+ if type == @schema.subscription
15
+ # This is a root field of `subscription`
16
+ subscribing_resolve_proc = SubscriptionRegistrationResolve.new(field.resolve_proc)
17
+ field.redefine(resolve: subscribing_resolve_proc)
18
+ else
19
+ field
20
+ end
21
+ end
22
+
23
+ # If needed, prepare to gather events which this query subscribes to
24
+ def before_query(query)
25
+ if query.subscription? && !query.subscription_update?
26
+ query.context.namespace(:subscriptions)[:events] = []
27
+ end
28
+ end
29
+
30
+ # After checking the root fields, pass the gathered events to the store
31
+ def after_query(query)
32
+ events = query.context.namespace(:subscriptions)[:events]
33
+ if events && events.any?
34
+ @schema.subscriptions.write_subscription(query, events)
35
+ end
36
+ end
37
+
38
+ private
39
+
40
+ class SubscriptionRegistrationResolve
41
+ def initialize(inner_proc)
42
+ @inner_proc = inner_proc
43
+ end
44
+
45
+ # Wrap the proc with subscription registration logic
46
+ def call(obj, args, ctx)
47
+ events = ctx.namespace(:subscriptions)[:events]
48
+ if events
49
+ # This is the first execution, so gather an Event
50
+ # for the backend to register:
51
+ events << Subscriptions::Event.new(
52
+ name: ctx.field.name,
53
+ arguments: args,
54
+ context: ctx,
55
+ )
56
+ ctx.skip
57
+ elsif ctx.irep_node.subscription_topic == ctx.query.subscription_topic
58
+ # The root object is _already_ the subscription update:
59
+ @inner_proc.call(obj, args, ctx)
60
+ else
61
+ # This is a subscription update, but this event wasn't triggered.
62
+ ctx.skip
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+ require "graphql/tracing/active_support_notifications_tracing"
3
+ module GraphQL
4
+ # Library entry point for performance metric reporting.
5
+ #
6
+ # {ActiveSupportNotificationsTracing} is imported by default
7
+ # when `ActiveSupport::Notifications` is found.
8
+ #
9
+ # You can remove it with `GraphQL::Tracing.uninstall(GraphQL::Tracing::ActiveSupportNotificationsTracing)`.
10
+ #
11
+ # __Warning:__ Installing/uninstalling tracers is not thread-safe. Do it during application boot only.
12
+ #
13
+ # @example Sending custom events
14
+ # GraphQL::Tracing.trace("my_custom_event", { ... }) do
15
+ # # do stuff ...
16
+ # end
17
+ #
18
+ # Events:
19
+ #
20
+ # Key | Metadata
21
+ # ----|---------
22
+ # lex | `{ query_string: String }`
23
+ # parse | `{ query_string: String }`
24
+ # validate | `{ query: GraphQL::Query, validate: Boolean }`
25
+ # analyze_multiplex | `{ multiplex: GraphQL::Execution::Multiplex }`
26
+ # analyze_query | `{ query: GraphQL::Query }`
27
+ # execute_multiplex | `{ query: GraphQL::Execution::Multiplex }`
28
+ # execute_query | `{ query: GraphQL::Query }`
29
+ # execute_query_lazy | `{ query: GraphQL::Query?, queries: Array<GraphQL::Query>? }`
30
+ # execute_field | `{ context: GraphQL::Query::Context::FieldResolutionContext }`
31
+ # execute_field_lazy | `{ context: GraphQL::Query::Context::FieldResolutionContext }`
32
+ #
33
+ module Tracing
34
+ class << self
35
+ # Override this method to do stuff
36
+ # @param key [String] The name of the event in GraphQL internals
37
+ # @param metadata [Hash] Event-related metadata (can be anything)
38
+ # @return [Object] Must return the value of the block
39
+ def trace(key, metadata)
40
+ call_tracers(0, key, metadata) { yield }
41
+ end
42
+
43
+ # Install a tracer to receive events.
44
+ # @param tracer [<#trace(key, metadata)>]
45
+ # @return [void]
46
+ def install(tracer)
47
+ if !tracers.include?(tracer)
48
+ @tracers << tracer
49
+ end
50
+ end
51
+
52
+ def uninstall(tracer)
53
+ @tracers.delete(tracer)
54
+ end
55
+
56
+ def tracers
57
+ @tracers ||= []
58
+ end
59
+
60
+ private
61
+
62
+ # If there's a tracer at `idx`, call it and then increment `idx`.
63
+ # Otherwise, yield.
64
+ #
65
+ # @param idx [Integer] Which tracer to call
66
+ # @param key [String] The current event name
67
+ # @param metadata [Object] The current event object
68
+ # @return Whatever the block returns
69
+ def call_tracers(idx, key, metadata)
70
+ if idx == @tracers.length
71
+ yield
72
+ else
73
+ @tracers[idx].trace(key, metadata) { call_tracers(idx + 1, key, metadata) { yield } }
74
+ end
75
+ end
76
+ end
77
+ # Initialize the array
78
+ tracers
79
+ end
80
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module GraphQL
4
+ module Tracing
5
+ # This implementation forwards events to ActiveSupport::Notifications
6
+ # with a `graphql.` prefix.
7
+ #
8
+ # Installed automatically when `ActiveSupport::Notifications` is discovered.
9
+ module ActiveSupportNotificationsTracing
10
+ # A cache of frequently-used keys to avoid needless string allocations
11
+ KEYS = {
12
+ "lex" => "graphql.lex",
13
+ "parse" => "graphql.parse",
14
+ "validate" => "graphql.validate",
15
+ "analyze_multiplex" => "graphql.analyze_multiplex",
16
+ "analyze_query" => "graphql.analyze_query",
17
+ "execute_query" => "graphql.execute_query",
18
+ "execute_query_lazy" => "graphql.execute_query_lazy",
19
+ "execute_field" => "graphql.execute_field",
20
+ "execute_field_lazy" => "graphql.execute_field_lazy",
21
+ }
22
+
23
+ def self.trace(key, metadata)
24
+ prefixed_key = KEYS[key] || "graphql.#{key}"
25
+ ActiveSupport::Notifications.instrument(prefixed_key, metadata) do
26
+ yield
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -1,4 +1,4 @@
1
1
  # frozen_string_literal: true
2
2
  module GraphQL
3
- VERSION = "1.6.8"
3
+ VERSION = "1.7.0"
4
4
  end
data/readme.md CHANGED
@@ -37,7 +37,7 @@ Or, see ["Getting Started"](https://rmosolgo.github.io/graphql-ruby/).
37
37
 
38
38
  ## Upgrade
39
39
 
40
- I also sell [GraphQL::Pro](http://graphql.pro) which provides several features on top of the GraphQL runtime, including [authorization](http://rmosolgo.github.io/graphql-ruby/pro/authorization), [monitoring](http://rmosolgo.github.io/graphql-ruby/pro/monitoring) plugins and [static queries](http://rmosolgo.github.io/graphql-ruby/pro/persisted_queries). Besides that, Pro customers get email support and an opportunity to support graphql-ruby's development!
40
+ I also sell [GraphQL::Pro](http://graphql.pro) which provides several features on top of the GraphQL runtime, including [authorization](http://rmosolgo.github.io/graphql-ruby/pro/authorization), [monitoring](http://rmosolgo.github.io/graphql-ruby/pro/monitoring) plugins and [persisted queries](http://rmosolgo.github.io/graphql-ruby/operation_store/overview). Besides that, Pro customers get email support and an opportunity to support graphql-ruby's development!
41
41
 
42
42
  ## Goals
43
43
 
@@ -49,6 +49,25 @@ describe GraphQL::Analysis do
49
49
  assert_equal expected_node_counts, node_counts
50
50
  end
51
51
 
52
+ describe "tracing" do
53
+ let(:query_string) { "{ t: __typename }"}
54
+
55
+ it "emits traces" do
56
+ traces = TestTracing.with_trace do
57
+ Dummy::Schema.execute(document: GraphQL.parse(query_string))
58
+ end
59
+
60
+ # The query_trace is on the list _first_ because it finished first
61
+ _lex, _parse, _validate, query_trace, multiplex_trace, *_rest = traces
62
+
63
+ assert_equal "analyze_multiplex", multiplex_trace[:key]
64
+ assert_instance_of GraphQL::Execution::Multiplex, multiplex_trace[:multiplex]
65
+
66
+ assert_equal "analyze_query", query_trace[:key]
67
+ assert_instance_of GraphQL::Query, query_trace[:query]
68
+ end
69
+ end
70
+
52
71
  describe "when a variable is missing" do
53
72
  let(:query_string) {%|
54
73
  query something($cheeseId: Int!){
@@ -19,6 +19,34 @@ describe GraphQL::Argument do
19
19
  assert_includes err.message, expected_error
20
20
  end
21
21
 
22
+ describe ".from_dsl" do
23
+ it "accepts an existing argument" do
24
+ existing = GraphQL::Argument.define do
25
+ name "bar"
26
+ type GraphQL::STRING_TYPE
27
+ end
28
+
29
+ arg = GraphQL::Argument.from_dsl(:foo, existing)
30
+
31
+ assert_equal "foo", arg.name
32
+ assert_equal GraphQL::STRING_TYPE, arg.type
33
+ end
34
+
35
+ it "creates an argument from dsl arguments" do
36
+ arg = GraphQL::Argument.from_dsl(
37
+ :foo,
38
+ GraphQL::STRING_TYPE,
39
+ "A Description",
40
+ default_value: "Bar"
41
+ )
42
+
43
+ assert_equal "foo", arg.name
44
+ assert_equal GraphQL::STRING_TYPE, arg.type
45
+ assert_equal "A Description", arg.description
46
+ assert_equal "Bar", arg.default_value
47
+ end
48
+ end
49
+
22
50
  it "accepts custom keywords" do
23
51
  type = GraphQL::ObjectType.define do
24
52
  name "Something"
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+ require "spec_helper"
3
+
4
+ describe GraphQL::Backtrace do
5
+ before {
6
+ GraphQL::Backtrace.enable
7
+ }
8
+
9
+ after {
10
+ GraphQL::Backtrace.disable
11
+ }
12
+
13
+ class LazyError
14
+ def raise_err
15
+ raise "Lazy Boom"
16
+ end
17
+ end
18
+
19
+ let(:resolvers) {
20
+ {
21
+ "Query" => {
22
+ "field1" => Proc.new { :something },
23
+ "field2" => Proc.new { :something },
24
+ },
25
+ "Thing" => {
26
+ "listField" => Proc.new { :not_a_list },
27
+ "raiseField" => Proc.new { |o, a| raise("This is broken: #{a[:message]}") },
28
+ },
29
+ "OtherThing" => {
30
+ "strField" => Proc.new { LazyError.new },
31
+ },
32
+ }
33
+ }
34
+ let(:schema) {
35
+ defn = <<-GRAPHQL
36
+ type Query {
37
+ field1: Thing
38
+ field2: OtherThing
39
+ }
40
+
41
+ type Thing {
42
+ listField: [OtherThing]
43
+ raiseField(message: String!): Int
44
+ }
45
+
46
+ type OtherThing {
47
+ strField: String
48
+ }
49
+ GRAPHQL
50
+ GraphQL::Schema.from_definition(defn, default_resolve: resolvers).redefine {
51
+ lazy_resolve(LazyError, :raise_err)
52
+ }
53
+ }
54
+
55
+ describe "GraphQL backtrace helpers" do
56
+ it "raises a TracedError when enabled" do
57
+ assert_raises(GraphQL::Backtrace::TracedError) {
58
+ schema.execute("query BrokenList { field1 { listField { strField } } }")
59
+ }
60
+
61
+ GraphQL::Backtrace.disable
62
+
63
+ assert_raises(NoMethodError) {
64
+ schema.execute("query BrokenList { field1 { listField { strField } } }")
65
+ }
66
+ end
67
+
68
+ it "annotates crashes from user code" do
69
+ err = assert_raises(GraphQL::Backtrace::TracedError) {
70
+ schema.execute <<-GRAPHQL, root_value: "Root"
71
+ query($msg: String = \"Boom\") {
72
+ field1 {
73
+ boomError: raiseField(message: $msg)
74
+ }
75
+ }
76
+ GRAPHQL
77
+ }
78
+
79
+ # The original error info is present
80
+ assert_instance_of RuntimeError, err.cause
81
+ b = err.cause.backtrace
82
+ assert_backtrace_includes(b, file: "backtrace_spec.rb", method: "block")
83
+ assert_backtrace_includes(b, file: "execute.rb", method: "resolve_field")
84
+ assert_backtrace_includes(b, file: "execute.rb", method: "resolve_field")
85
+ assert_backtrace_includes(b, file: "execute.rb", method: "resolve_root_selection")
86
+
87
+ # GraphQL backtrace is present
88
+ expected_graphql_backtrace = [
89
+ "3:13: Thing.raiseField as boomError",
90
+ "2:11: Query.field1",
91
+ "1:9: query",
92
+ ]
93
+ assert_equal expected_graphql_backtrace, err.graphql_backtrace
94
+
95
+ # The message includes the GraphQL context
96
+ rendered_table = [
97
+ 'Loc | Field | Object | Arguments | Result',
98
+ '3:13 | Thing.raiseField as boomError | :something | {"message"=>"Boom"} | #<RuntimeError: This is broken: Boom>',
99
+ '2:11 | Query.field1 | "Root" | {} | {}',
100
+ '1:9 | query | "Root" | {"msg"=>"Boom"} | ',
101
+ ].join("\n")
102
+
103
+ assert_includes err.message, rendered_table
104
+ # The message includes the original error message
105
+ assert_includes err.message, "This is broken: Boom"
106
+ assert_includes err.message, "spec/graphql/backtrace_spec.rb:27", "It includes the original backtrace"
107
+ assert_includes err.message, "more lines"
108
+ end
109
+
110
+ it "annotates errors inside lazy resolution" do
111
+ err = assert_raises(GraphQL::Backtrace::TracedError) {
112
+ schema.execute("query StrField { field2 { strField } __typename }")
113
+ }
114
+ assert_instance_of RuntimeError, err.cause
115
+ b = err.cause.backtrace
116
+ assert_backtrace_includes(b, file: "backtrace_spec.rb", method: "raise_err")
117
+ assert_backtrace_includes(b, file: "field.rb", method: "lazy_resolve")
118
+ assert_backtrace_includes(b, file: "lazy/resolve.rb", method: "block")
119
+
120
+ expected_graphql_backtrace = [
121
+ "1:27: OtherThing.strField",
122
+ "1:18: Query.field2",
123
+ "1:1: query StrField",
124
+ ]
125
+
126
+ assert_equal(expected_graphql_backtrace, err.graphql_backtrace)
127
+
128
+ rendered_table = [
129
+ 'Loc | Field | Object | Arguments | Result',
130
+ '1:27 | OtherThing.strField | :something | {} | #<RuntimeError: Lazy Boom>',
131
+ '1:18 | Query.field2 | nil | {} | {strField: (unresolved)}',
132
+ '1:1 | query StrField | nil | {} | {field2: {...}, __typename: "Query"}',
133
+ ].join("\n")
134
+ assert_includes err.message, rendered_table
135
+ end
136
+ end
137
+
138
+ # This will get brittle when execution code moves between files
139
+ # but I'm not sure how to be sure that the backtrace contains the right stuff!
140
+ def assert_backtrace_includes(backtrace, file:, method:)
141
+ includes_tag = backtrace.any? { |s| s.include?(file) && s.include?("`" + method) }
142
+ assert includes_tag, "Backtrace should include #{file} inside method #{method}"
143
+ end
144
+ end