lighthouse-graphql 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -1
  3. data/README.md +15 -7
  4. data/docs/best-practices.md +6 -4
  5. data/docs/configuration.md +19 -5
  6. data/docs/custom-directives.md +2 -1
  7. data/docs/directives.md +65 -7
  8. data/docs/federation.md +1 -1
  9. data/docs/filtering-and-ordering.md +330 -30
  10. data/docs/generators.md +2 -2
  11. data/docs/getting-started.md +43 -10
  12. data/docs/motivation.md +3 -2
  13. data/docs/performance.md +4 -3
  14. data/docs/relationships.md +2 -1
  15. data/docs/relay.md +52 -3
  16. data/docs/resolvers.md +2 -2
  17. data/docs/schema-organisation.md +177 -0
  18. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +14 -0
  19. data/lib/generators/lighthouse/install/install_generator.rb +31 -12
  20. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +11 -4
  22. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +30 -0
  23. data/lib/lighthouse/ast_builder.rb +124 -0
  24. data/lib/lighthouse/config.rb +23 -4
  25. data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
  26. data/lib/lighthouse/contracts/field_builder.rb +13 -0
  27. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
  28. data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
  29. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
  30. data/lib/lighthouse/directive_resolvers/base.rb +6 -37
  31. data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
  32. data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
  33. data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
  34. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
  35. data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
  36. data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
  37. data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
  38. data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
  39. data/lib/lighthouse/directives/definitions.rb +0 -1
  40. data/lib/lighthouse/directives/generated_types.rb +103 -0
  41. data/lib/lighthouse/directives/registry.rb +39 -10
  42. data/lib/lighthouse/document_ast.rb +111 -0
  43. data/lib/lighthouse/graphql/version.rb +1 -1
  44. data/lib/lighthouse/ide_helper.rb +80 -23
  45. data/lib/lighthouse/namespaces.rb +1 -0
  46. data/lib/lighthouse/order_by/types.rb +137 -0
  47. data/lib/lighthouse/railtie.rb +9 -2
  48. data/lib/lighthouse/rb_lighthouse.rb +15 -5
  49. data/lib/lighthouse/resolve_info.rb +5 -5
  50. data/lib/lighthouse/schema_cache.rb +8 -4
  51. data/lib/lighthouse/schema_factory.rb +6 -9
  52. data/lib/lighthouse/schema_generator.rb +4 -15
  53. data/lib/lighthouse/schema_stitcher.rb +64 -0
  54. data/lib/lighthouse/support/naming.rb +0 -12
  55. data/lib/lighthouse/where_conditions/columns.rb +36 -0
  56. data/lib/lighthouse/where_conditions/handler.rb +152 -0
  57. data/lib/lighthouse/where_conditions/json_column.rb +99 -0
  58. data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
  59. data/lib/lighthouse/where_conditions/types.rb +110 -0
  60. data/sig/lighthouse/directives.rbs +16 -3
  61. data/sig/lighthouse/graphql.rbs +48 -4
  62. data/sig/lighthouse.rbs +8 -2
  63. metadata +15 -4
  64. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
  65. data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
  66. data/lib/lighthouse/sdl_loader.rb +0 -22
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lighthouse
4
4
  module GraphQL
5
- VERSION = '0.6.0'
5
+ VERSION = '0.7.1'
6
6
  end
7
7
  end
@@ -13,7 +13,13 @@ require 'fileutils'
13
13
  # ...), which exist in the executable schema but in no .graphql file.
14
14
  #
15
15
  # Point your IDE's graphql-config at both files next to your own SDL. Run it
16
- # with `bin/rails lighthouse:ide_helper`.
16
+ # with `bin/rails lighthouse:ide_helper`; in development, an app with a
17
+ # graphql.config.yml also gets them rewritten on boot and on every reload
18
+ # (Config.ide_helper).
19
+ #
20
+ # The IDE reads the three together as one schema, so no type may be in two of
21
+ # them: graphql-js (the VS Code and JetBrains plugins) rejects a type defined
22
+ # twice, even where graphql-ruby keeps the last definition.
17
23
  module Lighthouse::IdeHelper
18
24
  DIRECTIVES_FILE = 'schema-directives.graphql'
