lighthouse-graphql 0.6.0 → 0.7.1
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 +107 -1
- data/README.md +15 -7
- data/docs/best-practices.md +6 -4
- data/docs/configuration.md +19 -5
- data/docs/custom-directives.md +2 -1
- data/docs/directives.md +65 -7
- data/docs/federation.md +1 -1
- data/docs/filtering-and-ordering.md +330 -30
- data/docs/generators.md +2 -2
- data/docs/getting-started.md +43 -10
- data/docs/motivation.md +3 -2
- data/docs/performance.md +4 -3
- data/docs/relationships.md +2 -1
- data/docs/relay.md +52 -3
- data/docs/resolvers.md +2 -2
- data/docs/schema-organisation.md +177 -0
- data/lib/generators/lighthouse/install/graphql_ruby_install.rb +14 -0
- data/lib/generators/lighthouse/install/install_generator.rb +31 -12
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +11 -4
- data/lib/generators/lighthouse/install/templates/schema.graphql.tt +30 -0
- data/lib/lighthouse/ast_builder.rb +124 -0
- data/lib/lighthouse/config.rb +23 -4
- data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
- data/lib/lighthouse/contracts/field_builder.rb +13 -0
- 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/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/count_resolver.rb +0 -1
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
- 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/graphql/version.rb +1 -1
- 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 +15 -5
- data/lib/lighthouse/resolve_info.rb +5 -5
- data/lib/lighthouse/schema_cache.rb +8 -4
- data/lib/lighthouse/schema_factory.rb +6 -9
- data/lib/lighthouse/schema_generator.rb +4 -15
- data/lib/lighthouse/schema_stitcher.rb +64 -0
- data/lib/lighthouse/support/naming.rb +0 -12
- 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 -2
- metadata +15 -4
- 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
|
@@ -36,7 +36,7 @@ module Lighthouse::GraphQL::DirectiveResolvers
|
|
|
36
36
|
return nil unless obj.respond_to?(name)
|
|
37
37
|
|
|
38
38
|
return paginate(obj, name, args, ctx) if pagination_type
|
|
39
|
-
return constrained(association_scope(obj, name), args) if constrained?(args)
|
|
39
|
+
return constrained(association_scope(obj, name), args, ctx) if constrained?(args)
|
|
40
40
|
|
|
41
41
|
dataloader = Lighthouse::Support::Batching.dataloader(ctx)
|
|
42
42
|
return obj.public_send(name) unless dataloader && obj.class.respond_to?(:reflect_on_association)
|
|
@@ -52,11 +52,14 @@ module Lighthouse::GraphQL::DirectiveResolvers
|
|
|
52
52
|
self.class.collection? && directive_arg('type')&.to_s&.upcase
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
# Scopes
|
|
56
|
-
#
|
|
55
|
+
# Scopes, a query directive on the field (@builder, @orderBy(column:)) or a
|
|
56
|
+
# supplied argument with a filter directive need a query of their own
|
|
57
|
+
# instead of the batched load.
|
|
57
58
|
def constrained?(args)
|
|
58
|
-
|
|
59
|
+
directive_arg('scopes') || Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.any?(@field_def.ast_node) || filtered?(args)
|
|
60
|
+
end
|
|
59
61
|
|
|
62
|
+
def filtered?(args)
|
|
60
63
|
registry = Lighthouse::DirectiveRegistry.default
|
|
61
64
|
(@field_def.ast_node&.arguments || []).any? do |argument|
|
|
62
65
|
args.key?(argument.name.underscore.to_sym) && argument.directives.any? { |d| filter_directive?(registry, d) }
|
|
@@ -68,8 +71,8 @@ module Lighthouse::GraphQL::DirectiveResolvers
|
|
|
68
71
|
registry.arg_builder_class(directive.name) || CONDITION_DIRECTIVES.include?(directive.name)
|
|
69
72
|
end
|
|
70
73
|
|
|
71
|
-
def constrained(relation, args)
|
|
72
|
-
relation = apply_where_filters(scoped(relation), args)
|
|
74
|
+
def constrained(relation, args, ctx)
|
|
75
|
+
relation = apply_where_filters(scoped(relation), args, ctx)
|
|
73
76
|
self.class.collection? ? relation : relation.first
|
|
74
77
|
end
|
|
75
78
|
|
|
@@ -18,12 +18,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
18
18
|
|
|
19
19
|
args_h = args.respond_to?(:to_h) ? args.to_h : (args || {})
|
|
20
20
|
|
|
21
|
-
relation
|
|
22
|
-
@field_def.ast_node.arguments.each do |arg_node|
|
|
23
|
-
relation = apply_where_has_conditions(relation, arg_node, args_h)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
apply_arg_builders(relation, args_h, ctx)
|
|
21
|
+
apply_field_builders(apply_arg_builders(relation, args_h, ctx), args_h, ctx)
|
|
27
22
|
rescue ::GraphQL::ExecutionError
|
|
28
23
|
raise
|
|
29
24
|
rescue StandardError => e
|
|
@@ -32,7 +27,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
32
27
|
end
|
|
33
28
|
|
|
34
29
|
# Compose argument-builder directives (@eq, @neq, @in, @notIn, @like,
|
|
35
|
-
# @where, @orderBy) onto the relation, in SDL argument order, for each
|
|
30
|
+
# @where, @orderBy, @whereConditions, @whereHasConditions) onto the relation, in SDL argument order, for each
|
|
36
31
|
# field argument that carries one and that the client supplied a value
|
|
37
32
|
# for — and for the input fields of an argument carrying @nest.
|
|
38
33
|
def apply_arg_builders(relation, args_h, ctx = nil)
|
|
@@ -51,18 +46,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
51
46
|
relation
|
|
52
47
|
end
|
|
53
48
|
|
|
54
|
-
def extract_relation_name_from_directive(arg_node)
|
|
55
|
-
directive = arg_node.directives.find { |dir| dir.name == 'whereHasConditions' }
|
|
56
|
-
return nil unless directive
|
|
57
|
-
|
|
58
|
-
relation_arg = directive.arguments.find { |arg| arg.name == 'relation' }
|
|
59
|
-
return nil unless relation_arg
|
|
60
|
-
|
|
61
|
-
relation_arg.value.respond_to?(:value) ? relation_arg.value.value : relation_arg.value
|
|
62
|
-
rescue StandardError
|
|
63
|
-
nil
|
|
64
|
-
end
|
|
65
|
-
|
|
66
49
|
def constantize_model(name)
|
|
67
50
|
Lighthouse::Support::ModelResolver.constantize(name)
|
|
68
51
|
end
|
|
@@ -113,24 +96,10 @@ class Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
|
|
|
113
96
|
@field_def.type.unwrap.graphql_name.sub(/(SimplePaginator|Paginator|Connection)\z/, '')
|
|
114
97
|
end
|
|
115
98
|
|
|
116
|
-
# The
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
Lighthouse::GraphQL::DirectiveResolvers::WhereConditionsApplier.apply(relation, where_condition)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# A `whereHasConditions` argument the client supplied filters the
|
|
125
|
-
# relation by the related records matching its condition.
|
|
126
|
-
def apply_where_has_conditions(relation, arg_node, args_h)
|
|
127
|
-
return relation unless arg_node.directives.any? { |dir| dir.name == 'whereHasConditions' }
|
|
128
|
-
|
|
129
|
-
arg_value = Lighthouse::Support::Naming.fetch_arg(args_h, arg_node.name)
|
|
130
|
-
return relation unless arg_value
|
|
131
|
-
|
|
132
|
-
relation_name = extract_relation_name_from_directive(arg_node) || Lighthouse::Support::Naming.derive_relation_name(arg_node.name)
|
|
133
|
-
Lighthouse::GraphQL::DirectiveResolvers::WhereConditionsApplier.apply_has_argument(relation, arg_node.name, arg_value, relation_name)
|
|
99
|
+
# The field's own query directives (@builder, @whereAuth, @orderBy(column:)),
|
|
100
|
+
# in SDL order, after the arguments', as PHP Lighthouse's enhanceBuilder applies them.
|
|
101
|
+
def apply_field_builders(relation, args_h = {}, ctx = nil)
|
|
102
|
+
Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args_h, ctx, self)
|
|
134
103
|
end
|
|
135
104
|
|
|
136
105
|
# Every (argument, directive, builder class) on the field, in SDL order.
|
|
@@ -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)
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
# What @whereConditions and @whereHasConditions share, as PHP Lighthouse's
|
|
6
|
+
# WhereConditionsBaseDirective: an argument written with the `_` placeholder
|
|
7
|
+
# gets the input types the directive generates (Lighthouse::WhereConditions::Types),
|
|
8
|
+
# and the client's value becomes WHERE conditions on the field's query
|
|
9
|
+
# (Lighthouse::WhereConditions::Handler).
|
|
10
|
+
#
|
|
11
|
+
# type Query {
|
|
12
|
+
# people(where: _ @whereConditions(columns: ["age", "type"])): [Person!]! @all
|
|
13
|
+
# users(hasPosts: _ @whereHasConditions(columns: ["title"])): [User!]! @all
|
|
14
|
+
# }
|
|
15
|
+
#
|
|
16
|
+
# `columns:` restricts the columns to an enum generated from them,
|
|
17
|
+
# `columnsEnum:` to an enum of the schema's; without either the column is any
|
|
18
|
+
# identifier the client sends.
|
|
19
|
+
class Lighthouse::GraphQL::Directives::Arguments::WhereConditionsBase < Lighthouse::GraphQL::Directives::Arguments::Base
|
|
20
|
+
include Lighthouse::Contracts::ArgManipulator
|
|
21
|
+
|
|
22
|
+
GeneratedTypes = Lighthouse::GraphQL::Directives::GeneratedTypes
|
|
23
|
+
|
|
24
|
+
COLUMN_ARGUMENTS = <<~GRAPHQL.chomp
|
|
25
|
+
"""
|
|
26
|
+
Restrict the allowed column names to a well-defined list.
|
|
27
|
+
This improves introspection capabilities and security.
|
|
28
|
+
Mutually exclusive with `columnsEnum`.
|
|
29
|
+
"""
|
|
30
|
+
columns: [String!]
|
|
31
|
+
|
|
32
|
+
"""
|
|
33
|
+
Use an existing enumeration type to restrict the allowed columns to a predefined list.
|
|
34
|
+
This allows you to re-use the same enum for multiple fields.
|
|
35
|
+
Mutually exclusive with `columns`.
|
|
36
|
+
"""
|
|
37
|
+
columnsEnum: String
|
|
38
|
+
|
|
39
|
+
"""
|
|
40
|
+
A class that applies the client given conditions to the query: `call(relation, conditions)`
|
|
41
|
+
answers the relation. "Class@method" names another method. Looked up in the builders namespaces.
|
|
42
|
+
"""
|
|
43
|
+
handler: String
|
|
44
|
+
GRAPHQL
|
|
45
|
+
|
|
46
|
+
# The argument, typed with the conditions input generated for it.
|
|
47
|
+
def manipulate_arg_definition(document_ast, field, type)
|
|
48
|
+
path = "the `#{@arg_node.name}` argument of #{type.name}.#{field.name}"
|
|
49
|
+
arg = GeneratedTypes.describe(@arg_node, self.class::DESCRIPTION)
|
|
50
|
+
types = Lighthouse::WhereConditions::Types
|
|
51
|
+
unless GeneratedTypes.allowed_columns?(@directive_node)
|
|
52
|
+
types.define_default(document_ast)
|
|
53
|
+
return GeneratedTypes.replace_placeholder(arg, types::DEFAULT)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
name = GeneratedTypes.qualified_arg_type(@arg_node, field, type) + self.class::SUFFIX
|
|
57
|
+
types.define(document_ast, name, GeneratedTypes.columns_enum(document_ast, @directive_node, @arg_node, field, type), path)
|
|
58
|
+
GeneratedTypes.replace_placeholder(arg, name)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def handle_builder(relation, value)
|
|
62
|
+
return relation if value.nil?
|
|
63
|
+
|
|
64
|
+
handler.call(relation, conditions(value)) || relation
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
private
|
|
68
|
+
|
|
69
|
+
def conditions(value) = value
|
|
70
|
+
|
|
71
|
+
# Lighthouse::WhereConditions::Handler, or the class (or instance) `handler:` names.
|
|
72
|
+
def handler
|
|
73
|
+
reference = Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'handler')
|
|
74
|
+
return Lighthouse::WhereConditions::Handler unless reference
|
|
75
|
+
|
|
76
|
+
class_name, method_name = reference.to_s.split('@', 2)
|
|
77
|
+
klass = Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[:builders])
|
|
78
|
+
raise Lighthouse::DefinitionError, "@#{@directive_node.name}(handler: \"#{reference}\"): no class #{class_name}" unless klass
|
|
79
|
+
|
|
80
|
+
method_name = method_name.presence || 'call'
|
|
81
|
+
receiver = klass.respond_to?(method_name) ? klass : klass.new
|
|
82
|
+
receiver.method(method_name)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'where_conditions_base'
|
|
4
|
+
|
|
5
|
+
# @whereHasConditions: the records with a related record matching the
|
|
6
|
+
# client's conditions.
|
|
7
|
+
#
|
|
8
|
+
# users(hasPosts: _ @whereHasConditions(columns: ["title"])): [User!]! @all
|
|
9
|
+
# { users(hasPosts: { column: TITLE, value: "Notes" }) { name } }
|
|
10
|
+
class Lighthouse::GraphQL::Directives::Arguments::WhereHasConditions < Lighthouse::GraphQL::Directives::Arguments::WhereConditionsBase
|
|
11
|
+
SUFFIX = 'WhereHasConditions'
|
|
12
|
+
DESCRIPTION = 'Filter results by related records conditions.'
|
|
13
|
+
|
|
14
|
+
DEFINITION = <<~GRAPHQL.chomp
|
|
15
|
+
"""
|
|
16
|
+
Allows clients to filter a query based on the existence of a related model, using
|
|
17
|
+
a dynamically controlled `WHERE` condition that applies to the relationship.
|
|
18
|
+
"""
|
|
19
|
+
directive @whereHasConditions(
|
|
20
|
+
"""
|
|
21
|
+
The relationship that the conditions will be applied to.
|
|
22
|
+
This argument can be omitted if the argument name follows the naming
|
|
23
|
+
convention `has{$RELATION}`. For example, if the relationship
|
|
24
|
+
is named `posts`, the argument name must be `hasPosts`.
|
|
25
|
+
"""
|
|
26
|
+
relation: String
|
|
27
|
+
|
|
28
|
+
#{COLUMN_ARGUMENTS.gsub(/^/, ' ')}
|
|
29
|
+
) on ARGUMENT_DEFINITION
|
|
30
|
+
GRAPHQL
|
|
31
|
+
|
|
32
|
+
def self.directive_name = 'whereHasConditions'
|
|
33
|
+
|
|
34
|
+
def self.definition = DEFINITION
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
# The records with at least one related record matching the value (`{}`: any related record).
|
|
39
|
+
def conditions(value)
|
|
40
|
+
condition = value.respond_to?(:to_h) ? value.to_h : value
|
|
41
|
+
{
|
|
42
|
+
has: {
|
|
43
|
+
relation: relation_name,
|
|
44
|
+
operator: Lighthouse::WhereConditions::Types::DEFAULT_HAS_OPERATOR,
|
|
45
|
+
amount: Lighthouse::WhereConditions::Types::DEFAULT_HAS_AMOUNT,
|
|
46
|
+
condition: condition.presence
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# `relation:`, else the argument name without its `has` prefix: hasPosts -> posts.
|
|
52
|
+
def relation_name
|
|
53
|
+
Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'relation') ||
|
|
54
|
+
@arg_node.name.sub(/\Ahas(?=[A-Z_])/, '').then { |name| name[0].downcase + name[1..] }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -31,7 +31,6 @@ module Lighthouse::GraphQL::Directives::Definitions
|
|
|
31
31
|
graphql::Directives::NodeDirective::DEFINITIONS,
|
|
32
32
|
graphql::Directives::ModelDirective::DEFINITIONS,
|
|
33
33
|
graphql::Directives::VisibilityDirective::DEFINITIONS,
|
|
34
|
-
graphql::Directives::WhereConditionsDirective::DEFINITIONS,
|
|
35
34
|
graphql::Directives::SoftDeletesDirective::DEFINITIONS
|
|
36
35
|
].reduce(:merge)
|
|
37
36
|
end
|