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,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,76 @@
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 resolves with the schema's base_types[:subscription]
12
+ # when given, else with Lighthouse::GraphQL::Subscription::Default, whose update
13
+ # is the broadcast root, which is all `@broadcast` needs.
14
+ #
15
+ # graphql-ruby registers a subscription through the class its field was
16
+ # built from, which a field built from SDL has not. The field is therefore
17
+ # told which class it stands for: its `resolver` (read by graphql-ruby to build
18
+ # the topic, and to know the subscription instance wrote itself) and its
19
+ # `subscription_scope` answer for the class. The class is instantiated for
20
+ # each request, so its hooks (`subscribe`, `update`, ...) run as usual.
21
+ class Lighthouse::GraphQL::DirectiveResolvers::SubscriptionDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
22
+ def self.directive_name = 'subscription'
23
+ def self.definition = 'directive @subscription(class: String!) on FIELD_DEFINITION'
24
+
25
+ def define_resolver(target_type_class)
26
+ unless @opts[:root_kind] == :subscriptions
27
+ raise Lighthouse::DefinitionError, "@subscription is for fields of the Subscription type, not #{location}."
28
+ end
29
+
30
+ me = self
31
+ @field_def.define_singleton_method(:resolver) { me.subscription_class }
32
+ @field_def.define_singleton_method(:subscription_scope) { me.subscription_class.subscription_scope }
33
+
34
+ install(target_type_class, lambda do |obj, args, ctx|
35
+ Lighthouse::GraphQL::ResolverDispatch.call(me.subscription_class, me.field_def, obj, args, ctx)
36
+ end)
37
+ end
38
+
39
+ attr_reader :field_def
40
+
41
+ # The class this field stands for: the one @subscription names, else the
42
+ # conventional one, else the schema's base subscription, else the default.
43
+ def subscription_class
44
+ klass = find_class
45
+ return base || Lighthouse::GraphQL::Subscription::Default unless klass
46
+
47
+ unless klass.is_a?(Class) && klass < ::GraphQL::Schema::Subscription
48
+ raise Lighthouse::DefinitionError, "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Subscription."
49
+ end
50
+
51
+ Lighthouse::GraphQL::ResolverBases.check!(klass, base, location)
52
+
53
+ klass
54
+ end
55
+
56
+ private
57
+
58
+ # The schema's base_types[:subscription], or nil.
59
+ def base
60
+ (@opts[:bases] || {})[:subscriptions]
61
+ end
62
+
63
+ def find_class
64
+ namespaces = Lighthouse::Config.namespaces[:subscriptions]
65
+ named = directive_arg('class') if @directive_node
66
+ klass = Lighthouse::Support::ModelResolver.constantize(named || @field_def.name.to_s.camelize, namespaces: namespaces, fallback: false)
67
+ return klass if klass || named.nil?
68
+
69
+ expected = Array(namespaces).map { |ns| "#{ns}::#{named}" }.join(' or ')
70
+ raise Lighthouse::DefinitionError, "@subscription(class: #{named.inspect}) on #{location}: no such class (looked for #{expected})."
71
+ end
72
+
73
+ def location
74
+ "#{@type_def.graphql_name}.#{@field_def.name}"
75
+ end
76
+ 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,13 @@ 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
+ register_definition('complexity', Lighthouse::GraphQL::Complexity::DEFINITION)
222
+ Lighthouse::GraphQL::FeatureDirective::TYPE_DEFINITIONS.each { |name, sdl| register_type_definition(name, sdl) }
215
223
  Lighthouse::GraphQL::Directives::WhereConditionsDirective::DEFINITIONS.each do |name, definition|
216
224
  register_definition(name, definition)
217
225
  end
226
+ Lighthouse::GraphQL::Directives::SoftDeletesDirective::DEFINITIONS.each { |name, definition| register_definition(name, definition) }
218
227
  end
219
228
 