19
25
  PROGRAMMATIC_TYPES_FILE = 'programmatic-types.graphql'
@@ -27,23 +33,66 @@ module Lighthouse::IdeHelper
27
33
  ::GraphQL::Language::Nodes::ScalarTypeDefinition
28
34
  ].freeze
29
35
 
30
- PLACEHOLDER_SCALAR = %("Placeholder for an argument type Lighthouse generates, e.g. with @whereConditions."\nscalar _)
36
+ PLACEHOLDER_SCALAR = %("Placeholder for an argument type Lighthouse generates, e.g. with @whereConditions or @orderBy."\nscalar _)
31
37
 
32
38
  module_function
33
39
 
34
- # Writes both files into `output_dir` and returns their paths.
35
- def write(output_dir:, sdl_folder: Lighthouse::Config.sdl_folder, registry: Lighthouse::DirectiveRegistry.default)
36
- sdl = sdl_folder && Dir.exist?(sdl_folder.to_s) ? Lighthouse::GraphQL::SDLLoader.load_from_folder(sdl_folder.to_s) : ''
40
+ # Writes both files into `output_dir` and returns their paths. A file whose
41
+ # content has not changed is left alone, so the IDE does not reload for nothing.
42
+ #
43
+ # A schema that does not build (a type defined twice, half-written, ...)
44
+ # still gets its directives, and keeps the programmatic types it had, or
45
+ # gets the `_` placeholder alone: the IDE never loses a type it was given.
46
+ def write(output_dir:, schema_path: Lighthouse::Config.schema_path, registry: Lighthouse::DirectiveRegistry.default)
47
+ sdl = ide_sdl(schema_path)
48
+ directives = directives_sdl(sdl, registry: registry)
37
49
 
38
50
  FileUtils.mkdir_p(output_dir.to_s)
39
- {
40
- DIRECTIVES_FILE => directives_sdl(sdl, registry: registry),
41
- PROGRAMMATIC_TYPES_FILE => programmatic_types_sdl(sdl, registry: registry)
42
- }.map do |name, content|
43
- path = File.join(output_dir.to_s, name)
44
- File.write(path, content)
45
- path
51
+ directives_path = write_file(output_dir, DIRECTIVES_FILE, directives)
52
+ types_path = File.join(output_dir.to_s, PROGRAMMATIC_TYPES_FILE)
53
+ begin
54
+ write_file(output_dir, PROGRAMMATIC_TYPES_FILE, programmatic_types_sdl(sdl, registry: registry, directives: directives))
55
+ rescue StandardError => e
56
+ warn("Lighthouse: #{PROGRAMMATIC_TYPES_FILE} kept as it was, the schema does not build: #{e.message}")
57
+ write_file(output_dir, PROGRAMMATIC_TYPES_FILE, placeholder_only(sdl)) unless File.exist?(types_path)
46
58
  end
59
+ [directives_path, types_path]
60
+ end
61
+
62
+ # The schema as the IDE reads it: the entrypoint and its imports, or, when
63
+ # they do not load, every .graphql file under the entrypoint's folder.
64
+ def ide_sdl(schema_path)
65
+ return '' unless schema_path
66
+
67
+ return Lighthouse::GraphQL::SchemaFactory.load_sdl(schema_path) if File.file?(schema_path.to_s)
68
+
69
+ raise Lighthouse::DefinitionError, "no schema at #{schema_path}"
70
+ rescue Lighthouse::DefinitionError
71
+ Dir[File.join(File.dirname(schema_path.to_s), '**', '*.graphql')].map { |file| File.read(file) }.join("\n")
72
+ end
73
+
74
+ def write_file(output_dir, name, content)
75
+ path = File.join(output_dir.to_s, name)
76
+ File.write(path, content) unless File.exist?(path) && File.read(path) == content
77
+ path
78
+ end
79
+
80
+ def placeholder_only(sdl)
81
+ declared = sdl.to_s.match?(/^\s*scalar\s+_\b/) ? Set['_'] : Set.new
82
+ render('The schema does not build: only the `_` placeholder is declared until it does.',
83
+ undeclared_placeholder?(sdl, declared) ? [PLACEHOLDER_SCALAR] : [])
84
+ end
85
+
86
+ # Config.ide_helper's refresh: the files in the Rails app's root, when the
87
+ # schema loads. A schema that does not is the next request's error to raise;
88
+ # here it only warns.
89
+ def refresh(output_dir: ::Rails.root)
90
+ return [] unless Lighthouse::Config.ide_helper
91
+
92
+ write(output_dir: output_dir)
93
+ rescue StandardError => e
94
+ warn("Lighthouse: the IDE helper files were not refreshed: #{e.message}")
95
+ []
47
96
  end
