lighthouse-graphql 0.2.1 → 0.4.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -1
  3. data/README.md +22 -3
  4. data/docs/best-practices.md +16 -6
  5. data/docs/caching.md +1 -1
  6. data/docs/configuration.md +74 -5
  7. data/docs/custom-directives.md +11 -1
  8. data/docs/directives.md +333 -2
  9. data/docs/events.md +203 -0
  10. data/docs/federation.md +3 -2
  11. data/docs/getting-started.md +90 -23
  12. data/docs/motivation.md +3 -2
  13. data/docs/mutations.md +15 -0
  14. data/docs/resolvers.md +95 -19
  15. data/docs/security.md +210 -0
  16. data/docs/subscriptions.md +446 -0
  17. data/lib/generators/lighthouse/base_classes.rb +87 -0
  18. data/lib/generators/lighthouse/class_generator.rb +19 -8
  19. data/lib/generators/lighthouse/install/install_generator.rb +23 -6
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
  22. data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
  23. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  24. data/lib/generators/lighthouse/query_generator.rb +5 -4
  25. data/lib/generators/lighthouse/subscription_generator.rb +100 -0
  26. data/lib/lighthouse/broadcast_job.rb +14 -0
  27. data/lib/lighthouse/broadcasting.rb +143 -0
  28. data/lib/lighthouse/cache_control.rb +5 -2
  29. data/lib/lighthouse/complexity.rb +174 -0
  30. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  31. data/lib/lighthouse/config.rb +56 -14
  32. data/lib/lighthouse/controller.rb +131 -0
  33. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  34. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
  35. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  36. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  37. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  38. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  39. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
  40. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  41. data/lib/lighthouse/directives/registry.rb +15 -6
  42. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  43. data/lib/lighthouse/events/execution_trace.rb +52 -0
  44. data/lib/lighthouse/events/lifecycle.rb +148 -0
  45. data/lib/lighthouse/events/schema_build.rb +44 -0
  46. data/lib/lighthouse/events.rb +76 -0
  47. data/lib/lighthouse/feature_directive.rb +137 -0
  48. data/lib/lighthouse/field_resolver_stack.rb +28 -7
  49. data/lib/lighthouse/graphql/version.rb +1 -1
  50. data/lib/lighthouse/namespaces.rb +1 -0
  51. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  52. data/lib/lighthouse/persisted_queries.rb +17 -4
  53. data/lib/lighthouse/rb_lighthouse.rb +23 -0
  54. data/lib/lighthouse/resolver_bases.rb +61 -0
  55. data/lib/lighthouse/schema_cache.rb +12 -5
  56. data/lib/lighthouse/schema_factory.rb +0 -1
  57. data/lib/lighthouse/schema_generator.rb +42 -9
  58. data/lib/lighthouse/sdl_loader.rb +0 -2
  59. data/lib/lighthouse/subscription.rb +120 -0
  60. data/lib/lighthouse/support/model_resolver.rb +1 -1
  61. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  62. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  63. data/sig/lighthouse/controller.rbs +30 -0
  64. data/sig/lighthouse/events.rbs +122 -0
  65. data/sig/lighthouse/graphql.rbs +62 -0
  66. data/sig/lighthouse.rbs +52 -1
  67. metadata +29 -1
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+
5
+ # A concern for a graphql-ruby analyzer that @complexity(resolver:) names. Its
6
+ # one method, `complexity`, gives the fields naming the class their cost, as
7
+ # graphql-ruby's `complexity` gives a field or a GraphQL::Schema::Resolver one:
8
+ #
9
+ # class Analyzers::TagPostsComplexity < GraphQL::Analysis::QueryComplexity
10
+ # include Lighthouse::GraphQL::ComplexityAnalyzer
11
+ #
12
+ # complexity ->(_context, _args, child_complexity) { 1 + (child_complexity * 25) }
13
+ # end
14
+ #
15
+ # A number is added to the cost of the field's selection; a proc is called with
16
+ # (context, args, child_complexity) and returns the whole cost. Without
17
+ # `complexity`, the field costs what a bare @complexity gives.
18
+ #
19
+ # The class stays an analyzer, installed on the schema like any class @complexity
20
+ # names: a `result` of its own runs on every query, in graphql-ruby's analysis.
21
+ # Without one it only gives costs, and graphql-ruby skips it.
22
+ module Lighthouse::GraphQL::ComplexityAnalyzer
23
+ extend ActiveSupport::Concern
24
+
25
+ class_methods do
26
+ # Set the cost (a number or a proc taking context, args and child_complexity),
27
+ # or read it: the class's own, else its superclass's, else nil.
28
+ def complexity(new_complexity = nil)
29
+ return inherited_complexity if new_complexity.nil?
30
+
31
+ unless new_complexity.is_a?(Numeric) || (new_complexity.is_a?(Proc) && new_complexity.parameters.size == 3)
32
+ raise ArgumentError, "#{name}.complexity takes a number or a proc of (context, args, child_complexity), " \
33
+ "got #{new_complexity.inspect}"
34
+ end
35
+
36
+ @complexity = new_complexity
37
+ end
38
+
39
+ private
40
+
41
+ def inherited_complexity
42
+ return @complexity if defined?(@complexity)
43
+
44
+ superclass.respond_to?(:complexity) ? superclass.complexity : nil
45
+ end
46
+ end
47
+
48
+ # Run in graphql-ruby's analysis only when the class has a `result` of its own.
49
+ def analyze?
50
+ self.class.instance_method(:result).owner != Lighthouse::GraphQL::ComplexityAnalyzer && super
51
+ end
52
+
53
+ def result
54
+ nil
55
+ end
56
+ end
@@ -12,12 +12,15 @@ module Lighthouse
12
12
  # Where each kind of class a schema refers to by name is looked up. Query and
