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.
Files changed (63) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +47 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +134 -0
  5. data/docs/authorization.md +109 -0
  6. data/docs/best-practices.md +75 -0
  7. data/docs/configuration.md +70 -0
  8. data/docs/custom-directives.md +129 -0
  9. data/docs/directives.md +298 -0
  10. data/docs/federation.md +108 -0
  11. data/docs/filtering-and-ordering.md +128 -0
  12. data/docs/getting-started.md +152 -0
  13. data/docs/relationships.md +95 -0
  14. data/lib/lighthouse/config.rb +112 -0
  15. data/lib/lighthouse/contracts/arg_builder.rb +17 -0
  16. data/lib/lighthouse/contracts/arg_manipulator.rb +17 -0
  17. data/lib/lighthouse/contracts/directive.rb +17 -0
  18. data/lib/lighthouse/contracts/field_manipulator.rb +17 -0
  19. data/lib/lighthouse/contracts/field_middleware.rb +16 -0
  20. data/lib/lighthouse/contracts/field_resolver.rb +18 -0
  21. data/lib/lighthouse/dataloader_sources/association_loader.rb +25 -0
  22. data/lib/lighthouse/dataloader_sources/model_loader.rb +19 -0
  23. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +86 -0
  24. data/lib/lighthouse/directive_resolvers/all_resolver.rb +73 -0
  25. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +44 -0
  26. data/lib/lighthouse/directive_resolvers/base.rb +92 -0
  27. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +56 -0
  28. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +47 -0
  29. data/lib/lighthouse/directive_resolvers/builder_applier.rb +63 -0
  30. data/lib/lighthouse/directive_resolvers/can_resolver.rb +86 -0
  31. data/lib/lighthouse/directive_resolvers/count_resolver.rb +56 -0
  32. data/lib/lighthouse/directive_resolvers/field_resolver.rb +78 -0
  33. data/lib/lighthouse/directive_resolvers/find_resolver.rb +65 -0
  34. data/lib/lighthouse/directive_resolvers/first_resolver.rb +53 -0
  35. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +32 -0
  36. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +109 -0
  37. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +43 -0
  38. data/lib/lighthouse/directive_resolvers/method_resolver.rb +35 -0
  39. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +147 -0
  40. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +35 -0
  41. data/lib/lighthouse/directive_resolvers/resolution.rb +35 -0
  42. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +169 -0
  43. data/lib/lighthouse/directives/arguments/base.rb +40 -0
  44. data/lib/lighthouse/directives/arguments/filters.rb +101 -0
  45. data/lib/lighthouse/directives/arguments/order_by.rb +48 -0
  46. data/lib/lighthouse/directives/registry.rb +168 -0
  47. data/lib/lighthouse/directives/relation_directive.rb +332 -0
  48. data/lib/lighthouse/directives/where_conditions_directive.rb +208 -0
  49. data/lib/lighthouse/graphql/version.rb +7 -0
  50. data/lib/lighthouse/graphql.rb +8 -0
  51. data/lib/lighthouse/rb_lighthouse.rb +88 -0
  52. data/lib/lighthouse/reference_resolver.rb +24 -0
  53. data/lib/lighthouse/schema_factory.rb +46 -0
  54. data/lib/lighthouse/schema_generator.rb +244 -0
  55. data/lib/lighthouse/schema_implementation.rb +119 -0
  56. data/lib/lighthouse/sdl_loader.rb +27 -0
  57. data/lib/lighthouse/support/authorization.rb +26 -0
  58. data/lib/lighthouse/support/directive_args.rb +33 -0
  59. data/lib/lighthouse/support/model_resolver.rb +61 -0
  60. data/lib/lighthouse/support/naming.rb +71 -0
  61. data/lib/lighthouse/where_conditions/operator_map.rb +35 -0
  62. data/lib/lighthouse-graphql.rb +10 -0
  63. 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