lighthouse-graphql 0.5.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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +212 -1
  3. data/README.md +38 -22
  4. data/docs/best-practices.md +6 -4
  5. data/docs/caching.md +4 -3
  6. data/docs/configuration.md +20 -26
  7. data/docs/custom-directives.md +2 -1
  8. data/docs/directives.md +79 -14
  9. data/docs/federation.md +1 -1
  10. data/docs/filtering-and-ordering.md +330 -30
  11. data/docs/generators.md +42 -40
  12. data/docs/getting-started.md +128 -58
  13. data/docs/motivation.md +3 -2
  14. data/docs/performance.md +5 -4
  15. data/docs/relationships.md +7 -5
  16. data/docs/relay.md +63 -0
  17. data/docs/resolvers.md +92 -39
  18. data/docs/schema-organisation.md +177 -0
  19. data/docs/security.md +64 -16
  20. data/docs/subscriptions.md +16 -9
  21. data/docs/types.md +16 -17
  22. data/docs/uploads.md +41 -14
  23. data/lib/generators/lighthouse/base_classes.rb +9 -14
  24. data/lib/generators/lighthouse/install/graphql_ruby_install.rb +113 -0
  25. data/lib/generators/lighthouse/install/install_generator.rb +125 -99
  26. data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +2 -1
  27. data/lib/generators/lighthouse/install/templates/initializer.rb.tt +14 -13
  28. data/lib/lighthouse/ast_builder.rb +124 -0
  29. data/lib/lighthouse/caching.rb +9 -3
  30. data/lib/lighthouse/config.rb +27 -25
  31. data/lib/lighthouse/contracts/arg_manipulator.rb +9 -5
  32. data/lib/lighthouse/contracts/field_builder.rb +13 -0
  33. data/lib/lighthouse/controller.rb +28 -5
  34. data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +1 -2
  35. data/lib/lighthouse/directive_resolvers/all_resolver.rb +0 -2
  36. data/lib/lighthouse/directive_resolvers/argument_transforms_resolver.rb +18 -18
  37. data/lib/lighthouse/directive_resolvers/association_resolvers.rb +9 -6
  38. data/lib/lighthouse/directive_resolvers/base.rb +6 -37
  39. data/lib/lighthouse/directive_resolvers/builder_applier.rb +21 -6
  40. data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +1 -1
  41. data/lib/lighthouse/directive_resolvers/count_resolver.rb +0 -1
  42. data/lib/lighthouse/directive_resolvers/first_resolver.rb +0 -2
  43. data/lib/lighthouse/directive_resolvers/global_id_resolver.rb +3 -1
  44. data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +2 -3
  45. data/lib/lighthouse/directive_resolvers/throttle_resolver.rb +85 -26
  46. data/lib/lighthouse/directive_resolvers/type_method_resolver.rb +12 -6
  47. data/lib/lighthouse/directives/arguments/more_filters.rb +1 -1
  48. data/lib/lighthouse/directives/arguments/order_by.rb +190 -26
  49. data/lib/lighthouse/directives/arguments/where_conditions.rb +25 -0
  50. data/lib/lighthouse/directives/arguments/where_conditions_base.rb +84 -0
  51. data/lib/lighthouse/directives/arguments/where_has_conditions.rb +56 -0
  52. data/lib/lighthouse/directives/definitions.rb +0 -1
  53. data/lib/lighthouse/directives/generated_types.rb +103 -0
  54. data/lib/lighthouse/directives/registry.rb +39 -10
  55. data/lib/lighthouse/document_ast.rb +111 -0
  56. data/lib/lighthouse/field_resolver_stack.rb +9 -6
  57. data/lib/lighthouse/global_id.rb +29 -3
  58. data/lib/lighthouse/graphql/version.rb +1 -1
  59. data/lib/lighthouse/graphql_ruby_patches.rb +45 -0
  60. data/lib/lighthouse/ide_helper.rb +80 -23
  61. data/lib/lighthouse/namespaces.rb +1 -0
  62. data/lib/lighthouse/order_by/types.rb +137 -0
  63. data/lib/lighthouse/railtie.rb +9 -2
  64. data/lib/lighthouse/rb_lighthouse.rb +16 -5
  65. data/lib/lighthouse/resolve_info.rb +5 -5
  66. data/lib/lighthouse/resolver_bases.rb +18 -4
  67. data/lib/lighthouse/resolver_dispatch.rb +16 -1
  68. data/lib/lighthouse/root_types.rb +117 -0
  69. data/lib/lighthouse/schema_cache.rb +8 -4
  70. data/lib/lighthouse/schema_factory.rb +6 -9
  71. data/lib/lighthouse/schema_generator.rb +8 -18
  72. data/lib/lighthouse/schema_stitcher.rb +64 -0
  73. data/lib/lighthouse/support/naming.rb +0 -12
  74. data/lib/lighthouse/upload.rb +4 -2
  75. data/lib/lighthouse/upload_store.rb +39 -14
  76. data/lib/lighthouse/where_conditions/columns.rb +36 -0
  77. data/lib/lighthouse/where_conditions/handler.rb +152 -0
  78. data/lib/lighthouse/where_conditions/json_column.rb +99 -0
  79. data/lib/lighthouse/where_conditions/operator_map.rb +1 -4
  80. data/lib/lighthouse/where_conditions/types.rb +110 -0
  81. data/sig/lighthouse/directives.rbs +16 -3
  82. data/sig/lighthouse/graphql.rbs +48 -4
  83. data/sig/lighthouse.rbs +8 -12
  84. metadata +17 -7
  85. data/lib/generators/lighthouse/install/templates/schema.rb.tt +0 -29
  86. data/lib/generators/lighthouse/mutation_generator.rb +0 -31
  87. data/lib/generators/lighthouse/query_generator.rb +0 -30
  88. data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +0 -170
  89. data/lib/lighthouse/directives/where_conditions_directive.rb +0 -244
  90. data/lib/lighthouse/sdl_loader.rb +0 -22