13
13
  # mutation fields resolve to GraphQL::Schema::Resolver / Mutation classes,
14
14
  # builders compose onto a relation, validators check arguments: distinct
15
- # contracts, so distinct namespaces (app/graphql/queries, .../mutations, ...).
15
+ # contracts, so distinct namespaces (app/graphql/resolvers, .../mutations, ...).
16
16
  DEFAULT_NAMESPACES = {
17
- queries: ['Queries'],
17
+ queries: ['Resolvers'],
18
18
  mutations: ['Mutations'],
19
19
  builders: ['Builders'],
20
- validators: ['Validators']
20
+ validators: ['Validators'],
21
+ events: ['Events'],
22
+ subscriptions: ['Subscriptions'],
23
+ analyzers: ['Analyzers']
21
24
  }.freeze
22
25
 
23
26
  # Every setting reset! forgets, so the next read falls back to its default.
@@ -25,19 +28,23 @@ module Lighthouse
25
28
  sdl_folder resolver_namespaces namespaces cache_store transactional_mutations pagination_default_count pagination_max_count
26
29
  schema_cache schema_cache_path watch_sdl persisted_queries persisted_queries_store persisted_queries_ttl
27
30
  persisted_queries_manifest persisted_queries_only on_error directive_map authenticated_user policy_user authorizer
31
+ feature_checker feature_actor
32
+ convert_empty_strings_to_null
33
+ soft_deletes
34
+ queue_broadcasts broadcast_queue
28
35
  ].freeze
29
36
 
30
37
  class << self
31
38
  attr_writer :sdl_folder, :resolver_namespaces, :on_error, :authenticated_user, :policy_user, :authorizer, :cache_store,
32
- :transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only
39
+ :transactional_mutations, :schema_cache, :watch_sdl, :persisted_queries, :persisted_queries_store, :persisted_queries_only,
40
+ :queue_broadcasts,
41
+ :convert_empty_strings_to_null, :feature_checker, :feature_actor
33
42
 
34
43
  def sdl_folder
35
44
  @sdl_folder ||= (defined?(Rails) && Rails.respond_to?(:root) ? Rails.root.join('graphql').to_s : nil)
36
45
  end
37
46
 
