Skip to content
16 changes: 16 additions & 0 deletions bin/graphql-migrate-execution
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require "graphql/migrate_execution"
require "optparse"

doctor_options = {}
parser = OptionParser.new
parser.on("--skip-description", "Don't print migration strategy descriptions")
parser.parse!(into: doctor_options)
filename = ARGV.shift || begin
warn "graphql-migrate-execution requires a filename or path as a first argument, please pass one."
exit 1
end

doctor = GraphQL::MigrateExecution.new(filename, **doctor_options)
doctor.run
43 changes: 43 additions & 0 deletions lib/graphql/migrate_execution.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# frozen_string_literal: true
require "prism"
require "graphql/migrate_execution/action"
require "graphql/migrate_execution/add_future"
require "graphql/migrate_execution/remove_legacy"
require "graphql/migrate_execution/analyze"

require "graphql/migrate_execution/field_definition"
require "graphql/migrate_execution/resolver_method"
require "graphql/migrate_execution/type_definition"
require "graphql/migrate_execution/visitor"

require "graphql/migrate_execution/strategy"
require "graphql/migrate_execution/implicit"
require "graphql/migrate_execution/do_nothing"
require "graphql/migrate_execution/resolve_each"
require "graphql/migrate_execution/resolve_static"
require "graphql/migrate_execution/not_implemented"
require "graphql/migrate_execution/dataloader_all"
require "graphql/migrate_execution/dataloader_batch"
require "graphql/migrate_execution/dataloader_manual"
require "graphql/migrate_execution/dataloader_shorthand"

require "graphql/migrate_execution/not_implemented"

module GraphQL
class MigrateExecution
def initialize(glob, skip_description: false)
@glob = glob
@skip_description = skip_description
end

attr_reader :skip_description

def run
Dir.glob(@glob).each do |filepath|
source = File.read(filepath)
file_migrate = Analyze.new(self, filepath, source)
puts file_migrate.run
end
end
end
end
44 changes: 44 additions & 0 deletions lib/graphql/migrate_execution/action.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class Action
def initialize(migration, path, source)
@migration = migration
@path = path
@source = source
@type_definitions = Hash.new { |h, k| h[k] = TypeDefinition.new(k) }
@field_definitions_by_strategy = Hash.new { |h, k| h[k] = [] }
@total_field_definitions = 0
end

attr_reader :type_definitions

def run
parse_result = Prism.parse(@source, filepath: @path)
visitor = Visitor.new(@source, @type_definitions)
visitor.visit(parse_result.value)
@type_definitions.each do |name, type_defn|
type_defn.field_definitions.each do |f_name, f_defn|
@total_field_definitions += 1
f_defn.check_for_resolver_method
@field_definitions_by_strategy[f_defn.migration_strategy] << f_defn
end
end
nil
end

private

def call_method_on_strategy(method_name)
new_source = @source.dup
@field_definitions_by_strategy.each do |strategy_class, field_definitions|
strategy = strategy_class.new
field_definitions.each do |field_defn|
strategy.public_send(method_name, field_defn, new_source)
end
end
new_source
end
end
end
end
11 changes: 11 additions & 0 deletions lib/graphql/migrate_execution/add_future.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class AddFuture < Action
def run
super
call_method_on_strategy(:add_future)
end
end
end
end
25 changes: 25 additions & 0 deletions lib/graphql/migrate_execution/analyze.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class Analyze < Action
def run
super
message = "Found #{@total_field_definitions} field definitions:".dup

@field_definitions_by_strategy.each do |strategy_class, definitions|
message << "\n\n#{strategy_class.name.split("::").last} (#{definitions.size}):"
if !@migration.skip_description
message << "\n#{strategy_class::DESCRIPTION.split("\n").map { |l| l.length > 0 ? " #{l}" : l }.join("\n")}\n"
end
max_path = definitions.map { |f| f.path.size }.max + 2
definitions.each do |field_defn|
name = field_defn.path.ljust(max_path)
message << "\n - #{name} (#{field_defn.resolve_mode.inspect} -> #{field_defn.resolve_mode_key.inspect}) @ #{@path}:#{field_defn.source_line}"
end
end

message
end
end
end
end
61 changes: 61 additions & 0 deletions lib/graphql/migrate_execution/dataloader_all.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# frozen_string_literal: true
# rubocop:disable Development/ContextIsPassedCop
module GraphQL
class MigrateExecution
class DataloaderAll < Strategy
DESCRIPTION = <<~DESC
These fields can be migrated to a `.load_all` call.
DESC

def add_future(field_definition, new_source)
inject_resolve_keyword(new_source, field_definition, :resolve_batch)
def_node = field_definition.resolver_method.node
call_node = def_node.body.body.first
case call_node.name
when :request, :load
load_arg_node = call_node.arguments.arguments.first
with_node = call_node.receiver
source_class_node, *source_args_nodes = with_node.arguments
when :dataload
source_class_node, *source_args_nodes, load_arg_node = call_node.arguments.arguments
else
raise ArgumentError, "Unexpected DataloadAll method name: #{def_node.name.inspect}"
end

