lighthouse-graphql 0.3.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -1
  3. data/README.md +18 -7
  4. data/docs/best-practices.md +16 -6
  5. data/docs/configuration.md +33 -8
  6. data/docs/directives.md +318 -10
  7. data/docs/events.md +3 -1
  8. data/docs/federation.md +3 -2
  9. data/docs/generators.md +82 -0
  10. data/docs/getting-started.md +66 -32
  11. data/docs/motivation.md +3 -2
  12. data/docs/relationships.md +42 -0
  13. data/docs/relay.md +137 -0
  14. data/docs/resolvers.md +338 -25
  15. data/docs/security.md +227 -0
  16. data/docs/subscriptions.md +60 -37
  17. data/docs/types.md +219 -0
  18. data/docs/uploads.md +80 -0
  19. data/lib/generators/lighthouse/base_classes.rb +70 -0
  20. data/lib/generators/lighthouse/class_generator.rb +19 -8
  21. data/lib/generators/lighthouse/install/install_generator.rb +96 -20
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -2
  24. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  25. data/lib/generators/lighthouse/query_generator.rb +5 -4
  26. data/lib/generators/lighthouse/subscription_generator.rb +21 -16
  27. data/lib/lighthouse/async_mutation_job.rb +17 -0
  28. data/lib/lighthouse/complexity.rb +175 -0
  29. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  30. data/lib/lighthouse/config.rb +16 -4
  31. data/lib/lighthouse/controller.rb +27 -9
  32. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  33. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  34. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  36. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  37. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  38. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  39. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  40. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  41. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  42. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  43. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
  44. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
  45. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  46. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  47. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  48. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  49. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  50. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  51. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  52. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  53. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  54. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  55. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  56. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +14 -5
  57. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  58. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  59. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  60. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  61. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  62. data/lib/lighthouse/directives/definitions.rb +52 -0
  63. data/lib/lighthouse/directives/model_directive.rb +131 -0
  64. data/lib/lighthouse/directives/node_directive.rb +134 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  66. data/lib/lighthouse/directives/registry.rb +21 -20
  67. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  68. data/lib/lighthouse/enum_directive.rb +41 -0
  69. data/lib/lighthouse/events/execution_trace.rb +2 -1
  70. data/lib/lighthouse/events/lifecycle.rb +3 -1
  71. data/lib/lighthouse/field_resolver_stack.rb +59 -11
  72. data/lib/lighthouse/generated_resolvers.rb +13 -8
  73. data/lib/lighthouse/global_id.rb +31 -0
  74. data/lib/lighthouse/graphql/version.rb +1 -1
  75. data/lib/lighthouse/multipart_request.rb +43 -0
  76. data/lib/lighthouse/node_registry.rb +68 -0
  77. data/lib/lighthouse/pagination.rb +3 -0
  78. data/lib/lighthouse/rb_lighthouse.rb +36 -0
  79. data/lib/lighthouse/relation_counts.rb +29 -0
  80. data/lib/lighthouse/resolve_info.rb +80 -0
  81. data/lib/lighthouse/resolver_bases.rb +68 -0
  82. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  83. data/lib/lighthouse/scalar_coercion.rb +72 -0
  84. data/lib/lighthouse/schema_cache.rb +2 -1
  85. data/lib/lighthouse/schema_factory.rb +0 -1
  86. data/lib/lighthouse/schema_generator.rb +21 -12
  87. data/lib/lighthouse/schema_implementation.rb +4 -8
  88. data/lib/lighthouse/sdl_loader.rb +0 -2
  89. data/lib/lighthouse/support/environment.rb +13 -0
  90. data/lib/lighthouse/support/model_resolver.rb +8 -6
  91. data/lib/lighthouse/support/preload.rb +49 -0
  92. data/lib/lighthouse/type.rb +103 -0
  93. data/lib/lighthouse/type_resolution.rb +150 -0
  94. data/lib/lighthouse/upload.rb +26 -0
  95. data/lib/lighthouse/upload_store.rb +32 -0
  96. data/sig/lighthouse/directives.rbs +3 -2
  97. data/sig/lighthouse/graphql.rbs +29 -0
  98. data/sig/lighthouse.rbs +18 -0
  99. metadata +43 -1
@@ -12,7 +12,8 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
12
12
  raise NotImplementedError
13
13
  end
14
14
 
15
- def apply_where_filters(relation, args)
15
+ # `ctx`: the query's context, for @builder on an argument.
16
+ def apply_where_filters(relation, args, ctx = nil)
16
17
  return relation unless relation.respond_to?(:where)
