lighthouse-graphql 0.5.0 → 0.7.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 +212 -1
- data/README.md +38 -22
- data/docs/best-practices.md +6 -4
- data/docs/caching.md +4 -3
- data/docs/configuration.md +20 -26
- data/docs/custom-directives.md +2 -1
- data/docs/directives.md +79 -14
- data/docs/federation.md +1 -1
- data/docs/filtering-and-ordering.md +330 -30
- data/docs/generators.md +42 -40
- data/docs/getting-started.md +128 -58
- data/docs/motivation.md +3 -2
- data/docs/performance.md +5 -4
- data/docs/relationships.md +7 -5
- data/docs/relay.md +63 -0
- data/docs/resolvers.md +92 -39
- data/docs/schema-organisation.md +177 -0
- data/docs/security.md +64 -16
- data/docs/subscriptions.md +16 -9
- data/docs/types.md +16 -17
- data/docs/uploads.md +41 -14
- data/lib/generators/lighthouse/base_classes.rb +9 -14
- data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
- data/lib/generators/lighthouse/install/install_generator.rb +125 -99
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -13
- data/lib/lighthouse/ast_builder.rb +124 -0
- data/lib/lighthouse/caching.rb +9 -3
- data/lib/lighthouse/config.rb +27 -25
- data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
- data/lib/lighthouse/contracts/field_builder.rb +13 -0
- data/lib/lighthouse/controller.rb +28 -5
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
- data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +18 -18
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
- data/lib/lighthouse/directive_resolvers/base.rb +6 -37
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
- data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
- data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +3 -1
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
- data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +85 -26
- data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +12 -6
- data/lib/lighthouse/directives/arguments/more_filters.rb +1 -1
- data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
- data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
- data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
- data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
- data/lib/lighthouse/directives/definitions.rb +0 -1
- data/lib/lighthouse/directives/generated_types.rb +103 -0
- data/lib/lighthouse/directives/registry.rb +39 -10
- data/lib/lighthouse/document_ast.rb +111 -0
- data/lib/lighthouse/field_resolver_stack.rb +9 -6
- data/lib/lighthouse/global_id.rb +29 -3
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
- data/lib/lighthouse/ide_helper.rb +80 -23
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/order_by/types.rb +137 -0
- data/lib/lighthouse/railtie.rb +9 -2
- data/lib/lighthouse/rb_lighthouse.rb +16 -5
- data/lib/lighthouse/resolve_info.rb +5 -5
- data/lib/lighthouse/resolver_bases.rb +18 -4
- data/lib/lighthouse/resolver_dispatch.rb +16 -1
- data/lib/lighthouse/root_types.rb +117 -0
- data/lib/lighthouse/schema_cache.rb +8 -4
- data/lib/lighthouse/schema_factory.rb +6 -9
- data/lib/lighthouse/schema_generator.rb +8 -18
- data/lib/lighthouse/schema_stitcher.rb +64 -0
- data/lib/lighthouse/support/naming.rb +0 -12
- data/lib/lighthouse/upload.rb +4 -2
- data/lib/lighthouse/upload_store.rb +39 -14
- data/lib/lighthouse/where_conditions/columns.rb +36 -0
- data/lib/lighthouse/where_conditions/handler.rb +152 -0
- data/lib/lighthouse/where_conditions/json_column.rb +99 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
- data/lib/lighthouse/where_conditions/types.rb +110 -0
- data/sig/lighthouse/directives.rbs +16 -3
- data/sig/lighthouse/graphql.rbs +48 -4
- data/sig/lighthouse.rbs +8 -12
- metadata +17 -7
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -29
- data/lib/generators/lighthouse/mutation_generator.rb +0 -31
- data/lib/generators/lighthouse/query_generator.rb +0 -30
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
- data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
- data/lib/lighthouse/sdl_loader.rb +0 -22
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
|
|
5
|
-
# The field-level query directives, in SDL order
|
|
6
|
-
# @whereAuth, which keeps the records the
|
|
5
|
+
# The field-level query directives, in SDL order, as PHP Lighthouse's field
|
|
6
|
+
# builder directives: @builder classes, @whereAuth, which keeps the records the
|
|
7
|
+
# signed-in user owns, and the registry's FieldBuilder directives (@orderBy on a
|
|
8
|
+
# field). They apply after the argument directives (apply_where_filters), so
|
|
9
|
+
# a builder's order breaks the ties of the client's orderBy. @builder on an
|
|
7
10
|
# argument is applied with the other argument directives (apply_argument).
|
|
8
11
|
#
|
|
9
12
|
# A builder is `class:` and `method:` (default `call`), or PHP Lighthouse's
|
|
@@ -52,15 +55,27 @@ class Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier
|
|
|
52
55
|
def self.apply(relation, field_def_ast_node, args, ctx, resolver_instance)
|
|
53
56
|
return relation unless field_def_ast_node&.directives
|
|
54
57
|
|
|
55
|
-
|
|
58
|
+
registry = Lighthouse::DirectiveRegistry.default
|
|
59
|
+
field_def_ast_node.directives.reduce(relation) do |acc, directive_node|
|
|
56
60
|
case directive_node.name
|
|
57
|
-
when 'builder' then
|
|
58
|
-
when 'whereAuth' then
|
|
61
|
+
when 'builder' then apply_directive(acc, directive_node, args, ctx, resolver_instance)
|
|
62
|
+
when 'whereAuth' then where_auth(acc, directive_node, ctx)
|
|
63
|
+
else field_builder(registry, acc, field_def_ast_node, directive_node)
|
|
59
64
|
end
|
|
60
65
|
end
|
|
66
|
+
end
|
|
61
67
|
|
|
62
|
-
|
|
68
|
+
# Does the field carry a query directive of its own?
|
|
69
|
+
def self.any?(field_def_ast_node)
|
|
70
|
+
registry = Lighthouse::DirectiveRegistry.default
|
|
71
|
+
(field_def_ast_node&.directives || []).any? { |d| DEFINITIONS.key?(d.name) || registry.field_builder_class(d.name) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.field_builder(registry, relation, field_def_ast_node, directive_node)
|
|
75
|
+
builder_class = registry.field_builder_class(directive_node.name)
|
|
76
|
+
builder_class ? builder_class.new(field_def_ast_node, directive_node).handle_field_builder(relation) : relation
|
|
63
77
|
end
|
|
78
|
+
private_class_method :field_builder
|
|
64
79
|
|
|
65
80
|
# The records whose `relation:` links them to the signed-in user; none for a guest.
|
|
66
81
|
def self.where_auth(relation, directive_node, ctx)
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
|
|
5
5
|
module Lighthouse::GraphQL::DirectiveResolvers
|
|
6
|
-
# @cache — keep a field's resolved value in
|
|
6
|
+
# @cache — keep a field's resolved value in Rails.cache (Caching.store).
|
|
7
7
|
#
|
|
8
8
|
# The key covers the parent type and its key (the field marked @cacheKey,
|
|
9
9
|
# else the first `ID` field; none on Query/Mutation), the field, and its
|
|
@@ -43,7 +43,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::CountDirectiveResolver < Lighthou
|
|
|
43
43
|
|
|
44
44
|
args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
|
|
45
45
|
relation = apply_scopes(relation)
|
|
46
|
-
relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args_h, ctx, self)
|
|
47
46
|
relation = apply_where_filters(relation, args_h, ctx)
|
|
48
47
|
relation.count
|
|
49
48
|
end
|
|
@@ -14,7 +14,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::FirstDirectiveResolver < Lighthou
|
|
|
14
14
|
method_name = "resolve_field_#{@field_def.name}"
|
|
15
15
|
model_name_static = directive_arg('model') || field_type_name
|
|
16
16
|
resolver_instance = self
|
|
17
|
-
field_ast_node = @field_def.ast_node
|
|
18
17
|
|
|
19
18
|
resolver = lambda do |_obj, arguments, ctx|
|
|
20
19
|
model_class = resolver_instance.constantize_model(model_name_static)
|
|
@@ -22,7 +21,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::FirstDirectiveResolver < Lighthou
|
|
|
22
21
|
|
|
23
22
|
args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
|
|
24
23
|
relation = resolver_instance.apply_scopes(model_class.all)
|
|
25
|
-
relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, field_ast_node, args_h, ctx, resolver_instance)
|
|
26
24
|
relation = resolver_instance.apply_where_filters(relation, args_h, ctx)
|
|
27
25
|
relation.first
|
|
28
26
|
end
|
|
@@ -51,7 +51,9 @@ class Lighthouse::GraphQL::DirectiveResolvers::GlobalIdDirectiveResolver < Light
|
|
|
51
51
|
|
|
52
52
|
install(target_type_class, lambda do |obj, args, ctx|
|
|
53
53
|
id = inner.call(obj, args, ctx)
|
|
54
|
-
id.nil?
|
|
54
|
+
next if id.nil?
|
|
55
|
+
|
|
56
|
+
Lighthouse::GraphQL::GlobalId.encode_field(target_type_class, obj, id, ctx) || Lighthouse::Config.global_id.encode(type_name, id)
|
|
55
57
|
end)
|
|
56
58
|
end
|
|
57
59
|
end
|
|
@@ -72,10 +72,9 @@ class Lighthouse::GraphQL::DirectiveResolvers::PaginateDirectiveResolver < Light
|
|
|
72
72
|
|
|
73
73
|
public
|
|
74
74
|
|
|
75
|
-
# `scopes:`,
|
|
75
|
+
# `scopes:`, the argument filters and @builder, on one relation or a RelationBatch.
|
|
76
76
|
def decorate_relation(relation, args, ctx)
|
|
77
|
-
|
|
78
|
-
apply_where_filters(relation, args, ctx)
|
|
77
|
+
apply_where_filters(apply_scopes(relation), args, ctx)
|
|
79
78
|
end
|
|
80
79
|
|
|
81
80
|
protected
|
|
@@ -1,17 +1,35 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'active_support/duration'
|
|
4
|
+
require 'active_support/notifications'
|
|
3
5
|
require_relative 'base'
|
|
4
6
|
|
|
5
|
-
# @throttle —
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
7
|
+
# @throttle — Rails' `rate_limit` on one field: at most `maxAttempts` calls (its
|
|
8
|
+
# to:) every `decayMinutes` (its within:), counted as ActionController::RateLimiting
|
|
9
|
+
# counts them, with its options:
|
|
10
|
+
#
|
|
11
|
+
# by: who the client is, as rate_limit's by: a path in the context, as
|
|
12
|
+
# @inject's ("current_user.id"); the client's IP by default.
|
|
13
|
+
# with: a method of the controller to call past the limit, as rate_limit's
|
|
14
|
+
# with:. One that renders or redirects ends the request with its
|
|
15
|
+
# response, as a before_action that renders does; one that returns lets
|
|
16
|
+
# the call through. Without it, rate_limit's default: raise
|
|
17
|
+
# ActionController::TooManyRequests, which Rails answers with a 429
|
|
18
|
+
# (`head :too_many_requests` on a Rails without it, 7.2). Without Action
|
|
19
|
+
# Pack at all, the field fails: "Rate limit for Query.x exceeded. Try again later."
|
|
20
|
+
# name: as rate_limit's: the field by default (Query.x). Fields, and the
|
|
21
|
+
# controller's own `rate_limit`, of the same name count together.
|
|
22
|
+
# prefix: as rate_limit's scope: the controller's path by default.
|
|
23
|
+
#
|
|
24
|
+
# The count lives in the controller's cache_store (config.action_controller.cache_store,
|
|
25
|
+
# Rails.cache unless set), under rate_limit's key, and a refused call is instrumented
|
|
26
|
+
# as rate_limit's (rate_limit.action_controller). The controller concern puts the
|
|
27
|
+
# controller in the context (:lighthouse_controller); outside one, the count lives in
|
|
28
|
+
# Rails.cache, with no scope. Outermost: refused calls never reach @guard or the resolver.
|
|
11
29
|
#
|
|
12
30
|
# type Mutation {
|
|
13
31
|
# login(email: String!, password: String!): User @throttle(maxAttempts: 5)
|
|
14
|
-
# search(term: String!): [Post!]! @throttle(
|
|
32
|
+
# search(term: String!): [Post!]! @throttle(maxAttempts: 30, decayMinutes: 0.5, by: "current_user.id")
|
|
15
33
|
# }
|
|
16
34
|
class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
17
35
|
def self.directive_name = 'throttle'
|
|
@@ -19,17 +37,21 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
|
|
|
19
37
|
def self.definition
|
|
20
38
|
<<~GRAPHQL.chomp
|
|
21
39
|
"""
|
|
22
|
-
Sets rate limit to access the field
|
|
40
|
+
Sets rate limit to access the field, as Rails' rate_limit does for an action.
|
|
23
41
|
"""
|
|
24
42
|
directive @throttle(
|
|
25
|
-
"
|
|
43
|
+
"As rate_limit's name: fields, and the controller's own rate_limit, of the same name count together. Defaults to the field."
|
|
26
44
|
name: String
|
|
27
45
|
"Maximum number of attempts in a specified time interval."
|
|
28
46
|
maxAttempts: Int = 60
|
|
29
47
|
"Time in minutes to reset attempts."
|
|
30
48
|
decayMinutes: Float = 1.0
|
|
31
|
-
"
|
|
49
|
+
"As rate_limit's scope: what the count is shared across. Defaults to the controller's path."
|
|
32
50
|
prefix: String
|
|
51
|
+
"As rate_limit's by: who the client is, a path in the context (\\"current_user.id\\"). Defaults to the client's IP."
|
|
52
|
+
by: String
|
|
53
|
+
"As rate_limit's with: the controller's method to call past the limit. Defaults to rate_limit's: a 429."
|
|
54
|
+
with: String
|
|
33
55
|
) on FIELD_DEFINITION
|
|
34
56
|
GRAPHQL
|
|
35
57
|
end
|
|
@@ -38,22 +60,24 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
|
|
|
38
60
|
me = self
|
|
39
61
|
inner = capture_inner(target_type_class)
|
|
40
62
|
location = "#{target_type_class.graphql_name}.#{@field_def.name}"
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
63
|
+
limit = {
|
|
64
|
+
to: directive_arg('maxAttempts') || 60, within: ActiveSupport::Duration.build(((directive_arg('decayMinutes') || 1.0) * 60).ceil),
|
|
65
|
+
by: directive_arg('by'), with: directive_arg('with'), name: directive_arg('name') || location, scope: directive_arg('prefix')
|
|
66
|
+
}
|
|
44
67
|
|
|
45
68
|
install(target_type_class, lambda do |obj, args, ctx|
|
|
46
|
-
me.hit!(
|
|
69
|
+
me.hit!(limit, ctx, location)
|
|
47
70
|
inner.call(obj, args, ctx)
|
|
48
71
|
end)
|
|
49
72
|
end
|
|
50
73
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
74
|
+
# ActionController::RateLimiting#rate_limiting, for one call of the field.
|
|
75
|
+
def hit!(limit, ctx, location)
|
|
76
|
+
controller = ctx[:lighthouse_controller]
|
|
77
|
+
key = { scope: limit[:scope] || controller&.controller_path, name: limit[:name], by: client(limit, ctx) }
|
|
78
|
+
cache_key = ['rate-limit', *key.values].compact.join(':')
|
|
79
|
+
count = increment(store(controller), cache_key, limit[:within], location)
|
|
80
|
+
refuse(controller, limit, location, key.merge(count: count, cache_key: cache_key)) if count && count > limit[:to]
|
|
57
81
|
end
|
|
58
82
|
|
|
59
83
|
# A :null_store keeps no count, so the limit never applies: say so, once per field.
|
|
@@ -61,17 +85,52 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
|
|
|
61
85
|
@warned ||= Set.new
|
|
62
86
|
return unless @warned.add?(location)
|
|
63
87
|
|
|
64
|
-
message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store.
|
|
88
|
+
message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. " \
|
|
89
|
+
'Set config.action_controller.cache_store (or config.cache_store), as for Rails\' rate_limit.'
|
|
65
90
|
defined?(Rails) && Rails.logger ? Rails.logger.warn(message) : warn(message)
|
|
66
91
|
end
|
|
67
92
|
|
|
68
93
|
private
|
|
69
94
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
95
|
+
# rate_limit's by: the value at `by`'s path in the context, else the client's IP.
|
|
96
|
+
def client(limit, ctx)
|
|
97
|
+
limit[:by] ? Lighthouse::Support::Arguments.dig(ctx, limit[:by]).first : ctx[:remote_ip]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Where rate_limit counts: the controller's cache_store; Rails.cache outside one.
|
|
101
|
+
def store(controller)
|
|
102
|
+
controller.respond_to?(:cache_store) ? controller.cache_store : Lighthouse::GraphQL::Caching.store
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# The count after this call. A store whose increment does not create the key
|
|
106
|
+
# (ActiveSupport before 7.1) starts it with a write.
|
|
107
|
+
def increment(store, key, within, location)
|
|
108
|
+
self.class.warn_null_store(location) if store.is_a?(::ActiveSupport::Cache::NullStore)
|
|
109
|
+
store.increment(key, 1, expires_in: within) || (store.write(key, 1, expires_in: within) && 1)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Past the limit: instrumented as rate_limit's, around `with`, after which a
|
|
113
|
+
# response `with` performed ends the request, as a before_action's would.
|
|
114
|
+
def refuse(controller, limit, location, counted)
|
|
115
|
+
payload = { request: controller&.request, to: limit[:to], within: limit[:within], **counted }
|
|
116
|
+
ActiveSupport::Notifications.instrument('rate_limit.action_controller', payload) { limited(controller, limit[:with], location) }
|
|
117
|
+
raise Lighthouse::GraphQL::Controller::Performed if controller&.performed?
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The controller's `with` method, else rate_limit's default `with`.
|
|
121
|
+
def limited(controller, with, location)
|
|
122
|
+
return call_with(controller, with, location) if with && controller
|
|
123
|
+
raise ::ActionController::TooManyRequests if defined?(::ActionController::TooManyRequests)
|
|
124
|
+
return controller.head(:too_many_requests) if controller
|
|
125
|
+
|
|
126
|
+
raise ::GraphQL::ExecutionError, "Rate limit for #{location} exceeded. Try again later."
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def call_with(controller, with, location)
|
|
130
|
+
unless controller.respond_to?(with, true)
|
|
131
|
+
raise Lighthouse::DefinitionError, "@throttle(with: \"#{with}\") on #{location}: #{controller.class} has no method #{with}."
|
|
132
|
+
end
|
|
73
133
|
|
|
74
|
-
|
|
75
|
-
ip ? "ip:#{ip}" : 'guest'
|
|
134
|
+
controller.send(with)
|
|
76
135
|
end
|
|
77
136
|
end
|
|
@@ -10,15 +10,18 @@ require_relative 'base'
|
|
|
10
10
|
# `object` (the parent), `context`, the `dataload*` helpers, and the field's
|
|
11
11
|
# arguments as snake_case keywords. A field with a resolving directive never
|
|
12
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
|
|
14
|
-
#
|
|
13
|
+
# object as before. Not a directive: FieldResolverStack installs it, on the
|
|
14
|
+
# root types too (Types::QueryType#latest_posts for Query.latestPosts, before
|
|
15
|
+
# the conventional Resolvers::LatestPosts).
|
|
15
16
|
class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
16
17
|
# The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
|
|
17
18
|
# Config.namespaces[:types], when it includes Lighthouse::GraphQL::Type; nil
|
|
18
19
|
# otherwise, so a type class that does not opt in is left alone. It must
|
|
19
20
|
# inherit from the schema's base_types[:object] (in `bases`, ResolverBases)
|
|
20
|
-
# when one is given.
|
|
21
|
-
def self.type_class(type_name, bases = {})
|
|
21
|
+
# when one is given. A root type's class is the one it is built on (RootTypes).
|
|
22
|
+
def self.type_class(type_name, bases = {}, root_kind = nil)
|
|
23
|
+
return Lighthouse::GraphQL::RootTypes.type_class(root_kind, bases[:roots]) if root_kind
|
|
24
|
+
|
|
22
25
|
klass = lookup(type_name)
|
|
23
26
|
return unless klass
|
|
24
27
|
|
|
@@ -38,9 +41,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::
|
|
|
38
41
|
name if klass.public_method_defined?(name) && !::GraphQL::Schema::Object.public_method_defined?(name)
|
|
39
42
|
end
|
|
40
43
|
|
|
41
|
-
|
|
44
|
+
# `root_class`: the root type class the type is built on (RootTypes), used as
|
|
45
|
+
# it is; any other class is looked up by the type's name on each call.
|
|
46
|
+
def initialize(field_def, type_def, method_name, root_class = nil)
|
|
42
47
|
super(field_def, type_def, nil)
|
|
43
48
|
@method_name = method_name
|
|
49
|
+
@root_class = root_class
|
|
44
50
|
end
|
|
45
51
|
|
|
46
52
|
def define_resolver(target_type_class)
|
|
@@ -59,7 +65,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::
|
|
|
59
65
|
# it no longer defines the method, the field is read as before. A parent that
|
|
60
66
|
# is not a record of the type's @model is reported, and the field resolves to nil.
|
|
61
67
|
def resolve_with_type(inner, obj, args, ctx)
|
|
62
|
-
klass = self.class.lookup(@type_def.graphql_name)
|
|
68
|
+
klass = @root_class || self.class.lookup(@type_def.graphql_name)
|
|
63
69
|
return inner.call(obj, args, ctx) unless klass&.public_method_defined?(@method_name)
|
|
64
70
|
|
|
65
71
|
model = Lighthouse::GraphQL::Directives::ModelDirective.declared_model(@type_def)
|
|
@@ -174,7 +174,7 @@ module Lighthouse::GraphQL::Directives::Arguments
|
|
|
174
174
|
Typically, this option should only be used for root fields,
|
|
175
175
|
as it may cause wrong results with batched relation queries.
|
|
176
176
|
"""
|
|
177
|
-
builder: Boolean
|
|
177
|
+
builder: Boolean = false
|
|
178
178
|
) on ARGUMENT_DEFINITION | FIELD_DEFINITION
|
|
179
179
|
GRAPHQL
|
|
180
180
|
end
|
|
@@ -2,47 +2,211 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative 'base'
|
|
4
4
|
|
|
5
|
-
# @orderBy
|
|
5
|
+
# @orderBy, as PHP Lighthouse's: on an argument, the client orders the result
|
|
6
|
+
# by one or more clauses; on a field, the result is always in that order.
|
|
6
7
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
8
|
+
# type Query {
|
|
9
|
+
# posts(orderBy: _ @orderBy(columns: ["posted_at", "title"])): [Post!]! @all
|
|
10
|
+
# users(orderBy: _ @orderBy(relations: [{ relation: "posts" }])): [User!]! @all
|
|
11
|
+
# latestPosts: [Post!]! @all @orderBy(column: "posted_at", direction: DESC)
|
|
12
|
+
# }
|
|
13
|
+
#
|
|
14
|
+
# { posts(orderBy: [{ column: POSTED_AT, order: DESC }, { column: TITLE, order: ASC }]) { title } }
|
|
15
|
+
# { users(orderBy: [{ posts: { aggregate: COUNT }, order: DESC }]) { name } }
|
|
16
|
+
#
|
|
17
|
+
# An argument written with the `_` placeholder gets the types generated for
|
|
18
|
+
# it (Query.posts(orderBy:) -> [QueryPostsOrderByOrderByClause!]):
|
|
19
|
+
#
|
|
20
|
+
# - no `columns:` / `columnsEnum:` / `relations:`: the shared OrderByClause, any column;
|
|
21
|
+
# - `columns:` / `columnsEnum:`: <QualifiedArgType>OrderByClause, its column one of the enum;
|
|
22
|
+
# - `relations:`: <QualifiedArgType>RelationOrderByClause, whose clauses name
|
|
23
|
+
# either a column or a relation's aggregate (COUNT, or AVG/MIN/MAX/SUM of a column).
|
|
24
|
+
#
|
|
25
|
+
# A relation's aggregate is computed per record in a LEFT JOIN on a grouped
|
|
26
|
+
# subquery, so records without related ones are ordered too (a COUNT of 0).
|
|
11
27
|
class Lighthouse::GraphQL::Directives::Arguments::OrderBy < Lighthouse::GraphQL::Directives::Arguments::Base
|
|
28
|
+
include Lighthouse::Contracts::ArgManipulator
|
|
29
|
+
include Lighthouse::Contracts::FieldBuilder
|
|
30
|
+
|
|
31
|
+
# The Arel node of each OrderByRelation*AggregateFunction value (Arel::Nodes::<name>).
|
|
32
|
+
AGGREGATES = { 'count' => :Count, 'avg' => :Avg, 'min' => :Min, 'max' => :Max, 'sum' => :Sum }.freeze
|
|
33
|
+
|
|
34
|
+
DEFINITION = <<~GRAPHQL.chomp
|
|
35
|
+
"""
|
|
36
|
+
Sort a result list by one or more given columns.
|
|
37
|
+
"""
|
|
38
|
+
directive @orderBy(
|
|
39
|
+
"""
|
|
40
|
+
Restrict the allowed column names to a well-defined list.
|
|
41
|
+
This improves introspection capabilities and security.
|
|
42
|
+
Mutually exclusive with `columnsEnum`.
|
|
43
|
+
Only used when the directive is added on an argument.
|
|
44
|
+
"""
|
|
45
|
+
columns: [String!]
|
|
46
|
+
|
|
47
|
+
"""
|
|
48
|
+
Use an existing enumeration type to restrict the allowed columns to a predefined list.
|
|
49
|
+
This allows you to re-use the same enum for multiple fields.
|
|
50
|
+
Mutually exclusive with `columns`.
|
|
51
|
+
Only used when the directive is added on an argument.
|
|
52
|
+
"""
|
|
53
|
+
columnsEnum: String
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
Allow clients to sort by aggregates on relations.
|
|
57
|
+
Only used when the directive is added on an argument.
|
|
58
|
+
"""
|
|
59
|
+
relations: [OrderByRelation!]
|
|
60
|
+
|
|
61
|
+
"""
|
|
62
|
+
The database column for which the order by clause will be applied on.
|
|
63
|
+
Only used when the directive is added on a field.
|
|
64
|
+
"""
|
|
65
|
+
column: String
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
The direction of the order by clause.
|
|
69
|
+
Only used when the directive is added on a field.
|
|
70
|
+
"""
|
|
71
|
+
direction: OrderByDirection = ASC
|
|
72
|
+
) on ARGUMENT_DEFINITION | FIELD_DEFINITION
|
|
73
|
+
GRAPHQL
|
|
74
|
+
|
|
75
|
+
TYPE_DEFINITIONS = {
|
|
76
|
+
'OrderByDirection' => <<~GRAPHQL.chomp,
|
|
77
|
+
"""
|
|
78
|
+
Options for the `direction` argument of `@orderBy`.
|
|
79
|
+
"""
|
|
80
|
+
enum OrderByDirection {
|
|
81
|
+
"""
|
|
82
|
+
Sort in ascending order.
|
|
83
|
+
"""
|
|
84
|
+
ASC
|
|
85
|
+
|
|
86
|
+
"""
|
|
87
|
+
Sort in descending order.
|
|
88
|
+
"""
|
|
89
|
+
DESC
|
|
90
|
+
}
|
|
91
|
+
GRAPHQL
|
|
92
|
+
'OrderByRelation' => <<~GRAPHQL.chomp
|
|
93
|
+
"""
|
|
94
|
+
Options for the `relations` argument of `@orderBy`.
|
|
95
|
+
"""
|
|
96
|
+
input OrderByRelation {
|
|
97
|
+
"""
|
|
98
|
+
Name of the relation.
|
|
99
|
+
"""
|
|
100
|
+
relation: String!
|
|
101
|
+
|
|
102
|
+
"""
|
|
103
|
+
Restrict the allowed column names to a well-defined list.
|
|
104
|
+
This improves introspection capabilities and security.
|
|
105
|
+
Mutually exclusive with `columnsEnum`.
|
|
106
|
+
"""
|
|
107
|
+
columns: [String!]
|
|
108
|
+
|
|
109
|
+
"""
|
|
110
|
+
Use an existing enumeration type to restrict the allowed columns to a predefined list.
|
|
111
|
+
This allows you to re-use the same enum for multiple fields.
|
|
112
|
+
Mutually exclusive with `columns`.
|
|
113
|
+
"""
|
|
114
|
+
columnsEnum: String
|
|
115
|
+
}
|
|
116
|
+
GRAPHQL
|
|
117
|
+
}.freeze
|
|
118
|
+
|
|
12
119
|
def self.directive_name = 'orderBy'
|
|
13
|
-
|
|
120
|
+
|
|
121
|
+
def self.definition = DEFINITION
|
|
122
|
+
|
|
123
|
+
def self.type_definitions = TYPE_DEFINITIONS
|
|
124
|
+
|
|
125
|
+
# --- Schema: the argument's types (Lighthouse::OrderBy::Types) ------------
|
|
126
|
+
|
|
127
|
+
def manipulate_arg_definition(document_ast, field, type)
|
|
128
|
+
Lighthouse::OrderBy::Types.new(document_ast, @directive_node, @arg_node, field, type).argument
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# --- Runtime --------------------------------------------------------------
|
|
14
132
|
|
|
15
133
|
def handle_builder(relation, value)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
next rel if col.blank?
|
|
134
|
+
Array(value).each_with_index.reduce(relation) { |ordered, (clause, index)| order_by(ordered, symbolized(clause), index) }
|
|
135
|
+
end
|
|
19
136
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
137
|
+
# On a field: `column:` in `direction:`.
|
|
138
|
+
def handle_field_builder(relation)
|
|
139
|
+
column = Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'column')
|
|
140
|
+
return relation unless column
|
|
141
|
+
|
|
142
|
+
direction = Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'direction').to_s.casecmp?('DESC') ? :desc : :asc
|
|
143
|
+
relation.order(column_attribute(relation, column).public_send(direction))
|
|
23
144
|
end
|
|
24
145
|
|
|
25
146
|
private
|
|
26
147
|
|
|
27
|
-
def
|
|
28
|
-
|
|
29
|
-
list.filter_map { |item| clause(item) }
|
|
148
|
+
def relations
|
|
149
|
+
@relations ||= Array(Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'relations')).map { |relation| relation.transform_keys(&:to_s) }
|
|
30
150
|
end
|
|
31
151
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
152
|
+
def order_by(relation, clause, index)
|
|
153
|
+
direction = clause[:order].to_s.casecmp?('DESC') ? :desc : :asc
|
|
154
|
+
aggregated = clause.except(:column, :order).compact
|
|
155
|
+
unless clause[:column].nil? ^ aggregated.empty?
|
|
156
|
+
raise ::GraphQL::ExecutionError, "An order by clause takes exactly one of: column, #{relations.pluck('relation').join(', ')}."
|
|
157
|
+
end
|
|
158
|
+
return relation.order(column_attribute(relation, clause[:column]).public_send(direction)) if clause[:column]
|
|
159
|
+
|
|
160
|
+
relation_name, aggregate = aggregated.first
|
|
161
|
+
order_by_aggregate(relation, relation_name, symbolized(aggregate), direction, index)
|
|
162
|
+
end
|
|
36
163
|
|
|
37
|
-
|
|
38
|
-
|
|
164
|
+
# LEFT JOIN (SELECT owner.id, AGGREGATE(related.column) FROM owner LEFT JOIN related ... GROUP BY owner.id)
|
|
165
|
+
# and ORDER BY the aggregate.
|
|
166
|
+
def order_by_aggregate(relation, relation_name, aggregate, direction, index)
|
|
167
|
+
owner = relation.klass
|
|
168
|
+
reflection = reflection(owner, relation_name)
|
|
169
|
+
key = owner.arel_table[owner.primary_key]
|
|
170
|
+
table = ::Arel::Table.new("lighthouse_order_#{index}_#{reflection.name}")
|
|
171
|
+
totals = owner.unscoped.left_joins(reflection.name).group(key)
|
|
172
|
+
.select(key.as('lighthouse_key'), aggregate_node(reflection.klass, aggregate).as('lighthouse_aggregate'))
|
|
173
|
+
|
|
174
|
+
relation.joins(left_join(owner, totals, table, key)).order(table[:lighthouse_aggregate].public_send(direction))
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def left_join(owner, totals, table, key)
|
|
178
|
+
subquery = ::Arel::Nodes::TableAlias.new(::Arel::Nodes::Grouping.new(totals.arel), table.name)
|
|
179
|
+
owner.arel_table.join(subquery, ::Arel::Nodes::OuterJoin).on(table[:lighthouse_key].eq(key)).join_sources
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
def aggregate_node(related, aggregate)
|
|
183
|
+
function = aggregate[:aggregate].to_s.downcase
|
|
184
|
+
::Arel::Nodes.const_get(AGGREGATES.fetch(function)).new([aggregated_attribute(related, function, aggregate[:column])])
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# COUNT counts the related records (their primary key); the other aggregates need a column.
|
|
188
|
+
def aggregated_attribute(related, function, column)
|
|
189
|
+
return column_attribute(related.all, column, as: function == 'count' ? :sort : :numeric) if column
|
|
190
|
+
raise ::GraphQL::ExecutionError, "Ordering by the #{function.upcase} of a relation needs a column." unless function == 'count'
|
|
191
|
+
|
|
192
|
+
related.arel_table[related.primary_key]
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
def reflection(owner, name)
|
|
196
|
+
candidates = [name.to_s, name.to_s.camelize(:lower), name.to_s.underscore].uniq
|
|
197
|
+
configured = relations.pluck('relation')
|
|
198
|
+
found = candidates.find { |candidate| configured.include?(candidate) } || name.to_s
|
|
199
|
+
owner.reflect_on_association(found.to_sym) || owner.reflect_on_association(found.underscore.to_sym) ||
|
|
200
|
+
raise(Lighthouse::DefinitionError, "@orderBy(relations:): #{owner.name} has no relation #{found}")
|
|
201
|
+
end
|
|
39
202
|
|
|
40
|
-
|
|
203
|
+
# A JSON path orders by its JSON value; an aggregate takes it as a number.
|
|
204
|
+
def column_attribute(relation, column, as: :sort)
|
|
205
|
+
Lighthouse::WhereConditions::Columns.attribute(relation.klass, column, as: as)
|
|
41
206
|
end
|
|
42
207
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
hash
|
|
46
|
-
hash.transform_keys { |k| k.to_s.to_sym } if hash.is_a?(Hash)
|
|
208
|
+
def symbolized(value)
|
|
209
|
+
hash = value.respond_to?(:to_h) ? value.to_h : value
|
|
210
|
+
hash.is_a?(Hash) ? hash.transform_keys { |key| key.to_s.underscore.to_sym } : {}
|
|
47
211
|
end
|
|
48
212
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'where_conditions_base'
|
|
4
|
+
|
|
5
|
+
# @whereConditions: the client's conditions on the field's records.
|
|
6
|
+
#
|
|
7
|
+
# people(where: _ @whereConditions(columns: ["age", "type"])): [Person!]! @all
|
|
8
|
+
# { people(where: { column: AGE, operator: GT, value: 37 }) { name } }
|
|
9
|
+
class Lighthouse::GraphQL::Directives::Arguments::WhereConditions < Lighthouse::GraphQL::Directives::Arguments::WhereConditionsBase
|
|
10
|
+
SUFFIX = 'WhereConditions'
|
|
11
|
+
DESCRIPTION = 'Filter results by field conditions.'
|
|
12
|
+
|
|
13
|
+
DEFINITION = <<~GRAPHQL.chomp
|
|
14
|
+
"""
|
|
15
|
+
Add a dynamically client-controlled WHERE condition to a fields query.
|
|
16
|
+
"""
|
|
17
|
+
directive @whereConditions(
|
|
18
|
+
#{COLUMN_ARGUMENTS.gsub(/^/, ' ')}
|
|
19
|
+
) on ARGUMENT_DEFINITION
|
|
20
|
+
GRAPHQL
|
|
21
|
+
|
|
22
|
+
def self.directive_name = 'whereConditions'
|
|
23
|
+
|
|
24
|
+
def self.definition = DEFINITION
|
|
25
|
+
end
|