lighthouse-graphql 0.2.1 → 0.3.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -1
  3. data/README.md +11 -0
  4. data/docs/caching.md +1 -1
  5. data/docs/configuration.md +68 -0
  6. data/docs/custom-directives.md +11 -1
  7. data/docs/directives.md +306 -0
  8. data/docs/events.md +201 -0
  9. data/docs/getting-started.md +38 -9
  10. data/docs/mutations.md +15 -0
  11. data/docs/resolvers.md +3 -1
  12. data/docs/subscriptions.md +423 -0
  13. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
  14. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +31 -3
  15. data/lib/generators/lighthouse/subscription_generator.rb +95 -0
  16. data/lib/lighthouse/broadcast_job.rb +14 -0
  17. data/lib/lighthouse/broadcasting.rb +143 -0
  18. data/lib/lighthouse/cache_control.rb +5 -2
  19. data/lib/lighthouse/config.rb +53 -12
  20. data/lib/lighthouse/controller.rb +131 -0
  21. data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
  22. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
  23. data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
  24. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
  25. data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
  26. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +68 -0
  27. data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
  28. data/lib/lighthouse/directives/registry.rb +14 -6
  29. data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
  30. data/lib/lighthouse/events/execution_trace.rb +51 -0
  31. data/lib/lighthouse/events/lifecycle.rb +146 -0
  32. data/lib/lighthouse/events/schema_build.rb +44 -0
  33. data/lib/lighthouse/events.rb +76 -0
  34. data/lib/lighthouse/feature_directive.rb +137 -0
  35. data/lib/lighthouse/field_resolver_stack.rb +22 -2
  36. data/lib/lighthouse/graphql/version.rb +1 -1
  37. data/lib/lighthouse/namespaces.rb +1 -0
  38. data/lib/lighthouse/persisted_queries/batch.rb +34 -0
  39. data/lib/lighthouse/persisted_queries.rb +17 -4
  40. data/lib/lighthouse/rb_lighthouse.rb +20 -0
  41. data/lib/lighthouse/schema_cache.rb +12 -5
  42. data/lib/lighthouse/schema_generator.rb +27 -2
  43. data/lib/lighthouse/subscription.rb +120 -0
  44. data/lib/lighthouse/support/soft_deletes.rb +164 -0
  45. data/lib/lighthouse/tasks/lighthouse.rake +10 -0
  46. data/sig/lighthouse/controller.rbs +30 -0
  47. data/sig/lighthouse/events.rbs +122 -0
  48. data/sig/lighthouse/graphql.rbs +33 -0
  49. data/sig/lighthouse.rbs +52 -1
  50. metadata +24 -1
