lighthouse-graphql 0.3.0 → 0.5.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 (99) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +182 -1
  3. data/README.md +18 -7
  4. data/docs/best-practices.md +16 -6
  5. data/docs/configuration.md +33 -8
  6. data/docs/directives.md +318 -10
  7. data/docs/events.md +3 -1
  8. data/docs/federation.md +3 -2
  9. data/docs/generators.md +82 -0
  10. data/docs/getting-started.md +66 -32
  11. data/docs/motivation.md +3 -2
  12. data/docs/relationships.md +42 -0
  13. data/docs/relay.md +137 -0
  14. data/docs/resolvers.md +338 -25
  15. data/docs/security.md +227 -0
  16. data/docs/subscriptions.md +60 -37
  17. data/docs/types.md +219 -0
  18. data/docs/uploads.md +80 -0
  19. data/lib/generators/lighthouse/base_classes.rb +70 -0
  20. data/lib/generators/lighthouse/class_generator.rb +19 -8
  21. data/lib/generators/lighthouse/install/install_generator.rb +96 -20
  22. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +19 -10
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +21 -2
  24. data/lib/generators/lighthouse/mutation_generator.rb +5 -4
  25. data/lib/generators/lighthouse/query_generator.rb +5 -4
  26. data/lib/generators/lighthouse/subscription_generator.rb +21 -16
  27. data/lib/lighthouse/async_mutation_job.rb +17 -0
  28. data/lib/lighthouse/complexity.rb +175 -0
  29. data/lib/lighthouse/complexity_analyzer.rb +56 -0
  30. data/lib/lighthouse/config.rb +16 -4
  31. data/lib/lighthouse/controller.rb +27 -9
  32. data/lib/lighthouse/dataloader_sources/preload_loader.rb +16 -0
  33. data/lib/lighthouse/dataloader_sources/relation_counts_loader.rb +46 -0
  34. data/lib/lighthouse/default_resolve_dispatcher.rb +26 -0
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +1 -1
  36. data/lib/lighthouse/directive_resolvers/argument_binding.rb +61 -0
  37. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +220 -0
  38. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +2 -2
  39. data/lib/lighthouse/directive_resolvers/async_resolver.rb +90 -0
  40. data/lib/lighthouse/directive_resolvers/base.rb +63 -14
  41. data/lib/lighthouse/directive_resolvers/builder_applier.rb +89 -19
  42. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +1 -1
  43. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +16 -5
  44. data/lib/lighthouse/directive_resolvers/convert_empty_strings_resolver.rb +1 -1
  45. data/lib/lighthouse/directive_resolvers/count_resolver.rb +1 -1
  46. data/lib/lighthouse/directive_resolvers/field_resolver.rb +35 -7
  47. data/lib/lighthouse/directive_resolvers/first_resolver.rb +1 -1
  48. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +57 -0
  49. data/lib/lighthouse/directive_resolvers/lazy_load_resolver.rb +61 -0
  50. data/lib/lighthouse/directive_resolvers/limit_resolver.rb +59 -0
  51. data/lib/lighthouse/directive_resolvers/method_resolver.rb +38 -3
  52. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +1 -1
  53. data/lib/lighthouse/directive_resolvers/namespaced_resolver.rb +31 -0
  54. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +1 -1
  55. data/lib/lighthouse/directive_resolvers/resolution.rb +19 -4
  56. data/lib/lighthouse/directive_resolvers/subscription_resolver.rb +14 -5
  57. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +77 -0
  58. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +81 -0
  59. data/lib/lighthouse/directive_resolvers/with_count_resolver.rb +43 -0
  60. data/lib/lighthouse/directive_resolvers/with_resolver.rb +42 -0
  61. data/lib/lighthouse/directives/arguments/more_filters.rb +215 -0
  62. data/lib/lighthouse/directives/definitions.rb +52 -0
  63. data/lib/lighthouse/directives/model_directive.rb +131 -0
  64. data/lib/lighthouse/directives/node_directive.rb +134 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +1 -1
  66. data/lib/lighthouse/directives/registry.rb +21 -20
  67. data/lib/lighthouse/directives/visibility_directive.rb +71 -0
  68. data/lib/lighthouse/enum_directive.rb +41 -0
  69. data/lib/lighthouse/events/execution_trace.rb +2 -1
  70. data/lib/lighthouse/events/lifecycle.rb +3 -1
  71. data/lib/lighthouse/field_resolver_stack.rb +59 -11
  72. data/lib/lighthouse/generated_resolvers.rb +13 -8
  73. data/lib/lighthouse/global_id.rb +31 -0
  74. data/lib/lighthouse/graphql/version.rb +1 -1
  75. data/lib/lighthouse/multipart_request.rb +43 -0
  76. data/lib/lighthouse/node_registry.rb +68 -0
  77. data/lib/lighthouse/pagination.rb +3 -0
  78. data/lib/lighthouse/rb_lighthouse.rb +36 -0
  79. data/lib/lighthouse/relation_counts.rb +29 -0
  80. data/lib/lighthouse/resolve_info.rb +80 -0
  81. data/lib/lighthouse/resolver_bases.rb +68 -0
  82. data/lib/lighthouse/resolver_dispatch.rb +36 -11
  83. data/lib/lighthouse/scalar_coercion.rb +72 -0
  84. data/lib/lighthouse/schema_cache.rb +2 -1
  85. data/lib/lighthouse/schema_factory.rb +0 -1
  86. data/lib/lighthouse/schema_generator.rb +21 -12
  87. data/lib/lighthouse/schema_implementation.rb +4 -8
  88. data/lib/lighthouse/sdl_loader.rb +0 -2
  89. data/lib/lighthouse/support/environment.rb +13 -0
  90. data/lib/lighthouse/support/model_resolver.rb +8 -6
  91. data/lib/lighthouse/support/preload.rb +49 -0
  92. data/lib/lighthouse/type.rb +103 -0
  93. data/lib/lighthouse/type_resolution.rb +150 -0
  94. data/lib/lighthouse/upload.rb +26 -0
  95. data/lib/lighthouse/upload_store.rb +32 -0
  96. data/sig/lighthouse/directives.rbs +3 -2
  97. data/sig/lighthouse/graphql.rbs +29 -0
  98. data/sig/lighthouse.rbs +18 -0
  99. metadata +43 -1
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @namespaced — a field that only groups others, as PHP Lighthouse's
6
+ # directive: it resolves to `true`, and the fields of its type resolve with
7
+ # their own directives.
8
+ #
9
+ # type Query { post: PostQueries! @namespaced }
10
+ # type PostQueries {
11
+ # find(id: ID! @eq): Post @find
12
+ # list: [Post!]! @paginate
13
+ # }
14
+ #
15
+ # `{ post { find(id: 1) { title } } }`. Works on Mutation too.
16
+ class Lighthouse::GraphQL::DirectiveResolvers::NamespacedDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
17
+ def self.directive_name = 'namespaced'
18
+
19
+ def self.definition
20
+ <<~GRAPHQL.chomp
21
+ """
22
+ A no-op field resolver that allows nesting of queries and mutations.
23
+ """
24
+ directive @namespaced on FIELD_DEFINITION
25
+ GRAPHQL
26
+ end
27
+
28
+ def define_resolver(target_type_class)
29
+ install(target_type_class, ->(_obj, _args, _ctx) { true })
30
+ end
31
+ end
@@ -75,7 +75,7 @@ class Lighthouse::GraphQL::DirectiveResolvers::PaginateDirectiveResolver < Light
75
75
  # `scopes:`, @builder and the argument filters, on one relation or a RelationBatch.
