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.
Files changed (63) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +95 -1
  3. data/README.md +15 -7
  4. data/docs/best-practices.md +6 -4
  5. data/docs/configuration.md +19 -5
  6. data/docs/custom-directives.md +2 -1
  7. data/docs/directives.md +65 -7
  8. data/docs/federation.md +1 -1
  9. data/docs/filtering-and-ordering.md +330 -30
  10. data/docs/generators.md +1 -1
  11. data/docs/getting-started.md +43 -10
  12. data/docs/motivation.md +3 -2
  13. data/docs/performance.md +4 -3
  14. data/docs/relationships.md +2 -1
  15. data/docs/resolvers.md +2 -2
  16. data/docs/schema-organisation.md +177 -0
  17. data/lib/generators/lighthouse/install/install_generator.rb +16 -11
  18. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
  19. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +11 -4
  20. data/lib/lighthouse/ast_builder.rb +124 -0
  21. data/lib/lighthouse/config.rb +23 -4
  22. data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
  23. data/lib/lighthouse/contracts/field_builder.rb +13 -0
  24. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
  25. data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
  26. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
  27. data/lib/lighthouse/directive_resolvers/base.rb +6 -37
  28. data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
  29. data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
  30. data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
  31. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
  32. data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
  33. data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
  34. data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
  35. data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
  36. data/lib/lighthouse/directives/definitions.rb +0 -1
  37. data/lib/lighthouse/directives/generated_types.rb +103 -0
  38. data/lib/lighthouse/directives/registry.rb +39 -10
  39. data/lib/lighthouse/document_ast.rb +111 -0
  40. data/lib/lighthouse/graphql/version.rb +1 -1
  41. data/lib/lighthouse/ide_helper.rb +80 -23
  42. data/lib/lighthouse/namespaces.rb +1 -0
  43. data/lib/lighthouse/order_by/types.rb +137 -0
  44. data/lib/lighthouse/railtie.rb +9 -2
  45. data/lib/lighthouse/rb_lighthouse.rb +15 -5
  46. data/lib/lighthouse/resolve_info.rb +5 -5
  47. data/lib/lighthouse/schema_cache.rb +8 -4
  48. data/lib/lighthouse/schema_factory.rb +6 -9
  49. data/lib/lighthouse/schema_generator.rb +4 -15
  50. data/lib/lighthouse/schema_stitcher.rb +64 -0
  51. data/lib/lighthouse/support/naming.rb +0 -12
  52. data/lib/lighthouse/where_conditions/columns.rb +36 -0
  53. data/lib/lighthouse/where_conditions/handler.rb +152 -0
  54. data/lib/lighthouse/where_conditions/json_column.rb +99 -0
  55. data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
  56. data/lib/lighthouse/where_conditions/types.rb +110 -0
  57. data/sig/lighthouse/directives.rbs +16 -3
  58. data/sig/lighthouse/graphql.rbs +48 -4
  59. data/sig/lighthouse.rbs +8 -2
  60. metadata +15 -4
  61. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
  62. data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
  63. 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: @builder classes, and
6
- # @whereAuth, which keeps the records the signed-in user owns. @builder on an
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
- field_def_ast_node.directives.each do |directive_node|
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 relation = apply_directive(relation, directive_node, args, ctx, resolver_instance)
58
- when 'whereAuth' then relation = where_auth(relation, directive_node, ctx)
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
- relation
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:`, @builder and the argument filters, on one relation or a RelationBatch.
75
+ # `scopes:`, the argument filters and @builder, on one relation or a RelationBatch.
76
76
  def decorate_relation(relation, args, ctx)
77
- relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(apply_scopes(relation), @field_def.ast_node, args, ctx, self)
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 — sort the result by one or more columns.
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
- # The client value is a list of clauses, each `{ column:, order: }`
8
- # (also accepts `{ field:, direction: }`). Clauses apply in order, so
9
- # `[{column: NAME, order: ASC}, {column: AGE, order: DESC}]` sorts by
10
- # name then age.
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
- def self.definition = 'directive @orderBy(columns: [String!], relations: [String!]) on ARGUMENT_DEFINITION | FIELD_DEFINITION'
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
- normalize(value).reduce(relation) do |rel, clause|
17
- col = Lighthouse::Support::Naming.column(clause[:column], rel)
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
- direction = clause[:order].to_s.downcase == 'desc' ? :desc : :asc
21
- rel.order(col => direction)
22
- end
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 normalize(value)
28
- list = value.is_a?(Array) ? value : [value]
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
- # One `{ column:, order: }` clause, or nil when the item names no column.
33
- def clause(item)
34
- symbolized = symbolized_hash(item)
35
- return nil unless symbolized
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
- col = symbolized[:column] || symbolized[:field]
38
- return nil if col.nil?
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
- { column: col, order: symbolized[:order] || symbolized[:direction] || 'ASC' }
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
- # The item as a symbol-keyed hash, or nil when it is not hash-like.
44
- def symbolized_hash(item)
45
- hash = item.respond_to?(:to_h) ? item.to_h : item
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