@@ -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
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'model_mutation_resolver'
4
+
5
+ module Lighthouse::GraphQL::DirectiveResolvers
6
+ # The shape of @forceDelete and @restore: like @delete, they act on the
7
+ # records the field's filter arguments select — trashed ones included, which
8
+ # the model's default behaviour may hide — and return them. The model must
9
+ # support soft deletes (see Support::SoftDeletes).
10
+ class TrashedModelsResolver < ModifyModelsResolver
11
+ DOCUMENTED_ARGUMENTS = <<~GRAPHQL.chomp
12
+ "Specify the class name of the model to use. This is only needed when the default model detection does not work."
13
+ model: String
14
+ "Apply scopes to the underlying query."
15
+ scopes: [String!]
16
+ GRAPHQL
17
+
18
+ protected
19
+
20
+ def records_of(model)
21
+ Lighthouse::Support::SoftDeletes.require_support!(model)
22
+ apply_scopes(Lighthouse::Support::SoftDeletes.with_trashed(model.all))
23
+ end
24
+ end
25
+
26
+ # @forceDelete — permanently remove the soft-deleted (or any) models the
27
+ # field's filter arguments select, running their callbacks.
28
+ class ForceDeleteDirectiveResolver < TrashedModelsResolver
29
+ def self.directive_name = 'forceDelete'
30
+
31
+ def self.definition
32
+ <<~GRAPHQL.chomp
33
+ """
34
+ Permanently remove one or more soft deleted models.
35
+ """
36
+ directive @forceDelete(
37
+ #{DOCUMENTED_ARGUMENTS.gsub(/^/, ' ')}
38
+ ) on FIELD_DEFINITION
39
+ GRAPHQL
40
+ end
41
+
42
+ protected
43
+
44
+ def modify(record) = Lighthouse::Support::SoftDeletes.force_delete(record)
45
+ end
46
+
47
+ # @restore — un-delete the soft-deleted models the field's filter arguments select.
48
+ class RestoreDirectiveResolver < TrashedModelsResolver
49
+ def self.directive_name = 'restore'
50
+
51
+ def self.definition
52
+ <<~GRAPHQL.chomp
53
+ """
54
+ Un-delete one or more soft deleted models.
55
+ """
56
+ directive @restore(
57
+ #{DOCUMENTED_ARGUMENTS.gsub(/^/, ' ')}
58
+ ) on FIELD_DEFINITION
59
+ GRAPHQL
60
+ end
61
+
62
+ protected
63
+
64
+ def modify(record) = Lighthouse::Support::SoftDeletes.restore(record)
65
+ end
66
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # Resolves a field of the Subscription root type, with @subscription(class:)
6
+ # or, without the directive, by convention: `Subscription.postUpdated`
7
+ # resolves to `Subscriptions::PostUpdated`, looked up in
8
+ # Config.namespaces[:subscriptions] at call time so code reloading keeps
9
+ # working. The class is a GraphQL::Schema::Subscription, which may include
10
+ # Lighthouse::GraphQL::Subscription for PHP Lighthouse's hooks. A field
11
+ # with no class still works: it is a Lighthouse::GraphQL::Subscription::Default
12
+ # whose update is the broadcast root, which is all `@broadcast` needs.
13
+ #
14
+ # graphql-ruby registers a subscription through the class its field was
15
+ # built from, which a field built from SDL has not. The field is therefore
16
+ # told which class it stands for: its `resolver` (read by graphql-ruby to build
17
+ # the topic, and to know the subscription instance wrote itself) and its
18
+ # `subscription_scope` answer for the class. The class is instantiated for
19
+ # each request, so its hooks (`subscribe`, `update`, ...) run as usual.
20
+ class Lighthouse::GraphQL::DirectiveResolvers::SubscriptionDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
21
+ def self.directive_name = 'subscription'
22
+ def self.definition = 'directive @subscription(class: String!) on FIELD_DEFINITION'
23
+
24
+ def define_resolver(target_type_class)
25
+ unless @opts[:root_kind] == :subscriptions
26
+ raise Lighthouse::DefinitionError, "@subscription is for fields of the Subscription type, not #{location}."
27
+ end
28
+
29
+ me = self
30
+ @field_def.define_singleton_method(:resolver) { me.subscription_class }
31
+ @field_def.define_singleton_method(:subscription_scope) { me.subscription_class.subscription_scope }
32
+
33
+ install(target_type_class, lambda do |obj, args, ctx|
34
+ Lighthouse::GraphQL::ResolverDispatch.call(me.subscription_class, me.field_def, obj, args, ctx)
35
+ end)
36
+ end
37
+
38
+ attr_reader :field_def
39
+
40
+ # The class this field stands for: the one @subscription names, else the
41
+ # conventional one, else the default.
42
+ def subscription_class
43
+ klass = find_class
44
+ return Lighthouse::GraphQL::Subscription::Default unless klass
45
+
46
+ unless klass.is_a?(Class) && klass < ::GraphQL::Schema::Subscription
47
+ raise Lighthouse::DefinitionError, "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Subscription."
48
+ end
49
+
50
+ klass
51
+ end
52
+
53
+ private
54
+
55
+ def find_class
56
+ namespaces = Lighthouse::Config.namespaces[:subscriptions]
57
+ named = directive_arg('class') if @directive_node
58
+ klass = Lighthouse::Support::ModelResolver.constantize(named || @field_def.name.to_s.camelize, namespaces: namespaces, fallback: false)
59
+ return klass if klass || named.nil?
60
+
61
+ expected = Array(namespaces).map { |ns| "#{ns}::#{named}" }.join(' or ')
62
+ raise Lighthouse::DefinitionError, "@subscription(class: #{named.inspect}) on #{location}: no such class (looked for #{expected})."
63
+ end
64
+
65
+ def location
66
+ "#{@type_def.graphql_name}.#{@field_def.name}"
67
+ end
68
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Lighthouse::GraphQL::Directives::Arguments
6
+ # @trashed — filter soft-deleted records by the `Trashed` enum the client
7
+ # passes: ONLY the trashed ones, WITH them, or WITHOUT them. When the client
8
+ # omits the argument, the model's own default behaviour is untouched
9
+ # (paranoia hides trashed records, discard shows everything). The model's
10
+ # soft-delete strategy decides what "trashed" is (see Support::SoftDeletes).
11
+ #
12
+ # Usually added by @softDeletes: `tasks: [Task!]! @all @softDeletes`.
13
+ class Trashed < Base
14
+ def self.directive_name = 'trashed'
15
+
16
+ def self.definition
17
+ <<~GRAPHQL.chomp
18
+ """
19
+ Allows to filter if trashed elements should be fetched.
20
+ """
21
+ directive @trashed on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
22
+ GRAPHQL
23
+ end
24
+
25
+ def handle_builder(relation, value)
26
+ return relation if value.nil?
27
+
28
+ Lighthouse::Support::SoftDeletes.filter(relation, value)
29
+ end
30
+ end
31
+
32
+ # @withoutGlobalScopes(names: [...]) — on a Boolean argument: when the client
33
+ # passes `true`, omit the named global scopes from the query.
34
+ #
35
+ # Rails has one anonymous `default_scope`, so each name is resolved as:
36
+ #
37
+ # 1. an app-defined scope `without_<name>` (the name in snake_case) on the
38
+ # model, e.g. `scope :without_scheduled, -> { unscope(where: :schedule_at) }`;
39
+ # 2. else a column of that name: `unscope(where: column)`. This removes every
40
+ # condition on that column in the relation, including ones added earlier
41
+ # by other arguments;
42
+ # 3. else a soft-delete name (`softDeletes`, `SoftDeletingScope`): trashed
43
+ # records are included (see Support::SoftDeletes).
44
+ #
45
+ # A name matching none of them is an error. A trailing `Scope` is optional in
46
+ # the name (`ScheduledScope` finds `without_scheduled` too).
47
+ class WithoutGlobalScopes < Base
48
+ SOFT_DELETE_NAMES = %w[soft_deletes soft_delete soft_deleting].freeze
49
+
50
+ def self.directive_name = 'withoutGlobalScopes'
51
+
52
+ def self.definition
53
+ <<~GRAPHQL.chomp
54
+ """
55
+ Omit any number of global scopes from the query builder.
56
+
57
+ This directive should be used on arguments of type `Boolean`.
58
+ The scopes will be removed only if `true` is passed by the client.
59
+ """
60
+ directive @withoutGlobalScopes(
61
+ "The names of the global scopes to omit."
62
+ names: [String!]!
63
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
64
+ GRAPHQL
65
+ end
66
+
67
+ def handle_builder(relation, value)
68
+ return relation unless value == true
69
+
70
+ Array(Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'names')).reduce(relation) do |acc, name|
71
+ without_scope(acc, name)
72
+ end
73
+ end
74
+
75
+ private
76
+
77
+ def without_scope(relation, name)
78
+ candidates = candidate_names(name)
79
+ omit_by_scope(relation, candidates) || omit_by_column(relation, candidates) || omit_trashed(relation, candidates) ||
80
+ raise(::GraphQL::ExecutionError, unknown_scope(relation, name, candidates.first))
81
+ end
82
+
83
+ def omit_by_scope(relation, candidates)
84
+ scope = candidates.map { |candidate| "without_#{candidate}" }.find { |method| relation.respond_to?(method) }
85
+ scope && (relation.public_send(scope) || relation)
86
+ end
87
+
88
+ def omit_by_column(relation, candidates)
89
+ column = candidates.find { |candidate| column?(relation, candidate) }
90
+ column && relation.unscope(where: column)
91
+ end
92
+
93
+ def omit_trashed(relation, candidates)
94
+ Lighthouse::Support::SoftDeletes.with_trashed(relation) if candidates.intersect?(SOFT_DELETE_NAMES)
95
+ end
96
+
97
+ def unknown_scope(relation, name, candidate)
98
+ "Cannot omit the global scope #{name}: #{relation.klass.name} has no without_#{candidate} scope or #{candidate} column."
99
+ end
100
+
101
+ def candidate_names(name)
102
+ snake = Lighthouse::Support::Naming.attribute(name)
103
+ [snake, snake.delete_suffix('_scope')].uniq
104
+ end
105
+
106
+ def column?(relation, name)
107
+ relation.klass.column_names.include?(name)
108
+ rescue ::ActiveRecord::ActiveRecordError
109
+ false
110
+ end
111
+ end
112
+ end
@@ -56,16 +56,18 @@ class Lighthouse::DirectiveRegistry
56
56
 
