lighthouse-graphql 0.1.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 +7 -0
- data/CHANGELOG.md +47 -0
- data/LICENSE.txt +21 -0
- data/README.md +134 -0
- data/docs/authorization.md +109 -0
- data/docs/best-practices.md +75 -0
- data/docs/configuration.md +70 -0
- data/docs/custom-directives.md +129 -0
- data/docs/directives.md +298 -0
- data/docs/federation.md +108 -0
- data/docs/filtering-and-ordering.md +128 -0
- data/docs/getting-started.md +152 -0
- data/docs/relationships.md +95 -0
- data/lib/lighthouse/config.rb +112 -0
- data/lib/lighthouse/contracts/arg_builder.rb +17 -0
- data/lib/lighthouse/contracts/arg_manipulator.rb +17 -0
- data/lib/lighthouse/contracts/directive.rb +17 -0
- data/lib/lighthouse/contracts/field_manipulator.rb +17 -0
- data/lib/lighthouse/contracts/field_middleware.rb +16 -0
- data/lib/lighthouse/contracts/field_resolver.rb +18 -0
- data/lib/lighthouse/dataloader_sources/association_loader.rb +25 -0
- data/lib/lighthouse/dataloader_sources/model_loader.rb +19 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +86 -0
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +73 -0
- data/lib/lighthouse/directive_resolvers/auth_resolver.rb +44 -0
- data/lib/lighthouse/directive_resolvers/base.rb +92 -0
- data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +56 -0
- data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +47 -0
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +63 -0
- data/lib/lighthouse/directive_resolvers/can_resolver.rb +86 -0
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +56 -0
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +78 -0
- data/lib/lighthouse/directive_resolvers/find_resolver.rb +65 -0
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +53 -0
- data/lib/lighthouse/directive_resolvers/guard_resolver.rb +32 -0
- data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +109 -0
- data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +43 -0
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +35 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +147 -0
- data/lib/lighthouse/directive_resolvers/rename_resolver.rb +35 -0
- data/lib/lighthouse/directive_resolvers/resolution.rb +35 -0
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +169 -0
- data/lib/lighthouse/directives/arguments/base.rb +40 -0
- data/lib/lighthouse/directives/arguments/filters.rb +101 -0
- data/lib/lighthouse/directives/arguments/order_by.rb +48 -0
- data/lib/lighthouse/directives/registry.rb +168 -0
- data/lib/lighthouse/directives/relation_directive.rb +332 -0
- data/lib/lighthouse/directives/where_conditions_directive.rb +208 -0
- data/lib/lighthouse/graphql/version.rb +7 -0
- data/lib/lighthouse/graphql.rb +8 -0
- data/lib/lighthouse/rb_lighthouse.rb +88 -0
- data/lib/lighthouse/reference_resolver.rb +24 -0
- data/lib/lighthouse/schema_factory.rb +46 -0
- data/lib/lighthouse/schema_generator.rb +244 -0
- data/lib/lighthouse/schema_implementation.rb +119 -0
- data/lib/lighthouse/sdl_loader.rb +27 -0
- data/lib/lighthouse/support/authorization.rb +26 -0
- data/lib/lighthouse/support/directive_args.rb +33 -0
- data/lib/lighthouse/support/model_resolver.rb +61 -0
- data/lib/lighthouse/support/naming.rb +71 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +35 -0
- data/lib/lighthouse-graphql.rb +10 -0
- metadata +195 -0
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Lighthouse
|
|
6
|
+
module GraphQL
|
|
7
|
+
module DirectiveResolvers
|
|
8
|
+
# @hasOne(relation: "name") — resolve a one-to-one association on the parent
|
|
9
|
+
# object. Mirrors @belongsTo (single record) and batch-loads through the
|
|
10
|
+
# AssociationLoader when a dataloader is available.
|
|
11
|
+
class HasOneDirectiveResolver < BaseDirectiveResolver
|
|
12
|
+
def self.directive_name = 'hasOne'
|
|
13
|
+
def self.definition = 'directive @hasOne(relation: String, scopes: [String!]) on FIELD_DEFINITION'
|
|
14
|
+
|
|
15
|
+
def define_resolver(target_type_class)
|
|
16
|
+
field_name = @field_def.name
|
|
17
|
+
assoc_name = directive_arg('relation') || field_name
|
|
18
|
+
method_name = "resolve_field_#{field_name}"
|
|
19
|
+
|
|
20
|
+
resolver = lambda do |obj, _arguments, ctx|
|
|
21
|
+
next nil if obj.nil?
|
|
22
|
+
|
|
23
|
+
if ctx && ctx.respond_to?(:dataloader) && ctx.dataloader
|
|
24
|
+
loader = ctx.dataloader.with(Lighthouse::GraphQL::DataloaderSources::AssociationLoader, obj.class, assoc_name)
|
|
25
|
+
loader.load(obj)
|
|
26
|
+
elsif obj.respond_to?(assoc_name)
|
|
27
|
+
obj.public_send(assoc_name)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
target_type_class.define_singleton_method(method_name, &resolver)
|
|
32
|
+
@field_def.resolve_proc = resolver if @field_def.respond_to?(:resolve_proc=)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def directive_arg(name)
|
|
38
|
+
Lighthouse::Support::DirectiveArgs.literal(@directive_node, name)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Lighthouse
|
|
6
|
+
module GraphQL
|
|
7
|
+
module DirectiveResolvers
|
|
8
|
+
# @method(name: "someMethod") — resolve the field by calling a method on the
|
|
9
|
+
# parent object. Mirrors PHP Lighthouse's @method. Defaults to the field's
|
|
10
|
+
# own (snake_cased) name when `name` is omitted.
|
|
11
|
+
#
|
|
12
|
+
# labels: [String!]! @method(name: "label_list")
|
|
13
|
+
# canReply: Boolean @method(name: "can_reply?")
|
|
14
|
+
class MethodDirectiveResolver < BaseDirectiveResolver
|
|
15
|
+
def self.directive_name = 'method'
|
|
16
|
+
def self.definition = 'directive @method(name: String) on FIELD_DEFINITION'
|
|
17
|
+
|
|
18
|
+
def define_resolver(target_type_class)
|
|
19
|
+
resolver_method = "resolve_field_#{@field_def.name}"
|
|
20
|
+
target_method = (Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'name') ||
|
|
21
|
+
Lighthouse::Support::Naming.attribute(@field_def.name)).to_s
|
|
22
|
+
|
|
23
|
+
resolver = lambda do |obj, _arguments, _ctx|
|
|
24
|
+
next nil if obj.nil?
|
|
25
|
+
|
|
26
|
+
obj.respond_to?(target_method) ? obj.public_send(target_method) : nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
target_type_class.define_singleton_method(resolver_method, &resolver)
|
|
30
|
+
@field_def.resolve_proc = resolver if @field_def.respond_to?(:resolve_proc=)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'ostruct'
|
|
4
|
+
require_relative 'base'
|
|
5
|
+
require_relative 'builder_applier'
|
|
6
|
+
|
|
7
|
+
module Lighthouse
|
|
8
|
+
module GraphQL
|
|
9
|
+
module DirectiveResolvers
|
|
10
|
+
class PaginateDirectiveResolver < BaseDirectiveResolver
|
|
11
|
+
class PaginatorResult
|
|
12
|
+
attr_reader :paginator_info, :data
|
|
13
|
+
|
|
14
|
+
def initialize(paginator_info:, data:)
|
|
15
|
+
@paginator_info = paginator_info
|
|
16
|
+
@data = data
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def paginatorInfo
|
|
20
|
+
@paginator_info
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def define_resolver(target_type_class)
|
|
25
|
+
field_name = @field_def.name
|
|
26
|
+
method_name = "resolve_field_#{field_name}"
|
|
27
|
+
|
|
28
|
+
# Resolve model name now, but constantize lazily at call time so
|
|
29
|
+
# Rails autoloading (Zeitwerk) can load the model after startup.
|
|
30
|
+
model_name_static = extract_model_name || extract_model_name_from_field_type
|
|
31
|
+
# If the field was transformed to a Paginator type (e.g. InboxPaginator),
|
|
32
|
+
# derive the underlying model name by stripping a trailing "Paginator".
|
|
33
|
+
model_name_static = model_name_static.to_s.sub(/Paginator\z/, '')
|
|
34
|
+
# Closure to lookup the model class lazily
|
|
35
|
+
model_lookup = proc { constantize_model(model_name_static) }
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Determine whether this paginate directive requested a cursor/connection
|
|
40
|
+
type_arg = extract_type_name
|
|
41
|
+
is_connection_type = type_arg && %w[CONNECTION CURSOR].include?(type_arg.to_s.upcase)
|
|
42
|
+
|
|
43
|
+
# Paginator object builder (responds to both `paginatorInfo` and `paginator_info`)
|
|
44
|
+
paginator_builder = proc do |pinfo, data|
|
|
45
|
+
paginator_info_object = OpenStruct.new(pinfo)
|
|
46
|
+
# Ensure both camelCase and underscored-methods can be resolved by fallback
|
|
47
|
+
if pinfo.is_a?(Hash)
|
|
48
|
+
underscored = {}
|
|
49
|
+
pinfo.each_key do |k|
|
|
50
|
+
key = k.to_s
|
|
51
|
+
snake = key.underscore
|
|
52
|
+
underscored[snake] = pinfo[k] unless pinfo.key?(snake)
|
|
53
|
+
end
|
|
54
|
+
underscored.each { |kk, vv| paginator_info_object[kk] = vv }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
PaginatorResult.new(paginator_info: paginator_info_object, data: data)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Define owner singleton method to support graphql-ruby's execution
|
|
61
|
+
resolver_instance = self
|
|
62
|
+
field_ast_node = @field_def.ast_node
|
|
63
|
+
target_type_class.define_singleton_method(method_name) do |obj, arguments, ctx|
|
|
64
|
+
model_class = model_lookup.call
|
|
65
|
+
return [] unless model_class
|
|
66
|
+
|
|
67
|
+
args_h = arguments.respond_to?(:to_h) ? arguments.to_h : {}
|
|
68
|
+
|
|
69
|
+
limit = args_h[:first]
|
|
70
|
+
offset = 0
|
|
71
|
+
if args_h[:after]
|
|
72
|
+
begin
|
|
73
|
+
decoded = Base64.decode64(args_h[:after])
|
|
74
|
+
if decoded.start_with?('offset:')
|
|
75
|
+
offset = decoded.split(':', 2)[1].to_i
|
|
76
|
+
end
|
|
77
|
+
rescue StandardError
|
|
78
|
+
offset = 0
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
relation = model_class.all
|
|
83
|
+
|
|
84
|
+
relation = DirectiveResolvers::BuilderApplier.apply(relation, field_ast_node, args_h, ctx, resolver_instance)
|
|
85
|
+
|
|
86
|
+
relation = DirectiveResolvers::WhereConditionsApplier.apply(relation, args_h[:where] || args_h['where'])
|
|
87
|
+
relation = relation.offset(offset) if offset > 0
|
|
88
|
+
relation = relation.limit(limit) if limit
|
|
89
|
+
|
|
90
|
+
if is_connection_type
|
|
91
|
+
::GraphQL::Pagination::ArrayConnection.new(relation, context: ctx, parent: nil, field: nil, arguments: {})
|
|
92
|
+
else
|
|
93
|
+
data = relation.to_a
|
|
94
|
+
per_page = limit
|
|
95
|
+
total = relation.count
|
|
96
|
+
paginator_info = {
|
|
97
|
+
count: data.size,
|
|
98
|
+
currentPage: 1,
|
|
99
|
+
firstItem: data.any? ? (offset + 1) : nil,
|
|
100
|
+
hasMorePages: (offset + data.size) < total,
|
|
101
|
+
lastItem: data.any? ? (offset + data.size) : nil,
|
|
102
|
+
lastPage: per_page && per_page > 0 ? (total.to_f / per_page).ceil : 1,
|
|
103
|
+
perPage: per_page || data.size,
|
|
104
|
+
total: total
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
paginator_builder.call(paginator_info, data)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Attach a field-level resolve_proc that delegates to the generated
|
|
112
|
+
# singleton resolver. This keeps runtime dispatch consistent with
|
|
113
|
+
# SchemaImplementation (which checks for resolve_field_* first).
|
|
114
|
+
if @field_def.respond_to?(:resolve_proc=)
|
|
115
|
+
@field_def.resolve_proc = ->(obj, arguments, ctx) do
|
|
116
|
+
target_type_class.public_send(method_name, obj, arguments, ctx)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def extract_model_name
|
|
124
|
+
arg = @directive_node.arguments&.find { |a| a.name == 'model' }
|
|
125
|
+
return nil unless arg
|
|
126
|
+
arg.value.respond_to?(:value) ? arg.value.value : arg.value
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def extract_type_name
|
|
131
|
+
arg = @directive_node.arguments&.find { |a| a.name == 'type' }
|
|
132
|
+
return nil unless arg
|
|
133
|
+
arg.value.respond_to?(:value) ? arg.value.value : arg.value
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def extract_model_name_from_field_type
|
|
137
|
+
unwrapped = @field_def.type.respond_to?(:unwrap) ? @field_def.type.unwrap : @field_def.type
|
|
138
|
+
return unwrapped.graphql_name if unwrapped.respond_to?(:graphql_name) && unwrapped.graphql_name
|
|
139
|
+
return unwrapped.name if unwrapped.respond_to?(:name) && unwrapped.name
|
|
140
|
+
|
|
141
|
+
# fallback for late-bound signatures like "InboxPaginator!"
|
|
142
|
+
@field_def.type.to_type_signature.to_s.gsub(/[!\[\]]/, '')
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Lighthouse
|
|
6
|
+
module GraphQL
|
|
7
|
+
module DirectiveResolvers
|
|
8
|
+
# @rename(attribute: "snake_case_name") — explicit override of the default
|
|
9
|
+
# field reader. Reads the named attribute off the parent object regardless
|
|
10
|
+
# of the GraphQL field name.
|
|
11
|
+
class RenameDirectiveResolver < BaseDirectiveResolver
|
|
12
|
+
def self.directive_name = 'rename'
|
|
13
|
+
def self.definition = 'directive @rename(attribute: String!) on FIELD_DEFINITION | INPUT_FIELD_DEFINITION'
|
|
14
|
+
|
|
15
|
+
def define_resolver(target_type_class)
|
|
16
|
+
method_name = "resolve_field_#{@field_def.name}"
|
|
17
|
+
attribute = Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'attribute').to_s
|
|
18
|
+
|
|
19
|
+
resolver = lambda do |obj, _arguments, _ctx|
|
|
20
|
+
next nil if obj.nil?
|
|
21
|
+
|
|
22
|
+
if obj.is_a?(Hash)
|
|
23
|
+
obj[attribute] || obj[attribute.to_sym]
|
|
24
|
+
elsif obj.respond_to?(attribute)
|
|
25
|
+
obj.public_send(attribute)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
target_type_class.define_singleton_method(method_name, &resolver)
|
|
30
|
+
@field_def.resolve_proc = resolver if @field_def.respond_to?(:resolve_proc=)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lighthouse
|
|
4
|
+
module GraphQL
|
|
5
|
+
module DirectiveResolvers
|
|
6
|
+
# Helper for "middleware" directives (@guard, @can) that need to wrap a
|
|
7
|
+
# field's existing resolver.
|
|
8
|
+
module Resolution
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Capture the field's current resolver (generated singleton method,
|
|
12
|
+
# resolve_proc, or the default attribute reader) as a callable
|
|
13
|
+
# (obj, args, ctx) -> value, so it can be wrapped. The returned callable
|
|
14
|
+
# is unaffected by subsequently redefining the singleton method.
|
|
15
|
+
def capture(target_type_class, method_name, field_def, field_name)
|
|
16
|
+
original_singleton = target_type_class.method(method_name) if target_type_class.respond_to?(method_name)
|
|
17
|
+
original_proc = field_def.resolve_proc if field_def.respond_to?(:resolve_proc) && field_def.resolve_proc
|
|
18
|
+
|
|
19
|
+
lambda do |obj, arguments, ctx|
|
|
20
|
+
if original_singleton
|
|
21
|
+
original_singleton.call(obj, arguments, ctx)
|
|
22
|
+
elsif original_proc
|
|
23
|
+
original_proc.call(obj, arguments, ctx)
|
|
24
|
+
elsif obj.respond_to?(field_name)
|
|
25
|
+
obj.public_send(field_name)
|
|
26
|
+
else
|
|
27
|
+
snake = Lighthouse::Support::Naming.attribute(field_name)
|
|
28
|
+
obj.respond_to?(snake) ? obj.public_send(snake) : nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lighthouse
|
|
4
|
+
module GraphQL
|
|
5
|
+
module DirectiveResolvers
|
|
6
|
+
class WhereConditionsApplier
|
|
7
|
+
# Operator semantics live in Lighthouse::WhereConditions::OperatorMap so
|
|
8
|
+
# they can be shared with the simple argument-filter directives.
|
|
9
|
+
SUPPORTED_OPERATORS = Lighthouse::WhereConditions::OperatorMap::OPERATORS
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
def apply(relation, value)
|
|
13
|
+
return relation unless relation.respond_to?(:where)
|
|
14
|
+
return relation if value.nil?
|
|
15
|
+
|
|
16
|
+
condition = value.is_a?(Hash) ? value.deep_symbolize_keys : symbolized(value)
|
|
17
|
+
return relation unless condition.is_a?(Hash)
|
|
18
|
+
|
|
19
|
+
result = relation
|
|
20
|
+
|
|
21
|
+
if condition.key?(:AND)
|
|
22
|
+
Array(condition[:AND]).each do |sub|
|
|
23
|
+
result = apply(result, sub)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
if condition.key?(:OR)
|
|
28
|
+
or_rel = nil
|
|
29
|
+
Array(condition[:OR]).each do |sub|
|
|
30
|
+
sub_scope = apply(relation, sub)
|
|
31
|
+
or_rel = or_rel ? or_rel.or(sub_scope) : sub_scope
|
|
32
|
+
end
|
|
33
|
+
result = or_rel if or_rel
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if condition.key?(:HAS)
|
|
37
|
+
has_cond = condition[:HAS]
|
|
38
|
+
if has_cond.is_a?(Hash) && has_cond[:relation]
|
|
39
|
+
result = apply_has(result, has_cond)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if condition.key?(:column)
|
|
44
|
+
result = apply_single_condition(result, condition)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
result
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def apply_has(relation, has_cond)
|
|
51
|
+
relation_name = has_cond[:relation].to_s
|
|
52
|
+
return relation unless relation_name.present?
|
|
53
|
+
|
|
54
|
+
resolved_relation_name = resolve_relation_name(relation, relation_name)
|
|
55
|
+
return relation unless resolved_relation_name.present?
|
|
56
|
+
|
|
57
|
+
source_klass = relation.respond_to?(:klass) ? relation.klass : relation
|
|
58
|
+
reflection = source_klass.respond_to?(:reflect_on_association) ? source_klass.reflect_on_association(resolved_relation_name.to_sym) : nil
|
|
59
|
+
return relation unless reflection&.klass
|
|
60
|
+
|
|
61
|
+
if has_cond[:condition].present?
|
|
62
|
+
child_scope = reflection.klass.all
|
|
63
|
+
child_scope = apply(child_scope, has_cond[:condition])
|
|
64
|
+
|
|
65
|
+
# Use an EXISTS-style filter to safely apply conditions from the related model.
|
|
66
|
+
table_name = reflection.klass.table_name
|
|
67
|
+
relation.joins(resolved_relation_name.to_sym)
|
|
68
|
+
.where(table_name => { id: child_scope.select(:id) })
|
|
69
|
+
.distinct
|
|
70
|
+
else
|
|
71
|
+
relation.joins(resolved_relation_name.to_sym).distinct
|
|
72
|
+
end
|
|
73
|
+
rescue StandardError
|
|
74
|
+
relation
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def resolve_relation_name(relation, relation_name)
|
|
78
|
+
candidate_names = [relation_name.to_s, relation_name.to_s.singularize, relation_name.to_s.pluralize].uniq
|
|
79
|
+
|
|
80
|
+
candidate_names.each do |candidate|
|
|
81
|
+
next if candidate.blank?
|
|
82
|
+
return candidate if association_exists?(relation, candidate)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
nil
|
|
86
|
+
rescue StandardError
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def association_exists?(relation, name)
|
|
91
|
+
klass = relation.respond_to?(:klass) ? relation.klass : relation
|
|
92
|
+
return false unless klass.respond_to?(:reflect_on_association)
|
|
93
|
+
|
|
94
|
+
klass.reflect_on_association(name.to_sym).present?
|
|
95
|
+
rescue StandardError
|
|
96
|
+
false
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def apply_has_argument(relation, arg_name, arg_value, relation_name = nil)
|
|
100
|
+
relation_name ||= derive_relation_name(arg_name)
|
|
101
|
+
return relation unless relation_name.present? && arg_value.present?
|
|
102
|
+
|
|
103
|
+
condition = arg_value.is_a?(Hash) ? arg_value : symbolized(arg_value)
|
|
104
|
+
has_payload = { relation: relation_name, condition: condition }
|
|
105
|
+
apply_has(relation, has_payload)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
private
|
|
109
|
+
|
|
110
|
+
def derive_relation_name(arg_name)
|
|
111
|
+
Lighthouse::Support::Naming.derive_relation_name(arg_name)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def apply_single_condition(relation, condition)
|
|
115
|
+
col = condition[:column]
|
|
116
|
+
op = condition[:operator]&.to_s&.upcase || 'EQ'
|
|
117
|
+
value = condition[:value]
|
|
118
|
+
|
|
119
|
+
return relation if col.blank? || !relation.respond_to?(:where)
|
|
120
|
+
|
|
121
|
+
column_name = resolve_column_name(col)
|
|
122
|
+
return relation if column_name.blank?
|
|
123
|
+
|
|
124
|
+
case op
|
|
125
|
+
when 'IS_NULL'
|
|
126
|
+
relation.where(column_name => nil)
|
|
127
|
+
when 'IS_NOT_NULL'
|
|
128
|
+
relation.where.not(column_name => nil)
|
|
129
|
+
when 'IN'
|
|
130
|
+
relation.where(column_name => Array(value))
|
|
131
|
+
when 'NIN'
|
|
132
|
+
relation.where.not(column_name => Array(value))
|
|
133
|
+
when 'CONTAINS'
|
|
134
|
+
relation.where(arel_table(relation)[column_name].matches("%#{value}%"))
|
|
135
|
+
when 'STARTS_WITH'
|
|
136
|
+
relation.where(arel_table(relation)[column_name].matches("#{value}%"))
|
|
137
|
+
when 'ENDS_WITH'
|
|
138
|
+
relation.where(arel_table(relation)[column_name].matches("%#{value}"))
|
|
139
|
+
else
|
|
140
|
+
if SUPPORTED_OPERATORS[op]
|
|
141
|
+
relation.where(arel_table(relation)[column_name].public_send(SUPPORTED_OPERATORS[op], value))
|
|
142
|
+
else
|
|
143
|
+
relation.where(column_name => value)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
rescue StandardError
|
|
147
|
+
relation
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def resolve_column_name(column)
|
|
151
|
+
Lighthouse::Support::Naming.column(column)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def arel_table(relation)
|
|
155
|
+
relation.klass.arel_table
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def symbolized(obj)
|
|
159
|
+
if obj.respond_to?(:to_h)
|
|
160
|
+
obj.to_h.deep_symbolize_keys
|
|
161
|
+
else
|
|
162
|
+
{}
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lighthouse
|
|
4
|
+
module GraphQL
|
|
5
|
+
module Directives
|
|
6
|
+
module Arguments
|
|
7
|
+
# Base class for argument-builder directives (@eq, @neq, @in, @notIn,
|
|
8
|
+
# @like, @where, @orderBy). Each receives the argument AST node and the
|
|
9
|
+
# directive AST node, and composes a constraint onto an ActiveRecord
|
|
10
|
+
# relation given the client-supplied value for that argument.
|
|
11
|
+
class Base
|
|
12
|
+
include Lighthouse::Contracts::ArgBuilder
|
|
13
|
+
|
|
14
|
+
def initialize(arg_node, directive_node)
|
|
15
|
+
@arg_node = arg_node
|
|
16
|
+
@directive_node = directive_node
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# @return relation
|
|
20
|
+
def handle_builder(_relation, _value)
|
|
21
|
+
raise NotImplementedError
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
# Column to filter on: the directive's `key:` argument if present,
|
|
27
|
+
# otherwise the snake_case form of the GraphQL argument name.
|
|
28
|
+
def column
|
|
29
|
+
Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'key') ||
|
|
30
|
+
Lighthouse::Support::Naming.attribute(@arg_node.name)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def arel_column(relation)
|
|
34
|
+
relation.klass.arel_table[column]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Lighthouse
|
|
6
|
+
module GraphQL
|
|
7
|
+
module Directives
|
|
8
|
+
module Arguments
|
|
9
|
+
# @eq — equality filter (the default). `field(status: String @eq)`
|
|
10
|
+
class Eq < Base
|
|
11
|
+
def self.directive_name = 'eq'
|
|
12
|
+
def self.definition = 'directive @eq(key: String) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION'
|
|
13
|
+
|
|
14
|
+
def handle_builder(relation, value)
|
|
15
|
+
return relation if value.nil?
|
|
16
|
+
|
|
17
|
+
relation.where(column => value)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# @neq — inequality filter.
|
|
22
|
+
class Neq < Base
|
|
23
|
+
def self.directive_name = 'neq'
|
|
24
|
+
def self.definition = 'directive @neq(key: String) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION'
|
|
25
|
+
|
|
26
|
+
def handle_builder(relation, value)
|
|
27
|
+
return relation if value.nil?
|
|
28
|
+
|
|
29
|
+
relation.where.not(column => value)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @in — value is one of a list.
|
|
34
|
+
class In < Base
|
|
35
|
+
def self.directive_name = 'in'
|
|
36
|
+
def self.definition = 'directive @in(key: String) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION'
|
|
37
|
+
|
|
38
|
+
def handle_builder(relation, value)
|
|
39
|
+
return relation if value.nil?
|
|
40
|
+
|
|
41
|
+
relation.where(column => Array(value))
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# @notIn — value is none of a list.
|
|
46
|
+
class NotIn < Base
|
|
47
|
+
def self.directive_name = 'notIn'
|
|
48
|
+
def self.definition = 'directive @notIn(key: String) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION'
|
|
49
|
+
|
|
50
|
+
def handle_builder(relation, value)
|
|
51
|
+
return relation if value.nil?
|
|
52
|
+
|
|
53
|
+
relation.where.not(column => Array(value))
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# @like — SQL LIKE with the value wrapped in wildcards.
|
|
58
|
+
class Like < Base
|
|
59
|
+
def self.directive_name = 'like'
|
|
60
|
+
def self.definition = 'directive @like(key: String) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION'
|
|
61
|
+
|
|
62
|
+
def handle_builder(relation, value)
|
|
63
|
+
return relation if value.nil?
|
|
64
|
+
|
|
65
|
+
relation.where(arel_column(relation).matches("%#{value}%"))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @where — generic operator filter, sharing the SQLOperator semantics with
|
|
70
|
+
# the @whereConditions extension. `field(age: Int @where(operator: "GT"))`
|
|
71
|
+
class Where < Base
|
|
72
|
+
def self.directive_name = 'where'
|
|
73
|
+
def self.definition = 'directive @where(key: String, operator: String) on ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION'
|
|
74
|
+
|
|
75
|
+
def handle_builder(relation, value)
|
|
76
|
+
return relation if value.nil?
|
|
77
|
+
|
|
78
|
+
op = (Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'operator') || 'EQ').to_s.upcase
|
|
79
|
+
|
|
80
|
+
case op
|
|
81
|
+
when 'IN' then relation.where(column => Array(value))
|
|
82
|
+
when 'NIN' then relation.where.not(column => Array(value))
|
|
83
|
+
when 'IS_NULL' then relation.where(column => nil)
|
|
84
|
+
when 'IS_NOT_NULL' then relation.where.not(column => nil)
|
|
85
|
+
when 'CONTAINS' then relation.where(arel_column(relation).matches("%#{value}%"))
|
|
86
|
+
when 'STARTS_WITH' then relation.where(arel_column(relation).matches("#{value}%"))
|
|
87
|
+
when 'ENDS_WITH' then relation.where(arel_column(relation).matches("%#{value}"))
|
|
88
|
+
else
|
|
89
|
+
arel_method = Lighthouse::WhereConditions::OperatorMap[op]
|
|
90
|
+
if arel_method
|
|
91
|
+
relation.where(arel_column(relation).public_send(arel_method, value))
|
|
92
|
+
else
|
|
93
|
+
relation.where(column => value)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Lighthouse
|
|
6
|
+
module GraphQL
|
|
7
|
+
module Directives
|
|
8
|
+
module Arguments
|
|
9
|
+
# @orderBy — sort the result by one or more columns.
|
|
10
|
+
#
|
|
11
|
+
# The client value is a list of clauses, each `{ column:, order: }`
|
|
12
|
+
# (also accepts `{ field:, direction: }`). Clauses apply in order, so
|
|
13
|
+
# `[{column: NAME, order: ASC}, {column: AGE, order: DESC}]` sorts by
|
|
14
|
+
# name then age.
|
|
15
|
+
class OrderBy < Base
|
|
16
|
+
def self.directive_name = 'orderBy'
|
|
17
|
+
def self.definition = 'directive @orderBy(columns: [String!], relations: [String!]) on ARGUMENT_DEFINITION | FIELD_DEFINITION'
|
|
18
|
+
|
|
19
|
+
def handle_builder(relation, value)
|
|
20
|
+
normalize(value).reduce(relation) do |rel, clause|
|
|
21
|
+
col = Lighthouse::Support::Naming.column(clause[:column], rel)
|
|
22
|
+
next rel if col.nil? || col.empty?
|
|
23
|
+
|
|
24
|
+
direction = clause[:order].to_s.downcase == 'desc' ? :desc : :asc
|
|
25
|
+
rel.order(col => direction)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def normalize(value)
|
|
32
|
+
list = value.is_a?(Array) ? value : [value]
|
|
33
|
+
list.filter_map do |item|
|
|
34
|
+
hash = item.respond_to?(:to_h) ? item.to_h : item
|
|
35
|
+
next nil unless hash.is_a?(Hash)
|
|
36
|
+
|
|
37
|
+
symbolized = hash.transform_keys { |k| k.to_s.to_sym }
|
|
38
|
+
col = symbolized[:column] || symbolized[:field]
|
|
39
|
+
next nil if col.nil?
|
|
40
|
+
|
|
41
|
+
{ column: col, order: symbolized[:order] || symbolized[:direction] || 'ASC' }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|