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,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # Rewrites the arguments a field receives, before it resolves, as PHP
6
+ # Lighthouse's argument directives do. On an argument or an input field, in the
7
+ # order the SDL lists them:
8
+ #
9
+ # @globalId(decode:) a Relay global id, decoded ([type, id], the type or the id)
10
+ # @trim leading and trailing whitespace removed
11
+ # @hash the value hashed with BCrypt, for a password column
12
+ # @drop the value removed: the resolver never sees it
13
+ # @bind(class:) the value replaced by the record(s) it identifies
14
+ # @upload the uploaded file stored (Config.upload_store), its blob's signed id passed
15
+ #
16
+ # type Mutation {
17
+ # signUp(email: String! @trim, password: String! @trim @hash, captcha: String @drop): User! @create
18
+ # assign(task: ID! @bind(class: "Task"), user: ID! @bind(class: "User")): Task! @field(resolver: "Assign")
19
+ # }
20
+ #
21
+ # @trim on a field trims every string it takes, nested inputs included. Lists
22
+ # and input objects are walked; input objects that change are copied, never
23
+ # mutated. Not a directive resolver: FieldResolverStack installs it twice, as
24
+ # two phases. :rewrite (@globalId, @trim, @drop) is outermost, so @guard, the
25
+ # @can* family and @validator see the rewritten values. :effects (@hash, @bind,
26
+ # @upload) runs once those have let the call through, around whatever resolves
27
+ # the field: a validator sees the password before it is hashed, and nothing is
28
+ # stored or looked up for a call that is refused.
29
+ class Lighthouse::GraphQL::DirectiveResolvers::ArgumentTransformsResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
30
+ PHASES = { rewrite: %w[globalId trim drop], effects: %w[hash bind upload] }.freeze
31
+ NAMES = PHASES.values.flatten.freeze
32
+ DROP = Object.new.freeze
33
+
34
+ DEFINITIONS = {
35
+ 'trim' => <<~GRAPHQL.chomp,
36
+ """
37
+ Remove whitespace from the beginning and end of a given input.
38
+ On a field, of every string input it takes.
39
+ """
40
+ directive @trim on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION
41
+ GRAPHQL
42
+ 'hash' => <<~GRAPHQL.chomp,
43
+ """
44
+ Hash an argument value with BCrypt (has_secure_password's), e.g. a password.
45
+ """
46
+ directive @hash on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
47
+ GRAPHQL
48
+ 'drop' => <<~GRAPHQL.chomp,
49
+ """
50
+ Ignore the user given value, don't pass it to the resolver.
51
+ """
52
+ directive @drop on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
53
+ GRAPHQL
54
+ 'bind' => <<~GRAPHQL.chomp,
55
+ """
56
+ Replace argument values with the corresponding model (or some other value)
57
+ before passing them to the resolver.
58
+ """
59
+ directive @bind(
60
+ "A model class, or a class whose `call(value, context)` returns what the value stands for."
61
+ class: String!
62
+ "The column the value is matched against, for a model."
63
+ column: String! = "id"
64
+ "Relations to eager-load on the bound records."
65
+ with: [String!]! = []
66
+ "Pass null instead of failing when no record matches."
67
+ optional: Boolean! = false
68
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
69
+ GRAPHQL
70
+ 'upload' => <<~GRAPHQL.chomp
71
+ """
72
+ Store the file an Upload argument carries, and pass along what Config.upload_store
73
+ answers instead: by default, the signed id of an ActiveStorage blob, which an
74
+ attachment attribute (has_one_attached) attaches.
75
+ """
76
+ directive @upload(
77
+ "The storage service to store the file on (an ActiveStorage service name). Defaults to the default one."
78
+ disk: String
79
+ "A prefix for the stored file's key."
80
+ path: String! = "/"
81
+ "Accepted for PHP Lighthouse parity: whether files are public is the service's setting."
82
+ public: Boolean! = false
83
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
84
+ GRAPHQL
85
+ }.freeze
86
+
87
+ # Does the field take anything to rewrite in `phase`: @trim on the field
88
+ # itself, or an argument at any depth of input types carrying one of the
89
+ # phase's directives?
90
+ def self.transforms?(field_def, phase)
91
+ (phase == :rewrite && trims_all?(field_def)) || carried?(field_def.arguments, PHASES.fetch(phase))
92
+ end
93
+
94
+ def self.trims_all?(field_def)
95
+ (field_def.ast_node&.directives || []).any? { |d| d.name == 'trim' }
96
+ end
97
+
98
+ def self.carried?(argument_defs, names, seen = Set.new)
99
+ argument_defs.each_value.any? do |argument|
100
+ type = argument.type.unwrap
101
+ directives(argument, names).any? || (type.kind.input_object? && seen.add?(type.graphql_name) && carried?(type.arguments, names, seen))
102
+ end
103
+ end
104
+
105
+ def self.directives(argument, names = NAMES)
106
+ (argument.ast_node&.directives || []).select { |d| names.include?(d.name) }
107
+ end
108
+
109
+ def define_resolver(target_type_class)
110
+ me = self
111
+ argument_defs = @field_def.arguments
112
+ trim_all = phase == :rewrite && self.class.trims_all?(@field_def)
113
+ inner = capture_inner(target_type_class)
114
+
115
+ install(target_type_class, lambda do |obj, args, ctx|
116
+ inner.call(obj, args.is_a?(Hash) ? me.transform_hash(argument_defs, args, ctx, trim_all) : args, ctx)
117
+ end)
118
+ end
119
+
120
+ # The values with every argument's directives applied; a dropped one removed.
121
+ def transform_hash(argument_defs, values, ctx, trim_all)
122
+ by_keyword = Lighthouse::Support::Arguments.keyword_index(argument_defs)
123
+ values.each_with_object({}) do |(key, value), result|
124
+ transformed = transform_argument(by_keyword[key.to_sym], value, ctx, trim_all)
125
+ result[key] = transformed unless transformed.equal?(DROP)
126
+ end
127
+ end
128
+
129
+ private
130
+
131
+ def phase
132
+ @opts.fetch(:phase, :rewrite)
133
+ end
134
+
135
+ def transform_argument(argument, value, ctx, trim_all)
136
+ return value if argument.nil?
137
+
138
+ value = nested(argument.type, value, ctx, trim_all)
139
+ value = trim(value) if trim_all
140
+ self.class.directives(argument, PHASES.fetch(phase)).reduce(value) do |current, directive|
141
+ break DROP if current.equal?(DROP)
142
+
143
+ apply(directive, argument, current, ctx)
144
+ end
145
+ end
146
+
147
+ # Each directive's transform, a method taking (directive, argument, value, context).
148
+ TRANSFORMS = { 'globalId' => :decode_global_id, 'trim' => :trim_value, 'hash' => :hash_value, 'bind' => :bind, 'upload' => :upload }.freeze
149
+
150
+ def apply(directive, argument, value, ctx)
151
+ return DROP if directive.name == 'drop'
152
+ return value if value.nil?
153
+
154
+ send(TRANSFORMS.fetch(directive.name), directive, argument, value, ctx)
155
+ end
156
+
157
+ def decode_global_id(directive, _argument, value, _ctx)
158
+ decode(value, Lighthouse::Support::DirectiveArgs.literal(directive, 'decode') || 'ARRAY')
159
+ end
160
+
161
+ def trim_value(_directive, _argument, value, _ctx) = trim(value)
162
+
163
+ def hash_value(_directive, _argument, value, _ctx) = hash(value)
164
+
165
+ def upload(directive, argument, value, ctx)
166
+ return value.map { |item| upload(directive, argument, item, ctx) } if value.is_a?(Array)
167
+
168
+ options = %w[disk path public].to_h { |name| [name.to_sym, Lighthouse::Support::DirectiveArgs.literal(directive, name)] }
169
+ Lighthouse::Config.upload_store.call(value, **options.compact)
170
+ end
171
+
172
+ def decode(value, mode)
173
+ return value.map { |item| decode(item, mode) } if value.is_a?(Array)
174
+
175
+ type, id = Lighthouse::GraphQL::GlobalId.decode!(value)
176
+ { 'TYPE' => type, 'ID' => id }.fetch(mode.to_s, [type, id])
177
+ end
178
+
179
+ def trim(value)
180
+ case value
181
+ when String then value.strip
182
+ when Array then value.map { |item| trim(item) }
183
+ else value
184
+ end
185
+ end
186
+
187
+ def hash(value)
188
+ return value.map { |item| hash(item) } if value.is_a?(Array)
189
+
190
+ begin
191
+ require 'bcrypt'
192
+ rescue LoadError
193
+ raise Lighthouse::DefinitionError, '@hash needs the bcrypt gem (gem "bcrypt", as has_secure_password does).'
194
+ end
195
+ BCrypt::Password.create(value.to_s).to_s
196
+ end
197
+
198
+ def bind(directive, argument, value, ctx)
199
+ Lighthouse::GraphQL::DirectiveResolvers::ArgumentBinding.new(directive, argument, @field_def).bind(value, ctx)
200
+ end
201
+
202
+ # An input object's own arguments, transformed in a copy.
203
+ def nested(type, value, ctx, trim_all)
204
+ type = type.unwrap
205
+ return value unless type.kind.input_object? && !value.nil?
206
+ return value.map { |item| nested(type, item, ctx, trim_all) } if value.is_a?(Array)
207
+
208
+ transform_input(type, value, ctx, trim_all)
209
+ end
210
+
211
+ def transform_input(type, value, ctx, trim_all)
212
+ values = value.respond_to?(:to_kwargs) ? value.to_kwargs : value
213
+ return value unless values.is_a?(Hash)
214
+
215
+ transformed = transform_hash(type.arguments, values, ctx, trim_all)
216
+ return transformed unless value.respond_to?(:to_kwargs)
217
+
218
+ value.dup.tap { |copy| copy.instance_variable_set(:@ruby_style_hash, transformed) }
219
+ end
220
+ end
@@ -92,8 +92,8 @@ module Lighthouse::GraphQL::DirectiveResolvers
92
92
 
93
93
  # The field's scopes and filters on the association's query, for one
94
94
  # parent or a RelationBatch of them.
95
- def decorate_relation(relation, args, _ctx)
96
- apply_where_filters(scoped(relation), args)
95
+ def decorate_relation(relation, args, ctx)
96
+ apply_where_filters(scoped(relation), args, ctx)
97
97
  end
98
98
 
99
99
  protected
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @async — run a mutation in a queued job and answer `true` at once, as PHP
6
+ # Lighthouse's directive. What decides whether it may run does so in the
7
+ # request: @guard, the @can* family, @validator and the argument directives, so
8
+ # the client still gets their errors. What resolves it (@create, a Mutations::
9
+ # class, ...) runs in Lighthouse::GraphQL::AsyncMutationJob, through ActiveJob,
10
+ # with the arguments and the context values ActiveJob can serialize (records
11
+ # included, through GlobalID; a request or a file is left out).
12
+ #
13
+ # type Mutation {
14
+ # importFeed(url: String!): Boolean! @guard @async(queue: "imports")
15
+ # }
16
+ #
17
+ # The field must be a Mutation field returning Boolean, and the schema a named
18
+ # constant, which the job looks up.
19
+ class Lighthouse::GraphQL::DirectiveResolvers::AsyncDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
20
+ def self.directive_name = 'async'
21
+
22
+ def self.definition
23
+ <<~GRAPHQL.chomp
24
+ """
25
+ Defer the execution of mutations to queued jobs.
26
+ The field answers true once the job is queued.
27
+ """
28
+ directive @async(
29
+ "The name of the queue to dispatch the job on."
30
+ queue: String
31
+ ) on FIELD_DEFINITION
32
+ GRAPHQL
33
+ end
34
+
35
+ # What resolves `field` of `type` below @async, for the job to call.
36
+ def self.inner(type, field)
37
+ (type.instance_variable_get(:@lighthouse_async_inners) || {}).fetch(field.to_s) do
38
+ raise Lighthouse::DefinitionError, "#{type.graphql_name}.#{field} has no @async."
39
+ end
40
+ end
41
+
42
+ def define_resolver(target_type_class)
43
+ validate!(target_type_class)
44
+ inner = capture_inner(target_type_class)
45
+ inners = target_type_class.instance_variable_get(:@lighthouse_async_inners) || {}
46
+ target_type_class.instance_variable_set(:@lighthouse_async_inners, inners.merge(@field_def.name => inner))
47
+ me = self
48
+
49
+ install(target_type_class, lambda do |_obj, args, ctx|
50
+ me.enqueue(target_type_class.graphql_name, args, ctx)
51
+ true
52
+ end)
53
+ end
54
+
55
+ def enqueue(type_name, args, ctx)
56
+ schema_name = ctx.schema.name
57
+ raise Lighthouse::DefinitionError, '@async needs a schema assigned to a constant (AppSchema = ...), for the job to find.' unless schema_name
58
+
59
+ job = Lighthouse::GraphQL::AsyncMutationJob
60
+ job = job.set(queue: directive_arg('queue')) if directive_arg('queue')
61
+ job.perform_later(schema_name, type_name, @field_def.name, serializable(Lighthouse::Support::Arguments.plain(args)), context_values(ctx))
62
+ end
63
+
64
+ private
65
+
66
+ def validate!(target_type_class)
67
+ return if @opts[:root_kind] == :mutations && @field_def.type.unwrap.graphql_name == 'Boolean'
68
+
69
+ raise Lighthouse::DefinitionError,
70
+ "@async on #{target_type_class.graphql_name}.#{@field_def.name}: only a Mutation field returning Boolean can run in a job."
71
+ end
72
+
73
+ # The context's values ActiveJob can serialize.
74
+ def context_values(ctx)
75
+ values = ctx.respond_to?(:to_h) ? ctx.to_h : {}
76
+ serializable(values.reject { |key, _| key.to_s.start_with?('graphql_', 'lighthouse_') || EXECUTION_KEYS.include?(key) })
77
+ end
78
+
79
+ # graphql-ruby's own context keys, which mean nothing outside the request.
80
+ EXECUTION_KEYS = %i[current_path current_field current_object current_arguments].freeze
81
+
82
+ def serializable(hash)
83
+ hash.select do |_, value|
84
+ ::ActiveJob::Arguments.serialize([value])
85
+ true
86
+ rescue StandardError
87
+ false
88
+ end.transform_keys(&:to_sym)
89
+ end
90
+ end
@@ -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
 
@@ -18,6 +18,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
18
18
  end
19
19
 
20
20
  def define_resolver(target_type_class)
21
+ Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
21
22
  me = self
22
23
  inner = capture_inner(target_type_class)
23
24
 
@@ -54,7 +55,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
54
55
  end
55
56
  Lighthouse::GraphQL::ResolverBases.check!(klass, @base, location)
56
57
 
57
- 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))
58
59
  end
59
60
 
60
61
  # No class: the root object may still answer (a custom root_value);
@@ -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