17
18
 
18
19
  args_h = args.respond_to?(:to_h) ? args.to_h : (args || {})
@@ -22,7 +23,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
22
23
  relation = apply_where_has_conditions(relation, arg_node, args_h)
23
24
  end
24
25
 
25
- apply_arg_builders(relation, args_h)
26
+ apply_arg_builders(relation, args_h, ctx)
26
27
  rescue ::GraphQL::ExecutionError
27
28
  raise
28
29
  rescue StandardError => e
@@ -33,16 +34,13 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
33
34
  # Compose argument-builder directives (@eq, @neq, @in, @notIn, @like,
34
35
  # @where, @orderBy) onto the relation, in SDL argument order, for each
35
36
  # field argument that carries one and that the client supplied a value
36
- # for.
37
- def apply_arg_builders(relation, args_h)
37
+ # for — and for the input fields of an argument carrying @nest.
38
+ def apply_arg_builders(relation, args_h, ctx = nil)
38
39
  return relation unless relation.respond_to?(:where)
39
40
  return relation unless @field_def.ast_node
40
41
 
41
- arg_builders.each do |arg_node, dnode, builder_class|
42
- value = Lighthouse::Support::Naming.fetch_arg(args_h, arg_node.name)
43
- next if value.nil?
44
-
45
- relation = builder_class.new(arg_node, dnode).handle_builder(relation, value)
42
+ arg_builders.each do |path, arg_node, dnode, builder_class|
43
+ relation = apply_arg_builder(relation, args_h, ctx, [path, arg_node, dnode, builder_class])
46
44
  end
47
45
 
48
46
  relation
@@ -136,13 +134,64 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
136
134
  end
137
135
 
138
136
  # Every (argument, directive, builder class) on the field, in SDL order.
137
+ # One argument directive: @builder whenever the client sent the argument,
138
+ # null included (as PHP Lighthouse's); the others when its value is not null.
139
+ def apply_arg_builder(relation, args_h, ctx, (path, arg_node, dnode, builder_class))
140
+ if builder_class == :builder
141
+ return relation unless arg_present?(args_h, path)
142
+
143
+ return Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply_argument(relation, dnode, dig_arg(args_h, path), ctx)
144
+ end
145
+
146
+ value = dig_arg(args_h, path)
147
+ value.nil? ? relation : builder_class.new(arg_node, dnode).handle_builder(relation, value)
148
+ end
149
+
150
+ # Did the client send the argument at `path`, even as null?
151
+ def arg_present?(args_h, path)
152
+ parent = path.size == 1 ? args_h : dig_arg(args_h, path[0...-1])
153
+ hash = parent.respond_to?(:to_h) && !parent.is_a?(Array) ? parent.to_h : parent
154
+ hash.is_a?(Hash) && Lighthouse::Support::Naming.candidate_keys(path.last).any? { |key| hash.key?(key) }
155
+ end
156
+
157
+ # [path, node, directive, builder class] for every argument-builder
158
+ # directive, in SDL order. An argument carrying @nest adds its input type's
159
+ # fields', at any depth, with their path into the value.
139
160
  def arg_builders
140
- registry = Lighthouse::DirectiveRegistry.default
141
161
  (@field_def.ast_node.arguments || []).flat_map do |arg_node|
142
- (arg_node.directives || []).filter_map do |dnode|
143
- builder_class = registry.arg_builder_class(dnode.name)
144
- [arg_node, dnode, builder_class] if builder_class
145
- end
162
+ builders_of(arg_node, [arg_node.name], @field_def.arguments[arg_node.name])
163
+ end
164
+ end
165
+
166
+ def builders_of(node, path, argument)
167
+ registry = Lighthouse::DirectiveRegistry.default
168
+ own = (node.directives || []).filter_map do |dnode|
169
+ builder_class = dnode.name == 'builder' ? :builder : registry.arg_builder_class(dnode.name)
170
+ [path, node, dnode, builder_class] if builder_class
171
+ end
172
+ own + (argument && nest?(node) ? nested_builders(argument, path) : [])
173
+ end
174
+
175
+ def nest?(node)
176
+ (node.directives || []).any? { |d| d.name == 'nest' }
177
+ end
178
+
179
+ def nested_builders(argument, path)
180
+ type = argument.type.unwrap
181
+ return [] unless type.kind.input_object?
182
+
183
+ type.arguments.each_value.flat_map do |field|
184
+ field.ast_node ? builders_of(field.ast_node, path + [field.ast_node.name], field) : []
185
+ end
186
+ end
187
+
188
+ # The client's value at `path` (an argument, then input fields), or nil.
189
+ def dig_arg(args_h, path)
190
+ path.reduce(args_h) do |value, name|
191
+ hash = value.respond_to?(:to_h) && !value.is_a?(Array) ? value.to_h : value
192
+ break nil unless hash.is_a?(Hash)
193
+
194
+ Lighthouse::Support::Naming.fetch_arg(hash, name)
146
195
  end