57
57
  # Application order for wrapping/resolution directives on a field. Lower is
58
58
  # applied first (innermost, closest to the data); higher is applied last
59
- # (outermost, so it executes first). From the outside in: authentication,
59
+ # (outermost, so it executes first). From the outside in: empty strings
60
+ # converted to null (so everything below sees the converted arguments), authentication,
60
61
  # authorization, argument validation, then @cache (so a cache hit is still
61
62
  # authorized and validated), @clearCache (which acts once the resolver
62
- # succeeded), and finally the data resolver (0).
63
+ # succeeded), @event and @broadcast (likewise), and finally the data resolver (0).
63
64
  RESOLUTION_ORDER = {
65
+ 'convertEmptyStringsToNull' => 6,
64
66
  'guard' => 5, 'auth' => 5,
65
67
  'canFind' => 4, 'canModel' => 4, 'canQuery' => 4, 'canResolved' => 4, 'canRoot' => 4,
66
68
  'validator' => 3,
67
69
  'cache' => 2,
68
- 'clearCache' => 1
70
+ 'clearCache' => 1, 'event' => 1, 'broadcast' => 1
69
71
  }.freeze
70
72
 
71
73
  def resolution_order(name)
@@ -173,14 +175,16 @@ class Lighthouse::DirectiveRegistry
173
175
  CreateDirectiveResolver CreateManyDirectiveResolver
