lighthouse-graphql 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -1
  3. data/README.md +38 -22
  4. data/docs/best-practices.md +6 -4
  5. data/docs/caching.md +4 -3
  6. data/docs/configuration.md +20 -26
  7. data/docs/custom-directives.md +2 -1
  8. data/docs/directives.md +79 -14
  9. data/docs/federation.md +1 -1
  10. data/docs/filtering-and-ordering.md +330 -30
  11. data/docs/generators.md +42 -40
  12. data/docs/getting-started.md +128 -58
  13. data/docs/motivation.md +3 -2
  14. data/docs/performance.md +5 -4
  15. data/docs/relationships.md +7 -5
  16. data/docs/relay.md +63 -0
  17. data/docs/resolvers.md +92 -39
  18. data/docs/schema-organisation.md +177 -0
  19. data/docs/security.md +64 -16
  20. data/docs/subscriptions.md +16 -9
  21. data/docs/types.md +16 -17
  22. data/docs/uploads.md +41 -14
  23. data/lib/generators/lighthouse/base_classes.rb +9 -14
  24. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  25. data/lib/generators/lighthouse/install/install_generator.rb +125 -99
  26. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
  27. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -13
  28. data/lib/lighthouse/ast_builder.rb +124 -0
  29. data/lib/lighthouse/caching.rb +9 -3
  30. data/lib/lighthouse/config.rb +27 -25
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
  32. data/lib/lighthouse/contracts/field_builder.rb +13 -0
  33. data/lib/lighthouse/controller.rb +28 -5
  34. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
  36. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +18 -18
  37. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
  38. data/lib/lighthouse/directive_resolvers/base.rb +6 -37
  39. data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
  40. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  41. data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
  42. data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
  43. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +3 -1
  44. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
  45. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +85 -26
  46. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +12 -6
  47. data/lib/lighthouse/directives/arguments/more_filters.rb +1 -1
  48. data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
  49. data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
  50. data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
  51. data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
  52. data/lib/lighthouse/directives/definitions.rb +0 -1
  53. data/lib/lighthouse/directives/generated_types.rb +103 -0
  54. data/lib/lighthouse/directives/registry.rb +39 -10
  55. data/lib/lighthouse/document_ast.rb +111 -0
  56. data/lib/lighthouse/field_resolver_stack.rb +9 -6
  57. data/lib/lighthouse/global_id.rb +29 -3
  58. data/lib/lighthouse/graphql/version.rb +1 -1
  59. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  60. data/lib/lighthouse/ide_helper.rb +80 -23
  61. data/lib/lighthouse/namespaces.rb +1 -0
  62. data/lib/lighthouse/order_by/types.rb +137 -0
  63. data/lib/lighthouse/railtie.rb +9 -2
  64. data/lib/lighthouse/rb_lighthouse.rb +16 -5
  65. data/lib/lighthouse/resolve_info.rb +5 -5
  66. data/lib/lighthouse/resolver_bases.rb +18 -4
  67. data/lib/lighthouse/resolver_dispatch.rb +16 -1
  68. data/lib/lighthouse/root_types.rb +117 -0
  69. data/lib/lighthouse/schema_cache.rb +8 -4
  70. data/lib/lighthouse/schema_factory.rb +6 -9
  71. data/lib/lighthouse/schema_generator.rb +8 -18
  72. data/lib/lighthouse/schema_stitcher.rb +64 -0
  73. data/lib/lighthouse/support/naming.rb +0 -12
  74. data/lib/lighthouse/upload.rb +4 -2
  75. data/lib/lighthouse/upload_store.rb +39 -14
  76. data/lib/lighthouse/where_conditions/columns.rb +36 -0
  77. data/lib/lighthouse/where_conditions/handler.rb +152 -0
  78. data/lib/lighthouse/where_conditions/json_column.rb +99 -0
  79. data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
  80. data/lib/lighthouse/where_conditions/types.rb +110 -0
  81. data/sig/lighthouse/directives.rbs +16 -3
  82. data/sig/lighthouse/graphql.rbs +48 -4
  83. data/sig/lighthouse.rbs +8 -12
  84. metadata +17 -7
  85. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -29
  86. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  87. data/lib/generators/lighthouse/query_generator.rb +0 -30
  88. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
  89. data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
  90. 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)