38
- def directive_map
39
- @directive_map ||= {}
40
- end
47
+ def directive_map = @directive_map ||= {}
41
48
 
42
49
  # Namespaces tried (in order) when resolving a class name from a directive
43
50
  # argument, e.g. `@field(resolver: "ContactPerson")` or `@all(model: "X")`.
@@ -67,6 +74,15 @@ module Lighthouse
67
74
  end
68
75
  end
69
76
 
77
+ # --- Subscriptions (used by @broadcast) ----------------------------------
78
+ #
79
+ # queue_broadcasts perform @broadcast triggers in a Lighthouse::GraphQL::BroadcastJob
80
+ # (ActiveJob) instead of inline; a directive's shouldQueue overrides it
81
+ # broadcast_queue the job's queue name; nil: ActiveJob's default
82
+ attr_accessor :broadcast_queue
83
+
84
+ def queue_broadcasts = @queue_broadcasts || false
85
+
70
86
  # --- Scale options: all off by default ----------------------------------
71
87
  #
72
88
  # schema_cache cache the built schema document in tmp/cache (SchemaCache)
@@ -84,6 +100,10 @@ module Lighthouse
84
100
  def persisted_queries = @persisted_queries || false
85
101
  def persisted_queries_only = @persisted_queries_only || false
86
102
 
103
+ # Make every field behave as if it carried @convertEmptyStringsToNull. Read
104
+ # when the schema is built, so set it in an initializer.
105
+ def convert_empty_strings_to_null = @convert_empty_strings_to_null || false
106
+
87
107
  def persisted_queries_store
88
108
  @persisted_queries_store || cache_store
89
109
  end
@@ -102,7 +122,7 @@ module Lighthouse
102
122
  # re-raising in test (so failures surface) and logging otherwise. Override
103
123
  # via `Lighthouse.configure { |c| c.on_error = ->(e, ctx) { ... } }`.
104
124
  def on_error
105
- @on_error ||= default_on_error
125
+ @on_error ||= Defaults.on_error
106
126
  end
107
127
 
108
128
  def handle_error(error, context = nil)
@@ -131,9 +151,28 @@ module Lighthouse
131
151
  # `record` may be a model class (@canModel). `args` carries the @can*
132
152
  # `args:` values and, with `injectArgs: true`, the field's arguments first.
133
153
  # Defaults to Pundit: `Pundit.policy!(user, record).<ability>?(*args)`.
134
- def authorizer
135
- @authorizer ||= default_authorizer
136
- end
154
+ def authorizer = @authorizer ||= Defaults.authorizer
155
+
156
+ # --- Feature flags (used by @feature) ------------------------------------
157
+
158
+ # Is a feature on? `->(name, actor, ctx) { true/false }`, with the feature's
159
+ # name as written in the SDL. Defaults to Flipper: `Flipper.enabled?(name.to_sym, actor)`.
160
+ def feature_checker = @feature_checker ||= Lighthouse::GraphQL::FeatureDirective.default_checker
161
+
162
+ # The actor features are checked for: `->(ctx) { actor }` (a Flipper actor
163
+ # answers `flipper_id`; nil checks the feature globally). Defaults to the
164
+ # authenticated user.
165
+ def feature_actor = @feature_actor ||= ->(ctx) { authenticated_user.call(ctx) }
166
+
167
+ # --- Soft deletes (used by @softDeletes, @trashed, @restore, @forceDelete) ---
168
+
169
+ # Teaches Lighthouse what "trashed" means. Any object answering
170
+ # `soft_deletes?(model)`, `only_trashed(relation)`, `with_trashed(relation)`,
171
+ # `without_trashed(relation)`, `restore(record)` and `force_delete(record)`.
172
+ # Defaults to detecting paranoia, then discard, then a `deleted_at` column
173
+ # per model (Support::SoftDeletes::Auto); `Support::SoftDeletes::Column.new(:archived_at)`
174
+ # is one for another column.
175
+ attr_accessor :soft_deletes
137
176
 
138
177
  def configure
139
178
  yield(self) if block_given?