174
176
  UpdateDirectiveResolver UpdateManyDirectiveResolver
175
177
  UpsertDirectiveResolver UpsertManyDirectiveResolver DeleteDirectiveResolver
178
+ ForceDeleteDirectiveResolver RestoreDirectiveResolver
176
179
  CanFindDirectiveResolver CanModelDirectiveResolver CanQueryDirectiveResolver
177
180
  CanResolvedDirectiveResolver CanRootDirectiveResolver
178
- CacheDirectiveResolver ClearCacheDirectiveResolver
179
- ValidatorDirectiveResolver
181
+ CacheDirectiveResolver ClearCacheDirectiveResolver EventDirectiveResolver
182
+ ValidatorDirectiveResolver ConvertEmptyStringsToNullDirectiveResolver
183
+ SubscriptionDirectiveResolver BroadcastDirectiveResolver
180
184
  ].freeze
181
185
 
182
186
  # Argument-builder filter/sort directives.
183
- ARG_BUILDERS = %i[Eq Neq In NotIn Like Where WhereKey OrderBy].freeze
187
+ ARG_BUILDERS = %i[Eq Neq In NotIn Like Where WhereKey OrderBy Trashed WithoutGlobalScopes].freeze
184
188
 
185
189
  # Read by other directives rather than resolving anything themselves.