147
196
  end
148
197
  end
@@ -2,39 +2,99 @@
2
2
 
3
3
  require_relative 'base'
4
4
 
5
+ # The field-level query directives, in SDL order: @builder classes, and
6
+ # @whereAuth, which keeps the records the signed-in user owns. @builder on an
7
+ # argument is applied with the other argument directives (apply_argument).
8
+ #
9
+ # A builder is `class:` and `method:` (default `call`), or PHP Lighthouse's
10
+ # `method: "Class@method"`, looked up in Config.namespaces[:builders]. It gets
11
+ # the relation, a second value and the context, and returns the relation:
12
+ #
13
+ # on a field (relation, args, context), or the directive's `value:` instead of args
14
+ # on an argument (relation, the argument's value, context), whenever the client sends
15
+ # the argument, null included
5
16
  class Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier
6
- DEFINITION = 'directive @builder(class: String!, method: String) repeatable on FIELD_DEFINITION'
17
+ DEFINITION = <<~GRAPHQL.chomp
18
+ """
19
+ Manipulate the query builder with a method.
20
+ """
21
+ directive @builder(
22
+ "The builder class, in the builders namespace."
23
+ class: String
24
+ "The method to call: a name (default `call`), or a class and a method separated by `@`."
25
+ method: String
26
+ "On a field, the value the method gets after the relation, instead of the arguments."
27
+ value: BuilderValue
28
+ ) repeatable on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION
29
+ GRAPHQL
30
+
31
+ TYPE_DEFINITIONS = {
32
+ 'BuilderValue' => <<~GRAPHQL.chomp
33
+ "Any constant literal value: https://graphql.github.io/graphql-spec/draft/#sec-Input-Values"
34
+ scalar BuilderValue
35
+ GRAPHQL
36
+ }.freeze
37
+
38
+ WHERE_AUTH_DEFINITION = <<~GRAPHQL.chomp
39
+ """
40
+ Filter a type to only return instances owned by the current user.
41
+ """
42
+ directive @whereAuth(
43
+ "The relation of the model that links it to the user: a belongs_to, a has_many, a :through."
44
+ relation: String!
45
+ "Accepted for PHP Lighthouse parity: the user is Config.authenticated_user's."
46
+ guards: [String!]
47
+ ) on FIELD_DEFINITION
48
+ GRAPHQL
49
+
50
+ DEFINITIONS = { 'builder' => DEFINITION, 'whereAuth' => WHERE_AUTH_DEFINITION }.freeze
7
51
 
8
52
  def self.apply(relation, field_def_ast_node, args, ctx, resolver_instance)
9
53
  return relation unless field_def_ast_node&.directives
10
54
 
11
- builder_directives = field_def_ast_node.directives.select { |d| d.name == 'builder' }
12
- return relation if builder_directives.empty?
13
-
14
- builder_directives.each do |directive_node|
15
- relation = apply_directive(relation, directive_node, args, ctx, resolver_instance)
55
+ field_def_ast_node.directives.each do |directive_node|
56
+ case directive_node.name
57
+ when 'builder' then relation = apply_directive(relation, directive_node, args, ctx, resolver_instance)
58
+ when 'whereAuth' then relation = where_auth(relation, directive_node, ctx)
59
+ end
16
60
  end
17
61
 
18
62
  relation
19
63
  end
20
64
 
65
+ # The records whose `relation:` links them to the signed-in user; none for a guest.
66
+ def self.where_auth(relation, directive_node, ctx)
67
+ user = Lighthouse::Support::Authorization.authenticated_user(ctx)
68
+ return relation.none unless user
69
+
70
+ model = relation.klass
71
+ name = extract_directive_arg(directive_node, 'relation').to_sym
72
+ reflection = model.reflect_on_association(name)
73
+ raise Lighthouse::DefinitionError, "@whereAuth: #{model} has no relation #{name}." unless reflection
74
+ return relation.where(name => user) if reflection.belongs_to?
75
+
76
+ owned = model.unscoped.joins(name).where(reflection.klass.table_name => { reflection.klass.primary_key => user.id })
77
+ relation.where(model.primary_key => owned.select(model.arel_table[model.primary_key]))
78
+ end
79
+
21
80
  def self.apply_directive(relation, directive_node, args, ctx, _resolver_instance)