76
76
  def decorate_relation(relation, args, ctx)
77
77
  relation = Lighthouse::GraphQL::DirectiveResolvers::BuilderApplier.apply(apply_scopes(relation), @field_def.ast_node, args, ctx, self)
78
- apply_where_filters(relation, args)
78
+ apply_where_filters(relation, args, ctx)
79
79
  end
80
80
 
81
81
  protected
@@ -18,15 +18,30 @@ module Lighthouse::GraphQL::DirectiveResolvers::Resolution
18
18
  original_singleton.call(obj, arguments, ctx)
19
19
  elsif original_proc
20
20
  original_proc.call(obj, arguments, ctx)
21
- elsif obj.respond_to?(field_name)
22
- obj.public_send(field_name)
23
21
  else
24
- snake = Lighthouse::Support::Naming.attribute(field_name)
25
- obj.respond_to?(snake) ? obj.public_send(snake) : nil
22
+ Lighthouse::GraphQL::DirectiveResolvers::Resolution.read(obj, field_name)
26
23
  end
27
24
  end
28
25
  end
29
26
 
27
+ # The default reader: a Hash's key, else the object's method of the field's
28
+ # name or its snake_case.
29
+ def read(obj, field_name)
30
+ return read_hash(obj, field_name) if obj.is_a?(Hash)
31
+ return obj.public_send(field_name) if obj.respond_to?(field_name)
32
+
33
+ snake = Lighthouse::Support::Naming.attribute(field_name)
34
+ obj.respond_to?(snake) ? obj.public_send(snake) : nil
35
+ end
36
+
37
+ # A Hash's value for the field, as the default reader finds it: by the field's
38
+ # name or its snake_case, as a String or a Symbol key.
39
+ def read_hash(hash, field_name)
40
+ snake = Lighthouse::Support::Naming.attribute(field_name)
41
+ key = [field_name.to_s, field_name.to_sym, snake, snake.to_sym].find { |candidate| hash.key?(candidate) }
42
+ key.nil? ? nil : hash[key]
43
+ end
44
+
30
45
  # graphql-ruby's SDL builder defines `resolve_field_*` methods of its own
