lighthouse-graphql 0.4.0 → 0.6.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 (93) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +256 -1
  3. data/README.md +26 -15
  4. data/docs/caching.md +4 -3
  5. data/docs/configuration.md +16 -12
  6. data/docs/directives.md +294 -4
  7. data/docs/generators.md +84 -0
  8. data/docs/getting-started.md +93 -60
  9. data/docs/performance.md +2 -2
  10. data/docs/relationships.md +47 -4
  11. data/docs/relay.md +200 -0
  12. data/docs/resolvers.md +326 -34
  13. data/docs/security.md +73 -8
  14. data/docs/subscriptions.md +16 -9
  15. data/docs/types.md +218 -0
  16. data/docs/uploads.md +107 -0
  17. data/lib/generators/lighthouse/base_classes.rb +27 -49
  18. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +125 -45
  20. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +10 -8
  21. data/lib/lighthouse/async_mutation_job.rb +17 -0
  22. data/lib/lighthouse/caching.rb +9 -3
  23. data/lib/lighthouse/complexity.rb +5 -4
  24. data/lib/lighthouse/config.rb +12 -18
  25. data/lib/lighthouse/controller.rb +53 -12
  26. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  27. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  28. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  29. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  30. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  31. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  32. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  33. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  34. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  35. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  36. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  37. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  38. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +2 -1
  39. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  40. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  41. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  42. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +59 -0
  43. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  44. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  45. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  46. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  47. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  48. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  49. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  50. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +2 -1
  51. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +136 -0
  52. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +87 -0
  53. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  54. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  55. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  56. data/lib/lighthouse/directives/definitions.rb +52 -0
  57. data/lib/lighthouse/directives/model_directive.rb +131 -0
  58. data/lib/lighthouse/directives/node_directive.rb +134 -0
  59. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  60. data/lib/lighthouse/directives/registry.rb +21 -21
  61. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  62. data/lib/lighthouse/enum_directive.rb +41 -0
  63. data/lib/lighthouse/field_resolver_stack.rb +56 -6
  64. data/lib/lighthouse/generated_resolvers.rb +13 -8
  65. data/lib/lighthouse/global_id.rb +57 -0
  66. data/lib/lighthouse/graphql/version.rb +1 -1
  67. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  68. data/lib/lighthouse/multipart_request.rb +43 -0
  69. data/lib/lighthouse/node_registry.rb +68 -0
  70. data/lib/lighthouse/pagination.rb +3 -0
  71. data/lib/lighthouse/rb_lighthouse.rb +34 -0
  72. data/lib/lighthouse/relation_counts.rb +29 -0
  73. data/lib/lighthouse/resolve_info.rb +80 -0
  74. data/lib/lighthouse/resolver_bases.rb +25 -4
  75. data/lib/lighthouse/resolver_dispatch.rb +51 -11
  76. data/lib/lighthouse/root_types.rb +117 -0
  77. data/lib/lighthouse/scalar_coercion.rb +72 -0
  78. data/lib/lighthouse/schema_cache.rb +2 -1
  79. data/lib/lighthouse/schema_generator.rb +13 -11
  80. data/lib/lighthouse/schema_implementation.rb +4 -8
  81. data/lib/lighthouse/support/environment.rb +13 -0
  82. data/lib/lighthouse/support/model_resolver.rb +7 -5
  83. data/lib/lighthouse/support/preload.rb +49 -0
  84. data/lib/lighthouse/type.rb +103 -0
  85. data/lib/lighthouse/type_resolution.rb +150 -0
  86. data/lib/lighthouse/upload.rb +28 -0
  87. data/lib/lighthouse/upload_store.rb +57 -0
  88. data/sig/lighthouse/directives.rbs +3 -2
  89. data/sig/lighthouse.rbs +10 -2
  90. metadata +40 -4
  91. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -22
  92. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  93. data/lib/generators/lighthouse/query_generator.rb +0 -30
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @withCount(relation: "posts", scopes: [...]) — count a relation of the parent
6
+ # before the field resolves, as PHP Lighthouse's @withCount does: the count is not
7
+ # the field's value, it is kept on the parent as `<relation>_count`
8
+ # (`user.posts_count`) for whatever resolves the field to read. Every parent in
9
+ # the response is counted in one query.
10
+ #
11
+ # type User {
12
+ # activity: String! @withCount(relation: "posts") @method(name: "activity_label")
13
+ # postsCount: Int! @withCount(relation: "posts") # the default reader reads posts_count
14
+ # }
15
+ class Lighthouse::GraphQL::DirectiveResolvers::WithCountDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ def self.directive_name = 'withCount'
17
+
18
+ def self.definition
19
+ <<~GRAPHQL.chomp
20
+ """
21
+ Count a relation of the parent before the field resolves, for every parent at once,
22
+ and keep the count on it as `<relation>_count`.
23
+ """
24
+ directive @withCount(
25
+ "The relation to count."
26
+ relation: String!
27
+ "Model scopes that narrow the counted records."
28
+ scopes: [String!]
29
+ ) repeatable on FIELD_DEFINITION
30
+ GRAPHQL
31
+ end
32
+
33
+ def define_resolver(target_type_class)
34
+ inner = capture_inner(target_type_class)
35
+ relation = directive_arg('relation')
36
+ scopes = Array(directive_arg('scopes'))
37
+
38
+ install(target_type_class, lambda do |obj, args, ctx|
39
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::RelationCountsLoader, relation, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
40
+ inner.call(obj, args, ctx)
41
+ end)
42
+ end
43
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @with(relation: "tasks", scopes: [...]) — eager-load a relation of the parent
6
+ # before the field resolves, as PHP Lighthouse's @with does: for a field computed
7
+ # from it (a @method, a type class method, the default reader of a method that
8
+ # reads it), so it is loaded once for every parent in the response, not once per
9
+ # parent. `relation` defaults to the field's (snake_case) name, and may be nested
10
+ # ("tasks.assignee"); `scopes` narrow the loaded records.
11
+ #
12
+ # type User {
13
+ # taskSummary: String! @with(relation: "tasks") @method(name: "task_summary")
14
+ # }
15
+ class Lighthouse::GraphQL::DirectiveResolvers::WithDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ def self.directive_name = 'with'
17
+
18
+ def self.definition
19
+ <<~GRAPHQL.chomp
20
+ """
21
+ Eager-load a relation of the parent before the field resolves, for every parent at once.
22
+ """
23
+ directive @with(
24
+ "The relation to load, nested with dots. Defaults to the field's name."
25
+ relation: String
26
+ "Model scopes that narrow the loaded records."
27
+ scopes: [String!]
28
+ ) repeatable on FIELD_DEFINITION
29
+ GRAPHQL
30
+ end
31
+
32
+ def define_resolver(target_type_class)
33
+ inner = capture_inner(target_type_class)
34
+ associations = Lighthouse::Support::Preload.associations(directive_arg('relation') || Lighthouse::Support::Naming.attribute(@field_def.name))
35
+ scopes = Array(directive_arg('scopes'))
36
+
37
+ install(target_type_class, lambda do |obj, args, ctx|
38
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::PreloadLoader, associations, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
39
+ inner.call(obj, args, ctx)
40
+ end)
41
+ end
42
+ end
@@ -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