22
- class_name = extract_directive_arg(directive_node, 'class')
23
- method_name = extract_directive_arg(directive_node, 'method') || 'call'
81
+ value = directive_node.arguments&.any? { |a| a.name == 'value' } ? extract_directive_arg(directive_node, 'value') : args
82
+ call_builder(relation, directive_node, value, ctx)
83
+ end
24
84
 
25
- return relation unless class_name
85
+ # @builder on an argument the client sent: the method gets its value.
86
+ def self.apply_argument(relation, directive_node, value, ctx)
87
+ call_builder(relation, directive_node, value, ctx)
88
+ end
26
89
 
27
- # Builders are not resolvers: they compose onto a relation, and live in
28
- # their own namespace (Config.namespaces[:builders], app/graphql/builders).
29
- builder_class = Lighthouse::Support::ModelResolver.constantize(
30
- class_name, namespaces: Lighthouse::Config.namespaces[:builders]
31
- )
90
+ def self.call_builder(relation, directive_node, value, ctx)
91
+ builder_class, method_name = reference(directive_node)
32
92
  return relation unless builder_class
33
93
 
34
94
  receiver = builder_receiver(builder_class, method_name)
35
95
  return relation unless receiver
36
96
 
37
- receiver.public_send(method_name, relation, args, ctx) || relation
97
+ receiver.public_send(method_name, relation, value, ctx) || relation
38
98
  rescue ::GraphQL::ExecutionError
39
99
  # Client-facing errors (e.g. authorization / scoping guards raised by a
40
100
  # builder) MUST propagate — never silently fall back to the unscoped
@@ -45,11 +105,21 @@ class Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier
45
105
  relation
46
106
  end
47
107
 
48
- def self.extract_directive_arg(directive_node, name)
49
- arg = directive_node.arguments&.find { |a| a.name == name }
50
- return nil unless arg
108
+ # [builder class, method name] from `class:` and `method:`, or from
109
+ # `method: "Class@method"`. Builders are not resolvers: they live in their
110
+ # own namespace (Config.namespaces[:builders], app/graphql/builders).
111
+ def self.reference(directive_node)
112
+ class_name = extract_directive_arg(directive_node, 'class')
113
+ method_name = extract_directive_arg(directive_node, 'method')
114
+ class_name, method_name = method_name.to_s.split('@', 2) if class_name.nil? && method_name
115
+ return [nil, nil] unless class_name
116
+
117
+ builder_class = Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[:builders])
118
+ [builder_class, method_name.presence || 'call']
119
+ end
51
120
 
52
- arg.value.respond_to?(:value) ? arg.value.value : arg.value
121
+ def self.extract_directive_arg(directive_node, name)
122
+ Lighthouse::Support::DirectiveArgs.literal(directive_node, name)
53
123
  end
54
124
 
55
125
  # What answers the builder method: the class itself, else an instance
@@ -136,7 +136,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
136
136
 
137
137
  # The records the field's argument filters (@eq, @where, ...) select.
138
138
  def authorize_before(_obj, args, ctx)
139
- authorize_each!(ctx, apply_where_filters(scoped(model_class.all), args).to_a, args)
139
+ authorize_each!(ctx, apply_where_filters(scoped(model_class.all), args, ctx).to_a, args)
140
140
  end
141
141
  end
142
142
 
@@ -4,17 +4,21 @@ require_relative 'base'
4
4
 
5
5
  # Resolves a Query or Mutation field that has no resolving directive by
6
6
  # convention, as PHP Lighthouse does: `Query.latestPosts` resolves to
7
- # `Queries::LatestPosts` (a GraphQL::Schema::Resolver), `Mutation.publishPost`
8
- # to `Mutations::PublishPost` (a GraphQL::Schema::Mutation), looked up in
7
+ # `Resolvers::LatestPosts` (a GraphQL::Schema::Resolver, by graphql-ruby's
8
+ # convention a Resolvers::BaseResolver), `Mutation.publishPost` to
9
+ # `Mutations::PublishPost` (a GraphQL::Schema::Mutation), looked up in
9
10
  # Config.namespaces[:queries] / [:mutations] at call time so code reloading