220
229
  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,52 @@
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
+ complexity = Lighthouse::GraphQL::Complexity.query_complexity(query)
37
+ merge_extensions(result, Lighthouse::Events.dispatch(Lighthouse::Events::BuildExtensionsResponse.new(result, complexity)))
38
+ manipulate = Lighthouse::Events::ManipulateResult.new(result)
39
+ Lighthouse::Events.dispatch(manipulate)
40
+ result.replace(manipulate.result) unless manipulate.result.equal?(result)
41
+ Lighthouse::Events.dispatch(Lighthouse::Events::EndExecution.new(result))
42
+ end
43
+
44
+ def merge_extensions(result, responses)
45
+ responses = responses.flatten.grep(Lighthouse::Events::ExtensionsResponse)
46
+ return if responses.empty?
47
+
48
+ extensions = (result['extensions'] ||= {})
49
+ responses.each { |response| extensions[response.key.to_s] = response.content }
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The events Lighthouse dispatches, in the order a request meets them (see
4
+ # docs/events.md). All inherit Events::Event, so `moment` is there when a
5
+ # class says so.
6
+ module Lighthouse::Events
7
+ # A request reached the controller. `request` is the Rack/Rails request.
8
+ class StartRequest < Event
9
+ attr_reader :request
10
+
11
+ def initialize(request)
12
+ super()
13
+ @request = request
14
+ end
15
+ end
16
+
17
+ # The operation(s) of a request were read, before they run: one Hash
18
+ # (query, variables, operation_name, extensions) or, for a batch, an Array of them.
19
+ class StartOperationOrOperations < Event
20
+ attr_reader :operation_or_operations
21
+
22
+ def initialize(operation_or_operations)
23
+ super()
24
+ @operation_or_operations = operation_or_operations
25
+ end
26
+ end
27
+
28
+ # The schema is being built from `user_schema`, the SDL of the app. A
29
+ # listener may return SDL (a String) to append to it.
30
+ class BuildSchemaString < Event
31
+ attr_reader :user_schema
32
+
33
+ def initialize(user_schema)
34
+ super()
35
+ @user_schema = user_schema
36
+ end
37
+ end
38
+
39
+ # The directive classes are being registered. A listener may return a
40
+ # namespace (a Module or its name) or an Array of them; every directive class
41
+ # under them is registered.
42
+ class RegisterDirectiveNamespaces < Event
43
+ end
44
+
45
+ # The schema document was built and manipulated, before the schema is built
46
+ # from it. graphql-ruby's nodes are immutable: a listener sets `document_ast`
47
+ # to the document to build from.
48
+ class ManipulateAST < Event
49
+ attr_accessor :document_ast
50
+
51
+ def initialize(document_ast)
52
+ super()
53
+ @document_ast = document_ast
54
+ end
55
+ end
56
+
57
+ # A query is about to run. `query` is its parsed GraphQL::Language::Nodes::Document
58
+ # (nil if it does not parse). Fires once per operation of a multiplex.
59
+ class StartExecution < Event
60
+ attr_reader :schema, :query, :variables, :operation_name, :context
61
+
62
+ def initialize(schema, query, variables, operation_name, context)
63
+ super()
64
+ @schema = schema
65
+ @query = query
66
+ @variables = variables
67
+ @operation_name = operation_name
68
+ @context = context
69
+ end
70
+ end
71
+
72
+ # A query ran. A listener may return an Events::ExtensionsResponse (or an
73
+ # Array of them), which is merged into the result's "extensions".
74
+ # `query_complexity` is the query's complexity score, as graphql-ruby checks it
75
+ # against max_complexity (see GraphQL::Complexity); nil for a query that
76
+ # failed validation, which is never scored.
77
+ class BuildExtensionsResponse < Event
78
+ attr_reader :result, :query_complexity
79
+
80
+ def initialize(result, query_complexity)
81
+ super()
82
+ @result = result
83
+ @query_complexity = query_complexity
84
+ end
85
+ end
86
+
87
+ # The result Hash ("data", "errors", "extensions") of a query, before it is
88
+ # returned. A listener may change it in place, or set `result` to replace it.
89
+ class ManipulateResult < Event
90
+ attr_accessor :result
91
+
92
+ def initialize(result)
93
+ super()
94
+ @result = result
95
+ end
96
+ end
97
+
98
+ # A query ran and its result is final.
99
+ class EndExecution < Event
100
+ attr_reader :result
101
+
102
+ def initialize(result)
103
+ super()
104
+ @result = result
105
+ end
106
+ end
107
+
108
+ # The operation(s) ran: the result, or for a batch an Array of results.
109
+ class EndOperationOrOperations < Event
110
+ attr_reader :result_or_results
111
+
112
+ def initialize(result_or_results)
113
+ super()
114
+ @result_or_results = result_or_results
115
+ end
116
+ end
117
+
118
+ # The response is rendered. `response` is the Rack/Rails response.
119
+ class EndRequest < Event
120
+ attr_reader :response
121
+
122
+ def initialize(response)
123
+ super()
124
+ @response = response
125
+ end
126
+ end
127
+
128
+ # The schema is being validated (`rake lighthouse:validate_schema`).
129
+ class ValidateSchema < Event
130
+ attr_reader :schema
131
+
132
+ def initialize(schema)
133
+ super()
134
+ @schema = schema
135
+ end
136
+ end
137
+
138
+ # A key and its content for the "extensions" of a result, as a listener of
139
+ # BuildExtensionsResponse returns it.
140
+ class ExtensionsResponse
141
+ attr_reader :key, :content
142
+
143
+ def initialize(key, content)
144
+ @key = key
145
+ @content = content
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The events of the schema build, as SchemaGenerator.build_document meets them.
4
+ module Lighthouse::Events::SchemaBuild
5
+ module_function
6
+
7
+ # Dispatch RegisterDirectiveNamespaces and register every directive class
8
+ # under the namespaces the listeners return (a Module, its name, or an
9
+ # Array of them), in the default registry.
10
+ def register_directive_namespaces(registry = Lighthouse::DirectiveRegistry.default)
11
+ namespaces = Lighthouse::Events.dispatch(Lighthouse::Events::RegisterDirectiveNamespaces.new).flatten
12
+ namespaces = namespaces.select { |namespace| namespace.is_a?(String) || namespace.is_a?(Module) }
13
+ namespaces.each { |namespace| register_namespace(resolve_namespace(namespace), registry) }
14
+ end
15
+
16
+ # The SDL with what the listeners of BuildSchemaString return appended.
17
+ def schema_string(sdl)
18
+ return sdl unless sdl.is_a?(String)
19
+
20
+ added = Lighthouse::Events.dispatch(Lighthouse::Events::BuildSchemaString.new(sdl)).flatten.grep(String)
21
+ added.empty? ? sdl : [sdl, *added].join("\n")
22
+ end
23
+
24
+ # The document the listeners of ManipulateAST leave on the event.
25
+ def manipulate_ast(document)
26
+ event = Lighthouse::Events::ManipulateAST.new(document)
27
+ Lighthouse::Events.dispatch(event)
28
+ event.document_ast
29
+ end
30
+
31
+ def resolve_namespace(namespace)
32
+ return namespace if namespace.is_a?(Module)
33
+
34
+ Lighthouse::Support::ModelResolver.constantize(namespace, namespaces: []) ||
35
+ raise(Lighthouse::DefinitionError, "RegisterDirectiveNamespaces: no namespace #{namespace}")
36
+ end
37
+
38
+ def register_namespace(namespace, registry)
39
+ namespace.constants(false).each do |name|
40
+ directive = namespace.const_get(name)
41
+ registry.register(directive) if directive.respond_to?(:directive_name)
42
+ end
43
+ end
44
+ end