lighthouse-graphql 0.4.0 → 0.5.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.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +139 -1
  3. data/README.md +8 -5
  4. data/docs/configuration.md +26 -2
  5. data/docs/directives.md +287 -4
  6. data/docs/generators.md +82 -0
  7. data/docs/getting-started.md +26 -30
  8. data/docs/relationships.md +42 -0
  9. data/docs/relay.md +137 -0
  10. data/docs/resolvers.md +261 -22
  11. data/docs/security.md +17 -0
  12. data/docs/types.md +219 -0
  13. data/docs/uploads.md +80 -0
  14. data/lib/generators/lighthouse/base_classes.rb +32 -49
  15. data/lib/generators/lighthouse/install/install_generator.rb +83 -24
  16. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -6
  17. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -3
  18. data/lib/lighthouse/async_mutation_job.rb +17 -0
  19. data/lib/lighthouse/complexity.rb +5 -4
  20. data/lib/lighthouse/config.rb +13 -2
  21. data/lib/lighthouse/controller.rb +27 -9
  22. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  23. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  24. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  25. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  26. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  27. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  28. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  29. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  30. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  31. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  32. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  33. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
  34. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  35. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  36. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  37. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  38. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  39. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  40. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  41. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  42. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  43. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  44. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  45. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
  46. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  47. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  48. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  49. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  50. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  51. data/lib/lighthouse/directives/definitions.rb +52 -0
  52. data/lib/lighthouse/directives/model_directive.rb +131 -0
  53. data/lib/lighthouse/directives/node_directive.rb +134 -0
  54. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  55. data/lib/lighthouse/directives/registry.rb +21 -21
  56. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  57. data/lib/lighthouse/enum_directive.rb +41 -0
  58. data/lib/lighthouse/field_resolver_stack.rb +53 -6
  59. data/lib/lighthouse/generated_resolvers.rb +13 -8
  60. data/lib/lighthouse/global_id.rb +31 -0
  61. data/lib/lighthouse/graphql/version.rb +1 -1
  62. data/lib/lighthouse/multipart_request.rb +43 -0
  63. data/lib/lighthouse/node_registry.rb +68 -0
  64. data/lib/lighthouse/pagination.rb +3 -0
  65. data/lib/lighthouse/rb_lighthouse.rb +33 -0
  66. data/lib/lighthouse/relation_counts.rb +29 -0
  67. data/lib/lighthouse/resolve_info.rb +80 -0
  68. data/lib/lighthouse/resolver_bases.rb +9 -2
  69. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  70. data/lib/lighthouse/scalar_coercion.rb +72 -0
  71. data/lib/lighthouse/schema_cache.rb +2 -1
  72. data/lib/lighthouse/schema_generator.rb +11 -10
  73. data/lib/lighthouse/schema_implementation.rb +4 -8
  74. data/lib/lighthouse/support/environment.rb +13 -0
  75. data/lib/lighthouse/support/model_resolver.rb +7 -5
  76. data/lib/lighthouse/support/preload.rb +49 -0
  77. data/lib/lighthouse/type.rb +103 -0
  78. data/lib/lighthouse/type_resolution.rb +150 -0
  79. data/lib/lighthouse/upload.rb +26 -0
  80. data/lib/lighthouse/upload_store.rb +32 -0
  81. data/sig/lighthouse/directives.rbs +3 -2
  82. data/sig/lighthouse.rbs +18 -0
  83. metadata +38 -1
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Lighthouse::GraphQL::Directives::Arguments
6
+ # @whereNull — `true` keeps the rows whose column is null, `false` those whose
7
+ # column is not. `posts(unpublished: Boolean @whereNull(key: "published_at"))`
8
+ class WhereNull < Base
9
+ def self.directive_name = 'whereNull'
10
+
11
+ def self.definition
12
+ <<~GRAPHQL.chomp
13
+ """
14
+ Filter the value is null: true keeps the rows where the column is null, false the others.
15
+ """
16
+ directive @whereNull(
17
+ "The database column. Defaults to the argument's name."
18
+ key: String
19
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
20
+ GRAPHQL
21
+ end
22
+
23
+ def handle_builder(relation, value)
24
+ return relation if value.nil?
25
+
26
+ value ? relation.where(column => nil) : relation.where.not(column => nil)
27
+ end
28
+ end
29
+
30
+ # @whereNotNull — the inverse: `true` keeps the rows whose column is not null.
31
+ class WhereNotNull < Base
32
+ def self.directive_name = 'whereNotNull'
33
+
34
+ def self.definition
35
+ <<~GRAPHQL.chomp
36
+ """
37
+ Filter the value is not null: true keeps the rows where the column is not null, false the others.
38
+ """
39
+ directive @whereNotNull(
40
+ "The database column. Defaults to the argument's name."
41
+ key: String
42
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
43
+ GRAPHQL
44
+ end
45
+
46
+ def handle_builder(relation, value)
47
+ return relation if value.nil?
48
+
49
+ value ? relation.where.not(column => nil) : relation.where(column => nil)
50
+ end
51
+ end
52
+
53
+ # @whereBetween — the column lies between two values: an input object's two
54
+ # fields, in order, or a list of two. `createdAt: DateRange @whereBetween(key: "created_at")`
55
+ class WhereBetween < Base
56
+ def self.directive_name = 'whereBetween'
57
+
58
+ def self.definition
59
+ <<~GRAPHQL.chomp
60
+ """
61
+ Verify that a column's value is between two values: the argument's two input fields, in order.
62
+ """
63
+ directive @whereBetween(
64
+ "The database column. Defaults to the argument's name."
65
+ key: String
66
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
67
+ GRAPHQL
68
+ end
69
+
70
+ def handle_builder(relation, value)
71
+ from, to = bounds(value)
72
+ return relation if from.nil? && to.nil?
73
+
74
+ relation.where(column => from..to)
75
+ end
76
+
77
+ private
78
+
79
+ def bounds(value)
80
+ values = value.respond_to?(:to_h) && !value.is_a?(Array) ? value.to_h.values : Array(value)
81
+ values.first(2)
82
+ end
83
+ end
84
+
85
+ # @whereNotBetween — the column lies outside two values.
86
+ class WhereNotBetween < WhereBetween
87
+ def self.directive_name = 'whereNotBetween'
88
+
89
+ def self.definition
90
+ <<~GRAPHQL.chomp
91
+ """
92
+ Verify that a column's value lies outside two values: the argument's two input fields, in order.
93
+ """
94
+ directive @whereNotBetween(
95
+ "The database column. Defaults to the argument's name."
96
+ key: String
97
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
98
+ GRAPHQL
99
+ end
100
+
101
+ def handle_builder(relation, value)
102
+ from, to = bounds(value)
103
+ return relation if from.nil? && to.nil?
104
+
105
+ relation.where.not(column => from..to)
106
+ end
107
+ end
108
+
109
+ # @whereJsonContains — a JSON column contains the value (Laravel's
110
+ # whereJsonContains), on PostgreSQL (@>), MySQL (JSON_CONTAINS) and SQLite
111
+ # (json_each). `key:` may point into the document: "options->tags".
112
+ class WhereJsonContains < Base
113
+ def self.directive_name = 'whereJsonContains'
114
+
115
+ def self.definition
116
+ <<~GRAPHQL.chomp
117
+ """
118
+ Use an input value as a whereJsonContains filter.
119
+ """
120
+ directive @whereJsonContains(
121
+ "The JSON column, optionally with a path into it: \\"options->tags\\". Defaults to the argument's name."
122
+ key: String
123
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
124
+ GRAPHQL
125
+ end
126
+
127
+ def handle_builder(relation, value)
128
+ return relation if value.nil?
129
+
130
+ connection = relation.connection
131
+ name, *path = column.to_s.split('->')
132
+ target = "#{connection.quote_table_name(relation.klass.table_name)}.#{connection.quote_column_name(name)}"
133
+ relation.where(*condition(connection.adapter_name, target, path, value))
134
+ end
135
+
136
+ private
137
+
138
+ def condition(adapter, target, path, value)
139
+ json_path = "$#{path.map { |segment| ".#{segment}" }.join}"
140
+ case adapter
141
+ when /postg/i then ["(#{target}#{path.map { |segment| "->'#{segment}'" }.join})::jsonb @> ?::jsonb", value.to_json]
142
+ when /mysql|trilogy/i then ["JSON_CONTAINS(#{target}, ?, ?)", value.to_json, json_path]
143
+ else sqlite(target, json_path, value)
144
+ end
145
+ end
146
+
147
+ # Every value of a list must be in the array, as JSON containment means.
148
+ def sqlite(target, json_path, value)
149
+ values = Array(value)
150
+ sql = values.map { "EXISTS (SELECT 1 FROM json_each(#{target}, ?) WHERE json_each.value = ?)" }.join(' AND ')
151
+ [sql, *values.flat_map { |item| [json_path, item] }]
152
+ end
153
+ end
154
+
155
+ # @limit — the argument directive behind LimitResolver. Only `builder: true`
156
+ # makes it one: a LIMIT on the field's query. Without it, LimitResolver cuts
157
+ # the results instead.
158
+ class Limit < Base
159
+ def self.directive_name = 'limit'
160
+
161
+ def self.definition
162
+ <<~GRAPHQL.chomp
163
+ """
164
+ Allow clients to specify the maximum number of results to return when used on an argument,
165
+ or statically limit them when used on a field.
166
+
167
+ By default, this directive does not influence the number of results the resolver queries internally,
168
+ but limits how much of it is returned to clients. Use the `builder` argument to change this.
169
+ """
170
+ directive @limit(
171
+ """
172
+ You may set this to `true` if the field uses a query builder,
173
+ then this directive will apply a LIMIT clause to it.
174
+ Typically, this option should only be used for root fields,
175
+ as it may cause wrong results with batched relation queries.
176
+ """
177
+ builder: Boolean! = false
178
+ ) on ARGUMENT_DEFINITION | FIELD_DEFINITION
179
+ GRAPHQL
180
+ end
181
+
182
+ def handle_builder(relation, value)
183
+ return relation unless value.is_a?(Integer) && Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'builder')
184
+
185
+ relation.limit(value)
186
+ end
187
+ end
188
+
189
+ # @scope — call a model scope with the client's value.
190
+ # `posts(trending: Boolean @scope)` calls Post.trending(value); `name:` names another scope.
191
+ class Scope < Base
192
+ def self.directive_name = 'scope'
193
+
194
+ def self.definition
195
+ <<~GRAPHQL.chomp
196
+ """
197
+ Adds a scope to the query builder.
198
+ The scope method will receive the client-given value of the argument as its argument.
199
+ """
200
+ directive @scope(
201
+ "The name of the scope. Defaults to the name of the argument."
202
+ name: String
203
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
204
+ GRAPHQL
205
+ end
206
+
207
+ def handle_builder(relation, value)
208
+ return relation if value.nil?
209
+
210
+ relation.public_send(
211
+ Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'name') || Lighthouse::Support::Naming.attribute(@arg_node.name), value
212
+ )
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The SDL of the directives no registered resolver class contributes, and of
4
+ # the types they refer to: the field-level query directives (@builder,
5
+ # @whereAuth), the argument directives, the type-system ones (@union, @node,
6
+ # @model, ...), and those the schema reads while it is built. The registry
7
+ # injects each into a schema that uses it without declaring it.
8
+ module Lighthouse::GraphQL::Directives::Definitions
9
+ # Directives that only mark the SDL for others to read.
10
+ PASSIVE = {
11
+ 'nest' => 'directive @nest on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION',
12
+ 'spread' => 'directive @spread on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION',
13
+ 'inject' => 'directive @inject(context: String!, name: String!) repeatable on FIELD_DEFINITION',
14
+ 'cacheKey' => 'directive @cacheKey on FIELD_DEFINITION'
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ # { name => "directive @name ..." }
20
+ def directives
21
+ graphql = Lighthouse::GraphQL
22
+ [
23
+ graphql::DirectiveResolvers::BuilderApplier::DEFINITIONS,
24
+ graphql::DirectiveResolvers::ArgumentTransformsResolver::DEFINITIONS,
25
+ PASSIVE,
26
+ { 'cacheControl' => graphql::CacheControl::DEFINITION, 'feature' => graphql::FeatureDirective::DEFINITION,
27
+ 'complexity' => graphql::Complexity::DEFINITION, 'scalar' => graphql::ScalarCoercion::DEFINITION,
28
+ 'enum' => graphql::EnumDirective::DEFINITION },
29
+ through_aliases,
30
+ graphql::TypeResolution::DEFINITIONS,
31
+ graphql::Directives::NodeDirective::DEFINITIONS,
32
+ graphql::Directives::ModelDirective::DEFINITIONS,
33
+ graphql::Directives::VisibilityDirective::DEFINITIONS,
34
+ graphql::Directives::WhereConditionsDirective::DEFINITIONS,
35
+ graphql::Directives::SoftDeletesDirective::DEFINITIONS
36
+ ].reduce(:merge)
37
+ end
38
+
39
+ # @hasManyThrough and @hasOneThrough: @hasMany's and @hasOne's definitions, renamed.
40
+ def through_aliases
41
+ resolvers = Lighthouse::GraphQL::DirectiveResolvers
42
+ { 'hasManyThrough' => resolvers::HasManyDirectiveResolver, 'hasOneThrough' => resolvers::HasOneDirectiveResolver }
43
+ .to_h { |name, klass| [name, klass.definition.sub("@#{klass.directive_name}(", "@#{name}(")] }
44
+ end
45
+
46
+ # { type name => its SDL }, for the types the directives above refer to.
47
+ def types
48
+ graphql = Lighthouse::GraphQL
49
+ [graphql::CacheControl::TYPE_DEFINITIONS, graphql::FeatureDirective::TYPE_DEFINITIONS, graphql::EnumDirective::TYPE_DEFINITIONS,
50
+ graphql::DirectiveResolvers::BuilderApplier::TYPE_DEFINITIONS].reduce(:merge)
51
+ end
52
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @model(class: "Post") — map an object type to the ActiveRecord model whose
4
+ # records it resolves, as PHP Lighthouse's directive, for when the type's name
5
+ # is not the model's:
6
+ #
7
+ # type Article @model(class: "Post") { id: ID! title: String! }
8
+ #
9
+ # type Query {
10
+ # articles: [Article!]! @paginate # pages through Post
11
+ # article(id: ID! @eq): Article @find # Post.find_by(id:)
12
+ # }
13
+ #
14
+ # - Every directive that takes `model:` (@all, @find, @first, @paginate, @create,
15
+ # @update, @upsert, @delete, @can*, ...) on a field returning the type gets it,
16
+ # unless it names its own: the SDL is rewritten before the schema is built.
17
+ # - A union or interface resolves a record of the model to the type.
18
+ # - @node finds the type's records in the model.
19
+ # - The schema does not build unless the class is an ActiveRecord model, and a
20
+ # type class's methods (Types::ArticleType) only run on its records.
21
+ class Lighthouse::GraphQL::Directives::ModelDirective
22
+ DEFINITIONS = {
23
+ 'model' => <<~GRAPHQL.chomp
24
+ """
25
+ Map a model class to an object type.
26
+ This can be used when the name of the model differs from the name of the type.
27
+ """
28
+ directive @model(
29
+ "The class name of the corresponding model."
30
+ class: String!
31
+ ) on OBJECT
32
+ GRAPHQL
33
+ }.freeze
34
+
35
+ OBJECTS = [::GraphQL::Language::Nodes::ObjectTypeDefinition, ::GraphQL::Language::Nodes::ObjectTypeExtension].freeze
36
+
37
+ DECLARATION = /directive\s+@model\b/
38
+ USAGE = /@model\b/
39
+
40
+ def self.manipulate(sdl)
41
+ return sdl unless sdl.is_a?(String) && sdl.gsub(DECLARATION, '').match?(USAGE)
42
+
43
+ new(::GraphQL::Language::Parser.parse(sdl)).manipulate(sdl)
44
+ rescue ::GraphQL::ParseError
45
+ sdl
46
+ end
47
+
48
+ # The model class name a schema type maps to with @model, or nil.
49
+ def self.class_name(type)
50
+ directive = (type.ast_node&.directives || []).find { |d| d.name == 'model' }
51
+ directive && Lighthouse::Support::DirectiveArgs.literal(directive, 'class')
52
+ end
53
+
54
+ # The model of a schema type: the one @model names, else the one named after it.
55
+ def self.model_for(type)
56
+ Lighthouse::Support::ModelResolver.constantize(class_name(type) || type.graphql_name)
57
+ end
58
+
59
+ # The model @model declares on a schema type, or nil.
60
+ def self.declared_model(type)
61
+ name = class_name(type)
62
+ name && Lighthouse::Support::ModelResolver.constantize(name)
63
+ end
64
+
65
+ # Fail at build time: every @model names an ActiveRecord model.
66
+ def self.validate!(schema)
67
+ schema.types.each_value do |type|
68
+ name = type.kind.object? && class_name(type)
69
+ next unless name
70
+
71
+ model = declared_model(type)
72
+ next if model.is_a?(Class) && defined?(::ActiveRecord::Base) && model < ::ActiveRecord::Base
73
+
74
+ raise Lighthouse::DefinitionError, "@model(class: #{name.inspect}) on #{type.graphql_name} names no ActiveRecord model."
75
+ end
76
+ end
77
+
78
+ # The directives that take `model:` and act on a field, from the registry.
79
+ def self.model_directives
80
+ Lighthouse::DirectiveRegistry.default.directive_definitions
81
+ .select { |_, definition| definition.match?(/\bmodel:/) && definition.include?('FIELD_DEFINITION') }.keys
82
+ end
83
+
84
+ def initialize(document)
85
+ @document = document
86
+ @models = document.definitions.filter_map { |definition| [definition.name, model_name(definition)] if object?(definition) }
87
+ .select(&:last).to_h
88
+ @directives = self.class.model_directives
89
+ end
90
+
91
+ def manipulate(sdl)
92
+ return sdl if @models.empty?
93
+
94
+ definitions = @document.definitions.map { |definition| object?(definition) ? with_models(definition) : definition }
95
+ ::GraphQL::Language::Printer.new.print(@document.merge(definitions: definitions))
96
+ end
97
+
98
+ private
99
+
100
+ def object?(definition)
101
+ OBJECTS.any? { |kind| definition.is_a?(kind) }
102
+ end
103
+
104
+ # The class @model names on a type definition, or nil.
105
+ def model_name(definition)
106
+ directive = definition.directives.find { |d| d.name == 'model' }
107
+ directive&.arguments&.find { |argument| argument.name == 'class' }&.value
108
+ end
109
+
110
+ def with_models(definition)
111
+ definition.merge(fields: definition.fields.map { |field| with_model(field) })
112
+ end
113
+
114
+ # The field's model directives with `model:` set to the model its return type maps to.
115
+ def with_model(field)
116
+ model = @models[unwrap(field.type).name]
117
+ return field unless model
118
+
119
+ field.merge(directives: field.directives.map { |directive| with_model_argument(directive, model) })
120
+ end
121
+
122
+ def with_model_argument(directive, model)
123
+ return directive unless @directives.include?(directive.name) && directive.arguments.none? { |argument| argument.name == 'model' }
124
+
125
+ directive.merge(arguments: directive.arguments + [::GraphQL::Language::Nodes::Argument.new(name: 'model', value: model)])
126
+ end
127
+
128
+ def unwrap(type)
129
+ type.respond_to?(:of_type) ? unwrap(type.of_type) : type
130
+ end
131
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ # @node — Relay's global object identification, as PHP Lighthouse's directive.
4
+ # On a type, it makes the schema:
5
+ #
6
+ # "Any object implementing this type can be found by ID through `Query.node`."
7
+ # interface Node @interface(resolveType: "Lighthouse::GraphQL::NodeRegistry") {
8
+ # "Global identifier that can be used to resolve any Node implementation."
9
+ # id: ID!
10
+ # }
11
+ #
12
+ # type User implements Node @node { id: ID! @globalId ... }
13
+ #
14
+ # type Query {
15
+ # node(id: ID! @globalId): Node @field(resolver: "Lighthouse::GraphQL::NodeRegistry@resolve")
16
+ # }
17
+ #
18
+ # leaving alone what the SDL declares itself (a Node interface, a Query.node
19
+ # field, with its own description or middleware). The type keeps its own
20
+ # `id: ID! @globalId`, which encodes the ids `node` decodes. NodeRegistry finds
21
+ # the record: through the type's `model:` or model, or its `resolver:`.
22
+ class Lighthouse::GraphQL::Directives::NodeDirective
23
+ DEFINITIONS = {
24
+ 'node' => <<~GRAPHQL.chomp
25
+ """
26
+ Register a type for Relay's global object identification.
27
+
28
+ When used without any arguments, Lighthouse will attempt
29
+ to resolve the type through a model with the same name.
30
+ """
31
+ directive @node(
32
+ """
33
+ Reference to a class (and method, after `@`) that receives the decoded `id`
34
+ and the context, and returns the object.
35
+
36
+ Mutually exclusive with `model`.
37
+ """
38
+ resolver: String
39
+
40
+ """
41
+ Specify the class name of the model to use.
42
+ This is only needed when the default model detection does not work.
43
+
44
+ Mutually exclusive with `resolver`.
45
+ """
46
+ model: String
47
+ ) on OBJECT
48
+ GRAPHQL
49
+ }.freeze
50
+
51
+ INTERFACE = <<~GRAPHQL.chomp
52
+ "Any object implementing this type can be found by ID through `Query.node`."
53
+ interface Node @interface(resolveType: "Lighthouse::GraphQL::NodeRegistry") {
54
+ "Global identifier that can be used to resolve any Node implementation."
55
+ id: ID!
56
+ }
57
+ GRAPHQL
58
+
59
+ NODE_FIELD = <<~GRAPHQL.chomp
60
+ type Query {
61
+ "Fetches an object given its globally unique `ID`."
62
+ node(id: ID! @globalId): Node @field(resolver: "Lighthouse::GraphQL::NodeRegistry@resolve")
63
+ }
64
+ GRAPHQL
65
+
66
+ OBJECTS = [::GraphQL::Language::Nodes::ObjectTypeDefinition, ::GraphQL::Language::Nodes::ObjectTypeExtension].freeze
67
+
68
+ DECLARATION = /directive\s+@node\b/
69
+ USAGE = /@node\b/
70
+
71
+ def self.manipulate(sdl)
72
+ return sdl unless sdl.is_a?(String) && sdl.gsub(DECLARATION, '').match?(USAGE)
73
+
74
+ new(::GraphQL::Language::Parser.parse(sdl)).manipulate(sdl)
75
+ rescue ::GraphQL::ParseError
76
+ sdl
77
+ end
78
+
79
+ def initialize(document)
80
+ @document = document
81
+ end
82
+
83
+ def manipulate(sdl)
84
+ return sdl unless @document.definitions.any? { |definition| node?(definition) }
85
+
86
+ definitions = @document.definitions.map { |definition| node?(definition) ? implement_node(definition) : definition }
87
+ definitions = with_node_field(definitions)
88
+ printed = ::GraphQL::Language::Printer.new.print(@document.merge(definitions: definitions))
89
+ printed = "#{printed}\n\n#{INTERFACE}" unless declared?('Node')
90
+ # The added @globalId, @field and @interface need their definitions too.
91
+ Lighthouse::GraphQL::SchemaGenerator.inject_directive_definitions(printed)
92
+ end
93
+
94
+ private
95
+
96
+ def node?(definition)
97
+ OBJECTS.any? { |kind| definition.is_a?(kind) } && definition.directives.any? { |directive| directive.name == 'node' }
98
+ end
99
+
100
+ def implement_node(definition)
101
+ return definition if definition.interfaces.any? { |interface| interface.name == 'Node' }
102
+
103
+ definition.merge(interfaces: definition.interfaces + [::GraphQL::Language::Nodes::TypeName.new(name: 'Node')])
104
+ end
105
+
106
+ # Query gains `node`, unless a Query definition or extension has one. With
107
+ # no Query at all, the schema gets one with `node` alone.
108
+ def with_node_field(definitions)
109
+ queries = definitions.select { |definition| query?(definition) }
110
+ return definitions if queries.any? { |query| node_field?(query) }
111
+
112
+ node_query = ::GraphQL::Language::Parser.parse(NODE_FIELD).definitions.first
113
+ return definitions + [node_query] if queries.empty?
114
+
115
+ definitions.map { |definition| definition.equal?(queries.first) ? definition.merge(fields: definition.fields + node_query.fields) : definition }
116
+ end
117
+
118
+ def node_field?(query)
119
+ query.fields.any? { |field| field.name == 'node' }
120
+ end
121
+
122
+ def query?(definition)
123
+ OBJECTS.any? { |kind| definition.is_a?(kind) } && definition.name == query_name
124
+ end
125
+
126
+ def query_name
127
+ schema = @document.definitions.find { |definition| definition.is_a?(::GraphQL::Language::Nodes::SchemaDefinition) }
128
+ schema&.query || 'Query'
129
+ end
130
+
131
+ def declared?(name)
132
+ @document.definitions.any? { |definition| definition.respond_to?(:name) && definition.name == name && !definition.is_a?(::GraphQL::Language::Nodes::ObjectTypeExtension) }
133
+ end
134
+ end
@@ -16,7 +16,7 @@ require_relative 'pagination_info_types'
16
16
  # relation directive (no `type:`) and @all keep returning the list.
17
17
  # The runtime side is Lighthouse::GraphQL::Pagination.
18
18
  class Lighthouse::GraphQL::Directives::PaginationManipulator
19
- RELATION_DIRECTIVES = %w[hasMany belongsToMany morphMany morphToMany].freeze
19
+ RELATION_DIRECTIVES = %w[hasMany hasManyThrough belongsToMany morphMany morphToMany].freeze
20
20
  OBJECT_DEFINITIONS = [
21
21
  ::GraphQL::Language::Nodes::ObjectTypeDefinition,
22
22
  ::GraphQL::Language::Nodes::ObjectTypeExtension
@@ -61,13 +61,17 @@ class Lighthouse::DirectiveRegistry
61
61
  # authorization, argument validation, then @cache (so a cache hit is still
62
62
  # authorized and validated), @clearCache (which acts once the resolver
63
63
  # succeeded), @event and @broadcast (likewise), and finally the data resolver (0).
64
+ # @throttle counts every call, before anything else runs; @async queues what is
65
+ # below @cache, once the request has authorized and validated the call.
64
66
  RESOLUTION_ORDER = {
67
+ 'throttle' => 7,
65
68
  'convertEmptyStringsToNull' => 6,
66
69
  'guard' => 5, 'auth' => 5,
67
70
  'canFind' => 4, 'canModel' => 4, 'canQuery' => 4, 'canResolved' => 4, 'canRoot' => 4,
68
71
  'validator' => 3,
69
- 'cache' => 2,
70
- 'clearCache' => 1, 'event' => 1, 'broadcast' => 1
72
+ 'cache' => 2, 'async' => 2,
73
+ 'clearCache' => 1, 'event' => 1, 'broadcast' => 1,
74
+ 'with' => 1, 'withCount' => 1, 'lazyLoad' => 1, 'globalId' => 1
71
75
  }.freeze
72
76
 
73
77
  def resolution_order(name)
@@ -164,6 +168,8 @@ class Lighthouse::DirectiveRegistry
164
168
  'rename' => :RenameDirectiveResolver,
165
169
  'method' => :MethodDirectiveResolver,
166
170
  'auth' => :AuthDirectiveResolver,
171
+ 'hasManyThrough' => :HasManyDirectiveResolver,
172
+ 'hasOneThrough' => :HasOneDirectiveResolver,
167
173
  'guard' => :GuardDirectiveResolver
168
174
  }.freeze
169
175
 
@@ -181,17 +187,15 @@ class Lighthouse::DirectiveRegistry
181
187
  CacheDirectiveResolver ClearCacheDirectiveResolver EventDirectiveResolver
182
188
  ValidatorDirectiveResolver ConvertEmptyStringsToNullDirectiveResolver
183
189
  SubscriptionDirectiveResolver BroadcastDirectiveResolver
190
+ WithDirectiveResolver WithCountDirectiveResolver LazyLoadDirectiveResolver
191
+ GlobalIdDirectiveResolver NamespacedDirectiveResolver ThrottleDirectiveResolver AsyncDirectiveResolver
184
192
  ].freeze
185
193
 
186
194
  # Argument-builder filter/sort directives.
187
- ARG_BUILDERS = %i[Eq Neq In NotIn Like Where WhereKey OrderBy Trashed WithoutGlobalScopes].freeze
188
-
189
- # Read by other directives rather than resolving anything themselves.
190
- PASSIVE_DEFINITIONS = {
191
- 'spread' => 'directive @spread on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION',
192
- 'inject' => 'directive @inject(context: String!, name: String!) repeatable on FIELD_DEFINITION',
193
- 'cacheKey' => 'directive @cacheKey on FIELD_DEFINITION'
194
- }.freeze
195
+ ARG_BUILDERS = %i[
196
+ Eq Neq In NotIn Like Where WhereKey OrderBy Trashed WithoutGlobalScopes
197
+ WhereNull WhereNotNull WhereBetween WhereNotBetween WhereJsonContains Scope Limit
198
+ ].freeze
195
199
 
196
200
  def register_defaults
197
201
  resolvers = Lighthouse::GraphQL::DirectiveResolvers
@@ -200,9 +204,14 @@ class Lighthouse::DirectiveRegistry
200
204
  ARG_BUILDERS.each { |constant| register(Lighthouse::GraphQL::Directives::Arguments.const_get(constant, false)) }
201
205
 
202
206
  # SDL manipulators run in registration order during the manipulation phase.
207
+ # @hide / @show first, so a removed field generates nothing; then @model,
208
+ # which names the model of fields @paginate then rewrites.
209
+ register_manipulator(Lighthouse::GraphQL::Directives::VisibilityDirective)
210
+ register_manipulator(Lighthouse::GraphQL::Directives::ModelDirective)
203
211
  register_manipulator(Lighthouse::GraphQL::Directives::PaginationManipulator)
204
212
  register_manipulator(Lighthouse::GraphQL::Directives::WhereConditionsDirective)
205
213
  register_manipulator(Lighthouse::GraphQL::Directives::SoftDeletesDirective)
214
+ register_manipulator(Lighthouse::GraphQL::Directives::NodeDirective)
206
215
 
207
216
  register_default_definitions
208
217
  self
@@ -213,17 +222,8 @@ class Lighthouse::DirectiveRegistry
213
222
  # SDL of the directives (and the types they refer to) that no registered
214
223
  # class contributes.
215
224
  def register_default_definitions
216
- register_definition('builder', Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier::DEFINITION)
217
- PASSIVE_DEFINITIONS.each { |name, definition| register_definition(name, definition) }
218
- register_definition('cacheControl', Lighthouse::GraphQL::CacheControl::DEFINITION)
219
- Lighthouse::GraphQL::CacheControl::TYPE_DEFINITIONS.each { |name, sdl| register_type_definition(name, sdl) }
220
- register_definition('feature', Lighthouse::GraphQL::FeatureDirective::DEFINITION)
221
- register_definition('complexity', Lighthouse::GraphQL::Complexity::DEFINITION)
222
- Lighthouse::GraphQL::FeatureDirective::TYPE_DEFINITIONS.each { |name, sdl| register_type_definition(name, sdl) }
223
- Lighthouse::GraphQL::Directives::WhereConditionsDirective::DEFINITIONS.each do |name, definition|
224
- register_definition(name, definition)
225
- end
226
- Lighthouse::GraphQL::Directives::SoftDeletesDirective::DEFINITIONS.each { |name, definition| register_definition(name, definition) }
225
+ Lighthouse::GraphQL::Directives::Definitions.directives.each { |name, definition| register_definition(name, definition) }
226
+ Lighthouse::GraphQL::Directives::Definitions.types.each { |name, sdl| register_type_definition(name, sdl) }
227
227
  end
228
228
 
229
229
  def safe_definition(klass)