graphql 2.5.11 → 2.5.16

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.
@@ -8,6 +8,13 @@ module GraphQL
8
8
  #
9
9
  # (This is for specifying mutually exclusive sets of arguments.)
10
10
  #
11
+ # If you use {GraphQL::Schema::Visibility} to hide all the arguments in a `one_of: [..]` set,
12
+ # then a developer-facing {GraphQL::Error} will be raised during execution. Pass `allow_all_hidden: true` to
13
+ # skip validation in this case instead.
14
+ #
15
+ # This validator also implements `argument ... required: :nullable`. If an argument has `required: :nullable`
16
+ # but it's hidden with {GraphQL::Schema::Visibility}, then this validator doesn't run.
17
+ #
11
18
  # @example Require exactly one of these arguments
12
19
  #
13
20
  # field :update_amount, IngredientAmount, null: false do
@@ -37,15 +44,17 @@ module GraphQL
37
44
  class RequiredValidator < Validator
38
45
  # @param one_of [Array<Symbol>] A list of arguments, exactly one of which is required for this field
39
46
  # @param argument [Symbol] An argument that is required for this field
47
+ # @param allow_all_hidden [Boolean] If `true`, then this validator won't run if all the `one_of: ...` arguments have been hidden
40
48
  # @param message [String]
41
- def initialize(one_of: nil, argument: nil, message: nil, **default_options)
49
+ def initialize(one_of: nil, argument: nil, allow_all_hidden: nil, message: nil, **default_options)
42
50
  @one_of = if one_of
43
51
  one_of
44
52
  elsif argument
45
- [argument]
53
+ [ argument ]
46
54
  else
47
55
  raise ArgumentError, "`one_of:` or `argument:` must be given in `validates required: {...}`"
48
56
  end
57
+ @allow_all_hidden = allow_all_hidden.nil? ? !!argument : allow_all_hidden
49
58
  @message = message
50
59
  super(**default_options)
51
60
  end
@@ -54,10 +63,17 @@ module GraphQL
54
63
  fully_matched_conditions = 0
55
64
  partially_matched_conditions = 0
56
65
 
66
+ visible_keywords = context.types.arguments(@validated).map(&:keyword)
67
+ no_visible_conditions = true
68
+
57
69
  if !value.nil?
58
70
  @one_of.each do |one_of_condition|
59
71
  case one_of_condition
60
72
  when Symbol
73
+ if no_visible_conditions && visible_keywords.include?(one_of_condition)
74
+ no_visible_conditions = false
75
+ end
76
+
61
77
  if value.key?(one_of_condition)
62
78
  fully_matched_conditions += 1
63
79
  end
@@ -66,6 +82,9 @@ module GraphQL
66
82
  full_match = true
67
83
 
68
84
  one_of_condition.each do |k|
85
+ if no_visible_conditions && visible_keywords.include?(k)
86
+ no_visible_conditions = false
87
+ end
69
88
  if value.key?(k)
70
89
  any_match = true
71
90
  else
@@ -88,6 +107,18 @@ module GraphQL
88
107
  end
89
108
  end
90
109
 
110
+ if no_visible_conditions
111
+ if @allow_all_hidden
112
+ return nil
113
+ else
114
+ raise GraphQL::Error, <<~ERR
115
+ #{@validated.path} validates `required: ...` but all required arguments were hidden.
116
+
117
+ Update your schema definition to allow the client to see some fields or skip validation by adding `required: { ..., allow_all_hidden: true }`
118
+ ERR
119
+ end
120
+ end
121
+
91
122
  if fully_matched_conditions == 1 && partially_matched_conditions == 0
92
123
  nil # OK
93
124
  else
@@ -10,9 +10,9 @@ module GraphQL
10
10
  class Visibility
11
11
  # @param schema [Class<GraphQL::Schema>]
12
12
  # @param profiles [Hash<Symbol => Hash>] A hash of `name => context` pairs for preloading visibility profiles
13
- # @param preload [Boolean] if `true`, load the default schema profile and all named profiles immediately (defaults to `true` for `Rails.env.production?`)
13
+ # @param preload [Boolean] if `true`, load the default schema profile and all named profiles immediately (defaults to `true` for `Rails.env.production?` and `Rails.env.staging?`)
14
14
  # @param migration_errors [Boolean] if `true`, raise an error when `Visibility` and `Warden` return different results