@@ -146,10 +185,13 @@ module Lighthouse
146
185
  def reset!
147
186
  SETTINGS.each { |setting| instance_variable_set("@#{setting}", nil) }
148
187
  end
188
+ end
149
189
 
150
- private
190
+ # The callables the authorizer and on_error settings fall back to.
191
+ module Defaults
192
+ module_function
151
193
 
152
- def default_authorizer
194
+ def authorizer
153
195
  lambda do |user, record, ability, args = []|
154
196
  raise 'Lighthouse: configure `Config.authorizer` to use the @can* directives (Pundit not detected).' unless defined?(::Pundit)
155
197
 
@@ -158,7 +200,7 @@ module Lighthouse
158
200
  end
159
201
  end
160
202
 
161
- def default_on_error
203
+ def on_error
162
204
  lambda do |error, _context|
163
205
  if defined?(Rails) && Rails.respond_to?(:env) && Rails.env.test?
164
206
  raise error
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'active_support/concern'
4
+ require 'active_support/core_ext/class/attribute'
5
+ require 'json'
6
+
7
+ # A Rails controller concern that serves a Lighthouse schema: include it, name
8
+ # the schema, route to `execute`.
9
+ #
10
+ # class GraphqlController < ApplicationController
11
+ # include Lighthouse::GraphQL::Controller
12
+ #
13
+ # lighthouse_schema AppSchema # or 'AppSchema', or { AppSchema }, resolved per request
14
+ #
15
+ # private
16
+ #
17
+ # def graphql_context = { current_user: current_user }
18
+ # end
19
+ #
20
+ # `execute` dispatches StartRequest, reads the variables (a JSON String, a Hash
21
+ # or parameters), runs the operation through PersistedQueries.execute (or
22
+ # PersistedQueries.multiplex for a batch, a JSON array body), sets the
23
+ # Cache-Control header the selected fields' @cacheControl hints allow, renders
24
+ # the JSON and dispatches EndRequest. In development an error renders as JSON.
25
+ #
26
+ # Hooks, private so they are not actions: `graphql_context` (default:
27
+ # `{ current_user: current_user }` when the controller has a current_user, else
28
+ # `{}`), `graphql_schema` and `graphql_variables(param)`. A controller with an
29
+ # action of its own wraps it in `lighthouse_request { ... }` to get the request
30
+ # events; a nested call dispatches them once.
31
+ module Lighthouse::GraphQL::Controller
32
+ extend ActiveSupport::Concern
33
+
34
+ included do
35
+ class_attribute :lighthouse_schema_source, instance_writer: false
36
+ end
37
+
38
+ class_methods do
39
+ # The schema `execute` runs: a schema class, its name (a String, constantized
40
+ # per request so code reloading works) or a block (evaluated in the
41
+ # controller, per request).
42
+ def lighthouse_schema(schema = nil, &block)
43
+ self.lighthouse_schema_source = schema || block
44
+ end
45
+ end
46
+
47
+ def execute
48
+ lighthouse_request do
49
+ batch = params[:_json]
50
+ if batch.is_a?(Array)
51
+ render json: lighthouse_batch(batch)
52
+ else
53
+ result = lighthouse_operation
54
+ Lighthouse::GraphQL::CacheControl.apply(response, result)
55
+ render json: result
56
+ end
57
+ end
58
+ rescue StandardError => e
59
+ raise e unless defined?(::Rails) && ::Rails.env.development?
60
+
61
+ lighthouse_development_error(e)
62
+ end
63
+
64
+ private
65
+
66
+ # Dispatch StartRequest, run the block, dispatch EndRequest with the
67
+ # response. Only the outermost call dispatches.
68
+ def lighthouse_request
69
+ return yield if @lighthouse_request_started
70
+
71
+ @lighthouse_request_started = true
72
+ Lighthouse::Events.dispatch(Lighthouse::Events::StartRequest.new(request))
73
+ result = yield
74
+ Lighthouse::Events.dispatch(Lighthouse::Events::EndRequest.new(response))
75
+ result
76
+ end
77
+
78
+ # The context of the operation: what @guard and the @can* directives read.
79
+ def graphql_context
80
+ respond_to?(:current_user, true) ? { current_user: current_user } : {}
81
+ end
82
+
83
+ def graphql_schema
84
+ source = lighthouse_schema_source
85
+ case source
86
+ when nil then raise Lighthouse::DefinitionError, "#{self.class}: name the schema with `lighthouse_schema AppSchema`"
87
+ when String, Symbol then source.to_s.constantize
88
+ when Proc then instance_exec(&source)
89
+ else source
90
+ end
91
+ end
92
+
93
+ # Variables arrive as a JSON string (GET or form posts) or as parameters.
94
+ def graphql_variables(param)
95
+ case param
96
+ when String then param.present? ? JSON.parse(param) || {} : {}
97
+ when Hash then param
98
+ when nil then {}
99
+ else
100
+ raise ArgumentError, "Unexpected parameter: #{param}" unless param.respond_to?(:to_unsafe_h)
101
+
102
+ param.to_unsafe_h
103
+ end
104
+ end
105
+
106
+ def lighthouse_operation
107
+ Lighthouse::GraphQL::PersistedQueries.execute(
108
+ graphql_schema,
109
+ query: params[:query], extensions: params[:extensions],
110
+ variables: graphql_variables(params[:variables]), operation_name: params[:operationName], context: graphql_context
111
+ )
112
+ end
113
+
114
+ # Each operation gets its own context: `schema.multiplex`'s `context:` is the
115
+ # multiplex's, which the queries' resolvers never see.
116
+ def lighthouse_batch(operations)
117
+ operations = operations.map do |operation|
118
+ { query: operation[:query], extensions: operation[:extensions], variables: graphql_variables(operation[:variables]),
119
+ operation_name: operation[:operationName], context: graphql_context }
120
+ end
121
+ Lighthouse::GraphQL::PersistedQueries.multiplex(graphql_schema, operations)
122
+ end
123
+
124
+ def lighthouse_development_error(error)
125
+ logger.error(error.message)
126
+ logger.error(error.backtrace.join("\n"))
127
+
128
+ render json: { errors: [{ message: error.message, backtrace: error.backtrace }], data: {} },
129
+ status: :internal_server_error
130
+ end
131
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @broadcast — on a mutation (or any field), trigger a subscription once the
6
+ # field resolved successfully: its result is the root the subscribers'
7
+ # updates are resolved from, each passing through the subscription's
8
+ # `filter?`. A nil result or a GraphQL::ExecutionError (a failed mutation)
9
+ # broadcasts nothing; repeat the directive to trigger several subscriptions.
10
+ # See Lighthouse::GraphQL::Broadcasting for how it is triggered, and queued.
11
+ class Lighthouse::GraphQL::DirectiveResolvers::BroadcastDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
12
+ def self.directive_name = 'broadcast'
13
+
14
+ def self.definition
15
+ 'directive @broadcast(subscription: String!, shouldQueue: Boolean) repeatable on FIELD_DEFINITION'
16
+ end
17
+
18
+ def define_resolver(target_type_class)
19
+ subscription = directive_arg('subscription')
20
+ should_queue = directive_arg('shouldQueue')
21
+ inner = capture_inner(target_type_class)
22
+
23
+ install(target_type_class, lambda do |obj, args, ctx|
24
+ result = inner.call(obj, args, ctx)
25
+ unless result.nil? || result.is_a?(::GraphQL::ExecutionError)
26
+ Lighthouse::GraphQL::Broadcasting.broadcast(ctx.schema, subscription, result, ctx, should_queue: should_queue)
27
+ end
28
+ result
29
+ end)
30
+ end
31
+ end
@@ -4,14 +4,17 @@ 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)
@@ -31,6 +34,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
31
34
  ROOT_KINDS[type.respond_to?(:graphql_name) ? type.graphql_name : type.to_s]