10
11
  # keeps working. Not a directive: SchemaGenerator installs it.
11
12
  class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
12
- def initialize(field_def, type_def, kind)
13
+ # `base`: the class the schema's base_types require the class to inherit from.
14
+ def initialize(field_def, type_def, kind, base = nil)
13
15
  super(field_def, type_def, nil)
14
16
  @kind = kind
17
+ @base = base
15
18
  end
16
19
 
17
20
  def define_resolver(target_type_class)
21
+ Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
18
22
  me = self
19
23
  inner = capture_inner(target_type_class)
20
24
 
@@ -31,6 +35,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
31
35
  ROOT_KINDS[type.respond_to?(:graphql_name) ? type.graphql_name : type.to_s]
32
36
  end
33
37
 
38
+ # The class a root field resolves to, without its namespace: `LatestPosts`
39
+ # for `latestPosts`.
40
+ def self.class_name_for(field_def)
41
+ field_def.name.to_s.camelize
42
+ end
43
+
34
44
  private
35
45
 
36
46
  def resolve_by_convention(inner, obj, args, ctx)
@@ -43,8 +53,9 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
43
53
  "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Resolver " \
44
54
  '(or GraphQL::Schema::Mutation).'
45
55
  end
56
+ Lighthouse::GraphQL::ResolverBases.check!(klass, @base, location)
46
57
 
47
- Lighthouse::GraphQL::ResolverDispatch.call(klass, @field_def, obj, args, ctx)
58
+ Lighthouse::GraphQL::ResolverDispatch.call(klass, Lighthouse::GraphQL::ResolveInfo.for(@field_def, @type_def, obj, args, ctx))
48
59
  end
49
60
 
50
61
  # No class: the root object may still answer (a custom root_value);
@@ -61,7 +72,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
61
72
  end
62
73
 
63
74
  def class_name
64
- @class_name ||= @field_def.name.to_s.camelize
75
+ @class_name ||= self.class.class_name_for(@field_def)
65
76
  end
66
77
 
67
78
  def location
@@ -19,7 +19,7 @@ require_relative 'base'
19
19
  # carried the directive.
20
20
  #
21
21
  # The conversion is a wrapper around the field's resolver (outermost, order
22
- # 6), so every kind of resolver — directives, @field, Queries::/Mutations::
22
+ # 6), so every kind of resolver — directives, @field, Resolvers::/Mutations::
23
23
  # classes, the default reader — and @validator see the converted arguments,
24
24
  # and other fields sharing an input type are unaffected. SchemaGenerator
25
25
  # installs it too, without the directive on the field, when an argument or
@@ -44,7 +44,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::CountDirectiveResolver < Lighthou
44
44
  args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
45
45
  relation = apply_scopes(relation)
46
46
  relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args_h, ctx, self)
47
- relation = apply_where_filters(relation, args_h)
47
+ relation = apply_where_filters(relation, args_h, ctx)
48
48
  relation.count
49
49
  end
50
50
  end
@@ -4,21 +4,49 @@ require_relative 'base'
4
4
 
5
5
  class Lighthouse::GraphQL::DirectiveResolvers::FieldDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
6
6
  def self.directive_name = 'field'
7
- def self.definition = 'directive @field(resolver: String, class: String) on FIELD_DEFINITION'
8
7
 
9
- # On Query and Mutation the class is looked up in the queries / mutations
10
- # namespaces; on any other type, in Config.resolver_namespaces.
8
+ def self.definition
9
+ <<~GRAPHQL.chomp
10
+ """
11
+ Assign a resolver to a field: a class, or a class and a method separated by `@`
12
+ ("Statistics@users_total"). Without a method, the class resolves, or is called.
13
+ """
14
+ directive @field(resolver: String, class: String) on FIELD_DEFINITION
15
+ GRAPHQL
16
+ end
17
+
18
+ # ["Statistics", "users_total"] for "Statistics@users_total"; [name, nil] without `@`.
19
+ def self.reference(resolver)
20
+ class_name, method = resolver.to_s.split('@', 2)
21
+ [class_name, method.presence]
22
+ end
23
+
24
+ # The class @field names. On Query and Mutation (`kind` :queries / :mutations)
25
+ # it is looked up in that kind's namespaces, nested names included
26
+ # (`Feeds::Home` is Resolvers::Feeds::Home), then as written. On any other
27
+ # type, in the resolvers namespace first (`Posts::TagsCount` is
28
+ # Resolvers::Posts::TagsCount), then in Config.resolver_namespaces. Looked up
29
+ # at call time, so code reloading keeps working.
30
+ def self.lookup(class_name, kind)
31
+ resolver = Lighthouse::Support::ModelResolver
32
+ return resolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[kind], nested: true) if kind
33
+
34
+ resolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[:queries], nested: true, fallback: false) ||
35
+ resolver.constantize(class_name, namespaces: Lighthouse::Config.resolver_namespaces)
36
+ end
37
+
11
38
  def define_resolver(target_type_class)
