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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -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 +2 -2
  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/relay.md +52 -3
  16. data/docs/resolvers.md +2 -2
  17. data/docs/schema-organisation.md +177 -0
  18. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +14 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +31 -12
  20. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +11 -4
  22. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +30 -0
  23. data/lib/lighthouse/ast_builder.rb +124 -0
  24. data/lib/lighthouse/config.rb +23 -4
  25. data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
  26. data/lib/lighthouse/contracts/field_builder.rb +13 -0
  27. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
  28. data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
  29. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
  30. data/lib/lighthouse/directive_resolvers/base.rb +6 -37
  31. data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
  32. data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
  33. data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
  34. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
  35. data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
  36. data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
  37. data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
  38. data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
  39. data/lib/lighthouse/directives/definitions.rb +0 -1
  40. data/lib/lighthouse/directives/generated_types.rb +103 -0
  41. data/lib/lighthouse/directives/registry.rb +39 -10
  42. data/lib/lighthouse/document_ast.rb +111 -0
  43. data/lib/lighthouse/graphql/version.rb +1 -1
  44. data/lib/lighthouse/ide_helper.rb +80 -23
  45. data/lib/lighthouse/namespaces.rb +1 -0
  46. data/lib/lighthouse/order_by/types.rb +137 -0
  47. data/lib/lighthouse/railtie.rb +9 -2
  48. data/lib/lighthouse/rb_lighthouse.rb +15 -5
  49. data/lib/lighthouse/resolve_info.rb +5 -5
  50. data/lib/lighthouse/schema_cache.rb +8 -4
  51. data/lib/lighthouse/schema_factory.rb +6 -9
  52. data/lib/lighthouse/schema_generator.rb +4 -15
  53. data/lib/lighthouse/schema_stitcher.rb +64 -0
  54. data/lib/lighthouse/support/naming.rb +0 -12
  55. data/lib/lighthouse/where_conditions/columns.rb +36 -0
  56. data/lib/lighthouse/where_conditions/handler.rb +152 -0
  57. data/lib/lighthouse/where_conditions/json_column.rb +99 -0
  58. data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
  59. data/lib/lighthouse/where_conditions/types.rb +110 -0
  60. data/sig/lighthouse/directives.rbs +16 -3
  61. data/sig/lighthouse/graphql.rbs +48 -4
  62. data/sig/lighthouse.rbs +8 -2
  63. metadata +15 -4
  64. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
  65. data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
  66. 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 or a supplied argument with a filter directive need a query of
56
- # their own instead of the batched load.
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
- return true if directive_arg('scopes')
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 = apply_where_conditions(relation, args_h)
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 `where:` argument (@whereConditions), when the client supplied one.
117
- def apply_where_conditions(relation, args_h)
118
- where_condition = args_h[:where] || args_h['where']
119
- return relation unless where_condition
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: @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