32
35
  end
33
36
 
37
+ # The class a root field resolves to, without its namespace: `LatestPosts`
38
+ # for `latestPosts`.
39
+ def self.class_name_for(field_def)
40
+ field_def.name.to_s.camelize
41
+ end
42
+
34
43
  private
35
44
 
36
45
  def resolve_by_convention(inner, obj, args, ctx)
@@ -43,6 +52,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
43
52
  "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Resolver " \
44
53
  '(or GraphQL::Schema::Mutation).'
45
54
  end
55
+ Lighthouse::GraphQL::ResolverBases.check!(klass, @base, location)
46
56
 
47
57
  Lighthouse::GraphQL::ResolverDispatch.call(klass, @field_def, obj, args, ctx)
48
58
  end
@@ -61,7 +71,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::ConventionResolver < Lighthouse::
61
71
  end
62
72
 
63
73
  def class_name
64
- @class_name ||= @field_def.name.to_s.camelize
74
+ @class_name ||= self.class.class_name_for(@field_def)
65
75
  end
66
76
 
67
77
  def location
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @convertEmptyStringsToNull — replace incoming empty strings with null, as
6
+ # PHP Lighthouse's directive of the same name does.
7
+ #
8
+ # type Mutation {
9
+ # createPost(title: String @convertEmptyStringsToNull): Post! # this argument
10
+ # createUser(input: UserInput!): User! @convertEmptyStringsToNull # every input of the field
11
+ # }
12
+ # input UserInput { name: String @convertEmptyStringsToNull, bio: String }
13
+ #
14
+ # On a field it applies to all the field's inputs, nested input objects and
15
+ # lists included, but leaves a non-null input (`String!`) alone: only placing
16
+ # the directive on that argument or input field itself forces the conversion.
17
+ # On an input field it applies wherever the input type is used. Setting
18
+ # Config.convert_empty_strings_to_null makes every field behave as if it
19
+ # carried the directive.
20
+ #
21
+ # The conversion is a wrapper around the field's resolver (outermost, order
22
+ # 6), so every kind of resolver — directives, @field, Resolvers::/Mutations::
23
+ # classes, the default reader — and @validator see the converted arguments,
24
+ # and other fields sharing an input type are unaffected. SchemaGenerator
25
+ # installs it too, without the directive on the field, when an argument or
26
+ # input type it takes carries the directive, or the setting is on.
27
+ class Lighthouse::GraphQL::DirectiveResolvers::ConvertEmptyStringsToNullDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
28
+ NAME = 'convertEmptyStringsToNull'
29
+
30
+ def self.directive_name = NAME
31
+
32
+ def self.definition
33
+ <<~GRAPHQL.chomp
34
+ """
35
+ Replaces incoming empty strings `""` with `null`.
36
+
37
+ When used upon fields, empty strings for non-nullable inputs will pass unchanged.
38
+ Only explicitly placing this on non-nullable inputs will force the conversion.
39
+ """
40
+ directive @convertEmptyStringsToNull on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | FIELD_DEFINITION
41
+ GRAPHQL
42
+ end
43
+
44
+ # Does a field without the directive still convert? Yes when the setting is
45
+ # on, or when an argument or input type it takes carries the directive.
46
+ def self.implicit?(field_def)
47
+ return false if field_def.arguments.empty?
48
+
49
+ Lighthouse::Config.convert_empty_strings_to_null || carried?(field_def.arguments)
50
+ end
51
+
52
+ def self.explicit?(argument)
53
+ (argument.ast_node&.directives || []).any? { |d| d.name == NAME }
54
+ end
55
+
56
+ # Does any argument, at any depth of input types, carry the directive?
57
+ def self.carried?(argument_defs, seen = Set.new)
58
+ argument_defs.each_value.any? do |argument|
59
+ type = argument.type.unwrap
60
+ explicit?(argument) || (type.kind.input_object? && seen.add?(type.graphql_name) && carried?(type.arguments, seen))
61
+ end
62
+ end
63
+
64
+ def initialize(field_def, type_def, directive_node, opts = {})
65
+ super
66
+ @active = !directive_node.nil? || Lighthouse::Config.convert_empty_strings_to_null
67
+ end
68
+
69
+ def define_resolver(target_type_class)
70
+ me = self
71
+ inner = capture_inner(target_type_class)
72
+
73
+ install(target_type_class, lambda do |obj, args, ctx|
74
+ inner.call(obj, me.convert_arguments(args), ctx)
75
+ end)
76
+ end
77
+
78
+ # The field's arguments (a Hash keyed by snake_case symbols) with the empty
79
+ # strings replaced. Input objects that change are copied, never mutated.
80
+ def convert_arguments(args)
81
+ return args unless args.is_a?(Hash)
82
+
83
+ convert_hash(@field_def.arguments, args, @active)
84
+ end
85
+
86
+ private
87
+
88
+ def convert_hash(argument_defs, values, active)
89
+ by_keyword = Lighthouse::Support::Arguments.keyword_index(argument_defs)
90
+ values.to_h { |key, value| [key, convert_argument(by_keyword[key.to_sym], value, active)] }
91
+ end
92
+
93
+ def convert_argument(argument, value, active)
94
+ return value if argument.nil? || value.nil?
95
+
96
+ explicit = self.class.explicit?(argument)
97
+ convert_value(argument.type, value, active || explicit, explicit)
98
+ end
99
+
100
+ # `active`: the enclosing field or argument converts its nullable inputs.
101
+ # `force`: this argument itself carries the directive, so non-null ones too.
102
+ def convert_value(type, value, active, force)
103
+ nullable = !type.kind.non_null?
104
+ type = type.of_type unless nullable
105
+ case type.kind.name
106
+ when 'LIST' then convert_list(type.of_type, value, active, force)
107
+ when 'INPUT_OBJECT' then convert_input(type, value, active)
108
+ when 'SCALAR' then convert_scalar(value, force || (active && nullable))
109
+ else value
110
+ end
111
+ end
112
+
113
+ def convert_scalar(value, convert)
114
+ convert && value == '' ? nil : value
115
+ end
116
+
117
+ def convert_list(item_type, value, active, force)
118
+ return value unless value.is_a?(Array)
119
+
120
+ items = value.map { |item| convert_value(item_type, item, active, force) }
121
+ items.zip(value).all? { |new, old| new.equal?(old) } ? value : items
122
+ end
123
+
124
+ def convert_input(type, value, active)
125
+ values = value.respond_to?(:to_kwargs) ? value.to_kwargs : value
126
+ return value unless values.is_a?(Hash)
127
+
128
+ converted = convert_hash(type.arguments, values, active)
129
+ return value if converted.all? { |key, new| new.equal?(values[key]) }
130
+ return converted unless value.respond_to?(:to_kwargs)
131
+
132
+ value.dup.tap { |copy| copy.instance_variable_set(:@ruby_style_hash, converted) }
133
+ end
134
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @event(dispatch: "OrderShipped") — dispatch an event once the field has
6
+ # resolved: the class is built with the resolved value and handed to
7
+ # Lighthouse::Events.dispatch. The value is returned unchanged; if the field
8
+ # raises, nothing is dispatched. Repeatable: each @event dispatches its own.
9
+ #
10
+ # The class is looked up in Config.namespaces[:events] (a fully-qualified
11
+ # name is used as-is); a name that is no class fails the schema build.
12
+ class Lighthouse::GraphQL::DirectiveResolvers::EventDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
13
+ def self.directive_name = 'event'
14
+ def self.definition = 'directive @event(dispatch: String!) repeatable on FIELD_DEFINITION'
15
+
16
+ def define_resolver(target_type_class)
17
+ name = directive_arg('dispatch')
18
+ self.class.event_class(name, "#{@type_def.graphql_name}.#{@field_def.name}")
19
+ inner = capture_inner(target_type_class)
20
+
21
+ install(target_type_class, lambda do |obj, args, ctx|
22
+ result = inner.call(obj, args, ctx)
23
+ Lighthouse::Events.dispatch(Lighthouse::GraphQL::DirectiveResolvers::EventDirectiveResolver.event_class(name).new(result))
24
+ result
25
+ end)
26
+ end
27
+
28
+ # The event class `name` names. Looked up on every dispatch, so a reloaded
29
+ # class is the one built.
30
+ def self.event_class(name, location = nil)
31
+ klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: Lighthouse::Config.namespaces[:events])
32
+ return klass if klass.respond_to?(:new)
33
+
34
+ raise Lighthouse::DefinitionError,
35
+ "@event on #{location}: no event class #{name} (looked in #{Lighthouse::Config.namespaces[:events].join(', ')})"
36
+ end
37
+ end
@@ -181,34 +181,38 @@ module Lighthouse::GraphQL::DirectiveResolvers
181
181
  end