@@ -3,7 +3,7 @@
3
3
  require_relative 'base'
4
4
 
5
5
  module Lighthouse::GraphQL::DirectiveResolvers
6
- # @cache — keep a field's resolved value in Config.cache_store (Rails.cache).
6
+ # @cache — keep a field's resolved value in Rails.cache (Caching.store).
7
7
  #
8
8
  # The key covers the parent type and its key (the field marked @cacheKey,
9
9
  # else the first `ID` field; none on Query/Mutation), the field, and its
@@ -43,7 +43,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::CountDirectiveResolver < Lighthou
43
43
 
44
44
  args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
45
45
  relation = apply_scopes(relation)
46
- relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, @field_def.ast_node, args_h, ctx, self)
47
46
  relation = apply_where_filters(relation, args_h, ctx)
48
47
  relation.count
49
48
  end
@@ -14,7 +14,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::FirstDirectiveResolver < Lighthou
14
14
  method_name = "resolve_field_#{@field_def.name}"
15
15
  model_name_static = directive_arg('model') || field_type_name
16
16
  resolver_instance = self
17
- field_ast_node = @field_def.ast_node
18
17
 
19
18
  resolver = lambda do |_obj, arguments, ctx|
20
19
  model_class = resolver_instance.constantize_model(model_name_static)
@@ -22,7 +21,6 @@ class Lighthouse::GraphQL::DirectiveResolvers::FirstDirectiveResolver < Lighthou
22
21
 
23
22
  args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
24
23
  relation = resolver_instance.apply_scopes(model_class.all)
25
- relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(relation, field_ast_node, args_h, ctx, resolver_instance)
26
24
  relation = resolver_instance.apply_where_filters(relation, args_h, ctx)
27
25
  relation.first
28
26
  end
@@ -51,7 +51,9 @@ class Lighthouse::GraphQL::DirectiveResolvers::GlobalIdDirectiveResolver < Light
51
51
 
52
52
  install(target_type_class, lambda do |obj, args, ctx|
53
53
  id = inner.call(obj, args, ctx)
54
- id.nil? ? nil : Lighthouse::Config.global_id.encode(type_name, id)
54
+ next if id.nil?
55
+
56
+ Lighthouse::GraphQL::GlobalId.encode_field(target_type_class, obj, id, ctx) || Lighthouse::Config.global_id.encode(type_name, id)
55
57
  end)
56
58
  end
57
59
  end
@@ -72,10 +72,9 @@ class Lighthouse::GraphQL::DirectiveResolvers::PaginateDirectiveResolver < Light
72
72
 
73
73
  public
74
74
 
75
- # `scopes:`, @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
@@ -1,17 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'active_support/duration'
4
+ require 'active_support/notifications'
3
5
  require_relative 'base'
4
6
 
