lighthouse-graphql 0.1.1 → 0.2.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 (108) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +154 -1
  3. data/README.md +42 -2
  4. data/docs/authorization.md +116 -57
  5. data/docs/best-practices.md +16 -3
  6. data/docs/caching.md +161 -0
  7. data/docs/configuration.md +83 -4
  8. data/docs/custom-directives.md +5 -1
  9. data/docs/directives.md +117 -38
  10. data/docs/getting-started.md +84 -0
  11. data/docs/mutations.md +260 -0
  12. data/docs/performance.md +138 -0
  13. data/docs/relationships.md +80 -33
  14. data/docs/resolvers.md +179 -0
  15. data/docs/validation.md +132 -0
  16. data/lib/generators/lighthouse/builder_generator.rb +30 -0
  17. data/lib/generators/lighthouse/class_generator.rb +61 -0
  18. data/lib/generators/lighthouse/install/install_generator.rb +127 -0
  19. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
  20. data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
  21. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
  22. data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
  23. data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
  24. data/lib/generators/lighthouse/mutation_generator.rb +30 -0
  25. data/lib/generators/lighthouse/query_generator.rb +29 -0
  26. data/lib/generators/lighthouse/validator_generator.rb +36 -0
  27. data/lib/lighthouse/cache_control.rb +126 -0
  28. data/lib/lighthouse/caching.rb +68 -0
  29. data/lib/lighthouse/config.rb +85 -19
  30. data/lib/lighthouse/contracts/arg_builder.rb +8 -12
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
  32. data/lib/lighthouse/contracts/directive.rb +10 -14
  33. data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
  34. data/lib/lighthouse/contracts/field_middleware.rb +7 -11
  35. data/lib/lighthouse/contracts/field_resolver.rb +9 -13
  36. data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
  37. data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
  38. data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
  39. data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
  40. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
  41. data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
  42. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
  43. data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
  44. data/lib/lighthouse/directive_resolvers/base.rb +142 -86
  45. data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
  46. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
  47. data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
  48. data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
  49. data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
  50. data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
  51. data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
  52. data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
  53. data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
  54. data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
  55. data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
  56. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
  57. data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
  58. data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
  59. data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
  60. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
  61. data/lib/lighthouse/directives/arguments/base.rb +23 -31
  62. data/lib/lighthouse/directives/arguments/filters.rb +113 -93
  63. data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
  64. data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
  65. data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
  66. data/lib/lighthouse/directives/registry.rb +210 -141
  67. data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
  68. data/lib/lighthouse/errors.rb +27 -0
  69. data/lib/lighthouse/federation_directives.rb +53 -0
  70. data/lib/lighthouse/field_resolver_stack.rb +56 -0
  71. data/lib/lighthouse/generated_resolvers.rb +82 -0
  72. data/lib/lighthouse/graphql/version.rb +1 -1
  73. data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
  74. data/lib/lighthouse/ide_helper.rb +98 -0
  75. data/lib/lighthouse/namespaces.rb +21 -0
  76. data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
  77. data/lib/lighthouse/nested_mutations/executor.rb +149 -0
  78. data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
  79. data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
  80. data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
  81. data/lib/lighthouse/nested_mutations.rb +210 -0
  82. data/lib/lighthouse/pagination.rb +221 -0
  83. data/lib/lighthouse/persisted_queries.rb +167 -0
  84. data/lib/lighthouse/railtie.rb +26 -0
  85. data/lib/lighthouse/rb_lighthouse.rb +50 -29
  86. data/lib/lighthouse/reference_resolver.rb +13 -15
  87. data/lib/lighthouse/relation_batch.rb +156 -0
  88. data/lib/lighthouse/resolver_dispatch.rb +26 -0
  89. data/lib/lighthouse/schema_cache.rb +120 -0
  90. data/lib/lighthouse/schema_factory.rb +27 -37
  91. data/lib/lighthouse/schema_generator.rb +196 -239
  92. data/lib/lighthouse/schema_implementation.rb +51 -48
  93. data/lib/lighthouse/sdl_loader.rb +17 -20
  94. data/lib/lighthouse/support/arguments.rb +119 -0
  95. data/lib/lighthouse/support/authorization.rb +18 -19
  96. data/lib/lighthouse/support/batching.rb +16 -0
  97. data/lib/lighthouse/support/directive_args.rb +26 -24
  98. data/lib/lighthouse/support/model_resolver.rb +56 -56
  99. data/lib/lighthouse/support/naming.rb +55 -59
  100. data/lib/lighthouse/tasks/lighthouse.rake +33 -0
  101. data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
  102. metadata +51 -63
  103. data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
  104. data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
  105. data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
  106. data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
  107. data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
  108. data/lib/lighthouse/directives/relation_directive.rb +0 -332
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'base'
4
+
5
+ # @validator — validate arguments with a contract class before the field
6
+ # resolves, as PHP Lighthouse's validator classes do.
7
+ #
8
+ # type Mutation { updateUser(id: ID!, name: String): User @update @validator }
9
+ # input CreatePostInput @validator { title: String }
10
+ #
11
+ # On a field, the contract gets all the field's arguments; the class
12
+ # defaults to Validators::<ParentType>::<FieldName>Validator. On an input
13
+ # type it gets that input's values wherever the type is used — nested,
14
+ # and per item of a list — defaulting to Validators::<InputType>Validator.
15
+ # Classes are looked up in Config.namespaces[:validators].
16
+ #
17
+ # A contract is a Dry::Validation::Contract (values arrive with
18
+ # snake_case symbol keys; declare `option :graphql_context` to receive the
19
+ # GraphQL context), or any object whose `call(values)` returns such a
20
+ # result or a { key => [messages] } Hash. Failures raise one
21
+ # Lighthouse::ValidationError keyed by GraphQL argument path.
22
+ #
23
+ # Model validations need no directive: @create/@update report them the
24
+ # same way. Use @validator for rules that are not the model's — checks
25
+ # across arguments, arguments of a query, or input that never reaches a model.
26
+ class Lighthouse::GraphQL::DirectiveResolvers::ValidatorDirectiveResolver < Lighthouse::GraphQL::DirectiveResolvers::BaseDirectiveResolver
27
+ def self.directive_name = 'validator'
28
+ def self.definition = 'directive @validator(class: String) on FIELD_DEFINITION | INPUT_OBJECT'
29
+
30
+ # A default name, e.g. Mutation::SignUpValidator: always looked up under
31
+ # the validators namespaces, although it contains `::`.
32
+ Conventional = Struct.new(:name) do
33
+ def to_s = name
34
+ end
35
+
36
+ # One validation of a field: the GraphQL context the contracts receive,
37
+ # and the messages collected so far, keyed by argument path.
38
+ Pass = Struct.new(:ctx, :messages)
39
+
40
+ # Does any input type the field takes (at any depth) carry @validator?
41
+ def self.input_validators?(field_def, seen = Set.new)
42
+ field_def.arguments.each_value.any? do |argument|
43
+ type = argument.type.unwrap
44
+ next false unless type.kind.input_object? && seen.add?(type.graphql_name)
45
+
46
+ validator_directive(type) || input_validators?(type, seen)
47
+ end
48
+ end
49
+
50
+ def self.validator_directive(type)
51
+ type.ast_node&.directives&.find { |d| d.name == 'validator' }
52
+ end
53
+
54
+ def define_resolver(target_type_class)
55
+ me = self
56
+ field_contract =
57
+ (directive_arg('class') || Conventional.new("#{@type_def.graphql_name}::#{@field_def.name.to_s.camelize}Validator") if @directive_node)
58
+ inner = capture_inner(target_type_class)
59
+
60
+ install(target_type_class, lambda do |obj, args, ctx|
61
+ me.send(:validate!, field_contract, args, ctx)
62
+ inner.call(obj, args, ctx)
63
+ end)
64
+ end
65
+
66
+ protected
67
+
68
+ def validate!(field_contract, args, ctx)
69
+ values = Lighthouse::Support::Arguments.plain(args)
70
+ pass = Pass.new(ctx, {})
71
+ run(field_contract, values, [], @field_def.arguments, pass) if field_contract
72
+ validate_inputs(@field_def.arguments, values, [], pass)
73
+ raise Lighthouse::ValidationError.new(@field_def.name, pass.messages) unless pass.messages.empty?
74
+ end
75
+
76
+ def validate_inputs(argument_defs, values, prefix, pass)
77
+ by_keyword = Lighthouse::Support::Arguments.keyword_index(argument_defs)
78
+ values.each do |key, value|
79
+ argument = by_keyword[key.to_sym]
80
+ next if argument.nil? || value.nil?
81
+
82
+ type = argument.type.unwrap
83
+ next unless type.kind.input_object?
84
+
85
+ input_items(value, prefix + [argument.graphql_name]).each do |item, item_path|
86
+ validate_input(type, item, item_path, pass)
87
+ end
88
+ end
89
+ end
90
+
91
+ # One input object value: its own @validator contract, then its nested inputs.
92
+ def validate_input(type, values, path, pass)
93
+ directive = self.class.validator_directive(type)
94
+ if directive
95
+ name = Lighthouse::Support::DirectiveArgs.literal(directive, 'class') || Conventional.new("#{type.graphql_name}Validator")
96
+ run(name, values, path, type.arguments, pass)
97
+ end
98
+ validate_inputs(type.arguments, values, path, pass)
99
+ end
100
+
101
+ def run(class_name, values, prefix, argument_defs, pass)
102
+ contract = instantiate(validator_class(class_name), pass.ctx)
103
+ errors = error_hash(contract.call(values))
104
+
105
+ flatten(errors).each do |segments, list|
106
+ path = (prefix + graphql_path(segments, argument_defs)).join('.')
107
+ (pass.messages[path.empty? ? @field_def.name : path] ||= []).concat(list)
108
+ end
109
+ end
110
+
111
+ private
112
+
113
+ # A list value validates per item, its path extended by the index.
114
+ def input_items(value, path)
115
+ items = Lighthouse::Support::Arguments.list(value)
116
+ return [[value, path]] unless items
117
+
118
+ items.each_with_index.map { |item, i| [item, path + [i.to_s]] }
119
+ end
120
+
121
+ # A contract answers a { key => [messages] } Hash, or a Dry::Validation
122
+ # result whose message set converts to one.
123
+ def error_hash(result)
124
+ return result if result.is_a?(Hash)
125
+ return {} if result.respond_to?(:success?) && result.success?
126
+
127
+ message_set = result.errors
128
+ message_set.to_h
129
+ end
130
+
131
+ def validator_class(name)
132
+ namespaces = Lighthouse::Config.namespaces[:validators]
133
+ found =
134
+ if name.is_a?(Conventional)
135
+ namespaces.lazy.filter_map { |ns| Lighthouse::Support::ModelResolver.constantize("#{ns}::#{name}") }.first
136
+ else
137
+ Lighthouse::Support::ModelResolver.constantize(name, namespaces: namespaces)
138
+ end
139
+ found ||
140
+ raise(Lighthouse::DefinitionError,
141
+ "@validator on #{@type_def.graphql_name}.#{@field_def.name}: no validator class #{name} " \
142
+ "in #{Lighthouse::Config.namespaces[:validators].join(', ')}")
143
+ end
144
+
145
+ def instantiate(klass, ctx)
146
+ return klass unless klass.is_a?(Class)
147
+ return klass.new(graphql_context: ctx) if dry_option?(klass, :graphql_context)
148
+
149
+ klass.new
150
+ end
151
+
152
+ def dry_option?(klass, name)
153
+ klass.respond_to?(:dry_initializer) && klass.dry_initializer.options.any? { |option| option.target == name }
154
+ end
155
+
156
+ # Dry's nested errors hash as [[key, ...], [messages]] pairs; base errors
157
+ # use a nil key.
158
+ def flatten(errors, prefix = [])
159
+ errors.flat_map do |key, value|
160
+ path = key.nil? ? prefix : prefix + [key]
161
+ case value
162
+ when Hash then flatten(value, path)
163
+ when Array then flatten_messages(value, path)
164
+ else [[path, [value.to_s]]]
165
+ end
166
+ end
167
+ end
168
+
169
+ # A key with both its own messages and nested ones comes as
170
+ # [messages, { nested }].
171
+ def flatten_messages(value, path)
172
+ if value.length == 2 && value.first.is_a?(Array) && value.last.is_a?(Hash)
173
+ [[path, value.first]] + flatten(value.last, path)
174
+ else
175
+ [[path, value.map(&:to_s)]]
176
+ end
177
+ end
178
+
179
+ # Map snake_case contract keys back to the GraphQL argument names.
180
+ def graphql_path(segments, argument_defs)
181
+ defs = argument_defs
182
+ segments.map do |segment|
183
+ next segment.to_s if segment.is_a?(Integer)
184
+
185
+ argument = Lighthouse::Support::Arguments.keyword_index(defs)[segment.to_sym]
186
+ next segment.to_s.camelize(:lower) unless argument
187
+
188
+ type = argument.type.unwrap
189
+ defs = type.kind.input_object? ? type.arguments : {}
190
+ argument.graphql_name
191
+ end
192
+ end
193
+ end
@@ -1,168 +1,169 @@
1
1
  # frozen_string_literal: true