31
46
  # (with a different arity); only ours, defined under lib/lighthouse/, count.
32
47
  def lighthouse_singleton(target_type_class, method_name)
@@ -8,8 +8,9 @@ require_relative 'base'
8
8
  # Config.namespaces[:subscriptions] at call time so code reloading keeps
9
9
  # working. The class is a GraphQL::Schema::Subscription, which may include
10
10
  # Lighthouse::GraphQL::Subscription for PHP Lighthouse's hooks. A field
11
- # with no class still works: it is a Lighthouse::GraphQL::Subscription::Default
12
- # whose update is the broadcast root, which is all `@broadcast` needs.
11
+ # with no class still works: it resolves with the schema's base_types[:subscription]
12
+ # when given, else with Lighthouse::GraphQL::Subscription::Default, whose update
13
+ # is the broadcast root, which is all `@broadcast` needs.
13
14
  #
14
15
  # graphql-ruby registers a subscription through the class its field was
15
16
  # built from, which a field built from SDL has not. The field is therefore
@@ -31,27 +32,35 @@ class Lighthouse::GraphQL::DirectiveResolvers::SubscriptionDirectiveResolver < L
31
32
  @field_def.define_singleton_method(:subscription_scope) { me.subscription_class.subscription_scope }
32
33
 
33
34
  install(target_type_class, lambda do |obj, args, ctx|
34
- Lighthouse::GraphQL::ResolverDispatch.call(me.subscription_class, me.field_def, obj, args, ctx)
35
+ info = Lighthouse::GraphQL::ResolveInfo.for(me.field_def, target_type_class, obj, args, ctx)
36
+ Lighthouse::GraphQL::ResolverDispatch.call(me.subscription_class, info)
35
37
  end)
36
38
  end
37
39
 
38
40
  attr_reader :field_def
39
41
 
40
42
  # The class this field stands for: the one @subscription names, else the
41
- # conventional one, else the default.
43
+ # conventional one, else the schema's base subscription, else the default.
42
44
  def subscription_class
43
45
  klass = find_class
44
- return Lighthouse::GraphQL::Subscription::Default unless klass
46
+ return base || Lighthouse::GraphQL::Subscription::Default unless klass
45
47
 
46
48
  unless klass.is_a?(Class) && klass < ::GraphQL::Schema::Subscription
47
49
  raise Lighthouse::DefinitionError, "#{klass} resolves #{location}, so it must inherit from GraphQL::Schema::Subscription."
48
50
  end
49
51
 
52
+ Lighthouse::GraphQL::ResolverBases.check!(klass, base, location)
53
+
50
54
  klass
51
55
  end
52
56
 
53
57
  private
54
58
 
59
+ # The schema's base_types[:subscription], or nil.
60
+ def base
61
+ (@opts[:bases] || {})[:subscriptions]
62
+ end
63
+
55
64
  def find_class