@@ -47,7 +47,7 @@ module Lighthouse::GraphQL::SchemaGenerator
47
47
  impl = default_implementation
48
48
  # base_types' resolver / mutation / subscription are Lighthouse's, not graphql-ruby's.
49
49
  bases = Lighthouse::GraphQL::ResolverBases.extract!(opts)
50
- schema = from_document(document, impl, opts)
50
+ schema = Lighthouse::GraphQL::RootTypes.build(document, bases[:roots]) { from_document(document, impl, opts) }
51
51
 
52
52
  delegate_to_federation_sdl(schema)
53
53
  Lighthouse::GraphQL::GeneratedResolvers.install_dispatcher(schema)
@@ -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 every
126
- # registered manipulator run (pagination types, whereConditions inputs, ...).
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
- augment_sdl_with_connections(inject_directive_definitions(sdl))
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).
@@ -140,8 +141,9 @@ module Lighthouse::GraphQL::SchemaGenerator
140
141
  # Apollo Federation helper can detect entities and add _entities.
141
142
  Lighthouse::GraphQL::FederationDirectives.apply(type)
142
143
  root_kind = root_kinds[type.graphql_name]
143
- type_class = root_kind ? nil : Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver.type_class(type.graphql_name, bases)
144
- type.fields.each_value { |field_def| process_field(type, field_def, root_kind, bases, type_class) }
144
+ type_class = Lighthouse::GraphQL::DirectiveResolvers::TypeMethodResolver.type_class(type.graphql_name, bases, root_kind)
145
+ # A field a code-first base declares (RootTypes) resolves as graphql-ruby resolves it.
146
+ type.fields.each_value { |field_def| process_field(type, field_def, root_kind, bases, type_class) if field_def.owner == type }
145
147
  end
146
148
 
147
149
  # @complexity, and paginated fields, give graphql-ruby's complexity analysis their cost.
@@ -193,18 +195,6 @@ module Lighthouse::GraphQL::SchemaGenerator
193
195
  # ignore failures during post-processing
194
196
  end
195
197
 
196
- # Scan SDL string for list-typed fields that carry relationship directives
197
- # and produce Connection/Edge types where missing. This is a lightweight
198
- # transformation that mirrors Lighthouse behavior: clients can declare
199
- # `roles: [Role!]! @belongsToMany` and the builder will expose a
200
- # `RoleConnection` type and swap the field to return that connection.
201
- def augment_sdl_with_connections(sdl)
202
- # Drive SDL manipulation through the registry so each directive owns its
203
- # manipulation behavior and new manipulators can be added by registering
204
- # a class (no edits here).
205
- Lighthouse::DirectiveRegistry.default.manipulate_sdl(sdl)
206
- end
207
-
208
198
  # Prepend `directive @name(...) on ...` definitions for registered,
209
199
  # self-describing directives that the SDL uses but has not declared.
210
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
@@ -13,11 +13,13 @@ class Lighthouse::GraphQL::Upload < GraphQL::Schema::Scalar
13
13
  def self.coerce_input(value, _context)
14
14
  return value if file?(value)
15
15
 
16
- raise ::GraphQL::CoercionError, 'Could not get uploaded file, be sure to conform to the GraphQL multipart request specification.'
16
+ raise ::GraphQL::CoercionError,
17
+ 'Could not get uploaded file, be sure to conform to GraphQL multipart request specification: ' \
18
+ "https://github.com/jaydenseric/graphql-multipart-request-spec. Instead got: #{value.inspect}."
17
19
  end
18
20
 
19
21
  def self.coerce_result(_value, _context)
