lighthouse-graphql 0.4.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 +139 -1
- data/README.md +8 -5
- data/docs/configuration.md +26 -2
- data/docs/directives.md +287 -4
- data/docs/generators.md +82 -0
- data/docs/getting-started.md +26 -30
- data/docs/relationships.md +42 -0
- data/docs/relay.md +137 -0
- data/docs/resolvers.md +261 -22
- data/docs/security.md +17 -0
- data/docs/types.md +219 -0
- data/docs/uploads.md +80 -0
- data/lib/generators/lighthouse/base_classes.rb +32 -49
- data/lib/generators/lighthouse/install/install_generator.rb +83 -24
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -6
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -3
- data/lib/lighthouse/async_mutation_job.rb +17 -0
- data/lib/lighthouse/complexity.rb +5 -4
- data/lib/lighthouse/config.rb +13 -2
- 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 +2 -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 +2 -1
- 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 -21
- data/lib/lighthouse/directives/visibility_directive.rb +71 -0
- data/lib/lighthouse/enum_directive.rb +41 -0
- data/lib/lighthouse/field_resolver_stack.rb +53 -6
- 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 +33 -0
- data/lib/lighthouse/relation_counts.rb +29 -0
- data/lib/lighthouse/resolve_info.rb +80 -0
- data/lib/lighthouse/resolver_bases.rb +9 -2
- 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_generator.rb +11 -10
- data/lib/lighthouse/schema_implementation.rb +4 -8
- data/lib/lighthouse/support/environment.rb +13 -0
- data/lib/lighthouse/support/model_resolver.rb +7 -5
- 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.rbs +18 -0
- metadata +38 -1
|
@@ -4,7 +4,12 @@
|
|
|
4
4
|
# namespaces follow graphql-ruby's contract — GraphQL::Schema::Resolver and
|
|
5
5
|
# GraphQL::Schema::Mutation: `resolve(**args)` with `object` and `context`,
|
|
6
6
|
# plus `ready?` / `authorized?` — exactly as in a graphql-ruby schema.
|
|
7
|
-
# @field also accepts a plain callable, `call(object, args, context)
|
|
7
|
+
# @field also accepts a plain callable, `call(object, args, context)`, which
|
|
8
|
+
# gets the ResolveInfo as a fourth argument when it takes one.
|
|
9
|
+
#
|
|
10
|
+
# `method:` is the method after `@` in `@field(resolver: "Class@method")`: it
|
|
11
|
+
# replaces `resolve` (keeping `ready?` / `authorized?`) on a resolver class, and
|
|
12
|
+
# `call` on a plain class, as a class method or else an instance method.
|
|
8
13
|
module Lighthouse::GraphQL::ResolverDispatch
|
|
9
14
|
module_function
|
|
10
15
|
|
|
@@ -12,15 +17,35 @@ module Lighthouse::GraphQL::ResolverDispatch
|
|
|
12
17
|
klass.is_a?(Class) && klass < ::GraphQL::Schema::Resolver
|
|
13
18
|
end
|
|
14
19
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
# `info`: the ResolveInfo of the field being resolved, which carries its
|
|
21
|
+
# object, arguments and context.
|
|
22
|
+
def call(klass, info, method: nil)
|
|
23
|
+
return call_resolver(klass, info, method) if graphql_resolver?(klass)
|
|
24
|
+
|
|
25
|
+
receiver = callable_receiver(klass, method || :call)
|
|
26
|
+
callable = receiver.method(method || (receiver.respond_to?(:call) ? :call : :resolve))
|
|
27
|
+
args = [info.object, info.arguments, info.context]
|
|
28
|
+
takes_info?(receiver, callable) ? callable.call(*args, info) : callable.call(*args)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call_resolver(klass, info, method)
|
|
32
|
+
kwargs = Lighthouse::Support::Arguments.prepare(info.field, info.arguments, info.context)
|
|
33
|
+
instance = Lighthouse::GraphQL::ResolveInfo.attach(klass.new(object: info.object, context: info.context, field: info.field), info)
|
|
34
|
+
instance.define_singleton_method(:resolve) { |**values| public_send(method, **values) } if method
|
|
35
|
+
instance.resolve_with_support(**kwargs)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# The class itself when it answers `name` as a class method, else an instance.
|
|
39
|
+
def callable_receiver(klass, name)
|
|
40
|
+
klass.respond_to?(name) ? klass : klass.new
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
POSITIONAL = %i[req opt].freeze
|
|
44
|
+
|
|
45
|
+
# Does the callable take a fourth argument? A lambda's own parameters count,
|
|
46
|
+
# not Proc#call's (*args).
|
|
47
|
+
def takes_info?(receiver, callable)
|
|
48
|
+
params = receiver.is_a?(Proc) && callable.name == :call ? receiver.parameters : callable.parameters
|
|
49
|
+
params.any? { |type, _| type == :rest } || params.count { |type, _| POSITIONAL.include?(type) } >= 4
|
|
25
50
|
end
|
|
26
51
|
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# How the values of an SDL scalar (`scalar DateTime`) are read from a query and
|
|
4
|
+
# written to a response, as a graphql-ruby scalar class does it with
|
|
5
|
+
# `coerce_input(value, context)` and `coerce_result(value, context)`. The class
|
|
6
|
+
# is, in order:
|
|
7
|
+
#
|
|
8
|
+
# 1. the one `@scalar(class: "Name")` on the scalar names, looked up in
|
|
9
|
+
# Config.namespaces[:types] (nested names too) and then as written. PHP
|
|
10
|
+
# Lighthouse's directive.
|
|
11
|
+
# 2. Its type class, Types::<Name>Type including Lighthouse::GraphQL::Type: a
|
|
12
|
+
# GraphQL::Schema::Scalar, as graphql-ruby's `rails g graphql:scalar` writes it.
|
|
13
|
+
# 3. Lighthouse's own, for `scalar Upload` (Lighthouse::GraphQL::Upload).
|
|
14
|
+
#
|
|
15
|
+
# A scalar with none passes its values through unchanged. A class raising
|
|
16
|
+
# GraphQL::CoercionError from coerce_input (or returning nil for a value) makes
|
|
17
|
+
# the argument invalid, with that message, as in graphql-ruby.
|
|
18
|
+
module Lighthouse::GraphQL::ScalarCoercion
|
|
19
|
+
DEFINITION = <<~GRAPHQL.chomp
|
|
20
|
+
"""
|
|
21
|
+
The class that reads and writes this scalar's values: coerce_input(value, context) and
|
|
22
|
+
coerce_result(value, context), as a GraphQL::Schema::Scalar.
|
|
23
|
+
"""
|
|
24
|
+
directive @scalar(class: String!) on SCALAR
|
|
25
|
+
GRAPHQL
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
# Give every SDL scalar that has a class its coercion. The class is looked up
|
|
30
|
+
# again on each value, so code reloading keeps working; `bases` is the
|
|
31
|
+
# schema's base_types[:scalar], which a type class must inherit from.
|
|
32
|
+
def apply(schema, base = nil)
|
|
33
|
+
schema.types.each_value do |type|
|
|
34
|
+
next unless type.kind.scalar? && type.ast_node
|
|
35
|
+
|
|
36
|
+
klass = class_for(type, base)
|
|
37
|
+
install(type, klass) if klass
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Scalars Lighthouse brings a class for, when nothing else names one.
|
|
42
|
+
BUILT_IN = { 'Upload' => 'Lighthouse::GraphQL::Upload' }.freeze
|
|
43
|
+
|
|
44
|
+
def class_for(scalar_type, base = nil)
|
|
45
|
+
directive_class(scalar_type) || type_class(scalar_type, base) || BUILT_IN[scalar_type.graphql_name]&.constantize
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def directive_class(scalar_type)
|
|
49
|
+
directive = (scalar_type.ast_node.directives || []).find { |d| d.name == 'scalar' }
|
|
50
|
+
name = directive && Lighthouse::Support::DirectiveArgs.literal(directive, 'class')
|
|
51
|
+
return unless name
|
|
52
|
+
|
|
53
|
+
klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: Lighthouse::Config.namespaces[:types], nested: true)
|
|
54
|
+
return klass if klass.respond_to?(:coerce_input) && klass.respond_to?(:coerce_result)
|
|
55
|
+
|
|
56
|
+
raise Lighthouse::DefinitionError,
|
|
57
|
+
"@scalar(class: #{name.inspect}) on #{scalar_type.graphql_name} names no class with coerce_input(value, context) " \
|
|
58
|
+
'and coerce_result(value, context).'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def type_class(scalar_type, base)
|
|
62
|
+
klass = Lighthouse::GraphQL::Type.class_for(scalar_type.graphql_name, :scalar)
|
|
63
|
+
Lighthouse::GraphQL::ResolverBases.check!(klass, base, "the #{scalar_type.graphql_name} scalar") if klass
|
|
64
|
+
klass
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def install(scalar_type, klass)
|
|
68
|
+
current = -> { klass.name ? klass.name.constantize : klass }
|
|
69
|
+
scalar_type.define_singleton_method(:coerce_input) { |value, ctx| current.call.coerce_input(value, ctx) }
|
|
70
|
+
scalar_type.define_singleton_method(:coerce_result) { |value, ctx| current.call.coerce_result(value, ctx) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -72,7 +72,8 @@ module Lighthouse::GraphQL::SchemaCache
|
|
|
72
72
|
registry.directive_definitions.sort,
|
|
73
73
|
registry.type_definitions.sort,
|
|
74
74
|
manipulators,
|
|
75
|
-
Lighthouse::Config.pagination_default_count
|
|
75
|
+
Lighthouse::Config.pagination_default_count,
|
|
76
|
+
Lighthouse::Support::Environment.current # @hide / @show
|
|
76
77
|
].inspect
|
|
77
78
|
end
|
|
78
79
|
|
|
@@ -15,8 +15,8 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
15
15
|
obj.respond_to?(field_name) ? obj.public_send(field_name) : nil
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def resolve_type(
|
|
19
|
-
|
|
18
|
+
def resolve_type(abstract_type, obj, ctx)
|
|
19
|
+
Lighthouse::GraphQL::TypeResolution.resolve(ctx.respond_to?(:schema) && ctx.schema ? ctx.schema : schema, abstract_type, obj, ctx)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def coerce_input(_type, value, _ctx)
|
|
@@ -139,7 +139,9 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
139
139
|
# Apply object-level federation directives to generated types so the
|
|
140
140
|
# Apollo Federation helper can detect entities and add _entities.
|
|
141
141
|
Lighthouse::GraphQL::FederationDirectives.apply(type)
|
|
142
|
-
|
|
142
|
+
root_kind = root_kinds[type.graphql_name]
|
|
143
|
+
type_class = root_kind ? nil : Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver.type_class(type.graphql_name, bases)
|
|
144
|
+
type.fields.each_value { |field_def| process_field(type, field_def, root_kind, bases, type_class) }
|
|
143
145
|
end
|
|
144
146
|
|
|
145
147
|
# @complexity, and paginated fields, give graphql-ruby's complexity analysis their cost.
|
|
@@ -151,6 +153,9 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
151
153
|
# Every @broadcast must name a field of the Subscription type.
|
|
152
154
|
Lighthouse::GraphQL::Broadcasting.validate!(schema)
|
|
153
155
|
|
|
156
|
+
# Unions, interfaces and scalars get the classes that resolve and coerce them.
|
|
157
|
+
Lighthouse::GraphQL::Type.apply(schema, bases.fetch(:types, {}))
|
|
158
|
+
|
|
154
159
|
# return schema for convenience
|
|
155
160
|
schema
|
|
156
161
|
end
|
|
@@ -163,10 +168,11 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
163
168
|
|
|
164
169
|
# A field's generated resolvers, then the runtime hooks that make
|
|
165
170
|
# graphql-ruby reach them.
|
|
166
|
-
def process_field(type, field_def, root_kind, bases = {})
|
|
171
|
+
def process_field(type, field_def, root_kind, bases = {}, type_class = nil)
|
|
167
172
|
return unless field_def.ast_node
|
|
168
173
|
|
|
169
|
-
|
|
174
|
+
# Installed in resolution order (see FieldResolverStack).
|
|
175
|
+
Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, root_kind, bases: bases, type_class: type_class)
|
|
170
176
|
Lighthouse::GraphQL::GeneratedResolvers.route_runtime_resolver(type, field_def)
|
|
171
177
|
ensure_connection_extension(field_def)
|
|
172
178
|
end
|
|
@@ -187,11 +193,6 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
187
193
|
# ignore failures during post-processing
|
|
188
194
|
end
|
|
189
195
|
|
|
190
|
-
# A field's resolvers, installed in resolution order (see FieldResolverStack).
|
|
191
|
-
def apply_field_resolvers(type, field_def, directives, root_kind, bases = {})
|
|
192
|
-
Lighthouse::GraphQL::FieldResolverStack.apply(type, field_def, directives, root_kind, bases: bases)
|
|
193
|
-
end
|
|
194
|
-
|
|
195
196
|
# Scan SDL string for list-typed fields that carry relationship directives
|
|
196
197
|
# and produce Connection/Edge types where missing. This is a lightweight
|
|
197
198
|
# transformation that mirrors Lighthouse behavior: clients can declare
|
|
@@ -27,14 +27,10 @@ class SchemaImplementation
|
|
|
27
27
|
read_attribute(obj, field_name)
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
Object.const_get("Types::#{type_name}Type")
|
|
35
|
-
rescue StandardError
|
|
36
|
-
nil
|
|
37
|
-
end
|
|
30
|
+
# The object type of a union's or interface's value (see TypeResolution).
|
|
31
|
+
def resolve_type(abstract_type, obj, ctx)
|
|
32
|
+
schema = ctx.respond_to?(:schema) && ctx.schema ? ctx.schema : @schema
|
|
33
|
+
Lighthouse::GraphQL::TypeResolution.resolve(schema, abstract_type, obj, ctx)
|
|
38
34
|
end
|
|
39
35
|
|
|
40
36
|
def coerce_input(_type, value, _ctx)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The application's environment, for @hide and @show: Rails.env, else
|
|
4
|
+
# RAILS_ENV or RACK_ENV, else "development".
|
|
5
|
+
module Lighthouse::Support::Environment
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def current
|
|
9
|
+
return Rails.env.to_s if defined?(Rails) && Rails.respond_to?(:env)
|
|
10
|
+
|
|
11
|
+
ENV['RAILS_ENV'] || ENV['RACK_ENV'] || 'development'
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -26,14 +26,16 @@ module Lighthouse::Support::ModelResolver
|
|
|
26
26
|
# `namespaces:` overrides the configured resolver_namespaces, e.g. with
|
|
27
27
|
# Config.namespaces[:builders] to look a builder class up.
|
|
28
28
|
# `fallback: false` skips the bare name, so `Resolvers::User` is never
|
|
29
|
-
# mistaken for the `User` model.
|
|
30
|
-
|
|
29
|
+
# mistaken for the `User` model. `nested: true` tries the namespaces before a
|
|
30
|
+
# name that already contains `::` too, so `Feeds::Home` is found as
|
|
31
|
+
# `Resolvers::Feeds::Home`; otherwise such a name is used as-is.
|
|
32
|
+
def constantize(name, namespaces: self.namespaces, fallback: true, nested: false)
|
|
31
33
|
return nil unless name
|
|
32
34
|
|
|
33
35
|
raw = name.to_s.strip
|
|
34
36
|
return nil if raw.empty?
|
|
35
37
|
|
|
36
|
-
candidates_for(raw, namespaces, fallback: fallback).each do |candidate|
|
|
38
|
+
candidates_for(raw, namespaces, fallback: fallback, nested: nested).each do |candidate|
|
|
37
39
|
klass = try_constantize(candidate)
|
|
38
40
|
return klass if klass
|
|
39
41
|
end
|
|
@@ -41,8 +43,8 @@ module Lighthouse::Support::ModelResolver
|
|
|
41
43
|
nil
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
def candidates_for(raw, namespaces = self.namespaces, fallback: true)
|
|
45
|
-
if raw.include?('::')
|
|
46
|
+
def candidates_for(raw, namespaces = self.namespaces, fallback: true, nested: false)
|
|
47
|
+
if raw.include?('::') && !nested
|
|
46
48
|
[raw]
|
|
47
49
|
else
|
|
48
50
|
Array(namespaces).map { |ns| "#{ns}::#{raw}" } + (fallback ? [raw] : [])
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Eager loading for @with and @lazyLoad: ActiveRecord's preloader, over records
|
|
4
|
+
# of any classes, with relation names as PHP Lighthouse writes them
|
|
5
|
+
# ("comments.author" is comments, and each comment's author).
|
|
6
|
+
module Lighthouse::Support::Preload
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# ["comments.author", "tags"] -> [{ comments: :author }, :tags]
|
|
10
|
+
def associations(names)
|
|
11
|
+
Array(names).map do |name|
|
|
12
|
+
*path, last = name.to_s.split('.').map(&:to_sym)
|
|
13
|
+
path.reverse.reduce(last) { |nested, segment| { segment => nested } }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Load `associations` into `records`, grouped by class; a class that has no
|
|
18
|
+
# such association is left alone. `scopes` (model scope names) narrow the
|
|
19
|
+
# loaded records, as Laravel's constrained eager loads do.
|
|
20
|
+
def records(records, associations, scopes = [])
|
|
21
|
+
records.grep(::ActiveRecord::Base).group_by(&:class).each do |klass, group|
|
|
22
|
+
own = associations.select { |association| klass.reflect_on_association(root(association)) }
|
|
23
|
+
own.each { |association| preload(klass, group, association, scopes) }
|
|
24
|
+
end
|
|
25
|
+
records
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def root(association)
|
|
29
|
+
association.is_a?(Hash) ? association.keys.first : association
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def preload(klass, records, association, scopes)
|
|
33
|
+
scope = scoped(klass, association, scopes)
|
|
34
|
+
preloader = ::ActiveRecord::Associations::Preloader
|
|
35
|
+
if preloader.method_defined?(:call)
|
|
36
|
+
preloader.new(records: records, associations: association, scope: scope).call # Rails >= 7
|
|
37
|
+
else
|
|
38
|
+
preloader.new.preload(records, association, scope) # Rails 6
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The scopes applied to the association's model, or nil for none.
|
|
43
|
+
def scoped(klass, association, scopes)
|
|
44
|
+
return if scopes.blank?
|
|
45
|
+
|
|
46
|
+
target = klass.reflect_on_association(root(association)).klass
|
|
47
|
+
scopes.reduce(target.all) { |relation, scope| relation.public_send(scope) }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/concern'
|
|
4
|
+
|
|
5
|
+
# Hands a graphql-ruby type class to Lighthouse, as `include
|
|
6
|
+
# Lighthouse::GraphQL::Subscription` does a subscription. The class keeps
|
|
7
|
+
# graphql-ruby's folder structure and naming (app/graphql/types/post_type.rb,
|
|
8
|
+
# Types::PostType for the SDL's Post type) and base classes, and does for the
|
|
9
|
+
# SDL type of its name what it does in a graphql-ruby schema:
|
|
10
|
+
#
|
|
11
|
+
# class Types::PostType < Types::BaseObject # an object type
|
|
12
|
+
# include Lighthouse::GraphQL::Type
|
|
13
|
+
#
|
|
14
|
+
# def reading_time = { words: object.body.to_s.split.size }
|
|
15
|
+
# end
|
|
16
|
+
#
|
|
17
|
+
# class Types::SearchResultType < Types::BaseUnion # a union
|
|
18
|
+
# include Lighthouse::GraphQL::Type
|
|
19
|
+
#
|
|
20
|
+
# def self.resolve_type(object, context) = object.is_a?(Post) ? 'Post' : 'User'
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# module Types::NamedType # an interface
|
|
24
|
+
# include Types::BaseInterface
|
|
25
|
+
# include Lighthouse::GraphQL::Type
|
|
26
|
+
#
|
|
27
|
+
# definition_methods do
|
|
28
|
+
# def resolve_type(object, context) = ...
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# class Types::DateTimeType < Types::BaseScalar # a scalar
|
|
33
|
+
# include Lighthouse::GraphQL::Type
|
|
34
|
+
#
|
|
35
|
+
# def self.coerce_input(value, context) = Time.iso8601(value)
|
|
36
|
+
# def self.coerce_result(value, context) = value.iso8601
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
# An object type's methods resolve its fields that have no resolving
|
|
40
|
+
# directive, with graphql-ruby's `object` (the parent), `context`, the
|
|
41
|
+
# `dataload*` helpers, and the field's arguments as snake_case keywords. A
|
|
42
|
+
# union's or interface's `resolve_type` picks the object type of a value (a
|
|
43
|
+
# type name, or a type class such as Types::PostType). A scalar's
|
|
44
|
+
# `coerce_input` / `coerce_result` convert its values. The SDL still declares
|
|
45
|
+
# the types and their fields: the class only lends its behaviour, so `field`
|
|
46
|
+
# declarations in it are ignored, and an object type's `authorized?` does not run.
|
|
47
|
+
#
|
|
48
|
+
# The model an object type resolves is the SDL's to say, with @model(class:).
|
|
49
|
+
#
|
|
50
|
+
# A Types::<Name>Type that does not include the concern is not Lighthouse's,
|
|
51
|
+
# and is left alone.
|
|
52
|
+
module Lighthouse::GraphQL::Type
|
|
53
|
+
extend ActiveSupport::Concern
|
|
54
|
+
|
|
55
|
+
# The kinds of graphql-ruby class the concern can be included in.
|
|
56
|
+
KINDS = {
|
|
57
|
+
object: ::GraphQL::Schema::Object,
|
|
58
|
+
union: ::GraphQL::Schema::Union,
|
|
59
|
+
scalar: ::GraphQL::Schema::Scalar,
|
|
60
|
+
interface: ::GraphQL::Schema::Interface
|
|
61
|
+
}.freeze
|
|
62
|
+
|
|
63
|
+
# :object, :union, :scalar or :interface for a graphql-ruby type class or
|
|
64
|
+
# interface module; nil for anything else.
|
|
65
|
+
def self.kind_of(mod)
|
|
66
|
+
return :interface if !mod.is_a?(Class) && mod.is_a?(Module) && mod.include?(KINDS[:interface])
|
|
67
|
+
return unless mod.is_a?(Class)
|
|
68
|
+
|
|
69
|
+
%i[object union scalar].find { |kind| mod < KINDS[kind] }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
|
|
73
|
+
# Config.namespaces[:types], when it includes the concern; nil otherwise. The
|
|
74
|
+
# class must be of the same `kind` as the SDL type.
|
|
75
|
+
def self.class_for(type_name, kind)
|
|
76
|
+
klass = Lighthouse::Support::ModelResolver.constantize("#{type_name}Type", namespaces: Lighthouse::Config.namespaces[:types],
|
|
77
|
+
fallback: false)
|
|
78
|
+
return unless klass.is_a?(Module) && klass.include?(self) && kind_of(klass)
|
|
79
|
+
return klass if kind_of(klass) == kind
|
|
80
|
+
|
|
81
|
+
raise Lighthouse::DefinitionError, "#{klass} is a type class of kind #{kind_of(klass)}, but #{type_name} is of kind #{kind} in the schema."
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# What the SDL's types take from their classes and directives, once the
|
|
85
|
+
# schema is built: every @model names a model (ModelDirective), every @union /
|
|
86
|
+
# @interface class exists and every type class matches its kind and base
|
|
87
|
+
# (TypeResolution), and every scalar with a class reads and writes its values
|
|
88
|
+
# through it (ScalarCoercion). `bases`: the schema's base_types, by kind.
|
|
89
|
+
def self.apply(schema, bases = {})
|
|
90
|
+
Lighthouse::GraphQL::Directives::ModelDirective.validate!(schema)
|
|
91
|
+
Lighthouse::GraphQL::TypeResolution.validate!(schema, bases)
|
|
92
|
+
Lighthouse::GraphQL::ScalarCoercion.apply(schema, bases[:scalar])
|
|
93
|
+
Lighthouse::GraphQL::EnumDirective.apply(schema)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
included do
|
|
97
|
+
unless Lighthouse::GraphQL::Type.kind_of(self)
|
|
98
|
+
raise Lighthouse::DefinitionError,
|
|
99
|
+
"#{self} includes Lighthouse::GraphQL::Type, which is for graphql-ruby types: a subclass of GraphQL::Schema::Object, " \
|
|
100
|
+
'Union or Scalar, or a module including GraphQL::Schema::Interface (class Types::PostType < Types::BaseObject).'
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Which object type a value of a union or interface is: graphql-ruby asks the
|
|
4
|
+
# schema (its `resolve_type`) each time a field returns one. In order:
|
|
5
|
+
#
|
|
6
|
+
# 1. `@union(resolveType: "Name")` / `@interface(resolveType: "Name")` on the
|
|
7
|
+
# SDL type names a class, looked up in Config.namespaces[:types] (nested
|
|
8
|
+
# names too) and then as written, with `resolve_type(object, context)` or
|
|
9
|
+
# `call(object, context)`. PHP Lighthouse's directives.
|
|
10
|
+
# 2. Its type class, Types::<Name>Type including Lighthouse::GraphQL::Type: a
|
|
11
|
+
# union's `self.resolve_type(object, context)`, or an interface's
|
|
12
|
+
# `definition_methods { def resolve_type(object, context) }`, as in graphql-ruby.
|
|
13
|
+
# 3. The default, as PHP Lighthouse's: a Hash's `__typename`; else the type
|
|
14
|
+
# named after the object's class, or one of its superclasses (STI); else the
|
|
15
|
+
# type whose @model names the object's model (`type Article @model(class: "Post")`).
|
|
16
|
+
#
|
|
17
|
+
# A class returns a type name, the SDL type, or a type class (Types::PostType),
|
|
18
|
+
# which stands for the SDL type of its graphql_name; or, as graphql-ruby allows,
|
|
19
|
+
# [type, value] to resolve the type's fields on `value`. nil falls through to the
|
|
20
|
+
# next step. A value no step resolves, or one resolved to a type the union or
|
|
21
|
+
# interface does not include, raises Lighthouse::DefinitionError.
|
|
22
|
+
module Lighthouse::GraphQL::TypeResolution
|
|
23
|
+
DEFINITIONS = {
|
|
24
|
+
'union' => <<~GRAPHQL.chomp,
|
|
25
|
+
"""
|
|
26
|
+
The class that picks the object type of this union's values: `resolve_type(object, context)`
|
|
27
|
+
or `call(object, context)`, returning a type name or a type class.
|
|
28
|
+
"""
|
|
29
|
+
directive @union(resolveType: String!) on UNION
|
|
30
|
+
GRAPHQL
|
|
31
|
+
'interface' => <<~GRAPHQL.chomp
|
|
32
|
+
"""
|
|
33
|
+
The class that picks the object type of this interface's values: `resolve_type(object, context)`
|
|
34
|
+
or `call(object, context)`, returning a type name or a type class.
|
|
35
|
+
"""
|
|
36
|
+
directive @interface(resolveType: String!) on INTERFACE
|
|
37
|
+
GRAPHQL
|
|
38
|
+
}.freeze
|
|
39
|
+
|
|
40
|
+
DIRECTIVES = DEFINITIONS.keys.freeze
|
|
41
|
+
|
|
42
|
+
module_function
|
|
43
|
+
|
|
44
|
+
# The object type, or [object type, value] when a class answered with a
|
|
45
|
+
# value to resolve the fields on instead of `object` (as graphql-ruby allows).
|
|
46
|
+
def resolve(schema, abstract_type, object, context)
|
|
47
|
+
answer = custom(abstract_type, object, context)
|
|
48
|
+
answer, value = answer if answer.is_a?(Array) && answer.size == 2
|
|
49
|
+
type = type_named(schema, answer) || default(schema, abstract_type, object)
|
|
50
|
+
unless type && schema.possible_types(abstract_type).include?(type)
|
|
51
|
+
raise Lighthouse::DefinitionError, unresolved_message(schema, abstract_type, object, type)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
value.nil? ? type : [type, value]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# What the directive's class or the type class says, or nil.
|
|
58
|
+
def custom(abstract_type, object, context)
|
|
59
|
+
resolver = directive_class(abstract_type) || type_class(abstract_type)
|
|
60
|
+
return unless resolver
|
|
61
|
+
|
|
62
|
+
resolver.respond_to?(:resolve_type) ? resolver.resolve_type(object, context) : resolver.call(object, context)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def default(schema, abstract_type, object)
|
|
66
|
+
candidates = schema.possible_types(abstract_type)
|
|
67
|
+
by_name = candidates.index_by(&:graphql_name)
|
|
68
|
+
|
|
69
|
+
typename = typename_of(object)
|
|
70
|
+
return by_name[typename] if typename
|
|
71
|
+
|
|
72
|
+
class_chain(object.class).each { |name| return by_name[name] if by_name.key?(name) }
|
|
73
|
+
candidates.find { |type| model_of(type)&.then { |model| object.is_a?(model) } }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Fail at build time, not on the first query: every class a @union /
|
|
77
|
+
# @interface names exists and answers, and every type class matches its kind.
|
|
78
|
+
# `bases`: the schema's base_types, by kind, that type classes inherit from.
|
|
79
|
+
def validate!(schema, bases = {})
|
|
80
|
+
schema.types.each_value do |type|
|
|
81
|
+
next unless type.kind.union? || type.kind.interface?
|
|
82
|
+
|
|
83
|
+
directive_class(type)
|
|
84
|
+
kind = type.kind.union? ? :union : :interface
|
|
85
|
+
klass = Lighthouse::GraphQL::Type.class_for(type.graphql_name, kind)
|
|
86
|
+
Lighthouse::GraphQL::ResolverBases.check!(klass, bases[kind], "the #{type.graphql_name} #{kind}") if klass
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The SDL type a resolver's answer stands for: a name, a type, or a type class.
|
|
91
|
+
def type_named(schema, answer)
|
|
92
|
+
return if answer.nil?
|
|
93
|
+
|
|
94
|
+
schema.get_type(answer.respond_to?(:graphql_name) ? answer.graphql_name : answer.to_s)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def directive_class(abstract_type)
|
|
98
|
+
directive, name = resolve_type_directive(abstract_type)
|
|
99
|
+
return unless name
|
|
100
|
+
|
|
101
|
+
klass = Lighthouse::Support::ModelResolver.constantize(name, namespaces: Lighthouse::Config.namespaces[:types], nested: true)
|
|
102
|
+
return klass if klass.respond_to?(:resolve_type) || klass.respond_to?(:call)
|
|
103
|
+
|
|
104
|
+
raise Lighthouse::DefinitionError,
|
|
105
|
+
"@#{directive.name}(resolveType: #{name.inspect}) on #{abstract_type.graphql_name} names no class with resolve_type(object, context) " \
|
|
106
|
+
'or call(object, context).'
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The @union / @interface on the type and the class name it gives, or nil.
|
|
110
|
+
def resolve_type_directive(abstract_type)
|
|
111
|
+
directive = (abstract_type.ast_node&.directives || []).find { |d| DIRECTIVES.include?(d.name) }
|
|
112
|
+
[directive, Lighthouse::Support::DirectiveArgs.literal(directive, 'resolveType')] if directive
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def type_class(abstract_type)
|
|
116
|
+
kind = abstract_type.kind.union? ? :union : :interface
|
|
117
|
+
klass = Lighthouse::GraphQL::Type.class_for(abstract_type.graphql_name, kind)
|
|
118
|
+
klass if klass.respond_to?(:resolve_type)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def typename_of(object)
|
|
122
|
+
return unless object.is_a?(Hash)
|
|
123
|
+
|
|
124
|
+
(object['__typename'] || object[:__typename])&.to_s
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
# Admin, User, ApplicationRecord, ... for an Admin < User record.
|
|
128
|
+
def class_chain(klass)
|
|
129
|
+
names = []
|
|
130
|
+
while klass
|
|
131
|
+
names << klass.name.demodulize if klass.name
|
|
132
|
+
klass = klass.superclass
|
|
133
|
+
end
|
|
134
|
+
names
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def model_of(type)
|
|
138
|
+
Lighthouse::GraphQL::Directives::ModelDirective.declared_model(type)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def unresolved_message(schema, abstract_type, object, type)
|
|
142
|
+
possible = schema.possible_types(abstract_type).map(&:graphql_name).join(' | ')
|
|
143
|
+
kind = abstract_type.kind.union? ? 'union' : 'interface'
|
|
144
|
+
return "#{abstract_type.graphql_name} resolved a #{object.class} to #{type.graphql_name}, which is not one of its types (#{possible})." if type
|
|
145
|
+
|
|
146
|
+
"Lighthouse could not tell the type of a #{object.class} in the #{kind} #{abstract_type.graphql_name} (#{possible}): " \
|
|
147
|
+
"name a type after its class, map one to it with @model(class: \"#{object.class}\"), or resolve it in " \
|
|
148
|
+
"Types::#{abstract_type.graphql_name}Type.resolve_type or with @#{kind}(resolveType:)."
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The `Upload` scalar: a file a client sends with a multipart request (the
|
|
4
|
+
# GraphQL multipart request specification, MultipartRequest). `scalar Upload`
|
|
5
|
+
# in the SDL reads its values through it unless the SDL or a type class names
|
|
6
|
+
# another class. The resolver gets the uploaded file (an
|
|
7
|
+
# ActionDispatch::Http::UploadedFile in Rails): attach it to a model
|
|
8
|
+
# (`avatar: Upload` with @create on a model with has_one_attached :avatar), or
|
|
9
|
+
# store it with @upload.
|
|
10
|
+
class Lighthouse::GraphQL::Upload < GraphQL::Schema::Scalar
|
|
11
|
+
graphql_name 'Upload'
|
|
12
|
+
|
|
13
|
+
def self.coerce_input(value, _context)
|
|
14
|
+
return value if file?(value)
|
|
15
|
+
|
|
16
|
+
raise ::GraphQL::CoercionError, 'Could not get uploaded file, be sure to conform to the GraphQL multipart request specification.'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.coerce_result(_value, _context)
|
|
20
|
+
raise ::GraphQL::Error, 'The Upload scalar can only be used as an argument, not returned.'
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.file?(value)
|
|
24
|
+
value.respond_to?(:read) && (value.respond_to?(:original_filename) || value.respond_to?(:path))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
# Where @upload puts a file, by default: an ActiveStorage blob, on the service
|
|
6
|
+
# `disk` names (the default service without it), its key under `path`. The
|
|
7
|
+
# argument then holds the blob's signed id, which a has_one_attached /
|
|
8
|
+
# has_many_attached attribute attaches, and which ActiveStorage::Blob.find_signed
|
|
9
|
+
# finds. Config.upload_store takes another `call(file, disk:, path:, public:)`.
|
|
10
|
+
module Lighthouse::GraphQL::UploadStore
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def call(file, disk: nil, path: nil, public: false) # rubocop:disable Lint/UnusedMethodArgument -- public is the service's setting
|
|
14
|
+
unless defined?(::ActiveStorage::Blob)
|
|
15
|
+
raise Lighthouse::DefinitionError,
|
|
16
|
+
'@upload stores files with ActiveStorage: install it (bin/rails active_storage:install), or set Config.upload_store.'
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
attributes = { io: file, filename: filename(file), content_type: file.try(:content_type), key: key(path) }
|
|
20
|
+
attributes[:service_name] = disk if disk
|
|
21
|
+
::ActiveStorage::Blob.create_and_upload!(**attributes.compact).signed_id
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def filename(file)
|
|
25
|
+
file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.path)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def key(path)
|
|
29
|
+
prefix = path.to_s.delete_prefix('/').delete_suffix('/')
|
|
30
|
+
prefix.empty? ? nil : "#{prefix}/#{SecureRandom.base36(28)}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -104,8 +104,9 @@ module Lighthouse
|
|
|
104
104
|
# Install the field's resolver on `target_type_class` (a graphql-ruby type class).
|
|
105
105
|
def define_resolver: (untyped target_type_class) -> untyped
|
|
106
106
|
|
|
107
|
-
|
|
108
|
-
def
|
|
107
|
+
# `ctx`: the query's context, which @builder on an argument receives.
|
|
108
|
+
def apply_where_filters: (relation relation, untyped args, ?context? ctx) -> relation
|
|
109
|
+
def apply_arg_builders: (relation relation, arguments args_h, ?context? ctx) -> relation
|
|
109
110
|
def extract_relation_name_from_directive: (untyped arg_node) -> String?
|
|
110
111
|
def constantize_model: (String? name) -> Class?
|
|
111
112
|
def apply_scopes: (relation relation) -> relation
|