39
+ Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
12
40
  field_def = @field_def
13
- class_name = extract_directive_arg('resolver') || extract_directive_arg('class')
41
+ class_name, method = self.class.reference(extract_directive_arg('resolver') || extract_directive_arg('class'))
14
42
  kind = root_kind(target_type_class)
15
43
 
16
44
  install(target_type_class, lambda do |obj, args, ctx|
17
- namespaces = kind ? Lighthouse::Config.namespaces[kind] : Lighthouse::Config.resolver_namespaces
18
- klass = Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: namespaces)
45
+ klass = Lighthouse::GraphQL::DirectiveResolvers::FieldDirectiveResolver.lookup(class_name, kind)
19
46
  next nil unless klass
20
47
 
21
- Lighthouse::GraphQL::ResolverDispatch.call(klass, field_def, obj, args, ctx)
48
+ info = Lighthouse::GraphQL::ResolveInfo.for(field_def, target_type_class, obj, args, ctx)
49
+ Lighthouse::GraphQL::ResolverDispatch.call(klass, info, method: method)
22
50
  end)
23
51
  end
24
52
 
@@ -23,7 +23,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::FirstDirectiveResolver < Lighthou
23
23
  args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
24
24
  relation = resolver_instance.apply_scopes(model_class.all)
25
25
  relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, field_ast_node, args_h, ctx, resolver_instance)
26
- relation = resolver_instance.apply_where_filters(relation, args_h)
26
+ relation = resolver_instance.apply_where_filters(relation, args_h, ctx)
27
27
  relation.first
28
28
  end