5
- # @throttle — rate-limit a field per client, as PHP Lighthouse's directive (and
6
- # Laravel's ThrottleRequests): at most `maxAttempts` calls every `decayMinutes`,
7
- # counted in Config.cache_store. A client is the signed-in user, else the
8
- # context's :remote_ip (Lighthouse::GraphQL::Controller sets it), else "guest".
9
- # Past the limit the field fails with "Rate limit for Query.x exceeded. Try
10
- # again later." Outermost: refused calls never reach @guard or the resolver.
7
+ # @throttle — Rails' `rate_limit` on one field: at most `maxAttempts` calls (its
8
+ # to:) every `decayMinutes` (its within:), counted as ActionController::RateLimiting
9
+ # counts them, with its options:
10
+ #
11
+ # by: who the client is, as rate_limit's by: a path in the context, as
12
+ # @inject's ("current_user.id"); the client's IP by default.
13
+ # with: a method of the controller to call past the limit, as rate_limit's
14
+ # with:. One that renders or redirects ends the request with its
15
+ # response, as a before_action that renders does; one that returns lets
16
+ # the call through. Without it, rate_limit's default: raise
17
+ # ActionController::TooManyRequests, which Rails answers with a 429
18
+ # (`head :too_many_requests` on a Rails without it, 7.2). Without Action
19
+ # Pack at all, the field fails: "Rate limit for Query.x exceeded. Try again later."
20
+ # name: as rate_limit's: the field by default (Query.x). Fields, and the
21
+ # controller's own `rate_limit`, of the same name count together.
22
+ # prefix: as rate_limit's scope: the controller's path by default.
23
+ #
24
+ # The count lives in the controller's cache_store (config.action_controller.cache_store,
25
+ # Rails.cache unless set), under rate_limit's key, and a refused call is instrumented
26
+ # as rate_limit's (rate_limit.action_controller). The controller concern puts the
27
+ # controller in the context (:lighthouse_controller); outside one, the count lives in
28
+ # Rails.cache, with no scope. Outermost: refused calls never reach @guard or the resolver.
11
29
  #
12
30
  # type Mutation {
13
31
  # login(email: String!, password: String!): User @throttle(maxAttempts: 5)
14
- # search(term: String!): [Post!]! @throttle(name: "search", maxAttempts: 30, decayMinutes: 0.5)
32
+ # search(term: String!): [Post!]! @throttle(maxAttempts: 30, decayMinutes: 0.5, by: "current_user.id")
15
33
  # }
16
34
  class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
17
35
  def self.directive_name = 'throttle'
@@ -19,17 +37,21 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
19
37
  def self.definition
20
38
  <<~GRAPHQL.chomp
21
39
  """
22
- Sets rate limit to access the field. Does the same as ThrottleRequests Laravel Middleware.
40
+ Sets rate limit to access the field, as Rails' rate_limit does for an action.
23
41
  """
24
42
  directive @throttle(
25
- "A name shared by the fields that count together. Defaults to the field."
43
+ "As rate_limit's name: fields, and the controller's own rate_limit, of the same name count together. Defaults to the field."
26
44
  name: String
27
45
  "Maximum number of attempts in a specified time interval."
28
46
  maxAttempts: Int = 60
29
47
  "Time in minutes to reset attempts."
30
48
  decayMinutes: Float = 1.0
31
- "Prefix to distinguish several field groups."
49
+ "As rate_limit's scope: what the count is shared across. Defaults to the controller's path."
32
50
  prefix: String
51
+ "As rate_limit's by: who the client is, a path in the context (\\"current_user.id\\"). Defaults to the client's IP."
52
+ by: String
53
+ "As rate_limit's with: the controller's method to call past the limit. Defaults to rate_limit's: a 429."
54
+ with: String
33
55
  ) on FIELD_DEFINITION
34
56
  GRAPHQL
35
57
  end
@@ -38,22 +60,24 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
38
60
  me = self
39
61
  inner = capture_inner(target_type_class)
40
62
  location = "#{target_type_class.graphql_name}.#{@field_def.name}"
41
- bucket = "#{directive_arg('prefix')}#{directive_arg('name') || location}"
42
- max_attempts = directive_arg('maxAttempts') || 60
43
- decay = ((directive_arg('decayMinutes') || 1.0) * 60).ceil
63
+ limit = {
64
+ to: directive_arg('maxAttempts') || 60, within: ActiveSupport::Duration.build(((directive_arg('decayMinutes') || 1.0) * 60).ceil),
65
+ by: directive_arg('by'), with: directive_arg('with'), name: directive_arg('name') || location, scope: directive_arg('prefix')
66
+ }
44
67
 
