lighthouse-graphql 0.1.0 → 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 +168 -1
  3. data/README.md +46 -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 +52 -64
  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,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lighthouse::GraphQL
4
+ # graphql-ruby (2.5/2.6) hands a directive application's `null` literal —
5
+ # `@paginate(maxCount: null)` — to the directive as a NullValue node instead
6
+ # of nil, and building the schema then fails to coerce it. Its own
7
+ # build_default_value already maps NullValue to nil; do the same here.
8
+ module NullDirectiveArguments
9
+ def args_to_kwargs(arg_owner, node)
10
+ node.is_a?(::GraphQL::Language::Nodes::NullValue) ? nil : super
11
+ end
12
+ end
13
+
14
+ # The builder is a private constant; if a graphql-ruby release reshapes it,
15
+ # skip the fix rather than fail to load (explicit nulls then fail as before).
16
+ builder = ::GraphQL::Schema::BuildFromDefinition.const_get(:Builder) if ::GraphQL::Schema::BuildFromDefinition.const_defined?(:Builder)
17
+ builder.prepend(NullDirectiveArguments) if builder && (builder.method_defined?(:args_to_kwargs) || builder.private_method_defined?(:args_to_kwargs))
18
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ # Writes the SDL an IDE needs to understand a Lighthouse schema, mirroring PHP
6
+ # Lighthouse's `lighthouse:ide-helper`:
7
+ #
8
+ # - schema-directives.graphql — a definition for every registered directive
9
+ # (built-in and app-registered) the app's SDL does not declare itself, so
10
+ # the IDE can validate and autocomplete them.
11
+ # - programmatic-types.graphql — the types the manipulation phase generates
12
+ # (Connection/Edge/Paginator types, PaginatorInfo, whereConditions inputs,
13
+ # ...), which exist in the executable schema but in no .graphql file.
14
+ #
15
+ # Point your IDE's graphql-config at both files next to your own SDL. Run it
16
+ # with `bin/rails lighthouse:ide_helper`.
17
+ module Lighthouse::IdeHelper
18
+ DIRECTIVES_FILE = 'schema-directives.graphql'
19
+ PROGRAMMATIC_TYPES_FILE = 'programmatic-types.graphql'
20
+
21
+ TYPE_DEFINITIONS = [
22
+ ::GraphQL::Language::Nodes::ObjectTypeDefinition,
23
+ ::GraphQL::Language::Nodes::InputObjectTypeDefinition,
24
+ ::GraphQL::Language::Nodes::InterfaceTypeDefinition,
25
+ ::GraphQL::Language::Nodes::UnionTypeDefinition,
26
+ ::GraphQL::Language::Nodes::EnumTypeDefinition,
27
+ ::GraphQL::Language::Nodes::ScalarTypeDefinition
28
+ ].freeze
29
+
30
+ PLACEHOLDER_SCALAR = %("Placeholder for an argument type Lighthouse generates, e.g. with @whereConditions."\nscalar _)
31
+
32
+ module_function
33
+
34
+ # Writes both files into `output_dir` and returns their paths.
35
+ def write(output_dir:, sdl_folder: Lighthouse::Config.sdl_folder, registry: Lighthouse::DirectiveRegistry.default)
36
+ sdl = sdl_folder && Dir.exist?(sdl_folder.to_s) ? Lighthouse::GraphQL::SDLLoader.load_from_folder(sdl_folder.to_s) : ''
37
+
38
+ FileUtils.mkdir_p(output_dir.to_s)
39
+ {
40
+ DIRECTIVES_FILE => directives_sdl(sdl, registry: registry),
41
+ PROGRAMMATIC_TYPES_FILE => programmatic_types_sdl(sdl, registry: registry)
42
+ }.map do |name, content|
43
+ path = File.join(output_dir.to_s, name)
44
+ File.write(path, content)
45
+ path
46
+ end
47
+ end
48
+
49
+ def directives_sdl(sdl = '', registry: Lighthouse::DirectiveRegistry.default)
50
+ generator = Lighthouse::GraphQL::SchemaGenerator
51
+ definitions = generator.missing_directive_definitions(sdl, registry: registry).sort.map { |_, definition| definition }
52
+ types = generator.missing_type_definitions(sdl, definitions, registry: registry).sort.map { |_, definition| definition }
53
+ render(
54
+ 'Directive definitions contributed by Lighthouse and the directives your app registered.',
55
+ definitions + types
56
+ )
57
+ end
58
+
59
+ def programmatic_types_sdl(sdl, registry: Lighthouse::DirectiveRegistry.default)
60
+ original = type_names(sdl)
61
+ generated = parse(registry.manipulate_sdl(sdl)).definitions.select do |definition|
62
+ type_definition?(definition) && original.exclude?(definition.name)
63
+ end
64
+ generated_sdl = generated.sort_by(&:name).map(&:to_query_string)
65
+ generated_sdl.unshift(PLACEHOLDER_SCALAR) if undeclared_placeholder?(sdl, original, generated)
66
+
67
+ render(
68
+ 'Types the manipulation phase generates (Connection/Paginator types, whereConditions inputs, ...).',
69
+ generated_sdl
70
+ )
71
+ end
72
+
73
+ # The manipulation phase replaces the `_` placeholder (`where: _ @whereConditions`),
74
+ # but the IDE reads the SDL as written, so it needs the placeholder declared.
75
+ def undeclared_placeholder?(sdl, original, generated)
76
+ sdl.to_s.match?(/:\s*\[?\s*_\b/) && original.exclude?('_') && generated.none? { |d| d.name == '_' }
77
+ end
78
+
79
+ def type_names(sdl)
80
+ parse(sdl).definitions.filter_map { |definition| definition.name if type_definition?(definition) }.to_set
81
+ end
82
+
83
+ def type_definition?(definition)
84
+ TYPE_DEFINITIONS.any? { |kind| definition.is_a?(kind) }
85
+ end
86
+
87
+ def parse(sdl)
88
+ ::GraphQL::Language::Parser.parse(sdl.to_s)
89
+ end
90
+
91
+ def render(summary, definitions)
92
+ header = <<~GRAPHQL
93
+ # Generated by `bin/rails lighthouse:ide_helper` — do not edit; rerun it after changing your schema.
94
+ # #{summary}
95
+ GRAPHQL
96
+ "#{header}\n#{definitions.join("\n\n")}\n"
97
+ end
98
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The namespaces the library's files live in. Every other file opens its class
4
+ # or module compactly (`class Lighthouse::GraphQL::DirectiveResolvers::Base`),
5
+ # which needs the enclosing namespaces to exist already, so they are defined
6
+ # here, first.
7
+ module Lighthouse
8
+ module Contracts; end
9
+ module Generators; end
10
+ module Support; end
11
+ module WhereConditions; end
12
+
13
+ module GraphQL
14
+ module DataloaderSources; end
15
+ module DirectiveResolvers; end
16
+
17
+ module Directives
18
+ module Arguments; end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ # belongsTo / morphTo operations: they set the foreign keys (and morph type)
4
+ # of the record being saved, so they run before it is saved.
5
+ module Lighthouse::GraphQL::NestedMutations::BelongsToOperations
6
+ private
7
+
8
+ def nest_belongs_to(record, reflection, operations, deferred_deletes)
9
+ association = record.association(reflection.name)
10
+ in_order(reflection, operations).each do |name, operation|
11
+ case name
12
+ when 'create', 'update', 'upsert' then association.writer(write(name, reflection.klass, operation.input, operation))
13
+ when 'connect' then connect_owner(record, reflection, operation.plain)
14
+ when 'disconnect' then association.writer(nil) if operation.truthy?
15
+ when 'delete' then delete_owner(record, reflection, operation, deferred_deletes)
16
+ end
17
+ end
18
+ end
19
+
20
+ def connect_owner(record, reflection, value)
21
+ if reflection.polymorphic?
22
+ model = morph_model(value[:type])
23
+ record[reflection.foreign_type] = model.polymorphic_name
24
+ record[reflection.foreign_key] = value[:id]
25
+ else
26
+ record[reflection.foreign_key] = value
27
+ end
28
+ end
29
+
30
+ # Unlinks the related record now and destroys it only after the record,
31
+ # no longer pointing at it, is saved (see Executor#save).
32
+ def delete_owner(record, reflection, operation, deferred_deletes)
33
+ return unless operation.truthy?
34
+
35
+ related = record.public_send(reflection.name)
36
+ record.association(reflection.name).writer(nil)
37
+ deferred_deletes << related if related
38
+ end
39
+
40
+ # The model a morphTo `connect` names: a class name, resolved like any
41
+ # model name, or an enum value such as TASK for Task.
42
+ def morph_model(type)
43
+ [type, type.to_s.downcase.camelize].uniq.each do |name|
44
+ model = Lighthouse::Support::ModelResolver.constantize(name)
45
+ return model if model.is_a?(Class) && model < ::ActiveRecord::Base
46
+ end
47
+
48
+ raise ::GraphQL::ExecutionError, "Unknown type #{type} to connect."
49
+ end
50
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Saves records from nested input: create, update and upsert each save one
4
+ # record, running the operations its input carries on its associations.
5
+ class Lighthouse::GraphQL::NestedMutations::Executor
6
+ include Lighthouse::GraphQL::NestedMutations::BelongsToOperations
7
+ include Lighthouse::GraphQL::NestedMutations::HasOperations
8
+ include Lighthouse::GraphQL::NestedMutations::ManyToManyOperations
9
+
10
+ def initialize(field_name)
11
+ @field_name = field_name
12
+ end
13
+
14
+ def create(model, input, link: nil)
15
+ save(model.new, partition(model, input), link)
16
+ end
17
+
18
+ def update(model, input, link: nil)
19
+ part = partition(model, input)
20
+ key, id = part.take_key(model)
21
+ raise ::GraphQL::ExecutionError, Lighthouse::GraphQL::NestedMutations::MISSING_PRIMARY_KEY if key.nil? || id.nil?
22
+
23
+ part.attributes.delete(key)
24
+ save(find!(model, id), part, link)
25
+ end
26
+
27
+ # The record identified by `identifying` columns, else by primary key,
28
+ # else `fallback`, else a new one — created with the given id, if any.
29
+ def upsert(model, input, link: nil, identifying: nil, fallback: nil)
30
+ part = partition(model, input)
31
+ record = identifying.nil? ? nil : find_identified(model, part, identifying)
32
+ record ||= find_by_given_key(model, part)
33
+ save(record || fallback || model.new, part, link)
34
+ end
35
+
36
+ def find!(model, id)
37
+ model.find_by(model.primary_key => id) ||
38
+ raise(::GraphQL::ExecutionError, "Couldn't find #{model.name} with '#{model.primary_key}'=#{id}")
39
+ end
40
+
41
+ # Assign, run the operations that set this record's foreign keys, save
42
+ # once, then run the operations on associations pointing back at it.
43
+ def save(record, part, link = nil)
44
+ record.assign_attributes(part.attributes)
45
+ deferred_deletes = []
46
+ before, after = part.operations.partition { |reflection, _| owner_side?(reflection) }
47
+
48
+ before.each { |reflection, operations| nest_belongs_to(record, reflection, operations, deferred_deletes) }
49
+ link&.before_save(record)
50
+ persist!(record, part.paths)
51
+ link&.after_save(record)
52
+ deferred_deletes.each(&:destroy!)
53
+ after.each { |reflection, operations| dependents(record, reflection, operations) }
54
+
55
+ part.operations.each_key { |reflection| record.association(reflection.name).reset }
56
+ record
57
+ end
58
+
59
+ def persist!(record, paths)
60
+ return record if record.save
61
+
62
+ messages = {}
63
+ record.errors.each do |error|
64
+ path = error.attribute == :base ? base_path(paths) : paths[error.attribute.to_s]
65
+ (messages[path] ||= []) << error.full_message
66
+ end
67
+ raise Lighthouse::ValidationError.new(@field_name, messages)
68
+ end
69
+
70
+ private
71
+
72
+ def partition(model, input)
73
+ Lighthouse::GraphQL::NestedMutations::Partitioner.new(model, input).call
74
+ end
75
+
76
+ def base_path(paths)
77
+ prefix = paths.default_proc.call({}, '').delete_suffix('.')
78
+ prefix.empty? ? @field_name : prefix
79
+ end
80
+
81
+ def find_identified(model, part, columns)
82
+ raise Lighthouse::DefinitionError, "@upsert's identifyingColumns must not be empty." if columns.empty?
83
+
84
+ values = columns.to_h { |column| [column.to_s, part.attributes[column.to_s]] }
85
+ raise ::GraphQL::ExecutionError, Lighthouse::GraphQL::NestedMutations::MISSING_IDENTIFYING_COLUMNS if values.values.any?(&:nil?)
86
+
87
+ model.find_by(values)
88
+ end
89
+
90
+ # The record the input's primary key (`id`, or the model's own) names. A
91
+ # blank key is dropped so the database assigns one; a key no record has
92
+ # becomes the new record's primary key.
93
+ def find_by_given_key(model, part)
94
+ key, id = part.take_key(model)
95
+ return nil if key.nil?
96
+
97
+ if id.nil? || id.to_s.empty?
98
+ part.attributes.delete(key)
99
+ return nil
100
+ end
101
+
102
+ record = model.find_by(model.primary_key => id)
103
+ part.attributes[model.primary_key] = part.attributes.delete(key) if record.nil? && key != model.primary_key
104
+ record
105
+ end
106
+
107
+ # ----------------------------------------------------------------------
108
+ # Operations (the relation kinds' own are in the *Operations modules)
109
+
110
+ def kind(reflection)
111
+ Lighthouse::GraphQL::NestedMutations.kind(reflection)
112
+ end
113
+
114
+ def owner_side?(reflection)
115
+ %i[belongs_to morph_to].include?(kind(reflection))
116
+ end
117
+
118
+ def dependents(record, reflection, operations)
119
+ case kind(reflection)
120
+ when :has_one then nest_has_one(record, reflection, operations)
121
+ when :has_many then nest_has_many(record, reflection, operations)
122
+ when :many_to_many then nest_many_to_many(record, reflection, operations)
123
+ end
124
+ end
125
+
126
+ # PHP Lighthouse's fixed order, whatever order the client wrote them in.
127
+ def in_order(reflection, operations)
128
+ Lighthouse::GraphQL::NestedMutations::OPERATIONS.fetch(kind(reflection)).filter_map do |name|
129
+ [name, operations[name]] if operations[name]
130
+ end
131
+ end
132
+
133
+ # The create, update or upsert operation `name` on one input; an upsert
134
+ # that identifies no record falls back to the one the block returns.
135
+ def write(name, model, input, operation, link: nil)
136
+ case name
137
+ when 'create' then create(model, input, link: link)
138
+ when 'update' then update(model, input, link: link)
139
+ when 'upsert'
140
+ fallback = yield if block_given?
141
+ # rubocop:disable-next Rails/SkipsModelValidations -- Executor#upsert, not ActiveRecord's: it saves through validations.
142
+ upsert(model, input, link: link, identifying: operation.identifying, fallback: fallback)
143
+ end
144
+ end
145
+
146
+ def write_each(name, model, operation, link)
147
+ operation.inputs.each { |input| write(name, model, input, operation, link: link) }
148
+ end
149
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ # hasOne / morphOne and hasMany / morphMany operations: their records point
4
+ # back at the saved owner, so they run after it is saved.
5
+ module Lighthouse::GraphQL::NestedMutations::HasOperations
6
+ BOOLEANS = [true, false].freeze
7
+
8
+ private
9
+
10
+ def nest_has_one(record, reflection, operations)
11
+ model = reflection.klass
12
+ link = Lighthouse::GraphQL::NestedMutations::HasLink.new(record, reflection)
13
+ existing = -> { record.association(reflection.name).scope.first }
14
+
15
+ in_order(reflection, operations).each do |name, operation|
16
+ case name
17
+ when 'create'
18
+ refuse_second_child!(record, model, existing.call)
19
+ create(model, operation.input, link: link)
20
+ # An upsert that identifies no record updates the current one, if any.
21
+ when 'update', 'upsert' then write(name, model, operation.input, operation, link: link) { existing.call }
22
+ when 'delete' then delete_child(model, operation, existing)
23
+ end
24
+ end
25
+ end
26
+
27
+ def refuse_second_child!(record, model, existing)
28
+ return unless existing
29
+
30
+ raise ::GraphQL::ExecutionError,
31
+ "Cannot create a related model: a #{model.name.demodulize} already exists for this " \
32
+ "#{record.class.name.demodulize}. Use upsert to modify the existing model."
33
+ end
34
+
35
+ # An ID, or — as for any single relation — a Boolean meaning "the current one".
36
+ def delete_child(model, operation, existing)
37
+ if BOOLEANS.include?(operation.value)
38
+ existing.call&.destroy! if operation.value
39
+ else
40
+ model.where(model.primary_key => operation.plain).destroy_all
41
+ end
42
+ end
43
+
44
+ def nest_has_many(record, reflection, operations)
45
+ model = reflection.klass
46
+ link = Lighthouse::GraphQL::NestedMutations::HasLink.new(record, reflection)
47
+
48
+ in_order(reflection, operations).each do |name, operation|
49
+ case name
50
+ when 'create', 'update', 'upsert' then write_each(name, model, operation, link)
51
+ when 'connect' then relink(model, operation) { |child| link.before_save(child) }
52
+ when 'disconnect' then relink(model, operation) { |child| child[reflection.foreign_key] = nil }
53
+ when 'delete' then model.where(model.primary_key => operation.plain).destroy_all
54
+ end
55
+ end
56
+ end
57
+
58
+ # Re-points (or detaches) the existing children the operation names, and
59
+ # saves each through its validations, errors keyed by the operation's path.
60
+ def relink(model, operation)
61
+ model.where(model.primary_key => operation.plain).each do |child|
62
+ yield child
63
+ persist!(child, Hash.new { operation.path.join('.') })
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # belongsToMany / morphToMany operations: they run after the owner is saved
4
+ # and change its join records (Lighthouse::GraphQL::NestedMutations::Pivot).
5
+ module Lighthouse::GraphQL::NestedMutations::ManyToManyOperations
6
+ private
7
+
8
+ def nest_many_to_many(record, reflection, operations)
9
+ model = reflection.klass
10
+ pivot = Lighthouse::GraphQL::NestedMutations::Pivot.new(record, reflection)
11
+ link = Lighthouse::GraphQL::NestedMutations::AttachLink.new(pivot)
12
+
13
+ in_order(reflection, operations).each do |name, operation|
14
+ case name
15
+ when 'create', 'update', 'upsert' then write_each(name, model, operation, link)
16
+ when 'delete'
17
+ pivot.detach(operation.plain)
18
+ model.where(model.primary_key => operation.plain).destroy_all
19
+ else link_pivot(pivot, name, operation)
20
+ end
21
+ end
22
+ pivot.reset
23
+ end
24
+
25
+ # The operations that only add or remove join records.
26
+ def link_pivot(pivot, name, operation)
27
+ case name
28
+ when 'sync' then pivot.sync(pairs(operation), detaching: true)
29
+ when 'syncWithoutDetaching' then pivot.sync(pairs(operation), detaching: false)
30
+ when 'connect' then pairs(operation).each { |id, values| pivot.attach(id, values) }
31
+ when 'disconnect' then pivot.detach(operation.plain)
32
+ end
33
+ end
34
+
35
+ # [[id, pivot values], ...] from a list of IDs or of { id, ...pivot } inputs.
36
+ def pairs(operation)
37
+ Array(operation.plain).map do |item|
38
+ next [item, {}] unless item.is_a?(Hash)
39
+
40
+ values = item.transform_keys(&:to_s)
41
+ [values.delete('id'), values]
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Splits one record's input into attributes and the operations on its
4
+ # associations (a Lighthouse::GraphQL::NestedMutations::Partition).
5
+ class Lighthouse::GraphQL::NestedMutations::Partitioner
6
+ def initialize(model, input)
7
+ @model = model
8
+ @input = input
9
+ end
10
+
11
+ def call
12
+ @part = Lighthouse::GraphQL::NestedMutations::Partition.new(argument_paths(@input.argument_defs, @input.path))
13
+ walk(@input)
14
+ @part
15
+ end
16
+
17
+ private
18
+
19
+ def walk(input)
20
+ by_keyword = Lighthouse::Support::Arguments.keyword_index(input.argument_defs)
21
+ input.args.each_pair do |key, value|
22
+ argument = by_keyword[key.to_sym]
23
+ path = input.path + [argument ? argument.graphql_name : key.to_s.camelize(:lower)]
24
+ attribute = (argument && renamed(argument)) || key.to_s
25
+ assign(attribute, value, path) unless argument && nest(argument, attribute, value, path)
26
+ end
27
+ end
28
+
29
+ # Takes the value of a @spread argument, of a nested operation argument
30
+ # (@create(relation:) ...) or of an association's operations; false when
31
+ # it is a plain attribute. A null for any of those is no operation at all.
32
+ def nest(argument, attribute, value, path)
33
+ if Lighthouse::Support::Arguments.spread?(argument)
34
+ walk(spread_input(argument, value, path)) unless value.nil?
35
+ elsif (directive = nested_directive(argument))
36
+ add_argument_operation(directive, attribute, Lighthouse::GraphQL::NestedMutations::Operation.new(value, argument, path))
37
+ elsif (reflection = relation_argument(attribute, argument))
38
+ @part.add(reflection, operations(reflection, argument, value, path)) unless value.nil?
39
+ else
40
+ return false
41
+ end
42
+ true
43
+ end
44
+
45
+ # A @spread input object is walked as if its fields were the parent's.
46
+ def spread_input(argument, value, path)
47
+ Lighthouse::GraphQL::NestedMutations::Input.new(Lighthouse::Support::Arguments.shallow(value), argument.type.unwrap.arguments, path)
48
+ end
49
+
50
+ def assign(attribute, value, path)
51
+ @part.attributes[attribute] = Lighthouse::Support::Arguments.plain(value)
52
+ @part.paths[attribute] ||= path.join('.')
53
+ end
54
+
55
+ # An argument with @create/@update/@upsert/@delete is one operation on the
56
+ # association its `relation:` names, else the one the argument is named after.
57
+ def add_argument_operation(directive, attribute, operation)
58
+ return if operation.value.nil?
59
+
60
+ reflection = reflection!(Lighthouse::Support::DirectiveArgs.literal(directive, 'relation') || attribute)
61
+ operation.identifying = identifying_columns(directive)
62
+ @part.add(reflection, Lighthouse::GraphQL::NestedMutations::ARGUMENT_DIRECTIVES[directive.name] => operation)
63
+ end
64
+
65
+ # @upsert(identifyingColumns:) — nil when the directive does not set them.
66
+ def identifying_columns(directive)
67
+ return nil unless directive.arguments.any? { |a| a.name == 'identifyingColumns' }
68
+
69
+ Array(Lighthouse::Support::DirectiveArgs.literal(directive, 'identifyingColumns'))
70
+ end
71
+
72
+ # An association-named argument whose type is an input object of operations.
73
+ def relation_argument(attribute, argument)
74
+ return nil unless argument.type.unwrap.kind.input_object?
75
+ return nil unless @model.respond_to?(:reflect_on_association)
76
+
77
+ @model.reflect_on_association(attribute.to_sym)
78
+ end
79
+
80
+ def operations(reflection, argument, value, path)
81
+ kind = Lighthouse::GraphQL::NestedMutations.kind(reflection)
82
+ allowed = Lighthouse::GraphQL::NestedMutations::OPERATIONS.fetch(kind)
83
+ by_keyword = Lighthouse::Support::Arguments.keyword_index(argument.type.unwrap.arguments)
84
+ Lighthouse::Support::Arguments.shallow(value).each_with_object({}) do |(key, operation_value), out|
85
+ next if operation_value.nil?
86
+
87
+ operation_argument = by_keyword[key.to_sym]
88
+ name = operation_argument&.graphql_name || key.to_s.camelize(:lower)
89
+ unless allowed.include?(name)
90
+ raise Lighthouse::DefinitionError,
91
+ "#{name} is not a nested operation on #{@model}##{reflection.name} (#{kind}): use #{allowed.join(', ')}."
92
+ end
93
+
94
+ out[name] = Lighthouse::GraphQL::NestedMutations::Operation.new(operation_value, operation_argument, path + [name])
95
+ end
96
+ end
97
+
98
+ def nested_directive(argument)
99
+ (argument.ast_node&.directives || []).find { |d| Lighthouse::GraphQL::NestedMutations::ARGUMENT_DIRECTIVES.key?(d.name) }
100
+ end
101
+
102
+ def reflection!(name)
103
+ @model.reflect_on_association(name.to_s.underscore.to_sym) ||
104
+ raise(Lighthouse::DefinitionError, "#{@model} has no association #{name} for a nested mutation.")
105
+ end
106
+
107
+ def renamed(argument)
108
+ directive = (argument.ast_node&.directives || []).find { |d| d.name == 'rename' }
109
+ directive && Lighthouse::Support::DirectiveArgs.literal(directive, 'attribute')
110
+ end
111
+
112
+ # { attribute => "argument.path" } for every argument the type defines —
113
+ # also those the client left out, where e.g. a presence error belongs.
114
+ def argument_paths(argument_defs, prefix)
115
+ paths = Hash.new { |_, attribute| (prefix + [attribute.to_s.camelize(:lower)]).join('.') }
116
+ (argument_defs || {}).each_value do |argument|
117
+ if Lighthouse::Support::Arguments.spread?(argument)
118
+ paths.merge!(argument_paths(argument.type.unwrap.arguments, prefix + [argument.graphql_name]))
119
+ else
120
+ paths[renamed(argument) || argument.keyword.to_s] = (prefix + [argument.graphql_name]).join('.')
121
+ end
122
+ end
123
+ paths
124
+ end
125
+ end