56
65
  namespaces = Lighthouse::Config.namespaces[:subscriptions]
57
66
  named = directive_arg('class') if @directive_node
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
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.
11
+ #
12
+ # type Mutation {
13
+ # login(email: String!, password: String!): User @throttle(maxAttempts: 5)
14
+ # search(term: String!): [Post!]! @throttle(name: "search", maxAttempts: 30, decayMinutes: 0.5)
15
+ # }
16
+ class Lighthouse::GraphQL::DirectiveResolvers::ThrottleDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
17
+ def self.directive_name = 'throttle'
18
+
19
+ def self.definition
20
+ <<~GRAPHQL.chomp
21
+ """
22
+ Sets rate limit to access the field. Does the same as ThrottleRequests Laravel Middleware.
23
+ """
24
+ directive @throttle(
25
+ "A name shared by the fields that count together. Defaults to the field."
26
+ name: String
27
+ "Maximum number of attempts in a specified time interval."
28
+ maxAttempts: Int = 60
29
+ "Time in minutes to reset attempts."
30
+ decayMinutes: Float = 1.0
31
+ "Prefix to distinguish several field groups."
32
+ prefix: String
33
+ ) on FIELD_DEFINITION
34
+ GRAPHQL
35
+ end
36
+
37
+ def define_resolver(target_type_class)
38
+ me = self
39
+ inner = capture_inner(target_type_class)
40
+ 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
44
+
45
+ install(target_type_class, lambda do |obj, args, ctx|
46
+ me.hit!(bucket, ctx, max_attempts, decay, location)
47
+ inner.call(obj, args, ctx)
48
+ end)
49
+ end
50
+
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
57
+ end
58
+
59
+ # A :null_store keeps no count, so the limit never applies: say so, once per field.
60
+ def self.warn_null_store(location)
61
+ @warned ||= Set.new
62
+ return unless @warned.add?(location)
63
+
64
+ message = "Lighthouse: @throttle on #{location} counts nothing, as the cache store is a :null_store. Set Config.cache_store."
65
+ defined?(Rails) && Rails.logger ? Rails.logger.warn(message) : warn(message)
66
+ end
67
+
68
+ private
69
+
70
+ def client(ctx)
71
+ user = Lighthouse::Support::Authorization.authenticated_user(ctx)
72
+ return "user:#{user.respond_to?(:id) ? user.id : user}" if user
73
+
74
+ ip = ctx.respond_to?(:[]) ? ctx[:remote_ip] : nil
75
+ ip ? "ip:#{ip}" : 'guest'
76
+ end
77
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # Resolves a field of an object type through a method of the type's class, in
6
+ # graphql-ruby's folder structure: `Post.readingTime` calls
7
+ # `Types::PostType#reading_time` when that class includes Lighthouse::GraphQL::Type
8
+ # and defines it. The SDL still declares the field; the class only says how it
9
+ # resolves, as a graphql-ruby type's method does: an instance per parent, with
10
+ # `object` (the parent), `context`, the `dataload*` helpers, and the field's
11
+ # arguments as snake_case keywords. A field with a resolving directive never
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.
15
+ class Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ # The class of the SDL type `type_name` (`Types::PostType` for `Post`) in
17
+ # Config.namespaces[:types], when it includes Lighthouse::GraphQL::Type; nil
18
+ # otherwise, so a type class that does not opt in is left alone. It must
19
+ # inherit from the schema's base_types[:object] (in `bases`, ResolverBases)
20
+ # when one is given.
21
+ def self.type_class(type_name, bases = {})
22
+ klass = lookup(type_name)
23
+ return unless klass
24
+
25
+ Lighthouse::GraphQL::ResolverBases.check!(klass, bases.dig(:types, :object), "the #{type_name} type")
26
+ klass
27
+ end
28
+
29
+ def self.lookup(type_name)
30
+ Lighthouse::GraphQL::Type.class_for(type_name, :object)
31
+ end
32
+
33
+ # The method of `klass` that resolves `field_def` (`reading_time` for
34
+ # `readingTime`), or nil. Methods every GraphQL::Schema::Object has
35
+ # (`object`, `context`, Object's own) never count.
36
+ def self.method_for(klass, field_def)
37
+ name = Lighthouse::Support::Naming.attribute(field_def.name)
38
+ name if klass.public_method_defined?(name) && !::GraphQL::Schema::Object.public_method_defined?(name)
39
+ end
40
+
41
+ def initialize(field_def, type_def, method_name)
42
+ super(field_def, type_def, nil)
43
+ @method_name = method_name
44
+ end
45
+
46
+ def define_resolver(target_type_class)
47
+ Lighthouse::GraphQL::ResolveInfo.request_lookahead(@field_def)
48
+ me = self
49
+ inner = capture_inner(target_type_class)
50
+
51
+ install(target_type_class, lambda do |obj, args, ctx|
52
+ me.send(:resolve_with_type, inner, obj, args, ctx)
53
+ end)
54
+ end
55
+
56
+ private
57
+
58
+ # The class is looked up on each call, so code reloading keeps working; when
59
+ # it no longer defines the method, the field is read as before. A parent that
60
+ # is not a record of the type's @model is reported, and the field resolves to nil.
61
+ def resolve_with_type(inner, obj, args, ctx)
62
+ klass = self.class.lookup(@type_def.graphql_name)
63
+ return inner.call(obj, args, ctx) unless klass&.public_method_defined?(@method_name)
64
+
65
+ model = Lighthouse::GraphQL::Directives::ModelDirective.declared_model(@type_def)
66
+ return wrong_parent(klass, model, obj, ctx) if model && !obj.is_a?(model)
67
+
68
+ info = Lighthouse::GraphQL::ResolveInfo.for(@field_def, @type_def, obj, args, ctx)
69
+ instance = Lighthouse::GraphQL::ResolveInfo.attach(klass.send(:new, obj, ctx), info)
70
+ kwargs = Lighthouse::Support::Arguments.prepare(@field_def, args, ctx)
71
+ kwargs.empty? ? instance.public_send(@method_name) : instance.public_send(@method_name, **kwargs)
72
+ end
73
+
74
+ def wrong_parent(klass, model, obj, ctx)
75
+ error = Lighthouse::DefinitionError.new(
76
+ "#{@type_def.graphql_name}.#{@field_def.name} resolves with #{klass}, but its parent is a #{obj.class}, not a #{model} (@model)."
77
+ )
78
+ Lighthouse::Config.handle_error(error, ctx)
79
+ nil
80
+ end
81
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @withCount(relation: "posts", scopes: [...]) — count a relation of the parent
6
+ # before the field resolves, as PHP Lighthouse's @withCount does: the count is not
7
+ # the field's value, it is kept on the parent as `<relation>_count`
8
+ # (`user.posts_count`) for whatever resolves the field to read. Every parent in
9
+ # the response is counted in one query.
10
+ #
11
+ # type User {
12
+ # activity: String! @withCount(relation: "posts") @method(name: "activity_label")
13
+ # postsCount: Int! @withCount(relation: "posts") # the default reader reads posts_count
14
+ # }
15
+ class Lighthouse::GraphQL::DirectiveResolvers::WithCountDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ def self.directive_name = 'withCount'
17
+
18
+ def self.definition
19
+ <<~GRAPHQL.chomp
20
+ """
21
+ Count a relation of the parent before the field resolves, for every parent at once,
22
+ and keep the count on it as `<relation>_count`.
23
+ """
24
+ directive @withCount(
25
+ "The relation to count."
26
+ relation: String!
27
+ "Model scopes that narrow the counted records."
28
+ scopes: [String!]
29
+ ) repeatable on FIELD_DEFINITION
30
+ GRAPHQL
31
+ end
32
+
33
+ def define_resolver(target_type_class)
34
+ inner = capture_inner(target_type_class)
35
+ relation = directive_arg('relation')
36
+ scopes = Array(directive_arg('scopes'))
37
+
38
+ install(target_type_class, lambda do |obj, args, ctx|
39
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::RelationCountsLoader, relation, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
40
+ inner.call(obj, args, ctx)
41
+ end)
42
+ end
43
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @with(relation: "tasks", scopes: [...]) — eager-load a relation of the parent
6
+ # before the field resolves, as PHP Lighthouse's @with does: for a field computed
7
+ # from it (a @method, a type class method, the default reader of a method that
8
+ # reads it), so it is loaded once for every parent in the response, not once per
9
+ # parent. `relation` defaults to the field's (snake_case) name, and may be nested
10
+ # ("tasks.assignee"); `scopes` narrow the loaded records.
11
+ #
12
+ # type User {
13
+ # taskSummary: String! @with(relation: "tasks") @method(name: "task_summary")
14
+ # }
15
+ class Lighthouse::GraphQL::DirectiveResolvers::WithDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
16
+ def self.directive_name = 'with'
17
+
18
+ def self.definition
19
+ <<~GRAPHQL.chomp
20
+ """
21
+ Eager-load a relation of the parent before the field resolves, for every parent at once.
22
+ """
23
+ directive @with(
24
+ "The relation to load, nested with dots. Defaults to the field's name."
25
+ relation: String
26
+ "Model scopes that narrow the loaded records."
27
+ scopes: [String!]
28
+ ) repeatable on FIELD_DEFINITION
29
+ GRAPHQL
30
+ end
31
+
32
+ def define_resolver(target_type_class)
33
+ inner = capture_inner(target_type_class)
34
+ associations = Lighthouse::Support::Preload.associations(directive_arg('relation') || Lighthouse::Support::Naming.attribute(@field_def.name))
35
+ scopes = Array(directive_arg('scopes'))
36
+
37
+ install(target_type_class, lambda do |obj, args, ctx|
38
+ ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::PreloadLoader, associations, scopes).load(obj) if obj.is_a?(::ActiveRecord::Base)
39
+ inner.call(obj, args, ctx)
40
+ end)
41
+ end
42
+ end
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ module Lighthouse::GraphQL::Directives::Arguments
6
+ # @whereNull — `true` keeps the rows whose column is null, `false` those whose
7
+ # column is not. `posts(unpublished: Boolean @whereNull(key: "published_at"))`
8
+ class WhereNull < Base
9
+ def self.directive_name = 'whereNull'
10
+
11
+ def self.definition
12
+ <<~GRAPHQL.chomp
13
+ """
14
+ Filter the value is null: true keeps the rows where the column is null, false the others.
15
+ """
16
+ directive @whereNull(
17
+ "The database column. Defaults to the argument's name."
18
+ key: String
19
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
20
+ GRAPHQL
21
+ end
22
+
23
+ def handle_builder(relation, value)
24
+ return relation if value.nil?
25
+
26
+ value ? relation.where(column => nil) : relation.where.not(column => nil)
27
+ end
28
+ end
29
+
30
+ # @whereNotNull — the inverse: `true` keeps the rows whose column is not null.
31
+ class WhereNotNull < Base
32
+ def self.directive_name = 'whereNotNull'
33
+
34
+ def self.definition
35
+ <<~GRAPHQL.chomp
36
+ """
37
+ Filter the value is not null: true keeps the rows where the column is not null, false the others.
38
+ """
39
+ directive @whereNotNull(
40
+ "The database column. Defaults to the argument's name."
41
+ key: String
42
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
43
+ GRAPHQL
44
+ end
45
+
46
+ def handle_builder(relation, value)
47
+ return relation if value.nil?
48
+
49
+ value ? relation.where.not(column => nil) : relation.where(column => nil)
50
+ end
51
+ end
52
+
53
+ # @whereBetween — the column lies between two values: an input object's two
54
+ # fields, in order, or a list of two. `createdAt: DateRange @whereBetween(key: "created_at")`
55
+ class WhereBetween < Base
56
+ def self.directive_name = 'whereBetween'
57
+
58
+ def self.definition
59
+ <<~GRAPHQL.chomp
60
+ """
61
+ Verify that a column's value is between two values: the argument's two input fields, in order.
62
+ """
63
+ directive @whereBetween(
64
+ "The database column. Defaults to the argument's name."
65
+ key: String
66
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
67
+ GRAPHQL
68
+ end
69
+
70
+ def handle_builder(relation, value)
71
+ from, to = bounds(value)
72
+ return relation if from.nil? && to.nil?
73
+
74
+ relation.where(column => from..to)
75
+ end
76
+
77
+ private
78
+
79
+ def bounds(value)
80
+ values = value.respond_to?(:to_h) && !value.is_a?(Array) ? value.to_h.values : Array(value)
81
+ values.first(2)
82
+ end
83
+ end
84
+
85
+ # @whereNotBetween — the column lies outside two values.
86
+ class WhereNotBetween < WhereBetween
87
+ def self.directive_name = 'whereNotBetween'
88
+
89
+ def self.definition
90
+ <<~GRAPHQL.chomp
91
+ """
92
+ Verify that a column's value lies outside two values: the argument's two input fields, in order.
93
+ """
94
+ directive @whereNotBetween(
95
+ "The database column. Defaults to the argument's name."
96
+ key: String
97
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
98
+ GRAPHQL
99
+ end
100
+
101
+ def handle_builder(relation, value)
102
+ from, to = bounds(value)
103
+ return relation if from.nil? && to.nil?
104
+
105
+ relation.where.not(column => from..to)
106
+ end
107
+ end
108
+
109
+ # @whereJsonContains — a JSON column contains the value (Laravel's
110
+ # whereJsonContains), on PostgreSQL (@>), MySQL (JSON_CONTAINS) and SQLite
111
+ # (json_each). `key:` may point into the document: "options->tags".
112
+ class WhereJsonContains < Base
113
+ def self.directive_name = 'whereJsonContains'
114
+
115
+ def self.definition
116
+ <<~GRAPHQL.chomp
117
+ """
118
+ Use an input value as a whereJsonContains filter.
119
+ """
120
+ directive @whereJsonContains(
121
+ "The JSON column, optionally with a path into it: \\"options->tags\\". Defaults to the argument's name."
122
+ key: String
123
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
124
+ GRAPHQL
125
+ end
126
+
127
+ def handle_builder(relation, value)
128
+ return relation if value.nil?
129
+
130
+ connection = relation.connection
131
+ name, *path = column.to_s.split('->')
132
+ target = "#{connection.quote_table_name(relation.klass.table_name)}.#{connection.quote_column_name(name)}"
133
+ relation.where(*condition(connection.adapter_name, target, path, value))
134
+ end
135
+
136
+ private
137
+
138
+ def condition(adapter, target, path, value)
139
+ json_path = "$#{path.map { |segment| ".#{segment}" }.join}"
140
+ case adapter
141
+ when /postg/i then ["(#{target}#{path.map { |segment| "->'#{segment}'" }.join})::jsonb @> ?::jsonb", value.to_json]
142
+ when /mysql|trilogy/i then ["JSON_CONTAINS(#{target}, ?, ?)", value.to_json, json_path]
143
+ else sqlite(target, json_path, value)
144
+ end
145
+ end
146
+
147
+ # Every value of a list must be in the array, as JSON containment means.
148
+ def sqlite(target, json_path, value)
149
+ values = Array(value)
150
+ sql = values.map { "EXISTS (SELECT 1 FROM json_each(#{target}, ?) WHERE json_each.value = ?)" }.join(' AND ')
151
+ [sql, *values.flat_map { |item| [json_path, item] }]
152
+ end
153
+ end
154
+
155
+ # @limit — the argument directive behind LimitResolver. Only `builder: true`
156
+ # makes it one: a LIMIT on the field's query. Without it, LimitResolver cuts
157
+ # the results instead.
158
+ class Limit < Base
159
+ def self.directive_name = 'limit'
160
+
161
+ def self.definition
162
+ <<~GRAPHQL.chomp
163
+ """
164
+ Allow clients to specify the maximum number of results to return when used on an argument,
165
+ or statically limit them when used on a field.
166
+
167
+ By default, this directive does not influence the number of results the resolver queries internally,
168
+ but limits how much of it is returned to clients. Use the `builder` argument to change this.
169
+ """
170
+ directive @limit(
171
+ """
172
+ You may set this to `true` if the field uses a query builder,
173
+ then this directive will apply a LIMIT clause to it.
174
+ Typically, this option should only be used for root fields,
175
+ as it may cause wrong results with batched relation queries.
176
+ """
177
+ builder: Boolean! = false
178
+ ) on ARGUMENT_DEFINITION | FIELD_DEFINITION
179
+ GRAPHQL
180
+ end
181
+
182
+ def handle_builder(relation, value)
183
+ return relation unless value.is_a?(Integer) && Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'builder')
184
+
185
+ relation.limit(value)
186
+ end
187
+ end
188
+
189
+ # @scope — call a model scope with the client's value.
190
+ # `posts(trending: Boolean @scope)` calls Post.trending(value); `name:` names another scope.
191
+ class Scope < Base
192
+ def self.directive_name = 'scope'
193
+
194
+ def self.definition
195
+ <<~GRAPHQL.chomp
196
+ """
197
+ Adds a scope to the query builder.
198
+ The scope method will receive the client-given value of the argument as its argument.
199
+ """
200
+ directive @scope(
201
+ "The name of the scope. Defaults to the name of the argument."
202
+ name: String
203
+ ) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION
204
+ GRAPHQL
205
+ end
206
+
207
+ def handle_builder(relation, value)
208
+ return relation if value.nil?
209
+
210
+ relation.public_send(
211
+ Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'name') || Lighthouse::Support::Naming.attribute(@arg_node.name), value
212
+ )
213
+ end
214
+ end
215
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The SDL of the directives no registered resolver class contributes, and of
4
+ # the types they refer to: the field-level query directives (@builder,
5
+ # @whereAuth), the argument directives, the type-system ones (@union, @node,
6
+ # @model, ...), and those the schema reads while it is built. The registry
7
+ # injects each into a schema that uses it without declaring it.
8
+ module Lighthouse::GraphQL::Directives::Definitions
9
+ # Directives that only mark the SDL for others to read.
10
+ PASSIVE = {
11
+ 'nest' => 'directive @nest on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION',
12
+ 'spread' => 'directive @spread on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION',
13
+ 'inject' => 'directive @inject(context: String!, name: String!) repeatable on FIELD_DEFINITION',
14
+ 'cacheKey' => 'directive @cacheKey on FIELD_DEFINITION'
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ # { name => "directive @name ..." }
20
+ def directives
21
+ graphql = Lighthouse::GraphQL
22
+ [
23
+ graphql::DirectiveResolvers::BuilderApplier::DEFINITIONS,
24
+ graphql::DirectiveResolvers::ArgumentTransformsResolver::DEFINITIONS,
25
+ PASSIVE,
26
+ { 'cacheControl' => graphql::CacheControl::DEFINITION, 'feature' => graphql::FeatureDirective::DEFINITION,
27
+ 'complexity' => graphql::Complexity::DEFINITION, 'scalar' => graphql::ScalarCoercion::DEFINITION,
28
+ 'enum' => graphql::EnumDirective::DEFINITION },
29
+ through_aliases,
30
+ graphql::TypeResolution::DEFINITIONS,
31
+ graphql::Directives::NodeDirective::DEFINITIONS,
32
+ graphql::Directives::ModelDirective::DEFINITIONS,
33
+ graphql::Directives::VisibilityDirective::DEFINITIONS,
34
+ graphql::Directives::WhereConditionsDirective::DEFINITIONS,
35
+ graphql::Directives::SoftDeletesDirective::DEFINITIONS
36
+ ].reduce(:merge)
37
+ end
38
+
39
+ # @hasManyThrough and @hasOneThrough: @hasMany's and @hasOne's definitions, renamed.
40
+ def through_aliases
41
+ resolvers = Lighthouse::GraphQL::DirectiveResolvers
42
+ { 'hasManyThrough' => resolvers::HasManyDirectiveResolver, 'hasOneThrough' => resolvers::HasOneDirectiveResolver }
43
+ .to_h { |name, klass| [name, klass.definition.sub("@#{klass.directive_name}(", "@#{name}(")] }
44
+ end
45
+
46
+ # { type name => its SDL }, for the types the directives above refer to.
47
+ def types
48
+ graphql = Lighthouse::GraphQL
49
+ [graphql::CacheControl::TYPE_DEFINITIONS, graphql::FeatureDirective::TYPE_DEFINITIONS, graphql::EnumDirective::TYPE_DEFINITIONS,
50
+ graphql::DirectiveResolvers::BuilderApplier::TYPE_DEFINITIONS].reduce(:merge)
51
+ end
52
+ end