48
97
 
49
98
  def directives_sdl(sdl = '', registry: Lighthouse::DirectiveRegistry.default)
@@ -56,13 +105,12 @@ module Lighthouse::IdeHelper
56
105
  )
57
106
  end
58
107
 
59
- def programmatic_types_sdl(sdl, registry: Lighthouse::DirectiveRegistry.default)
60
- original = type_names(sdl)
61
- generated = parse(registry.manipulate_sdl(sdl)).definitions.select do |definition|
62
- type_definition?(definition) && original.exclude?(definition.name)
63
- end
64
- generated_sdl = generated.sort_by(&:name).map(&:to_query_string)
65
- generated_sdl.unshift(PLACEHOLDER_SCALAR) if undeclared_placeholder?(sdl, original, generated)
108
+ # `directives`: the schema-directives.graphql written next to it, whose types
109
+ # (`CanAction`, `Mixed`, ...) some manipulators inject too and are left out here.
110
+ def programmatic_types_sdl(sdl, registry: Lighthouse::DirectiveRegistry.default, directives: directives_sdl(sdl, registry: registry))
111
+ declared = type_names(sdl) | type_names(directives)
112
+ generated_sdl = generated_types(sdl, declared, registry).sort_by(&:name).map(&:to_query_string)
113
+ generated_sdl.unshift(PLACEHOLDER_SCALAR) if undeclared_placeholder?(sdl, declared)
66
114
 
67
115
  render(
68
116
  'Types the manipulation phase generates (Connection/Paginator types, whereConditions inputs, ...).',
@@ -70,10 +118,19 @@ module Lighthouse::IdeHelper
70
118
  )
71
119
  end
72
120
 
