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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -1
- data/README.md +22 -3
- data/docs/best-practices.md +16 -6
- data/docs/caching.md +1 -1
- data/docs/configuration.md +74 -5
- data/docs/custom-directives.md +11 -1
- data/docs/directives.md +333 -2
- data/docs/events.md +203 -0
- data/docs/federation.md +3 -2
- data/docs/getting-started.md +90 -23
- data/docs/motivation.md +3 -2
- data/docs/mutations.md +15 -0
- data/docs/resolvers.md +95 -19
- data/docs/security.md +210 -0
- data/docs/subscriptions.md +446 -0
- data/lib/generators/lighthouse/base_classes.rb +87 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +23 -6
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +7 -45
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +34 -5
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +14 -2
- data/lib/generators/lighthouse/mutation_generator.rb +5 -4
- data/lib/generators/lighthouse/query_generator.rb +5 -4
- data/lib/generators/lighthouse/subscription_generator.rb +100 -0
- data/lib/lighthouse/broadcast_job.rb +14 -0
- data/lib/lighthouse/broadcasting.rb +143 -0
- data/lib/lighthouse/cache_control.rb +5 -2
- data/lib/lighthouse/complexity.rb +174 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +56 -14
- data/lib/lighthouse/controller.rb +131 -0
- data/lib/lighthouse/directive_resolvers/broadcast_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +14 -4
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +134 -0
- data/lib/lighthouse/directive_resolvers/event_resolver.rb +37 -0
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +28 -13
- data/lib/lighthouse/directive_resolvers/soft_delete_resolvers.rb +66 -0
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +76 -0
- data/lib/lighthouse/directives/arguments/soft_deletes.rb +112 -0
- data/lib/lighthouse/directives/registry.rb +15 -6
- data/lib/lighthouse/directives/soft_deletes_directive.rb +123 -0
- data/lib/lighthouse/events/execution_trace.rb +52 -0
- data/lib/lighthouse/events/lifecycle.rb +148 -0
- data/lib/lighthouse/events/schema_build.rb +44 -0
- data/lib/lighthouse/events.rb +76 -0
- data/lib/lighthouse/feature_directive.rb +137 -0
- data/lib/lighthouse/field_resolver_stack.rb +28 -7
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/persisted_queries/batch.rb +34 -0
- data/lib/lighthouse/persisted_queries.rb +17 -4
- data/lib/lighthouse/rb_lighthouse.rb +23 -0
- data/lib/lighthouse/resolver_bases.rb +61 -0
- data/lib/lighthouse/schema_cache.rb +12 -5
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +42 -9
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/subscription.rb +120 -0
- data/lib/lighthouse/support/model_resolver.rb +1 -1
- data/lib/lighthouse/support/soft_deletes.rb +164 -0
- data/lib/lighthouse/tasks/lighthouse.rake +10 -0
- data/sig/lighthouse/controller.rbs +30 -0
- data/sig/lighthouse/events.rbs +122 -0
- data/sig/lighthouse/graphql.rbs +62 -0
- data/sig/lighthouse.rbs +52 -1
- 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:
|
|
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
|