old_load_arg_s = load_arg_node.slice
new_load_arg_s = case old_load_arg_s
when "object"
"objects"
when /object((\.|\[)[:a-zA-Z0-9_\.\"\'\[\]]+)/
call_chain = $1
if /^\.[a-z0-9_A-Z]+$/.match?(call_chain)
"objects.map(&:#{call_chain[1..-1]})"
else
"objects.map { |obj| obj#{call_chain} }"
end
else
raise ArgumentError, "Failed to transform Dataloader argument: #{old_load_arg_s.inspect}"
end
new_args = [
source_class_node.slice,
*source_args_nodes.map(&:slice),
new_load_arg_s
].join(", ")

old_method_source = def_node.slice_lines
new_method_source = old_method_source.sub(/def ([a-z_A-Z0-9]+)(\(|$| )/) do
is_adding_args = $2.size == 0
"def self.#{$1}#{is_adding_args ? "(" : $2}objects, context#{is_adding_args ? ")" : ", "}"
end
new_method_source.sub!(call_node.slice, "context.dataload_all(#{new_args})")

combined_new_source = new_method_source + "\n" + old_method_source
new_source.sub!(old_method_source, combined_new_source)
end

def remove_legacy(field_definition, new_source)
remove_resolver_method(new_source, field_definition)
end
end
end
end
10 changes: 10 additions & 0 deletions lib/graphql/migrate_execution/dataloader_batch.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class DataloaderBatch < Strategy
DESCRIPTION = <<~DESC
These fields can be rewritten to dataload in a `resolve_batch:` method.
DESC
end
end
end
11 changes: 11 additions & 0 deletions lib/graphql/migrate_execution/dataloader_manual.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class DataloaderManual < Strategy
DESCRIPTION = <<~DESC
These fields use Dataloader in a way that can't be automatically migrated. You'll have to migrate them manually.
If you have a lot of these, consider opening up an issue on GraphQL-Ruby -- maybe we can find a way to programmatically support them.
DESC
end
end
end
26 changes: 26 additions & 0 deletions lib/graphql/migrate_execution/dataloader_shorthand.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class DataloaderShorthand < Strategy
DESCRIPTION = <<~DESC
These fields can use a `dataload: ...` configuration.
DESC

def add_future(field_definition, new_source)
rm = field_definition.resolver_method
if (da = rm.dataload_association)
dataload_config = "{ association: #{da.inspect} }"
elsif rm.source_arg_nodes.empty?
dataload_config = rm.source_class_node.full_name
else
dataload_config = "{ with: #{rm.source_class_node.full_name}, by: [#{rm.source_arg_nodes.map { |n| Visitor.source_for_constant_node(n) }.join(", ")}] }"
end
inject_field_keyword(new_source, field_definition, :dataload, dataload_config)
end

def remove_legacy(field_definition, new_source)
remove_resolver_method(new_source, field_definition)
end
end
end
end
8 changes: 8 additions & 0 deletions lib/graphql/migrate_execution/do_nothing.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class DoNothing < Strategy
DESCRIPTION = "These field definitions are already future-compatible. No migration is required."
end
end
end
101 changes: 101 additions & 0 deletions lib/graphql/migrate_execution/field_definition.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class FieldDefinition
def initialize(type_definition, name, node)
@type_definition = type_definition
@name = name.to_sym
@node = node

@resolve_mode = nil
@hash_key = nil
@resolver = nil
@type_instance_method = nil
@object_direct_method = nil
@dig = nil
@already_migrated = nil

@resolver_method = nil
@unknown_options = []
end

def migration_strategy
case resolve_mode
when nil, :implicit_resolve
Implicit
when :hash_key, :object_direct_method, :dig
DoNothing
when :already_migrated
case @already_migrated.keys.first
when :resolve_each
ResolveEach
when :resolve_static
ResolveStatic
when :resolve_batch
NotImplemented
else
raise ArgumentError, "Unexpected already_migrated: #{@already_migrated.inspect}"
end
when :type_instance_method
resolver_method.migration_strategy
when :resolver
NotImplemented
else
raise "No migration strategy for resolve_mode #{@resolve_mode.inspect}"
end
end

attr_reader :name, :node, :unknown_options, :type_definition, :resolve_mode

def source
node.location.slice
end

def future_resolve_shorthand
method_name = resolver_method.name
name == method_name ? true : method_name
end

attr_writer :resolve_mode

attr_accessor :hash_key, :object_direct_method, :type_instance_method, :resolver, :dig, :already_migrated

def path
@path ||= "#{type_definition.name}.#{@name}"
end

def source_line
@node.location.start_line
end

def resolver_method
case @resolver_method
when nil
method_name = @type_instance_method || @name
@resolver_method = @type_definition.resolver_methods[method_name] || :NOT_FOUND
resolver_method
when :NOT_FOUND
nil
else
@resolver_method
end
end

def implicit_resolve
@name
end

def resolve_mode_key
resolve_mode && public_send(resolve_mode)
end

def check_for_resolver_method
if resolve_mode.nil? && (resolver_method)
@resolve_mode = :type_instance_method
@type_instance_method = @name
end
nil
end
end
end
end
13 changes: 13 additions & 0 deletions lib/graphql/migrate_execution/implicit.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class Implicit < Strategy
DESCRIPTION = <<~DESC
These fields use GraphQL-Ruby's default, implicit resolution behavior. It's changing in the future, please audit these fields and choose a migration strategy:

- `--preserve-implicit`: Don't add any new configuration; use GraphQL-Ruby's future direct method send behavior (ie `object.public_send(field_name, **arguments)`)
- `--shim-implicit`: Add a method to preserve GraphQL-Ruby's previous dynamic implicit behavior (ie, checking for `respond_to?` and `key?`)
DESC
end
end
end
8 changes: 8 additions & 0 deletions lib/graphql/migrate_execution/not_implemented.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# frozen_string_literal: true
module GraphQL
class MigrateExecution
class NotImplemented < Strategy
DESCRIPTION = "GraphQL-Ruby doesn't have a migration strategy for these fields. Automated migration may be possible -- please open an issue on GitHub with the source for these fields to investigate."
end
end
end
Loading
Loading