73
- # The manipulation phase replaces the `_` placeholder (`where: _ @whereConditions`),
74
- # but the IDE reads the SDL as written, so it needs the placeholder declared.
75
- def undeclared_placeholder?(sdl, original, generated)
76
- sdl.to_s.match?(/:\s*\[?\s*_\b/) && original.exclude?('_') && generated.none? { |d| d.name == '_' }
121
+ # The types of the manipulated schema that neither the SDL nor the directives file defines.
122
+ def generated_types(sdl, declared, registry)
123
+ manipulated = Lighthouse::GraphQL::ASTBuilder.build(Lighthouse::GraphQL::SchemaGenerator.inject_directive_definitions(sdl), registry: registry)
124
+ parse(manipulated).definitions.select do |definition|
125
+ type_definition?(definition) && declared.exclude?(definition.name) && definition.name != '_'
126
+ end
127
+ end
128
+
129
+ # The manipulation phase replaces the `_` placeholder (`where: _ @whereConditions`,
130
+ # `orderBy: _ @orderBy`), but the IDE reads the SDL as written, so it needs
131
+ # the placeholder declared.
132
+ def undeclared_placeholder?(sdl, declared)
133
+ sdl.to_s.match?(/:\s*\[?\s*_\b/) && declared.exclude?('_')
77
134
  end
78
135
 
79
136
  def type_names(sdl)
@@ -8,6 +8,7 @@ module Lighthouse
8
8
  module Contracts; end
9
9
  module Events; end
10
10
  module Generators; end
11
+ module OrderBy; end
11
12
  module Support; end
12
13
  module WhereConditions; end
13
14
 
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The types @orderBy generates for an argument written with the `_`
4
+ # placeholder, as PHP Lighthouse's OrderByDirective and OrderByServiceProvider
5
+ # define them. For Query.posts(orderBy:), the prefix is QueryPostsOrderBy:
6
+ #
7
+ # no columns:/columnsEnum:/relations: [OrderByClause!] column: String!, order: SortOrder!
8
+ # columns: / columnsEnum: [<Prefix>OrderByClause!] column: <Prefix>Column! (or the columnsEnum)
9
+ # relations: [<Prefix>RelationOrderByClause!]
10
+ # column: <Prefix>Column (or String), order: SortOrder!, and per relation
11
+ # <relation>: <Prefix><Relation> { aggregate: COUNT } or, with the relation's
12
+ # own columns:, { aggregate: AVG|MIN|MAX|SUM|COUNT, column: <Prefix><Relation>Column }
13
+ class Lighthouse::OrderBy::Types
14
+ GeneratedTypes = Lighthouse::GraphQL::Directives::GeneratedTypes
15
+
16
+ DEFAULT_CLAUSE = 'OrderByClause'
17
+ SORT_ORDER = 'SortOrder'
18
+ AGGREGATE = 'OrderByRelationAggregateFunction'
19
+ AGGREGATE_WITH_COLUMN = 'OrderByRelationWithColumnAggregateFunction'
20
+
21
+ SDL = {
22
+ SORT_ORDER => <<~GRAPHQL,
23
+ "Directions for ordering a list of records."
24
+ enum SortOrder {
25
+ "Sort records in ascending order."
26
+ ASC
27
+
28
+ "Sort records in descending order."
29
+ DESC
30
+ }
31
+ GRAPHQL
32
+ AGGREGATE => <<~GRAPHQL,
33
+ "Aggregate functions when ordering by a relation without specifying a column."
34
+ enum OrderByRelationAggregateFunction {
35
+ "Amount of items."
36
+ COUNT @enum(value: "count")
37
+ }
38
+ GRAPHQL
39
+ AGGREGATE_WITH_COLUMN => <<~GRAPHQL
40
+ "Aggregate functions when ordering by a relation that may specify a column."
41
+ enum OrderByRelationWithColumnAggregateFunction {
42
+ "Average."
43
+ AVG @enum(value: "avg")
44
+
45
+ "Minimum."
46
+ MIN @enum(value: "min")
47
+
48
+ "Maximum."
49
+ MAX @enum(value: "max")
50
+
51
+ "Sum."
52
+ SUM @enum(value: "sum")
53
+
54
+ "Amount of items."
55
+ COUNT @enum(value: "count")
56
+ }
57
+ GRAPHQL
58
+ }.freeze
59
+
60
+ def initialize(document_ast, directive, arg, field, type)
61
+ @document_ast = document_ast
62
+ @directive = directive
63
+ @relations = Array(GeneratedTypes.directive_value(directive, 'relations')).map { |relation| relation.transform_keys(&:to_s) }
64
+ @arg = arg
65
+ @path = "#{type.name}.#{field.name}.#{arg.name}"
66
+ @prefix = GeneratedTypes.qualified_arg_type(arg, field, type)
67
+ @columns_enum = -> { GeneratedTypes.columns_enum(document_ast, directive, arg, field, type) }
68
+ end
69
+
70
+ # The argument, typed with the clauses generated for it.
71
+ def argument
72
+ @document_ast.define_type(SORT_ORDER, SDL[SORT_ORDER])
73
+ arg = GeneratedTypes.describe(@arg, "Order by clause for #{@path}.")
74
+ allowed = GeneratedTypes.allowed_columns?(@directive)
75
+ column_type = allowed ? @columns_enum.call : 'String'
76
+
77
+ clause =
78
+ if @relations.any? then relation_clause(column_type)
79
+ elsif allowed then column_clause("#{@prefix}OrderByClause", column_type, "Order by clause for #{@path}.")
80
+ else column_clause(DEFAULT_CLAUSE, 'String', 'Allows ordering a list of records.')
81
+ end
82
+ GeneratedTypes.replace_placeholder(arg, "[#{clause}!]")
83
+ end
84
+
85
+ private
86
+
87
+ def column_clause(name, column_type, description)
88
+ define(name, description, ["column: #{column_type}!", 'The column that is used for ordering.'], order_field)
89
+ end
90
+
91
+ # A column, or one relation's aggregate.
92
+ def relation_clause(column_type)
93
+ aggregates = @relations.map { |relation| ["#{relation['relation']}: #{aggregate_input(relation)}", 'Aggregate specification.'] }
94
+ define("#{@prefix}RelationOrderByClause", "Order by clause for #{@path}.",
95
+ ["column: #{column_type}", 'The column that is used for ordering.'], order_field, *aggregates)
96
+ end
97
+
98
+ # COUNT, or, with the relation's `columns:` / `columnsEnum:`, an aggregate of one of its columns.
99
+ def aggregate_input(relation)
100
+ name = "#{@prefix}#{GeneratedTypes.studly(relation['relation'])}"
101
+ columns_enum = relation_columns_enum(relation, name)
102
+ aggregate = columns_enum ? AGGREGATE_WITH_COLUMN : AGGREGATE
103
+ @document_ast.define_type(aggregate, SDL[aggregate])
104
+
105
+ fields = if columns_enum
106
+ [["aggregate: #{aggregate}!", 'The aggregate function to apply to the column.'],
107
+ ["column: #{columns_enum}", 'Name of the column to use.']]
108
+ else
109
+ [["aggregate: #{aggregate}!", 'Always COUNT.']]
110
+ end
111
+ define(name, "Aggregate specification for #{@path}.#{relation['relation']}.", *fields)
112
+ end
113
+
114
+ def relation_columns_enum(relation, name)
115
+ if relation['columns'] && relation['columnsEnum']
116
+ raise Lighthouse::DefinitionError,
117
+ "The relation #{relation['relation']} of @orderBy on #{@path} can only have one of the following arguments: `columns`, `columnsEnum`."
118
+ end
119
+ return relation['columnsEnum'] if relation['columnsEnum']
120
+ return nil unless relation['columns']
121
+
122
+ GeneratedTypes.define_columns_enum(@document_ast, "#{name}Column", relation['columns'], "#{@path}.#{relation['relation']}")
123
+ "#{name}Column"
124
+ end
125
+
126
+ def order_field
127
+ ["order: #{SORT_ORDER}!", 'The direction that is used for ordering.']
128
+ end
129
+
130
+ # Defines the input `name` with `fields`, each [SDL, description]; returns its name.
131
+ def define(name, description, *fields)
132
+ @document_ast.define_type(name) do
133
+ %("#{description}"\ninput #{name} {\n#{fields.map { |sdl, doc| %( "#{doc}"\n #{sdl}) }.join("\n\n")}\n}\n)
134
+ end
135
+ name
136
+ end
137
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  # Hooks Lighthouse into a Rails host: exposes the `lighthouse:*` rake tasks,
4
- # and — with `config.watch_sdl` — reloads the app when a .graphql file changes.
4
+ # with `config.watch_sdl` reloads the app when a .graphql file changes, and
5
+ # with `config.ide_helper` (on in development when the app has a
6
+ # graphql.config.yml) rewrites the IDE helper files on boot and every reload.
5
7
  # Generators (`rails g lighthouse:install`) are found through lib/generators.
6
8
  class Lighthouse::Railtie < Rails::Railtie
7
9
  rake_tasks do
@@ -14,10 +16,15 @@ class Lighthouse::Railtie < Rails::Railtie
14
16
  Lighthouse::Railtie.watch_sdl(app.config)
15
17
  end
16
18
 
19
+ initializer 'lighthouse.ide_helper', after: :load_config_initializers do |app|
20
+ app.reloader.to_prepare { Lighthouse::IdeHelper.refresh(output_dir: app.root) }
21
+ end
22
+
17
23
  # Rails' reloader then treats a .graphql edit like a Ruby one: the app's
18
24
  # constants — the schema among them — are reloaded on the next request.
25
+ # Every .graphql under the entrypoint's folder is watched, imported or not.
19
26
  def self.watch_sdl(config)
20
- folder = Lighthouse::Config.sdl_folder
27
+ folder = Lighthouse::Config.schema_directory
21
28
  return false unless Lighthouse::Config.watch_sdl && folder
22
29
 
23
30
  config.watchable_dirs[folder.to_s] = %w[graphql graphqls]