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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +107 -1
- data/README.md +15 -7
- data/docs/best-practices.md +6 -4
- data/docs/configuration.md +19 -5
- data/docs/custom-directives.md +2 -1
- data/docs/directives.md +65 -7
- data/docs/federation.md +1 -1
- data/docs/filtering-and-ordering.md +330 -30
- data/docs/generators.md +2 -2
- data/docs/getting-started.md +43 -10
- data/docs/motivation.md +3 -2
- data/docs/performance.md +4 -3
- data/docs/relationships.md +2 -1
- data/docs/relay.md +52 -3
- data/docs/resolvers.md +2 -2
- data/docs/schema-organisation.md +177 -0
- data/lib/generators/lighthouse/install/graphql_ruby_install.rb +14 -0
- data/lib/generators/lighthouse/install/install_generator.rb +31 -12
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +11 -4
- data/lib/generators/lighthouse/install/templates/schema.graphql.tt +30 -0
- data/lib/lighthouse/ast_builder.rb +124 -0
- data/lib/lighthouse/config.rb +23 -4
- data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
- data/lib/lighthouse/contracts/field_builder.rb +13 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
- data/lib/lighthouse/directive_resolvers/base.rb +6 -37
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
- data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
- data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
- data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
- data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
- data/lib/lighthouse/directives/definitions.rb +0 -1
- data/lib/lighthouse/directives/generated_types.rb +103 -0
- data/lib/lighthouse/directives/registry.rb +39 -10
- data/lib/lighthouse/document_ast.rb +111 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/ide_helper.rb +80 -23
- data/lib/lighthouse/namespaces.rb +1 -0
- data/lib/lighthouse/order_by/types.rb +137 -0
- data/lib/lighthouse/railtie.rb +9 -2
- data/lib/lighthouse/rb_lighthouse.rb +15 -5
- data/lib/lighthouse/resolve_info.rb +5 -5
- data/lib/lighthouse/schema_cache.rb +8 -4
- data/lib/lighthouse/schema_factory.rb +6 -9
- data/lib/lighthouse/schema_generator.rb +4 -15
- data/lib/lighthouse/schema_stitcher.rb +64 -0
- data/lib/lighthouse/support/naming.rb +0 -12
- data/lib/lighthouse/where_conditions/columns.rb +36 -0
- data/lib/lighthouse/where_conditions/handler.rb +152 -0
- data/lib/lighthouse/where_conditions/json_column.rb +99 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
- data/lib/lighthouse/where_conditions/types.rb +110 -0
- data/sig/lighthouse/directives.rbs +16 -3
- data/sig/lighthouse/graphql.rbs +48 -4
- data/sig/lighthouse.rbs +8 -2
- metadata +15 -4
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
- data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
- 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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
|
|
36
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
generated_sdl
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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)
|
|
@@ -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
|
data/lib/lighthouse/railtie.rb
CHANGED
|
@@ -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
|
-
#
|
|
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.
|
|
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]
|