15
- def self.use(schema, dynamic: false, profiles: EmptyObjects::EMPTY_HASH, preload: (defined?(Rails.env) ? Rails.env.production? : nil), migration_errors: false)
15
+ def self.use(schema, dynamic: false, profiles: EmptyObjects::EMPTY_HASH, preload: (defined?(Rails.env) ? (Rails.env.production? || Rails.env.staging?) : nil), migration_errors: false)
16
16
  profiles&.each { |name, ctx|
17
17
  ctx[:visibility_profile] = name
18
18
  ctx.freeze
@@ -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"
@@ -9,8 +9,12 @@ module GraphQL
9
9
  # When `MySchema.detailed_trace?(query)` returns `true`, a profiler-specific `trace_mode: ...` will be used for the query,
10
10
  # overriding the one in `context[:trace_mode]`.
11
11
  #
12
+ # By default, the detailed tracer calls `.inspect` on application objects returned from fields. You can customize
13
+ # this behavior by extending {DetailedTrace} and overriding {#inspect_object}. You can opt out of debug annotations
14
+ # entirely with `use ..., debug: false` or for a single query with `context: { detailed_trace_debug: false }`.
15
+ #
12
16
  # __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.
17
+ # You can configure this database to retain all data (persistent) or to expire data according to your rules.
14
18
  # If you need to save traces indefinitely, you can download them from Perfetto after opening them there.
15
19
  #
16
20
  # @example Adding the sampler to your schema
@@ -27,10 +31,29 @@ module GraphQL
27
31
  # end
28
32
  #
29
33
  # @see Graphql::Dashboard GraphQL::Dashboard for viewing stored results
34
+ #
35
+ # @example Customizing debug output in traces
36
+ # class CustomDetailedTrace < GraphQL::Tracing::DetailedTrace
37
+ # def inspect_object(object)
38
+ # if object.is_a?(SomeThing)
39
+ # # handle it specially ...
40
+ # else
41
+ # super
42
+ # end
43
+ # end
44
+ # end
45
+ #
46
+ # @example disabling debug annotations completely
47
+ # use DetailedTrace, debug: false, ...
48
+ #
49
+ # @example disabling debug annotations for one query
50
+ # MySchema.execute(query_str, context: { detailed_trace_debug: false })
51
+ #
30
52
  class DetailedTrace
31
53
  # @param redis [Redis] If provided, profiles will be stored in Redis for later review
32
54
  # @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)
55
+ # @param debug [Boolean] if `false`, it won't create `debug` annotations in Perfetto traces (reduces overhead)
56
+ def self.use(schema, trace_mode: :profile_sample, memory: false, debug: debug?, redis: nil, limit: nil)
34
57
  storage = if redis
35
58
  RedisBackend.new(redis: redis, limit: limit)
36
59
  elsif memory
@@ -38,13 +61,15 @@ module GraphQL
38
61
  else
39
62
  raise ArgumentError, "Pass `redis: ...` to store traces in Redis for later review"
40
63
  end
41
- schema.detailed_trace = self.new(storage: storage, trace_mode: trace_mode)
64
+ detailed_trace = self.new(storage: storage, trace_mode: trace_mode, debug: debug)
65
+ schema.detailed_trace = detailed_trace
42
66
  schema.trace_with(PerfettoTrace, mode: trace_mode, save_profile: true)
43
67
  end
44
68
 
45
- def initialize(storage:, trace_mode:)
69
+ def initialize(storage:, trace_mode:, debug:)
46
70
  @storage = storage
47
71
  @trace_mode = trace_mode
72
+ @debug = debug
48
73
  end
49
74
 
50
75
  # @return [Symbol] The trace mode to use when {Schema.detailed_trace?} returns `true`
@@ -55,6 +80,11 @@ module GraphQL
55
80
  @storage.save_trace(operation_name, duration_ms, begin_ms, trace_data)
56
81
  end
57
82
 
83
+ # @return [Boolean]
84
+ def debug?
85
+ @debug
86
+ end
87
+
58
88
  # @param last [Integer]
59
89
  # @param before [Integer] Timestamp in milliseconds since epoch
60
90
  # @return [Enumerable<StoredTrace>]
@@ -77,6 +107,24 @@ module GraphQL
77
107
  @storage.delete_all_traces
78
108
  end
79
109
 
110
+ def inspect_object(object)
111
+ self.class.inspect_object(object)
112
+ end
113
+
114
+ def self.inspect_object(object)
115
+ if defined?(ActiveRecord::Relation) && object.is_a?(ActiveRecord::Relation)
116
+ "#{object.class}, .to_sql=#{object.to_sql.inspect}"
117
+ else
118
+ object.inspect
119
+ end
120
+ end
121
+
122
+ # Default debug setting
123
+ # @return [true]
124
+ def self.debug?
125
+ true
126
+ end
127
+
80
128
  class StoredTrace
81
129
  def initialize(id:, operation_name:, duration_ms:, begin_ms:, trace_data:)
82
130
  @id = id