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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +168 -1
- data/README.md +46 -2
- data/docs/authorization.md +116 -57
- data/docs/best-practices.md +16 -3
- data/docs/caching.md +161 -0
- data/docs/configuration.md +83 -4
- data/docs/custom-directives.md +5 -1
- data/docs/directives.md +117 -38
- data/docs/getting-started.md +84 -0
- data/docs/mutations.md +260 -0
- data/docs/performance.md +138 -0
- data/docs/relationships.md +80 -33
- data/docs/resolvers.md +179 -0
- data/docs/validation.md +132 -0
- data/lib/generators/lighthouse/builder_generator.rb +30 -0
- data/lib/generators/lighthouse/class_generator.rb +61 -0
- data/lib/generators/lighthouse/install/install_generator.rb +127 -0
- data/lib/generators/lighthouse/install/templates/graphql.config.yml.tt +8 -0
- data/lib/generators/lighthouse/install/templates/graphql_controller.rb.tt +57 -0
- data/lib/generators/lighthouse/install/templates/initializer.rb.tt +67 -0
- data/lib/generators/lighthouse/install/templates/schema.graphql.tt +19 -0
- data/lib/generators/lighthouse/install/templates/schema.rb.tt +10 -0
- data/lib/generators/lighthouse/mutation_generator.rb +30 -0
- data/lib/generators/lighthouse/query_generator.rb +29 -0
- data/lib/generators/lighthouse/validator_generator.rb +36 -0
- data/lib/lighthouse/cache_control.rb +126 -0
- data/lib/lighthouse/caching.rb +68 -0
- data/lib/lighthouse/config.rb +85 -19
- data/lib/lighthouse/contracts/arg_builder.rb +8 -12
- data/lib/lighthouse/contracts/arg_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/directive.rb +10 -14
- data/lib/lighthouse/contracts/field_manipulator.rb +8 -12
- data/lib/lighthouse/contracts/field_middleware.rb +7 -11
- data/lib/lighthouse/contracts/field_resolver.rb +9 -13
- data/lib/lighthouse/dataloader_sources/association_loader.rb +28 -19
- data/lib/lighthouse/dataloader_sources/model_loader.rb +10 -14
- data/lib/lighthouse/dataloader_sources/relation_count_loader.rb +16 -0
- data/lib/lighthouse/dataloader_sources/relation_page_loader.rb +18 -0
- data/lib/lighthouse/directive_resolvers/aggregate_resolver.rb +57 -69
- data/lib/lighthouse/directive_resolvers/all_resolver.rb +26 -57
- data/lib/lighthouse/directive_resolvers/association_resolvers.rb +161 -0
- data/lib/lighthouse/directive_resolvers/auth_resolver.rb +41 -38
- data/lib/lighthouse/directive_resolvers/base.rb +142 -86
- data/lib/lighthouse/directive_resolvers/builder_applier.rb +58 -56
- data/lib/lighthouse/directive_resolvers/cache_resolvers.rb +133 -0
- data/lib/lighthouse/directive_resolvers/can_resolvers.rb +177 -0
- data/lib/lighthouse/directive_resolvers/convention_resolver.rb +70 -0
- data/lib/lighthouse/directive_resolvers/count_resolver.rb +42 -48
- data/lib/lighthouse/directive_resolvers/field_resolver.rb +27 -68
- data/lib/lighthouse/directive_resolvers/find_resolver.rb +42 -52
- data/lib/lighthouse/directive_resolvers/first_resolver.rb +39 -45
- data/lib/lighthouse/directive_resolvers/guard_resolver.rb +16 -22
- data/lib/lighthouse/directive_resolvers/method_resolver.rb +19 -25
- data/lib/lighthouse/directive_resolvers/model_mutation_resolver.rb +219 -0
- data/lib/lighthouse/directive_resolvers/paginate_resolver.rb +70 -128
- data/lib/lighthouse/directive_resolvers/rename_resolver.rb +18 -24
- data/lib/lighthouse/directive_resolvers/resolution.rb +31 -27
- data/lib/lighthouse/directive_resolvers/validator_resolver.rb +193 -0
- data/lib/lighthouse/directive_resolvers/where_conditions_applier.rb +164 -163
- data/lib/lighthouse/directives/arguments/base.rb +23 -31
- data/lib/lighthouse/directives/arguments/filters.rb +113 -93
- data/lib/lighthouse/directives/arguments/order_by.rb +41 -41
- data/lib/lighthouse/directives/pagination_info_types.rb +69 -0
- data/lib/lighthouse/directives/pagination_manipulator.rb +176 -0
- data/lib/lighthouse/directives/registry.rb +210 -141
- data/lib/lighthouse/directives/where_conditions_directive.rb +236 -200
- data/lib/lighthouse/errors.rb +27 -0
- data/lib/lighthouse/federation_directives.rb +53 -0
- data/lib/lighthouse/field_resolver_stack.rb +56 -0
- data/lib/lighthouse/generated_resolvers.rb +82 -0
- data/lib/lighthouse/graphql/version.rb +1 -1
- data/lib/lighthouse/graphql_ruby_patches.rb +18 -0
- data/lib/lighthouse/ide_helper.rb +98 -0
- data/lib/lighthouse/namespaces.rb +21 -0
- data/lib/lighthouse/nested_mutations/belongs_to_operations.rb +50 -0
- data/lib/lighthouse/nested_mutations/executor.rb +149 -0
- data/lib/lighthouse/nested_mutations/has_operations.rb +66 -0
- data/lib/lighthouse/nested_mutations/many_to_many_operations.rb +44 -0
- data/lib/lighthouse/nested_mutations/partitioner.rb +125 -0
- data/lib/lighthouse/nested_mutations.rb +210 -0
- data/lib/lighthouse/pagination.rb +221 -0
- data/lib/lighthouse/persisted_queries.rb +167 -0
- data/lib/lighthouse/railtie.rb +26 -0
- data/lib/lighthouse/rb_lighthouse.rb +50 -29
- data/lib/lighthouse/reference_resolver.rb +13 -15
- data/lib/lighthouse/relation_batch.rb +156 -0
- data/lib/lighthouse/resolver_dispatch.rb +26 -0
- data/lib/lighthouse/schema_cache.rb +120 -0
- data/lib/lighthouse/schema_factory.rb +27 -37
- data/lib/lighthouse/schema_generator.rb +196 -239
- data/lib/lighthouse/schema_implementation.rb +51 -48
- data/lib/lighthouse/sdl_loader.rb +17 -20
- data/lib/lighthouse/support/arguments.rb +119 -0
- data/lib/lighthouse/support/authorization.rb +18 -19
- data/lib/lighthouse/support/batching.rb +16 -0
- data/lib/lighthouse/support/directive_args.rb +26 -24
- data/lib/lighthouse/support/model_resolver.rb +56 -56
- data/lib/lighthouse/support/naming.rb +55 -59
- data/lib/lighthouse/tasks/lighthouse.rake +33 -0
- data/lib/lighthouse/where_conditions/operator_map.rb +25 -29
- metadata +52 -64
- data/lib/lighthouse/directive_resolvers/belongs_to_many_resolver.rb +0 -56
- data/lib/lighthouse/directive_resolvers/belongs_to_resolver.rb +0 -47
- data/lib/lighthouse/directive_resolvers/can_resolver.rb +0 -86
- data/lib/lighthouse/directive_resolvers/has_many_resolver.rb +0 -109
- data/lib/lighthouse/directive_resolvers/has_one_resolver.rb +0 -43
- 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
|