lighthouse-graphql 0.6.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +95 -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 +1 -1
- 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/resolvers.md +2 -2
- data/docs/schema-organisation.md +177 -0
- data/lib/generators/lighthouse/install/install_generator.rb +16 -11
- 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/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
|
@@ -7,13 +7,13 @@
|
|
|
7
7
|
module Lighthouse::GraphQL::SchemaFactory
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
def build_base_schema(
|
|
10
|
+
def build_base_schema(schema_path: nil, sdl: nil, base_types: {}, schema_options: {}, **extra_options)
|
|
11
11
|
# Absorb any extra keyword options (e.g. `federation: true`) into the
|
|
12
12
|
# schema options bag so callers can pass schema-level flags directly
|
|
13
13
|
# without the factory needing to enumerate every one.
|
|
14
14
|
schema_options = extra_options.merge(schema_options)
|
|
15
15
|
|
|
16
|
-
sdl_string = sdl || load_sdl(
|
|
16
|
+
sdl_string = sdl || load_sdl(schema_path)
|
|
17
17
|
schema_class = schema_options.delete(:schema_class)
|
|
18
18
|
|
|
19
19
|
Lighthouse::GraphQL::SchemaGenerator.build(
|
|
@@ -24,12 +24,9 @@ module Lighthouse::GraphQL::SchemaFactory
|
|
|
24
24
|
)
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
# The
|
|
28
|
-
#
|
|
29
|
-
def load_sdl(
|
|
30
|
-
|
|
31
|
-
return Lighthouse::GraphQL::SDLLoader.load_from_folder(folder) if folder && Dir.exist?(folder)
|
|
32
|
-
|
|
33
|
-
File.read(File.expand_path('../../schema.graphql', __dir__))
|
|
27
|
+
# The schema read from `schema_path` (default: Config.schema_path) and the
|
|
28
|
+
# files it imports.
|
|
29
|
+
def load_sdl(schema_path = nil)
|
|
30
|
+
Lighthouse::GraphQL::SchemaStitcher.new(schema_path || Lighthouse::Config.schema_path).schema_string
|
|
34
31
|
end
|
|
35
32
|
end
|
|
@@ -122,10 +122,11 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
122
122
|
end
|
|
123
123
|
|
|
124
124
|
# The SDL as the schema is built from it: directive definitions injected
|
|
125
|
-
# (so apps don't hand-declare Lighthouse's directives), then
|
|
126
|
-
#
|
|
125
|
+
# (so apps don't hand-declare Lighthouse's directives), then the
|
|
126
|
+
# manipulation phase (ASTBuilder: type extensions merged, pagination types,
|
|
127
|
+
# whereConditions / orderBy inputs, ...).
|
|
127
128
|
def manipulate(sdl)
|
|
128
|
-
|
|
129
|
+
Lighthouse::GraphQL::ASTBuilder.build(inject_directive_definitions(sdl))
|
|
129
130
|
end
|
|
130
131
|
|
|
131
132
|
# `bases`: { root kind => the base class its fields' classes inherit from } (ResolverBases).
|
|
@@ -194,18 +195,6 @@ module Lighthouse::GraphQL::SchemaGenerator
|
|
|
194
195
|
# ignore failures during post-processing
|
|
195
196
|
end
|
|
196
197
|
|
|
197
|
-
# Scan SDL string for list-typed fields that carry relationship directives
|
|
198
|
-
# and produce Connection/Edge types where missing. This is a lightweight
|
|
199
|
-
# transformation that mirrors Lighthouse behavior: clients can declare
|
|
200
|
-
# `roles: [Role!]! @belongsToMany` and the builder will expose a
|
|
201
|
-
# `RoleConnection` type and swap the field to return that connection.
|
|
202
|
-
def augment_sdl_with_connections(sdl)
|
|
203
|
-
# Drive SDL manipulation through the registry so each directive owns its
|
|
204
|
-
# manipulation behavior and new manipulators can be added by registering
|
|
205
|
-
# a class (no edits here).
|
|
206
|
-
Lighthouse::DirectiveRegistry.default.manipulate_sdl(sdl)
|
|
207
|
-
end
|
|
208
|
-
|
|
209
198
|
# Prepend `directive @name(...) on ...` definitions for registered,
|
|
210
199
|
# self-describing directives that the SDL uses but has not declared.
|
|
211
200
|
# Already-declared directives are left untouched (no duplicates).
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Reads the schema from one entrypoint (Config.schema_path,
|
|
4
|
+
# graphql/schema.graphql by default) and pastes in the files it imports, as PHP
|
|
5
|
+
# Lighthouse's SchemaStitcher does:
|
|
6
|
+
#
|
|
7
|
+
# type Query
|
|
8
|
+
#
|
|
9
|
+
# #import user.graphql
|
|
10
|
+
# #import blog/*.graphql
|
|
11
|
+
#
|
|
12
|
+
# An import names a path relative to the file it is in, and may be a glob.
|
|
13
|
+
# Imported files are read the same way, so they can import others. A file is
|
|
14
|
+
# read once: importing it again, or importing a file that imports it back,
|
|
15
|
+
# adds nothing. Only the files reached from the entrypoint are in the schema.
|
|
16
|
+
class Lighthouse::GraphQL::SchemaStitcher
|
|
17
|
+
IMPORT = '#import '
|
|
18
|
+
|
|
19
|
+
attr_reader :root_schema_path
|
|
20
|
+
|
|
21
|
+
def initialize(root_schema_path)
|
|
22
|
+
path = File.expand_path(root_schema_path.to_s)
|
|
23
|
+
unless File.file?(path)
|
|
24
|
+
raise Lighthouse::DefinitionError,
|
|
25
|
+
"Failed to find a GraphQL schema file at #{path}. Set config.schema_path, or run bin/rails generate lighthouse:install."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
@root_schema_path = File.realpath(path)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The stitched schema: the entrypoint with every import replaced by the file's content.
|
|
32
|
+
def schema_string
|
|
33
|
+
gather(@root_schema_path, Set.new)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Every file the schema is read from, the entrypoint first.
|
|
37
|
+
def files
|
|
38
|
+
visited = Set.new
|
|
39
|
+
gather(@root_schema_path, visited)
|
|
40
|
+
visited.to_a
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def gather(path, visited)
|
|
46
|
+
return '' unless visited.add?(path)
|
|
47
|
+
|
|
48
|
+
File.readlines(path, chomp: true).map { |line| import_line?(line) ? import(path, line, visited) : "#{line}\n" }.join
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def import_line?(line)
|
|
52
|
+
line.strip.start_with?(IMPORT)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def import(path, line, visited)
|
|
56
|
+
name = line.strip.delete_prefix(IMPORT).strip
|
|
57
|
+
import_path = File.join(File.dirname(path), name)
|
|
58
|
+
return Dir.glob(import_path).map { |file| gather(File.realpath(file), visited) }.join if name.include?('*')
|
|
59
|
+
|
|
60
|
+
raise Lighthouse::DefinitionError, "Did not find GraphQL schema import at #{import_path}." unless File.file?(import_path)
|
|
61
|
+
|
|
62
|
+
gather(File.realpath(import_path), visited)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -52,16 +52,4 @@ module Lighthouse::Support::Naming
|
|
|
52
52
|
snake = raw.underscore
|
|
53
53
|
[raw.to_sym, raw, snake.to_sym, snake].uniq
|
|
54
54
|
end
|
|
55
|
-
|
|
56
|
-
# Derive an Eloquent-style relation name from a `has<Relation>` argument
|
|
57
|
-
# name, e.g. "hasContact" -> "contacts". Returns nil when the argument does
|
|
58
|
-
# not follow the `has` convention.
|
|
59
|
-
def derive_relation_name(arg_name)
|
|
60
|
-
return nil unless arg_name.to_s.match?(/^has/i)
|
|
61
|
-
|
|
62
|
-
trailing = arg_name.to_s.sub(/^has/i, '')
|
|
63
|
-
return nil if trailing.empty?
|
|
64
|
-
|
|
65
|
-
trailing.underscore.pluralize
|
|
66
|
-
end
|
|
67
55
|
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The column a client names in @whereConditions or @orderBy, as an Arel node.
|
|
4
|
+
# Enum values (`CREATED_AT`, or the column an @enum(value:) gives) map onto
|
|
5
|
+
# columns as everywhere (Support::Naming). A column of an unrestricted input
|
|
6
|
+
# (`column: String`) comes from the client as is, so it must be an identifier,
|
|
7
|
+
# `table.column`, or either with a path into a JSON document (`meta->tags`,
|
|
8
|
+
# JsonColumn), as PHP Lighthouse checks it.
|
|
9
|
+
module Lighthouse::WhereConditions::Columns
|
|
10
|
+
IDENTIFIER = /\A[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?\z/
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The column on `klass`'s table, or `table.column`. A JSON path's value is
|
|
15
|
+
# compiled `as` JsonColumn.node says: :sort to order by, :text, :numeric or
|
|
16
|
+
# :boolean to compare with.
|
|
17
|
+
def attribute(klass, column, as: :sort)
|
|
18
|
+
json = Lighthouse::WhereConditions::JsonColumn
|
|
19
|
+
if json.json?(column)
|
|
20
|
+
raise ::GraphQL::ExecutionError, invalid_column_name(column) unless json.valid?(column)
|
|
21
|
+
|
|
22
|
+
return json.node(klass, column, as: as)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
name = Lighthouse::Support::Naming.column(column)
|
|
26
|
+
raise ::GraphQL::ExecutionError, invalid_column_name(column) unless IDENTIFIER.match?(name)
|
|
27
|
+
|
|
28
|
+
table, name = name.split('.', 2) if name.include?('.')
|
|
29
|
+
table ? ::Arel::Table.new(table)[name] : klass.arel_table[name]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def invalid_column_name(column)
|
|
33
|
+
'Column names may contain only alphanumerics or underscores, optionally a table and a JSON path ' \
|
|
34
|
+
"(posts.meta->tags), and may not begin with a digit, got: #{column}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Applies a @whereConditions value to a relation, as PHP Lighthouse's
|
|
4
|
+
# WhereConditionsHandler: the conditions of one level all hold, an AND list is
|
|
5
|
+
# a nested group whose conditions all hold, an OR list one where at least one
|
|
6
|
+
# does, and HAS checks how many related records match a condition.
|
|
7
|
+
#
|
|
8
|
+
# { column: AGE, operator: GT, value: 37, OR: [{ column: TYPE, value: "Actor" }, ...] }
|
|
9
|
+
# -> age > 37 AND (type = 'Actor' OR ...)
|
|
10
|
+
#
|
|
11
|
+
# { HAS: { relation: "posts", amount: 3, condition: { column: "published", value: true } } }
|
|
12
|
+
# -> users with at least 3 published posts
|
|
13
|
+
#
|
|
14
|
+
# A column the client names (an unrestricted WhereConditions takes any) must
|
|
15
|
+
# be an identifier, `table.column` at most (Columns). The columns of a HAS
|
|
16
|
+
# condition are those of the related model's table.
|
|
17
|
+
#
|
|
18
|
+
# A custom handler (`@whereConditions(handler: "...")`) is any object or
|
|
19
|
+
# class answering `call(relation, conditions)` with the relation.
|
|
20
|
+
module Lighthouse::WhereConditions::Handler
|
|
21
|
+
Types = Lighthouse::WhereConditions::Types
|
|
22
|
+
|
|
23
|
+
# How a HAS operator compares the number of related records with `amount`.
|
|
24
|
+
COUNT_COMPARISONS = { 'EQ' => :eq, 'NEQ' => :not_eq, 'GT' => :gt, 'GTE' => :gteq, 'LT' => :lt, 'LTE' => :lteq }.freeze
|
|
25
|
+
RUBY_COMPARISONS = { 'EQ' => :==, 'NEQ' => :!=, 'GT' => :>, 'GTE' => :>=, 'LT' => :<, 'LTE' => :<= }.freeze
|
|
26
|
+
|
|
27
|
+
# The Arel predicate of each operator that takes a value: [method, how the value is passed].
|
|
28
|
+
PREDICATES = {
|
|
29
|
+
'LIKE' => [:matches, :pattern], 'NOT_LIKE' => [:does_not_match, :pattern],
|
|
30
|
+
'IN' => [:in, :list], 'NOT_IN' => [:not_in, :list],
|
|
31
|
+
'BETWEEN' => [:between, :range], 'NOT_BETWEEN' => [:not_between, :range]
|
|
32
|
+
}.merge(COUNT_COMPARISONS.transform_values { |method| [method, :value] }).freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def call(relation, conditions)
|
|
37
|
+
predicate = predicate(relation.klass, normalize(conditions))
|
|
38
|
+
predicate ? relation.where(predicate) : relation
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# The conditions as a Hash of :column, :operator, :value, :and, :or, :has
|
|
42
|
+
# (and :relation, :amount, :condition in a HAS), leaving the value as given.
|
|
43
|
+
def normalize(conditions)
|
|
44
|
+
return nil if conditions.nil?
|
|
45
|
+
|
|
46
|
+
hash = conditions.respond_to?(:to_h) ? conditions.to_h : conditions
|
|
47
|
+
hash.each_with_object({}) do |(key, value), normalized|
|
|
48
|
+
key = key.to_s.underscore.to_sym
|
|
49
|
+
normalized[key] =
|
|
50
|
+
case key
|
|
51
|
+
when :and, :or then Array(value).map { |condition| normalize(condition) }
|
|
52
|
+
when :has, :condition then normalize(value)
|
|
53
|
+
else value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# The Arel predicate of one level of conditions, or nil when it has none.
|
|
59
|
+
def predicate(klass, conditions)
|
|
60
|
+
return nil if conditions.blank?
|
|
61
|
+
|
|
62
|
+
nodes = [
|
|
63
|
+
group(klass, conditions[:and], :and),
|
|
64
|
+
group(klass, conditions[:or], :or),
|
|
65
|
+
conditions[:has] && has(klass, conditions[:has]),
|
|
66
|
+
conditions[:column] && column_condition(klass, conditions)
|
|
67
|
+
].compact
|
|
68
|
+
nodes.empty? ? nil : nodes.reduce { |left, right| left.and(right) }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def group(klass, conditions, boolean)
|
|
72
|
+
nodes = Array(conditions).filter_map { |condition| predicate(klass, condition) }
|
|
73
|
+
return nil if nodes.empty?
|
|
74
|
+
|
|
75
|
+
boolean == :and ? ::Arel::Nodes::Grouping.new(::Arel::Nodes::And.new(nodes)) : nodes.reduce { |left, right| left.or(right) }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def column_condition(klass, conditions)
|
|
79
|
+
operator = (conditions[:operator] || Types::DEFAULT_OPERATOR).to_s.upcase
|
|
80
|
+
compared_as = Lighthouse::WhereConditions::JsonColumn.compared_as(operator, conditions[:value])
|
|
81
|
+
attribute = Lighthouse::WhereConditions::Columns.attribute(klass, conditions[:column], as: compared_as)
|
|
82
|
+
return attribute.eq(nil) if operator == 'IS_NULL'
|
|
83
|
+
return attribute.not_eq(nil) if operator == 'IS_NOT_NULL'
|
|
84
|
+
|
|
85
|
+
unless conditions.key?(:value)
|
|
86
|
+
raise ::GraphQL::ExecutionError, "Did not receive a value to match the WhereConditions for column #{conditions[:column]}."
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
compare(attribute, operator, conditions[:value])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def compare(attribute, operator, value)
|
|
93
|
+
method, kind = PREDICATES[operator]
|
|
94
|
+
raise ::GraphQL::ExecutionError, "Unknown SQLOperator #{operator}." unless method
|
|
95
|
+
|
|
96
|
+
case kind
|
|
97
|
+
when :pattern then attribute.public_send(method, value, nil, true) # LIKE, case-sensitive as SQL's
|
|
98
|
+
when :list then attribute.public_send(method, Array(value))
|
|
99
|
+
when :range then attribute.public_send(method, range(value))
|
|
100
|
+
else attribute.public_send(method, value)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def range(value)
|
|
105
|
+
values = Array(value)
|
|
106
|
+
raise ::GraphQL::ExecutionError, 'BETWEEN needs a value of two elements: [from, to].' unless values.size == 2
|
|
107
|
+
|
|
108
|
+
values.first..values.last
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Records of `klass` with `amount` (compared by `operator`) related records
|
|
112
|
+
# matching the condition: an IN over the records that have them, and, when
|
|
113
|
+
# having none satisfies the comparison, the records that have none too.
|
|
114
|
+
def has(klass, has)
|
|
115
|
+
reflection = reflection(klass, has[:relation])
|
|
116
|
+
operator = (has[:operator] || Types::DEFAULT_HAS_OPERATOR).to_s.upcase
|
|
117
|
+
amount = (has[:amount] || Types::DEFAULT_HAS_AMOUNT).to_i
|
|
118
|
+
raise ::GraphQL::ExecutionError, "HAS can not compare the amount with #{operator}." unless COUNT_COMPARISONS.key?(operator)
|
|
119
|
+
|
|
120
|
+
key = klass.arel_table[klass.primary_key]
|
|
121
|
+
related = related_scope(klass, reflection, has[:condition])
|
|
122
|
+
predicate = key.in(owners(related, key, reflection.klass, operator, amount).arel)
|
|
123
|
+
0.public_send(RUBY_COMPARISONS.fetch(operator), amount) ? predicate.or(key.not_in(related.select(key).arel)) : predicate
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# `klass` joined to its related records that match the condition.
|
|
127
|
+
def related_scope(klass, reflection, condition)
|
|
128
|
+
scope = klass.unscoped.joins(reflection.name)
|
|
129
|
+
predicate = predicate(reflection.klass, condition)
|
|
130
|
+
predicate ? scope.where(predicate) : scope
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The keys of the records with `amount` related records, by `operator`. At
|
|
134
|
+
# least one is any join; another amount needs them counted.
|
|
135
|
+
def owners(related_scope, key, related, operator, amount)
|
|
136
|
+
return related_scope.select(key) if (operator == 'GTE' && amount == 1) || (operator == 'GT' && amount.zero?)
|
|
137
|
+
|
|
138
|
+
counted = related.primary_key ? related.arel_table[related.primary_key] : ::Arel.star
|
|
139
|
+
count = ::Arel::Nodes::Count.new([counted], true)
|
|
140
|
+
related_scope.group(key).having(count.public_send(COUNT_COMPARISONS.fetch(operator), amount)).select(key)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The association `name` names on `klass`: as written, snake_cased, singular or plural.
|
|
144
|
+
def reflection(klass, name)
|
|
145
|
+
candidates = [name.to_s, name.to_s.underscore, name.to_s.underscore.singularize, name.to_s.underscore.pluralize].uniq
|
|
146
|
+
reflection = candidates.lazy.filter_map { |candidate| klass.reflect_on_association(candidate.to_sym) }.first
|
|
147
|
+
raise ::GraphQL::ExecutionError, "#{klass.name} has no relation #{name}." unless reflection
|
|
148
|
+
raise ::GraphQL::ExecutionError, "HAS can not check the polymorphic relation #{name}." if reflection.polymorphic?
|
|
149
|
+
|
|
150
|
+
reflection
|
|
151
|
+
end
|
|
152
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# A column with a path into its JSON document, as Laravel names one and PHP
|
|
4
|
+
# Lighthouse's @whereConditions and @orderBy take it: `meta->tags`,
|
|
5
|
+
# `posts.meta->author->name`, `meta->tags->0` (an array index). The value at
|
|
6
|
+
# the path is compiled for the database, as Laravel's query grammars do:
|
|
7
|
+
#
|
|
8
|
+
# PostgreSQL "posts"."meta"->'author'->>'name'
|
|
9
|
+
# MySQL JSON_UNQUOTE(JSON_EXTRACT(`posts`.`meta`, '$."author"."name"'))
|
|
10
|
+
# SQLite json_extract("posts"."meta", '$."author"."name"')
|
|
11
|
+
#
|
|
12
|
+
# Unlike Laravel, it compares numbers and booleans as such, and orders by the
|
|
13
|
+
# JSON value (numbers numerically), on every database. The result is an Arel
|
|
14
|
+
# node: a condition calls its predicates (`eq`, `in`, `matches`, ...) and an
|
|
15
|
+
# order its `asc` / `desc`.
|
|
16
|
+
module Lighthouse::WhereConditions::JsonColumn
|
|
17
|
+
PATH = /\A(?:(?<table>[A-Za-z_]\w*)\.)?(?<column>[A-Za-z_]\w*)(?<path>(?:->[A-Za-z0-9_-]+)+)\z/
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def json?(name)
|
|
22
|
+
name.to_s.include?('->')
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def valid?(name)
|
|
26
|
+
PATH.match?(name.to_s)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The value at the path, as `as` says: :text, :numeric, :boolean, :null (NULL
|
|
30
|
+
# when the path is missing or holds JSON null), or :sort (the JSON value,
|
|
31
|
+
# ordered by type, numbers numerically).
|
|
32
|
+
def node(klass, name, as: :text)
|
|
33
|
+
match = PATH.match(name.to_s) or raise ArgumentError, "not a JSON column: #{name}"
|
|
34
|
+
connection = klass.connection
|
|
35
|
+
target = [match[:table] || klass.table_name, match[:column]].then do |table, column|
|
|
36
|
+
"#{connection.quote_table_name(table)}.#{connection.quote_column_name(column)}"
|
|
37
|
+
end
|
|
38
|
+
segments = match[:path].split('->').drop(1)
|
|
39
|
+
|
|
40
|
+
::Arel.sql(public_send(dialect(connection), target, segments, as))
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# How a condition with `operator` compares the value at the path: patterns as
|
|
44
|
+
# text, nulls as JSON null too, the rest as the value is.
|
|
45
|
+
def compared_as(operator, value)
|
|
46
|
+
case operator
|
|
47
|
+
when 'LIKE', 'NOT_LIKE' then :text
|
|
48
|
+
when 'IS_NULL', 'IS_NOT_NULL' then :null
|
|
49
|
+
else kind_of_value(value)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# What a value compares as: numbers as numbers, booleans as booleans, the rest as text.
|
|
54
|
+
def kind_of_value(value)
|
|
55
|
+
sample = value.is_a?(Array) ? value.first : value
|
|
56
|
+
case sample
|
|
57
|
+
when Numeric then :numeric
|
|
58
|
+
when true, false then :boolean
|
|
59
|
+
else :text
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def dialect(connection)
|
|
64
|
+
case connection.adapter_name
|
|
65
|
+
when /postg/i then :postgresql
|
|
66
|
+
when /mysql|trilogy/i then :mysql
|
|
67
|
+
else :sqlite
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def postgresql(target, segments, as)
|
|
72
|
+
keys = segments.map { |segment| segment.match?(/\A\d+\z/) ? segment : "'#{segment}'" }
|
|
73
|
+
return "(#{target}->#{keys.join('->')})::jsonb" if as == :sort
|
|
74
|
+
|
|
75
|
+
text = keys.size == 1 ? "#{target}->>#{keys.first}" : "#{target}->#{keys[0...-1].join('->')}->>#{keys.last}"
|
|
76
|
+
{ numeric: "(#{text})::numeric", boolean: "(#{text})::boolean" }.fetch(as, text) # :text, :null
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def mysql(target, segments, as)
|
|
80
|
+
extract = "JSON_EXTRACT(#{target}, '#{json_path(segments)}')"
|
|
81
|
+
case as
|
|
82
|
+
when :text then "JSON_UNQUOTE(#{extract})"
|
|
83
|
+
when :numeric then "CAST(JSON_UNQUOTE(#{extract}) AS DECIMAL(65, 10))"
|
|
84
|
+
when :boolean then "(#{extract} = CAST('true' AS JSON))"
|
|
85
|
+
when :null then "NULLIF(JSON_TYPE(#{extract}), 'NULL')"
|
|
86
|
+
else extract
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# json_extract answers SQLite's own types: numbers compare and order as numbers,
|
|
91
|
+
# booleans are 1 and 0.
|
|
92
|
+
def sqlite(target, segments, _as)
|
|
93
|
+
"json_extract(#{target}, '#{json_path(segments)}')"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def json_path(segments)
|
|
97
|
+
"$#{segments.map { |segment| segment.match?(/\A\d+\z/) ? "[#{segment}]" : %(."#{segment}") }.join}"
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
# Maps the
|
|
4
|
-
# Extracted from WhereConditionsApplier so that simple argument-filter
|
|
5
|
-
# directives (@eq, @neq, @in, @notIn, @like, @where) can share the same
|
|
6
|
-
# operator semantics.
|
|
3
|
+
# Maps the operators @where(operator:) takes to Arel predicate methods.
|
|
7
4
|
module Lighthouse::WhereConditions::OperatorMap
|
|
8
5
|
OPERATORS = {
|
|
9
6
|
'EQ' => :eq,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# The types @whereConditions and @whereHasConditions generate, as PHP
|
|
4
|
+
# Lighthouse's WhereConditionsServiceProvider defines them. For an argument
|
|
5
|
+
# whose input is named N:
|
|
6
|
+
#
|
|
7
|
+
# N column, operator, value, AND, OR, HAS: the conditions
|
|
8
|
+
# NRelation HAS: a relation, how many of its records (operator, amount), and a condition on them
|
|
9
|
+
# NHasCondition the condition on the related records: as N, but on any column
|
|
10
|
+
#
|
|
11
|
+
# Without `columns:` / `columnsEnum:` the argument shares the unrestricted
|
|
12
|
+
# WhereConditions (and its WhereConditionsRelation / WhereConditionsHasCondition).
|
|
13
|
+
module Lighthouse::WhereConditions::Types
|
|
14
|
+
DEFAULT = 'WhereConditions'
|
|
15
|
+
SQL_OPERATOR = 'SQLOperator'
|
|
16
|
+
MIXED = 'Mixed'
|
|
17
|
+
|
|
18
|
+
# The SQLOperator enum: what a condition compares the column with.
|
|
19
|
+
OPERATORS = {
|
|
20
|
+
'EQ' => 'Equal operator (`=`)',
|
|
21
|
+
'NEQ' => 'Not equal operator (`!=`)',
|
|
22
|
+
'GT' => 'Greater than operator (`>`)',
|
|
23
|
+
'GTE' => 'Greater than or equal operator (`>=`)',
|
|
24
|
+
'LT' => 'Less than operator (`<`)',
|
|
25
|
+
'LTE' => 'Less than or equal operator (`<=`)',
|
|
26
|
+
'LIKE' => 'Simple pattern matching (`LIKE`)',
|
|
27
|
+
'NOT_LIKE' => 'Negation of simple pattern matching (`NOT LIKE`)',
|
|
28
|
+
'IN' => 'Whether a value is within a set of values (`IN`)',
|
|
29
|
+
'NOT_IN' => 'Whether a value is not within a set of values (`NOT IN`)',
|
|
30
|
+
'BETWEEN' => 'Whether a value is within a range of values (`BETWEEN`)',
|
|
31
|
+
'NOT_BETWEEN' => 'Whether a value is not within a range of values (`NOT BETWEEN`)',
|
|
32
|
+
'IS_NULL' => 'Whether a value is null (`IS NULL`)',
|
|
33
|
+
'IS_NOT_NULL' => 'Whether a value is not null (`IS NOT NULL`)'
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
DEFAULT_OPERATOR = 'EQ'
|
|
37
|
+
DEFAULT_HAS_OPERATOR = 'GTE'
|
|
38
|
+
DEFAULT_HAS_AMOUNT = 1
|
|
39
|
+
|
|
40
|
+
module_function
|
|
41
|
+
|
|
42
|
+
# Defines N, NHasCondition and NRelation, and the SQLOperator and Mixed they use.
|
|
43
|
+
def define(document_ast, name, column_type, path)
|
|
44
|
+
document_ast.define_type(SQL_OPERATOR) { sql_operator }
|
|
45
|
+
document_ast.define_type(MIXED, 'scalar Mixed')
|
|
46
|
+
document_ast.define_type(name) { conditions(name, "Dynamic WHERE conditions for #{path}.", column_type) }
|
|
47
|
+
document_ast.define_type("#{name}HasCondition") do
|
|
48
|
+
conditions("#{name}HasCondition", "Dynamic WHERE HAS conditions for #{path}.", 'String')
|
|
49
|
+
end
|
|
50
|
+
document_ast.define_type("#{name}Relation") { relation(name, "Dynamic HAS conditions for WHERE conditions for #{path}.") }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def define_default(document_ast)
|
|
54
|
+
define(document_ast, DEFAULT, 'String', 'any column')
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def conditions(name, description, column_type)
|
|
58
|
+
<<~GRAPHQL
|
|
59
|
+
"#{description}"
|
|
60
|
+
input #{name} {
|
|
61
|
+
"The column that is used for the condition."
|
|
62
|
+
column: #{column_type}
|
|
63
|
+
|
|
64
|
+
"The operator that is used for the condition."
|
|
65
|
+
operator: #{SQL_OPERATOR} = #{DEFAULT_OPERATOR}
|
|
66
|
+
|
|
67
|
+
"The value that is used for the condition."
|
|
68
|
+
value: #{MIXED}
|
|
69
|
+
|
|
70
|
+
"A set of conditions that requires all conditions to match."
|
|
71
|
+
AND: [#{name}!]
|
|
72
|
+
|
|
73
|
+
"A set of conditions that requires at least one condition to match."
|
|
74
|
+
OR: [#{name}!]
|
|
75
|
+
|
|
76
|
+
"Check whether a relation exists. Extra conditions or a minimum amount can be applied."
|
|
77
|
+
HAS: #{name.delete_suffix('HasCondition')}Relation
|
|
78
|
+
}
|
|
79
|
+
GRAPHQL
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def relation(name, description)
|
|
83
|
+
<<~GRAPHQL
|
|
84
|
+
"#{description}"
|
|
85
|
+
input #{name}Relation {
|
|
86
|
+
"The relation that is checked."
|
|
87
|
+
relation: String!
|
|
88
|
+
|
|
89
|
+
"The comparison operator to test against the amount."
|
|
90
|
+
operator: #{SQL_OPERATOR} = #{DEFAULT_HAS_OPERATOR}
|
|
91
|
+
|
|
92
|
+
"The amount to test."
|
|
93
|
+
amount: Int = #{DEFAULT_HAS_AMOUNT}
|
|
94
|
+
|
|
95
|
+
"Additional condition logic."
|
|
96
|
+
condition: #{name}HasCondition
|
|
97
|
+
}
|
|
98
|
+
GRAPHQL
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def sql_operator
|
|
102
|
+
values = OPERATORS.map { |value, description| %( "#{description}"\n #{value}) }
|
|
103
|
+
<<~GRAPHQL
|
|
104
|
+
"The available SQL operators that are used to filter query results."
|
|
105
|
+
enum #{SQL_OPERATOR} {
|
|
106
|
+
#{values.join("\n\n")}
|
|
107
|
+
}
|
|
108
|
+
GRAPHQL
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -36,9 +36,17 @@ module Lighthouse
|
|
|
36
36
|
include Directive
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Its instances, built with (arg_node, directive_node), answer
|
|
40
|
+
# `manipulate_arg_definition(document_ast, field_node, type_node)` with the argument node.
|
|
39
41
|
module ArgManipulator
|
|
40
42
|
include Directive
|
|
41
43
|
end
|
|
44
|
+
|
|
45
|
+
# Its instances, built with (field_node, directive_node), answer
|
|
46
|
+
# `handle_field_builder(relation)` with the constrained relation.
|
|
47
|
+
module FieldBuilder
|
|
48
|
+
include Directive
|
|
49
|
+
end
|
|
42
50
|
end
|
|
43
51
|
|
|
44
52
|
class DirectiveRegistry
|
|
@@ -67,6 +75,9 @@ module Lighthouse
|
|
|
67
75
|
def resolution_names: () -> Array[String]
|
|
68
76
|
def arg_builder_class: (String | Symbol name) -> Class?
|
|
69
77
|
def arg_builder_names: () -> Array[String]
|
|
78
|
+
def arg_manipulator_class: (String | Symbol name) -> Class?
|
|
79
|
+
def arg_manipulator_classes: () -> Array[Class]
|
|
80
|
+
def field_builder_class: (String | Symbol name) -> Class?
|
|
70
81
|
|
|
71
82
|
# { "name" => "directive @name(...) on ..." }
|
|
72
83
|
def directive_definitions: () -> Hash[String, String]
|
|
@@ -83,9 +94,11 @@ module Lighthouse
|
|
|
83
94
|
PROGRAMMATIC_TYPES_FILE: String
|
|
84
95
|
|
|
85
96
|
# Writes both files into `output_dir` and returns their paths.
|
|
86
|
-
def self?.write: (output_dir: path, ?
|
|
97
|
+
def self?.write: (output_dir: path, ?schema_path: path?, ?registry: DirectiveRegistry) -> Array[String]
|
|
98
|
+
# Config.ide_helper's rewrite, on boot and every reload.
|
|
99
|
+
def self?.refresh: (?output_dir: path) -> Array[String]
|
|
87
100
|
def self?.directives_sdl: (?String sdl, ?registry: DirectiveRegistry) -> String
|
|
88
|
-
def self?.programmatic_types_sdl: (String sdl, ?registry: DirectiveRegistry) -> String
|
|
101
|
+
def self?.programmatic_types_sdl: (String sdl, ?registry: DirectiveRegistry, ?directives: String) -> String
|
|
89
102
|
end
|
|
90
103
|
|
|
91
104
|
module GraphQL
|
|
@@ -107,7 +120,7 @@ module Lighthouse
|
|
|
107
120
|
# `ctx`: the query's context, which @builder on an argument receives.
|
|
108
121
|
def apply_where_filters: (relation relation, untyped args, ?context? ctx) -> relation
|
|
109
122
|
def apply_arg_builders: (relation relation, arguments args_h, ?context? ctx) -> relation
|
|
110
|
-
def
|
|
123
|
+
def apply_field_builders: (relation relation, ?arguments args_h, ?context? ctx) -> relation
|
|
111
124
|
def constantize_model: (String? name) -> Class?
|
|
112
125
|
def apply_scopes: (relation relation) -> relation
|
|
113
126
|
|
data/sig/lighthouse/graphql.rbs
CHANGED
|
@@ -9,16 +9,60 @@ module Lighthouse
|
|
|
9
9
|
type schema = untyped
|
|
10
10
|
|
|
11
11
|
module RbLightHouse
|
|
12
|
-
# The schema built from the SDL
|
|
12
|
+
# The schema built from the SDL `schema_path` (default: Config.schema_path) and its imports.
|
|
13
13
|
# `options` takes :base_types, :base_schema and the schema's own options.
|
|
14
|
-
def self.get_schema: (?
|
|
14
|
+
def self.get_schema: (?schema_path: path?, ?options: Hash[Symbol, untyped]) -> schema
|
|
15
15
|
def self.configure: () ?{ (singleton(Lighthouse::Config) config) -> void } -> void
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
module SchemaFactory
|
|
19
|
-
def self?.build_base_schema: (?
|
|
19
|
+
def self?.build_base_schema: (?schema_path: path?, ?sdl: String?, ?base_types: Hash[untyped, untyped],
|
|
20
20
|
?schema_options: Hash[Symbol, untyped], **untyped extra_options) -> schema
|
|
21
|
-
def self?.load_sdl: (path?
|
|
21
|
+
def self?.load_sdl: (?path? schema_path) -> String
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Reads the schema from its entrypoint, pasting in what it #imports.
|
|
25
|
+
class SchemaStitcher
|
|
26
|
+
IMPORT: String
|
|
27
|
+
|
|
28
|
+
attr_reader root_schema_path: String
|
|
29
|
+
|
|
30
|
+
def initialize: (path root_schema_path) -> void
|
|
31
|
+
def schema_string: () -> String
|
|
32
|
+
# Every file the schema is read from, the entrypoint first.
|
|
33
|
+
def files: () -> Array[String]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The schema document the manipulation phase works on: each type defined once.
|
|
37
|
+
class DocumentAST
|
|
38
|
+
KEYWORDS: Hash[Class, String]
|
|
39
|
+
EXTENSIONS: Array[Class]
|
|
40
|
+
|
|
41
|
+
attr_reader types: Hash[String, untyped]
|
|
42
|
+
attr_reader type_extensions: Hash[String, Array[untyped]]
|
|
43
|
+
attr_reader directives: Hash[String, untyped]
|
|
44
|
+
attr_reader schema_definitions: Array[untyped]
|
|
45
|
+
|
|
46
|
+
def self.from_source: (String sdl) -> DocumentAST
|
|
47
|
+
def self.from_document: (untyped document) -> DocumentAST
|
|
48
|
+
|
|
49
|
+
def initialize: () -> void
|
|
50
|
+
def add: (untyped definition) -> untyped
|
|
51
|
+
# Adds a type, or replaces the one of the same name.
|
|
52
|
+
def put_type: (untyped definition) -> self
|
|
53
|
+
# Adds the type parsed from the SDL (or the block's), unless one of the name exists.
|
|
54
|
+
def define_type: (String name, ?String? sdl) ?{ () -> String } -> untyped
|
|
55
|
+
def type?: (String name) -> bool
|
|
56
|
+
def type_names: () -> Array[String]
|
|
57
|
+
def to_document: () -> untyped
|
|
58
|
+
def to_sdl: () -> String
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The manipulation phase: extensions merged, then the type, field and argument manipulators.
|
|
62
|
+
module ASTBuilder
|
|
63
|
+
ROOT_TYPES: Array[String]
|
|
64
|
+
|
|
65
|
+
def self?.build: (String sdl, ?registry: DirectiveRegistry) -> String
|
|
22
66
|
end
|
|
23
67
|
|
|
24
68
|
# Apollo's Automatic Persisted Queries, and a manifest of trusted operations.
|