2
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
3
+ class Lighthouse::GraphQL::DirectiveResolvers::WhereConditionsApplier
4
+ # Operator semantics live in Lighthouse::WhereConditions::OperatorMap so
5
+ # they can be shared with the simple argument-filter directives.
6
+ SUPPORTED_OPERATORS = Lighthouse::WhereConditions::OperatorMap::OPERATORS
7
+ # graphql-ruby hands input objects over with Ruby-style keys, so the
8
+ # generated input's AND/OR/HAS fields arrive as :and/:or/:has.
9
+ LOGICAL_KEYS = { and: :AND, or: :OR, has: :HAS }.freeze
10
+
11
+ class << self
12
+ def apply(relation, value)
13
+ return relation unless relation.respond_to?(:where)
14
+
15
+ condition = normalize_condition(value)
16
+ return relation unless condition
17
+
18
+ # AND narrows the incoming scope step by step; each OR branch starts
19
+ # from the incoming scope, and their union replaces it.
20
+ result = apply_all(relation, condition[:AND])
21
+ result = apply_any(relation, condition[:OR]) || result
22
+ result = apply_has_condition(result, condition[:HAS])
23
+ result = apply_single_condition(result, condition) if condition.key?(:column)
24
+ result
25
+ end
26
+
27
+ def apply_has(relation, has_cond)
28
+ relation_name = has_cond[:relation].to_s
29
+ return relation if relation_name.blank?
30
+
31
+ resolved_relation_name = resolve_relation_name(relation, relation_name)
32
+ return relation if resolved_relation_name.blank?
33
+
34
+ reflection = reflection_for(relation, resolved_relation_name)
35
+ return relation unless reflection&.klass
36
+
37
+ joined = relation.joins(resolved_relation_name.to_sym)
38
+ return joined.distinct if has_cond[:condition].blank?
39
+
40
+ # Use an EXISTS-style filter to safely apply conditions from the related model.
41
+ child_scope = apply(reflection.klass.all, has_cond[:condition])
42
+ joined.where(reflection.klass.table_name => { id: child_scope.select(:id) }).distinct
43
+ rescue StandardError
44
+ relation
45
+ end
46
+
47
+ def resolve_relation_name(relation, relation_name)
48
+ candidate_names = [relation_name.to_s, relation_name.to_s.singularize, relation_name.to_s.pluralize].uniq
49
+
50
+ candidate_names.each do |candidate|
51
+ next if candidate.blank?
52
+ return candidate if association_exists?(relation, candidate)
53
+ end
54
+
55
+ nil
56
+ rescue StandardError
57
+ nil
58
+ end
59
+
60
+ def association_exists?(relation, name)
61
+ reflection_for(relation, name).present?
62
+ rescue StandardError
63
+ false
64
+ end
65
+
66
+ def apply_has_argument(relation, arg_name, arg_value, relation_name = nil)
67
+ relation_name ||= derive_relation_name(arg_name)
68
+ return relation unless relation_name.present? && arg_value.present?
69
+
70
+ condition = arg_value.is_a?(Hash) ? arg_value : symbolized(arg_value)
71
+ has_payload = { relation: relation_name, condition: condition }
72
+ apply_has(relation, has_payload)
73
+ end
74
+
75
+ private
76
+
77
+ # The condition as a symbol-keyed Hash, or nil when there is none.
78
+ def normalize_condition(value)
79
+ return nil if value.nil?
80
+
81
+ condition = value.is_a?(Hash) ? value.deep_symbolize_keys : symbolized(value)
82
+ condition.transform_keys { |key| LOGICAL_KEYS.fetch(key, key) } if condition.is_a?(Hash)
83
+ end
84
+
85
+ def apply_all(relation, conditions)
86
+ Array(conditions).reduce(relation) { |acc, sub| apply(acc, sub) }
87
+ end
88
+
89
+ # The union of the branches, or nil when there are none.
90
+ def apply_any(relation, conditions)
91
+ Array(conditions).map { |sub| apply(relation, sub) }.reduce { |acc, sub_scope| acc.or(sub_scope) }
92
+ end
93
+
94
+ def apply_has_condition(relation, has_cond)
95
+ return relation unless has_cond.is_a?(Hash) && has_cond[:relation]
96
+
97
+ apply_has(relation, has_cond)
98
+ end
99
+
100
+ def reflection_for(relation, name)
101
+ klass = relation.respond_to?(:klass) ? relation.klass : relation
102
+ klass.reflect_on_association(name.to_sym) if klass.respond_to?(:reflect_on_association)
103
+ end
104
+
105
+ def derive_relation_name(arg_name)
106
+ Lighthouse::Support::Naming.derive_relation_name(arg_name)
107
+ end
108
+
109
+ def apply_single_condition(relation, condition)
110
+ return relation if condition[:column].blank? || !relation.respond_to?(:where)
111
+
112
+ column_name = resolve_column_name(condition[:column])
113
+ return relation if column_name.blank?
114
+
115
+ apply_operator(relation, column_name, operator_of(condition), condition[:value])
116
+ rescue StandardError
117
+ relation
118
+ end
119
+
120
+ def operator_of(condition)
121
+ condition[:operator]&.to_s&.upcase || 'EQ'
122
+ end
123
+
124
+ # Null and set operators are plain hash conditions; the rest need Arel.
125
+ def apply_operator(relation, column_name, operator, value)
126
+ case operator
127
+ when 'IS_NULL' then relation.where(column_name => nil)
128
+ when 'IS_NOT_NULL' then relation.where.not(column_name => nil)
129
+ when 'IN' then relation.where(column_name => Array(value))
130
+ when 'NIN' then relation.where.not(column_name => Array(value))
131
+ else apply_arel_operator(relation, column_name, operator, value)
132
+ end
133
+ end
134
+
135
+ # An unknown operator falls back to equality rather than failing the query.
136
+ def apply_arel_operator(relation, column_name, operator, value)
137
+ pattern = like_pattern(operator, value)
138
+ return relation.where(arel_table(relation)[column_name].matches(pattern)) if pattern
139
+
140
+ predicate = SUPPORTED_OPERATORS[operator]
141
+ return relation.where(column_name => value) unless predicate
142
+
143
+ relation.where(arel_table(relation)[column_name].public_send(predicate, value))
144
+ end
145
+
146
+ def like_pattern(operator, value)
147
+ case operator
148
+ when 'CONTAINS' then "%#{value}%"
149
+ when 'STARTS_WITH' then "#{value}%"
150
+ when 'ENDS_WITH' then "%#{value}"
151
+ end
152
+ end
153
+
154
+ def resolve_column_name(column)
155
+ Lighthouse::Support::Naming.column(column)
156
+ end
157
+
158
+ def arel_table(relation)
159
+ relation.klass.arel_table
160
+ end
161
+
162
+ def symbolized(obj)
163
+ if obj.respond_to?(:to_h)
164
+ obj.to_h.deep_symbolize_keys
165
+ else
166
+ {}
166
167
  end
