lighthouse-graphql 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +182 -1
- data/README.md +18 -7
- data/docs/best-practices.md +16 -6
- data/docs/configuration.md +33 -8
- data/docs/directives.md +318 -10
- data/docs/events.md +3 -1
- data/docs/federation.md +3 -2
- data/docs/generators.md +82 -0
- data/docs/getting-started.md +66 -32
- data/docs/motivation.md +3 -2
- data/docs/relationships.md +42 -0
- data/docs/relay.md +137 -0
- data/docs/resolvers.md +338 -25
- data/docs/security.md +227 -0
- data/docs/subscriptions.md +60 -37
- data/docs/types.md +219 -0
- data/docs/uploads.md +80 -0
- data/lib/generators/lighthouse/base_classes.rb +70 -0
- data/lib/generators/lighthouse/class_generator.rb +19 -8
- data/lib/generators/lighthouse/install/install_generator.rb +96 -20
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -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 +21 -16
- data/lib/lighthouse/async_mutation_job.rb +17 -0
- data/lib/lighthouse/complexity.rb +175 -0
- data/lib/lighthouse/complexity_analyzer.rb +56 -0
- data/lib/lighthouse/config.rb +16 -4
- data/lib/lighthouse/controller.rb +27 -9
- data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
- data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
- data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
- data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
- data/lib/lighthouse/directive_resolvers/base.rb +63 -14
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
- data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
- data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
- data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
- data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
- data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +14 -5
- data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
- data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
- data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
- data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
- data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
- data/lib/lighthouse/directives/definitions.rb +52 -0
- data/lib/lighthouse/directives/model_directive.rb +131 -0
- data/lib/lighthouse/directives/node_directive.rb +134 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
- data/lib/lighthouse/directives/registry.rb +21 -20
- data/lib/lighthouse/directives/visibility_directive.rb +71 -0
- data/lib/lighthouse/enum_directive.rb +41 -0
- data/lib/lighthouse/events/execution_trace.rb +2 -1
- data/lib/lighthouse/events/lifecycle.rb +3 -1
- data/lib/lighthouse/field_resolver_stack.rb +59 -11
- data/lib/lighthouse/generated_resolvers.rb +13 -8
- data/lib/lighthouse/global_id.rb +31 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/multipart_request.rb +43 -0
- data/lib/lighthouse/node_registry.rb +68 -0
- data/lib/lighthouse/pagination.rb +3 -0
- data/lib/lighthouse/rb_lighthouse.rb +36 -0
- data/lib/lighthouse/relation_counts.rb +29 -0
- data/lib/lighthouse/resolve_info.rb +80 -0
- data/lib/lighthouse/resolver_bases.rb +68 -0
- data/lib/lighthouse/resolver_dispatch.rb +36 -11
- data/lib/lighthouse/scalar_coercion.rb +72 -0
- data/lib/lighthouse/schema_cache.rb +2 -1
- data/lib/lighthouse/schema_factory.rb +0 -1
- data/lib/lighthouse/schema_generator.rb +21 -12
- data/lib/lighthouse/schema_implementation.rb +4 -8
- data/lib/lighthouse/sdl_loader.rb +0 -2
- data/lib/lighthouse/support/environment.rb +13 -0
- data/lib/lighthouse/support/model_resolver.rb +8 -6
- data/lib/lighthouse/support/preload.rb +49 -0
- data/lib/lighthouse/type.rb +103 -0
- data/lib/lighthouse/type_resolution.rb +150 -0
- data/lib/lighthouse/upload.rb +26 -0
- data/lib/lighthouse/upload_store.rb +32 -0
- data/sig/lighthouse/directives.rbs +3 -2
- data/sig/lighthouse/graphql.rbs +29 -0
- data/sig/lighthouse.rbs +18 -0
- metadata +43 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @namespaced — a field that only groups others, as PHP Lighthouse's
|
|
6
|
+
# directive: it resolves to `true`, and the fields of its type resolve with
|
|
7
|
+
# their own directives.
|
|
8
|
+
#
|
|
9
|
+
# type Query { post: PostQueries! @namespaced }
|
|
10
|
+
# type PostQueries {
|
|
11
|
+
# find(id: ID! @eq): Post @find
|
|
12
|
+
# list: [Post!]! @paginate
|
|
13
|
+
# }
|
|
14
|
+
#
|
|
15
|
+
# `{ post { find(id: 1) { title } } }`. Works on Mutation too.
|
|
16
|
+
class Lighthouse::GraphQL::DirectiveResolvers::NamespacedDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
17
|
+
def self.directive_name = 'namespaced'
|
|
18
|
+
|
|
19
|
+
def self.definition
|
|
20
|
+
<<~GRAPHQL.chomp
|
|
21
|
+
"""
|
|
22
|
+
A no-op field resolver that allows nesting of queries and mutations.
|
|
23
|
+
"""
|
|
24
|
+
directive @namespaced on FIELD_DEFINITION
|
|
25
|
+
GRAPHQL
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def define_resolver(target_type_class)
|
|
29
|
+
install(target_type_class, ->(_obj, _args, _ctx) { true })
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -75,7 +75,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::PaginateDirectiveResolver < Light
|
|
|
75
75
|
# `scopes:`, @builder and the argument filters, on one relation or a RelationBatch.
|
|
76
76
|
def decorate_relation(relation, args, ctx)
|
|
77
77
|
relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(apply_scopes(relation), @field_def.ast_node, args, ctx, self)
|
|
78
|
-
apply_where_filters(relation, args)
|
|
78
|
+
apply_where_filters(relation, args, ctx)
|
|
79
79
|
end
|
|
80
80
|
|
|
81
81
|
protected
|
|
@@ -18,15 +18,30 @@ module Lighthouse::GraphQL::DirectiveResolvers::Resolution
|
|
|
18
18
|
original_singleton.call(obj, arguments, ctx)
|
|
19
19
|
elsif original_proc
|
|
20
20
|
original_proc.call(obj, arguments, ctx)
|
|
21
|
-
elsif obj.respond_to?(field_name)
|
|
22
|
-
obj.public_send(field_name)
|
|
23
21
|
else
|
|
24
|
-
|
|
25
|
-
obj.respond_to?(snake) ? obj.public_send(snake) : nil
|
|
22
|
+
Lighthouse::GraphQL::DirectiveResolvers::Resolution.read(obj, field_name)
|
|
26
23
|
end
|
|
27
24
|
end
|
|
28
25
|
end
|
|
29
26
|
|
|
27
|
+
# The default reader: a Hash's key, else the object's method of the field's
|
|
28
|
+
# name or its snake_case.
|
|
29
|
+
def read(obj, field_name)
|
|
30
|
+
return read_hash(obj, field_name) if obj.is_a?(Hash)
|
|
31
|
+
return obj.public_send(field_name) if obj.respond_to?(field_name)
|
|
32
|
+
|
|
33
|
+
snake = Lighthouse::Support::Naming.attribute(field_name)
|
|
34
|
+
obj.respond_to?(snake) ? obj.public_send(snake) : nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# A Hash's value for the field, as the default reader finds it: by the field's
|
|
38
|
+
# name or its snake_case, as a String or a Symbol key.
|
|
39
|
+
def read_hash(hash, field_name)
|
|
40
|
+
snake = Lighthouse::Support::Naming.attribute(field_name)
|
|
41
|
+
key = [field_name.to_s, field_name.to_sym, snake, snake.to_sym].find { |candidate| hash.key?(candidate) }
|
|
42
|
+
key.nil? ? nil : hash[key]
|
|
43
|
+
end
|
|
44
|
+
|
|
30
45
|
# graphql-ruby's SDL builder defines `resolve_field_*` methods of its own
|
|
31
46
|
# (with a different arity); only ours, defined under lib/lighthouse/, count.
|
|
32
47
|
def lighthouse_singleton(target_type_class, method_name)
|
|
@@ -8,8 +8,9 @@ require_relative 'base'
|
|
|
8
8
|
# Config.namespaces[:subscriptions] at call time so code reloading keeps
|
|
9
9
|
# working. The class is a GraphQL::Schema::Subscription, which may include
|
|
10
10
|
# Lighthouse::GraphQL::Subscription for PHP Lighthouse's hooks. A field
|
|
11
|
-
# with no class still works: it
|
|
12
|
-
#
|
|
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.
|
|
13
14
|
#
|
|
14
15
|
# graphql-ruby registers a subscription through the class its field was
|
|
15
16
|
# built from, which a field built from SDL has not. The field is therefore
|
|
@@ -31,27 +32,35 @@ class Lighthouse::GraphQL::DirectiveResolvers::SubscriptionDirectiveResolver < L
|
|
|
31
32
|
@field_def.define_singleton_method(:subscription_scope) { me.subscription_class.subscription_scope }
|
|
32
33
|
|
|
33
34
|
install(target_type_class, lambda do |obj, args, ctx|
|
|
34
|
-
Lighthouse::GraphQL::
|
|
35
|
+
info = Lighthouse::GraphQL::ResolveInfo.for(me.field_def, target_type_class, obj, args, ctx)
|
|
36
|
+
Lighthouse::GraphQL::ResolverDispatch.call(me.subscription_class, info)
|
|
35
37
|
end)
|
|
36
38
|
end
|
|
37
39
|
|
|
38
40
|
attr_reader :field_def
|
|
39
41
|
|
|
40
42
|
# The class this field stands for: the one @subscription names, else the
|
|
41
|
-
# conventional one, else the default.
|
|
43
|
+
# conventional one, else the schema's base subscription, else the default.
|
|
42
44
|
def subscription_class
|
|
43
45
|
klass = find_class
|
|
44
|
-
return Lighthouse::GraphQL::Subscription::Default unless klass
|
|
46
|
+
return base || Lighthouse::GraphQL::Subscription::Default unless klass
|
|
45
47
|
|
|
46
48
|
unless klass.is_a?(Class) && klass < ::GraphQL::Schema::Subscription
|
|
47
49
|
raise Lighthouse::DefinitionError, "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Subscription."
|
|
48
50
|
end
|
|
49
51
|
|
|
52
|
+
Lighthouse::GraphQL::ResolverBases.check!(klass, base, location)
|
|
53
|
+
|
|
50
54
|
klass
|
|
51
55
|
end
|
|
52
56
|
|
|
53
57
|
private
|
|
54
58
|
|
|
59
|
+
# The schema's base_types[:subscription], or nil.
|
|
60
|
+
def base
|
|
61
|
+
(@opts[:bases] || {})[:subscriptions]
|
|
62
|
+
end
|
|
63
|
+
|
|
55
64
|
def find_class
|
|
56
65
|
namespaces = Lighthouse::Config.namespaces[:subscriptions]
|
|
57
66
|
named = directive_arg('class') if @directive_node
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @throttle — rate-limit a field per client, as PHP Lighthouse's directive (and
|
|
6
|
+
# Laravel's ThrottleRequests): at most `maxAttempts` calls every `decayMinutes`,
|
|
7
|
+
# counted in Config.cache_store. A client is the signed-in user, else the
|
|
8
|
+
# context's :remote_ip (Lighthouse::GraphQL::Controller sets it), else "guest".
|
|
9
|
+
# Past the limit the field fails with "Rate limit for Query.x exceeded. Try
|
|
10
|
+
# again later." Outermost: refused calls never reach @guard or the resolver.
|
|
11
|
+
#
|
|
12
|
+
# type Mutation {
|
|
13
|
+
# login(email: String!, password: String!): User @throttle(maxAttempts: 5)
|
|
14
|
+
# search(term: String!): [Post!]! @throttle(name: "search", maxAttempts: 30, decayMinutes: 0.5)
|
|
15
|
+
# }
|
|
16
|
+
class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
17
|
+
def self.directive_name = 'throttle'
|
|
18
|
+
|
|
19
|
+
def self.definition
|
|
20
|
+
<<~GRAPHQL.chomp
|
|
21
|
+
"""
|
|
22
|
+
Sets rate limit to access the field. Does the same as ThrottleRequests Laravel Middleware.
|
|
23
|
+
"""
|
|
24
|
+
directive @throttle(
|
|
25
|
+
"A name shared by the fields that count together. Defaults to the field."
|
|
26
|
+
name: String
|
|
27
|
+
"Maximum number of attempts in a specified time interval."
|
|
28
|
+
maxAttempts: Int = 60
|
|
29
|
+
"Time in minutes to reset attempts."
|
|
30
|
+
decayMinutes: Float = 1.0
|
|
31
|
+
"Prefix to distinguish several field groups."
|
|
32
|
+
prefix: String
|
|
33
|
+
) on FIELD_DEFINITION
|
|
34
|
+
GRAPHQL
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def define_resolver(target_type_class)
|
|
38
|
+
me = self
|
|
39
|
+
inner = capture_inner(target_type_class)
|
|
40
|
+
location = "#{target_type_class.graphql_name}.#{@field_def.name}"
|
|
41
|
+
bucket = "#{directive_arg('prefix')}#{directive_arg('name') || location}"
|
|
42
|
+
max_attempts = directive_arg('maxAttempts') || 60
|
|
43
|
+
decay = ((directive_arg('decayMinutes') || 1.0) * 60).ceil
|
|
44
|
+
|
|
45
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
46
|
+
me.hit!(bucket, ctx, max_attempts, decay, location)
|
|
47
|
+
inner.call(obj, args, ctx)
|
|
48
|
+
end)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def hit!(bucket, ctx, max_attempts, decay, location)
|
|
52
|
+
key = "lighthouse:throttle:#{bucket}:#{client(ctx)}"
|
|
53
|
+
store = Lighthouse::GraphQL::Caching.store
|
|
54
|
+
self.class.warn_null_store(location) if store.is_a?(::ActiveSupport::Cache::NullStore)
|
|
55
|
+
attempts = store.increment(key, 1, expires_in: decay) || (store.write(key, 1, expires_in: decay) && 1)
|
|
56
|
+
raise ::GraphQL::ExecutionError, "Rate limit for #{location} exceeded. Try again later." if attempts > max_attempts
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# A :null_store keeps no count, so the limit never applies: say so, once per field.
|
|
60
|
+
def self.warn_null_store(location)
|
|
61
|
+
@warned ||= Set.new
|
|
62
|
+
return unless @warned.add?(location)
|
|
63
|
+
|
|
64
|
+
message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. Set Config.cache_store."
|
|
65
|
+
defined?(Rails) && Rails.logger ? Rails.logger.warn(message) : warn(message)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
def client(ctx)
|
|
71
|
+
user = Lighthouse::Support::Authorization.authenticated_user(ctx)
|
|
72
|
+
return "user:#{user.respond_to?(:id) ? user.id : user}" if user
|
|
73
|
+
|
|
74
|
+
ip = ctx.respond_to?(:[]) ? ctx[:remote_ip] : nil
|
|
75
|
+
ip ? "ip:#{ip}" : 'guest'
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# Resolves a field of an object type through a method of the type's class, in
|
|
6
|
+
# graphql-ruby's folder structure: `Post.readingTime` calls
|
|
7
|
+
# `Types::PostType#reading_time` when that class includes Lighthouse::GraphQL::Type
|
|
8
|
+
# and defines it. The SDL still declares the field; the class only says how it
|
|
9
|
+
# resolves, as a graphql-ruby type's method does: an instance per parent, with
|
|
10
|
+
# `object` (the parent), `context`, the `dataload*` helpers, and the field's
|
|
11
|
+
# arguments as snake_case keywords. A field with a resolving directive never
|
|
12
|
+
# reaches it, and one whose name the class does not define is read off the
|
|
13
|
+
# object as before. Not a directive: FieldResolverStack installs it on types
|
|
14
|
+
# other than the root ones.
|
|
15
|
+
class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
16
|
+
# The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
|
|
17
|
+
# Config.namespaces[:types], when it includes Lighthouse::GraphQL::Type; nil
|
|
18
|
+
# otherwise, so a type class that does not opt in is left alone. It must
|
|
19
|
+
# inherit from the schema's base_types[:object] (in `bases`, ResolverBases)
|
|
20
|
+
# when one is given.
|
|
21
|
+
def self.type_class(type_name, bases = {})
|
|
22
|
+
klass = lookup(type_name)
|
|
23
|
+
return unless klass
|
|
24
|
+
|
|
25
|
+
Lighthouse::GraphQL::ResolverBases.check!(klass, bases.dig(:types, :object), "the #{type_name} type")
|
|
26
|
+
klass
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.lookup(type_name)
|
|
30
|
+
Lighthouse::GraphQL::Type.class_for(type_name, :object)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# The method of `klass` that resolves `field_def` (`reading_time` for
|
|
34
|
+
# `readingTime`), or nil. Methods every GraphQL::Schema::Object has
|
|
35
|
+
# (`object`, `context`, Object's own) never count.
|
|
36
|
+
def self.method_for(klass, field_def)
|
|
37
|
+
name = Lighthouse::Support::Naming.attribute(field_def.name)
|
|
38
|
+
name if klass.public_method_defined?(name) && !::GraphQL::Schema::Object.public_method_defined?(name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(field_def, type_def, method_name)
|
|
42
|
+
super(field_def, type_def, nil)
|
|
43
|
+
@method_name = method_name
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def define_resolver(target_type_class)
|
|
47
|
+
Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
|
|
48
|
+
me = self
|
|
49
|
+
inner = capture_inner(target_type_class)
|
|
50
|
+
|
|
51
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
52
|
+
me.send(:resolve_with_type, inner, obj, args, ctx)
|
|
53
|
+
end)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# The class is looked up on each call, so code reloading keeps working; when
|
|
59
|
+
# it no longer defines the method, the field is read as before. A parent that
|
|
60
|
+
# is not a record of the type's @model is reported, and the field resolves to nil.
|
|
61
|
+
def resolve_with_type(inner, obj, args, ctx)
|
|
62
|
+
klass = self.class.lookup(@type_def.graphql_name)
|
|
63
|
+
return inner.call(obj, args, ctx) unless klass&.public_method_defined?(@method_name)
|
|
64
|
+
|
|
65
|
+
model = Lighthouse::GraphQL::Directives::ModelDirective.declared_model(@type_def)
|
|
66
|
+
return wrong_parent(klass, model, obj, ctx) if model && !obj.is_a?(model)
|
|
67
|
+
|
|
68
|
+
info = Lighthouse::GraphQL::ResolveInfo.for(@field_def, @type_def, obj, args, ctx)
|
|
69
|
+
instance = Lighthouse::GraphQL::ResolveInfo.attach(klass.send(:new, obj, ctx), info)
|
|
70
|
+
kwargs = Lighthouse::Support::Arguments.prepare(@field_def, args, ctx)
|
|
71
|
+
kwargs.empty? ? instance.public_send(@method_name) : instance.public_send(@method_name, **kwargs)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def wrong_parent(klass, model, obj, ctx)
|
|
75
|
+
error = Lighthouse::DefinitionError.new(
|
|
76
|
+
"#{@type_def.graphql_name}.#{@field_def.name} resolves with #{klass}, but its parent is a #{obj.class}, not a #{model} (@model)."
|
|
77
|
+
)
|
|
78
|
+
Lighthouse::Config.handle_error(error, ctx)
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @withCount(relation: "posts", scopes: [...]) — count a relation of the parent
|
|
6
|
+
# before the field resolves, as PHP Lighthouse's @withCount does: the count is not
|
|
7
|
+
# the field's value, it is kept on the parent as `<relation>_count`
|
|
8
|
+
# (`user.posts_count`) for whatever resolves the field to read. Every parent in
|
|
9
|
+
# the response is counted in one query.
|
|
10
|
+
#
|
|
11
|
+
# type User {
|
|
12
|
+
# activity: String! @withCount(relation: "posts") @method(name: "activity_label")
|
|
13
|
+
# postsCount: Int! @withCount(relation: "posts") # the default reader reads posts_count
|
|
14
|
+
# }
|
|
15
|
+
class Lighthouse::GraphQL::DirectiveResolvers::WithCountDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
16
|
+
def self.directive_name = 'withCount'
|
|
17
|
+
|
|
18
|
+
def self.definition
|
|
19
|
+
<<~GRAPHQL.chomp
|
|
20
|
+
"""
|
|
21
|
+
Count a relation of the parent before the field resolves, for every parent at once,
|
|
22
|
+
and keep the count on it as `<relation>_count`.
|
|
23
|
+
"""
|
|
24
|
+
directive @withCount(
|
|
25
|
+
"The relation to count."
|
|
26
|
+
relation: String!
|
|
27
|
+
"Model scopes that narrow the counted records."
|
|
28
|
+
scopes: [String!]
|
|
29
|
+
) repeatable on FIELD_DEFINITION
|
|
30
|
+
GRAPHQL
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def define_resolver(target_type_class)
|
|
34
|
+
inner = capture_inner(target_type_class)
|
|
35
|
+
relation = directive_arg('relation')
|
|
36
|
+
scopes = Array(directive_arg('scopes'))
|
|
37
|
+
|
|
38
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
39
|
+
ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::RelationCountsLoader, relation, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
|
|
40
|
+
inner.call(obj, args, ctx)
|
|
41
|
+
end)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# @with(relation: "tasks", scopes: [...]) — eager-load a relation of the parent
|
|
6
|
+
# before the field resolves, as PHP Lighthouse's @with does: for a field computed
|
|
7
|
+
# from it (a @method, a type class method, the default reader of a method that
|
|
8
|
+
# reads it), so it is loaded once for every parent in the response, not once per
|
|
9
|
+
# parent. `relation` defaults to the field's (snake_case) name, and may be nested
|
|
10
|
+
# ("tasks.assignee"); `scopes` narrow the loaded records.
|
|
11
|
+
#
|
|
12
|
+
# type User {
|
|
13
|
+
# taskSummary: String! @with(relation: "tasks") @method(name: "task_summary")
|
|
14
|
+
# }
|
|
15
|
+
class Lighthouse::GraphQL::DirectiveResolvers::WithDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
16
|
+
def self.directive_name = 'with'
|
|
17
|
+
|
|
18
|
+
def self.definition
|
|
19
|
+
<<~GRAPHQL.chomp
|
|
20
|
+
"""
|
|
21
|
+
Eager-load a relation of the parent before the field resolves, for every parent at once.
|
|
22
|
+
"""
|
|
23
|
+
directive @with(
|
|
24
|
+
"The relation to load, nested with dots. Defaults to the field's name."
|
|
25
|
+
relation: String
|
|
26
|
+
"Model scopes that narrow the loaded records."
|
|
27
|
+
scopes: [String!]
|
|
28
|
+
) repeatable on FIELD_DEFINITION
|
|
29
|
+
GRAPHQL
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def define_resolver(target_type_class)
|
|
33
|
+
inner = capture_inner(target_type_class)
|
|
34
|
+
associations = Lighthouse::Support::Preload.associations(directive_arg('relation') || Lighthouse::Support::Naming.attribute(@field_def.name))
|
|
35
|
+
scopes = Array(directive_arg('scopes'))
|
|
36
|
+
|
|
37
|
+
install(target_type_class, lambda do |obj, args, ctx|
|
|
38
|
+
ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::PreloadLoader, associations, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
|
|
39
|
+
inner.call(obj, args, ctx)
|
|
40
|
+
end)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Lighthouse::GraphQL::Directives::Arguments
|
|
6
|
+
# @whereNull — `true` keeps the rows whose column is null, `false` those whose
|
|
7
|
+
# column is not. `posts(unpublished: Boolean @whereNull(key: "published_at"))`
|
|
8
|
+
class WhereNull < Base
|
|
9
|
+
def self.directive_name = 'whereNull'
|
|
10
|
+
|
|
11
|
+
def self.definition
|
|
12
|
+
<<~GRAPHQL.chomp
|
|
13
|
+
"""
|
|
14
|
+
Filter the value is null: true keeps the rows where the column is null, false the others.
|
|
15
|
+
"""
|
|
16
|
+
directive @whereNull(
|
|
17
|
+
"The database column. Defaults to the argument's name."
|
|
18
|
+
key: String
|
|
19
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
20
|
+
GRAPHQL
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def handle_builder(relation, value)
|
|
24
|
+
return relation if value.nil?
|
|
25
|
+
|
|
26
|
+
value ? relation.where(column => nil) : relation.where.not(column => nil)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @whereNotNull — the inverse: `true` keeps the rows whose column is not null.
|
|
31
|
+
class WhereNotNull < Base
|
|
32
|
+
def self.directive_name = 'whereNotNull'
|
|
33
|
+
|
|
34
|
+
def self.definition
|
|
35
|
+
<<~GRAPHQL.chomp
|
|
36
|
+
"""
|
|
37
|
+
Filter the value is not null: true keeps the rows where the column is not null, false the others.
|
|
38
|
+
"""
|
|
39
|
+
directive @whereNotNull(
|
|
40
|
+
"The database column. Defaults to the argument's name."
|
|
41
|
+
key: String
|
|
42
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
43
|
+
GRAPHQL
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def handle_builder(relation, value)
|
|
47
|
+
return relation if value.nil?
|
|
48
|
+
|
|
49
|
+
value ? relation.where.not(column => nil) : relation.where(column => nil)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# @whereBetween — the column lies between two values: an input object's two
|
|
54
|
+
# fields, in order, or a list of two. `createdAt: DateRange @whereBetween(key: "created_at")`
|
|
55
|
+
class WhereBetween < Base
|
|
56
|
+
def self.directive_name = 'whereBetween'
|
|
57
|
+
|
|
58
|
+
def self.definition
|
|
59
|
+
<<~GRAPHQL.chomp
|
|
60
|
+
"""
|
|
61
|
+
Verify that a column's value is between two values: the argument's two input fields, in order.
|
|
62
|
+
"""
|
|
63
|
+
directive @whereBetween(
|
|
64
|
+
"The database column. Defaults to the argument's name."
|
|
65
|
+
key: String
|
|
66
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
67
|
+
GRAPHQL
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle_builder(relation, value)
|
|
71
|
+
from, to = bounds(value)
|
|
72
|
+
return relation if from.nil? && to.nil?
|
|
73
|
+
|
|
74
|
+
relation.where(column => from..to)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def bounds(value)
|
|
80
|
+
values = value.respond_to?(:to_h) && !value.is_a?(Array) ? value.to_h.values : Array(value)
|
|
81
|
+
values.first(2)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @whereNotBetween — the column lies outside two values.
|
|
86
|
+
class WhereNotBetween < WhereBetween
|
|
87
|
+
def self.directive_name = 'whereNotBetween'
|
|
88
|
+
|
|
89
|
+
def self.definition
|
|
90
|
+
<<~GRAPHQL.chomp
|
|
91
|
+
"""
|
|
92
|
+
Verify that a column's value lies outside two values: the argument's two input fields, in order.
|
|
93
|
+
"""
|
|
94
|
+
directive @whereNotBetween(
|
|
95
|
+
"The database column. Defaults to the argument's name."
|
|
96
|
+
key: String
|
|
97
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
98
|
+
GRAPHQL
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def handle_builder(relation, value)
|
|
102
|
+
from, to = bounds(value)
|
|
103
|
+
return relation if from.nil? && to.nil?
|
|
104
|
+
|
|
105
|
+
relation.where.not(column => from..to)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# @whereJsonContains — a JSON column contains the value (Laravel's
|
|
110
|
+
# whereJsonContains), on PostgreSQL (@>), MySQL (JSON_CONTAINS) and SQLite
|
|
111
|
+
# (json_each). `key:` may point into the document: "options->tags".
|
|
112
|
+
class WhereJsonContains < Base
|
|
113
|
+
def self.directive_name = 'whereJsonContains'
|
|
114
|
+
|
|
115
|
+
def self.definition
|
|
116
|
+
<<~GRAPHQL.chomp
|
|
117
|
+
"""
|
|
118
|
+
Use an input value as a whereJsonContains filter.
|
|
119
|
+
"""
|
|
120
|
+
directive @whereJsonContains(
|
|
121
|
+
"The JSON column, optionally with a path into it: \\"options->tags\\". Defaults to the argument's name."
|
|
122
|
+
key: String
|
|
123
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
124
|
+
GRAPHQL
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def handle_builder(relation, value)
|
|
128
|
+
return relation if value.nil?
|
|
129
|
+
|
|
130
|
+
connection = relation.connection
|
|
131
|
+
name, *path = column.to_s.split('->')
|
|
132
|
+
target = "#{connection.quote_table_name(relation.klass.table_name)}.#{connection.quote_column_name(name)}"
|
|
133
|
+
relation.where(*condition(connection.adapter_name, target, path, value))
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
private
|
|
137
|
+
|
|
138
|
+
def condition(adapter, target, path, value)
|
|
139
|
+
json_path = "$#{path.map { |segment| ".#{segment}" }.join}"
|
|
140
|
+
case adapter
|
|
141
|
+
when /postg/i then ["(#{target}#{path.map { |segment| "->'#{segment}'" }.join})::jsonb @> ?::jsonb", value.to_json]
|
|
142
|
+
when /mysql|trilogy/i then ["JSON_CONTAINS(#{target}, ?, ?)", value.to_json, json_path]
|
|
143
|
+
else sqlite(target, json_path, value)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Every value of a list must be in the array, as JSON containment means.
|
|
148
|
+
def sqlite(target, json_path, value)
|
|
149
|
+
values = Array(value)
|
|
150
|
+
sql = values.map { "EXISTS (SELECT 1 FROM json_each(#{target}, ?) WHERE json_each.value = ?)" }.join(' AND ')
|
|
151
|
+
[sql, *values.flat_map { |item| [json_path, item] }]
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# @limit — the argument directive behind LimitResolver. Only `builder: true`
|
|
156
|
+
# makes it one: a LIMIT on the field's query. Without it, LimitResolver cuts
|
|
157
|
+
# the results instead.
|
|
158
|
+
class Limit < Base
|
|
159
|
+
def self.directive_name = 'limit'
|
|
160
|
+
|
|
161
|
+
def self.definition
|
|
162
|
+
<<~GRAPHQL.chomp
|
|
163
|
+
"""
|
|
164
|
+
Allow clients to specify the maximum number of results to return when used on an argument,
|
|
165
|
+
or statically limit them when used on a field.
|
|
166
|
+
|
|
167
|
+
By default, this directive does not influence the number of results the resolver queries internally,
|
|
168
|
+
but limits how much of it is returned to clients. Use the `builder` argument to change this.
|
|
169
|
+
"""
|
|
170
|
+
directive @limit(
|
|
171
|
+
"""
|
|
172
|
+
You may set this to `true` if the field uses a query builder,
|
|
173
|
+
then this directive will apply a LIMIT clause to it.
|
|
174
|
+
Typically, this option should only be used for root fields,
|
|
175
|
+
as it may cause wrong results with batched relation queries.
|
|
176
|
+
"""
|
|
177
|
+
builder: Boolean! = false
|
|
178
|
+
) on ARGUMENT_DEFINITION | FIELD_DEFINITION
|
|
179
|
+
GRAPHQL
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def handle_builder(relation, value)
|
|
183
|
+
return relation unless value.is_a?(Integer) && Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'builder')
|
|
184
|
+
|
|
185
|
+
relation.limit(value)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# @scope — call a model scope with the client's value.
|
|
190
|
+
# `posts(trending: Boolean @scope)` calls Post.trending(value); `name:` names another scope.
|
|
191
|
+
class Scope < Base
|
|
192
|
+
def self.directive_name = 'scope'
|
|
193
|
+
|
|
194
|
+
def self.definition
|
|
195
|
+
<<~GRAPHQL.chomp
|
|
196
|
+
"""
|
|
197
|
+
Adds a scope to the query builder.
|
|
198
|
+
The scope method will receive the client-given value of the argument as its argument.
|
|
199
|
+
"""
|
|
200
|
+
directive @scope(
|
|
201
|
+
"The name of the scope. Defaults to the name of the argument."
|
|
202
|
+
name: String
|
|
203
|
+
) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
|
|
204
|
+
GRAPHQL
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def handle_builder(relation, value)
|
|
208
|
+
return relation if value.nil?
|
|
209
|
+
|
|
210
|
+
relation.public_send(
|
|
211
|
+
Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'name') || Lighthouse::Support::Naming.attribute(@arg_node.name), value
|
|
212
|
+
)
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The SDL of the directives no registered resolver class contributes, and of
|
|
4
|
+
# the types they refer to: the field-level query directives (@builder,
|
|
5
|
+
# @whereAuth), the argument directives, the type-system ones (@union, @node,
|
|
6
|
+
# @model, ...), and those the schema reads while it is built. The registry
|
|
7
|
+
# injects each into a schema that uses it without declaring it.
|
|
8
|
+
module Lighthouse::GraphQL::Directives::Definitions
|
|
9
|
+
# Directives that only mark the SDL for others to read.
|
|
10
|
+
PASSIVE = {
|
|
11
|
+
'nest' => 'directive @nest on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION',
|
|
12
|
+
'spread' => 'directive @spread on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION',
|
|
13
|
+
'inject' => 'directive @inject(context: String!, name: String!) repeatable on FIELD_DEFINITION',
|
|
14
|
+
'cacheKey' => 'directive @cacheKey on FIELD_DEFINITION'
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# { name => "directive @name ..." }
|
|
20
|
+
def directives
|
|
21
|
+
graphql = Lighthouse::GraphQL
|
|
22
|
+
[
|
|
23
|
+
graphql::DirectiveResolvers::BuilderApplier::DEFINITIONS,
|
|
24
|
+
graphql::DirectiveResolvers::ArgumentTransformsResolver::DEFINITIONS,
|
|
25
|
+
PASSIVE,
|
|
26
|
+
{ 'cacheControl' => graphql::CacheControl::DEFINITION, 'feature' => graphql::FeatureDirective::DEFINITION,
|
|
27
|
+
'complexity' => graphql::Complexity::DEFINITION, 'scalar' => graphql::ScalarCoercion::DEFINITION,
|
|
28
|
+
'enum' => graphql::EnumDirective::DEFINITION },
|
|
29
|
+
through_aliases,
|
|
30
|
+
graphql::TypeResolution::DEFINITIONS,
|
|
31
|
+
graphql::Directives::NodeDirective::DEFINITIONS,
|
|
32
|
+
graphql::Directives::ModelDirective::DEFINITIONS,
|
|
33
|
+
graphql::Directives::VisibilityDirective::DEFINITIONS,
|
|
34
|
+
graphql::Directives::WhereConditionsDirective::DEFINITIONS,
|
|
35
|
+
graphql::Directives::SoftDeletesDirective::DEFINITIONS
|
|
36
|
+
].reduce(:merge)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @hasManyThrough and @hasOneThrough: @hasMany's and @hasOne's definitions, renamed.
|
|
40
|
+
def through_aliases
|
|
41
|
+
resolvers = Lighthouse::GraphQL::DirectiveResolvers
|
|
42
|
+
{ 'hasManyThrough' => resolvers::HasManyDirectiveResolver, 'hasOneThrough' => resolvers::HasOneDirectiveResolver }
|
|
43
|
+
.to_h { |name, klass| [name, klass.definition.sub("@#{klass.directive_name}(", "@#{name}(")] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# { type name => its SDL }, for the types the directives above refer to.
|
|
47
|
+
def types
|
|
48
|
+
graphql = Lighthouse::GraphQL
|
|
49
|
+
[graphql::CacheControl::TYPE_DEFINITIONS, graphql::FeatureDirective::TYPE_DEFINITIONS, graphql::EnumDirective::TYPE_DEFINITIONS,
|
|
50
|
+
graphql::DirectiveResolvers::BuilderApplier::TYPE_DEFINITIONS].reduce(:merge)
|
|
51
|
+
end
|
|
52
|
+
end
|