182
182
  end
183
183
 
184
- # @delete — destroy the models the field's argument filters select
185
- # (@whereKey, @eq, ...), running their callbacks, and return them: a list
186
- # for a list field, else the first or null. Refuses to run without a
187
- # filter argument, which would delete every row.
188
- class DeleteDirectiveResolver < ModelMutationResolver
189
- def self.directive_name = 'delete'
190
- def self.definition = "directive @delete(#{ARGUMENTS}, scopes: [String!]) on #{LOCATIONS}"
191
-
184
+ # The shared shape of @delete, @forceDelete and @restore: act on each record
185
+ # the field's argument filters (@whereKey, @eq, ...) select, and return them:
186
+ # a list for a list field, else the first or null. Refuses to run without a
187
+ # filter argument, which would modify every row.
188
+ class ModifyModelsResolver < ModelMutationResolver
192
189
  WOULD_MODIFY_ALL = 'Would modify all models, use an argument to filter.'
193
190
 
194
191
  protected
195
192
 
196
- # A record whose callbacks refuse the destroy is reported as an error next
197
- # to the data, as PHP Lighthouse does; the others are still deleted.
193
+ # A record whose callbacks refuse the action is reported as an error next
194
+ # to the data, as PHP Lighthouse does; the others are still modified.
198
195
  def mutate(model, _obj, args, ctx)