45
68
  install(target_type_class, lambda do |obj, args, ctx|
46
- me.hit!(bucket, ctx, max_attempts, decay, location)
69
+ me.hit!(limit, ctx, location)
47
70
  inner.call(obj, args, ctx)
48
71
  end)
49
72
  end
50
73
 
51
- def hit!(bucket, ctx, max_attempts, decay, location)
52
- key = "lighthouse:throttle:#{bucket}:#{client(ctx)}"
53
- store = Lighthouse::GraphQL::Caching.store
54
- self.class.warn_null_store(location) if store.is_a?(::ActiveSupport::Cache::NullStore)
55
- attempts = store.increment(key, 1, expires_in: decay) || (store.write(key, 1, expires_in: decay) && 1)
56
- raise ::GraphQL::ExecutionError, "Rate limit for #{location} exceeded. Try again later." if attempts > max_attempts
74
+ # ActionController::RateLimiting#rate_limiting, for one call of the field.
75
+ def hit!(limit, ctx, location)
76
+ controller = ctx[:lighthouse_controller]
77
+ key = { scope: limit[:scope] || controller&.controller_path, name: limit[:name], by: client(limit, ctx) }
78
+ cache_key = ['rate-limit', *key.values].compact.join(':')
79
+ count = increment(store(controller), cache_key, limit[:within], location)
80
+ refuse(controller, limit, location, key.merge(count: count, cache_key: cache_key)) if count && count > limit[:to]
57
81
  end
58
82
 
59
83
  # A :null_store keeps no count, so the limit never applies: say so, once per field.
@@ -61,17 +85,52 @@ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Light
61
85
  @warned ||= Set.new
62
86
  return unless @warned.add?(location)
63
87
 
64
- message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. Set Config.cache_store."
88
+ message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. " \
89
+ 'Set config.action_controller.cache_store (or config.cache_store), as for Rails\' rate_limit.'
65
90
  defined?(Rails) && Rails.logger ? Rails.logger.warn(message) : warn(message)
66
91
  end
67
92
 
68
93
  private
69
94
 
70
- def client(ctx)
71
- user = Lighthouse::Support::Authorization.authenticated_user(ctx)
72
- return "user:#{user.respond_to?(:id) ? user.id : user}" if user
95
+ # rate_limit's by: the value at `by`'s path in the context, else the client's IP.
96
+ def client(limit, ctx)
97
+ limit[:by] ? Lighthouse::Support::Arguments.dig(ctx, limit[:by]).first : ctx[:remote_ip]
98
+ end
99
+
100
+ # Where rate_limit counts: the controller's cache_store; Rails.cache outside one.
101
+ def store(controller)
102
+ controller.respond_to?(:cache_store) ? controller.cache_store : Lighthouse::GraphQL::Caching.store
103
+ end
104
+
105
+ # The count after this call. A store whose increment does not create the key
106
+ # (ActiveSupport before 7.1) starts it with a write.
107
+ def increment(store, key, within, location)
108
+ self.class.warn_null_store(location) if store.is_a?(::ActiveSupport::Cache::NullStore)
109
+ store.increment(key, 1, expires_in: within) || (store.write(key, 1, expires_in: within) && 1)
110
+ end
111
+
112
+ # Past the limit: instrumented as rate_limit's, around `with`, after which a
113
+ # response `with` performed ends the request, as a before_action's would.
114
+ def refuse(controller, limit, location, counted)
115
+ payload = { request: controller&.request, to: limit[:to], within: limit[:within], **counted }
116
+ ActiveSupport::Notifications.instrument('rate_limit.action_controller', payload) { limited(controller, limit[:with], location) }
117
+ raise Lighthouse::GraphQL::Controller::Performed if controller&.performed?
118
+ end
119
+
120
+ # The controller's `with` method, else rate_limit's default `with`.
121
+ def limited(controller, with, location)
122
+ return call_with(controller, with, location) if with && controller
123
+ raise ::ActionController::TooManyRequests if defined?(::ActionController::TooManyRequests)
124
+ return controller.head(:too_many_requests) if controller
125
+
126
+ raise ::GraphQL::ExecutionError, "Rate limit for #{location} exceeded. Try again later."
127
+ end
128
+
129
+ def call_with(controller, with, location)
130
+ unless controller.respond_to?(with, true)
131
+ raise Lighthouse::DefinitionError, "@throttle(with: \"#{with}\") on #{location}: #{controller.class} has no method #{with}."
132
+ end
73
133
 
