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
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # What @whereConditions and @whereHasConditions share, as PHP Lighthouse's
6
+ # WhereConditionsBaseDirective: an argument written with the `_` placeholder
7
+ # gets the input types the directive generates (Lighthouse::WhereConditions::Types),
8
+ # and the client's value becomes WHERE conditions on the field's query
9
+ # (Lighthouse::WhereConditions::Handler).
10
+ #
11
+ # type Query {
12
+ # people(where: _ @whereConditions(columns: ["age", "type"])): [Person!]! @all
13
+ # users(hasPosts: _ @whereHasConditions(columns: ["title"])): [User!]! @all
14
+ # }
15
+ #
16
+ # `columns:` restricts the columns to an enum generated from them,
17
+ # `columnsEnum:` to an enum of the schema's; without either the column is any
18
+ # identifier the client sends.
19
+ class Lighthouse::GraphQL::Directives::Arguments::WhereConditionsBase < Lighthouse::GraphQL::Directives::Arguments::Base
20
+ include Lighthouse::Contracts::ArgManipulator
21
+
22
+ GeneratedTypes = Lighthouse::GraphQL::Directives::GeneratedTypes
23
+
24
+ COLUMN_ARGUMENTS = <<~GRAPHQL.chomp
25
+ """
26
+ Restrict the allowed column names to a well-defined list.
27
+ This improves introspection capabilities and security.
28
+ Mutually exclusive with `columnsEnum`.
29
+ """
30
+ columns: [String!]
31
+
32
+ """
33
+ Use an existing enumeration type to restrict the allowed columns to a predefined list.
34
+ This allows you to re-use the same enum for multiple fields.
35
+ Mutually exclusive with `columns`.
36
+ """
37
+ columnsEnum: String
38
+
39
+ """
40
+ A class that applies the client given conditions to the query: `call(relation, conditions)`
41
+ answers the relation. "Class@method" names another method. Looked up in the builders namespaces.
42
+ """
43
+ handler: String
44
+ GRAPHQL
45
+
46
+ # The argument, typed with the conditions input generated for it.
47
+ def manipulate_arg_definition(document_ast, field, type)
48
+ path = "the `#{@arg_node.name}` argument of #{type.name}.#{field.name}"
49
+ arg = GeneratedTypes.describe(@arg_node, self.class::DESCRIPTION)
50
+ types = Lighthouse::WhereConditions::Types
51
+ unless GeneratedTypes.allowed_columns?(@directive_node)
52
+ types.define_default(document_ast)
53
+ return GeneratedTypes.replace_placeholder(arg, types::DEFAULT)
54
+ end
55
+
56
+ name = GeneratedTypes.qualified_arg_type(@arg_node, field, type) + self.class::SUFFIX
57
+ types.define(document_ast, name, GeneratedTypes.columns_enum(document_ast, @directive_node, @arg_node, field, type), path)
58
+ GeneratedTypes.replace_placeholder(arg, name)
59
+ end
60
+
61
+ def handle_builder(relation, value)
62
+ return relation if value.nil?
63
+
64
+ handler.call(relation, conditions(value)) || relation
65
+ end
66
+
67
+ private
68
+
69
+ def conditions(value) = value
70
+
71
+ # Lighthouse::WhereConditions::Handler, or the class (or instance) `handler:` names.
72
+ def handler
73
+ reference = Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'handler')
74
+ return Lighthouse::WhereConditions::Handler unless reference
75
+
76
+ class_name, method_name = reference.to_s.split('@', 2)
77
+ klass = Lighthouse::Support::ModelResolver.constantize(class_name, namespaces: Lighthouse::Config.namespaces[:builders])
78
+ raise Lighthouse::DefinitionError, "@#{@directive_node.name}(handler: \"#{reference}\"): no class #{class_name}" unless klass
79
+
80
+ method_name = method_name.presence || 'call'
81
+ receiver = klass.respond_to?(method_name) ? klass : klass.new
82
+ receiver.method(method_name)
83
+ end
84
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'where_conditions_base'
4
+
5
+ # @whereHasConditions: the records with a related record matching the
6
+ # client's conditions.
7
+ #
8
+ # users(hasPosts: _ @whereHasConditions(columns: ["title"])): [User!]! @all
9
+ # { users(hasPosts: { column: TITLE, value: "Notes" }) { name } }
10
+ class Lighthouse::GraphQL::Directives::Arguments::WhereHasConditions < Lighthouse::GraphQL::Directives::Arguments::WhereConditionsBase
11
+ SUFFIX = 'WhereHasConditions'
12
+ DESCRIPTION = 'Filter results by related records conditions.'
13
+
14
+ DEFINITION = <<~GRAPHQL.chomp
15
+ """
16
+ Allows clients to filter a query based on the existence of a related model, using
17
+ a dynamically controlled `WHERE` condition that applies to the relationship.
18
+ """
19
+ directive @whereHasConditions(
20
+ """
21
+ The relationship that the conditions will be applied to.
22
+ This argument can be omitted if the argument name follows the naming
23
+ convention `has{$RELATION}`. For example, if the relationship
24
+ is named `posts`, the argument name must be `hasPosts`.
25
+ """
26
+ relation: String
27
+
28
+ #{COLUMN_ARGUMENTS.gsub(/^/, ' ')}
29
+ ) on ARGUMENT_DEFINITION
30
+ GRAPHQL
31
+
32
+ def self.directive_name = 'whereHasConditions'
33
+
34
+ def self.definition = DEFINITION
35
+
36
+ private
37
+
38
+ # The records with at least one related record matching the value (`{}`: any related record).
39
+ def conditions(value)
40
+ condition = value.respond_to?(:to_h) ? value.to_h : value
41
+ {
42
+ has: {
43
+ relation: relation_name,
44
+ operator: Lighthouse::WhereConditions::Types::DEFAULT_HAS_OPERATOR,
45
+ amount: Lighthouse::WhereConditions::Types::DEFAULT_HAS_AMOUNT,
46
+ condition: condition.presence
47
+ }
48
+ }
49
+ end
50
+
51
+ # `relation:`, else the argument name without its `has` prefix: hasPosts -> posts.
52
+ def relation_name
53
+ Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'relation') ||
54
+ @arg_node.name.sub(/\Ahas(?=[A-Z_])/, '').then { |name| name[0].downcase + name[1..] }
55
+ end
56
+ end
@@ -31,7 +31,6 @@ module Lighthouse::GraphQL::Directives::Definitions
31
31
  graphql::Directives::NodeDirective::DEFINITIONS,
