lighthouse-graphql 0.6.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 +95 -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 +1 -1
- 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/resolvers.md +2 -2
- data/docs/schema-organisation.md +177 -0
- data/lib/generators/lighthouse/install/install_generator.rb +16 -11
- 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/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
|
@@ -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
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# What the argument manipulators that generate types share, as PHP
|
|
4
|
+
# Lighthouse's ASTHelper and GeneratesColumnsEnum: the qualified name of an
|
|
5
|
+
# argument's types, the enum of its allowed columns, and the `_` placeholder
|
|
6
|
+
# the argument is written with.
|
|
7
|
+
#
|
|
8
|
+
# type Query { posts(orderBy: _ @orderBy(columns: ["posted_at"])): [Post!]! @all }
|
|
9
|
+
#
|
|
10
|
+
# names Query.posts(orderBy:)'s types QueryPostsOrderBy..., its columns enum
|
|
11
|
+
# QueryPostsOrderByColumn { POSTED_AT @enum(value: "posted_at") }.
|
|
12
|
+
module Lighthouse::GraphQL::Directives::GeneratedTypes
|
|
13
|
+
PLACEHOLDER = '_'
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
# Studly(Type) + Studly(field) + Studly(argument): QueryPostsOrderBy.
|
|
18
|
+
def qualified_arg_type(arg, field, type)
|
|
19
|
+
[type.name, field.name, arg.name].map { |name| studly(name) }.join
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def studly(name)
|
|
23
|
+
name.to_s.split(/[_\s-]+/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# `argument` of the directive as a Ruby value (`columns: ["a"]` -> ["a"]), or nil.
|
|
27
|
+
def directive_value(directive, name)
|
|
28
|
+
Lighthouse::Support::DirectiveArgs.literal(directive, name)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def directive_argument?(directive, name)
|
|
32
|
+
directive.arguments.any? { |argument| argument.name == name }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Raises unless at most one of `names` is given to the directive.
|
|
36
|
+
def mutually_exclusive!(directive, names)
|
|
37
|
+
given = names.select { |name| directive_argument?(directive, name) }
|
|
38
|
+
return if given.size <= 1
|
|
39
|
+
|
|
40
|
+
raise Lighthouse::DefinitionError,
|
|
41
|
+
"The @#{directive.name} directive can only have one of the following arguments: #{names.map { |n| "`#{n}`" }.join(', ')}."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Does the directive restrict the columns (`columns:` or `columnsEnum:`)?
|
|
45
|
+
def allowed_columns?(directive)
|
|
46
|
+
mutually_exclusive!(directive, %w[columns columnsEnum])
|
|
47
|
+
directive_argument?(directive, 'columns') || directive_argument?(directive, 'columnsEnum')
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# The enum of the allowed columns: `columnsEnum:`, or one generated from
|
|
51
|
+
# `columns:` as <QualifiedArgType>Column.
|
|
52
|
+
def columns_enum(document_ast, directive, arg, field, type)
|
|
53
|
+
enum = directive_value(directive, 'columnsEnum')
|
|
54
|
+
return enum if enum
|
|
55
|
+
|
|
56
|
+
name = "#{qualified_arg_type(arg, field, type)}Column"
|
|
57
|
+
define_columns_enum(document_ast, name, directive_value(directive, 'columns'), "#{type.name}.#{field.name}.#{arg.name}")
|
|
58
|
+
name
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def define_columns_enum(document_ast, name, columns, path)
|
|
62
|
+
values = Array(columns).map { |column| %( #{enum_value_name(column)} @enum(value: "#{column}")) }.uniq
|
|
63
|
+
document_ast.put_type(parse(<<~GRAPHQL))
|
|
64
|
+
"Allowed column names for #{path}."
|
|
65
|
+
enum #{name} {
|
|
66
|
+
#{values.join("\n")}
|
|
67
|
+
}
|
|
68
|
+
GRAPHQL
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# `posted_at` -> POSTED_AT, `roles.id` -> ROLES_ID.
|
|
72
|
+
def enum_value_name(column)
|
|
73
|
+
name = column.to_s.gsub(/[^0-9A-Za-z_]/, '_').upcase
|
|
74
|
+
name.match?(/\A[0-9]/) ? "_#{name}" : name
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The argument with its type replaced by `type_string` ("[QueryPostsOrderByClause!]"),
|
|
78
|
+
# when it is written with the `_` placeholder. An argument given a type of
|
|
79
|
+
# its own keeps it.
|
|
80
|
+
def replace_placeholder(arg, type_string)
|
|
81
|
+
return arg unless placeholder?(arg.type)
|
|
82
|
+
|
|
83
|
+
arg.merge(type: parse_type(type_string))
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def placeholder?(type)
|
|
87
|
+
type = type.of_type while type.respond_to?(:of_type)
|
|
88
|
+
type.name == PLACEHOLDER
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# The argument with `description`, unless it has one.
|
|
92
|
+
def describe(arg, description)
|
|
93
|
+
arg.description.to_s.empty? ? arg.merge(description: description) : arg
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def parse(sdl)
|
|
97
|
+
::GraphQL::Language::Parser.parse(sdl).definitions.first
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def parse_type(type_string)
|
|
101
|
+
parse("input LighthouseTypeReference { field: #{type_string} }").fields.first.type
|
|
102
|
+
end
|
|
103
|
+
end
|