186
190
  PASSIVE_DEFINITIONS = {
@@ -198,6 +202,7 @@ class Lighthouse::DirectiveRegistry
198
202
  # SDL manipulators run in registration order during the manipulation phase.
199
203
  register_manipulator(Lighthouse::GraphQL::Directives::PaginationManipulator)
200
204
  register_manipulator(Lighthouse::GraphQL::Directives::WhereConditionsDirective)
205
+ register_manipulator(Lighthouse::GraphQL::Directives::SoftDeletesDirective)
201
206
 
202
207
  register_default_definitions
203
208
  self
@@ -212,9 +217,12 @@ class Lighthouse::DirectiveRegistry
212
217
  PASSIVE_DEFINITIONS.each { |name, definition| register_definition(name, definition) }
213
218
  register_definition('cacheControl', Lighthouse::GraphQL::CacheControl::DEFINITION)
214
219
  Lighthouse::GraphQL::CacheControl::TYPE_DEFINITIONS.each { |name, sdl| register_type_definition(name, sdl) }
220
+ register_definition('feature', Lighthouse::GraphQL::FeatureDirective::DEFINITION)
221
+ Lighthouse::GraphQL::FeatureDirective::TYPE_DEFINITIONS.each { |name, sdl| register_type_definition(name, sdl) }
215
222
  Lighthouse::GraphQL::Directives::WhereConditionsDirective::DEFINITIONS.each do |name, definition|
216
223
  register_definition(name, definition)
217
224
  end
225
+ Lighthouse::GraphQL::Directives::SoftDeletesDirective::DEFINITIONS.each { |name, definition| register_definition(name, definition) }
218
226
  end
219
227
 
220
228
  def safe_definition(klass)
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Schema side of @softDeletes, as PHP Lighthouse does it: a field carrying it
4
+ # gains the `trashed` argument (see Arguments::Trashed for what it does), and
5
+ # the directive is consumed.
6
+ #
7
+ # tasks: [Task!]! @all @softDeletes -> tasks(trashed: Trashed @trashed): [Task!]! @all
8
+ #
9
+ # The `Trashed` enum is added once whenever a `@trashed` argument is in the
10
+ # schema (generated here or written by hand), unless the app declares its own.
11
+ class Lighthouse::GraphQL::Directives::SoftDeletesDirective
12
+ DEFINITIONS = {
13
+ 'softDeletes' => <<~GRAPHQL.chomp
14
+ """
15
+ Allows to filter if trashed elements should be fetched.
16
+ This manipulates the schema by adding the argument
17
+ `trashed: Trashed @trashed` to the field.
18
+ """
19
+ directive @softDeletes on FIELD_DEFINITION
20
+ GRAPHQL
21
+ }.freeze
22
+
23
+ TRASHED_ENUM = <<~GRAPHQL.chomp
24
+ "Specify if you want to include or exclude trashed results from a query."
25
+ enum Trashed {
26
+ "Only return trashed results."
27
+ ONLY
28
+ "Return both trashed and non-trashed results."
29
+ WITH
30
+ "Only return non-trashed results."
31
+ WITHOUT
32
+ }
33
+ GRAPHQL
34
+
35
+ ARGUMENT = %(type T { f("Specify if you want to include or exclude trashed results from a query." trashed: Trashed @trashed): Int })
36
+
37
+ FIELD_DEFINITIONS = [
38
+ ::GraphQL::Language::Nodes::ObjectTypeDefinition,
39
+ ::GraphQL::Language::Nodes::ObjectTypeExtension,
40
+ ::GraphQL::Language::Nodes::InterfaceTypeDefinition,
41
+ ::GraphQL::Language::Nodes::InterfaceTypeExtension
42
+ ].freeze
43
+
44
+ INPUT_DEFINITIONS = [
45
+ ::GraphQL::Language::Nodes::InputObjectTypeDefinition,
46
+ ::GraphQL::Language::Nodes::InputObjectTypeExtension
47
+ ].freeze
48
+
49
+ # The directives' own definitions are in every injected SDL; usage is what counts.
50
+ DECLARATION = /directive\s+@(softDeletes|trashed)\b/
51
+ USAGE = /@(softDeletes|trashed)\b/
52
+
53
+ def self.manipulate(sdl)
54
+ return sdl unless sdl.is_a?(String) && sdl.gsub(DECLARATION, '').match?(USAGE)
55
+
56
+ document = parse(sdl)
57
+ document ? new(document).manipulate(sdl) : sdl
58
+ end
59
+
60
+ def self.parse(sdl)
61
+ ::GraphQL::Language::Parser.parse(sdl)
62
+ rescue ::GraphQL::ParseError => e
63
+ warn("Lighthouse soft deletes: parse failed: #{e.message}")
64
+ nil
65
+ end
66
+ private_class_method :parse
67
+
68
+ def initialize(document)
69
+ @document = document
70
+ @rewritten = false
71
+ @trashed = false
72
+ end
73
+
74
+ # The SDL with the `trashed` arguments added and `Trashed` defined. Left
75
+ # untouched when there is nothing to rewrite.
76
+ def manipulate(sdl)
77
+ definitions = @document.definitions.map { |definition| rewrite(definition) }
78
+ printed = @rewritten ? ::GraphQL::Language::Printer.new.print(@document.merge(definitions: definitions)) : sdl
79
+ @trashed && !enum_declared? ? "#{printed}\n\n#{TRASHED_ENUM}" : printed
80
+ end
81
+
82
+ private
83
+
84
+ def rewrite(definition)
85
+ if FIELD_DEFINITIONS.any? { |kind| definition.is_a?(kind) }
86
+ definition.merge(fields: definition.fields.map { |field| soft_delete(field) })
87
+ else
88
+ note_input_arguments(definition)
89
+ definition
90
+ end
91
+ end
92
+
93
+ # A hand-written @trashed on an input field needs the enum too.
94
+ def note_input_arguments(definition)
95
+ return unless INPUT_DEFINITIONS.any? { |kind| definition.is_a?(kind) }
96
+
97
+ @trashed = true if definition.fields.any? { |field| trashed?(field) }
98
+ end
99
+
100
+ def soft_delete(field)
101
+ if field.directives.any? { |directive| directive.name == 'softDeletes' }
102
+ @rewritten = true
103
+ field = field.merge(arguments: with_trashed_argument(field.arguments),
104
+ directives: field.directives.reject { |directive| directive.name == 'softDeletes' })
105
+ end
106
+ @trashed ||= field.arguments.any? { |argument| trashed?(argument) }
107
+ field
108
+ end
109
+
110
+ def with_trashed_argument(arguments)
111
+ return arguments if arguments.any? { |argument| argument.name == 'trashed' }
112
+
113
+ arguments + ::GraphQL::Language::Parser.parse(ARGUMENT).definitions.first.fields.first.arguments
114
+ end
115
+
116
+ def trashed?(node)
117
+ node.directives.any? { |directive| directive.name == 'trashed' }
118
+ end
119
+
120
+ def enum_declared?
121
+ @document.definitions.any? { |definition| definition.is_a?(::GraphQL::Language::Nodes::EnumTypeDefinition) && definition.name == 'Trashed' }
122
+ end
123
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The graphql-ruby trace module SchemaGenerator.build installs on every schema,
4
+ # dispatching the execution events (StartExecution, BuildExtensionsResponse,
5
+ # ManipulateResult, EndExecution) around each query of a multiplex. A query
6
+ # runs through a multiplex even on its own. With nothing listening it adds one
7
+ # lookup per request.
8
+ module Lighthouse::Events::ExecutionTrace
9
+ EVENTS = [
10
+ Lighthouse::Events::StartExecution, Lighthouse::Events::BuildExtensionsResponse,
11
+ Lighthouse::Events::ManipulateResult, Lighthouse::Events::EndExecution
12
+ ].freeze
13
+
14
+ def execute_multiplex(multiplex:)
15
+ return super unless EVENTS.any? { |event| Lighthouse::Events.listening?(event) }
16
+
17
+ multiplex.queries.each { |query| Lighthouse::Events::ExecutionTrace.start(multiplex.schema, query) }
18
+ results = super
19
+ multiplex.queries.each { |query| Lighthouse::Events::ExecutionTrace.finish(query) }
20
+ results
21
+ end
22
+
23
+ class << self
24
+ def start(schema, query)
25
+ Lighthouse::Events.dispatch(
26
+ Lighthouse::Events::StartExecution.new(schema, query.document, query.provided_variables, query.operation_name, query.context)
27
+ )
28
+ end
29
+
30
+ # The query's result Hash is final but for the listeners: they add
31
+ # extensions and may change it in place before the response is built.
32
+ def finish(query)
33
+ result = query.result_values
34
+ return unless result.is_a?(Hash)
35
+
36
+ merge_extensions(result, Lighthouse::Events.dispatch(Lighthouse::Events::BuildExtensionsResponse.new(result, nil)))
37
+ manipulate = Lighthouse::Events::ManipulateResult.new(result)
38
+ Lighthouse::Events.dispatch(manipulate)
39
+ result.replace(manipulate.result) unless manipulate.result.equal?(result)
40
+ Lighthouse::Events.dispatch(Lighthouse::Events::EndExecution.new(result))
41
+ end
42
+
43
+ def merge_extensions(result, responses)
44
+ responses = responses.flatten.grep(Lighthouse::Events::ExtensionsResponse)
45
+ return if responses.empty?
46
+
47
+ extensions = (result['extensions'] ||= {})
48
+ responses.each { |response| extensions[response.key.to_s] = response.content }
49
+ end
50
+ end
51
+ end