167
168
  end
168
169
  end
@@ -1,40 +1,32 @@
1
1
  # frozen_string_literal: true
2
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
3
+ # Base class for argument-builder directives (@eq, @neq, @in, @notIn,
4
+ # @like, @where, @orderBy). Each receives the argument AST node and the
5
+ # directive AST node, and composes a constraint onto an ActiveRecord
6
+ # relation given the client-supplied value for that argument.
7
+ class Lighthouse::GraphQL::Directives::Arguments::Base
8
+ include Lighthouse::Contracts::ArgBuilder
13
9
 
14
- def initialize(arg_node, directive_node)
15
- @arg_node = arg_node
16
- @directive_node = directive_node
17
- end
10
+ def initialize(arg_node, directive_node)
11
+ @arg_node = arg_node
12
+ @directive_node = directive_node
13
+ end
18
14
 
19
- # @return relation
20
- def handle_builder(_relation, _value)
21
- raise NotImplementedError
22
- end
15
+ # @return relation
16
+ def handle_builder(_relation, _value)
17
+ raise NotImplementedError
18
+ end
23
19
 
24
- private
20
+ private
25
21
 
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
22
+ # Column to filter on: the directive's `key:` argument if present,
23
+ # otherwise the snake_case form of the GraphQL argument name.
24
+ def column
25
+ Lighthouse::Support::DirectiveArgs.literal(@directive_node, 'key') ||
26
+ Lighthouse::Support::Naming.attribute(@arg_node.name)
27
+ end
32
28
 
33
- def arel_column(relation)
34
- relation.klass.arel_table[column]
35
- end
36
- end
37
- end
38
- end
29
+ def arel_column(relation)
30
+ relation.klass.arel_table[column]
39
31
  end
40
32
  end