74
- ip = ctx.respond_to?(:[]) ? ctx[:remote_ip] : nil
75
- ip ? "ip:#{ip}" : 'guest'
134
+ controller.send(with)
76
135
  end
77
136
  end
@@ -10,15 +10,18 @@ require_relative 'base'
10
10
  # `object` (the parent), `context`, the `dataload*` helpers, and the field's
11
11
  # arguments as snake_case keywords. A field with a resolving directive never
12
12
  # reaches it, and one whose name the class does not define is read off the
13
- # object as before. Not a directive: FieldResolverStack installs it on types
14
- # other than the root ones.
13
+ # object as before. Not a directive: FieldResolverStack installs it, on the
14
+ # root types too (Types::QueryType#latest_posts for Query.latestPosts, before
15
+ # the conventional Resolvers::LatestPosts).
15
16
  class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
17
  # The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
17
18
  # Config.namespaces[:types], when it includes Lighthouse::GraphQL::Type; nil
18
19
  # otherwise, so a type class that does not opt in is left alone. It must
19
20
  # inherit from the schema's base_types[:object] (in `bases`, ResolverBases)
20
- # when one is given.
21
- def self.type_class(type_name, bases = {})
21
+ # when one is given. A root type's class is the one it is built on (RootTypes).
22
+ def self.type_class(type_name, bases = {}, root_kind = nil)
23
+ return Lighthouse::GraphQL::RootTypes.type_class(root_kind, bases[:roots]) if root_kind
24
+
22
25
  klass = lookup(type_name)
23
26
  return unless klass
24
27
 
@@ -38,9 +41,12 @@ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::
38
41
  name if klass.public_method_defined?(name) && !::GraphQL::Schema::Object.public_method_defined?(name)
39
42
  end
40
43
 
41
- def initialize(field_def, type_def, method_name)
44
+ # `root_class`: the root type class the type is built on (RootTypes), used as
45
+ # it is; any other class is looked up by the type's name on each call.
46
+ def initialize(field_def, type_def, method_name, root_class = nil)
42
47
  super(field_def, type_def, nil)
43
48
  @method_name = method_name
49
+ @root_class = root_class
44
50
  end
45
51
 
46
52
  def define_resolver(target_type_class)
@@ -59,7 +65,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::
59
65
  # it no longer defines the method, the field is read as before. A parent that
60
66
  # is not a record of the type's @model is reported, and the field resolves to nil.
61
67
  def resolve_with_type(inner, obj, args, ctx)
62
- klass = self.class.lookup(@type_def.graphql_name)
68
+ klass = @root_class || self.class.lookup(@type_def.graphql_name)
63
69
  return inner.call(obj, args, ctx) unless klass&.public_method_defined?(@method_name)
64
70
 
65
71
  model = Lighthouse::GraphQL::Directives::ModelDirective.declared_model(@type_def)
@@ -174,7 +174,7 @@ module Lighthouse::GraphQL::Directives::Arguments
174
174
  Typically, this option should only be used for root fields,
175
175
  as it may cause wrong results with batched relation queries.
176
176
  """
177
- builder: Boolean! = false
177
+ builder: Boolean = false
178
178
  ) on ARGUMENT_DEFINITION | FIELD_DEFINITION
179
179
  GRAPHQL
180
180
  end
@@ -2,47 +2,211 @@
2
2
 
3
3
  require_relative 'base'
4
4
 
5
- # @orderBy — 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