32
32
  graphql::Directives::ModelDirective::DEFINITIONS,
33
33
  graphql::Directives::VisibilityDirective::DEFINITIONS,
34
- graphql::Directives::WhereConditionsDirective::DEFINITIONS,
35
34
  graphql::Directives::SoftDeletesDirective::DEFINITIONS
36
35
  ].reduce(:merge)
37
36
  end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ # What the argument manipulators that generate types share, as PHP
4
+ # Lighthouse's ASTHelper and GeneratesColumnsEnum: the qualified name of an
5
+ # argument's types, the enum of its allowed columns, and the `_` placeholder
6
+ # the argument is written with.
7
+ #
8
+ # type Query { posts(orderBy: _ @orderBy(columns: ["posted_at"])): [Post!]! @all }
9
+ #
10
+ # names Query.posts(orderBy:)'s types QueryPostsOrderBy..., its columns enum
11
+ # QueryPostsOrderByColumn { POSTED_AT @enum(value: "posted_at") }.
12
+ module Lighthouse::GraphQL::Directives::GeneratedTypes
13
+ PLACEHOLDER = '_'
14
+
15
+ module_function
16
+
17
+ # Studly(Type) + Studly(field) + Studly(argument): QueryPostsOrderBy.
18
+ def qualified_arg_type(arg, field, type)
19
+ [type.name, field.name, arg.name].map { |name| studly(name) }.join
20
+ end
21
+
22
+ def studly(name)
23
+ name.to_s.split(/[_\s-]+/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join
24
+ end
25
+
26
+ # `argument` of the directive as a Ruby value (`columns: ["a"]` -> ["a"]), or nil.
27
+ def directive_value(directive, name)
28
+ Lighthouse::Support::DirectiveArgs.literal(directive, name)
29
+ end
30
+
31
+ def directive_argument?(directive, name)
32
+ directive.arguments.any? { |argument| argument.name == name }
33
+ end
34
+
35
+ # Raises unless at most one of `names` is given to the directive.
36
+ def mutually_exclusive!(directive, names)
37
+ given = names.select { |name| directive_argument?(directive, name) }
38
+ return if given.size <= 1
39
+
40
+ raise Lighthouse::DefinitionError,
41
+ "The @#{directive.name} directive can only have one of the following arguments: #{names.map { |n| "`#{n}`" }.join(', ')}."
42
+ end
43
+
44
+ # Does the directive restrict the columns (`columns:` or `columnsEnum:`)?
45
+ def allowed_columns?(directive)
46
+ mutually_exclusive!(directive, %w[columns columnsEnum])
47
+ directive_argument?(directive, 'columns') || directive_argument?(directive, 'columnsEnum')
48
+ end
49
+
50
+ # The enum of the allowed columns: `columnsEnum:`, or one generated from
51
+ # `columns:` as <QualifiedArgType>Column.
52
+ def columns_enum(document_ast, directive, arg, field, type)
53
+ enum = directive_value(directive, 'columnsEnum')
54
+ return enum if enum
55
+
56
+ name = "#{qualified_arg_type(arg, field, type)}Column"
57
+ define_columns_enum(document_ast, name, directive_value(directive, 'columns'), "#{type.name}.#{field.name}.#{arg.name}")
58
+ name
59
+ end
60
+
61
+ def define_columns_enum(document_ast, name, columns, path)
62
+ values = Array(columns).map { |column| %( #{enum_value_name(column)} @enum(value: "#{column}")) }.uniq
63
+ document_ast.put_type(parse(<<~GRAPHQL))
64
+ "Allowed column names for #{path}."
65
+ enum #{name} {
66
+ #{values.join("\n")}
67
+ }
68
+ GRAPHQL
69
+ end
70
+
71
+ # `posted_at` -> POSTED_AT, `roles.id` -> ROLES_ID.
72
+ def enum_value_name(column)
73
+ name = column.to_s.gsub(/[^0-9A-Za-z_]/, '_').upcase
74
+ name.match?(/\A[0-9]/) ? "_#{name}" : name
75
+ end
76
+
77
+ # The argument with its type replaced by `type_string` ("[QueryPostsOrderByClause!]"),
78
+ # when it is written with the `_` placeholder. An argument given a type of
79
+ # its own keeps it.
80
+ def replace_placeholder(arg, type_string)
81
+ return arg unless placeholder?(arg.type)
82
+
83
+ arg.merge(type: parse_type(type_string))
84
+ end
85
+
86
+ def placeholder?(type)
87
+ type = type.of_type while type.respond_to?(:of_type)
88
+ type.name == PLACEHOLDER
89
+ end
90
+
91
+ # The argument with `description`, unless it has one.
92
+ def describe(arg, description)
93
+ arg.description.to_s.empty? ? arg.merge(description: description) : arg
94
+ end
95
+
96
+ def parse(sdl)
97
+ ::GraphQL::Language::Parser.parse(sdl).definitions.first
98
+ end
99
+
100
+ def parse_type(type_string)
101
+ parse("input LighthouseTypeReference { field: #{type_string} }").fields.first.type
102
+ end
103
+ end
@@ -13,7 +13,9 @@
13
13
  #
14
14
  # A registered class is classified as a field resolver when it includes
15
15
  # Contracts::FieldResolver OR responds to the legacy `#define_resolver`
16
- # instance method.
16
+ # instance method. A class including Contracts::ArgManipulator also rewrites
17
+ # its arguments while the schema is built (ASTBuilder), and one including
18
+ # Contracts::FieldBuilder constrains its fields' queries.
17
19
  class Lighthouse::DirectiveRegistry
18
20
  class << self
19
21
  def default
@@ -29,6 +31,8 @@ class Lighthouse::DirectiveRegistry
29
31
  def initialize
30
32
  @resolution = {}
31
33
  @arg_builders = {}
34
+ @arg_manipulators = {}
35
+ @field_builders = {}
32
36
  @sdl_manipulators = []
33
37
  @definitions = {}
34
38
  @type_definitions = {}
@@ -42,11 +46,7 @@ class Lighthouse::DirectiveRegistry
42
46
  name = name_or_klass
43
47
  end
44
48
 
45
- if arg_builder?(klass)
46
- @arg_builders[name.to_s] = klass
47
- elsif resolver?(klass)
48
- @resolution[name.to_s] = klass
49
- end
49
+ classify(name.to_s, klass)
50
50
  self
51
51
  end
52
52
 
@@ -97,6 +97,21 @@ class Lighthouse::DirectiveRegistry
97
97
  @arg_builders.keys
98
98
  end
99
99
 
100
+ # Argument directives that rewrite their argument while the schema is built
101
+ # (@whereConditions, @whereHasConditions, @orderBy), as ASTBuilder applies them.
102
+ def arg_manipulator_class(name)
103
+ @arg_manipulators[name.to_s]
104
+ end
105
+
106
+ def arg_manipulator_classes
107
+ @arg_manipulators.values
108
+ end
109
+
110
+ # Field directives that constrain the field's query (@orderBy on a field).
111
+ def field_builder_class(name)
112
+ @field_builders[name.to_s]
113
+ end
114
+
100
115
  # SDL `directive @name(...) on ...` definitions contributed by registered,
101
116
  # self-describing directive classes. The generator injects these so the app
102
117
  # does not have to hand-declare every directive it uses.
@@ -193,7 +208,7 @@ class Lighthouse::DirectiveRegistry
193
208
 
194
209
  # Argument-builder filter/sort directives.
195
210
  ARG_BUILDERS = %i[
196
- Eq Neq In NotIn Like Where WhereKey OrderBy Trashed WithoutGlobalScopes
211
+ Eq Neq In NotIn Like Where WhereKey OrderBy WhereConditions WhereHasConditions Trashed WithoutGlobalScopes
197
212
  WhereNull WhereNotNull WhereBetween WhereNotBetween WhereJsonContains Scope Limit
198
213
  ].freeze
199
214
 
@@ -205,11 +220,11 @@ class Lighthouse::DirectiveRegistry
205
220
 
206
221
  # SDL manipulators run in registration order during the manipulation phase.
207
222
  # @hide / @show first, so a removed field generates nothing; then @model,
208
- # which names the model of fields @paginate then rewrites.
223
+ # which names the model of fields @paginate then rewrites. The argument
224
+ # manipulators (@whereConditions, @orderBy, ...) run after all of them.
209
225
  register_manipulator(Lighthouse::GraphQL::Directives::VisibilityDirective)
210
226
  register_manipulator(Lighthouse::GraphQL::Directives::ModelDirective)
211
227
  register_manipulator(Lighthouse::GraphQL::Directives::PaginationManipulator)
212
- register_manipulator(Lighthouse::GraphQL::Directives::WhereConditionsDirective)
213
228
  register_manipulator(Lighthouse::GraphQL::Directives::SoftDeletesDirective)
214
229
  register_manipulator(Lighthouse::GraphQL::Directives::NodeDirective)
215
230
 
@@ -232,8 +247,22 @@ class Lighthouse::DirectiveRegistry
232
247
  nil
233
248
  end
234
249
 
250
+ def classify(name, klass)
251
+ if arg_builder?(klass)
252
+ @arg_builders[name] = klass
253
+ elsif resolver?(klass)
254
+ @resolution[name] = klass
255
+ end
256
+ @arg_manipulators[name] = klass if includes?(klass, Lighthouse::Contracts::ArgManipulator)
257
+ @field_builders[name] = klass if includes?(klass, Lighthouse::Contracts::FieldBuilder)
258
+ end
259
+
235
260
  def arg_builder?(klass)
236
- klass.respond_to?(:include?) && klass.include?(Lighthouse::Contracts::ArgBuilder)
261
+ includes?(klass, Lighthouse::Contracts::ArgBuilder)
262
+ end
263
+
264
+ def includes?(klass, contract)
265
+ klass.respond_to?(:include?) && klass.include?(contract)
237
266
  end
238
267
 
239
268
  def resolver?(klass)
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The schema document as the manipulation phase works on it, as PHP
4
+ # Lighthouse's DocumentAST: the type definitions by name, the type extensions
5
+ # by the name they extend, the directive definitions and the schema
6
+ # definition/extensions.
7
+ #
8
+ # A type is defined once. A second definition of the same name is an error,
9
+ # not a merge: add to a type from another file with `extend type` (or
10
+ # `extend input`, `extend enum`, ...), which ASTBuilder merges into it.
11
+ #
12
+ # graphql-ruby's nodes are immutable, so a manipulation replaces a type with
13
+ # `put_type(type.merge(...))` rather than editing it.
14
+ class Lighthouse::GraphQL::DocumentAST
15
+ Nodes = ::GraphQL::Language::Nodes
16
+
17
+ # The SDL keyword each kind of definition is extended with.
18
+ KEYWORDS = {
19
+ Nodes::ObjectTypeDefinition => 'type',
20
+ Nodes::InterfaceTypeDefinition => 'interface',
21
+ Nodes::InputObjectTypeDefinition => 'input',
22
+ Nodes::EnumTypeDefinition => 'enum',
23
+ Nodes::UnionTypeDefinition => 'union',
24
+ Nodes::ScalarTypeDefinition => 'scalar'
25
+ }.freeze
26
+
27
+ EXTENSIONS = [
28
+ Nodes::ObjectTypeExtension, Nodes::InterfaceTypeExtension, Nodes::InputObjectTypeExtension,
29
+ Nodes::EnumTypeExtension, Nodes::UnionTypeExtension, Nodes::ScalarTypeExtension
30
+ ].freeze
31
+
32
+ attr_reader :types, :type_extensions, :directives, :schema_definitions
33
+
34
+ def self.from_source(sdl)
35
+ document = begin
36
+ ::GraphQL::Language::Parser.parse(sdl.to_s)
37
+ rescue ::GraphQL::ParseError => e
38
+ raise Lighthouse::DefinitionError, "The schema is not valid GraphQL: #{e.message}"
39
+ end
40
+ from_document(document)
41
+ end
42
+
43
+ def self.from_document(document)
44
+ new.tap { |ast| document.definitions.each { |definition| ast.add(definition) } }
45
+ end
46
+
47
+ def initialize
48
+ @types = {}
49
+ @type_extensions = Hash.new { |hash, name| hash[name] = [] }
50
+ @directives = {}
51
+ @schema_definitions = []
52
+ end
53
+
54
+ def add(definition)
55
+ case definition
56
+ when *KEYWORDS.keys then add_type(definition)
57
+ when *EXTENSIONS then @type_extensions[definition.name] << definition
58
+ when Nodes::DirectiveDefinition then add_directive(definition)
59
+ when Nodes::SchemaDefinition, Nodes::SchemaExtension then @schema_definitions << definition
60
+ else raise Lighthouse::DefinitionError, "Unknown definition type: #{definition.class}"
61
+ end
62
+ end
63
+
64
+ # Adds a type, or replaces the definition of the same name.
65
+ def put_type(definition)
66
+ @types[definition.name] = definition
67
+ self
68
+ end
69
+
70
+ # Adds the type parsed from `sdl`, unless the document has a type of that name.
71
+ def define_type(name, sdl = nil)
72
+ return @types[name] if @types.key?(name)
73
+
74
+ definition = ::GraphQL::Language::Parser.parse(sdl || yield).definitions.first
75
+ put_type(definition)
76
+ definition
77
+ end
78
+
79
+ def type?(name) = @types.key?(name)
80
+
81
+ # The names of the types, as they are now: manipulators add types while
82
+ # ASTBuilder goes over these.
83
+ def type_names = @types.keys
84
+
85
+ def to_document
86
+ Nodes::Document.new(definitions: @schema_definitions + @directives.values + @types.values + @type_extensions.values.flatten)
87
+ end
88
+
89
+ def to_sdl
90
+ ::GraphQL::Language::Printer.new.print(to_document)
91
+ end
92
+
93
+ private
94
+
95
+ def add_type(definition)
96
+ if @types.key?(definition.name)
97
+ keyword = KEYWORDS.fetch(definition.class)
98
+ raise Lighthouse::DefinitionError,
99
+ "Duplicate definition #{definition.name} found when parsing the schema. Define a type once and add to it " \
100
+ "from other files with `extend #{keyword} #{definition.name}`."
101
+ end
102
+
103
+ @types[definition.name] = definition
104
+ end
105
+
106
+ def add_directive(definition)
107
+ raise Lighthouse::DefinitionError, "Duplicate definition @#{definition.name} found when parsing the schema." if @directives.key?(definition.name)
108
+
109
+ @directives[definition.name] = definition
110
+ end
111
+ end
@@ -55,20 +55,23 @@ module Lighthouse::GraphQL::FieldResolverStack
55
55
  [0, -> { resolver.define_resolver(type) }]
56
56
  end
57
57
 
58
- # What resolves a field with no resolving directive: the conventional class
59
- # of a root field, or the method of the type's class. nil: the default reader.
58
+ # What resolves a field with no resolving directive: the method of the
59
+ # type's class (Types::QueryType#latest_posts too, as in graphql-ruby), else
60
+ # for a Query or Mutation field the conventional class. nil: the default reader.
60
61
  def own_step(type, field_def, root_kind, bases, type_class)
61
- return convention_step(type, field_def, root_kind, bases) if root_kind
62
+ return convention_step(type, field_def, root_kind, bases) if root_kind == :subscriptions
62
63
 
63
- type_method_step(type, field_def, type_class) if type_class
64
+ step = type_method_step(type, field_def, type_class, root: !root_kind.nil?) if type_class
65
+ step || (convention_step(type, field_def, root_kind, bases) if root_kind)
64
66
  end
65
67
 
66
68
  # Nothing to install when the class does not define the field's method: the
67
69
  # field is read off the object.
68
- def type_method_step(type, field_def, type_class)
70
+ # `root`: the class is the root type's, which the type is built on, not one found by name.
71
+ def type_method_step(type, field_def, type_class, root: false)
69
72
  resolver = Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver
70
73
  method_name = resolver.method_for(type_class, field_def)
71
- [0, -> { resolver.new(field_def, type, method_name).define_resolver(type) }] if method_name
74
+ [0, -> { resolver.new(field_def, type, method_name, root ? type_class : nil).define_resolver(type) }] if method_name
72
75
  end
73
76
 
74
77
  # An explicit @validator on the field already validates its arguments.
@@ -7,6 +7,12 @@ require 'base64'
7
7
  # Config.global_id takes another object with `encode(type, id)` and
8
8
  # `decode(global_id)` -> [type, id], as PHP Lighthouse lets the GlobalId
9
9
  # contract be rebound.
10
+ #
11
+ # A schema that implements graphql-ruby's Relay ids, `id_from_object` and
12
+ # `object_from_id` (graphql-ruby's install writes them, on Rails' GlobalID),
13
+ # has one kind of global id: @globalId encodes an object's own id with
14
+ # `id_from_object`, and decodes with `object_from_id`, as graphql-ruby's
15
+ # Query.node and Node's `id` do.
10
16
  module Lighthouse::GraphQL::GlobalId
11
17
  module_function
12
18
 
@@ -20,12 +26,32 @@ module Lighthouse::GraphQL::GlobalId
20
26
  []
21
27
  end
22
28
 
23
- # [type, id] for a global id a client sent, through Config.global_id; a
29
+ # [type, id] for a global id a client sent, through the schema's
30
+ # object_from_id (schema_ids?) or Config.global_id; a
24
31
  # GraphQL::ExecutionError when it is not one.
25
- def decode!(global_id)
26
- type, id = Lighthouse::Config.global_id.decode(global_id)
32
+ def decode!(global_id, context = nil)
33
+ type, id = schema_ids?(context&.schema) ? decode_object(global_id, context) : Lighthouse::Config.global_id.decode(global_id)
27
34
  return [type, id] if type.present? && !id.nil?
28
35
 
29
36
  raise ::GraphQL::ExecutionError, "#{global_id.inspect} is not a valid global id."
30
37
  end
38
+
39
+ # The global id of `object`'s own id (`id`, what a field of `type` resolved)
40
+ # through the schema's id_from_object (schema_ids?); nil for anything else.
41
+ def encode_field(type, object, id, context)
42
+ return unless schema_ids?(context&.schema) && object.respond_to?(:id) && object.id.to_s == id.to_s
43
+
44
+ context.schema.id_from_object(object, type, context)
45
+ end
46
+
47
+ # Does `schema` define graphql-ruby's object_from_id (GraphQL::Schema's own raises)?
48
+ def schema_ids?(schema)
49
+ schema.respond_to?(:object_from_id) && schema.method(:object_from_id).owner != ::GraphQL::Schema.singleton_class
50
+ end
51
+
52
+ # [type, id] of the object the schema's object_from_id finds.
53
+ def decode_object(global_id, context)
54
+ object = context.schema.object_from_id(global_id, context)
55
+ object.nil? ? [] : [object.class.name.demodulize, object.id]
56
+ end
31
57
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lighthouse
4
4
  module GraphQL
5
- VERSION = '0.5.0'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
@@ -15,4 +15,49 @@ module Lighthouse::GraphQL
15
15
  # skip the fix rather than fail to load (explicit nulls then fail as before).
16
16
  builder = ::GraphQL::Schema::BuildFromDefinition.const_get(:Builder) if ::GraphQL::Schema::BuildFromDefinition.const_defined?(:Builder)
17
17
  builder.prepend(NullDirectiveArguments) if builder && (builder.method_defined?(:args_to_kwargs) || builder.private_method_defined?(:args_to_kwargs))
18
+
19
+ # graphql-ruby builds every SDL object type on base_types[:object], and every
20
+ # interface from base_types[:interface]. RootTypes builds a root type on the
21
+ # app's code-first one (Types::QueryType) instead, and takes the app's Node
22
+ # interface (Types::NodeType) as the SDL's Node.
23
+ module RootTypeBuilder
24
+ def build_object_type(definition, type_resolver, base_type)
25
+ root = Lighthouse::GraphQL::RootTypes.base_for(definition.name)
26
+ return super unless root
27
+
28
+ super(Lighthouse::GraphQL::RootTypes.definition_on(definition, root), type_resolver, root)
29
+ end
30
+
31
+ def build_interface_type(definition, type_resolver, base_type)
32
+ Lighthouse::GraphQL::RootTypes.base_for(definition.name) || super
33
+ end
34
+ end
35
+
36
+ # As above, a graphql-ruby release that reshapes the builder skips the hook:
37
+ # root types are then built on base_types[:object], as before.
38
+ if builder && %i[build_object_type build_interface_type].all? { |name| builder.method_defined?(name) || builder.private_method_defined?(name) }
39
+ builder.prepend(RootTypeBuilder)
40
+ end
41
+
42
+ # The app's schema class inherits Lighthouse's (`class AppSchema <
43
+ # get_schema(...)`), so its Subscription root type is the parent's. A
44
+ # subscriptions backend the subclass `use`s (ActionCableSubscriptions) then
45
+ # finds no root type of the subclass's own, and graphql-ruby adds its
46
+ # subscription extension to no field. Look the root type up through the
47
+ # parent too. Extended onto the schemas Lighthouse builds (SchemaGenerator).
48
+ module InheritedSubscriptionRoot
49
+ def add_subscription_extension_if_necessary
50
+ return super if @subscription_object.is_a?(Class) || !defined?(::GraphQL::Subscriptions::DefaultSubscriptionResolveExtension)
51
+ return unless inherited_subscription_root_pending?
52
+
53
+ @subscription_extension_added = true
54
+ extension = ::GraphQL::Subscriptions::DefaultSubscriptionResolveExtension
55
+ subscription.all_field_definitions.each { |field| field.extension(extension) unless field.extensions.any?(extension) }
56
+ end
57
+
58
+ # A backend, a root type from the parent, and no extension added yet.
59
+ def inherited_subscription_root_pending?
60
+ !defined?(@subscription_extension_added) && subscription.is_a?(Class) && !subscriptions.nil?
61
+ end
62
+ end
18
63
  end