rubocop-graphql 1.6.0 → 1.8.0
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.
- checksums.yaml +4 -4
- data/README.md +4 -1
- data/config/default.yml +35 -0
- data/lib/rubocop/cop/graphql/default_for_optional_argument.rb +187 -0
- data/lib/rubocop/cop/graphql/disallowed_types.rb +114 -0
- data/lib/rubocop/cop/graphql/field_description.rb +11 -0
- data/lib/rubocop/cop/graphql/method_shadowed_by_resolver_method.rb +122 -0
- data/lib/rubocop/cop/graphql/nullability_mismatch.rb +137 -0
- data/lib/rubocop/cop/graphql/object_description.rb +171 -17
- data/lib/rubocop/cop/graphql/ordered_fields.rb +32 -3
- data/lib/rubocop/cop/graphql/unused_argument.rb +1 -21
- data/lib/rubocop/cop/graphql/useless_method_option.rb +100 -0
- data/lib/rubocop/cop/graphql.rb +44 -0
- data/lib/rubocop/cop/graphql_cops.rb +4 -27
- data/lib/rubocop/graphql/argument/kwargs.rb +18 -0
- data/lib/rubocop/graphql/argument.rb +45 -0
- data/lib/rubocop/graphql/field/kwargs.rb +24 -0
- data/lib/rubocop/graphql/version.rb +1 -1
- data/lib/rubocop-graphql.rb +1 -1
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f7108c819a363b06725b9de204f6e1eb26a19c0e022d07a51f7cac3570ccc041
|
|
4
|
+
data.tar.gz: 5b5b231155cf6ac19e58df7ab0ded5060517f0a59ed5b3bcef77db04adf9355a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2bd922b4a7234a4ca1315dc42fd6b3adf7fff850bce9ec71abf41df175fd56889cd76be254dcdb4bbec09615dc85a8317a87884778fc62797ab991f63eee04bf
|
|
7
|
+
data.tar.gz: 282acda7b0c3f54e51a7e77245964e55d3d9d0847009490bfa096959cb49d5de72536e0e49b94f148dbfe1c72c40877494ce8edc9d5187c9b8eacdcf33be6129
|
data/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
[Rubocop](https://github.com/rubocop-hq/rubocop) extension for enforcing [graphql-ruby](https://github.com/rmosolgo/graphql-ruby) best practices.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Professional Support
|
|
6
|
+
|
|
7
|
+
Need help with your GraphQL API architecture, schema design, or performance?
|
|
8
|
+
I'm available for consulting — [get in touch](https://dmitrytsepelev.dev/consulting).
|
|
6
9
|
|
|
7
10
|
## Installation
|
|
8
11
|
|
data/config/default.yml
CHANGED
|
@@ -28,6 +28,19 @@ GraphQL/ContextWriteInType:
|
|
|
28
28
|
Include:
|
|
29
29
|
- '**/graphql/types/**/*'
|
|
30
30
|
|
|
31
|
+
GraphQL/DefaultForOptionalArgument:
|
|
32
|
+
Enabled: true
|
|
33
|
+
VersionAdded: '1.8.0'
|
|
34
|
+
Description: 'Optional arguments should have a default value in the resolver signature'
|
|
35
|
+
|
|
36
|
+
GraphQL/DisallowedTypes:
|
|
37
|
+
Enabled: false
|
|
38
|
+
VersionAdded: '1.8.0'
|
|
39
|
+
Description: 'Flags field and argument types the project has decided not to expose'
|
|
40
|
+
# Type name => the message explaining what to use instead. Nothing is disallowed
|
|
41
|
+
# until this is configured, so the cop is inert out of the box.
|
|
42
|
+
Types: {}
|
|
43
|
+
|
|
31
44
|
GraphQL/ExtractInputType:
|
|
32
45
|
Enabled: true
|
|
33
46
|
VersionAdded: '0.2.0'
|
|
@@ -117,6 +130,11 @@ GraphQL/MaxDepthSchema:
|
|
|
117
130
|
Include:
|
|
118
131
|
- '**/graphql/**/*_schema.rb'
|
|
119
132
|
|
|
133
|
+
GraphQL/MethodShadowedByResolverMethod:
|
|
134
|
+
Enabled: true
|
|
135
|
+
VersionAdded: '1.8.0'
|
|
136
|
+
Description: 'Checks for method definitions shadowed by the field''s effective resolver_method'
|
|
137
|
+
|
|
120
138
|
GraphQL/MultipleFieldDefinitions:
|
|
121
139
|
Enabled: true
|
|
122
140
|
VersionAdded: '0.15.0'
|
|
@@ -130,6 +148,11 @@ GraphQL/NotAuthorizedNodeType:
|
|
|
130
148
|
- '**/graphql/types/**/*'
|
|
131
149
|
SafeBaseClasses: []
|
|
132
150
|
|
|
151
|
+
GraphQL/NullabilityMismatch:
|
|
152
|
+
Enabled: true
|
|
153
|
+
VersionAdded: '1.8.0'
|
|
154
|
+
Description: 'Detects a non-null field whose Sorbet resolver signature returns a nilable type'
|
|
155
|
+
|
|
133
156
|
GraphQL/ResolverMethodLength:
|
|
134
157
|
Enabled: true
|
|
135
158
|
VersionAdded: '0.1.0'
|
|
@@ -143,6 +166,13 @@ GraphQL/ObjectDescription:
|
|
|
143
166
|
Enabled: true
|
|
144
167
|
VersionAdded: '0.3.0'
|
|
145
168
|
Description: 'Ensures all types have a description'
|
|
169
|
+
# Skip the root operation types (Query, Mutation, Subscription), which are
|
|
170
|
+
# usually left undescribed. Set to false to require a description on them too.
|
|
171
|
+
IgnoreRootTypes: true
|
|
172
|
+
# Superclass (or included module) names to treat as GraphQL bases, on top of the
|
|
173
|
+
# conventional ones. Use when your base types are named unconventionally, e.g.
|
|
174
|
+
# `class Types::UserType < ApplicationType`.
|
|
175
|
+
AdditionalTypeBases: []
|
|
146
176
|
Exclude:
|
|
147
177
|
- "spec/**/*"
|
|
148
178
|
- "test/**/*"
|
|
@@ -182,3 +212,8 @@ GraphQL/UnnecessaryFieldCamelize:
|
|
|
182
212
|
Enabled: true
|
|
183
213
|
VersionAdded: '0.18.0'
|
|
184
214
|
Description: "Camelize isn't necessary if the field name doesn't contain underscores"
|
|
215
|
+
|
|
216
|
+
GraphQL/UselessMethodOption:
|
|
217
|
+
Enabled: true
|
|
218
|
+
VersionAdded: '1.8.0'
|
|
219
|
+
Description: 'Checks for resolver_method:/method: made ineffective by resolver:, or method: shadowed by a same-named def'
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module GraphQL
|
|
6
|
+
# Optional arguments should have a default value in the resolver signature.
|
|
7
|
+
#
|
|
8
|
+
# When the client omits an argument declared `required: false`, graphql-ruby leaves it
|
|
9
|
+
# out of the keyword arguments entirely, so a required keyword raises
|
|
10
|
+
# `ArgumentError: missing keyword`. Giving the keyword a default value is what makes the
|
|
11
|
+
# argument actually optional at runtime.
|
|
12
|
+
#
|
|
13
|
+
# Arguments declared with a `default_value:` are always passed, so they are not reported.
|
|
14
|
+
# Neither is `required: :nullable`, which still demands the argument be present.
|
|
15
|
+
#
|
|
16
|
+
# Both class-level arguments (checked against `#resolve` and `#authorized?`) and
|
|
17
|
+
# arguments defined inside a field block (checked against that field's resolver method)
|
|
18
|
+
# are covered.
|
|
19
|
+
#
|
|
20
|
+
# @example
|
|
21
|
+
# # bad
|
|
22
|
+
#
|
|
23
|
+
# class SomeResolver < Resolvers::Base
|
|
24
|
+
# argument :name, String, required: false
|
|
25
|
+
#
|
|
26
|
+
# def resolve(name:); end
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# # good
|
|
30
|
+
#
|
|
31
|
+
# class SomeResolver < Resolvers::Base
|
|
32
|
+
# argument :name, String, required: false
|
|
33
|
+
#
|
|
34
|
+
# def resolve(name: nil); end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# # good - a default value means the keyword is always passed
|
|
38
|
+
#
|
|
39
|
+
# class SomeResolver < Resolvers::Base
|
|
40
|
+
# argument :name, String, required: false, default_value: "anonymous"
|
|
41
|
+
#
|
|
42
|
+
# def resolve(name:); end
|
|
43
|
+
# end
|
|
44
|
+
#
|
|
45
|
+
# # bad
|
|
46
|
+
#
|
|
47
|
+
# class UserType < BaseObject
|
|
48
|
+
# field :posts, [PostType], null: false do
|
|
49
|
+
# argument :limit, Integer, required: false
|
|
50
|
+
# end
|
|
51
|
+
#
|
|
52
|
+
# def posts(limit:); end
|
|
53
|
+
# end
|
|
54
|
+
#
|
|
55
|
+
# # good
|
|
56
|
+
#
|
|
57
|
+
# class UserType < BaseObject
|
|
58
|
+
# field :posts, [PostType], null: false do
|
|
59
|
+
# argument :limit, Integer, required: false
|
|
60
|
+
# end
|
|
61
|
+
#
|
|
62
|
+
# def posts(limit: 10); end
|
|
63
|
+
# end
|
|
64
|
+
#
|
|
65
|
+
class DefaultForOptionalArgument < Base
|
|
66
|
+
MSG = "Optional argument `%<keyword>s` has no default value in `%<method>s`, so " \
|
|
67
|
+
"omitting it raises ArgumentError."
|
|
68
|
+
|
|
69
|
+
RESOLVER_METHODS = %i[resolve authorized?].freeze
|
|
70
|
+
|
|
71
|
+
def on_class(node)
|
|
72
|
+
body = node.body
|
|
73
|
+
return unless body
|
|
74
|
+
|
|
75
|
+
defs = definitions_in(node)
|
|
76
|
+
return if defs.empty?
|
|
77
|
+
|
|
78
|
+
check_class_arguments(node, defs)
|
|
79
|
+
check_field_arguments(node, defs)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def check_class_arguments(class_node, defs)
|
|
85
|
+
keywords = optional_keywords(class_arguments(class_node))
|
|
86
|
+
return if keywords.empty?
|
|
87
|
+
|
|
88
|
+
RESOLVER_METHODS.each do |method_name|
|
|
89
|
+
check_signature(defs[method_name], keywords)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def check_field_arguments(class_node, defs)
|
|
94
|
+
field_blocks(class_node).each do |block_node|
|
|
95
|
+
field = RuboCop::GraphQL::Field.new(block_node.send_node)
|
|
96
|
+
next if field.kwargs.resolver
|
|
97
|
+
|
|
98
|
+
keywords = optional_keywords(block_arguments(block_node))
|
|
99
|
+
next if keywords.empty?
|
|
100
|
+
|
|
101
|
+
check_signature(defs[field.resolver_method_name.to_sym], keywords)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def check_signature(def_node, keywords)
|
|
106
|
+
return unless def_node
|
|
107
|
+
|
|
108
|
+
def_node.arguments.each do |arg_node|
|
|
109
|
+
next unless arg_node.kwarg_type?
|
|
110
|
+
next unless keywords.include?(arg_node.node_parts[0])
|
|
111
|
+
|
|
112
|
+
add_offense(
|
|
113
|
+
arg_node,
|
|
114
|
+
message: format(MSG, keyword: arg_node.node_parts[0], method: def_node.method_name)
|
|
115
|
+
)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Keywords of arguments that graphql-ruby may leave out of the resolver call.
|
|
120
|
+
def optional_keywords(argument_nodes)
|
|
121
|
+
argument_nodes.filter_map do |argument_node|
|
|
122
|
+
argument = RuboCop::GraphQL::Argument.new(argument_node)
|
|
123
|
+
argument.keyword if argument.optional? && !argument.default_value?
|
|
124
|
+
end.to_set
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# `def`s owned by this class, keyed by name. A nested class gets its own `on_class`.
|
|
128
|
+
def definitions_in(class_node)
|
|
129
|
+
each_in_scope(class_node.body, :def).each_with_object({}) do |def_node, defs|
|
|
130
|
+
defs[def_node.method_name] ||= def_node
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Class-level `argument` calls: the walk stops at blocks, so field-block arguments
|
|
135
|
+
# (handled separately, against a different method) are not picked up here.
|
|
136
|
+
def class_arguments(class_node)
|
|
137
|
+
each_in_scope(class_node.body, :send, stop_at_block: true).select do |send_node|
|
|
138
|
+
argument_declaration?(send_node)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def block_arguments(block_node)
|
|
143
|
+
body = block_node.body
|
|
144
|
+
return [] unless body
|
|
145
|
+
|
|
146
|
+
each_in_scope(body, :send).select { |send_node| argument_declaration?(send_node) }
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def field_blocks(class_node)
|
|
150
|
+
each_in_scope(class_node.body, :block).select do |block_node|
|
|
151
|
+
field_declaration?(block_node.send_node)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Collects nodes of `type` without descending into a nested class or module body.
|
|
156
|
+
# With `stop_at_block`, a block contributes only the call that opens it, so a nested
|
|
157
|
+
# `field ... do ... end` keeps its own arguments out of the enclosing scope.
|
|
158
|
+
def each_in_scope(node, type, stop_at_block: false, found: [])
|
|
159
|
+
found << node if node.type == type
|
|
160
|
+
return found if node.type?(:class, :module, :sclass)
|
|
161
|
+
|
|
162
|
+
children =
|
|
163
|
+
if stop_at_block && node.type?(:any_block)
|
|
164
|
+
[node.send_node]
|
|
165
|
+
else
|
|
166
|
+
node.each_child_node
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
children.each do |child|
|
|
170
|
+
each_in_scope(child, type, stop_at_block: stop_at_block, found: found)
|
|
171
|
+
end
|
|
172
|
+
found
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @!method argument_declaration?(node)
|
|
176
|
+
def_node_matcher :argument_declaration?, <<~PATTERN
|
|
177
|
+
(send nil? :argument (:sym _) ...)
|
|
178
|
+
PATTERN
|
|
179
|
+
|
|
180
|
+
# @!method field_declaration?(node)
|
|
181
|
+
def_node_matcher :field_declaration?, <<~PATTERN
|
|
182
|
+
(send nil? :field (:sym _) ...)
|
|
183
|
+
PATTERN
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module GraphQL
|
|
6
|
+
# Flags field and argument types the project has decided not to expose, with a message
|
|
7
|
+
# explaining what to use instead.
|
|
8
|
+
#
|
|
9
|
+
# Every schema accumulates types that are still resolvable but shouldn't be reached for
|
|
10
|
+
# in new code: a scalar kept alive only for backwards compatibility, a type that predates
|
|
11
|
+
# a better one, or a builtin whose semantics don't fit the domain -- `Float` for money,
|
|
12
|
+
# say, where the serialization loses precision. The convention is usually documented and
|
|
13
|
+
# then re-litigated in review; this makes it fail the build instead.
|
|
14
|
+
#
|
|
15
|
+
# Nothing is disallowed by default: the cop is inert until `Types` is configured.
|
|
16
|
+
#
|
|
17
|
+
# A configured name matches the written constant exactly, or as a trailing segment of it,
|
|
18
|
+
# so `Float` covers `Float`, `Types::Float` and `GraphQL::Types::Float`. Configure
|
|
19
|
+
# `GraphQL::Types::Float` instead to match only the fully qualified form.
|
|
20
|
+
#
|
|
21
|
+
# List types are unwrapped, so `[Float]` and `[Float, null: true]` are flagged too, and
|
|
22
|
+
# both the positional type and the `type:` keyword are checked.
|
|
23
|
+
#
|
|
24
|
+
# @example Types: {'Float' => 'Use Types::Decimal, which serializes as a string.'}
|
|
25
|
+
# # bad
|
|
26
|
+
# field :amount, Float, null: false
|
|
27
|
+
# argument :amount, Float, required: true
|
|
28
|
+
# field :amounts, [Float], null: false
|
|
29
|
+
# field :amount, type: Float, null: false
|
|
30
|
+
#
|
|
31
|
+
# # good
|
|
32
|
+
# field :amount, Types::Decimal, null: false
|
|
33
|
+
# argument :amount, Types::Decimal, required: true
|
|
34
|
+
#
|
|
35
|
+
# @example Types: {'Types::LegacyDate' => 'Use GraphQL::Types::ISO8601Date.'}
|
|
36
|
+
# # bad
|
|
37
|
+
# field :starts_on, Types::LegacyDate, null: false
|
|
38
|
+
#
|
|
39
|
+
# # good
|
|
40
|
+
# field :starts_on, GraphQL::Types::ISO8601Date, null: false
|
|
41
|
+
#
|
|
42
|
+
class DisallowedTypes < Base
|
|
43
|
+
MSG = "`%<type>s` is not allowed as a field or argument type."
|
|
44
|
+
MSG_WITH_REASON = "`%<type>s` is not allowed as a field or argument type. %<reason>s"
|
|
45
|
+
|
|
46
|
+
RESTRICT_ON_SEND = %i[field argument].freeze
|
|
47
|
+
|
|
48
|
+
def on_send(node)
|
|
49
|
+
return if disallowed_types.empty?
|
|
50
|
+
return unless type_declaration?(node)
|
|
51
|
+
|
|
52
|
+
each_type_const(node) do |const_node|
|
|
53
|
+
configured_name = disallowed_name_for(const_node)
|
|
54
|
+
next unless configured_name
|
|
55
|
+
|
|
56
|
+
add_offense(const_node, message: message_for(configured_name))
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def disallowed_types
|
|
63
|
+
@disallowed_types ||= (cop_config["Types"] || {}).transform_keys(&:to_s)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def message_for(configured_name)
|
|
67
|
+
reason = disallowed_types[configured_name].to_s.strip
|
|
68
|
+
|
|
69
|
+
if reason.empty?
|
|
70
|
+
format(MSG, type: configured_name)
|
|
71
|
+
else
|
|
72
|
+
format(MSG_WITH_REASON, type: configured_name, reason: reason)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def disallowed_name_for(const_node)
|
|
77
|
+
written_name = const_node.const_name
|
|
78
|
+
|
|
79
|
+
disallowed_types.keys.find do |configured_name|
|
|
80
|
+
written_name == configured_name || written_name.end_with?("::#{configured_name}")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Yields the constants a `field`/`argument` call names as its type: the positional type
|
|
85
|
+
# and the `type:` keyword, each unwrapped through any list nesting.
|
|
86
|
+
def each_type_const(send_node, &block)
|
|
87
|
+
each_const_in(send_node.arguments[1], &block)
|
|
88
|
+
each_const_in(type_kwarg(send_node), &block)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def each_const_in(node, &block)
|
|
92
|
+
return unless node
|
|
93
|
+
|
|
94
|
+
case node.type
|
|
95
|
+
when :const then yield node
|
|
96
|
+
when :array then node.children.each { |child| each_const_in(child, &block) }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# An explicit receiver means this is some other `field`/`argument` method, not the DSL.
|
|
101
|
+
#
|
|
102
|
+
# @!method type_declaration?(node)
|
|
103
|
+
def_node_matcher :type_declaration?, <<~PATTERN
|
|
104
|
+
(send nil? {:field :argument} ...)
|
|
105
|
+
PATTERN
|
|
106
|
+
|
|
107
|
+
# @!method type_kwarg(node)
|
|
108
|
+
def_node_matcher :type_kwarg, <<~PATTERN
|
|
109
|
+
(send nil? {:field :argument} ... (hash <(pair (sym :type) $_) ...>))
|
|
110
|
+
PATTERN
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -18,6 +18,16 @@ module RuboCop
|
|
|
18
18
|
# field :name, String, null: true
|
|
19
19
|
# end
|
|
20
20
|
#
|
|
21
|
+
# Fields built from a resolver, mutation or subscription class are not
|
|
22
|
+
# flagged: graphql-ruby takes their description from that class.
|
|
23
|
+
#
|
|
24
|
+
# @example
|
|
25
|
+
# # good
|
|
26
|
+
#
|
|
27
|
+
# class UserType < BaseType
|
|
28
|
+
# field :posts, resolver: PostsResolver
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
21
31
|
class FieldDescription < Base
|
|
22
32
|
include RuboCop::GraphQL::NodePattern
|
|
23
33
|
|
|
@@ -28,6 +38,7 @@ module RuboCop
|
|
|
28
38
|
return unless field_definition?(node)
|
|
29
39
|
|
|
30
40
|
field = RuboCop::GraphQL::Field.new(node)
|
|
41
|
+
return if field.kwargs.resolver_class
|
|
31
42
|
|
|
32
43
|
add_offense(node) unless field.description
|
|
33
44
|
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module GraphQL
|
|
6
|
+
# This cop detects method definitions that are never called because
|
|
7
|
+
# the field's effective `resolver_method` points somewhere else.
|
|
8
|
+
#
|
|
9
|
+
# graphql-ruby only ever calls a single method on the type instance:
|
|
10
|
+
# the field's `resolver_method` (which is the resolver class's own
|
|
11
|
+
# method when `resolver:` is set, the explicit `resolver_method:`
|
|
12
|
+
# value when given, or the field name otherwise). Any other
|
|
13
|
+
# same-named method left on the type is unreachable:
|
|
14
|
+
#
|
|
15
|
+
# - When `resolver:` is set, the resolver class handles resolution
|
|
16
|
+
# entirely, so neither the field name, `resolver_method:`, nor
|
|
17
|
+
# `method:` (if also given) is ever dispatched to a method on the type.
|
|
18
|
+
# - When only `resolver_method:` is set, that name is the one
|
|
19
|
+
# actually called -- a leftover method matching the plain field
|
|
20
|
+
# name is never reached.
|
|
21
|
+
#
|
|
22
|
+
# @example
|
|
23
|
+
# # good
|
|
24
|
+
#
|
|
25
|
+
# class Types::PostType < Types::BaseObject
|
|
26
|
+
# field :author, resolver: Resolvers::AuthorResolver
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# class Types::PostType < Types::BaseObject
|
|
30
|
+
# field :author, String, null: true, resolver_method: :fetch_author
|
|
31
|
+
#
|
|
32
|
+
# def fetch_author
|
|
33
|
+
# object.author
|
|
34
|
+
# end
|
|
35
|
+
# end
|
|
36
|
+
#
|
|
37
|
+
# # bad
|
|
38
|
+
#
|
|
39
|
+
# class Types::PostType < Types::BaseObject
|
|
40
|
+
# field :author, resolver: Resolvers::AuthorResolver
|
|
41
|
+
#
|
|
42
|
+
# def author
|
|
43
|
+
# object.author
|
|
44
|
+
# end
|
|
45
|
+
# end
|
|
46
|
+
#
|
|
47
|
+
# class Types::PostType < Types::BaseObject
|
|
48
|
+
# field :author, String, null: true, resolver_method: :fetch_author
|
|
49
|
+
#
|
|
50
|
+
# def author
|
|
51
|
+
# object.author
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
# def fetch_author
|
|
55
|
+
# object.author
|
|
56
|
+
# end
|
|
57
|
+
# end
|
|
58
|
+
#
|
|
59
|
+
class MethodShadowedByResolverMethod < Base
|
|
60
|
+
include RuboCop::GraphQL::NodePattern
|
|
61
|
+
|
|
62
|
+
RESOLVER_MSG = "Remove this method, it is never called: `resolver: %<resolver>s` " \
|
|
63
|
+
"resolves this field instead."
|
|
64
|
+
RESOLVER_METHOD_MSG = "Remove this method, it is never called: " \
|
|
65
|
+
"`resolver_method: :%<resolver_method>s` is called instead."
|
|
66
|
+
RESTRICT_ON_SEND = %i[field].freeze
|
|
67
|
+
|
|
68
|
+
def on_send(node)
|
|
69
|
+
return unless field_definition?(node)
|
|
70
|
+
|
|
71
|
+
field = RuboCop::GraphQL::Field.new(node)
|
|
72
|
+
|
|
73
|
+
if field.kwargs.resolver
|
|
74
|
+
register_resolver_offenses(field)
|
|
75
|
+
else
|
|
76
|
+
register_resolver_method_offense(field)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Neither `resolver_method:` nor `method:` has any effect once `resolver:` is
|
|
83
|
+
# set (graphql-ruby always calls the resolver class's own resolver_method), so
|
|
84
|
+
# a method matching the field's own name, an explicit `resolver_method:`, or
|
|
85
|
+
# an explicit `method:` is equally unreachable.
|
|
86
|
+
def register_resolver_offenses(field)
|
|
87
|
+
resolver_source = field.kwargs.resolver.value.source
|
|
88
|
+
message = format(RESOLVER_MSG, resolver: resolver_source)
|
|
89
|
+
|
|
90
|
+
candidate_names = [
|
|
91
|
+
field.name, field.kwargs.resolver_method_name, method_kwarg_name(field)
|
|
92
|
+
].compact.uniq
|
|
93
|
+
candidate_names.each do |method_name|
|
|
94
|
+
shadowed_method = field.schema_member.find_method_definition(method_name)
|
|
95
|
+
add_offense(shadowed_method.loc.name, message: message) if shadowed_method
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# The `resolver_method:`-named method is the one actually called; only a
|
|
100
|
+
# leftover method matching the plain field name is dead.
|
|
101
|
+
def register_resolver_method_offense(field)
|
|
102
|
+
resolver_method_kwarg = field.kwargs.resolver_method_name
|
|
103
|
+
return unless resolver_method_kwarg && resolver_method_kwarg != field.name
|
|
104
|
+
|
|
105
|
+
shadowed_method = field.schema_member.find_method_definition(field.name)
|
|
106
|
+
return unless shadowed_method
|
|
107
|
+
|
|
108
|
+
message = format(RESOLVER_METHOD_MSG, resolver_method: resolver_method_kwarg)
|
|
109
|
+
add_offense(shadowed_method.loc.name, message: message)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def method_kwarg_name(field)
|
|
113
|
+
pair = field.kwargs.method
|
|
114
|
+
return nil unless pair
|
|
115
|
+
|
|
116
|
+
value_node = pair.value
|
|
117
|
+
value_node.value if value_node.sym_type?
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RuboCop
|
|
4
|
+
module Cop
|
|
5
|
+
module GraphQL
|
|
6
|
+
# A non-null field should not have a resolver whose Sorbet signature returns a nilable
|
|
7
|
+
# type. The two disagree: the schema promises a value, the signature admits `nil`, and
|
|
8
|
+
# graphql-ruby raises an invalid-null error for every object that resolves to `nil`.
|
|
9
|
+
#
|
|
10
|
+
# Sorbet cannot catch this, because the field declaration is not part of the signature.
|
|
11
|
+
# Only the crashing direction is reported: a nullable field with a non-nilable resolver
|
|
12
|
+
# is merely imprecise, not broken.
|
|
13
|
+
#
|
|
14
|
+
# Codebases without Sorbet signatures never trigger this cop.
|
|
15
|
+
#
|
|
16
|
+
# @example
|
|
17
|
+
# # bad
|
|
18
|
+
#
|
|
19
|
+
# class UserType < BaseObject
|
|
20
|
+
# field :name, String, null: false
|
|
21
|
+
#
|
|
22
|
+
# sig { override.returns(T.nilable(String)) }
|
|
23
|
+
# def name
|
|
24
|
+
# object.name
|
|
25
|
+
# end
|
|
26
|
+
# end
|
|
27
|
+
#
|
|
28
|
+
# # good - the schema admits what the resolver may return
|
|
29
|
+
#
|
|
30
|
+
# class UserType < BaseObject
|
|
31
|
+
# field :name, String, null: true
|
|
32
|
+
#
|
|
33
|
+
# sig { override.returns(T.nilable(String)) }
|
|
34
|
+
# def name
|
|
35
|
+
# object.name
|
|
36
|
+
# end
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# # good - the resolver guarantees what the schema promises
|
|
40
|
+
#
|
|
41
|
+
# class UserType < BaseObject
|
|
42
|
+
# field :name, String, null: false
|
|
43
|
+
#
|
|
44
|
+
# sig { override.returns(String) }
|
|
45
|
+
# def name
|
|
46
|
+
# object.name || "anonymous"
|
|
47
|
+
# end
|
|
48
|
+
# end
|
|
49
|
+
#
|
|
50
|
+
class NullabilityMismatch < Base
|
|
51
|
+
include RuboCop::GraphQL::Sorbet
|
|
52
|
+
|
|
53
|
+
MSG = "Field `%<field>s` is `null: false` but its resolver signature returns a " \
|
|
54
|
+
"nilable type, so a nil resolves to an invalid null error."
|
|
55
|
+
|
|
56
|
+
def on_class(node)
|
|
57
|
+
non_null_fields = collect_non_null_fields(node)
|
|
58
|
+
return if non_null_fields.empty?
|
|
59
|
+
|
|
60
|
+
node.each_descendant(:def) do |def_node|
|
|
61
|
+
next unless owned_by?(def_node, node)
|
|
62
|
+
|
|
63
|
+
field_node = non_null_fields[def_node.method_name]
|
|
64
|
+
next unless field_node && nilable_signature?(def_node)
|
|
65
|
+
|
|
66
|
+
field_name = RuboCop::GraphQL::Field.new(field_node).name
|
|
67
|
+
add_offense(field_node, message: format(MSG, field: field_name))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
alias on_module on_class
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
# Non-null fields owned by this type, keyed by the method that resolves them. Fields
|
|
75
|
+
# handed off to a `resolver:` class are resolved elsewhere, so they are skipped.
|
|
76
|
+
def collect_non_null_fields(node)
|
|
77
|
+
fields = {}
|
|
78
|
+
|
|
79
|
+
node.each_descendant(:send) do |send_node|
|
|
80
|
+
next unless non_null_field?(send_node) && owned_by?(send_node, node)
|
|
81
|
+
|
|
82
|
+
field = RuboCop::GraphQL::Field.new(send_node)
|
|
83
|
+
next if field.kwargs.resolver
|
|
84
|
+
|
|
85
|
+
fields[field.resolver_method_name.to_sym] ||= send_node
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
fields
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def nilable_signature?(def_node)
|
|
92
|
+
signature = sorbet_signature_for(def_node)
|
|
93
|
+
return false unless signature
|
|
94
|
+
|
|
95
|
+
signature.each_descendant(:send).any? do |send_node|
|
|
96
|
+
send_node.method?(:returns) && nilable_type?(send_node.first_argument)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Only the outermost type counts. `T::Array[T.nilable(String)]` is a non-null list of
|
|
101
|
+
# nullable items, which pairs correctly with a `null: false` field.
|
|
102
|
+
def nilable_type?(node)
|
|
103
|
+
return false unless node
|
|
104
|
+
return true if t_nilable?(node)
|
|
105
|
+
|
|
106
|
+
Array(t_any_types(node)).any? { |type| nil_class?(type) }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# True when `node`'s nearest enclosing class or module is `owner`, so that a nested
|
|
110
|
+
# type's fields and methods are not attributed to the one around it.
|
|
111
|
+
def owned_by?(node, owner)
|
|
112
|
+
node.each_ancestor(:class, :module).first == owner
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# @!method non_null_field?(node)
|
|
116
|
+
def_node_matcher :non_null_field?, <<~PATTERN
|
|
117
|
+
(send nil? :field (sym _) ... (hash <(pair (sym :null) (false)) ...>))
|
|
118
|
+
PATTERN
|
|
119
|
+
|
|
120
|
+
# @!method t_nilable?(node)
|
|
121
|
+
def_node_matcher :t_nilable?, <<~PATTERN
|
|
122
|
+
(send (const {nil? cbase} :T) :nilable ...)
|
|
123
|
+
PATTERN
|
|
124
|
+
|
|
125
|
+
# @!method t_any_types(node)
|
|
126
|
+
def_node_matcher :t_any_types, <<~PATTERN
|
|
127
|
+
(send (const {nil? cbase} :T) :any $...)
|
|
128
|
+
PATTERN
|
|
129
|
+
|
|
130
|
+
# @!method nil_class?(node)
|
|
131
|
+
def_node_matcher :nil_class?, <<~PATTERN
|
|
132
|
+
(const {nil? cbase} :NilClass)
|
|
133
|
+
PATTERN
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|