199
196
  args = Lighthouse::Support::Arguments.shallow(args)
200
197
  raise ::GraphQL::ExecutionError, WOULD_MODIFY_ALL unless filtered?(args)
201
198
 
202
- relation = Array(directive_arg('scopes')).reduce(model.all) { |acc, scope| acc.public_send(scope) }
203
- records = apply_where_filters(relation, args).to_a
199
+ records = apply_where_filters(records_of(model), args).to_a
204
200
  records.each do |record|
205
- next if record.destroy
201
+ next if modify(record)
206
202
 
207
203
  ctx.add_error(::GraphQL::ExecutionError.new("Could not modify model #{model.name} with ID #{record.id}."))
208
204
  end
209
205
  @field_def.type.list? ? records : records.first
210
206
  end
211
207
 
208
+ # The relation the filters narrow down: the model's, narrowed by `scopes:`.
209
+ def records_of(model) = apply_scopes(model.all)
210
+
211
+ # Act on one record; false when its callbacks refused.
212
+ def modify(_record)
213
+ raise NotImplementedError
214
+ end
215
+
212
216
  def filtered?(args)
213
217
  registry = Lighthouse::DirectiveRegistry.default
214
218
  (@field_def.ast_node.arguments || []).any? do |argument|
@@ -216,4 +220,15 @@ module Lighthouse::GraphQL::DirectiveResolvers
216
220
  end
217
221
  end
218
222
  end
223
+
224
+ # @delete — destroy the models the field's argument filters select
225
+ # (@whereKey, @eq, ...), running their callbacks, and return them.
226
+ class DeleteDirectiveResolver < ModifyModelsResolver
227
+ def self.directive_name = 'delete'
228
+ def self.definition = "directive @delete(#{ARGUMENTS}, scopes: [String!]) on #{LOCATIONS}"
229
+
230
+ protected
231
+
232
+ def modify(record) = record.destroy
233
+ end
219
234
  end