20
- raise ::GraphQL::Error, 'The Upload scalar can only be used as an argument, not returned.'
22
+ raise ::GraphQL::Error, '"Upload" cannot be serialized, it can only be used as an argument.'
21
23
  end
22
24
 
23
25
  def self.file?(value)
@@ -1,32 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'securerandom'
4
-
5
- # Where @upload puts a file, by default: an ActiveStorage blob, on the service
6
- # `disk` names (the default service without it), its key under `path`. The
7
- # argument then holds the blob's signed id, which a has_one_attached /
8
- # has_many_attached attribute attaches, and which ActiveStorage::Blob.find_signed
9
- # finds. Config.upload_store takes another `call(file, disk:, path:, public:)`.
3
+ # Where @upload puts a file, as PHP Lighthouse's @upload puts it on one of the
4
+ # app's Laravel disks: an ActiveStorage blob on one of the app's services
5
+ # (config/storage.yml). `disk` names the service (config.active_storage.service
6
+ # without it), `path` prefixes the blob's key. ActiveStorage sets visibility per
7
+ # service (`public: true` in storage.yml), not per file, so `public` must agree
8
+ # with the service's: the schema never says a file is private that anyone can
9
+ # read, or public that no one can. The argument then holds the blob's signed id,
10
+ # which a has_one_attached / has_many_attached attribute attaches (where Laravel
11
+ # hands the stored path, for a model to keep). To store files elsewhere, declare
12
+ # an ActiveStorage service for it, as a Laravel app declares a disk.
10
13
  module Lighthouse::GraphQL::UploadStore
11
14
  module_function
12
15
 
13
- def call(file, disk: nil, path: nil, public: false) # rubocop:disable Lint/UnusedMethodArgument -- public is the service's setting
16
+ def call(file, argument:, disk: nil, path: nil, public: nil)
14
17
  unless defined?(::ActiveStorage::Blob)
15
- raise Lighthouse::DefinitionError,
16
- '@upload stores files with ActiveStorage: install it (bin/rails active_storage:install), or set Config.upload_store.'
18
+ raise Lighthouse::DefinitionError, '@upload stores files with ActiveStorage: install it (bin/rails active_storage:install).'
19
+ end
20
+ unless Lighthouse::GraphQL::Upload.file?(file)
21
+ raise Lighthouse::DefinitionError, "Expected argument `#{argument}` to be an uploaded file (Upload)."
17
22
  end
18
23
 
19
- attributes = { io: file, filename: filename(file), content_type: file.try(:content_type), key: key(path) }
20
- attributes[:service_name] = disk if disk
21
- ::ActiveStorage::Blob.create_and_upload!(**attributes.compact).signed_id
24
+ service = service(argument, disk, public == true)
25
+ file.rewind if file.respond_to?(:rewind) # a @validator, or another argument the multipart map sent it to, may have read it
26
+ ::ActiveStorage::Blob.create_and_upload!(
27
+ io: file, filename: filename(file), content_type: file.try(:content_type), key: key(path), service_name: service.name
28
+ ).signed_id
29
+ end
30
+
31
+ # The service `disk` names, or the default one, checked to be as public as asked.
32
+ def service(argument, disk, public)
33
+ service = disk ? ::ActiveStorage::Blob.services.fetch(disk) : ::ActiveStorage::Blob.service
34
+ return service if (service.public? == true) == public
35
+
36
+ raise Lighthouse::DefinitionError, visibility_mismatch(argument, service.name, public)
37
+ rescue KeyError => e
38
+ raise Lighthouse::DefinitionError, "@upload on `#{argument}`: #{e.message}"
39
+ end
40
+
41
+ def visibility_mismatch(argument, service_name, public)
42
+ fix = public ? 'name a public one (public: true in config/storage.yml) with disk:' : 'pass public: true, or name a private one with disk:'
43
+ "@upload#{'(public: true)' if public} on `#{argument}` stores on the ActiveStorage service `#{service_name}`, " \
44
+ "which is #{'not ' if public}public: #{fix}."
22
45
  end
23
46
 
24
47
  def filename(file)
25
48
  file.respond_to?(:original_filename) ? file.original_filename : File.basename(file.path)
26
49
  end
27
50
 
51
+ # `path`'s directory, then a fresh token, as Laravel's `storeAs(path, hashName)`;
52
+ # under the root ("/", the default), ActiveStorage's own key.
28
53
  def key(path)
29
54
  prefix = path.to_s.delete_prefix('/').delete_suffix('/')
30
- prefix.empty? ? nil : "#{prefix}/#{SecureRandom.base36(28)}"
55
+ prefix.empty? ? nil : "#{prefix}/#{::ActiveStorage::Blob.generate_unique_secure_token}"
31
56
  end
32
57
  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 GraphQL SQLOperator enum values to Arel predicate methods.
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