29
29
 
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @globalId — Relay global ids, as PHP Lighthouse's directive: on a field it
6
+ # encodes the value with the type's name (`id: ID! @globalId` answers
7
+ # "VXNlcjox" for User 1); on an argument or input field it decodes what the
8
+ # client sent (ArgumentTransformsResolver). Config.global_id does the encoding.
9
+ class Lighthouse::GraphQL::DirectiveResolvers::GlobalIdDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
10
+ NAME = 'globalId'
11
+
12
+ def self.directive_name = NAME
13
+
14
+ def self.definition
15
+ <<~GRAPHQL.chomp
16
+ """
17
+ Converts between IDs/types and global IDs.
18
+
19
+ When used upon a field, it encodes;
20
+ when used upon an argument, it decodes.
21
+ """
22
+ directive @globalId(
23
+ """
24
+ Decoding a global id produces a tuple of `$type` and `$id`.
25
+ This setting controls which of those is passed along.
26
+ """
27
+ decode: GlobalIdDecode = ARRAY
28
+ ) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION | ARGUMENT_DEFINITION
29
+ GRAPHQL
30
+ end
31
+
32
+ def self.type_definitions
33
+ {
34
+ 'GlobalIdDecode' => <<~GRAPHQL.chomp
35
+ "Options for the `decode` argument of `@globalId`."
36
+ enum GlobalIdDecode {
37
+ "Return an array of `[$type, $id]`."
38
+ ARRAY
39
+ "Return just `$type`."
40
+ TYPE
41
+ "Return just `$id`."
42
+ ID
43
+ }
44
+ GRAPHQL
45
+ }
46
+ end
47
+
48
+ def define_resolver(target_type_class)
49
+ inner = capture_inner(target_type_class)
50
+ type_name = target_type_class.graphql_name
51
+
52
+ install(target_type_class, lambda do |obj, args, ctx|
53
+ id = inner.call(obj, args, ctx)
54
+ id.nil? ? nil : Lighthouse::Config.global_id.encode(type_name, id)
55
+ end)
56
+ end
57
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @lazyLoad(relations: ["replies"]) — eager-load relations of the records a field
6
+ # returns, as PHP Lighthouse's @lazyLoad does, so the fields the client selects on
7
+ # them do not query once per record. Works on a list, a relation, and a @paginate
8
+ # page; across every parent in the response at once, so a @hasMany's records for
9
+ # all its parents load their relations together.
10
+ #
11
+ # type Post {
12
+ # comments: [Comment!]! @hasMany @lazyLoad(relations: ["replies", "author"])
13
+ # }
14
+ class Lighthouse::GraphQL::DirectiveResolvers::LazyLoadDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
15
+ def self.directive_name = 'lazyLoad'
16
+
17
+ def self.definition
18
+ <<~GRAPHQL.chomp
19
+ """
20
+ Eager-load relations of the records the field returns.
21
+ """
22
+ directive @lazyLoad(
23
+ "The relations to load, nested with dots."
24
+ relations: [String!]!
25
+ ) repeatable on FIELD_DEFINITION
26
+ GRAPHQL
27
+ end
28
+
29
+ def define_resolver(target_type_class)
30
+ inner = capture_inner(target_type_class)
31
+ associations = Lighthouse::Support::Preload.associations(directive_arg('relations'))
32
+
33
+ install(target_type_class, lambda do |obj, args, ctx|
34
+ Lighthouse::GraphQL::DirectiveResolvers::LazyLoadDirectiveResolver.load(inner.call(obj, args, ctx), associations, ctx)
35
+ end)
36
+ end
37
+
38
+ # `result` with `associations` loaded into its records.
39
+ def self.load(result, associations, ctx)
40
+ return result.preload(*associations) if unloaded_relation?(result)
41
+
42
+ records = records_of(result).grep(::ActiveRecord::Base)
43
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::PreloadLoader, associations, []).load_all(records) if records.any?
44
+ result
45
+ end
46
+
47
+ # A query not run yet takes the preload itself. An association's records (what
48
+ # @hasMany returns, loaded for every parent at once) or a loaded relation
49
+ # are records already: they load their relations together, in the Dataloader.
50
+ def self.unloaded_relation?(result)
51
+ result.is_a?(::ActiveRecord::Relation) && !result.is_a?(::ActiveRecord::Associations::CollectionProxy) && !result.loaded?
52
+ end
53
+
54
+ def self.records_of(result)
55
+ case result
56
+ when Lighthouse::GraphQL::Pagination::LazyPage then result.items
57
+ when Hash then Array(result['data'] || result[:data])
58
+ else Array(result)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @limit on an argument — the client caps how many results the field returns,
6
+ # as PHP Lighthouse's directive: `posts(limit: Int @limit): [Post!]! @all`. The
7
+ # argument must be an Int; null leaves the results alone. A query not run yet
8
+ # gets a LIMIT; a list already loaded (a @hasMany's records, loaded for every
9
+ # parent at once) is cut in memory, so one parent's limit never cuts another's.
10
+ # With `builder: true` it is a LIMIT on the field's query instead, through the
11
+ # argument directives (Arguments::Limit): for root fields, as PHP Lighthouse
12
+ # warns, since a relation batched for many parents would be cut as a whole.
13
+ # PHP Lighthouse also allows @limit on the field, where it marks the field the
14
+ # argument limits; it is accepted there and changes nothing. Not a directive
15
+ # resolver: FieldResolverStack installs it on a field whose arguments carry
16
+ # @limit, around whatever resolves the field.
17
+ class Lighthouse::GraphQL::DirectiveResolvers::LimitResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
18
+ # The keyword of the argument whose @limit cuts the results, or nil; every
19
+ # argument carrying @limit must be an Int.
20
+ def self.keyword(field_def)
21
+ limiting = field_def.arguments.each_value.select { |argument| directive(argument) }
22
+ limiting.each { |argument| validate!(field_def, argument) }
23
+ limiting.find { |argument| !Lighthouse::Support::DirectiveArgs.literal(directive(argument), 'builder') }&.keyword
24
+ end
25
+
26
+ def self.directive(argument)
27
+ (argument.ast_node&.directives || []).find { |d| d.name == 'limit' }
28
+ end
29
+
30
+ def self.validate!(field_def, argument)
31
+ return if argument.type.unwrap.graphql_name == 'Int'
32
+
33
+ raise Lighthouse::DefinitionError,
34
+ "The @limit directive must only be used on arguments of type Int, got #{field_def.name}.#{argument.graphql_name} of type " \
35
+ "#{argument.type.unwrap.graphql_name}."
36
+ end
37
+
38
+ def define_resolver(target_type_class)
39
+ keyword = self.class.keyword(@field_def)
40
+ inner = capture_inner(target_type_class)
41
+
42
+ install(target_type_class, lambda do |obj, args, ctx|
43
+ limit = args.is_a?(Hash) ? args[keyword] : nil
44
+ result = inner.call(obj, args, ctx)
45
+ limit.nil? ? result : Lighthouse::GraphQL::DirectiveResolvers::LimitResolver.cut(result, limit)
46
+ end)
47
+ end
48
+
49
+ def self.cut(result, limit)
50
+ raise ::GraphQL::ExecutionError, "The limit must be positive, got #{limit}." if limit.negative?
51
+
52
+ case result
53
+ when ::ActiveRecord::Associations::CollectionProxy then result.to_a.first(limit)
54
+ when ::ActiveRecord::Relation then result.loaded? ? result.to_a.first(limit) : result.limit(limit)
55
+ when Array then result.first(limit)
56
+ else result
57
+ end
58
+ end
59
+ end
@@ -8,6 +8,12 @@ require_relative 'base'
8
8
  #
9
9
  # labels: [String!]! @method(name: "label_list")
10
10
  # canReply: Boolean @method(name: "can_reply?")
11
+ # excerpt(length: Int = 80): String @method
12
+ #
13
+ # The field's arguments reach the method: as snake_case keywords when it takes
14
+ # keywords (`def excerpt(length:)`), else positionally in the order the SDL
15
+ # declares them, as PHP Lighthouse passes them (`def excerpt(length)`). A method
16
+ # taking no parameters is called without them.
11
17
  class Lighthouse::GraphQL::DirectiveResolvers::MethodDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
12
18
  def self.directive_name = 'method'
13
19
  def self.definition = 'directive @method(name: String) on FIELD_DEFINITION'
@@ -17,13 +23,42 @@ class Lighthouse::GraphQL::DirectiveResolvers::MethodDirectiveResolver < Lightho
17
23
  target_method = (Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'name') ||
18
24
  Lighthouse::Support::Naming.attribute(@field_def.name)).to_s
19
25
 
20
- resolver = lambda do |obj, _arguments, _ctx|
21
- next nil if obj.nil?
26
+ field_def = @field_def
27
+ resolver = lambda do |obj, arguments, ctx|
28
+ next nil if obj.nil? || !obj.respond_to?(target_method)
22
29
 
23
- obj.respond_to?(target_method) ? obj.public_send(target_method) : nil
30
+ Lighthouse::GraphQL::DirectiveResolvers::MethodDirectiveResolver.call_method(obj.method(target_method), field_def, arguments, ctx)
24
31
  end
25
32
 
26
33
  target_type_class.define_singleton_method(resolver_method, &resolver)
27
34
  @field_def.resolve_proc = resolver if @field_def.respond_to?(:resolve_proc=)
28
35
  end
36
+
37
+ KEYWORDS = %i[key keyreq keyrest].freeze
38
+ NAMED_KEYWORDS = %i[key keyreq].freeze
39
+
40
+ # Call `method` with the field's arguments, as the method takes them.
41
+ def self.call_method(method, field_def, arguments, ctx)
42
+ params = method.parameters
43
+ return method.call if field_def.arguments.empty? || params.empty?
44
+
45
+ values = Lighthouse::Support::Arguments.prepare(field_def, arguments, ctx)
46
+ return method.call(**keywords(params, values)) if params.any? { |type, _| KEYWORDS.include?(type) }
47
+
48
+ method.call(*positional(params, field_def, values))
49
+ end
50
+
51
+ # The values in the order the SDL declares the arguments, as many as the
52
+ # method takes; all of them when it takes *rest.
53
+ def self.positional(params, field_def, values)
54
+ ordered = field_def.arguments.each_value.map { |arg| values[arg.keyword] }
55
+ params.any? { |type, _| type == :rest } ? ordered : ordered.first(params.size)
56
+ end
57
+
58
+ # The values the method's keywords name; all of them when it takes **keywords.
59
+ def self.keywords(params, values)
60
+ return values if params.any? { |type, _| type == :keyrest }
61
+
62
+ values.slice(*params.filter_map { |type, name| name if NAMED_KEYWORDS.include?(type) })
63
+ end
29
64
  end
@@ -196,7 +196,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
196
196
  args = Lighthouse::Support::Arguments.shallow(args)
197
197
  raise ::GraphQL::ExecutionError, WOULD_MODIFY_ALL unless filtered?(args)
198
198
 
199
- records = apply_where_filters(records_of(model), args).to_a
199
+ records = apply_where_filters(records_of(model), args, ctx).to_a
200
200
  records.each do |record|
201
201
  next if modify(record)
202
202