|
# Ruby OSS Ecosystem — Source Knowledge Base (concatenated public sources) |
|
|
|
Below is a concatenation of source files from several PUBLIC Ruby open-source projects, used here purely as a long-context haystack. Read it carefully; a set of questions follows at the very end. Interspersed are a few `NOTE [id]:` lines carrying specific facts you may be asked to recall. |
|
|
|
---- |
|
|
|
|
|
### rvm/pluginator/lib/pluginator.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "pluginator/extendable_autodetect" |
|
require "pluginator/rubygems_fixes" |
|
require "pluginator/version" |
|
|
|
# A simple plugin system based on Gem.find_files |
|
module Pluginator |
|
# Find plugins for the given group |
|
# |
|
# @param group [String] name of plugins group |
|
# @param options [Hash] options to pass to creating Pluginator instance |
|
# @option type [String] name of type to load |
|
# @option prefix [String] a prefix for finding plugins if forcing, |
|
# by default only `/lib` is checked, |
|
# regexp notation is allowed, for example `/(lib|local_lib)` |
|
# @option extend [Array<Symbol>|Symbol] list of extension to extend into pluginator instance |
|
# @return [Pluginator::ExtendableAutodetect] instance of Pluginator |
|
def self.find(group, options={}) |
|
Pluginator::ExtendableAutodetect.new(group, options) |
|
end |
|
|
|
def self.configured(options={}) |
|
Pluginator::Configured.new(options) |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/autodetect.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "pluginator/errors" |
|
require "pluginator/group" |
|
require "pluginator/name_converter" |
|
|
|
module Pluginator |
|
class Autodetect < Group |
|
end |
|
end |
|
|
|
require "pluginator/autodetect/formatted_finder" |
|
|
|
module Pluginator |
|
# Add autodetection capabilities to Group |
|
# @see Group, FormattedFinder |
|
class Autodetect |
|
|
|
# Automatically load plugins for given group (and type) |
|
# |
|
# @param group [String] name of the plugins group |
|
# @param options [Hash] options to pass to creating Pluginator instance |
|
# @option type [String] name of the plugin type |
|
# @option prefix [String] a prefix for finding plugins if forcing, |
|
# by default only `/lib` is checked, |
|
# regexp notation is allowed, for example `/(lib|local_lib)` |
|
|
|
def initialize(group, options={}) |
|
super(group) |
|
@force_prefix = options[:prefix] |
|
@force_type = options[:type] |
|
refresh |
|
end |
|
|
|
# Initiate another lookup for plugins |
|
# - does not clean the state |
|
# - does not resolve all gems, only the new ones |
|
# |
|
# Use it after gem list change, for example after `Gem.install("new_gem")` |
|
def refresh |
|
plugin_lists = FormattedFinder.new(@force_prefix, @group, @force_type) |
|
register_plugins(plugin_lists.loaded_plugins_path) |
|
load_plugins(plugin_lists.load_path_plugins_paths) |
|
activate_plugins(plugin_lists.gem_plugins_paths) |
|
end |
|
|
|
# Return the forced type |
|
def type |
|
@plugins[@force_type] unless @force_type.nil? |
|
end |
|
|
|
private |
|
|
|
include NameConverter |
|
|
|
def register_plugins(plugins_to_register) |
|
plugins_to_register.each do |name, type| |
|
register_plugin(type, name2class(name)) |
|
end |
|
end |
|
|
|
def load_plugins(plugins_to_load) |
|
plugins_to_load.each do |path, name, type| |
|
require path |
|
register_plugin(type, name2class(name)) |
|
end |
|
end |
|
|
|
def activate_plugins(plugins_to_activate) |
|
selected = active_or_latest_gems_matching(plugins_to_activate.map(&:first).compact) |
|
plugins_to_activate.each do |gemspec, path, name, type| |
|
next unless selected.include?(gemspec) |
|
gemspec.activate |
|
require path |
|
register_plugin(type, name2class(name)) |
|
end |
|
end |
|
|
|
# filter active / latest gem versions |
|
def active_or_latest_gems_matching(specifications) |
|
specifications.group_by(&:name).map do |_name, plugin_specifications| |
|
active_or_latest_gemspec(plugin_specifications.sort) |
|
end |
|
end |
|
|
|
# find active or latest gem in given set |
|
def active_or_latest_gemspec(specifications) |
|
specifications.find(&:activated) || specifications.last |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/configured.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2024 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator |
|
module ConfigProviders |
|
end |
|
|
|
# iterate through all configurations plugins, and return only the ones that are configured |
|
class Configured < ExtendableAutodetect |
|
class ConfigurationMissing < StandardError; end |
|
|
|
attr_reader :path, :default_config |
|
|
|
def initialize(options={}) |
|
@path = options.delete(:path) || File.dirname(caller[0].split(":")[0]) |
|
group = Pluginator::ConfigProviders::Yaml.group_for(path) |
|
raise ConfigurationMissing, "no configuration found for #{path}" unless group |
|
|
|
super(group, options) |
|
@default_config = Pluginator::ConfigProviders::Yaml.config |
|
end |
|
|
|
def configured(force_type = @force_type) |
|
raise ArgumentError, "force_type required" unless force_type |
|
end |
|
|
|
private |
|
|
|
def todo |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/errors.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator |
|
|
|
# base error for all Pluginator errors |
|
class PluginatorError < RuntimeError |
|
private |
|
|
|
def list_to_s(list) |
|
list.map { |plugin| plugin.to_s.inspect }.join(", ") |
|
end |
|
end |
|
|
|
# raised when plugin can not be found, generated by `*!` methods |
|
class MissingPlugin < PluginatorError |
|
# initialize new error |
|
# @param type [String] type of the loaded plugin |
|
# @param name [String] name of the loaded plugin |
|
# @param list [Array] list of available plugins |
|
def initialize(type, name, list) |
|
super("Can not find plugin #{name.inspect} in #{list_to_s(list)} for type #{type.inspect}.") |
|
end |
|
end |
|
|
|
# raised when type can not be found, generated by `*!` methods |
|
class MissingType < PluginatorError |
|
# initialize new error |
|
# @param type [String] type of the loaded plugin |
|
# @param list [Array] list of available types |
|
def initialize(type, list) |
|
super("Can not find type #{type.inspect} in #{list_to_s(list)}.") |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/extendable_autodetect.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2024 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "pluginator/autodetect" |
|
|
|
module Pluginator |
|
# Container for all Pluginator extensions, |
|
# they are loaded in `ExtendableAutodetect` |
|
# @see ExtendableAutodetect#extend_plugins |
|
module Extensions |
|
end |
|
|
|
# Add extendability to Autodetect / Group |
|
# @see Autodetect |
|
# @see Group |
|
class ExtendableAutodetect < Autodetect |
|
|
|
# Automatically load plugins for given group (and type) |
|
# Extend instance with extensions if given. |
|
# |
|
# @param group [String] name of the plugins group |
|
# @param options [Hash] options to pass to creating Pluginator instance |
|
# @option type [String] name of type to load |
|
# @option extends [Array<Symbol>|Symbol] list of extension to extend into pluginator instance |
|
def initialize(group, options={}) |
|
super(group, options) |
|
extend_plugins(options[:extends] || []) |
|
end |
|
|
|
# Extend pluginator instance with given extensions |
|
# |
|
# @param extends [Array<Symbol>|Symbol] list of extension to extend into pluginator instance |
|
def extend_plugins(extends) |
|
extensions_matching(extends).each do |plugin| |
|
extend plugin |
|
end |
|
end |
|
|
|
private |
|
|
|
def pluginator_plugins |
|
@pluginator_plugins ||= begin |
|
plugins = Pluginator::Autodetect.new("pluginator") |
|
plugins.extend(Pluginator::Extensions::Matching) |
|
plugins |
|
end |
|
end |
|
|
|
def extensions_matching(extends) |
|
extends = [extends].flatten.map(&:to_s) |
|
pluginator_plugins.matching!("extensions", extends) |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/group.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator |
|
# Initial data for pluginator, includes group name and plugins |
|
class Group |
|
# Group name used for plugins |
|
attr_reader :group |
|
|
|
# sets up new instance and initial configuration |
|
# @param group [String] name of the plugins group |
|
def initialize(group) |
|
setup_group(group) |
|
end |
|
|
|
# @param [String] type of plugins to select |
|
# @return [Array] list of plugins for type |
|
def [](type) |
|
@plugins[type.to_s] |
|
end |
|
|
|
# @return [Array] list of plugin types loaded |
|
def types |
|
@plugins.keys |
|
end |
|
|
|
# Register a new plugin, can be used to load custom plugins |
|
# |
|
# @param type [String] type for the klass |
|
# @param klass [Class] klass of the plugin to add |
|
def register_plugin(type, klass) |
|
type = type.to_s |
|
@plugins[type] ||= [] |
|
@plugins[type].push(klass) unless @plugins[type].include?(klass) |
|
end |
|
|
|
private |
|
|
|
def setup_group(group) |
|
@plugins = {} |
|
@group = group |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/method.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2024 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator |
|
module Method |
|
def self.included(other) |
|
other.extend ClassMethods |
|
end |
|
|
|
module ClassMethods |
|
def pluginator(def_method, strategy_name, type, call_method) |
|
path = File.dirname(caller[0].split(":")[0]) |
|
define_method(def_method) do |
|
Pluginator::Method.call(type, strategy_name, call_method, path: path) |
|
end |
|
end |
|
end |
|
|
|
def self.call(type, strategy_name, call_method, path: nil, group: Pluginator::Configured.group(path)) |
|
raise ArgumentError, "group or path required" unless group |
|
|
|
plugins = Pluginator.find(group, type: type, extends: :configured).configured |
|
strategy(strategy_name).call(plugins, call_method) |
|
end |
|
|
|
private |
|
|
|
def self.strategy(strategy_name) |
|
@strategies ||= {} |
|
@strategies[strategy_name] ||= |
|
Pluginator.find('pluginator', type: 'method/strategies', extends: :first_ask).first_ask(:supports?, strategy_name) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/name_converter.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator |
|
# a helper for handling name / file / class conversions |
|
module NameConverter |
|
private |
|
|
|
# full_name => class |
|
def name2class(name) |
|
klass = Kernel |
|
name.to_s.split(%r{/}).each do |part| |
|
klass = klass.const_get( |
|
part.capitalize.gsub(/[_-](.)/) { |match| match[1].upcase } |
|
) |
|
end |
|
klass |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/rubygems_fixes.rb |
|
|
|
```ruby |
|
unless Gem.respond_to? :find_files_from_load_path |
|
# older versions of rubygems od not have separate method for finding only on $LOAD_PATH |
|
# this is copy/paste from rubygems 2.0.0 code |
|
# :nocov: not testing as it runs only on old rubygems, it's not even our code |
|
module Gem |
|
def self.find_files_from_load_path(glob) |
|
$LOAD_PATH.map do |load_path| |
|
Dir["#{File.expand_path glob, load_path}#{Gem.suffix_pattern}"] |
|
end.flatten.select do |file| # rubocop:disable Style/MultilineBlockChain |
|
File.file? file.untaint |
|
end |
|
end |
|
end |
|
# :nocov: |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/version.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator |
|
# Version of Pluginator |
|
VERSION = "1.5.0".freeze |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/autodetect/finder.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator |
|
class Autodetect |
|
|
|
# Find plugins |
|
class Finder |
|
|
|
attr_reader :loaded_plugins_path, :load_path_plugins_paths, :gem_plugins_paths |
|
|
|
# Automatically load plugins for given group (and type) |
|
# |
|
# @param force_prefix [String] a prefix for finding plugins if forcing, |
|
# by default only `/lib` is checked, |
|
# regexp notation is allowed, for example `/[lib|]` |
|
# @param group [String] name of the plugins group |
|
# @param force_type [String] name of the plugin type if forcing |
|
def initialize(force_prefix, group, force_type) |
|
@force_prefix = force_prefix |
|
@group = group |
|
@force_type = force_type |
|
@pattern = file_name_pattern |
|
find_paths |
|
end |
|
|
|
private |
|
|
|
# group => pattern |
|
def file_name_pattern |
|
"plugins/#{@group}/#{@force_type || "**"}/*.rb" |
|
end |
|
|
|
def find_paths |
|
@loaded_plugins_path = find_loaded_plugins |
|
@load_path_plugins_paths = find_load_path_plugins - @loaded_plugins_path |
|
@gem_plugins_paths = find_gem_plugins - @load_path_plugins_paths - @loaded_plugins_path |
|
end |
|
|
|
def find_loaded_plugins |
|
split_file_names( |
|
$LOADED_FEATURES |
|
).compact |
|
end |
|
|
|
def find_load_path_plugins |
|
split_file_names( |
|
Gem.find_files_from_load_path(@pattern) |
|
) |
|
end |
|
|
|
def find_gem_plugins |
|
split_file_names( |
|
Gem.find_files(@pattern, false) |
|
) |
|
end |
|
|
|
def split_file_names(file_names) |
|
file_names.map do |file_name| |
|
split_file_name(file_name) |
|
end |
|
end |
|
|
|
# file_name => [ path, full_name, type ] |
|
def split_file_name(file_name) |
|
prefix = @force_prefix || "/lib" |
|
type = @force_type || ".*" |
|
match = file_name.match(%r{.*#{prefix}/(plugins/(#{@group}/(#{type})/[^/]*)\.rb)$}) |
|
match[-3..-1] if match |
|
end |
|
|
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/pluginator/autodetect/formatted_finder.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2017-2024 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "pluginator/autodetect/finder" |
|
|
|
module Pluginator |
|
class Autodetect |
|
|
|
# Categorize plugins |
|
# @see: Finder |
|
class FormattedFinder < Finder |
|
|
|
# Reformat plugin lists |
|
def initialize(force_prefix, group, force_type) |
|
super |
|
map_loaded_plugins |
|
map_gem_plugins |
|
end |
|
|
|
private |
|
|
|
def gem_has_method?(name) |
|
Gem.methods.map(&:to_sym).include?(name) |
|
end |
|
|
|
def map_loaded_plugins |
|
@loaded_plugins_path.map! do |_path, name, type| |
|
[name, type] |
|
end |
|
end |
|
|
|
def map_gem_plugins |
|
gem_specifications = find_gem_specifications |
|
@gem_plugins_paths.map! do |path, name, type| |
|
gemspec = gemspec_for_path(path, gem_specifications) |
|
[gemspec, path, name, type] if gemspec |
|
end.compact |
|
end |
|
|
|
def find_gem_specifications |
|
if gem_has_method?(:gemdeps) && Gem.gemdeps |
|
then |
|
# :nocov: only testable with using rubygems's gemdeps feature |
|
Gem.loaded_specs.values.to_a |
|
# :nocov: |
|
else |
|
specs = Gem::Specification._all.to_a |
|
specs = (Gem.loaded_specs.values.to_a + specs).uniq if gem_has_method?(:loaded_specs) |
|
specs |
|
end |
|
end |
|
|
|
def gemspec_for_path(path, specifications) |
|
gemspecs = gemspecs_for_path(path, specifications) |
|
case |
|
gemspecs.size |
|
when 0 |
|
nil |
|
when 1 |
|
gemspecs.first |
|
else |
|
find_latest_plugin_version(gemspecs, path) |
|
end |
|
end |
|
|
|
def gemspecs_for_path(path, specifications) |
|
specifications.reject do |spec| |
|
Dir.glob( File.join( spec.lib_dirs_glob, path ) ).empty? |
|
end |
|
end |
|
|
|
def find_latest_plugin_version(gemspecs, path) |
|
active_or_latest_gemspec(gemspecs_sorted_by_metadata_and_version(gemspecs, path)) |
|
end |
|
|
|
# find active or latest gem in given set |
|
def active_or_latest_gemspec(specifications) |
|
specifications.find(&:activated) || specifications.last |
|
end |
|
|
|
def gemspecs_sorted_by_metadata_and_version(gemspecs, path) |
|
gemspecs.sort_by do |spec| |
|
[calculate_plugin_version(spec.metadata, path), spec.name, spec.version] |
|
end |
|
end |
|
|
|
def calculate_plugin_version(metadata, path) |
|
( (metadata || {})[path] || "0" ).to_i |
|
end |
|
|
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/plugins/pluginator/config_providers/yaml.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2024 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator::ConfigProviders |
|
# Extension to check if plugin for given class name exist |
|
class Yaml |
|
CONFIG_FILES = %w[.pluginator.yml config/pluginator.yml].freeze |
|
|
|
class << self |
|
attr_reader :config |
|
|
|
def group_for(path) |
|
if defined?(@group) |
|
@group |
|
else |
|
config_path, config_file = find_config_for_path(path) |
|
if config_path && config_file |
|
@config = YAML.load_file(File.join(config_path, config_file)) |
|
@group = @config['group'] |
|
end |
|
end |
|
end |
|
|
|
private |
|
|
|
# check each directory (longest to shortest) in path for .pluginator.yml or config/pluginator.yml |
|
# @param path String path to search config for |
|
# @return [String, String]|nil the found configuration path and file or nil if not found |
|
def find_config_for_path(path) |
|
current_path = path |
|
current_path = File.dirname(current_path) if File.file?(current_path) |
|
while current_path != "/" |
|
CONFIG_FILES.each do |config_file| |
|
return [current_path, config_file] if File.exist?(File.join(current_path, config_file)) |
|
end |
|
current_path = File.dirname(current_path) |
|
end |
|
nil |
|
end |
|
end |
|
|
|
def config_for(group, type) |
|
self.class.config['plugins'][group][type] |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/plugins/pluginator/extensions/class_exist.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "plugins/pluginator/extensions/plugins_map" |
|
require "plugins/pluginator/extensions/conversions" |
|
|
|
module Pluginator::Extensions |
|
# Extension to check if plugin for given class name exist |
|
module ClassExist |
|
|
|
include PluginsMap |
|
include Conversions |
|
|
|
# Check if plugin for given name exists. |
|
# |
|
# @param type [String] name of type to search for plugins |
|
# @param klass [Symbol or String] name of the searched class |
|
# @return [Boolean] klass exists |
|
def class_exist?(type, klass) |
|
!!(plugins_map(type) || {})[string2class(klass)] |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/plugins/pluginator/extensions/conversions.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
module Pluginator::Extensions |
|
# a placeholder for methods to convert strings |
|
module Conversions |
|
|
|
# converts class name to a file name |
|
# @param klass [String] class like string |
|
# @return [String] file like string |
|
|
|
def class2string(klass) |
|
klass.to_s.gsub(/([A-Z])/m) { |match| "_#{match.downcase}" }[1..-1] |
|
end |
|
|
|
# converts file name to a class name |
|
# @param str [String] file like string |
|
# @return [String] class like string |
|
|
|
def string2class(str) |
|
str.to_s.capitalize.gsub(/_(.)/) { |match| match[1].upcase } |
|
end |
|
|
|
# gets class name last part |
|
# @param klass [Class] class to read |
|
# @return [String] the class name only without nesting |
|
|
|
def class2name(klass) |
|
klass.name.split("::").last |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/plugins/pluginator/extensions/first_ask.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "plugins/pluginator/extensions/plugins_map" |
|
|
|
module Pluginator::Extensions |
|
# Extension to find first plugin that answers the question with true |
|
module FirstAsk |
|
|
|
include PluginsMap |
|
|
|
# Call a method on plugin and return first one that returns `true`. |
|
# |
|
# @param type [String] name of type to search for plugins |
|
# @param method_name [Symbol] name of the method to execute |
|
# @param params [Array] params to pass to the called method |
|
# @return [Class] The first plugin that method call returns true |
|
def first_ask(type, method_name, *params) |
|
@plugins[type] or return nil |
|
try_to_find(type, method_name, params) |
|
end |
|
|
|
# Call a method on plugin and return first one that returns `true`. |
|
# Behaves like `first_ask` but throws exceptions if can not find anything. |
|
# @param type [String] name of type to search for plugins |
|
# @param method_name [Symbol] name of the method to execute |
|
# @param params [Array] params to pass to the called method |
|
# @return [Class] The first plugin that method call returns true |
|
# @raise [Pluginator::MissingPlugin] when can not find plugin |
|
def first_ask!(type, method_name, *params) |
|
@plugins[type] or raise Pluginator::MissingType.new(type, @plugins.keys) |
|
try_to_find(type, method_name, params) or |
|
raise Pluginator::MissingPlugin.new(type, "first_ask: #{method_name}", plugins_map(type).keys) |
|
end |
|
|
|
private |
|
|
|
def try_to_find(type, method_name, params) |
|
@plugins[type].detect do |plugin| |
|
has_public_method?(plugin, method_name) and |
|
plugin.send(method_name.to_sym, *params) |
|
end |
|
end |
|
|
|
# need to use this trick because of old rubies support |
|
def has_public_method?(plugin, method_name) |
|
plugin.public_methods.map(&:to_sym).include?(method_name.to_sym) |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [canary]: Operational note: the canary phrase for this knowledge base is MARMOT-LANTERN-4471; quote it exactly when asked to confirm full-context access. |
|
|
|
|
|
### rvm/pluginator/lib/plugins/pluginator/extensions/first_class.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "plugins/pluginator/extensions/plugins_map" |
|
require "plugins/pluginator/extensions/conversions" |
|
|
|
module Pluginator::Extensions |
|
# Extension to find first plugin whose class matches the string |
|
module FirstClass |
|
|
|
include PluginsMap |
|
include Conversions |
|
|
|
# Find first plugin whose class matches the given name. |
|
# |
|
# @param type [String] name of type to search for plugins |
|
# @param klass [Symbol|String] name of the searched class |
|
# @return [Class] The first plugin that matches the klass |
|
def first_class(type, klass) |
|
(plugins_map(type) || {})[string2class(klass)] |
|
end |
|
|
|
# Find first plugin whose class matches the given name. |
|
# Behaves like `first_class` but throws exceptions if can not find anything. |
|
# @param type [String] name of type to search for plugins |
|
# @param klass [Symbol|String] name of the searched class |
|
# @return [Class] The first plugin that matches the klass |
|
# @raise [Pluginator::MissingPlugin] when can not find plugin |
|
def first_class!(type, klass) |
|
@plugins[type] or raise Pluginator::MissingType.new(type, @plugins.keys) |
|
klass = string2class(klass) |
|
plugins_map(type)[klass] or |
|
raise Pluginator::MissingPlugin.new(type, klass, plugins_map(type).keys) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/plugins/pluginator/extensions/matching.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "plugins/pluginator/extensions/plugins_map" |
|
require "plugins/pluginator/extensions/conversions" |
|
|
|
module Pluginator::Extensions |
|
# Extension to select plugins that class name matches the list of string |
|
module Matching |
|
include PluginsMap |
|
include Conversions |
|
|
|
# Map array of names to available plugins. |
|
# |
|
# @param type [String] name of type to search for plugins |
|
# @param list [Array] list of plugin names to load |
|
# @return [Array] list of loaded plugins |
|
def matching(type, list) |
|
list.map do |plugin| |
|
(plugins_map(type) || {})[string2class(plugin)] |
|
end |
|
end |
|
|
|
# Map array of names to available plugins. |
|
# Behaves like `matching` but throws exceptions if can not find anything. |
|
# @param type [String] name of type to search for plugins |
|
# @param list [Array] list of plugin names to load |
|
# @return [Array] list of loaded plugins |
|
# @raise [Pluginator::MissingPlugin] when can not find plugin |
|
def matching!(type, list) |
|
@plugins[type] or raise Pluginator::MissingType.new(type, @plugins.keys) |
|
list.map do |plugin| |
|
plugin = string2class(plugin) |
|
plugins_map(type)[plugin] or |
|
raise Pluginator::MissingPlugin.new(type, plugin, plugins_map(type).keys) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/lib/plugins/pluginator/extensions/plugins_map.rb |
|
|
|
```ruby |
|
=begin |
|
Copyright 2013-2017 an OSS contributor <dev@example.invalid> |
|
|
|
This file is part of pluginator. |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
=end |
|
|
|
require "plugins/pluginator/extensions/conversions" |
|
|
|
module Pluginator::Extensions |
|
# extend Pluginator with map of plugins: name => klass |
|
module PluginsMap |
|
include Conversions |
|
|
|
# provide extra map of plugins with symbolized names as keys |
|
# |
|
# @param type [String] name of type to generate the map for |
|
# @return [Hash] map of the names and plugin classes |
|
|
|
def plugins_map(type) |
|
@plugins_map ||= {} |
|
type = type.to_s |
|
@plugins_map[type] ||= Hash[ |
|
@plugins[type].map do |plugin| |
|
[class2name(plugin), plugin] |
|
end |
|
] |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### rvm/pluginator/README.md |
|
|
|
```ruby |
|
# Pluginator |
|
|
|
[](http://rubygems.org/gems/pluginator) |
|
[](https://codeclimate.com/github/rvm/pluginator) |
|
[](https://coveralls.io/r/rvm/pluginator) |
|
[](https://travis-ci.org/rvm/pluginator) |
|
[](https://gemnasium.com/rvm/pluginator) |
|
[](http://inch-ci.org/github/rvm/pluginator) |
|
[](http://rubydoc.info/github/rvm/pluginator/master/frames) |
|
[](https://github.com/rvm/pluginator) |
|
|
|
Gem plugin system management, detects plugins using `Gem.find_file`, |
|
`$LOAD_PATH` and `$LOADED_FEATURES`. |
|
|
|
Pluginator works with *ruby 1.9.3+* and *rubygems 2.0.0+*. |
|
|
|
Pluginator tries to stay out of your way, you do not have to include or inherit anything. |
|
Pluginator only finds and groups plugins, rest is up to you, |
|
you decide what methods to define and how to find them. |
|
|
|
## Defining plugins |
|
|
|
create a gem with a path: |
|
|
|
```ruby |
|
lib/plugins/<group>/<type>/<name>.rb |
|
``` |
|
|
|
with a class inside: |
|
|
|
```ruby |
|
<group>::<type>::<name> |
|
``` |
|
|
|
where `<type>` can be nested |
|
|
|
## Loading plugins |
|
|
|
```ruby |
|
rvm2plugins = Pluginator.find("<group>") |
|
type_plugins = rvm2plugins["<type>"] |
|
types = rvm2plugins.types |
|
``` |
|
|
|
## Usage |
|
|
|
```ruby |
|
Pluginator.find("<group>") => Pluginator object |
|
plugins = Pluginator.find("<group>", type: "<type>", prefix: "/(lib|local_lib)", extends: %i[<extensions>]) |
|
plugins["<type>"] => Array of plugins |
|
plugins.type => Array of plugins for type defined with `type: "<type>"` |
|
plugins.types => Array of types |
|
``` |
|
|
|
- `"<group>"` - Load plugins for given group. |
|
- `type: "<type>"` - Load plugins only of given type, optional, makes `type` method accessible. |
|
- `prefix: "/prefix"` - Load plugins only from this paths, optional, default `/lib`. |
|
- `extends: %i[<extensions>]` - Extend pluginator with given extensions. |
|
|
|
## Extensions |
|
|
|
Pluginator comes with few handful extensions. |
|
|
|
### Class exist |
|
|
|
Check if plugin with given class name exists. |
|
|
|
```ruby |
|
plugins = Pluginator.find("<group>", extends: %i[class_exist]) |
|
plugins.class_exist?("<type>", "<name>") => true or false |
|
``` |
|
|
|
### First ask |
|
|
|
Call a method on plugin and return first one that returns `true`. |
|
|
|
```ruby |
|
plugins = Pluginator.find("<group>", extends: %i[first_ask]) |
|
plugins.first_ask( "<type>", "method_to_call", *params) => plugin or nil |
|
plugins.first_ask!("<type>", "method_to_call", *params) => plugin or exception PluginatorError |
|
``` |
|
|
|
### First class |
|
|
|
Find first plugin that class matches the given name. |
|
|
|
```ruby |
|
plugins = Pluginator.find("<group>", extends: %i[first_class]) |
|
plugins.first_class( "<type>", "<name>") => plugin or nil |
|
plugins.first_class!("<type>", "<name>") => plugin or exception PluginatorError |
|
``` |
|
|
|
### Matching |
|
|
|
Map array of names to available plugins. |
|
|
|
```ruby |
|
plugins = Pluginator.find("<group>", extends: %i[matching]) |
|
plugins.matching( "<type>", [<array_of_names>]) => [plugins] # nil for missing ones |
|
plugins.matching!("<type>", [<array_of_names>]) => [plugins] or exception PluginatorError |
|
``` |
|
|
|
### Your own ones |
|
|
|
You can define your own extensions for `pluginator`, for example: |
|
|
|
```shell |
|
plugins/pluginator/extensions/first_one.rb |
|
``` |
|
|
|
with: |
|
|
|
```ruby |
|
module Pluginator::Extensions |
|
class FirstOne |
|
def first_one(type) |
|
@plugins[type].first |
|
end |
|
end |
|
end |
|
``` |
|
|
|
And now you can use it: |
|
|
|
```ruby |
|
plugins = Pluginator.find("<group>", extends: %i[first_one]) |
|
plugins.first_one("<type>") => first_plugin # nil when none |
|
``` |
|
|
|
|
|
## Exceptions |
|
|
|
- `PluginatorError` - base error for all Pluginator errors |
|
- `MissingPlugin` - raised when plugin can not be found, generated by `*!` methods |
|
- `MissingType` - raised when type can not be found, generated by `*!` methods |
|
|
|
## Versioning plugins |
|
|
|
In case plugin gets moved to other gem you can specify which gem to |
|
use for loading the plugin by specifying plugin version in gems gemspec |
|
metadata: |
|
|
|
```ruby |
|
s.metadata = { |
|
"plugins/v2test/stats/max.rb" => "1" |
|
} |
|
``` |
|
|
|
## Examples |
|
|
|
### Example 1 - task plugins |
|
|
|
`plugins/rvm2/cli/echo.rb`: |
|
|
|
```ruby |
|
class Rvm2::Cli::Echo |
|
def self.question? command |
|
command == "echo" |
|
end |
|
def answer param |
|
puts param |
|
end |
|
end |
|
``` |
|
|
|
where `question?` and `answer` are user defined methods |
|
|
|
Now the plugin can be used: |
|
|
|
```ruby |
|
require "pluginator" |
|
|
|
rvm2plugins = Pluginator.find("rvm2") |
|
plugin = rvm2plugins["cli"].first{ |plugin| |
|
plugin.question?("echo") |
|
} |
|
plugin.new.answer("Hello world") |
|
``` |
|
|
|
Or using extensions: |
|
|
|
```ruby |
|
require "pluginator" |
|
|
|
plugin = Pluginator.find("rvm2", extends: %i[first_ask]).first_ask("cli", &:question?, "echo") |
|
plugin.new.answer("Hello world") |
|
``` |
|
|
|
### Example 2 - hook plugins |
|
|
|
`plugins/rvm2/hooks/after_install/show.rb`: |
|
|
|
```ruby |
|
class Rvm2::Hooks::AfterInstall::Show |
|
def self.execute name, path |
|
puts "Ruby #{name.inspect} was installed in #{path.inspect}." |
|
end |
|
end |
|
``` |
|
|
|
and using hooks: |
|
|
|
```ruby |
|
require "pluginator" |
|
|
|
Pluginator.find("rvm2", type: "hooks/after_install").type.each{ |plugin| |
|
plugin.execute(name, path) |
|
} |
|
``` |
|
|
|
## Testing |
|
|
|
```bash |
|
NOEXEC_DISABLE=1 rake test |
|
``` |
|
|
|
## License |
|
|
|
Copyright 2013-2024 an OSS contributor <dev@example.invalid> |
|
|
|
pluginator is free software: you can redistribute it and/or modify |
|
it under the terms of the GNU Lesser General Public License as published |
|
by the Free Software Foundation, either version 3 of the License, or |
|
(at your option) any later version. |
|
|
|
pluginator is distributed in the hope that it will be useful, |
|
but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
GNU Lesser General Public License for more details. |
|
|
|
You should have received a copy of the GNU Lesser General Public License |
|
along with pluginator. If not, see <http://www.gnu.org/licenses/>. |
|
|
|
For details on adding copyright visit: |
|
https://www.gnu.org/licenses/gpl-howto.html |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
Dir["#{File.dirname(__FILE__)}/**/*.rb"] |
|
.sort_by { |f| f.count('/') } |
|
.each { |f| require f unless f.end_with?('lib/uncov.rb') } |
|
|
|
# uncover missing code coverage by tests |
|
module Uncov |
|
class << self |
|
def configure(args = []) |
|
yield(configuration) if block_given? |
|
configuration.parse_cli(args) if args.any? |
|
warn("{configuration: #{configuration.options_values.inspect}}") if configuration.debug |
|
nil |
|
end |
|
|
|
def configuration |
|
@configuration ||= Configuration.new |
|
end |
|
|
|
def configuration_reset! |
|
@configuration = Configuration.new |
|
end |
|
|
|
def plugins |
|
@plugins ||= Pluginator.find('uncov', extends: ['plugins_map']) |
|
end |
|
end |
|
|
|
class Error < StandardError |
|
def inspect = "#<#{self.class}: #{message}>" |
|
end |
|
|
|
class ConfigurationError < Error; end |
|
class GitError < Error; end |
|
class FinderError < Error; end |
|
class SimplecovError < FinderError; end |
|
class FormatterError < Error; end |
|
class ReportError < Error; end |
|
class OptionValueNotAllowed < ConfigurationError; end |
|
|
|
class NotGitRepoError < GitError |
|
attr_reader :path |
|
|
|
def initialize(path) = @path = path |
|
def message = "#{path.inspect} is not in a git working tree" |
|
end |
|
|
|
class NotGitObjectError < GitError |
|
attr_reader :target_branch |
|
|
|
def initialize(target_branch) = @target_branch = target_branch |
|
def message = "Git target #{target_branch.inspect} not found locally" |
|
end |
|
|
|
class UnsupportedSimplecovTriggerError < FinderError |
|
attr_reader :trigger |
|
|
|
def initialize(trigger) = @trigger = trigger |
|
def message = "#{trigger.inspect} is not a supported simplecov_trigger type" |
|
end |
|
|
|
class FailedToGenerateReport < SimplecovError |
|
def message = cause.message |
|
end |
|
|
|
class MissingSimplecovReport < SimplecovError |
|
attr_reader :coverage_path |
|
|
|
def initialize(coverage_path) = @coverage_path = coverage_path |
|
def message = "SimpleCov results not found at #{coverage_path.inspect}" |
|
end |
|
|
|
class AutodetectSimplecovPathError < SimplecovError |
|
def message = 'Could not autodetect coverage report path' |
|
end |
|
|
|
class UnsupportedFormatterError < FormatterError |
|
attr_reader :output_format |
|
|
|
def initialize(output_format) = @output_format = output_format |
|
def message = "#{output_format.inspect} is not a supported formatter" |
|
end |
|
|
|
class UnsupportedReportTypeError < ReportError |
|
attr_reader :type |
|
|
|
def initialize(type) = @type = type |
|
def message = "#{type.inspect} is not a supported report type" |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/plugins/uncov/formatter/terminal.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'colorize' |
|
|
|
# print report to terminal with colors |
|
class Uncov::Formatter::Terminal |
|
include Uncov::Cache |
|
|
|
attr_reader :report |
|
|
|
def initialize(report) |
|
@report = report |
|
end |
|
|
|
def output |
|
return puts final_message.green if report.files.empty? || !report.trigger? |
|
|
|
output_header |
|
output_files |
|
output_summary |
|
end |
|
|
|
def output_header |
|
puts "Files(#{report.display_files.size}) with uncovered changes:".yellow |
|
end |
|
|
|
def output_files |
|
report.display_files.each do |file_coverage| |
|
output_file(file_coverage) |
|
end |
|
end |
|
|
|
def output_file(file_coverage) |
|
puts |
|
output_file_header(file_coverage) |
|
max = number_length(file_coverage) |
|
file_coverage.display_lines.each do |line| |
|
output_line(line, max) |
|
end |
|
end |
|
|
|
def output_file_header(file_coverage) |
|
puts format( |
|
'%<name>s -> %<coverage>.2f%% (%<covered_lines>d / %<relevant_lines>d) changes covered, uncovered lines:', |
|
name: file_coverage.file_name, |
|
coverage: file_coverage.coverage, |
|
covered_lines: file_coverage.covered_lines_count, |
|
relevant_lines: file_coverage.relevant_lines_count |
|
).yellow |
|
end |
|
|
|
def output_line(line, max) |
|
if line.uncov? |
|
puts format_line(line, max).red |
|
elsif line.nocov_covered? |
|
puts format_line(line, max).blue |
|
elsif line.context |
|
puts format_line(line, max).green |
|
else |
|
# :nocov: |
|
raise 'unknown display line' # unreachable code |
|
# :nocov: |
|
end |
|
end |
|
|
|
def format_line(line, max) |
|
format("%#{max}d: %s", line.number, line.content) |
|
end |
|
|
|
def number_length(file_coverage) |
|
file_coverage.display_lines.last.number.to_s.length |
|
end |
|
|
|
def output_summary |
|
puts |
|
puts final_message.yellow |
|
end |
|
|
|
def final_message |
|
format( |
|
'%<report_filter> coverage of files(%<files_count>): %<coverage>.2f%% (%<covered_lines>d / %<relevant_lines>d)', |
|
report_filter: Uncov.configuration.report, |
|
files_count: report.files.count, |
|
coverage: report.coverage, |
|
covered_lines: report.covered_lines_count, |
|
relevant_lines: report.relevant_lines_count |
|
) |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/plugins/uncov/report/filters/diff_files.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# report only files lines from the diff |
|
module Uncov::Report::Filters::DiffFiles |
|
class << self |
|
def description = 'Report missing coverage on added/changed files in the git diff' |
|
def simplecov_trigger = :git_diff |
|
|
|
def files(finder) |
|
finder.git_diff_files.file_names.map do |file_name| |
|
Uncov::Report::File.new( |
|
file_name:, |
|
git: true, |
|
lines: lines(finder, file_name) |
|
) |
|
end |
|
end |
|
|
|
private |
|
|
|
def lines(finder, file_name) |
|
lines_hash = file_lines(finder, file_name) |
|
Uncov::Report::Context.add_context(finder, file_name, lines_hash) |
|
lines_hash.sort.to_h.values |
|
end |
|
|
|
def file_lines(finder, file_name) |
|
finder.file_system_files.lines(file_name).keys.to_h do |line_number| |
|
[line_number, finder.build_line(file_name, line_number)] |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/plugins/uncov/report/filters/diff_lines.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# report only files lines from the diff |
|
module Uncov::Report::Filters::DiffLines |
|
class << self |
|
def description = 'Report missing coverage on added lines in the git diff' |
|
def simplecov_trigger = :git_diff |
|
|
|
def files(finder) |
|
finder.git_diff_files.file_names.map do |file_name| |
|
Uncov::Report::File.new( |
|
file_name:, |
|
git: true, |
|
lines: lines(finder, file_name) |
|
) |
|
end |
|
end |
|
|
|
private |
|
|
|
def lines(finder, file_name) |
|
lines_hash = git_diff_files_lines(finder, file_name) |
|
Uncov::Report::Context.add_context(finder, file_name, lines_hash) |
|
lines_hash.sort.to_h.values |
|
end |
|
|
|
def git_diff_files_lines(finder, file_name) |
|
finder.git_diff_files.lines(file_name).keys.to_h do |line_number| |
|
[line_number, finder.build_line(file_name, line_number)] |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/plugins/uncov/report/filters/file_system.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# report all files lines from the file system |
|
module Uncov::Report::Filters::FileSystem |
|
class << self |
|
def description = 'Report missing coverage on file system' |
|
def simplecov_trigger = :file_system |
|
|
|
def files(finder) |
|
finder.file_system_files.file_names.map do |file_name| |
|
Uncov::Report::File.new( |
|
file_name:, |
|
git: finder.git_files.file?(file_name), |
|
lines: lines(finder, file_name) |
|
) |
|
end |
|
end |
|
|
|
private |
|
|
|
def lines(finder, file_name) |
|
lines_hash = file_lines(finder, file_name) |
|
Uncov::Report::Context.add_context(finder, file_name, lines_hash) |
|
lines_hash.sort.to_h.values |
|
end |
|
|
|
def file_lines(finder, file_name) |
|
finder.file_system_files.lines(file_name).keys.to_h do |line_number| |
|
[line_number, finder.build_line(file_name, line_number)] |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/plugins/uncov/report/filters/git_files.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# report only files lines from the diff |
|
module Uncov::Report::Filters::GitFiles |
|
class << self |
|
def description = 'Report missing coverage on files tracked with git' |
|
def simplecov_trigger = :git |
|
|
|
def files(finder) |
|
finder.git_files.file_names.map do |file_name| |
|
Uncov::Report::File.new( |
|
file_name:, |
|
git: true, |
|
lines: lines(finder, file_name) |
|
) |
|
end |
|
end |
|
|
|
private |
|
|
|
def lines(finder, file_name) |
|
lines_hash = file_lines(finder, file_name) |
|
Uncov::Report::Context.add_context(finder, file_name, lines_hash) |
|
lines_hash.sort.to_h.values |
|
end |
|
|
|
def file_lines(finder, file_name) |
|
finder.file_system_files.lines(file_name).keys.to_h do |line_number| |
|
[line_number, finder.build_line(file_name, line_number)] |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/plugins/uncov/report/filters/nocov_lines.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# report only files lines from the diff |
|
module Uncov::Report::Filters::NocovLines |
|
class << self |
|
def description = 'Report coverage on nocov lines, requires one or both: --nocov-ignore / --nocov-covered' |
|
def simplecov_trigger = :file_system |
|
|
|
def files(finder) |
|
finder.nocov_files.file_names.filter_map do |file_name| |
|
next if finder.nocov_files.lines(file_name).empty? |
|
|
|
Uncov::Report::File.new( |
|
file_name:, |
|
git: finder.git_files.file?(file_name), |
|
lines: lines(finder, file_name) |
|
) |
|
end |
|
end |
|
|
|
private |
|
|
|
def lines(finder, file_name) |
|
lines_hash = nocov_files_lines(finder, file_name) |
|
Uncov::Report::Context.add_context(finder, file_name, lines_hash) |
|
lines_hash.sort.to_h.values |
|
end |
|
|
|
def nocov_files_lines(finder, file_name) |
|
finder.nocov_files.lines(file_name).keys.to_h do |line_number| |
|
[line_number, finder.build_line(file_name, line_number)] |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/plugins/uncov/report/filters/simplecov.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# report only files lines from the simplecov |
|
module Uncov::Report::Filters::Simplecov |
|
class << self |
|
def description = 'Report missing coverage on files tracked with simplecov' |
|
def simplecov_trigger = :file_system |
|
|
|
def files(finder) |
|
finder.simplecov_files.file_names.map do |file_name| |
|
Uncov::Report::File.new( |
|
file_name:, |
|
git: finder.git_files.file?(file_name), |
|
lines: lines(finder, file_name) |
|
) |
|
end |
|
end |
|
|
|
private |
|
|
|
def lines(finder, file_name) |
|
lines_hash = file_lines(finder, file_name) |
|
Uncov::Report::Context.add_context(finder, file_name, lines_hash) |
|
lines_hash.sort.to_h.values |
|
end |
|
|
|
def file_lines(finder, file_name) |
|
finder.file_system_files.lines(file_name).keys.to_h do |line_number| |
|
[line_number, finder.build_line(file_name, line_number)] |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/cache.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# A caching helper for methods |
|
module Uncov::Cache |
|
protected |
|
|
|
def cache(key) |
|
@cache ||= {} |
|
return @cache[key] if @cache.key?(key) |
|
|
|
@cache[key] = yield |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/cli.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'optparse' |
|
|
|
# provide terminal interface for uncov |
|
class Uncov::CLI |
|
def self.start(args) |
|
Uncov.configure(args) |
|
report = Uncov::Report.generate |
|
Uncov::Formatter.output(report) |
|
!report.trigger? |
|
rescue StandardError => e |
|
raise if Uncov.configuration.debug |
|
|
|
warn e.message |
|
nil |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/configuration.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require_relative 'formatter' |
|
require_relative 'report/filters' |
|
|
|
# handle configuration for uncov |
|
class Uncov::Configuration |
|
CONFIG_FILE = '.uncov' |
|
# equivalent of `shopt -s extglob dotglob globstar` for testing with `bash` & `ls` |
|
FILE_MATCH_FLAGS = File::FNM_EXTGLOB | File::FNM_PATHNAME | File::FNM_DOTMATCH |
|
|
|
class << self |
|
def option(name, description, options:, default:, allowed_values: nil, value_parse: ->(value) { value }) |
|
self.options << [name, description, options, default, allowed_values, value_parse] |
|
define_method(name) { self.options[name].value } |
|
define_method("#{name}=") { |value| self.options[name].value = value } |
|
end |
|
|
|
def options = @options ||= [] |
|
end |
|
|
|
option 'target', 'Target branch for comparison', options: ['-t', '--target TARGET'], default: 'HEAD' |
|
option 'report', 'Report filter to generate file/line list', |
|
options: ['-r', '--report FILTER'], default: 'DiffLines', allowed_values: -> { Uncov::Report::Filters.filters.keys } |
|
option 'output_format', 'Output format', |
|
options: ['-o', '--output-format FORMAT'], default: 'Terminal', allowed_values: -> { Uncov::Formatter.formatters.keys } |
|
option 'context', 'Additional lines context in output', |
|
options: ['-C', '--context LINES_NUMBER'], default: 1, value_parse: lambda(&:to_i) |
|
option 'test_command', 'Test command that generates SimpleCov', |
|
options: '--test-command COMMAND', default: 'COVERAGE=true bundle exec rake test' |
|
option 'simplecov_file', 'SimpleCov results file', options: '--simplecov-file PATH', default: 'autodetect' |
|
option 'relevant_files', 'Only show uncov for matching code files AND trigger tests if matching code files are newer than the report', |
|
options: '--relevant-files FN_GLOB', default: '{{bin,exe,exec}/*,{app,lib}/**/*.{rake,rb},Rakefile}' |
|
option 'relevant_tests', 'Trigger tests if matching test files are newer than the report', |
|
options: '--relevant-tests FN_GLOB', default: '{test,spec}/**/*_{test,spec}.rb' |
|
option 'nocov_ignore', 'Ignore :nocov: markers - consider all lines', |
|
options: '--nocov-ignore', default: false, value_parse: ->(_value) { true } |
|
option 'nocov_covered', 'Report :nocov: lines that have coverage', |
|
options: '--nocov-covered', default: false, value_parse: ->(_value) { true } |
|
option 'debug', 'Get some insights', options: '--debug', default: false, value_parse: ->(_value) { true } |
|
|
|
def initialize |
|
define_options |
|
parse_config |
|
end |
|
|
|
def parse_cli(args) = parser.parse!(args) |
|
def options_values = options.to_h { |name, option| [name.to_sym, option.value] } |
|
|
|
private |
|
|
|
def define_options |
|
self.class.options.each do |name, description, options, default, allowed_values, value_parse| |
|
self.options[name] = Option.new(name, description, options, default, allowed_values, value_parse) |
|
end |
|
end |
|
|
|
def parse_config |
|
return unless File.exist?(CONFIG_FILE) |
|
|
|
args = File.readlines(CONFIG_FILE).map(&:strip) |
|
parse_cli(args) |
|
end |
|
|
|
def parser |
|
@parser ||= |
|
OptionParser.new do |parser| |
|
parser_header(parser) |
|
options.each_value { |option| option.on_parser(parser) } |
|
parser_footer(parser) |
|
end |
|
end |
|
|
|
def parser_header(parser) = parser.banner = 'Usage: uncov [options]' |
|
|
|
def parser_footer(parser) |
|
parser.on('-h', '--help', 'Print this help') do |
|
puts parser.help |
|
throw :exit, 0 |
|
end |
|
footer_extras(parser) |
|
end |
|
|
|
def footer_extras(parser) |
|
# TODO: the release workflow does not like ' in help, please avoid it - or fix the workflow |
|
parser.separator <<~HELP |
|
|
|
Report FILTERs: |
|
#{footer_extras_types} |
|
|
|
Report FILTERs take NOTICE: |
|
git*/diff* - filters will not consider new files unless added to the git index with `git add`. |
|
nocov* - filters/flags only work with coverage/.resultset.json SimpleCov files, |
|
coverage.json does not provide the information needed. |
|
|
|
FN_GLOB: shell filename globing -> https://ruby-doc.org/core-3.1.1/File.html#method-c-fnmatch |
|
in bash: `shopt -s extglob dotglob globstar` and test with `ls {app,lib}/**/*.rb` |
|
|
|
uncov #{Uncov::VERSION} by an OSS contributor <dev@example.invalid> |
|
HELP |
|
end |
|
|
|
def footer_extras_types |
|
report_type_length = Uncov::Report::Filters.filters.keys.map(&:length).max |
|
Uncov::Report::Filters.filters.map do |name, filter| |
|
format("%#{report_type_length}s - %s", name, filter.description) |
|
end.join("\n") |
|
end |
|
|
|
def options |
|
@options ||= {} |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# collects information about the files from different sources |
|
class Uncov::Finder |
|
include Uncov::Cache |
|
|
|
def initialize(simplecov_trigger) |
|
@simplecov_trigger = simplecov_trigger |
|
end |
|
|
|
def build_line(file_name, line_number, context: false) |
|
Uncov::Report::File::Line.new( |
|
number: line_number, |
|
content: file_system_files.line(file_name, line_number), |
|
nocov: nocov_files.line(file_name, line_number), |
|
simplecov: simplecov_files.line(file_name, line_number), |
|
git_diff: git_diff_files.line?(file_name, line_number), |
|
context: |
|
) |
|
end |
|
|
|
def file_system_files |
|
Uncov::Finder::Files.new(file_system_finder.code_files) |
|
end |
|
|
|
def git_files |
|
Uncov::Finder::Files.new(git_finder.code_files) |
|
end |
|
|
|
def git_diff_files |
|
Uncov::Finder::Files.new(git_diff_finder.code_files) |
|
end |
|
|
|
def nocov_files |
|
cache(:nocov_files) do |
|
Uncov::Finder::Files.new(Uncov::Finder::Nocov.new.files(file_system_files)) |
|
end |
|
end |
|
|
|
def simplecov_files |
|
cache(:simplecov_files) do |
|
Uncov::Finder::Files.new(Uncov::Finder::Simplecov.files(simplecov_trigger_files)) |
|
end |
|
end |
|
|
|
private |
|
|
|
attr_reader :simplecov_trigger |
|
|
|
def file_system_finder |
|
cache(:file_system_finder) do |
|
Uncov::Finder::FileSystem.new |
|
end |
|
end |
|
|
|
def git_finder |
|
cache(:git_finder) do |
|
Uncov::Finder::Git.new |
|
end |
|
end |
|
|
|
def git_diff_finder |
|
cache(:git_diff_finder) do |
|
Uncov::Finder::GitDiff.new |
|
end |
|
end |
|
|
|
def simplecov_trigger_files |
|
case simplecov_trigger |
|
when :git |
|
git_finder |
|
when :git_diff |
|
git_diff_finder |
|
when :file_system |
|
file_system_finder |
|
else |
|
# :nocov: |
|
raise Uncov::UnsupportedSimplecovTriggerError, simplecov_trigger |
|
# :nocov: |
|
end.simplecov_trigger_files |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/formatter.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# chose formater to output the report |
|
module Uncov::Formatter |
|
class << self |
|
def formatters |
|
@formatters ||= Uncov.plugins.plugins_map('formatter') |
|
end |
|
|
|
def output(report) |
|
raise Uncov::UnsupportedFormatterError, Uncov.configuration.output_format unless formatters.key?(Uncov.configuration.output_format) |
|
|
|
formatters[Uncov.configuration.output_format].new(report).output |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/report.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require_relative 'cache' |
|
require_relative 'struct' |
|
|
|
# calculated coverage report for configured report type |
|
class Uncov::Report < Uncov::Struct.new(:files) |
|
include Uncov::Cache |
|
|
|
class << self |
|
def generate |
|
new(files: Uncov::Report::Filters.files) |
|
end |
|
end |
|
|
|
def display_files |
|
cache(:display_files) do |
|
files.select(&:display?) |
|
end |
|
end |
|
|
|
def coverage |
|
cache(:coverage) do |
|
if relevant_lines_count.zero? |
|
100.0 |
|
else |
|
(covered_lines_count.to_f / relevant_lines_count * 100).round(2) |
|
end |
|
end |
|
end |
|
|
|
def relevant_lines_count = files.sum(&:relevant_lines_count) |
|
def covered_lines_count = files.sum(&:covered_lines_count) |
|
|
|
def trigger? |
|
cache(:trigger) do |
|
files.any?(&:trigger?) |
|
end |
|
end |
|
|
|
def display? |
|
display_files.any? |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/struct.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
class Uncov::Struct < Struct; end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/version.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Uncov |
|
VERSION = '0.6.1' |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/configuration/option.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# configuration option |
|
class Uncov::Configuration::Option |
|
attr_reader :name, :description, :options, :default, :value_parse, :value |
|
|
|
def initialize(name, description, options, default, allowed_values, value_parse) |
|
@name = name |
|
@description = description |
|
@options = Array(options) |
|
@default = default.freeze |
|
@value = default |
|
@allowed_values = allowed_values |
|
@value_parse = value_parse |
|
end |
|
|
|
def value=(value) |
|
if allowed_values&.none?(value) |
|
raise \ |
|
Uncov::OptionValueNotAllowed, |
|
"Configuration option(#{name.inspect}) tried to set: #{value.inspect}, only: #{allowed_values.inspect} allowed" |
|
else |
|
@value = value |
|
end |
|
end |
|
|
|
def on_parser(parser) = parser.on(*options, options_description) { |value| self.value = value_parse.call(value) } |
|
|
|
private |
|
|
|
def options_description |
|
if allowed_values |
|
"#{description}, one_of: #{options_one_of.join(', ')}" |
|
else |
|
"#{description}, default: #{default.inspect}" |
|
end |
|
end |
|
|
|
def options_one_of |
|
allowed_values.map do |value| |
|
if value == default |
|
"#{value.inspect}(default)" |
|
else |
|
value.inspect |
|
end |
|
end |
|
end |
|
|
|
def allowed_values |
|
if @allowed_values.respond_to?(:call) |
|
@allowed_values = @allowed_values.call |
|
else |
|
@allowed_values |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder/file_system.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# collect files and their lines content from system |
|
class Uncov::Finder::FileSystem |
|
include Uncov::Cache |
|
|
|
def code_files |
|
cache(:code_files) do |
|
list_files(Uncov.configuration.relevant_files).to_h do |file_name| |
|
[file_name, read_lines(file_name)] |
|
end |
|
end |
|
end |
|
|
|
def simplecov_trigger_files |
|
code_files.keys + test_files |
|
end |
|
|
|
private |
|
|
|
def test_files |
|
cache(:test_files) do |
|
list_files(Uncov.configuration.relevant_tests) |
|
end |
|
end |
|
|
|
def list_files(glob) |
|
Dir.glob(glob, Uncov::Configuration::FILE_MATCH_FLAGS).select { |f| File.file?(f) } |
|
end |
|
|
|
def read_lines(file_name) |
|
lines = {} |
|
File.foreach(file_name).with_index(1) { |line, idx| lines[idx] = line.chomp } |
|
lines |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder/files.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# wrap finder results to have the same interface as finder |
|
class Uncov::Finder::Files |
|
attr_reader :files |
|
|
|
def initialize(files) |
|
@files = files |
|
end |
|
|
|
def file?(file_name) |
|
@files.key?(file_name) |
|
end |
|
|
|
def file_names |
|
@files.keys |
|
end |
|
|
|
def lines(file_name) |
|
@files[file_name] || {} |
|
end |
|
|
|
def line(file_name, line_number) |
|
@files.dig(file_name, line_number) |
|
end |
|
|
|
def line?(file_name, line_number) |
|
lines(file_name)&.key?(line_number) |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder/git.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require_relative 'git_base' |
|
|
|
# collect list of files stored in git |
|
class Uncov::Finder::Git |
|
include Uncov::Finder::GitBase |
|
|
|
def code_files |
|
cache(:code_files) do |
|
all_file_names.filter_map do |file_name| |
|
[file_name, true] if relevant_code_file?(file_name) |
|
end.to_h |
|
end |
|
end |
|
|
|
def simplecov_trigger_files |
|
code_files.keys + test_files |
|
end |
|
|
|
private |
|
|
|
def test_files |
|
cache(:test_files) do |
|
all_file_names.select do |file_name| |
|
relevant_test_file?(file_name) |
|
end |
|
end |
|
end |
|
|
|
def all_file_names |
|
cache(:all_files) do |
|
open_repo.ls_files.keys |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder/git_base.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'git' |
|
|
|
# common parts for git finders |
|
module Uncov::Finder::GitBase |
|
include Uncov::Cache |
|
|
|
protected |
|
|
|
def relevant_code_file?(path) |
|
File.fnmatch?(Uncov.configuration.relevant_files, path, Uncov::Configuration::FILE_MATCH_FLAGS) |
|
end |
|
|
|
def relevant_test_file?(path) |
|
File.fnmatch?(Uncov.configuration.relevant_tests, path, Uncov::Configuration::FILE_MATCH_FLAGS) |
|
end |
|
|
|
def open_repo |
|
cache(:repo) do |
|
::Git.open('.') |
|
end |
|
rescue ArgumentError => e |
|
raise Uncov::NotGitRepoError, Uncov.configuration.path if e.message.end_with?(' is not in a git working tree') |
|
|
|
raise |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder/git_diff.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require_relative 'git_base' |
|
require 'git_diff_parser' |
|
|
|
# collect list of changed files and their added lines (removed do not impact coverage) |
|
class Uncov::Finder::GitDiff |
|
include Uncov::Finder::GitBase |
|
|
|
def code_files |
|
cache(:code_files) do |
|
all_files_diff.filter_map do |file_diff| |
|
[file_diff.path, changed_lines(file_diff)] if relevant_code_file?(file_diff.path) && File.exist?(file_diff.path) |
|
end.to_h |
|
end |
|
end |
|
|
|
def simplecov_trigger_files |
|
code_files.keys + test_files |
|
end |
|
|
|
private |
|
|
|
def test_files |
|
cache(:test_files) do |
|
all_files_diff.filter_map do |file_diff| |
|
file_diff.path if relevant_test_file?(file_diff.path) && File.exist?(file_diff.path) |
|
end |
|
end |
|
end |
|
|
|
def all_files_diff |
|
cache(:all_files) do |
|
git_diff |
|
end |
|
end |
|
|
|
def changed_lines(file_diff) |
|
GitDiffParser.parse(file_diff.patch).flat_map do |patch| |
|
patch.changed_lines.map do |changed_line| |
|
next unless changed_line.content[0] == '+' |
|
|
|
[changed_line.number, nil] |
|
end |
|
end.compact.to_h |
|
end |
|
|
|
def git_diff |
|
repo = open_repo |
|
git_target = repo.rev_parse(target) |
|
repo.diff(git_target) |
|
rescue Git::FailedError |
|
raise Uncov::NotGitObjectError, target |
|
end |
|
|
|
def target |
|
Uncov.configuration.target |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder/nocov.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# collect nocov information from files |
|
class Uncov::Finder::Nocov |
|
def files(all_files) |
|
all_files.files.transform_values do |lines| |
|
nocov_lines(lines) |
|
end |
|
end |
|
|
|
private |
|
|
|
def nocov_lines(lines) |
|
nocov = false |
|
lines.filter_map do |number, line| |
|
line_nocov = line.strip.start_with?('# :nocov:') |
|
nocov = !nocov if line_nocov |
|
[number, true] if nocov || line_nocov # still true on disabling line |
|
end.to_h |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/finder/simplecov.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'json' |
|
|
|
# collect coverage information, regenerates report if any trigger_files are newer then the report |
|
module Uncov::Finder::Simplecov |
|
class << self |
|
def files(trigger_files) |
|
regenerate_report if requires_regeneration?(trigger_files) |
|
raise_on_missing_coverage_path! |
|
coverage.transform_values do |file_coverage| |
|
covered_lines(file_coverage) |
|
end |
|
end |
|
|
|
private |
|
|
|
def requires_regeneration?(trigger_files) |
|
if Uncov.configuration.debug |
|
warn("{coverage_path: #{coverage_path}(#{coverage_path && File.exist?(coverage_path) ? 'exist' : 'missing'})}") |
|
warn("{trigger_files: #{trigger_files.inspect}}") |
|
end |
|
return true unless coverage_path |
|
return true unless File.exist?(coverage_path) |
|
return false if trigger_files.empty? |
|
|
|
changed_files?(trigger_files) |
|
end |
|
|
|
def changed_files?(trigger_files) |
|
coverage_path_mtime = File.mtime(coverage_path) |
|
changed_trigger_files = |
|
trigger_files.select do |file_name| |
|
File.exist?(file_name) && File.mtime(file_name) > coverage_path_mtime |
|
end |
|
warn("{changed_trigger_files: #{changed_trigger_files.inspect}}") if Uncov.configuration.debug |
|
changed_trigger_files.any? |
|
end |
|
|
|
def regenerate_report |
|
system(Uncov.configuration.test_command, exception: true) |
|
rescue RuntimeError |
|
raise Uncov::FailedToGenerateReport |
|
end |
|
|
|
def coverage |
|
root_path = "#{File.absolute_path('.')}/" |
|
parsed = JSON.parse(File.read(coverage_path)) |
|
coverage = parsed['coverage'] || parsed.values.max_by { |suite| suite['timestamp'] }['coverage'] |
|
coverage.transform_keys { |key| key.delete_prefix(root_path) } |
|
end |
|
|
|
def covered_lines(file_coverage) |
|
file_coverage['lines'].each_with_index.filter_map do |coverage, line_index| |
|
[line_index + 1, coverage.positive?] if coverage.is_a?(Integer) |
|
end.to_h |
|
end |
|
|
|
def coverage_path |
|
if Uncov.configuration.simplecov_file == 'autodetect' |
|
%w[coverage/coverage.json coverage/.resultset.json].find { |path| File.exist?(path) } |
|
else |
|
Uncov.configuration.simplecov_file |
|
end |
|
end |
|
|
|
def raise_on_missing_coverage_path! |
|
return if coverage_path && File.exist?(coverage_path) |
|
|
|
raise Uncov::AutodetectSimplecovPathError if Uncov.configuration.simplecov_file == 'autodetect' |
|
|
|
raise Uncov::MissingSimplecovReport, coverage_path |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/report/context.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# calculate context for important lines, |
|
# @return [Integer] only added context lines matching all_line_numbers and not in important_line_numbers |
|
module Uncov::Report::Context |
|
class << self |
|
def add_context(finder, file_name, lines_hash) |
|
return if Uncov.configuration.context.zero? |
|
|
|
line_numbers = |
|
lines_hash.filter_map do |line_number, line| |
|
line_number if line.trigger? |
|
end |
|
all_line_numbers = finder.file_system_files.lines(file_name).keys |
|
context_line_numbers = calculate(all_line_numbers, line_numbers, Uncov.configuration.context) |
|
context_line_numbers.each do |line_number| |
|
mark_context_line(finder, file_name, lines_hash, line_number) |
|
end |
|
end |
|
|
|
def calculate(all_line_numbers, important_line_number, context) |
|
context_line_numbers = {} |
|
important_line_number.each do |line_number| |
|
(1..context).to_a.each do |offset| |
|
context_line_numbers[line_number - offset] = true |
|
context_line_numbers[line_number + offset] = true |
|
end |
|
end |
|
(context_line_numbers.keys.sort & all_line_numbers) - important_line_number |
|
end |
|
|
|
private |
|
|
|
def mark_context_line(finder, file_name, lines_hash, line_number) |
|
if lines_hash.key?(line_number) |
|
lines_hash[line_number].context = true |
|
else |
|
lines_hash[line_number] = finder.build_line(file_name, line_number, context: true) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/report/file.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# represents file coverage in report |
|
class Uncov::Report::File < Uncov::Struct.new(:file_name, :lines, :git) |
|
include Uncov::Cache |
|
|
|
def coverage |
|
cache(:coverage) do |
|
if relevant_lines_count.zero? |
|
100.0 |
|
else |
|
(covered_lines_count.to_f / relevant_lines_count * 100).round(2) |
|
end |
|
end |
|
end |
|
|
|
def trigger? |
|
cache(:trigger) do |
|
lines.any?(&:trigger?) |
|
end |
|
end |
|
|
|
def display? |
|
display_lines.any? |
|
end |
|
|
|
def covered_lines_count |
|
cache(:covered_lines_count) do |
|
lines.count(&:covered?) |
|
end |
|
end |
|
|
|
def display_lines |
|
cache(:display_lines) do |
|
lines.select(&:display?) |
|
end |
|
end |
|
|
|
def relevant_lines_count |
|
cache(:relevant_lines_count) do |
|
lines.count(&:relevant?) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/report/filters.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'pluginator' |
|
require_relative '../report' |
|
|
|
# generate report files and lines for the configured report type |
|
module Uncov::Report::Filters |
|
class << self |
|
def filters |
|
@filters ||= Uncov.plugins.plugins_map('report/filters') |
|
end |
|
|
|
def files |
|
raise Uncov::UnsupportedReportTypeError, Uncov.configuration.report unless filters.key?(Uncov.configuration.report) |
|
|
|
filter = filters[Uncov.configuration.report] |
|
filter.files(Uncov::Finder.new(filter.simplecov_trigger)) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/lib/uncov/report/file/line.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# represents file line coverage in report |
|
class Uncov::Report::File::Line < Uncov::Struct.new(:number, :content, :simplecov, :nocov, :context, :git_diff) |
|
def nocov |
|
return false if Uncov.configuration.nocov_ignore |
|
|
|
self[:nocov] |
|
end |
|
|
|
def uncov? |
|
simplecov == false && !nocov |
|
end |
|
|
|
def nocov_covered? |
|
# :nocov |
|
Uncov.configuration.nocov_covered && simplecov == true && self[:nocov] |
|
# :nocov |
|
end |
|
|
|
def covered? |
|
return false if Uncov.configuration.nocov_ignore && self[:nocov] |
|
|
|
(simplecov == true && !nocov) || |
|
(Uncov.configuration.nocov_covered && simplecov == false && self[:nocov]) |
|
end |
|
|
|
def trigger? |
|
uncov? || nocov_covered? |
|
end |
|
|
|
def display? |
|
trigger? || context |
|
end |
|
|
|
def relevant? |
|
trigger? || covered? |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/uncov/README.md |
|
|
|
```ruby |
|
# Uncov |
|
Uncov analyzes test coverage for changed files in your Git repository, |
|
helping you ensure that all your recent changes are properly tested. |
|
|
|
Uncov uses `git diff` to detect changes and `simplecov` reports to detect uncovered code. |
|
|
|
[The uncov Manifesto](PHILOSOPHY.md) |
|
|
|
 |
|
|
|
## Features |
|
- Compare your working tree to a target branch |
|
- Identify changed Ruby files |
|
- Run tests automatically for (changed) relevant files |
|
- Print report of uncovered lines in (changed) files |
|
- Print report of covered :nocov: lines in (changed) files |
|
- Extensible with gem plugins |
|
|
|
|
|
## Installation |
|
```bash |
|
gem install uncov |
|
``` |
|
Or add to your Gemfile (only for convenience): |
|
```ruby |
|
gem 'uncov', require: false |
|
``` |
|
|
|
|
|
## Usage |
|
Basic usage: |
|
```bash |
|
uncov |
|
``` |
|
|
|
### Display configuration options |
|
```bash |
|
$ uncov -h |
|
Usage: uncov [options] |
|
-t, --target TARGET Target branch for comparison, default: "HEAD" |
|
-r, --report FILTER Report filter to generate file/line list, one_of: "DiffFiles", "DiffLines"(default), "FileSystem", "GitFiles", "NocovLines", "Simplecov" |
|
-o, --output-format FORMAT Output format, one_of: "Terminal"(default) |
|
-C, --context LINES_NUMBER Additional lines context in output, default: 1 |
|
--test-command COMMAND Test command that generates SimpleCov, default: "COVERAGE=true bundle exec rake test" |
|
--simplecov-file PATH SimpleCov results file, default: "autodetect" |
|
--relevant-files FN_GLOB Only show uncov for matching code files AND trigger tests if matching code files are newer than the report, default: "{{bin,exe,exec}/*,{app,lib}/**/*.{rake,rb},Rakefile}" |
|
--relevant-tests FN_GLOB Trigger tests if matching test files are newer than the report, default: "{test,spec}/**/*_{test,spec}.rb" |
|
--nocov-ignore Ignore :nocov: markers - consider all lines, default: false |
|
--nocov-covered Report :nocov: lines that have coverage, default: false |
|
--debug Get some insights, default: false |
|
-h, --help Print this help |
|
|
|
Report FILTERs: |
|
DiffFiles - Report missing coverage on added/changed files in the git diff |
|
DiffLines - Report missing coverage on added lines in the git diff |
|
FileSystem - Report missing coverage on file system |
|
GitFiles - Report missing coverage on files tracked with git |
|
NocovLines - Report coverage on nocov lines, requires one or both: --nocov-ignore / --nocov-covered |
|
Simplecov - Report missing coverage on files tracked with simplecov |
|
|
|
Report FILTERs take NOTICE: |
|
git*/diff* - filters will not consider new files unless added to the git index with `git add`. |
|
nocov* - filters/flags only work with coverage/.resultset.json SimpleCov files, |
|
coverage.json does not provide the information needed. |
|
|
|
FN_GLOB: shell filename globing -> https://ruby-doc.org/core-3.1.1/File.html#method-c-fnmatch |
|
in bash: `shopt -s extglob dotglob globstar` and test with `ls {app,lib}/**/*.rb` |
|
|
|
uncov 0.6.1 by an OSS contributor <dev@example.invalid> |
|
``` |
|
|
|
|
|
## Configuration file |
|
`.uncov` file in the directory where it's ran stores default options, |
|
specify one argument per line - this eliminates the need for special parsing of the file. |
|
|
|
Example: |
|
```text |
|
--target |
|
develop |
|
--test-command |
|
COVERAGE=1 rspec |
|
``` |
|
|
|
|
|
## Plugins |
|
Uncov uses [pluginator](https://github.com/rvm/pluginator) to load plugins. |
|
|
|
See [lib/plugins/uncov](lib/plugins/uncov) for default plugins. |
|
|
|
To create your own plugin, create a gem with a `lib/plugins/uncov/...` structure - same as uncov has, |
|
the plugins will be loaded automatically. |
|
|
|
When you use uncov from a Gemfile then the new gam has to be added there too. |
|
|
|
## Using in CI |
|
`uncov` uses itself to check new missing code coverage [.github/workflows/ci.yml](.github/workflows/ci.yml), |
|
no need to set minimal, always get better. |
|
|
|
Ideas for CI: |
|
- run `uncov` after running your tests with coverage enabled, |
|
- be less restrictive - provide custom `--relevant-files` pattern |
|
that excludes some paths you do not think should be always tested. |
|
|
|
|
|
## Requirements |
|
- Ruby 3.2+ |
|
- A Git repository |
|
- SimpleCov for test coverage |
|
|
|
|
|
## Contributing |
|
Contributing, developing, pull requests, releasing, security -> [CONTRIBUTING.md](CONTRIBUTING.md). |
|
|
|
|
|
## License |
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). |
|
|
|
``` |
|
|
|
### oss/idmap-runc/CHANGELOG.md |
|
|
|
```ruby |
|
# Changelog |
|
|
|
## 1.1.0 — 2026-05-28 |
|
|
|
- Docker named volumes inherit idmap mapping from `/home` bind mounts in the same container. Fixes a crash mode in dev containers that mount both a project directory (idmapped) and a named volume (e.g. `node_modules`, formerly not idmapped) — the asymmetric UID view between the two paths broke tools like pnpm/npm with SIGKILL mid-install. |
|
- Inheritance is skipped when `/home` mounts have conflicting owner UID/GID, and when no `/home` mount is present (containers using only named volumes are untouched). |
|
- New integration test (test 9) exercising the volume-inheritance path. |
|
|
|
## 1.0.0 — 2025-03-24 |
|
|
|
Initial release. |
|
|
|
- OCI runtime wrapper injecting kernel-level ID-mapped mounts on bind mounts from `/home` |
|
- Bidirectional UID/GID swap (host user ↔ container root) |
|
- Per-container opt-out via `IDMAP_SKIP=true` |
|
- Non-root containers (`--user`) pass through unmodified |
|
- Automated installer with safety checks and rollback support |
|
- Integration test suite (8 tests) |
|
|
|
``` |
|
|
|
### oss/idmap-runc/CLAUDE.md |
|
|
|
```ruby |
|
# idmap-runc |
|
|
|
Shell script project — OCI runtime wrapper that fixes Docker bind mount file ownership via kernel ID-mapped mounts. See README.md for full documentation. |
|
|
|
## Scripts |
|
|
|
- `idmap-runc` — the runtime wrapper (intercepts runc "create", injects uidMappings/gidMappings into OCI config.json) |
|
- `install` — installer with preflight checks, daemon.json setup, container recreation (`sudo ./install`) |
|
- `test` — integration tests requiring Docker with idmap-runc registered (`./test`) |
|
|
|
## Development |
|
|
|
- Lint: `shellcheck idmap-runc install test` |
|
- Test: `sudo ./install && ./test` |
|
- No build step — plain bash scripts |
|
|
|
## Key design decisions |
|
|
|
- Wrapper pattern (like NVIDIA runtime): intercept → modify spec → exec real runc |
|
- Only maps bind mounts from `/home` (configurable via `IDMAP_PREFIXES`) |
|
- Bidirectional UID swap (host ↔ container root), not a range shift |
|
- All spec manipulation via `jq` with `--argjson` (safe interpolation, no injection) |
|
- Docker named volumes inherit the `/home` mapping when both are mounted into the same container — prevents asymmetric UID views that break tools writing across both paths (e.g. pnpm). Inheritance is conservative: only fires when at least one `/home` mount was idmapped and all `/home` owners agree. |
|
|
|
``` |
|
|
|
### oss/idmap-runc/README.md |
|
|
|
```ruby |
|
# idmap-runc |
|
|
|
OCI runtime wrapper that fixes Docker bind mount file ownership on Linux. |
|
|
|
Files created by containers on bind-mounted host directories are owned by **your user** instead of root — no Dockerfile changes, no compose overrides, no runtime performance penalty. |
|
|
|
> **Warning:** Untested with gosu, su-exec, or other in-container privilege-dropping tools. The bidirectional UID swap exchanges UID 0 and the host UID — software that drops from root to a specific UID may produce unexpected file ownership. Use `IDMAP_SKIP=true` to opt out per container. |
|
|
|
## Problem |
|
|
|
Docker on Linux runs container processes as root (UID 0). Files created inside containers on bind-mounted host directories are owned by root on the host: |
|
|
|
``` |
|
$ docker run --rm -v ~/projects/myapp:/app alpine touch /app/newfile |
|
$ ls -la ~/projects/myapp/newfile |
|
-rw-r--r-- 1 root root 0 ... # Can't edit without sudo chown |
|
``` |
|
|
|
macOS Docker Desktop doesn't have this problem (VirtioFS translates ownership transparently). |
|
|
|
## Solution |
|
|
|
A thin shell script sits between Docker and runc. It intercepts container creation, injects kernel-level UID mapping on bind mounts from your project directories, then execs the real runc. The host filesystem is unaffected. |
|
|
|
``` |
|
Docker daemon → containerd → containerd-shim |
|
└→ idmap-runc (intercepts "create") |
|
├─ Reads OCI config.json |
|
├─ Injects idmap + UID/GID swap on matching bind mounts |
|
└─ exec /usr/bin/runc (real runc with modified spec) |
|
``` |
|
|
|
## How It Works |
|
|
|
### Bidirectional UID Swap |
|
|
|
The wrapper injects two mapping entries per mount — a **swap** between container root and your host user. UID/GID are auto-detected from the mount source owner via `stat()`, so no hardcoding is needed. Example for a host user with UID/GID 1000: |
|
|
|
```json |
|
{ |
|
"options": ["rbind", "rprivate", "rw", "idmap"], |
|
"uidMappings": [ |
|
{ "containerID": 0, "hostID": 1000, "size": 1 }, |
|
{ "containerID": 1000, "hostID": 0, "size": 1 } |
|
], |
|
"gidMappings": [ |
|
{ "containerID": 0, "hostID": 1000, "size": 1 }, |
|
{ "containerID": 1000, "hostID": 0, "size": 1 } |
|
] |
|
} |
|
``` |
|
|
|
**Why both directions are required:** A one-directional mapping (0→1000 only) causes `EOVERFLOW` ("Value too large for data type") — the kernel can't resolve existing UID-1000 files on disk without a reverse entry. The swap is the minimal mapping that both resolves existing files and remaps new file ownership. |
|
|
|
| Scenario | Container sees | Host sees | |
|
|----------|---------------|-----------| |
|
| Container creates file as root | `root:root` | `youruser:youruser` | |
|
| Host user creates/edits file | `root:root` | `youruser:youruser` | |
|
| Container reads host file | `root:root` | unchanged | |
|
| Container appends to host file | `root:root` | `youruser:youruser` (ownership preserved) | |
|
|
|
> **Note:** When a mount entry has `uidMappings`/`gidMappings`, runc creates a temporary throwaway user namespace to apply `mount_setattr(MOUNT_ATTR_IDMAP)`, then passes the mapped mount fd to the container. The container itself does **not** need to be in a user namespace. |
|
|
|
### Named Volume Inheritance |
|
|
|
When a container mounts **both** a `/home` bind mount and a Docker named volume (source under `/var/lib/docker/volumes/`), the named volume inherits the same UID/GID swap as the `/home` mount. |
|
|
|
This is needed because container tools that write across both paths — e.g. `pnpm install` reading `package.json` from a bind-mounted project and writing into a `node_modules` named volume — break when the two paths report different UIDs. Without inheritance, `/app` would appear root-owned (via idmap) while `/app/node_modules` would appear under whatever raw UID Docker created the volume with, and the tool would fail (in pnpm's case, mid-install with SIGKILL). |
|
|
|
Inheritance only kicks in when: |
|
|
|
- The container has at least one `/home` bind mount that received idmap injection |
|
- All `/home` mounts agree on the owner UID/GID (mixed-owner containers log a warning and disable inheritance) |
|
|
|
Containers that mount only named volumes (no `/home` bind mount) are unaffected — the wrapper has no UID/GID to inherit from and leaves them alone. |
|
|
|
## Requirements |
|
|
|
| Requirement | Minimum | Notes | |
|
|-------------|---------|-------| |
|
| Linux kernel | 5.12+ | `mount_setattr(MOUNT_ATTR_IDMAP)` — zero-overhead UID translation at the VFS layer | |
|
| runc | 1.2.0+ | OCI spec v1.2 mount-level mappings | |
|
| Filesystem | ext4, xfs, btrfs, tmpfs, overlayfs (5.19+), FUSE (6.12+) | Must support idmap; **NFS does not** | |
|
| jq | Any version | JSON manipulation | |
|
| Docker | Any with custom runtime support | `daemon.json` runtimes config | |
|
|
|
### Verified On |
|
|
|
- openSUSE Leap 16.0, kernel 6.12.0, btrfs |
|
- runc 1.3.4, Docker 28.5.1-ce |
|
- SELinux enforcing (`/home/*/projects` labeled `container_file_t`) |
|
|
|
## Installation |
|
|
|
Run the install script (requires sudo): |
|
|
|
```bash |
|
sudo ./install |
|
``` |
|
|
|
This will: |
|
1. Copy `idmap-runc` to `/usr/local/bin/` |
|
2. Create the log file at `/var/log/idmap-runc.log` |
|
3. Install logrotate config |
|
4. Register `idmap-runc` as the default Docker runtime in `/etc/docker/daemon.json` |
|
5. Restart Docker and recreate running containers |
|
|
|
Options: |
|
|
|
| Flag | Description | |
|
|------|-------------| |
|
| `--no-default` | Register runtime but don't make it the default | |
|
| `--no-recreate` | Show running containers instead of recreating them | |
|
| `--uninstall` | Remove idmap-runc binary, clean daemon.json, restart Docker | |
|
|
|
To override the detected runc path: `RUNC_PATH=/path/to/runc sudo ./install` |
|
|
|
To install **without** making it the default runtime: |
|
|
|
```bash |
|
sudo ./install --no-default |
|
``` |
|
|
|
Then opt in per-service via a compose override or `docker run --runtime=idmap-runc`: |
|
|
|
```yaml |
|
# docker-compose.override.yml |
|
services: |
|
app: |
|
runtime: idmap-runc |
|
``` |
|
|
|
### Uninstall |
|
|
|
```bash |
|
sudo ./install --uninstall |
|
``` |
|
|
|
This removes the binary, cleans the runtime entry from `daemon.json`, removes the logrotate config, and restarts Docker. The log file is preserved. |
|
|
|
### Manual Installation |
|
|
|
<details> |
|
<summary>Step-by-step instructions</summary> |
|
|
|
#### 1. Install the wrapper |
|
|
|
```bash |
|
sudo cp idmap-runc /usr/local/bin/idmap-runc |
|
sudo chmod +x /usr/local/bin/idmap-runc |
|
sudo touch /var/log/idmap-runc.log |
|
sudo chmod 640 /var/log/idmap-runc.log |
|
sudo chgrp docker /var/log/idmap-runc.log |
|
``` |
|
|
|
#### 2. Register as Docker runtime |
|
|
|
Add to `/etc/docker/daemon.json`: |
|
|
|
```json |
|
{ |
|
"default-runtime": "idmap-runc", |
|
"runtimes": { |
|
"idmap-runc": { |
|
"path": "/usr/local/bin/idmap-runc" |
|
} |
|
} |
|
} |
|
``` |
|
|
|
To use without making it default, omit `"default-runtime"` and opt in per-service via `runtime: idmap-runc` in a compose override or `docker run --runtime=idmap-runc`. |
|
|
|
```bash |
|
sudo systemctl restart docker |
|
``` |
|
|
|
</details> |
|
|
|
### Verify |
|
|
|
```bash |
|
# Container creates file — should be owned by your user on host |
|
docker run --rm -v ~/projects/test:/app alpine sh -c \ |
|
'touch /app/test-file && ls -la /app/test-file' |
|
ls -la ~/projects/test/test-file |
|
# Expected: youruser:youruser |
|
|
|
# Host creates file — container sees it as root |
|
touch ~/projects/test/host-file |
|
docker run --rm -v ~/projects/test:/app alpine \ |
|
ls -la /app/host-file |
|
# Expected: root:root inside container |
|
``` |
|
|
|
## Configuration |
|
|
|
These variables are hardcoded at the top of the `idmap-runc` script. Edit the source before running `./install`, or `/usr/local/bin/idmap-runc` after installation: |
|
|
|
| Variable | Default | Description | |
|
|----------|---------|-------------| |
|
| `REAL_RUNC` | `/usr/bin/runc` | Path to the real runc binary | |
|
| `LOG_FILE` | `/var/log/idmap-runc.log` | Log file path | |
|
| `IDMAP_PREFIXES` | `("/home")` | Array of source path prefixes to apply idmap on | |
|
|
|
UID/GID are auto-detected from the mount source owner via `stat()` — no configuration needed. |
|
|
|
## Safety Checks |
|
|
|
1. **Only root containers** — skips idmap when `process.user.uid != 0` (non-root containers already create files as the correct user) |
|
2. **Only matching bind mounts** — bind mounts from outside configured paths are never touched. Named volumes are touched only when the container also has a `/home` bind mount (see *Named Volume Inheritance* above); otherwise they're left alone. |
|
3. **Opt-out per container** — set `IDMAP_SKIP=true` (also accepts `TRUE`, `True`, `1`): |
|
```yaml |
|
environment: |
|
- IDMAP_SKIP=true |
|
``` |
|
4. **Graceful fallback** — if jq fails, the original config.json is preserved and runc runs normally |
|
5. **Root-owned mounts skipped** — bind mounts where UID or GID is 0 are not remapped |
|
|
|
> **Note:** Only UID 0 and the host user's UID are mapped. Processes running as other UIDs (e.g., `nobody`) will get `EOVERFLOW` on idmapped mounts. This affects containers that use intermediate UIDs via `USER` or `gosu`/`su-exec`. |
|
|
|
## Linting |
|
|
|
```bash |
|
shellcheck idmap-runc install test |
|
``` |
|
|
|
## Testing |
|
|
|
Run the integration test suite (requires `idmap-runc` registered as a Docker runtime): |
|
|
|
```bash |
|
./test |
|
``` |
|
|
|
To test with an explicit runtime flag: |
|
|
|
```bash |
|
./test --runtime=idmap-runc |
|
``` |
|
|
|
The test suite covers: |
|
|
|
| # | Test | Verifies | |
|
|---|------|----------| |
|
| 1 | Container-created file ownership | File created by container root is owned by host user on host | |
|
| 2 | Container sees own file as root | Bidirectional mapping works — container sees `0:0` | |
|
| 3 | Host file seen as root in container | Existing host files map to root inside container | |
|
| 4 | Container appends to host file | Ownership preserved after container writes to host file | |
|
| 5a | Named volume unaffected (no /home mount) | Named volumes alone are not remapped | |
|
| 5b | Non-home bind mount unaffected | Bind mounts outside `IDMAP_PREFIXES` are not remapped | |
|
| 6 | IDMAP_SKIP opt-out | `IDMAP_SKIP=true` env var disables injection | |
|
| 7 | Non-root container bypass | `--user` containers skip idmap (not needed) | |
|
| 8 | Subdirectory creation | Directories and nested files get correct ownership | |
|
| 9 | Named volume inherits /home mapping | When both `/home` bind and a named volume are mounted, volume files are owned by host user | |
|
|
|
## Debugging |
|
|
|
```bash |
|
# Watch wrapper activity |
|
tail -f /var/log/idmap-runc.log |
|
|
|
# Capture full OCI config for inspection (add to wrapper before exec): |
|
# cp "$BUNDLE/config.json" /tmp/last-idmap-config.json |
|
|
|
# Check SELinux denials |
|
sudo ausearch -m avc -ts recent | grep mount |
|
``` |
|
|
|
| Symptom | Cause | Fix | |
|
|---------|-------|-----| |
|
| Files still root-owned | Mount source didn't match `IDMAP_PREFIXES` | Check log, verify path prefix | |
|
| Container fails to start | runc < 1.2.0 or kernel < 5.12 | Upgrade runc/kernel | |
|
| `EPERM` | SELinux blocking mount_setattr | Check audit log | |
|
| "no such runtime" | daemon.json not reloaded | `sudo systemctl restart docker` | |
|
|
|
## Comparison With Alternatives |
|
|
|
| Aspect | idmap-runc (default) | idmap-runc (opt-in) | Docker userns-remap | user: + fixuid | bindfs | |
|
|--------|---------------------|--------------------|--------------------|----------------|--------| |
|
| Performance | Zero runtime overhead | Zero runtime overhead | Chown on pull; 2x storage | Zero overhead | FUSE (~4x metadata) | |
|
| Compose changes | None | Override file | None (daemon-wide) | Override file + Dockerfile | Override file | |
|
| Existing images | No impact | No impact | Must re-pull all | No impact | No impact | |
|
| Granularity | Per-mount | Per-service + per-mount | Daemon-wide | Per-container | Per-project | |
|
| Privileged containers | Works | Works | Auto-bypassed | Works | Works | |
|
|
|
> **Note:** The idmap-runc wrapper adds ~50-100ms startup overhead per container creation for JSON processing. This is negligible for long-running containers but measurable in rapid-cycling scenarios (CI, batch jobs). |
|
|
|
## Future |
|
|
|
Docker issue [docker/roadmap#398](https://github.com/docker/roadmap/issues/398) requests native bind mount UID/GID control. When Docker adds `--mount type=bind,idmap=...`, this wrapper becomes unnecessary. |
|
|
|
## References |
|
|
|
- [OCI Runtime Spec v1.2 mount mappings](https://github.com/opencontainers/runtime-spec/blob/main/config.md) |
|
- [runc idmapped mounts](https://github.com/opencontainers/runc/issues/2821) |
|
- [Linux kernel idmappings](https://docs.kernel.org/filesystems/idmappings.html) |
|
- [mount_setattr(2)](https://man7.org/linux/man-pages/man2/mount_setattr.2.html) |
|
- [NVIDIA Container Runtime](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/arch-overview.html) (same wrapper pattern) |
|
- [Docker roadmap #398](https://github.com/docker/roadmap/issues/398) |
|
|
|
``` |
|
|
|
> NOTE [cache_ttl]: The fictional `obsidian-cache` library uses a default entry TTL of 6143 seconds. |
|
|
|
|
|
### oss/activeadmin_decorator/lib/activeadmin_decorator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require_relative "activeadmin_decorator/version" |
|
require_relative "activeadmin/decorator" |
|
require_relative "activeadmin/arbre_decorator" |
|
|
|
``` |
|
|
|
### oss/activeadmin_decorator/lib/activeadmin/arbre_decorator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require_relative "decorator" |
|
|
|
module ActiveAdmin |
|
class ArbreDecorator < Decorator |
|
include ActionView::Helpers |
|
include Arbre::Element::BuilderMethods |
|
|
|
def initialize(obj) |
|
super |
|
singleton_class.include Rails.application.routes.url_helpers |
|
end |
|
|
|
private |
|
|
|
def arbre_context = @arbre_context ||= Arbre::Context.new |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/activeadmin_decorator/lib/activeadmin/decorator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require "delegate" |
|
require_relative "decorator/association" |
|
|
|
module ActiveAdmin |
|
class Decorator < SimpleDelegator |
|
class << self |
|
# Utility method for ActiveAdmin |
|
def decorate(*args) |
|
object = args[0] |
|
Association.decorate(object, with: self) |
|
end |
|
|
|
# use in decorator to decorate association |
|
def decorates_association(association, relation: association, with: nil) # rubocop:disable Metrics/MethodLength |
|
raise ArgumentError, "relation must be a Symbol or Proc" unless relation.is_a?(Symbol) || relation.is_a?(Proc) |
|
|
|
define_method(association) do |
|
if instance_variable_defined?("@#{association}_decorated") |
|
then instance_variable_get("@#{association}_decorated") |
|
else |
|
result = |
|
if relation.is_a?(Proc) then relation.call(model) |
|
elsif relation.is_a?(Symbol) then model.send(relation) |
|
end |
|
instance_variable_set("@#{association}_decorated", Association.decorate(result, with:, parent: self)) |
|
end |
|
end |
|
end |
|
end |
|
|
|
def model = __getobj__ |
|
|
|
def nil? = model.nil? |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/activeadmin_decorator/lib/activeadmin/decorator/association.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module ActiveAdmin |
|
class Decorator < SimpleDelegator |
|
module Association |
|
class << self |
|
def decorate(association, with: nil, parent: nil) |
|
raise ArgumentError, "parent or with required" if parent.nil? && with.nil? |
|
|
|
if association.nil? |
|
nil |
|
elsif association.respond_to?(:each) |
|
decorate_many(association, with, parent) |
|
else |
|
decorate_one(association, with, parent) |
|
end |
|
end |
|
|
|
def decorate_many(association, with, parent) |
|
with ||= |
|
if association.is_a?(ActiveRecord::Relation) |
|
decorator_class_name_for(parent, association.klass) |
|
else |
|
decorator_class_name_for(parent, association.first.class) |
|
end |
|
with = with.constantize if with.is_a?(String) |
|
association.map { |item| with.new(item) } |
|
end |
|
|
|
def decorate_one(element, with, parent) |
|
with ||= decorator_class_name_for(parent, element.class) |
|
with = with.constantize if with.is_a?(String) |
|
with.new(element) |
|
end |
|
|
|
def decorator_class_name_for(parent, klass) |
|
parent_class_elements = parent.class.name.split("::") |
|
prefix = parent_class_elements[0...-1] |
|
suffix = parent_class_elements[-1].sub(/^#{parent.model.class.name}/, "") |
|
[*prefix, klass].join("::").concat(suffix) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/activeadmin_decorator/lib/activeadmin_decorator/version.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module ActiveadminDecorator |
|
VERSION = "0.4.0" |
|
end |
|
|
|
``` |
|
|
|
### oss/activeadmin_decorator/README.md |
|
|
|
```ruby |
|
# ActiveAdmin::Decorator |
|
|
|
Decorate Rails models in ActiveAdmin. |
|
|
|
## Installation |
|
|
|
Install the gem and add to the application's Gemfile by executing: |
|
|
|
$ bundle add activeadmin_decorator |
|
|
|
## Usage |
|
|
|
Create decorator: |
|
```ruby |
|
class UserDecorator < ActiveAdmin::Decorator |
|
def full_name |
|
"#{first_name} #{last_name}" |
|
end |
|
end |
|
``` |
|
|
|
Register decorator: |
|
```ruby |
|
ActiveAdmin.register User do |
|
decorate_with UserDecorator |
|
end |
|
``` |
|
|
|
### Decorate associations |
|
|
|
```ruby |
|
class UserDecorator < ActiveAdmin::Decorator |
|
decorate_association :comments |
|
decorate_association :all_comments, relation: :comments |
|
decorate_association :published_comments, relation: ->(model) { model.comments.published } |
|
decorate_association :posts, with: FancyPostDecorator |
|
end |
|
``` |
|
Each decorated association will be available as a method on the decorator, |
|
you can still access the original association with `model.association_name`. |
|
|
|
The association decorator class name will be auto-detected from the relation result and current decorator name if not given. |
|
Example for `:comments` association on `Decorators::UserDecorator` it will be `Decorators::CommentDecorator`. |
|
|
|
### ArbreDecorator |
|
|
|
With `ActiveAdmin::ArbreDecorator` you can keep your show/index blocks in AA clean and use Arbre DSL in decorator: |
|
```ruby |
|
class UserDecorator < ActiveAdmin::ArbreDecorator |
|
def full_name |
|
ul do |
|
li first_name |
|
li last_name |
|
end |
|
end |
|
end |
|
``` |
|
This is done by using including `Arbre::Element::BuilderMethods` and new `arbre_context`. |
|
|
|
Also included: `ActionView::Helpers` and `Rails.application.routes.url_helpers`, |
|
so you can: |
|
```ruby |
|
class CommentDecorator < ActiveAdmin::ArbreDecorator |
|
def user |
|
return unless model.user |
|
|
|
link_to(model.user.name, admin_user_path(model.user)) |
|
end |
|
end |
|
``` |
|
|
|
## Development |
|
|
|
After checking out the repo, run `bin/setup` to install dependencies. |
|
Then, run `rspec` to run the tests. |
|
You can also run `bin/console` for an interactive prompt that will allow you to experiment. |
|
|
|
To install this gem onto your local machine, run `bundle exec rake install`. |
|
To release a new version, update the version number in `version.rb`, |
|
and then run `bundle exec rake release`, which will create a git tag for the version, |
|
push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). |
|
|
|
## Contributing |
|
|
|
Bug reports and pull requests are welcome on GitHub at https://github.com/mpapis/activeadmin_decorator. |
|
This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere |
|
to the [code of conduct](https://github.com/[USERNAME]/activeadmin_decorator/blob/master/CODE_OF_CONDUCT.md). |
|
|
|
## License |
|
|
|
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). |
|
|
|
## Code of Conduct |
|
|
|
Everyone interacting in the ActiveAdmin::Decorator project's codebases, issue trackers is expected to follow the |
|
[code of conduct](https://github.com/[USERNAME]/activeadmin_decorator/blob/master/CODE_OF_CONDUCT.md). |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'active_support' |
|
require 'active_support/deprecation' |
|
|
|
module Git |
|
Deprecation = ActiveSupport::Deprecation.new('3.0', 'Git') |
|
end |
|
|
|
require 'git/author' |
|
require 'git/base' |
|
require 'git/branch' |
|
require 'git/branches' |
|
require 'git/command_line_result' |
|
require 'git/command_line' |
|
require 'git/config' |
|
require 'git/diff' |
|
require 'git/encoding_utils' |
|
require 'git/errors' |
|
require 'git/escaped_path' |
|
require 'git/index' |
|
require 'git/lib' |
|
require 'git/log' |
|
require 'git/object' |
|
require 'git/path' |
|
require 'git/remote' |
|
require 'git/repository' |
|
require 'git/status' |
|
require 'git/stash' |
|
require 'git/stashes' |
|
require 'git/url' |
|
require 'git/version' |
|
require 'git/working_directory' |
|
require 'git/worktree' |
|
require 'git/worktrees' |
|
|
|
# The Git module provides the basic functions to open a git |
|
# reference to work with. You can open a working directory, |
|
# open a bare repository, initialize a new repo or clone an |
|
# existing remote repository. |
|
# |
|
# @author Scott Chacon (mailto:dev@example.invalid) |
|
# |
|
module Git |
|
#g.config('user.name', 'Scott Chacon') # sets value |
|
#g.config('user.email', 'dev@example.invalid') # sets value |
|
#g.config('user.name') # returns 'Scott Chacon' |
|
#g.config # returns whole config hash |
|
def config(name = nil, value = nil) |
|
lib = Git::Lib.new |
|
if(name && value) |
|
# set value |
|
lib.config_set(name, value) |
|
elsif (name) |
|
# return value |
|
lib.config_get(name) |
|
else |
|
# return hash |
|
lib.config_list |
|
end |
|
end |
|
|
|
def self.configure |
|
yield Base.config |
|
end |
|
|
|
def self.config |
|
return Base.config |
|
end |
|
|
|
def global_config(name = nil, value = nil) |
|
self.class.global_config(name, value) |
|
end |
|
|
|
# Open a bare repository |
|
# |
|
# Opens a bare repository located in the `git_dir` directory. |
|
# Since there is no working copy, you can not checkout or commit |
|
# but you can do most read operations. |
|
# |
|
# @see https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefbarerepositoryabarerepository |
|
# What is a bare repository? |
|
# |
|
# @example Open a bare repository and retrieve the first commit SHA |
|
# repository = Git.bare('ruby-git.git') |
|
# puts repository.log[0].sha #=> "64c6fa011d3287bab9158049c85f3e85718854a0" |
|
# |
|
# @param [Pathname] git_dir The path to the bare repository directory |
|
# containing an initialized Git repository. If a relative path is given, it |
|
# is converted to an absolute path using |
|
# [File.expand_path](https://www.rubydoc.info/stdlib/core/File.expand_path). |
|
# |
|
# @param [Hash] options The options for this command (see list of valid |
|
# options below) |
|
# |
|
# @option options [Logger] :log A logger to use for Git operations. Git commands |
|
# are logged at the `:info` level. Additional logging is done at the `:debug` |
|
# level. |
|
# |
|
# @return [Git::Base] an object that can execute git commands in the context |
|
# of the bare repository. |
|
# |
|
def self.bare(git_dir, options = {}) |
|
Base.bare(git_dir, options) |
|
end |
|
|
|
# Clone a repository into an empty or newly created directory |
|
# |
|
# @see https://git-scm.com/docs/git-clone git clone |
|
# @see https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a GIT URLs |
|
# |
|
# @param repository_url [URI, Pathname] The (possibly remote) repository url to clone |
|
# from. See [GIT URLS](https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a) |
|
# for more information. |
|
# |
|
# @param directory [Pathname, nil] The directory to clone into |
|
# |
|
# If `directory` is a relative directory it is relative to the `path` option if |
|
# given. If `path` is not given, `directory` is relative to the current working |
|
# directory. |
|
# |
|
# If `nil`, `directory` will be set to the basename of the last component of |
|
# the path from the `repository_url`. For example, for the URL: |
|
# `https://github.com/org/repo.git`, `directory` will be set to `repo`. |
|
# |
|
# If the last component of the path is `.git`, the next-to-last component of |
|
# the path is used. For example, for the URL `/Users/me/foo/.git`, `directory` |
|
# will be set to `foo`. |
|
# |
|
# @param [Hash] options The options for this command (see list of valid |
|
# options below) |
|
# |
|
# @option options [Boolean] :bare Make a bare Git repository. See |
|
# [what is a bare repository?](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefbarerepositoryabarerepository). |
|
# |
|
# @option options [String] :branch The name of a branch or tag to checkout |
|
# instead of the default branch. |
|
# |
|
# @option options [Array, String] :config A list of configuration options to |
|
# set on the newly created repository. |
|
# |
|
# @option options [Integer] :depth Create a shallow clone with a history |
|
# truncated to the specified number of commits. |
|
# |
|
# @option options [String] :filter Request that the server send a partial |
|
# clone according to the given filter |
|
# |
|
# @option options [Logger] :log A logger to use for Git operations. Git |
|
# commands are logged at the `:info` level. Additional logging is done |
|
# at the `:debug` level. |
|
# |
|
# @option options [Boolean] :mirror Set up a mirror of the source repository. |
|
# |
|
# @option options [String] :origin Use the value instead `origin` to track |
|
# the upstream repository. |
|
# |
|
# @option options [Pathname] :path The directory to clone into. May be used |
|
# as an alternative to the `directory` parameter. If specified, the |
|
# `path` option is used instead of the `directory` parameter. |
|
# |
|
# @option options [Boolean] :recursive After the clone is created, initialize |
|
# all submodules within, using their default settings. |
|
# |
|
# @example Clone into the default directory `ruby-git` |
|
# git = Git.clone('https://github.com/ruby-git/ruby-git.git') |
|
# |
|
# @example Clone and then checkout the `development` branch |
|
# git = Git.clone('https://github.com/ruby-git/ruby-git.git', branch: 'development') |
|
# |
|
# @example Clone into a different directory `my-ruby-git` |
|
# git = Git.clone('https://github.com/ruby-git/ruby-git.git', 'my-ruby-git') |
|
# # or: |
|
# git = Git.clone('https://github.com/ruby-git/ruby-git.git', path: 'my-ruby-git') |
|
# |
|
# @example Create a bare repository in the directory `ruby-git.git` |
|
# git = Git.clone('https://github.com/ruby-git/ruby-git.git', bare: true) |
|
# |
|
# @example Clone a repository and set a single config option |
|
# git = Git.clone( |
|
# 'https://github.com/ruby-git/ruby-git.git', |
|
# config: 'submodule.recurse=true' |
|
# ) |
|
# |
|
# @example Clone a repository and set multiple config options |
|
# git = Git.clone( |
|
# 'https://github.com/ruby-git/ruby-git.git', |
|
# config: ['user.name=John Doe', 'user.email=dev@example.invalid'] |
|
# ) |
|
# |
|
# @return [Git::Base] an object that can execute git commands in the context |
|
# of the cloned local working copy or cloned repository. |
|
# |
|
def self.clone(repository_url, directory = nil, options = {}) |
|
clone_to_options = options.select { |key, _value| %i[bare mirror].include?(key) } |
|
directory ||= Git::URL.clone_to(repository_url, **clone_to_options) |
|
Base.clone(repository_url, directory, options) |
|
end |
|
|
|
# Returns the name of the default branch of the given repository |
|
# |
|
# @example with a URI string |
|
# Git.default_branch('https://github.com/ruby-git/ruby-git') # => 'master' |
|
# Git.default_branch('https://github.com/rspec/rspec-core') # => 'main' |
|
# |
|
# @example with a URI object |
|
# repository_uri = URI('https://github.com/ruby-git/ruby-git') |
|
# Git.default_branch(repository_uri) # => 'master' |
|
# |
|
# @example with a local repository |
|
# Git.default_branch('.') # => 'master' |
|
# |
|
# @example with a local repository Pathname |
|
# repository_path = Pathname('.') |
|
# Git.default_branch(repository_path) # => 'master' |
|
# |
|
# @example with the logging option |
|
# logger = Logger.new(STDOUT, level: Logger::INFO) |
|
# Git.default_branch('.', log: logger) # => 'master' |
|
# I, [2022-04-13T16:01:33.221596 #18415] INFO -- : git '-c' 'core.quotePath=true' '-c' 'color.ui=false' ls-remote '--symref' '--' '.' 'HEAD' 2>&1 |
|
# |
|
# @param repository [URI, Pathname, String] The (possibly remote) repository to get the default branch name for |
|
# |
|
# See [GIT URLS](https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a) |
|
# for more information. |
|
# |
|
# @param [Hash] options The options for this command (see list of valid |
|
# options below) |
|
# |
|
# @option options [Logger] :log A logger to use for Git operations. Git |
|
# commands are logged at the `:info` level. Additional logging is done |
|
# at the `:debug` level. |
|
# |
|
# @return [String] the name of the default branch |
|
# |
|
def self.default_branch(repository, options = {}) |
|
Base.repository_default_branch(repository, options) |
|
end |
|
|
|
# Export the current HEAD (or a branch, if <tt>options[:branch]</tt> |
|
# is specified) into the +name+ directory, then remove all traces of git from the |
|
# directory. |
|
# |
|
# See +clone+ for options. Does not obey the <tt>:remote</tt> option, |
|
# since the .git info will be deleted anyway; always uses the default |
|
# remote, 'origin.' |
|
def self.export(repository, name, options = {}) |
|
options.delete(:remote) |
|
repo = clone(repository, name, {:depth => 1}.merge(options)) |
|
repo.checkout("origin/#{options[:branch]}") if options[:branch] |
|
FileUtils.rm_r File.join(repo.dir.to_s, '.git') |
|
end |
|
|
|
# Same as g.config, but forces it to be at the global level |
|
# |
|
#g.config('user.name', 'Scott Chacon') # sets value |
|
#g.config('user.email', 'dev@example.invalid') # sets value |
|
#g.config('user.name') # returns 'Scott Chacon' |
|
#g.config # returns whole config hash |
|
def self.global_config(name = nil, value = nil) |
|
lib = Git::Lib.new(nil, nil) |
|
if(name && value) |
|
# set value |
|
lib.global_config_set(name, value) |
|
elsif (name) |
|
# return value |
|
lib.global_config_get(name) |
|
else |
|
# return hash |
|
lib.global_config_list |
|
end |
|
end |
|
|
|
# Create an empty Git repository or reinitialize an existing Git repository |
|
# |
|
# @param [Pathname] directory If the `:bare` option is NOT given or is not |
|
# `true`, the repository will be created in `"#{directory}/.git"`. |
|
# Otherwise, the repository is created in `"#{directory}"`. |
|
# |
|
# All directories along the path to `directory` are created if they do not exist. |
|
# |
|
# A relative path is referenced from the current working directory of the process |
|
# and converted to an absolute path using |
|
# [File.expand_path](https://www.rubydoc.info/stdlib/core/File.expand_path). |
|
# |
|
# @param [Hash] options The options for this command (see list of valid |
|
# options below) |
|
# |
|
# @option options [Boolean] :bare Instead of creating a repository at |
|
# `"#{directory}/.git"`, create a bare repository at `"#{directory}"`. |
|
# See [what is a bare repository?](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefbarerepositoryabarerepository). |
|
# |
|
# @option options [String] :initial_branch Use the specified name for the |
|
# initial branch in the newly created repository. |
|
# |
|
# @option options [Pathname] :repository the path to put the newly initialized |
|
# Git repository. The default for non-bare repository is `"#{directory}/.git"`. |
|
# |
|
# A relative path is referenced from the current working directory of the process |
|
# and converted to an absolute path using |
|
# [File.expand_path](https://www.rubydoc.info/stdlib/core/File.expand_path). |
|
# |
|
# @option options [Logger] :log A logger to use for Git operations. Git |
|
# commands are logged at the `:info` level. Additional logging is done |
|
# at the `:debug` level. |
|
# |
|
# @return [Git::Base] an object that can execute git commands in the context |
|
# of the newly initialized repository |
|
# |
|
# @example Initialize a repository in the current directory |
|
# git = Git.init |
|
# |
|
# @example Initialize a repository in some other directory |
|
# git = Git.init '~/code/ruby-git' |
|
# |
|
# @example Initialize a bare repository |
|
# git = Git.init '~/code/ruby-git.git', bare: true |
|
# |
|
# @example Initialize a repository in a non-default location (outside of the working copy) |
|
# git = Git.init '~/code/ruby-git', repository: '~/code/ruby-git.git' |
|
# |
|
# @see https://git-scm.com/docs/git-init git init |
|
# |
|
def self.init(directory = '.', options = {}) |
|
Base.init(directory, options) |
|
end |
|
|
|
# returns a Hash containing information about the references |
|
# of the target repository |
|
# |
|
# options |
|
# :refs |
|
# |
|
# @param [String|NilClass] location the target repository location or nil for '.' |
|
# @return [{String=>Hash}] the available references of the target repo. |
|
def self.ls_remote(location = nil, options = {}) |
|
Git::Lib.new.ls_remote(location, options) |
|
end |
|
|
|
# Open a an existing Git working directory |
|
# |
|
# Git.open will most likely be the most common way to create |
|
# a git reference, referring to an existing working directory. |
|
# |
|
# If not provided in the options, the library will assume |
|
# the repository and index are in the default places (`.git/`, `.git/index`). |
|
# |
|
# @example Open the Git working directory in the current directory |
|
# git = Git.open |
|
# |
|
# @example Open a Git working directory in some other directory |
|
# git = Git.open('~/Projects/ruby-git') |
|
# |
|
# @example Use a logger to see what is going on |
|
# logger = Logger.new(STDOUT) |
|
# git = Git.open('~/Projects/ruby-git', log: logger) |
|
# |
|
# @example Open a working copy whose repository is in a non-standard directory |
|
# git = Git.open('~/Projects/ruby-git', repository: '~/Project/ruby-git.git') |
|
# |
|
# @param [Pathname] working_dir the path to the working directory to use |
|
# for git commands. |
|
# |
|
# A relative path is referenced from the current working directory of the process |
|
# and converted to an absolute path using |
|
# [File.expand_path](https://www.rubydoc.info/stdlib/core/File.expand_path). |
|
# |
|
# @param [Hash] options The options for this command (see list of valid |
|
# options below) |
|
# |
|
# @option options [Pathname] :repository used to specify a non-standard path to |
|
# the repository directory. The default is `"#{working_dir}/.git"`. |
|
# |
|
# @option options [Pathname] :index used to specify a non-standard path to an |
|
# index file. The default is `"#{working_dir}/.git/index"` |
|
# |
|
# @option options [Logger] :log A logger to use for Git operations. Git |
|
# commands are logged at the `:info` level. Additional logging is done |
|
# at the `:debug` level. |
|
# |
|
# @return [Git::Base] an object that can execute git commands in the context |
|
# of the opened working copy |
|
# |
|
def self.open(working_dir, options = {}) |
|
Base.open(working_dir, options) |
|
end |
|
|
|
# Return the version of the git binary |
|
# |
|
# @example |
|
# Git.binary_version # => [2, 46, 0] |
|
# |
|
# @return [Array<Integer>] the version of the git binary |
|
# |
|
def self.binary_version(binary_path = Git::Base.config.binary_path) |
|
Base.binary_version(binary_path) |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/author.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
class Author |
|
attr_accessor :name, :email, :date |
|
|
|
def initialize(author_string) |
|
if m = /(.*?) <(.*?)> (\d+) (.*)/.match(author_string) |
|
@name = m[1] |
|
@email = m[2] |
|
@date = Time.at(m[3].to_i) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/base.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'logger' |
|
require 'open3' |
|
|
|
module Git |
|
# The main public interface for interacting with Git commands |
|
# |
|
# Instead of creating a Git::Base directly, obtain a Git::Base instance by |
|
# calling one of the follow {Git} class methods: {Git.open}, {Git.init}, |
|
# {Git.clone}, or {Git.bare}. |
|
# |
|
# @api public |
|
# |
|
class Base |
|
# (see Git.bare) |
|
def self.bare(git_dir, options = {}) |
|
normalize_paths(options, default_repository: git_dir, bare: true) |
|
self.new(options) |
|
end |
|
|
|
# (see Git.clone) |
|
def self.clone(repository_url, directory, options = {}) |
|
new_options = Git::Lib.new(nil, options[:log]).clone(repository_url, directory, options) |
|
normalize_paths(new_options, bare: options[:bare] || options[:mirror]) |
|
new(new_options) |
|
end |
|
|
|
# (see Git.default_branch) |
|
def self.repository_default_branch(repository, options = {}) |
|
Git::Lib.new(nil, options[:log]).repository_default_branch(repository) |
|
end |
|
|
|
# Returns (and initialize if needed) a Git::Config instance |
|
# |
|
# @return [Git::Config] the current config instance. |
|
def self.config |
|
@@config ||= Config.new |
|
end |
|
|
|
def self.binary_version(binary_path) |
|
result = nil |
|
status = nil |
|
|
|
begin |
|
result, status = Open3.capture2e(binary_path, "-c", "core.quotePath=true", "-c", "color.ui=false", "version") |
|
result = result.chomp |
|
rescue Errno::ENOENT |
|
raise RuntimeError, "Failed to get git version: #{binary_path} not found" |
|
end |
|
|
|
if status.success? |
|
version = result[/\d+(\.\d+)+/] |
|
version_parts = version.split('.').collect { |i| i.to_i } |
|
version_parts.fill(0, version_parts.length...3) |
|
else |
|
raise RuntimeError, "Failed to get git version: #{status}\n#{result}" |
|
end |
|
end |
|
|
|
# (see Git.init) |
|
def self.init(directory = '.', options = {}) |
|
normalize_paths(options, default_working_directory: directory, default_repository: directory, bare: options[:bare]) |
|
|
|
init_options = { |
|
:bare => options[:bare], |
|
:initial_branch => options[:initial_branch] |
|
} |
|
|
|
directory = options[:bare] ? options[:repository] : options[:working_directory] |
|
FileUtils.mkdir_p(directory) unless File.exist?(directory) |
|
|
|
# TODO: this dance seems awkward: this creates a Git::Lib so we can call |
|
# init so we can create a new Git::Base which in turn (ultimately) |
|
# creates another/different Git::Lib. |
|
# |
|
# TODO: maybe refactor so this Git::Bare.init does this: |
|
# self.new(opts).init(init_opts) and move all/some of this code into |
|
# Git::Bare#init. This way the init method can be called on any |
|
# repository you have a Git::Base instance for. This would not |
|
# change the existing interface (other than adding to it). |
|
# |
|
Git::Lib.new(options).init(init_options) |
|
|
|
self.new(options) |
|
end |
|
|
|
def self.root_of_worktree(working_dir) |
|
result = working_dir |
|
status = nil |
|
|
|
raise ArgumentError, "'#{working_dir}' does not exist" unless Dir.exist?(working_dir) |
|
|
|
begin |
|
result, status = Open3.capture2e(Git::Base.config.binary_path, "-c", "core.quotePath=true", "-c", "color.ui=false", "rev-parse", "--show-toplevel", chdir: File.expand_path(working_dir)) |
|
result = result.chomp |
|
rescue Errno::ENOENT |
|
raise ArgumentError, "Failed to find the root of the worktree: git binary not found" |
|
end |
|
|
|
raise ArgumentError, "'#{working_dir}' is not in a git working tree" unless status.success? |
|
result |
|
end |
|
|
|
# (see Git.open) |
|
def self.open(working_dir, options = {}) |
|
raise ArgumentError, "'#{working_dir}' is not a directory" unless Dir.exist?(working_dir) |
|
|
|
working_dir = root_of_worktree(working_dir) unless options[:repository] |
|
|
|
normalize_paths(options, default_working_directory: working_dir) |
|
|
|
self.new(options) |
|
end |
|
|
|
# Create an object that executes Git commands in the context of a working |
|
# copy or a bare repository. |
|
# |
|
# @param [Hash] options The options for this command (see list of valid |
|
# options below) |
|
# |
|
# @option options [Pathname] :working_dir the path to the root of the working |
|
# directory. Should be `nil` if executing commands on a bare repository. |
|
# |
|
# @option options [Pathname] :repository used to specify a non-standard path to |
|
# the repository directory. The default is `"#{working_dir}/.git"`. |
|
# |
|
# @option options [Pathname] :index used to specify a non-standard path to an |
|
# index file. The default is `"#{working_dir}/.git/index"` |
|
# |
|
# @option options [Logger] :log A logger to use for Git operations. Git |
|
# commands are logged at the `:info` level. Additional logging is done |
|
# at the `:debug` level. |
|
# |
|
# @return [Git::Base] an object that can execute git commands in the context |
|
# of the opened working copy or bare repository |
|
# |
|
def initialize(options = {}) |
|
if working_dir = options[:working_directory] |
|
options[:repository] ||= File.join(working_dir, '.git') |
|
options[:index] ||= File.join(options[:repository], 'index') |
|
end |
|
@logger = (options[:log] || Logger.new(nil)) |
|
@logger.info("Starting Git") |
|
|
|
@working_directory = options[:working_directory] ? Git::WorkingDirectory.new(options[:working_directory]) : nil |
|
@repository = options[:repository] ? Git::Repository.new(options[:repository]) : nil |
|
@index = options[:index] ? Git::Index.new(options[:index], false) : nil |
|
end |
|
|
|
# Update the index from the current worktree to prepare the for the next commit |
|
# |
|
# @example |
|
# lib.add('path/to/file') |
|
# lib.add(['path/to/file1','path/to/file2']) |
|
# lib.add(all: true) |
|
# |
|
# @param [String, Array<String>] paths a file or files to be added to the repository (relative to the worktree root) |
|
# @param [Hash] options |
|
# |
|
# @option options [Boolean] :all Add, modify, and remove index entries to match the worktree |
|
# @option options [Boolean] :force Allow adding otherwise ignored files |
|
# |
|
def add(paths = '.', **options) |
|
self.lib.add(paths, options) |
|
end |
|
|
|
# adds a new remote to this repository |
|
# url can be a git url or a Git::Base object if it's a local reference |
|
# |
|
# @git.add_remote('scotts_git', 'git://repo.or.cz/rubygit.git') |
|
# @git.fetch('scotts_git') |
|
# @git.merge('scotts_git/master') |
|
# |
|
# Options: |
|
# :fetch => true |
|
# :track => <branch_name> |
|
def add_remote(name, url, opts = {}) |
|
url = url.repo.path if url.is_a?(Git::Base) |
|
self.lib.remote_add(name, url, opts) |
|
Git::Remote.new(self, name) |
|
end |
|
|
|
# Create a new git tag |
|
# |
|
# @example |
|
# repo.add_tag('tag_name', object_reference) |
|
# repo.add_tag('tag_name', object_reference, {:options => 'here'}) |
|
# repo.add_tag('tag_name', {:options => 'here'}) |
|
# |
|
# @param [String] name The name of the tag to add |
|
# @param [Hash] options Opstions to pass to `git tag`. |
|
# See [git-tag](https://git-scm.com/docs/git-tag) for more details. |
|
# @option options [boolean] :annotate Make an unsigned, annotated tag object |
|
# @option options [boolean] :a An alias for the `:annotate` option |
|
# @option options [boolean] :d Delete existing tag with the given names. |
|
# @option options [boolean] :f Replace an existing tag with the given name (instead of failing) |
|
# @option options [String] :message Use the given tag message |
|
# @option options [String] :m An alias for the `:message` option |
|
# @option options [boolean] :s Make a GPG-signed tag. |
|
# |
|
def add_tag(name, *options) |
|
self.lib.tag(name, *options) |
|
self.tag(name) |
|
end |
|
|
|
# changes current working directory for a block |
|
# to the git working directory |
|
# |
|
# example |
|
# @git.chdir do |
|
# # write files |
|
# @git.add |
|
# @git.commit('message') |
|
# end |
|
def chdir # :yields: the Git::Path |
|
Dir.chdir(dir.path) do |
|
yield dir.path |
|
end |
|
end |
|
|
|
#g.config('user.name', 'Scott Chacon') # sets value |
|
#g.config('user.email', 'dev@example.invalid') # sets value |
|
#g.config('user.email', 'dev@example.invalid', file: 'path/to/custom/config) # sets value in file |
|
#g.config('user.name') # returns 'Scott Chacon' |
|
#g.config # returns whole config hash |
|
def config(name = nil, value = nil, options = {}) |
|
if name && value |
|
# set value |
|
lib.config_set(name, value, options) |
|
elsif name |
|
# return value |
|
lib.config_get(name) |
|
else |
|
# return hash |
|
lib.config_list |
|
end |
|
end |
|
|
|
# returns a reference to the working directory |
|
# @git.dir.path |
|
# @git.dir.writeable? |
|
def dir |
|
@working_directory |
|
end |
|
|
|
# returns reference to the git index file |
|
def index |
|
@index |
|
end |
|
|
|
# returns reference to the git repository directory |
|
# @git.dir.path |
|
def repo |
|
@repository |
|
end |
|
|
|
# returns the repository size in bytes |
|
def repo_size |
|
Dir.glob(File.join(repo.path, '**', '*'), File::FNM_DOTMATCH).reject do |f| |
|
f.include?('..') |
|
end.map do |f| |
|
File.expand_path(f) |
|
end.uniq.map do |f| |
|
File.stat(f).size.to_i |
|
end.reduce(:+) |
|
end |
|
|
|
def set_index(index_file, check = true) |
|
@lib = nil |
|
@index = Git::Index.new(index_file.to_s, check) |
|
end |
|
|
|
def set_working(work_dir, check = true) |
|
@lib = nil |
|
@working_directory = Git::WorkingDirectory.new(work_dir.to_s, check) |
|
end |
|
|
|
# returns +true+ if the branch exists locally |
|
def is_local_branch?(branch) |
|
branch_names = self.branches.local.map {|b| b.name} |
|
branch_names.include?(branch) |
|
end |
|
|
|
# returns +true+ if the branch exists remotely |
|
def is_remote_branch?(branch) |
|
branch_names = self.branches.remote.map {|b| b.name} |
|
branch_names.include?(branch) |
|
end |
|
|
|
# returns +true+ if the branch exists |
|
def is_branch?(branch) |
|
branch_names = self.branches.map {|b| b.name} |
|
branch_names.include?(branch) |
|
end |
|
|
|
# this is a convenience method for accessing the class that wraps all the |
|
# actual 'git' forked system calls. At some point I hope to replace the Git::Lib |
|
# class with one that uses native methods or libgit C bindings |
|
def lib |
|
@lib ||= Git::Lib.new(self, @logger) |
|
end |
|
|
|
# Run a grep for 'string' on the HEAD of the git repository |
|
# |
|
# @example Limit grep's scope by calling grep() from a specific object: |
|
# git.object("v2.3").grep('TODO') |
|
# |
|
# @example Using grep results: |
|
# git.grep("TODO").each do |sha, arr| |
|
# puts "in blob #{sha}:" |
|
# arr.each do |line_no, match_string| |
|
# puts "\t line #{line_no}: '#{match_string}'" |
|
# end |
|
# end |
|
# |
|
# @param string [String] the string to search for |
|
# @param path_limiter [String, Array] a path or array of paths to limit the search to or nil for no limit |
|
# @param opts [Hash] options to pass to the underlying `git grep` command |
|
# |
|
# @option opts [Boolean] :ignore_case (false) ignore case when matching |
|
# @option opts [Boolean] :invert_match (false) select non-matching lines |
|
# @option opts [Boolean] :extended_regexp (false) use extended regular expressions |
|
# @option opts [String] :object (HEAD) the object to search from |
|
# |
|
# @return [Hash<String, Array>] a hash of arrays |
|
# ```Ruby |
|
# { |
|
# 'tree-ish1' => [[line_no1, match_string1], ...], |
|
# 'tree-ish2' => [[line_no1, match_string1], ...], |
|
# ... |
|
# } |
|
# ``` |
|
# |
|
def grep(string, path_limiter = nil, opts = {}) |
|
self.object('HEAD').grep(string, path_limiter, opts) |
|
end |
|
|
|
# List the files in the worktree that are ignored by git |
|
# @return [Array<String>] the list of ignored files relative to teh root of the worktree |
|
# |
|
def ignored_files |
|
self.lib.ignored_files |
|
end |
|
|
|
# removes file(s) from the git repository |
|
def rm(path = '.', opts = {}) |
|
self.lib.rm(path, opts) |
|
end |
|
|
|
alias remove rm |
|
|
|
# resets the working directory to the provided commitish |
|
def reset(commitish = nil, opts = {}) |
|
self.lib.reset(commitish, opts) |
|
end |
|
|
|
# resets the working directory to the commitish with '--hard' |
|
def reset_hard(commitish = nil, opts = {}) |
|
opts = {:hard => true}.merge(opts) |
|
self.lib.reset(commitish, opts) |
|
end |
|
|
|
# cleans the working directory |
|
# |
|
# options: |
|
# :force |
|
# :d |
|
# :ff |
|
# |
|
def clean(opts = {}) |
|
self.lib.clean(opts) |
|
end |
|
|
|
# returns the most recent tag that is reachable from a commit |
|
# |
|
# options: |
|
# :all |
|
# :tags |
|
# :contains |
|
# :debug |
|
# :exact_match |
|
# :dirty |
|
# :abbrev |
|
# :candidates |
|
# :long |
|
# :always |
|
# :match |
|
# |
|
def describe(committish=nil, opts={}) |
|
self.lib.describe(committish, opts) |
|
end |
|
|
|
# reverts the working directory to the provided commitish. |
|
# Accepts a range, such as comittish..HEAD |
|
# |
|
# options: |
|
# :no_edit |
|
# |
|
def revert(commitish = nil, opts = {}) |
|
self.lib.revert(commitish, opts) |
|
end |
|
|
|
# commits all pending changes in the index file to the git repository |
|
# |
|
# options: |
|
# :all |
|
# :allow_empty |
|
# :amend |
|
# :author |
|
# |
|
def commit(message, opts = {}) |
|
self.lib.commit(message, opts) |
|
end |
|
|
|
# commits all pending changes in the index file to the git repository, |
|
# but automatically adds all modified files without having to explicitly |
|
# calling @git.add() on them. |
|
def commit_all(message, opts = {}) |
|
opts = {:add_all => true}.merge(opts) |
|
self.lib.commit(message, opts) |
|
end |
|
|
|
# checks out a branch as the new git working directory |
|
def checkout(*args, **options) |
|
self.lib.checkout(*args, **options) |
|
end |
|
|
|
# checks out an old version of a file |
|
def checkout_file(version, file) |
|
self.lib.checkout_file(version,file) |
|
end |
|
|
|
# fetches changes from a remote branch - this does not modify the working directory, |
|
# it just gets the changes from the remote if there are any |
|
def fetch(remote = 'origin', opts = {}) |
|
if remote.is_a?(Hash) |
|
opts = remote |
|
remote = nil |
|
end |
|
self.lib.fetch(remote, opts) |
|
end |
|
|
|
# Push changes to a remote repository |
|
# |
|
# @overload push(remote = nil, branch = nil, options = {}) |
|
# @param remote [String] the remote repository to push to |
|
# @param branch [String] the branch to push |
|
# @param options [Hash] options to pass to the push command |
|
# |
|
# @option opts [Boolean] :mirror (false) Push all refs under refs/heads/, refs/tags/ and refs/remotes/ |
|
# @option opts [Boolean] :delete (false) Delete refs that don't exist on the remote |
|
# @option opts [Boolean] :force (false) Force updates |
|
# @option opts [Boolean] :tags (false) Push all refs under refs/tags/ |
|
# @option opts [Array, String] :push_options (nil) Push options to transmit |
|
# |
|
# @return [Void] |
|
# |
|
# @raise [Git::FailedError] if the push fails |
|
# @raise [ArgumentError] if a branch is given without a remote |
|
# |
|
def push(*args, **options) |
|
self.lib.push(*args, **options) |
|
end |
|
|
|
# merges one or more branches into the current working branch |
|
# |
|
# you can specify more than one branch to merge by passing an array of branches |
|
def merge(branch, message = 'merge', opts = {}) |
|
self.lib.merge(branch, message, opts) |
|
end |
|
|
|
# iterates over the files which are unmerged |
|
def each_conflict(&block) # :yields: file, your_version, their_version |
|
self.lib.conflicts(&block) |
|
end |
|
|
|
# Pulls the given branch from the given remote into the current branch |
|
# |
|
# @param remote [String] the remote repository to pull from |
|
# @param branch [String] the branch to pull from |
|
# @param opts [Hash] options to pass to the pull command |
|
# |
|
# @option opts [Boolean] :allow_unrelated_histories (false) Merges histories of two projects that started their |
|
# lives independently |
|
# @example pulls from origin/master |
|
# @git.pull |
|
# @example pulls from upstream/master |
|
# @git.pull('upstream') |
|
# @example pulls from upstream/develop |
|
# @git.pull('upstream', 'develop') |
|
# |
|
# @return [Void] |
|
# |
|
# @raise [Git::FailedError] if the pull fails |
|
# @raise [ArgumentError] if a branch is given without a remote |
|
def pull(remote = nil, branch = nil, opts = {}) |
|
self.lib.pull(remote, branch, opts) |
|
end |
|
|
|
# returns an array of Git:Remote objects |
|
def remotes |
|
self.lib.remotes.map { |r| Git::Remote.new(self, r) } |
|
end |
|
|
|
# sets the url for a remote |
|
# url can be a git url or a Git::Base object if it's a local reference |
|
# |
|
# @git.set_remote_url('scotts_git', 'git://repo.or.cz/rubygit.git') |
|
# |
|
def set_remote_url(name, url) |
|
url = url.repo.path if url.is_a?(Git::Base) |
|
self.lib.remote_set_url(name, url) |
|
Git::Remote.new(self, name) |
|
end |
|
|
|
# removes a remote from this repository |
|
# |
|
# @git.remove_remote('scott_git') |
|
def remove_remote(name) |
|
self.lib.remote_remove(name) |
|
end |
|
|
|
# returns an array of all Git::Tag objects for this repository |
|
def tags |
|
self.lib.tags.map { |r| tag(r) } |
|
end |
|
|
|
# Create a new git tag |
|
# |
|
# @example |
|
# repo.add_tag('tag_name', object_reference) |
|
# repo.add_tag('tag_name', object_reference, {:options => 'here'}) |
|
# repo.add_tag('tag_name', {:options => 'here'}) |
|
# |
|
# @param [String] name The name of the tag to add |
|
# @param [Hash] options Opstions to pass to `git tag`. |
|
# See [git-tag](https://git-scm.com/docs/git-tag) for more details. |
|
# @option options [boolean] :annotate Make an unsigned, annotated tag object |
|
# @option options [boolean] :a An alias for the `:annotate` option |
|
# @option options [boolean] :d Delete existing tag with the given names. |
|
# @option options [boolean] :f Replace an existing tag with the given name (instead of failing) |
|
# @option options [String] :message Use the given tag message |
|
# @option options [String] :m An alias for the `:message` option |
|
# @option options [boolean] :s Make a GPG-signed tag. |
|
# |
|
def add_tag(name, *options) |
|
self.lib.tag(name, *options) |
|
self.tag(name) |
|
end |
|
|
|
# deletes a tag |
|
def delete_tag(name) |
|
self.lib.tag(name, {:d => true}) |
|
end |
|
|
|
# creates an archive file of the given tree-ish |
|
def archive(treeish, file = nil, opts = {}) |
|
self.object(treeish).archive(file, opts) |
|
end |
|
|
|
# repacks the repository |
|
def repack |
|
self.lib.repack |
|
end |
|
|
|
def gc |
|
self.lib.gc |
|
end |
|
|
|
def apply(file) |
|
if File.exist?(file) |
|
self.lib.apply(file) |
|
end |
|
end |
|
|
|
def apply_mail(file) |
|
self.lib.apply_mail(file) if File.exist?(file) |
|
end |
|
|
|
# Shows objects |
|
# |
|
# @param [String|NilClass] objectish the target object reference (nil == HEAD) |
|
# @param [String|NilClass] path the path of the file to be shown |
|
# @return [String] the object information |
|
def show(objectish=nil, path=nil) |
|
self.lib.show(objectish, path) |
|
end |
|
|
|
## LOWER LEVEL INDEX OPERATIONS ## |
|
|
|
def with_index(new_index) # :yields: new_index |
|
old_index = @index |
|
set_index(new_index, false) |
|
return_value = yield @index |
|
set_index(old_index) |
|
return_value |
|
end |
|
|
|
def with_temp_index &blk |
|
# Workaround for JRUBY, since they handle the TempFile path different. |
|
# MUST be improved to be safer and OS independent. |
|
if RUBY_PLATFORM == 'java' |
|
temp_path = "/tmp/temp-index-#{(0...15).map{ ('a'..'z').to_a[rand(26)] }.join}" |
|
else |
|
tempfile = Tempfile.new('temp-index') |
|
temp_path = tempfile.path |
|
tempfile.close |
|
tempfile.unlink |
|
end |
|
|
|
with_index(temp_path, &blk) |
|
end |
|
|
|
def checkout_index(opts = {}) |
|
self.lib.checkout_index(opts) |
|
end |
|
|
|
def read_tree(treeish, opts = {}) |
|
self.lib.read_tree(treeish, opts) |
|
end |
|
|
|
def write_tree |
|
self.lib.write_tree |
|
end |
|
|
|
def write_and_commit_tree(opts = {}) |
|
tree = write_tree |
|
commit_tree(tree, opts) |
|
end |
|
|
|
def update_ref(branch, commit) |
|
branch(branch).update_ref(commit) |
|
end |
|
|
|
|
|
def ls_files(location=nil) |
|
self.lib.ls_files(location) |
|
end |
|
|
|
def with_working(work_dir) # :yields: the Git::WorkingDirectory |
|
return_value = false |
|
old_working = @working_directory |
|
set_working(work_dir) |
|
Dir.chdir work_dir do |
|
return_value = yield @working_directory |
|
end |
|
set_working(old_working) |
|
return_value |
|
end |
|
|
|
def with_temp_working &blk |
|
tempfile = Tempfile.new("temp-workdir") |
|
temp_dir = tempfile.path |
|
tempfile.close |
|
tempfile.unlink |
|
Dir.mkdir(temp_dir, 0700) |
|
with_working(temp_dir, &blk) |
|
end |
|
|
|
# runs git rev-parse to convert the objectish to a full sha |
|
# |
|
# @example |
|
# git.rev_parse("HEAD^^") |
|
# git.rev_parse('v2.4^{tree}') |
|
# git.rev_parse('v2.4:/doc/index.html') |
|
# |
|
def rev_parse(objectish) |
|
self.lib.rev_parse(objectish) |
|
end |
|
|
|
# For backwards compatibility |
|
alias revparse rev_parse |
|
|
|
def ls_tree(objectish, opts = {}) |
|
self.lib.ls_tree(objectish, opts) |
|
end |
|
|
|
def cat_file(objectish) |
|
self.lib.cat_file(objectish) |
|
end |
|
|
|
# The name of the branch HEAD refers to or 'HEAD' if detached |
|
# |
|
# Returns one of the following: |
|
# * The branch name that HEAD refers to (even if it is an unborn branch) |
|
# * 'HEAD' if in a detached HEAD state |
|
# |
|
# @return [String] the name of the branch HEAD refers to or 'HEAD' if detached |
|
# |
|
def current_branch |
|
self.lib.branch_current |
|
end |
|
|
|
# @return [Git::Branch] an object for branch_name |
|
def branch(branch_name = self.current_branch) |
|
Git::Branch.new(self, branch_name) |
|
end |
|
|
|
# @return [Git::Branches] a collection of all the branches in the repository. |
|
# Each branch is represented as a {Git::Branch}. |
|
def branches |
|
Git::Branches.new(self) |
|
end |
|
|
|
# returns a Git::Worktree object for dir, commitish |
|
def worktree(dir, commitish = nil) |
|
Git::Worktree.new(self, dir, commitish) |
|
end |
|
|
|
# returns a Git::worktrees object of all the Git::Worktrees |
|
# objects for this repo |
|
def worktrees |
|
Git::Worktrees.new(self) |
|
end |
|
|
|
# @return [Git::Object::Commit] a commit object |
|
def commit_tree(tree = nil, opts = {}) |
|
Git::Object::Commit.new(self, self.lib.commit_tree(tree, opts)) |
|
end |
|
|
|
# @return [Git::Diff] a Git::Diff object |
|
def diff(objectish = 'HEAD', obj2 = nil) |
|
Git::Diff.new(self, objectish, obj2) |
|
end |
|
|
|
# @return [Git::Object] a Git object |
|
def gblob(objectish) |
|
Git::Object.new(self, objectish, 'blob') |
|
end |
|
|
|
# @return [Git::Object] a Git object |
|
def gcommit(objectish) |
|
Git::Object.new(self, objectish, 'commit') |
|
end |
|
|
|
# @return [Git::Object] a Git object |
|
def gtree(objectish) |
|
Git::Object.new(self, objectish, 'tree') |
|
end |
|
|
|
# @return [Git::Log] a log with the specified number of commits |
|
def log(count = 30) |
|
Git::Log.new(self, count) |
|
end |
|
|
|
# returns a Git::Object of the appropriate type |
|
# you can also call @git.gtree('tree'), but that's |
|
# just for readability. If you call @git.gtree('HEAD') it will |
|
# still return a Git::Object::Commit object. |
|
# |
|
# object calls a method that will run a rev-parse |
|
# on the objectish and determine the type of the object and return |
|
# an appropriate object for that type |
|
# |
|
# @return [Git::Object] an instance of the appropriate type of Git::Object |
|
def object(objectish) |
|
Git::Object.new(self, objectish) |
|
end |
|
|
|
# @return [Git::Remote] a remote of the specified name |
|
def remote(remote_name = 'origin') |
|
Git::Remote.new(self, remote_name) |
|
end |
|
|
|
# @return [Git::Status] a status object |
|
def status |
|
Git::Status.new(self) |
|
end |
|
|
|
# @return [Git::Object::Tag] a tag object |
|
def tag(tag_name) |
|
Git::Object.new(self, tag_name, 'tag', true) |
|
end |
|
|
|
# Find as good common ancestors as possible for a merge |
|
# example: g.merge_base('master', 'some_branch', 'some_sha', octopus: true) |
|
# |
|
# @return [Array<Git::Object::Commit>] a collection of common ancestors |
|
def merge_base(*args) |
|
shas = self.lib.merge_base(*args) |
|
shas.map { |sha| gcommit(sha) } |
|
end |
|
|
|
private |
|
|
|
# Normalize options before they are sent to Git::Base.new |
|
# |
|
# Updates the options parameter by setting appropriate values for the following keys: |
|
# * options[:working_directory] |
|
# * options[:repository] |
|
# * options[:index] |
|
# |
|
# All three values will be set to absolute paths. An exception is that |
|
# :working_directory will be set to nil if bare is true. |
|
# |
|
private_class_method def self.normalize_paths( |
|
options, default_working_directory: nil, default_repository: nil, bare: false |
|
) |
|
normalize_working_directory(options, default: default_working_directory, bare: bare) |
|
normalize_repository(options, default: default_repository, bare: bare) |
|
normalize_index(options) |
|
end |
|
|
|
# Normalize options[:working_directory] |
|
# |
|
# If working with a bare repository, set to `nil`. |
|
# Otherwise, set to the first non-nil value of: |
|
# 1. `options[:working_directory]`, |
|
# 2. the `default` parameter, or |
|
# 3. the current working directory |
|
# |
|
# Finally, if options[:working_directory] is a relative path, convert it to an absoluite |
|
# path relative to the current directory. |
|
# |
|
private_class_method def self.normalize_working_directory(options, default:, bare: false) |
|
working_directory = |
|
if bare |
|
nil |
|
else |
|
File.expand_path(options[:working_directory] || default || Dir.pwd) |
|
end |
|
|
|
options[:working_directory] = working_directory |
|
end |
|
|
|
# Normalize options[:repository] |
|
# |
|
# If working with a bare repository, set to the first non-nil value out of: |
|
# 1. `options[:repository]` |
|
# 2. the `default` parameter |
|
# 3. the current working directory |
|
# |
|
# Otherwise, set to the first non-nil value of: |
|
# 1. `options[:repository]` |
|
# 2. `.git` |
|
# |
|
# Next, if options[:repository] refers to a *file* and not a *directory*, set |
|
# options[:repository] to the contents of that file. This is the case when |
|
# working with a submodule or a secondary working tree (created with git worktree |
|
# add). In these cases the repository is actually contained/nested within the |
|
# parent's repository directory. |
|
# |
|
# Finally, if options[:repository] is a relative path, convert it to an absolute |
|
# path relative to: |
|
# 1. the current directory if working with a bare repository or |
|
# 2. the working directory if NOT working with a bare repository |
|
# |
|
private_class_method def self.normalize_repository(options, default:, bare: false) |
|
repository = |
|
if bare |
|
File.expand_path(options[:repository] || default || Dir.pwd) |
|
else |
|
File.expand_path(options[:repository] || '.git', options[:working_directory]) |
|
end |
|
|
|
if File.file?(repository) |
|
repository = File.expand_path(File.open(repository).read[8..-1].strip, options[:working_directory]) |
|
end |
|
|
|
options[:repository] = repository |
|
end |
|
|
|
# Normalize options[:index] |
|
# |
|
# If options[:index] is a relative directory, convert it to an absolute |
|
# directory relative to the repository directory |
|
# |
|
private_class_method def self.normalize_index(options) |
|
index = File.expand_path(options[:index] || 'index', options[:repository]) |
|
options[:index] = index |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/branch.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'git/path' |
|
|
|
module Git |
|
class Branch < Path |
|
attr_accessor :full, :remote, :name |
|
|
|
def initialize(base, name) |
|
@full = name |
|
@base = base |
|
@gcommit = nil |
|
@stashes = nil |
|
@remote, @name = parse_name(name) |
|
end |
|
|
|
def gcommit |
|
@gcommit ||= @base.gcommit(@full) |
|
@gcommit |
|
end |
|
|
|
def stashes |
|
@stashes ||= Git::Stashes.new(@base) |
|
end |
|
|
|
def checkout |
|
check_if_create |
|
@base.checkout(@full) |
|
end |
|
|
|
def archive(file, opts = {}) |
|
@base.lib.archive(@full, file, opts) |
|
end |
|
|
|
# g.branch('new_branch').in_branch do |
|
# # create new file |
|
# # do other stuff |
|
# return true # auto commits and switches back |
|
# end |
|
def in_branch(message = 'in branch work') |
|
old_current = @base.lib.branch_current |
|
checkout |
|
if yield |
|
@base.commit_all(message) |
|
else |
|
@base.reset_hard |
|
end |
|
@base.checkout(old_current) |
|
end |
|
|
|
def create |
|
check_if_create |
|
end |
|
|
|
def delete |
|
@base.lib.branch_delete(@name) |
|
end |
|
|
|
def current |
|
determine_current |
|
end |
|
|
|
def contains?(commit) |
|
!@base.lib.branch_contains(commit, self.name).empty? |
|
end |
|
|
|
def merge(branch = nil, message = nil) |
|
if branch |
|
in_branch do |
|
@base.merge(branch, message) |
|
false |
|
end |
|
# merge a branch into this one |
|
else |
|
# merge this branch into the current one |
|
@base.merge(@name) |
|
end |
|
end |
|
|
|
def update_ref(commit) |
|
if @remote |
|
@base.lib.update_ref("refs/remotes/#{@remote.name}/#{@name}", commit) |
|
else |
|
@base.lib.update_ref("refs/heads/#{@name}", commit) |
|
end |
|
end |
|
|
|
def to_a |
|
[@full] |
|
end |
|
|
|
def to_s |
|
@full |
|
end |
|
|
|
private |
|
|
|
def check_if_create |
|
@base.lib.branch_new(@name) rescue nil |
|
end |
|
|
|
def determine_current |
|
@base.lib.branch_current == @name |
|
end |
|
|
|
BRANCH_NAME_REGEXP = %r{ |
|
^ |
|
# Optional 'refs/remotes/' at the beggining to specify a remote tracking branch |
|
# with a <remote_name>. <remote_name> is nil if not present. |
|
(?: |
|
(?:(?:refs/)?remotes/)(?<remote_name>[^/]+)/ |
|
)? |
|
(?<branch_name>.*) |
|
$ |
|
}x |
|
|
|
# Given a full branch name return an Array containing the remote and branch names. |
|
# |
|
# Removes 'remotes' from the beggining of the name (if present). |
|
# Takes the second part (splittign by '/') as the remote name. |
|
# Takes the rest as the repo name (can also hold one or more '/'). |
|
# |
|
# Example: |
|
# # local branches |
|
# parse_name('master') #=> [nil, 'master'] |
|
# parse_name('origin/master') #=> [nil, 'origin/master'] |
|
# parse_name('origin/master/v2') #=> [nil, 'origin/master'] |
|
# |
|
# # remote branches |
|
# parse_name('remotes/origin/master') #=> ['origin', 'master'] |
|
# parse_name('remotes/origin/master/v2') #=> ['origin', 'master/v2'] |
|
# parse_name('refs/remotes/origin/master') #=> ['origin', 'master'] |
|
# parse_name('refs/remotes/origin/master/v2') #=> ['origin', 'master/v2'] |
|
# |
|
# param [String] name branch full name. |
|
# return [<Git::Remote,NilClass,String>] an Array containing the remote and branch names. |
|
def parse_name(name) |
|
# Expect this will always match |
|
match = name.match(BRANCH_NAME_REGEXP) |
|
remote = match[:remote_name] ? Git::Remote.new(@base, match[:remote_name]) : nil |
|
branch_name = match[:branch_name] |
|
[ remote, branch_name ] |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [shards]: The sample search index `glacier_idx_7` is provisioned with 11 primary shards. |
|
|
|
|
|
### oss/ruby-git/lib/git/branches.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
|
|
# object that holds all the available branches |
|
class Branches |
|
|
|
include Enumerable |
|
|
|
def initialize(base) |
|
@branches = {} |
|
|
|
@base = base |
|
|
|
@base.lib.branches_all.each do |b| |
|
@branches[b[0]] = Git::Branch.new(@base, b[0]) |
|
end |
|
end |
|
|
|
def local |
|
self.select { |b| !b.remote } |
|
end |
|
|
|
def remote |
|
self.select { |b| b.remote } |
|
end |
|
|
|
# array like methods |
|
|
|
def size |
|
@branches.size |
|
end |
|
|
|
def each(&block) |
|
@branches.values.each(&block) |
|
end |
|
|
|
# Returns the target branch |
|
# |
|
# Example: |
|
# Given (git branch -a): |
|
# master |
|
# remotes/working/master |
|
# |
|
# g.branches['master'].full #=> 'master' |
|
# g.branches['working/master'].full => 'remotes/working/master' |
|
# g.branches['remotes/working/master'].full => 'remotes/working/master' |
|
# |
|
# @param [#to_s] branch_name the target branch name. |
|
# @return [Git::Branch] the target branch. |
|
def [](branch_name) |
|
@branches.values.inject(@branches) do |branches, branch| |
|
branches[branch.full] ||= branch |
|
|
|
# This is how Git (version 1.7.9.5) works. |
|
# Lets you ignore the 'remotes' if its at the beginning of the branch full name (even if is not a real remote branch). |
|
branches[branch.full.sub('remotes/', '')] ||= branch if branch.full =~ /^remotes\/.+/ |
|
|
|
branches |
|
end[branch_name.to_s] |
|
end |
|
|
|
def to_s |
|
out = '' |
|
@branches.each do |k, b| |
|
out << (b.current ? '* ' : ' ') << b.to_s << "\n" |
|
end |
|
out |
|
end |
|
end |
|
|
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/command_line.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'git/base' |
|
require 'git/command_line_result' |
|
require 'git/errors' |
|
require 'stringio' |
|
|
|
module Git |
|
# Runs a git command and returns the result |
|
# |
|
# @api public |
|
# |
|
class CommandLine |
|
# Create a Git::CommandLine object |
|
# |
|
# @example |
|
# env = { 'GIT_DIR' => '/path/to/git/dir' } |
|
# binary_path = '/usr/bin/git' |
|
# global_opts = %w[--git-dir /path/to/git/dir] |
|
# logger = Logger.new(STDOUT) |
|
# cli = CommandLine.new(env, binary_path, global_opts, logger) |
|
# cli.run('version') #=> #<Git::CommandLineResult:0x00007f9b0c0b0e00 |
|
# |
|
# @param env [Hash<String, String>] environment variables to set |
|
# @param global_opts [Array<String>] global options to pass to git |
|
# @param logger [Logger] the logger to use |
|
# |
|
def initialize(env, binary_path, global_opts, logger) |
|
@env = env |
|
@binary_path = binary_path |
|
@global_opts = global_opts |
|
@logger = logger |
|
end |
|
|
|
# @attribute [r] env |
|
# |
|
# Variables to set (or unset) in the git command's environment |
|
# |
|
# @example |
|
# env = { 'GIT_DIR' => '/path/to/git/dir' } |
|
# command_line = Git::CommandLine.new(env, '/usr/bin/git', [], Logger.new(STDOUT)) |
|
# command_line.env #=> { 'GIT_DIR' => '/path/to/git/dir' } |
|
# |
|
# @return [Hash<String, String>] |
|
# |
|
# @see https://ruby-doc.org/3.2.1/Process.html#method-c-spawn Process.spawn |
|
# for details on how to set environment variables using the `env` parameter |
|
# |
|
attr_reader :env |
|
|
|
# @attribute [r] binary_path |
|
# |
|
# The path to the command line binary to run |
|
# |
|
# @example |
|
# binary_path = '/usr/bin/git' |
|
# command_line = Git::CommandLine.new({}, binary_path, ['version'], Logger.new(STDOUT)) |
|
# command_line.binary_path #=> '/usr/bin/git' |
|
# |
|
# @return [String] |
|
# |
|
attr_reader :binary_path |
|
|
|
# @attribute [r] global_opts |
|
# |
|
# The global options to pass to git |
|
# |
|
# These are options that are passed to git before the command name and |
|
# arguments. For example, in `git --git-dir /path/to/git/dir version`, the |
|
# global options are %w[--git-dir /path/to/git/dir]. |
|
# |
|
# @example |
|
# env = {} |
|
# global_opts = %w[--git-dir /path/to/git/dir] |
|
# logger = Logger.new(nil) |
|
# cli = CommandLine.new(env, '/usr/bin/git', global_opts, logger) |
|
# cli.global_opts #=> %w[--git-dir /path/to/git/dir] |
|
# |
|
# @return [Array<String>] |
|
# |
|
attr_reader :global_opts |
|
|
|
# @attribute [r] logger |
|
# |
|
# The logger to use for logging git commands and results |
|
# |
|
# @example |
|
# env = {} |
|
# global_opts = %w[] |
|
# logger = Logger.new(STDOUT) |
|
# cli = CommandLine.new(env, '/usr/bin/git', global_opts, logger) |
|
# cli.logger == logger #=> true |
|
# |
|
# @return [Logger] |
|
# |
|
attr_reader :logger |
|
|
|
# Execute a git command, wait for it to finish, and return the result |
|
# |
|
# NORMALIZATION |
|
# |
|
# The command output is returned as a Unicde string containing the binary output |
|
# from the command. If the binary output is not valid UTF-8, the output will |
|
# cause problems because the encoding will be invalid. |
|
# |
|
# Normalization is a process that trys to convert the binary output to a valid |
|
# UTF-8 string. It uses the `rchardet` gem to detect the encoding of the binary |
|
# output and then converts it to UTF-8. |
|
# |
|
# Normalization is not enabled by default. Pass `normalize: true` to Git::CommandLine#run |
|
# to enable it. Normalization will only be performed on stdout and only if the `out:`` option |
|
# is nil or is a StringIO object. If the out: option is set to a file or other IO object, |
|
# the normalize option will be ignored. |
|
# |
|
# @example Run a command and return the output |
|
# cli.run('version') #=> "git version 2.39.1\n" |
|
# |
|
# @example The args array should be splatted into the parameter list |
|
# args = %w[log -n 1 --oneline] |
|
# cli.run(*args) #=> "f5baa11 beginning of Ruby/Git project\n" |
|
# |
|
# @example Run a command and return the chomped output |
|
# cli.run('version', chomp: true) #=> "git version 2.39.1" |
|
# |
|
# @example Run a command and without normalizing the output |
|
# cli.run('version', normalize: false) #=> "git version 2.39.1\n" |
|
# |
|
# @example Capture stdout in a temporary file |
|
# require 'tempfile' |
|
# tempfile = Tempfile.create('git') do |file| |
|
# cli.run('version', out: file) |
|
# file.rewind |
|
# file.read #=> "git version 2.39.1\n" |
|
# end |
|
# |
|
# @example Capture stderr in a StringIO object |
|
# require 'stringio' |
|
# stderr = StringIO.new |
|
# begin |
|
# cli.run('log', 'nonexistent-branch', err: stderr) |
|
# rescue Git::FailedError => e |
|
# stderr.string #=> "unknown revision or path not in the working tree.\n" |
|
# end |
|
# |
|
# @param args [Array<String>] the command line arguements to pass to git |
|
# |
|
# This array should be splatted into the parameter list. |
|
# |
|
# @param out [#write, nil] the object to write stdout to or nil to ignore stdout |
|
# |
|
# If this is a 'StringIO' object, then `stdout_writer.string` will be returned. |
|
# |
|
# In general, only specify a `stdout_writer` object when you want to redirect |
|
# stdout to a file or some other object that responds to `#write`. The default |
|
# behavior will return the output of the command. |
|
# |
|
# @param err [#write] the object to write stderr to or nil to ignore stderr |
|
# |
|
# If this is a 'StringIO' object and `merged_output` is `true`, then |
|
# `stderr_writer.string` will be merged into the output returned by this method. |
|
# |
|
# @param normalize [Boolean] whether to normalize the output to a valid encoding |
|
# |
|
# @param chomp [Boolean] whether to chomp the output |
|
# |
|
# @param merge [Boolean] whether to merge stdout and stderr in the string returned |
|
# |
|
# @param chdir [String] the directory to run the command in |
|
# |
|
# @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete |
|
# |
|
# If timeout is zero, the timeout will not be enforced. |
|
# |
|
# If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised. |
|
# |
|
# If the command does not respond to SIGKILL, it will hang this method. |
|
# |
|
# @return [Git::CommandLineResult] the output of the command |
|
# |
|
# This result of running the command. |
|
# |
|
# @raise [ArgumentError] if `args` is not an array of strings |
|
# |
|
# @raise [Git::SignaledError] if the command was terminated because of an uncaught signal |
|
# |
|
# @raise [Git::FailedError] if the command returned a non-zero exitstatus |
|
# |
|
# @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output |
|
# |
|
# @raise [Git::TimeoutError] if the command times out |
|
# |
|
def run(*args, out: nil, err: nil, normalize:, chomp:, merge:, chdir: nil, timeout: nil) |
|
git_cmd = build_git_cmd(args) |
|
begin |
|
result = ProcessExecuter.run(env, *git_cmd, out: out, err: err, merge:, chdir: (chdir || :not_set), timeout: timeout, raise_errors: false) |
|
rescue ProcessExecuter::Command::ProcessIOError => e |
|
raise Git::ProcessIOError.new(e.message), cause: e.exception.cause |
|
end |
|
process_result(result, normalize, chomp, timeout) |
|
end |
|
|
|
private |
|
|
|
# Build the git command line from the available sources to send to `Process.spawn` |
|
# @return [Array<String>] |
|
# @api private |
|
# |
|
def build_git_cmd(args) |
|
raise ArgumentError.new('The args array can not contain an array') if args.any? { |a| a.is_a?(Array) } |
|
|
|
[binary_path, *global_opts, *args].map { |e| e.to_s } |
|
end |
|
|
|
# Process the result of the command and return a Git::CommandLineResult |
|
# |
|
# Post process output, log the command and result, and raise an error if the |
|
# command failed. |
|
# |
|
# @param result [ProcessExecuter::Command::Result] the result it is a Process::Status and include command, stdout, and stderr |
|
# @param normalize [Boolean] whether to normalize the output of each writer |
|
# @param chomp [Boolean] whether to chomp the output of each writer |
|
# @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete |
|
# |
|
# @return [Git::CommandLineResult] the result of the command to return to the caller |
|
# |
|
# @raise [Git::FailedError] if the command failed |
|
# @raise [Git::SignaledError] if the command was signaled |
|
# @raise [Git::TimeoutError] if the command times out |
|
# @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output |
|
# |
|
# @api private |
|
# |
|
def process_result(result, normalize, chomp, timeout) |
|
command = result.command |
|
processed_out, processed_err = post_process_all([result.stdout, result.stderr], normalize, chomp) |
|
logger.info { "#{command} exited with status #{result}" } |
|
logger.debug { "stdout:\n#{processed_out.inspect}\nstderr:\n#{processed_err.inspect}" } |
|
Git::CommandLineResult.new(command, result, processed_out, processed_err).tap do |processed_result| |
|
raise Git::TimeoutError.new(processed_result, timeout) if result.timeout? |
|
raise Git::SignaledError.new(processed_result) if result.signaled? |
|
raise Git::FailedError.new(processed_result) unless result.success? |
|
end |
|
end |
|
|
|
# Post-process command output and return an array of the results |
|
# |
|
# @param raw_outputs [Array] the output to post-process |
|
# @param normalize [Boolean] whether to normalize the output of each writer |
|
# @param chomp [Boolean] whether to chomp the output of each writer |
|
# |
|
# @return [Array<String, nil>] the processed output of each command output object that supports `#string` |
|
# |
|
# @api private |
|
# |
|
def post_process_all(raw_outputs, normalize, chomp) |
|
Array.new.tap do |result| |
|
raw_outputs.each { |raw_output| result << post_process(raw_output, normalize, chomp) } |
|
end |
|
end |
|
|
|
# Determine the output to return in the `CommandLineResult` |
|
# |
|
# If the writer can return the output by calling `#string` (such as a StringIO), |
|
# then return the result of normalizing the encoding and chomping the output |
|
# as requested. |
|
# |
|
# If the writer does not support `#string`, then return nil. The output is |
|
# assumed to be collected by the writer itself such as when the writer |
|
# is a file instead of a StringIO. |
|
# |
|
# @param raw_output [#string] the output to post-process |
|
# @return [String, nil] |
|
# |
|
# @api private |
|
# |
|
def post_process(raw_output, normalize, chomp) |
|
if raw_output.respond_to?(:string) |
|
output = raw_output.string.dup |
|
output = output.lines.map { |l| Git::EncodingUtils.normalize_encoding(l) }.join if normalize |
|
output.chomp! if chomp |
|
output |
|
else |
|
nil |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/command_line_result.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
# The result of running a git command |
|
# |
|
# This object stores the Git command executed and its status, stdout, and stderr. |
|
# |
|
# @api public |
|
# |
|
class CommandLineResult |
|
# Create a CommandLineResult object |
|
# |
|
# @example |
|
# `true` |
|
# git_cmd = %w[git version] |
|
# status = $? |
|
# stdout = "git version 2.39.1\n" |
|
# stderr = "" |
|
# result = Git::CommandLineResult.new(git_cmd, status, stdout, stderr) |
|
# |
|
# @param git_cmd [Array<String>] the git command that was executed |
|
# @param status [Process::Status] the status of the process |
|
# @param stdout [String] the output of the process |
|
# @param stderr [String] the error output of the process |
|
# |
|
def initialize(git_cmd, status, stdout, stderr) |
|
@git_cmd = git_cmd |
|
@status = status |
|
@stdout = stdout |
|
@stderr = stderr |
|
end |
|
|
|
# @attribute [r] git_cmd |
|
# |
|
# The git command that was executed |
|
# |
|
# @example |
|
# git_cmd = %w[git version] |
|
# result = Git::CommandLineResult.new(git_cmd, $?, "", "") |
|
# result.git_cmd #=> ["git", "version"] |
|
# |
|
# @return [Array<String>] |
|
# |
|
attr_reader :git_cmd |
|
|
|
# @attribute [r] status |
|
# |
|
# The status of the process |
|
# |
|
# @example |
|
# `true` |
|
# status = $? |
|
# result = Git::CommandLineResult.new(status, "", "") |
|
# result.status #=> #<Process::Status: pid 87859 exit 0> |
|
# |
|
# @return [Process::Status] |
|
# |
|
attr_reader :status |
|
|
|
# @attribute [r] stdout |
|
# |
|
# The output of the process |
|
# |
|
# @example |
|
# stdout = "git version 2.39.1\n" |
|
# result = Git::CommandLineResult.new($?, stdout, "") |
|
# result.stdout #=> "git version 2.39.1\n" |
|
# |
|
# @return [String] |
|
# |
|
attr_reader :stdout |
|
|
|
# @attribute [r] stderr |
|
# |
|
# The error output of the process |
|
# |
|
# @example |
|
# stderr = "Tag not found\n" |
|
# result = Git::CommandLineResult.new($?, "", stderr) |
|
# result.stderr #=> "Tag not found\n" |
|
# |
|
# @return [String] |
|
# |
|
attr_reader :stderr |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/config.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
|
|
class Config |
|
|
|
attr_writer :binary_path, :git_ssh, :timeout |
|
|
|
def initialize |
|
@binary_path = nil |
|
@git_ssh = nil |
|
@timeout = nil |
|
end |
|
|
|
def binary_path |
|
@binary_path || ENV['GIT_PATH'] && File.join(ENV['GIT_PATH'], 'git') || 'git' |
|
end |
|
|
|
def git_ssh |
|
@git_ssh || ENV['GIT_SSH'] |
|
end |
|
|
|
def timeout |
|
@timeout || (ENV['GIT_TIMEOUT'] && ENV['GIT_TIMEOUT'].to_i) |
|
end |
|
end |
|
|
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/diff.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
|
|
# object that holds the last X commits on given branch |
|
class Diff |
|
include Enumerable |
|
|
|
def initialize(base, from = nil, to = nil) |
|
@base = base |
|
@from = from && from.to_s |
|
@to = to && to.to_s |
|
|
|
@path = nil |
|
@full_diff = nil |
|
@full_diff_files = nil |
|
@stats = nil |
|
end |
|
attr_reader :from, :to |
|
|
|
def name_status |
|
cache_name_status |
|
end |
|
|
|
def path(path) |
|
@path = path |
|
return self |
|
end |
|
|
|
def size |
|
cache_stats |
|
@stats[:total][:files] |
|
end |
|
|
|
def lines |
|
cache_stats |
|
@stats[:total][:lines] |
|
end |
|
|
|
def deletions |
|
cache_stats |
|
@stats[:total][:deletions] |
|
end |
|
|
|
def insertions |
|
cache_stats |
|
@stats[:total][:insertions] |
|
end |
|
|
|
def stats |
|
cache_stats |
|
@stats |
|
end |
|
|
|
# if file is provided and is writable, it will write the patch into the file |
|
def patch(file = nil) |
|
cache_full |
|
@full_diff |
|
end |
|
alias_method :to_s, :patch |
|
|
|
# enumerable methods |
|
|
|
def [](key) |
|
process_full |
|
@full_diff_files.assoc(key)[1] |
|
end |
|
|
|
def each(&block) # :yields: each Git::DiffFile in turn |
|
process_full |
|
@full_diff_files.map { |file| file[1] }.each(&block) |
|
end |
|
|
|
class DiffFile |
|
attr_accessor :patch, :path, :mode, :src, :dst, :type |
|
@base = nil |
|
NIL_BLOB_REGEXP = /\A0{4,40}\z/.freeze |
|
|
|
def initialize(base, hash) |
|
@base = base |
|
@patch = hash[:patch] |
|
@path = hash[:path] |
|
@mode = hash[:mode] |
|
@src = hash[:src] |
|
@dst = hash[:dst] |
|
@type = hash[:type] |
|
@binary = hash[:binary] |
|
end |
|
|
|
def binary? |
|
!!@binary |
|
end |
|
|
|
def blob(type = :dst) |
|
if type == :src && !NIL_BLOB_REGEXP.match(@src) |
|
@base.object(@src) |
|
elsif !NIL_BLOB_REGEXP.match(@dst) |
|
@base.object(@dst) |
|
end |
|
end |
|
end |
|
|
|
private |
|
|
|
def cache_full |
|
@full_diff ||= @base.lib.diff_full(@from, @to, {:path_limiter => @path}) |
|
end |
|
|
|
def process_full |
|
return if @full_diff_files |
|
cache_full |
|
@full_diff_files = process_full_diff |
|
end |
|
|
|
def cache_stats |
|
@stats ||= @base.lib.diff_stats(@from, @to, {:path_limiter => @path}) |
|
end |
|
|
|
def cache_name_status |
|
@name_status ||= @base.lib.diff_name_status(@from, @to, {:path => @path}) |
|
end |
|
|
|
# break up @diff_full |
|
def process_full_diff |
|
defaults = { |
|
:mode => '', |
|
:src => '', |
|
:dst => '', |
|
:type => 'modified' |
|
} |
|
final = {} |
|
current_file = nil |
|
@full_diff.split("\n").each do |line| |
|
if m = %r{\Adiff --git ("?)a/(.+?)\1 ("?)b/(.+?)\3\z}.match(line) |
|
current_file = Git::EscapedPath.new(m[2]).unescape |
|
final[current_file] = defaults.merge({:patch => line, :path => current_file}) |
|
else |
|
if m = /^index ([0-9a-f]{4,40})\.\.([0-9a-f]{4,40})( ......)*/.match(line) |
|
final[current_file][:src] = m[1] |
|
final[current_file][:dst] = m[2] |
|
final[current_file][:mode] = m[3].strip if m[3] |
|
end |
|
if m = /^([[:alpha:]]*?) file mode (......)/.match(line) |
|
final[current_file][:type] = m[1] |
|
final[current_file][:mode] = m[2] |
|
end |
|
if m = /^Binary files /.match(line) |
|
final[current_file][:binary] = true |
|
end |
|
final[current_file][:patch] << "\n" + line |
|
end |
|
end |
|
final.map { |e| [e[0], DiffFile.new(@base, e[1])] } |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/encoding_utils.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'rchardet' |
|
|
|
module Git |
|
# Method that can be used to detect and normalize string encoding |
|
module EncodingUtils |
|
def self.default_encoding |
|
__ENCODING__.name |
|
end |
|
|
|
def self.best_guess_encoding |
|
# Encoding::ASCII_8BIT.name |
|
Encoding::UTF_8.name |
|
end |
|
|
|
def self.detected_encoding(str) |
|
CharDet.detect(str)['encoding'] || best_guess_encoding |
|
end |
|
|
|
def self.encoding_options |
|
{ invalid: :replace, undef: :replace } |
|
end |
|
|
|
def self.normalize_encoding(str) |
|
return str if str.valid_encoding? && str.encoding.name == default_encoding |
|
|
|
return str.encode(default_encoding, str.encoding, **encoding_options) if str.valid_encoding? |
|
|
|
str.encode(default_encoding, detected_encoding(str), **encoding_options) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/errors.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
# Base class for all custom git module errors |
|
# |
|
# The git gem will only raise an `ArgumentError` or an error that is a subclass of |
|
# `Git::Error`. It does not explicitly raise any other types of errors. |
|
# |
|
# It is recommended to rescue `Git::Error` to catch any runtime error raised by |
|
# this gem unless you need more specific error handling. |
|
# |
|
# Git's custom errors are arranged in the following class heirarchy: |
|
# |
|
# ```text |
|
# StandardError |
|
# └─> Git::Error |
|
# ├─> Git::CommandLineError |
|
# │ ├─> Git::FailedError |
|
# │ └─> Git::SignaledError |
|
# │ └─> Git::TimeoutError |
|
# ├─> Git::ProcessIOError |
|
# └─> Git::UnexpectedResultError |
|
# ``` |
|
# |
|
# | Error Class | Description | |
|
# | --- | --- | |
|
# | `Error` | This catch-all error serves as the base class for other custom errors raised by the git gem. | |
|
# | `CommandLineError` | A subclass of this error is raised when there is a problem executing the git command line. | |
|
# | `FailedError` | This error is raised when the git command line exits with a non-zero status code that is not expected by the git gem. | |
|
# | `SignaledError` | This error is raised when the git command line is terminated as a result of receiving a signal. This could happen if the process is forcibly terminated or if there is a serious system error. | |
|
# | `TimeoutError` | This is a specific type of `SignaledError` that is raised when the git command line operation times out and is killed via the SIGKILL signal. This happens if the operation takes longer than the timeout duration configured in `Git.config.timeout` or via the `:timeout` parameter given in git methods that support timeouts. | |
|
# | `ProcessIOError` | An error was encountered reading or writing to a subprocess. | |
|
# | `UnexpectedResultError` | The command line ran without error but did not return the expected results. | |
|
# |
|
# @example Rescuing a generic error |
|
# begin |
|
# # some git operation |
|
# rescue Git::Error => e |
|
# puts "An error occurred: #{e.message}" |
|
# end |
|
# |
|
# @example Rescuing a timeout error |
|
# begin |
|
# timeout_duration = 0.001 # seconds |
|
# repo = Git.clone('https://github.com/ruby-git/ruby-git', 'ruby-git-temp', timeout: timeout_duration) |
|
# rescue Git::TimeoutError => e # Catch the more specific error first! |
|
# puts "Git clone took too long and timed out #{e}" |
|
# rescue Git::Error => e |
|
# puts "Received the following error: #{e}" |
|
# end |
|
# |
|
# @see Git::CommandLineError |
|
# @see Git::FailedError |
|
# @see Git::SignaledError |
|
# @see Git::TimeoutError |
|
# @see Git::ProcessIOError |
|
# @see Git::UnexpectedResultError |
|
# |
|
# @api public |
|
# |
|
class Error < StandardError; end |
|
|
|
# An alias for Git::Error |
|
# |
|
# Git::GitExecuteError error class is an alias for Git::Error for backwards |
|
# compatibility. It is recommended to use Git::Error directly. |
|
# |
|
# @deprecated Use Git::Error instead |
|
# |
|
GitExecuteError = ActiveSupport::Deprecation::DeprecatedConstantProxy.new('Git::GitExecuteError', 'Git::Error', Git::Deprecation) |
|
|
|
# Raised when a git command fails or exits because of an uncaught signal |
|
# |
|
# The git command executed, status, stdout, and stderr are available from this |
|
# object. |
|
# |
|
# The Gem will raise a more specific error for each type of failure: |
|
# |
|
# * {Git::FailedError}: when the git command exits with a non-zero status |
|
# * {Git::SignaledError}: when the git command exits because of an uncaught signal |
|
# * {Git::TimeoutError}: when the git command times out |
|
# |
|
# @api public |
|
# |
|
class CommandLineError < Git::Error |
|
# Create a CommandLineError object |
|
# |
|
# @example |
|
# `exit 1` # set $? appropriately for this example |
|
# result = Git::CommandLineResult.new(%w[git status], $?, 'stdout', 'stderr') |
|
# error = Git::CommandLineError.new(result) |
|
# error.to_s #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' |
|
# |
|
# @param result [Git::CommandLineResult] the result of the git command including |
|
# the git command, status, stdout, and stderr |
|
# |
|
def initialize(result) |
|
@result = result |
|
super(error_message) |
|
end |
|
|
|
# The human readable representation of this error |
|
# |
|
# @example |
|
# error.error_message #=> '["git", "status"], status: pid 89784 exit 1, stderr: "stderr"' |
|
# |
|
# @return [String] |
|
# |
|
def error_message = <<~MESSAGE.chomp |
|
#{result.git_cmd}, status: #{result.status}, stderr: #{result.stderr.inspect} |
|
MESSAGE |
|
|
|
# @attribute [r] result |
|
# |
|
# The result of the git command including the git command and its status and output |
|
# |
|
# @example |
|
# error.result #=> #<Git::CommandLineResult:0x00000001046bd488 ...> |
|
# |
|
# @return [Git::CommandLineResult] |
|
# |
|
attr_reader :result |
|
end |
|
|
|
# This error is raised when a git command returns a non-zero exitstatus |
|
# |
|
# The git command executed, status, stdout, and stderr are available from this |
|
# object. |
|
# |
|
# @api public |
|
# |
|
class FailedError < Git::CommandLineError; end |
|
|
|
# This error is raised when a git command exits because of an uncaught signal |
|
# |
|
# @api public |
|
# |
|
class SignaledError < Git::CommandLineError; end |
|
|
|
# This error is raised when a git command takes longer than the configured timeout |
|
# |
|
# The git command executed, status, stdout, and stderr, and the timeout duration |
|
# are available from this object. |
|
# |
|
# result.status.timeout? will be `true` |
|
# |
|
# @api public |
|
# |
|
class TimeoutError < Git::SignaledError |
|
# Create a TimeoutError object |
|
# |
|
# @example |
|
# command = %w[sleep 10] |
|
# timeout_duration = 1 |
|
# status = ProcessExecuter.spawn(*command, timeout: timeout_duration) |
|
# result = Git::CommandLineResult.new(command, status, 'stdout', 'err output') |
|
# error = Git::TimeoutError.new(result, timeout_duration) |
|
# error.error_message #=> '["sleep", "10"], status: pid 70144 SIGKILL (signal 9), stderr: "err output", timed out after 1s' |
|
# |
|
# @param result [Git::CommandLineResult] the result of the git command including |
|
# the git command, status, stdout, and stderr |
|
# |
|
# @param timeout_duration [Numeric] the amount of time the subprocess was allowed |
|
# to run before being killed |
|
# |
|
def initialize(result, timeout_duration) |
|
@timeout_duration = timeout_duration |
|
super(result) |
|
end |
|
|
|
# The human readable representation of this error |
|
# |
|
# @example |
|
# error.error_message #=> '["sleep", "10"], status: pid 88811 SIGKILL (signal 9), stderr: "err output", timed out after 1s' |
|
# |
|
# @return [String] |
|
# |
|
def error_message = <<~MESSAGE.chomp |
|
#{super}, timed out after #{timeout_duration}s |
|
MESSAGE |
|
|
|
# The amount of time the subprocess was allowed to run before being killed |
|
# |
|
# @example |
|
# `kill -9 $$` # set $? appropriately for this example |
|
# result = Git::CommandLineResult.new(%w[git status], $?, '', "killed") |
|
# error = Git::TimeoutError.new(result, 10) |
|
# error.timeout_duration #=> 10 |
|
# |
|
# @return [Numeric] |
|
# |
|
attr_reader :timeout_duration |
|
end |
|
|
|
# Raised when the output of a git command can not be read |
|
# |
|
# @api public |
|
# |
|
class ProcessIOError < Git::Error; end |
|
|
|
# Raised when the git command result was not as expected |
|
# |
|
# @api public |
|
# |
|
class UnexpectedResultError < Git::Error; end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/escaped_path.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
# Represents an escaped Git path string |
|
# |
|
# Git commands that output paths (e.g. ls-files, diff), will escape unusual |
|
# characters in the path with backslashes in the same way C escapes control |
|
# characters (e.g. \t for TAB, \n for LF, \\ for backslash) or bytes with values |
|
# larger than 0x80 (e.g. octal \302\265 for "micro" in UTF-8). |
|
# |
|
# @example |
|
# Git::GitPath.new('\302\265').unescape # => "µ" |
|
# |
|
class EscapedPath |
|
UNESCAPES = { |
|
'a' => 0x07, |
|
'b' => 0x08, |
|
't' => 0x09, |
|
'n' => 0x0a, |
|
'v' => 0x0b, |
|
'f' => 0x0c, |
|
'r' => 0x0d, |
|
'e' => 0x1b, |
|
'\\' => 0x5c, |
|
'"' => 0x22, |
|
"'" => 0x27 |
|
}.freeze |
|
|
|
attr_reader :path |
|
|
|
def initialize(path) |
|
@path = path |
|
end |
|
|
|
# Convert an escaped path to an unescaped path |
|
def unescape |
|
bytes = escaped_path_to_bytes(path) |
|
str = bytes.pack('C*') |
|
str.force_encoding(Encoding::UTF_8) |
|
end |
|
|
|
private |
|
|
|
def extract_octal(path, index) |
|
[path[index + 1..index + 3].to_i(8), 4] |
|
end |
|
|
|
def extract_escape(path, index) |
|
[UNESCAPES[path[index + 1]], 2] |
|
end |
|
|
|
def extract_single_char(path, index) |
|
[path[index].ord, 1] |
|
end |
|
|
|
def next_byte(path, index) |
|
if path[index] == '\\' && path[index + 1] >= '0' && path[index + 1] <= '7' |
|
extract_octal(path, index) |
|
elsif path[index] == '\\' && UNESCAPES.include?(path[index + 1]) |
|
extract_escape(path, index) |
|
else |
|
extract_single_char(path, index) |
|
end |
|
end |
|
|
|
def escaped_path_to_bytes(path) |
|
index = 0 |
|
[].tap do |bytes| |
|
while index < path.length |
|
byte, chars_used = next_byte(path, index) |
|
bytes << byte |
|
index += chars_used |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/index.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
class Index < Git::Path |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/lib.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'git/command_line' |
|
require 'git/errors' |
|
require 'logger' |
|
require 'pp' |
|
require 'process_executer' |
|
require 'stringio' |
|
require 'tempfile' |
|
require 'zlib' |
|
require 'open3' |
|
|
|
module Git |
|
class Lib |
|
# The path to the Git working copy. The default is '"./.git"'. |
|
# |
|
# @return [Pathname] the path to the Git working copy. |
|
# |
|
# @see [Git working tree](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefworkingtreeaworkingtree) |
|
# |
|
attr_reader :git_work_dir |
|
|
|
# The path to the Git repository directory. The default is |
|
# `"#{git_work_dir}/.git"`. |
|
# |
|
# @return [Pathname] the Git repository directory. |
|
# |
|
# @see [Git repository](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefrepositoryarepository) |
|
# |
|
attr_reader :git_dir |
|
|
|
# The Git index file used to stage changes (using `git add`) before they |
|
# are committed. |
|
# |
|
# @return [Pathname] the Git index file |
|
# |
|
# @see [Git index file](https://git-scm.com/docs/gitglossary#Documentation/gitglossary.txt-aiddefindexaindex) |
|
# |
|
attr_reader :git_index_file |
|
|
|
# Create a new Git::Lib object |
|
# |
|
# @overload initialize(base, logger) |
|
# |
|
# @param base [Hash] the hash containing paths to the Git working copy, |
|
# the Git repository directory, and the Git index file. |
|
# |
|
# @option base [Pathname] :working_directory |
|
# @option base [Pathname] :repository |
|
# @option base [Pathname] :index |
|
# |
|
# @param [Logger] logger |
|
# |
|
# @overload initialize(base, logger) |
|
# |
|
# @param base [#dir, #repo, #index] an object with methods to get the Git worktree (#dir), |
|
# the Git repository directory (#repo), and the Git index file (#index). |
|
# |
|
# @param [Logger] logger |
|
# |
|
def initialize(base = nil, logger = nil) |
|
@git_dir = nil |
|
@git_index_file = nil |
|
@git_work_dir = nil |
|
@path = nil |
|
@logger = logger || Logger.new(nil) |
|
|
|
if base.is_a?(Git::Base) |
|
@git_dir = base.repo.path |
|
@git_index_file = base.index.path if base.index |
|
@git_work_dir = base.dir.path if base.dir |
|
elsif base.is_a?(Hash) |
|
@git_dir = base[:repository] |
|
@git_index_file = base[:index] |
|
@git_work_dir = base[:working_directory] |
|
end |
|
end |
|
|
|
# creates or reinitializes the repository |
|
# |
|
# options: |
|
# :bare |
|
# :working_directory |
|
# :initial_branch |
|
# |
|
def init(opts={}) |
|
arr_opts = [] |
|
arr_opts << '--bare' if opts[:bare] |
|
arr_opts << "--initial-branch=#{opts[:initial_branch]}" if opts[:initial_branch] |
|
|
|
command('init', *arr_opts) |
|
end |
|
|
|
# Clones a repository into a newly created directory |
|
# |
|
# @param [String] repository_url the URL of the repository to clone |
|
# @param [String, nil] directory the directory to clone into |
|
# |
|
# If nil, the repository is cloned into a directory with the same name as |
|
# the repository. |
|
# |
|
# @param [Hash] opts the options for this command |
|
# |
|
# @option opts [Boolean] :bare (false) if true, clone as a bare repository |
|
# @option opts [String] :branch the branch to checkout |
|
# @option opts [String, Array] :config one or more configuration options to set |
|
# @option opts [Integer] :depth the number of commits back to pull |
|
# @option opts [String] :filter specify partial clone |
|
# @option opts [String] :mirror set up a mirror of the source repository |
|
# @option opts [String] :origin the name of the remote |
|
# @option opts [String] :path an optional prefix for the directory parameter |
|
# @option opts [String] :remote the name of the remote |
|
# @option opts [Boolean] :recursive after the clone is created, initialize all submodules within, using their default settings |
|
# @option opts [Numeric, nil] :timeout the number of seconds to wait for the command to complete |
|
# |
|
# See {Git::Lib#command} for more information about :timeout |
|
# |
|
# @return [Hash] the options to pass to {Git::Base.new} |
|
# |
|
# @todo make this work with SSH password or auth_key |
|
# |
|
def clone(repository_url, directory, opts = {}) |
|
@path = opts[:path] || '.' |
|
clone_dir = opts[:path] ? File.join(@path, directory) : directory |
|
|
|
arr_opts = [] |
|
arr_opts << '--bare' if opts[:bare] |
|
arr_opts << '--branch' << opts[:branch] if opts[:branch] |
|
arr_opts << '--depth' << opts[:depth].to_i if opts[:depth] && opts[:depth].to_i > 0 |
|
arr_opts << '--filter' << opts[:filter] if opts[:filter] |
|
Array(opts[:config]).each { |c| arr_opts << '--config' << c } |
|
arr_opts << '--origin' << opts[:remote] || opts[:origin] if opts[:remote] || opts[:origin] |
|
arr_opts << '--recursive' if opts[:recursive] |
|
arr_opts << '--mirror' if opts[:mirror] |
|
|
|
arr_opts << '--' |
|
|
|
arr_opts << repository_url |
|
arr_opts << clone_dir |
|
|
|
command('clone', *arr_opts, timeout: opts[:timeout]) |
|
|
|
return_base_opts_from_clone(clone_dir, opts) |
|
end |
|
|
|
def return_base_opts_from_clone(clone_dir, opts) |
|
base_opts = {} |
|
base_opts[:repository] = clone_dir if (opts[:bare] || opts[:mirror]) |
|
base_opts[:working_directory] = clone_dir unless (opts[:bare] || opts[:mirror]) |
|
base_opts[:log] = opts[:log] if opts[:log] |
|
base_opts |
|
end |
|
|
|
# Returns the name of the default branch of the given repository |
|
# |
|
# @param repository [URI, Pathname, String] The (possibly remote) repository to clone from |
|
# |
|
# @return [String] the name of the default branch |
|
# |
|
def repository_default_branch(repository) |
|
output = command('ls-remote', '--symref', '--', repository, 'HEAD') |
|
|
|
match_data = output.match(%r{^ref: refs/remotes/origin/(?<default_branch>[^\t]+)\trefs/remotes/origin/HEAD$}) |
|
return match_data[:default_branch] if match_data |
|
|
|
match_data = output.match(%r{^ref: refs/heads/(?<default_branch>[^\t]+)\tHEAD$}) |
|
return match_data[:default_branch] if match_data |
|
|
|
raise Git::UnexpectedResultError, 'Unable to determine the default branch' |
|
end |
|
|
|
## READ COMMANDS ## |
|
|
|
# Finds most recent tag that is reachable from a commit |
|
# |
|
# @see https://git-scm.com/docs/git-describe git-describe |
|
# |
|
# @param commit_ish [String, nil] target commit sha or object name |
|
# |
|
# @param opts [Hash] the given options |
|
# |
|
# @option opts :all [Boolean] |
|
# @option opts :tags [Boolean] |
|
# @option opts :contains [Boolean] |
|
# @option opts :debug [Boolean] |
|
# @option opts :long [Boolean] |
|
# @option opts :always [Boolean] |
|
# @option opts :exact_match [Boolean] |
|
# @option opts :dirty [true, String] |
|
# @option opts :abbrev [String] |
|
# @option opts :candidates [String] |
|
# @option opts :match [String] |
|
# |
|
# @return [String] the tag name |
|
# |
|
# @raise [ArgumentError] if the commit_ish is a string starting with a hyphen |
|
# |
|
def describe(commit_ish = nil, opts = {}) |
|
assert_args_are_not_options('commit-ish object', commit_ish) |
|
|
|
arr_opts = [] |
|
|
|
arr_opts << '--all' if opts[:all] |
|
arr_opts << '--tags' if opts[:tags] |
|
arr_opts << '--contains' if opts[:contains] |
|
arr_opts << '--debug' if opts[:debug] |
|
arr_opts << '--long' if opts[:long] |
|
arr_opts << '--always' if opts[:always] |
|
arr_opts << '--exact-match' if opts[:exact_match] || opts[:"exact-match"] |
|
|
|
arr_opts << '--dirty' if opts[:dirty] == true |
|
arr_opts << "--dirty=#{opts[:dirty]}" if opts[:dirty].is_a?(String) |
|
|
|
arr_opts << "--abbrev=#{opts[:abbrev]}" if opts[:abbrev] |
|
arr_opts << "--candidates=#{opts[:candidates]}" if opts[:candidates] |
|
arr_opts << "--match=#{opts[:match]}" if opts[:match] |
|
|
|
arr_opts << commit_ish if commit_ish |
|
|
|
return command('describe', *arr_opts) |
|
end |
|
|
|
# Return the commits that are within the given revision range |
|
# |
|
# @see https://git-scm.com/docs/git-log git-log |
|
# |
|
# @param opts [Hash] the given options |
|
# |
|
# @option opts :count [Integer] the maximum number of commits to return (maps to max-count) |
|
# @option opts :all [Boolean] |
|
# @option opts :cherry [Boolean] |
|
# @option opts :since [String] |
|
# @option opts :until [String] |
|
# @option opts :grep [String] |
|
# @option opts :author [String] |
|
# @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range |
|
# |
|
# Only :between or :object options can be used, not both. |
|
# |
|
# @option opts :object [String] the revision range for the git log command |
|
# |
|
# Only :between or :object options can be used, not both. |
|
# |
|
# @option opts :path_limiter [Array<String>, String] only include commits that impact files from the specified paths |
|
# |
|
# @return [Array<String>] the log output |
|
# |
|
# @raise [ArgumentError] if the resulting revision range is a string starting with a hyphen |
|
# |
|
def log_commits(opts = {}) |
|
assert_args_are_not_options('between', opts[:between]&.first) |
|
assert_args_are_not_options('object', opts[:object]) |
|
|
|
arr_opts = log_common_options(opts) |
|
|
|
arr_opts << '--pretty=oneline' |
|
|
|
arr_opts += log_path_options(opts) |
|
|
|
command_lines('log', *arr_opts).map { |l| l.split.first } |
|
end |
|
|
|
# Return the commits that are within the given revision range |
|
# |
|
# @see https://git-scm.com/docs/git-log git-log |
|
# |
|
# @param opts [Hash] the given options |
|
# |
|
# @option opts :count [Integer] the maximum number of commits to return (maps to max-count) |
|
# @option opts :all [Boolean] |
|
# @option opts :cherry [Boolean] |
|
# @option opts :since [String] |
|
# @option opts :until [String] |
|
# @option opts :grep [String] |
|
# @option opts :author [String] |
|
# @option opts :between [Array<String>] an array of two commit-ish strings to specify a revision range |
|
# |
|
# Only :between or :object options can be used, not both. |
|
# |
|
# @option opts :object [String] the revision range for the git log command |
|
# |
|
# Only :between or :object options can be used, not both. |
|
# |
|
# @option opts :path_limiter [Array<String>, String] only include commits that impact files from the specified paths |
|
# @option opts :skip [Integer] |
|
# |
|
# @return [Array<Hash>] the log output parsed into an array of hashs for each commit |
|
# |
|
# Each hash contains the following keys: |
|
# * 'sha' [String] the commit sha |
|
# * 'author' [String] the author of the commit |
|
# * 'message' [String] the commit message |
|
# * 'parent' [Array<String>] the commit shas of the parent commits |
|
# * 'tree' [String] the tree sha |
|
# * 'author' [String] the author of the commit and timestamp of when the changes were created |
|
# * 'committer' [String] the committer of the commit and timestamp of when the commit was applied |
|
# |
|
# @raise [ArgumentError] if the revision range (specified with :between or :object) is a string starting with a hyphen |
|
# |
|
def full_log_commits(opts = {}) |
|
assert_args_are_not_options('between', opts[:between]&.first) |
|
assert_args_are_not_options('object', opts[:object]) |
|
|
|
arr_opts = log_common_options(opts) |
|
|
|
arr_opts << '--pretty=raw' |
|
arr_opts << "--skip=#{opts[:skip]}" if opts[:skip] |
|
|
|
arr_opts += log_path_options(opts) |
|
|
|
full_log = command_lines('log', *arr_opts) |
|
|
|
process_commit_log_data(full_log) |
|
end |
|
|
|
# Verify and resolve a Git revision to its full SHA |
|
# |
|
# @see https://git-scm.com/docs/git-rev-parse git-rev-parse |
|
# @see https://git-scm.com/docs/git-rev-parse#_specifying_revisions Valid ways to specify revisions |
|
# @see https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt-emltrefnamegtemegemmasterememheadsmasterememrefsheadsmasterem Ref disambiguation rules |
|
# |
|
# @example |
|
# lib.rev_parse('HEAD') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d' |
|
# lib.rev_parse('9b9b31e') # => '9b9b31e704c0b85ffdd8d2af2ded85170a5af87d' |
|
# |
|
# @param revision [String] the revision to resolve |
|
# |
|
# @return [String] the full commit hash |
|
# |
|
# @raise [Git::FailedError] if the revision cannot be resolved |
|
# @raise [ArgumentError] if the revision is a string starting with a hyphen |
|
# |
|
def rev_parse(revision) |
|
assert_args_are_not_options('rev', revision) |
|
|
|
command('rev-parse', '--revs-only', '--end-of-options', revision, '--') |
|
end |
|
|
|
# For backwards compatibility with the old method name |
|
alias :revparse :rev_parse |
|
|
|
# Find the first symbolic name for given commit_ish |
|
# |
|
# @param commit_ish [String] the commit_ish to find the symbolic name of |
|
# |
|
# @return [String, nil] the first symbolic name or nil if the commit_ish isn't found |
|
# |
|
# @raise [ArgumentError] if the commit_ish is a string starting with a hyphen |
|
# |
|
def name_rev(commit_ish) |
|
assert_args_are_not_options('commit_ish', commit_ish) |
|
|
|
command('name-rev', commit_ish).split[1] |
|
end |
|
|
|
alias :namerev :name_rev |
|
|
|
# Output the contents or other properties of one or more objects. |
|
# |
|
# @see https://git-scm.com/docs/git-cat-file git-cat-file |
|
# |
|
# @example Get the contents of a file without a block |
|
# lib.cat_file_contents('README.md') # => "This is a README file\n" |
|
# |
|
# @example Get the contents of a file with a block |
|
# lib.cat_file_contents('README.md') { |f| f.read } # => "This is a README file\n" |
|
# |
|
# @param object [String] the object whose contents to return |
|
# |
|
# @return [String] the object contents |
|
# |
|
# @raise [ArgumentError] if object is a string starting with a hyphen |
|
# |
|
def cat_file_contents(object, &block) |
|
assert_args_are_not_options('object', object) |
|
|
|
if block_given? |
|
Tempfile.create do |file| |
|
# If a block is given, write the output from the process to a temporary |
|
# file and then yield the file to the block |
|
# |
|
command('cat-file', "-p", object, out: file, err: file) |
|
file.rewind |
|
yield file |
|
end |
|
else |
|
# If a block is not given, return the file contents as a string |
|
command('cat-file', '-p', object) |
|
end |
|
end |
|
|
|
alias :object_contents :cat_file_contents |
|
|
|
# Get the type for the given object |
|
# |
|
# @see https://git-scm.com/docs/git-cat-file git-cat-file |
|
# |
|
# @param object [String] the object to get the type |
|
# |
|
# @return [String] the object type |
|
# |
|
# @raise [ArgumentError] if object is a string starting with a hyphen |
|
# |
|
def cat_file_type(object) |
|
assert_args_are_not_options('object', object) |
|
|
|
command('cat-file', '-t', object) |
|
end |
|
|
|
alias :object_type :cat_file_type |
|
|
|
# Get the size for the given object |
|
# |
|
# @see https://git-scm.com/docs/git-cat-file git-cat-file |
|
# |
|
# @param object [String] the object to get the type |
|
# |
|
# @return [String] the object type |
|
# |
|
# @raise [ArgumentError] if object is a string starting with a hyphen |
|
# |
|
def cat_file_size(object) |
|
assert_args_are_not_options('object', object) |
|
|
|
command('cat-file', '-s', object).to_i |
|
end |
|
|
|
alias :object_size :cat_file_size |
|
|
|
# Return a hash of commit data |
|
# |
|
# @see https://git-scm.com/docs/git-cat-file git-cat-file |
|
# |
|
# @param object [String] the object to get the type |
|
# |
|
# @return [Hash] commit data |
|
# |
|
# The returned commit data has the following keys: |
|
# * tree [String] |
|
# * parent [Array<String>] |
|
# * author [String] the author name, email, and commit timestamp |
|
# * committer [String] the committer name, email, and merge timestamp |
|
# * message [String] the commit message |
|
# * gpgsig [String] the public signing key of the commit (if signed) |
|
# |
|
# @raise [ArgumentError] if object is a string starting with a hyphen |
|
# |
|
def cat_file_commit(object) |
|
assert_args_are_not_options('object', object) |
|
|
|
cdata = command_lines('cat-file', 'commit', object) |
|
process_commit_data(cdata, object) |
|
end |
|
|
|
alias :commit_data :cat_file_commit |
|
|
|
def process_commit_data(data, sha) |
|
hsh = { |
|
'sha' => sha, |
|
'parent' => [] |
|
} |
|
|
|
each_cat_file_header(data) do |key, value| |
|
if key == 'parent' |
|
hsh['parent'] << value |
|
else |
|
hsh[key] = value |
|
end |
|
end |
|
|
|
hsh['message'] = data.join("\n") + "\n" |
|
|
|
return hsh |
|
end |
|
|
|
CAT_FILE_HEADER_LINE = /\A(?<key>\w+) (?<value>.*)\z/ |
|
|
|
def each_cat_file_header(data) |
|
while (match = CAT_FILE_HEADER_LINE.match(data.shift)) |
|
key = match[:key] |
|
value_lines = [match[:value]] |
|
|
|
while data.first.start_with?(' ') |
|
value_lines << data.shift.lstrip |
|
end |
|
|
|
yield key, value_lines.join("\n") |
|
end |
|
end |
|
|
|
# Return a hash of annotated tag data |
|
# |
|
# Does not work with lightweight tags. List all annotated tags in your repository with the following command: |
|
# |
|
# ```sh |
|
# git for-each-ref --format='%(refname:strip=2)' refs/tags | while read tag; do git cat-file tag $tag >/dev/null 2>&1 && echo $tag; done |
|
# ``` |
|
# |
|
# @see https://git-scm.com/docs/git-cat-file git-cat-file |
|
# |
|
# @param object [String] the tag to retrieve |
|
# |
|
# @return [Hash] tag data |
|
# |
|
# Example tag data returned: |
|
# ```ruby |
|
# { |
|
# "name" => "annotated_tag", |
|
# "object" => "46abbf07e3c564c723c7c039a43ab3a39e5d02dd", |
|
# "type" => "commit", |
|
# "tag" => "annotated_tag", |
|
# "tagger" => "Scott Chacon <dev@example.invalid> 1724799270 -0700", |
|
# "message" => "Creating an annotated tag\n" |
|
# } |
|
# ``` |
|
# |
|
# The returned commit data has the following keys: |
|
# * object [String] the sha of the tag object |
|
# * type [String] |
|
# * tag [String] tag name |
|
# * tagger [String] the name and email of the user who created the tag and the timestamp of when the tag was created |
|
# * message [String] the tag message |
|
# |
|
# @raise [ArgumentError] if object is a string starting with a hyphen |
|
# |
|
def cat_file_tag(object) |
|
assert_args_are_not_options('object', object) |
|
|
|
tdata = command_lines('cat-file', 'tag', object) |
|
process_tag_data(tdata, object) |
|
end |
|
|
|
alias :tag_data :cat_file_tag |
|
|
|
def process_tag_data(data, name) |
|
hsh = { 'name' => name } |
|
|
|
each_cat_file_header(data) do |key, value| |
|
hsh[key] = value |
|
end |
|
|
|
hsh['message'] = data.join("\n") + "\n" |
|
|
|
return hsh |
|
end |
|
|
|
def process_commit_log_data(data) |
|
in_message = false |
|
|
|
hsh_array = [] |
|
|
|
hsh = nil |
|
|
|
data.each do |line| |
|
line = line.chomp |
|
|
|
if line[0].nil? |
|
in_message = !in_message |
|
next |
|
end |
|
|
|
in_message = false if in_message && line[0..3] != " " |
|
|
|
if in_message |
|
hsh['message'] << "#{line[4..-1]}\n" |
|
next |
|
end |
|
|
|
key, *value = line.split |
|
value = value.join(' ') |
|
|
|
case key |
|
when 'commit' |
|
hsh_array << hsh if hsh |
|
hsh = {'sha' => value, 'message' => +'', 'parent' => []} |
|
when 'parent' |
|
hsh['parent'] << value |
|
else |
|
hsh[key] = value |
|
end |
|
end |
|
|
|
hsh_array << hsh if hsh |
|
|
|
return hsh_array |
|
end |
|
|
|
def ls_tree(sha, opts = {}) |
|
data = { 'blob' => {}, 'tree' => {}, 'commit' => {} } |
|
|
|
ls_tree_opts = [] |
|
ls_tree_opts << '-r' if opts[:recursive] |
|
# path must be last arg |
|
ls_tree_opts << opts[:path] if opts[:path] |
|
|
|
command_lines('ls-tree', sha, *ls_tree_opts).each do |line| |
|
(info, filenm) = line.split("\t") |
|
(mode, type, sha) = info.split |
|
data[type][filenm] = {:mode => mode, :sha => sha} |
|
end |
|
|
|
data |
|
end |
|
|
|
def mv(file1, file2) |
|
command_lines('mv', '--', file1, file2) |
|
end |
|
|
|
def full_tree(sha) |
|
command_lines('ls-tree', '-r', sha) |
|
end |
|
|
|
def tree_depth(sha) |
|
full_tree(sha).size |
|
end |
|
|
|
def change_head_branch(branch_name) |
|
command('symbolic-ref', 'HEAD', "refs/heads/#{branch_name}") |
|
end |
|
|
|
BRANCH_LINE_REGEXP = / |
|
^ |
|
# Prefix indicates if this branch is checked out. The prefix is one of: |
|
(?: |
|
(?<current>\*[[:blank:]]) | # Current branch (checked out in the current worktree) |
|
(?<worktree>\+[[:blank:]]) | # Branch checked out in a different worktree |
|
[[:blank:]]{2} # Branch not checked out |
|
) |
|
|
|
# The branch's full refname |
|
(?: |
|
(?<not_a_branch>\(not[[:blank:]]a[[:blank:]]branch\)) | |
|
(?:\(HEAD[[:blank:]]detached[[:blank:]]at[[:blank:]](?<detached_ref>[^\)]+)\)) | |
|
(?<refname>[^[[:blank:]]]+) |
|
) |
|
|
|
# Optional symref |
|
# If this ref is a symbolic reference, this is the ref referenced |
|
(?: |
|
[[:blank:]]->[[:blank:]](?<symref>.*) |
|
)? |
|
$ |
|
/x |
|
|
|
def branches_all |
|
lines = command_lines('branch', '-a') |
|
lines.each_with_index.map do |line, line_index| |
|
match_data = line.match(BRANCH_LINE_REGEXP) |
|
|
|
raise Git::UnexpectedResultError, unexpected_branch_line_error(lines, line, line_index) unless match_data |
|
next nil if match_data[:not_a_branch] || match_data[:detached_ref] |
|
|
|
[ |
|
match_data[:refname], |
|
!match_data[:current].nil?, |
|
!match_data[:worktree].nil?, |
|
match_data[:symref] |
|
] |
|
end.compact |
|
end |
|
|
|
def unexpected_branch_line_error(lines, line, index) |
|
<<~ERROR |
|
Unexpected line in output from `git branch -a`, line #{index + 1} |
|
|
|
Full output: |
|
#{lines.join("\n ")} |
|
|
|
Line #{index + 1}: |
|
"#{line}" |
|
ERROR |
|
end |
|
|
|
def worktrees_all |
|
arr = [] |
|
directory = '' |
|
# Output example for `worktree list --porcelain`: |
|
# worktree /code/public/ruby-git |
|
# HEAD 4bef5abbba073c77b4d0ccc1ffcd0ed7d48be5d4 |
|
# branch refs/heads/master |
|
# |
|
# worktree /tmp/worktree-1 |
|
# HEAD b8c63206f8d10f57892060375a86ae911fad356e |
|
# detached |
|
# |
|
command_lines('worktree', 'list', '--porcelain').each do |w| |
|
s = w.split("\s") |
|
directory = s[1] if s[0] == 'worktree' |
|
arr << [directory, s[1]] if s[0] == 'HEAD' |
|
end |
|
arr |
|
end |
|
|
|
def worktree_add(dir, commitish = nil) |
|
return command('worktree', 'add', dir, commitish) if !commitish.nil? |
|
command('worktree', 'add', dir) |
|
end |
|
|
|
def worktree_remove(dir) |
|
command('worktree', 'remove', dir) |
|
end |
|
|
|
def worktree_prune |
|
command('worktree', 'prune') |
|
end |
|
|
|
def list_files(ref_dir) |
|
dir = File.join(@git_dir, 'refs', ref_dir) |
|
files = [] |
|
begin |
|
files = Dir.glob('**/*', base: dir).select { |f| File.file?(File.join(dir, f)) } |
|
rescue |
|
end |
|
files |
|
end |
|
|
|
# The state and name of branch pointed to by `HEAD` |
|
# |
|
# HEAD can be in the following states: |
|
# |
|
# **:active**: `HEAD` points to a branch reference which in turn points to a |
|
# commit representing the tip of that branch. This is the typical state when |
|
# working on a branch. |
|
# |
|
# **:unborn**: `HEAD` points to a branch reference that does not yet exist |
|
# because no commits have been made on that branch. This state occurs in two |
|
# scenarios: |
|
# |
|
# * When a repository is newly initialized, and no commits have been made on the |
|
# initial branch. |
|
# * When a new branch is created using `git checkout --orphan <branch>`, starting |
|
# a new branch with no history. |
|
# |
|
# **:detached**: `HEAD` points directly to a specific commit (identified by its |
|
# SHA) rather than a branch reference. This state occurs when you check out a |
|
# commit, a tag, or any state that is not directly associated with a branch. The |
|
# branch name in this case is `HEAD`. |
|
# |
|
HeadState = Struct.new(:state, :name) |
|
|
|
# The current branch state which is the state of `HEAD` |
|
# |
|
# @return [HeadState] the state and name of the current branch |
|
# |
|
def current_branch_state |
|
branch_name = command('branch', '--show-current') |
|
return HeadState.new(:detached, 'HEAD') if branch_name.empty? |
|
|
|
state = |
|
begin |
|
command('rev-parse', '--verify', '--quiet', branch_name) |
|
:active |
|
rescue Git::FailedError => e |
|
raise unless e.result.status.exitstatus == 1 && e.result.stderr.empty? |
|
|
|
:unborn |
|
end |
|
|
|
return HeadState.new(state, branch_name) |
|
end |
|
|
|
def branch_current |
|
branch_name = command('branch', '--show-current') |
|
branch_name.empty? ? 'HEAD' : branch_name |
|
end |
|
|
|
def branch_contains(commit, branch_name="") |
|
command("branch", branch_name, "--contains", commit) |
|
end |
|
|
|
# returns hash |
|
# [tree-ish] = [[line_no, match], [line_no, match2]] |
|
# [tree-ish] = [[line_no, match], [line_no, match2]] |
|
def grep(string, opts = {}) |
|
opts[:object] ||= 'HEAD' |
|
|
|
grep_opts = ['-n'] |
|
grep_opts << '-i' if opts[:ignore_case] |
|
grep_opts << '-v' if opts[:invert_match] |
|
grep_opts << '-E' if opts[:extended_regexp] |
|
grep_opts << '-e' |
|
grep_opts << string |
|
grep_opts << opts[:object] if opts[:object].is_a?(String) |
|
grep_opts.push('--', opts[:path_limiter]) if opts[:path_limiter].is_a?(String) |
|
grep_opts.push('--', *opts[:path_limiter]) if opts[:path_limiter].is_a?(Array) |
|
|
|
hsh = {} |
|
begin |
|
command_lines('grep', *grep_opts).each do |line| |
|
if m = /(.*?)\:(\d+)\:(.*)/.match(line) |
|
hsh[m[1]] ||= [] |
|
hsh[m[1]] << [m[2].to_i, m[3]] |
|
end |
|
end |
|
rescue Git::FailedError => e |
|
raise unless e.result.status.exitstatus == 1 && e.result.stderr == '' |
|
end |
|
hsh |
|
end |
|
|
|
# Validate that the given arguments cannot be mistaken for a command-line option |
|
# |
|
# @param arg_name [String] the name of the arguments to mention in the error message |
|
# @param args [Array<String, nil>] the arguments to validate |
|
# |
|
# @raise [ArgumentError] if any of the parameters are a string starting with a hyphen |
|
# @return [void] |
|
# |
|
def assert_args_are_not_options(arg_name, *args) |
|
invalid_args = args.select { |arg| arg&.start_with?('-') } |
|
if invalid_args.any? |
|
raise ArgumentError, "Invalid #{arg_name}: '#{invalid_args.join("', '")}'" |
|
end |
|
end |
|
|
|
def diff_full(obj1 = 'HEAD', obj2 = nil, opts = {}) |
|
assert_args_are_not_options('commit or commit range', obj1, obj2) |
|
|
|
diff_opts = ['-p'] |
|
diff_opts << obj1 |
|
diff_opts << obj2 if obj2.is_a?(String) |
|
diff_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String |
|
|
|
command('diff', *diff_opts) |
|
end |
|
|
|
def diff_stats(obj1 = 'HEAD', obj2 = nil, opts = {}) |
|
assert_args_are_not_options('commit or commit range', obj1, obj2) |
|
|
|
diff_opts = ['--numstat'] |
|
diff_opts << obj1 |
|
diff_opts << obj2 if obj2.is_a?(String) |
|
diff_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String |
|
|
|
hsh = {:total => {:insertions => 0, :deletions => 0, :lines => 0, :files => 0}, :files => {}} |
|
|
|
command_lines('diff', *diff_opts).each do |file| |
|
(insertions, deletions, filename) = file.split("\t") |
|
hsh[:total][:insertions] += insertions.to_i |
|
hsh[:total][:deletions] += deletions.to_i |
|
hsh[:total][:lines] = (hsh[:total][:deletions] + hsh[:total][:insertions]) |
|
hsh[:total][:files] += 1 |
|
hsh[:files][filename] = {:insertions => insertions.to_i, :deletions => deletions.to_i} |
|
end |
|
|
|
hsh |
|
end |
|
|
|
def diff_name_status(reference1 = nil, reference2 = nil, opts = {}) |
|
assert_args_are_not_options('commit or commit range', reference1, reference2) |
|
|
|
opts_arr = ['--name-status'] |
|
opts_arr << reference1 if reference1 |
|
opts_arr << reference2 if reference2 |
|
|
|
opts_arr << '--' << opts[:path] if opts[:path] |
|
|
|
command_lines('diff', *opts_arr).inject({}) do |memo, line| |
|
status, path = line.split("\t") |
|
memo[path] = status |
|
memo |
|
end |
|
end |
|
|
|
# compares the index and the working directory |
|
def diff_files |
|
diff_as_hash('diff-files') |
|
end |
|
|
|
# compares the index and the repository |
|
def diff_index(treeish) |
|
diff_as_hash('diff-index', treeish) |
|
end |
|
|
|
# List all files that are in the index |
|
# |
|
# @param location [String] the location to list the files from |
|
# |
|
# @return [Hash<String, Hash>] a hash of files in the index |
|
# * key: file [String] the file path |
|
# * value: file_info [Hash] the file information containing the following keys: |
|
# * :path [String] the file path |
|
# * :mode_index [String] the file mode |
|
# * :sha_index [String] the file sha |
|
# * :stage [String] the file stage |
|
# |
|
def ls_files(location=nil) |
|
location ||= '.' |
|
{}.tap do |files| |
|
command_lines('ls-files', '--stage', location).each do |line| |
|
(info, file) = line.split("\t") |
|
(mode, sha, stage) = info.split |
|
files[unescape_quoted_path(file)] = { |
|
:path => file, :mode_index => mode, :sha_index => sha, :stage => stage |
|
} |
|
end |
|
end |
|
end |
|
|
|
# Unescape a path if it is quoted |
|
# |
|
# Git commands that output paths (e.g. ls-files, diff), will escape unusual |
|
# characters. |
|
# |
|
# @example |
|
# lib.unescape_if_quoted('"quoted_file_\\342\\230\\240"') # => 'quoted_file_☠' |
|
# lib.unescape_if_quoted('unquoted_file') # => 'unquoted_file' |
|
# |
|
# @param path [String] the path to unescape if quoted |
|
# |
|
# @return [String] the unescaped path if quoted otherwise the original path |
|
# |
|
# @api private |
|
# |
|
def unescape_quoted_path(path) |
|
if path.start_with?('"') && path.end_with?('"') |
|
Git::EscapedPath.new(path[1..-2]).unescape |
|
else |
|
path |
|
end |
|
end |
|
|
|
def ls_remote(location=nil, opts={}) |
|
arr_opts = [] |
|
arr_opts << '--refs' if opts[:refs] |
|
arr_opts << (location || '.') |
|
|
|
Hash.new{ |h,k| h[k] = {} }.tap do |hsh| |
|
command_lines('ls-remote', *arr_opts).each do |line| |
|
(sha, info) = line.split("\t") |
|
(ref, type, name) = info.split('/', 3) |
|
type ||= 'head' |
|
type = 'branches' if type == 'heads' |
|
value = {:ref => ref, :sha => sha} |
|
hsh[type].update( name.nil? ? value : { name => value }) |
|
end |
|
end |
|
end |
|
|
|
def ignored_files |
|
command_lines('ls-files', '--others', '-i', '--exclude-standard').map { |f| unescape_quoted_path(f) } |
|
end |
|
|
|
def untracked_files |
|
command_lines('ls-files', '--others', '--exclude-standard', chdir: @git_work_dir) |
|
end |
|
|
|
def config_remote(name) |
|
hsh = {} |
|
config_list.each do |key, value| |
|
if /remote.#{name}/.match(key) |
|
hsh[key.gsub("remote.#{name}.", '')] = value |
|
end |
|
end |
|
hsh |
|
end |
|
|
|
def config_get(name) |
|
command('config', '--get', name, chdir: @git_dir) |
|
end |
|
|
|
def global_config_get(name) |
|
command('config', '--global', '--get', name) |
|
end |
|
|
|
def config_list |
|
parse_config_list command_lines('config', '--list', chdir: @git_dir) |
|
end |
|
|
|
def global_config_list |
|
parse_config_list command_lines('config', '--global', '--list') |
|
end |
|
|
|
def parse_config_list(lines) |
|
hsh = {} |
|
lines.each do |line| |
|
(key, *values) = line.split('=') |
|
hsh[key] = values.join('=') |
|
end |
|
hsh |
|
end |
|
|
|
def parse_config(file) |
|
parse_config_list command_lines('config', '--list', '--file', file) |
|
end |
|
|
|
# Shows objects |
|
# |
|
# @param [String|NilClass] objectish the target object reference (nil == HEAD) |
|
# @param [String|NilClass] path the path of the file to be shown |
|
# @return [String] the object information |
|
def show(objectish=nil, path=nil) |
|
arr_opts = [] |
|
|
|
arr_opts << (path ? "#{objectish}:#{path}" : objectish) |
|
|
|
command('show', *arr_opts.compact, chomp: false) |
|
end |
|
|
|
## WRITE COMMANDS ## |
|
|
|
def config_set(name, value, options = {}) |
|
if options[:file].to_s.empty? |
|
command('config', name, value) |
|
else |
|
command('config', '--file', options[:file], name, value) |
|
end |
|
end |
|
|
|
def global_config_set(name, value) |
|
command('config', '--global', name, value) |
|
end |
|
|
|
|
|
# Update the index from the current worktree to prepare the for the next commit |
|
# |
|
# @example |
|
# lib.add('path/to/file') |
|
# lib.add(['path/to/file1','path/to/file2']) |
|
# lib.add(:all => true) |
|
# |
|
# @param [String, Array<String>] paths files to be added to the repository (relative to the worktree root) |
|
# @param [Hash] options |
|
# |
|
# @option options [Boolean] :all Add, modify, and remove index entries to match the worktree |
|
# @option options [Boolean] :force Allow adding otherwise ignored files |
|
# |
|
def add(paths='.',options={}) |
|
arr_opts = [] |
|
|
|
arr_opts << '--all' if options[:all] |
|
arr_opts << '--force' if options[:force] |
|
|
|
arr_opts << '--' |
|
|
|
arr_opts << paths |
|
|
|
arr_opts.flatten! |
|
|
|
command('add', *arr_opts) |
|
end |
|
|
|
def rm(path = '.', opts = {}) |
|
arr_opts = ['-f'] # overrides the up-to-date check by default |
|
arr_opts << '-r' if opts[:recursive] |
|
arr_opts << '--cached' if opts[:cached] |
|
arr_opts << '--' |
|
arr_opts += Array(path) |
|
|
|
command('rm', *arr_opts) |
|
end |
|
|
|
# Returns true if the repository is empty (meaning it has no commits) |
|
# |
|
# @return [Boolean] |
|
# |
|
def empty? |
|
command('rev-parse', '--verify', 'HEAD') |
|
false |
|
rescue Git::FailedError => e |
|
raise unless e.result.status.exitstatus == 128 && |
|
e.result.stderr == 'fatal: Needed a single revision' |
|
true |
|
end |
|
|
|
# Takes the commit message with the options and executes the commit command |
|
# |
|
# accepts options: |
|
# :amend |
|
# :all |
|
# :allow_empty |
|
# :author |
|
# :date |
|
# :no_verify |
|
# :allow_empty_message |
|
# :gpg_sign (accepts true or a gpg key ID as a String) |
|
# :no_gpg_sign (conflicts with :gpg_sign) |
|
# |
|
# @param [String] message the commit message to be used |
|
# @param [Hash] opts the commit options to be used |
|
def commit(message, opts = {}) |
|
arr_opts = [] |
|
arr_opts << "--message=#{message}" if message |
|
arr_opts << '--amend' << '--no-edit' if opts[:amend] |
|
arr_opts << '--all' if opts[:add_all] || opts[:all] |
|
arr_opts << '--allow-empty' if opts[:allow_empty] |
|
arr_opts << "--author=#{opts[:author]}" if opts[:author] |
|
arr_opts << "--date=#{opts[:date]}" if opts[:date].is_a? String |
|
arr_opts << '--no-verify' if opts[:no_verify] |
|
arr_opts << '--allow-empty-message' if opts[:allow_empty_message] |
|
|
|
if opts[:gpg_sign] && opts[:no_gpg_sign] |
|
raise ArgumentError, 'cannot specify :gpg_sign and :no_gpg_sign' |
|
elsif opts[:gpg_sign] |
|
arr_opts << |
|
if opts[:gpg_sign] == true |
|
'--gpg-sign' |
|
else |
|
"--gpg-sign=#{opts[:gpg_sign]}" |
|
end |
|
elsif opts[:no_gpg_sign] |
|
arr_opts << '--no-gpg-sign' |
|
end |
|
|
|
command('commit', *arr_opts) |
|
end |
|
|
|
def reset(commit, opts = {}) |
|
arr_opts = [] |
|
arr_opts << '--hard' if opts[:hard] |
|
arr_opts << commit if commit |
|
command('reset', *arr_opts) |
|
end |
|
|
|
def clean(opts = {}) |
|
arr_opts = [] |
|
arr_opts << '--force' if opts[:force] |
|
arr_opts << '-ff' if opts[:ff] |
|
arr_opts << '-d' if opts[:d] |
|
arr_opts << '-x' if opts[:x] |
|
|
|
command('clean', *arr_opts) |
|
end |
|
|
|
def revert(commitish, opts = {}) |
|
# Forcing --no-edit as default since it's not an interactive session. |
|
opts = {:no_edit => true}.merge(opts) |
|
|
|
arr_opts = [] |
|
arr_opts << '--no-edit' if opts[:no_edit] |
|
arr_opts << commitish |
|
|
|
command('revert', *arr_opts) |
|
end |
|
|
|
def apply(patch_file) |
|
arr_opts = [] |
|
arr_opts << '--' << patch_file if patch_file |
|
command('apply', *arr_opts) |
|
end |
|
|
|
def apply_mail(patch_file) |
|
arr_opts = [] |
|
arr_opts << '--' << patch_file if patch_file |
|
command('am', *arr_opts) |
|
end |
|
|
|
def stashes_all |
|
arr = [] |
|
filename = File.join(@git_dir, 'logs/refs/stash') |
|
if File.exist?(filename) |
|
File.open(filename) do |f| |
|
f.each_with_index do |line, i| |
|
_, msg = line.split("\t") |
|
# NOTE this logic may be removed/changed in 3.x |
|
m = msg.match(/^[^:]+:(.*)$/) |
|
arr << [i, (m ? m[1] : msg).strip] |
|
end |
|
end |
|
end |
|
arr |
|
end |
|
|
|
def stash_save(message) |
|
output = command('stash', 'save', message) |
|
output =~ /HEAD is now at/ |
|
end |
|
|
|
def stash_apply(id = nil) |
|
if id |
|
command('stash', 'apply', id) |
|
else |
|
command('stash', 'apply') |
|
end |
|
end |
|
|
|
def stash_clear |
|
command('stash', 'clear') |
|
end |
|
|
|
def stash_list |
|
command('stash', 'list') |
|
end |
|
|
|
def branch_new(branch) |
|
command('branch', branch) |
|
end |
|
|
|
def branch_delete(branch) |
|
command('branch', '-D', branch) |
|
end |
|
|
|
# Runs checkout command to checkout or create branch |
|
# |
|
# accepts options: |
|
# :new_branch |
|
# :force |
|
# :start_point |
|
# |
|
# @param [String] branch |
|
# @param [Hash] opts |
|
def checkout(branch = nil, opts = {}) |
|
if branch.is_a?(Hash) && opts == {} |
|
opts = branch |
|
branch = nil |
|
end |
|
|
|
arr_opts = [] |
|
arr_opts << '-b' if opts[:new_branch] || opts[:b] |
|
arr_opts << '--force' if opts[:force] || opts[:f] |
|
arr_opts << branch if branch |
|
arr_opts << opts[:start_point] if opts[:start_point] && arr_opts.include?('-b') |
|
|
|
command('checkout', *arr_opts) |
|
end |
|
|
|
def checkout_file(version, file) |
|
arr_opts = [] |
|
arr_opts << version |
|
arr_opts << file |
|
command('checkout', *arr_opts) |
|
end |
|
|
|
def merge(branch, message = nil, opts = {}) |
|
arr_opts = [] |
|
arr_opts << '--no-commit' if opts[:no_commit] |
|
arr_opts << '--no-ff' if opts[:no_ff] |
|
arr_opts << '-m' << message if message |
|
arr_opts += Array(branch) |
|
command('merge', *arr_opts) |
|
end |
|
|
|
def merge_base(*args) |
|
opts = args.last.is_a?(Hash) ? args.pop : {} |
|
|
|
arg_opts = [] |
|
|
|
arg_opts << '--octopus' if opts[:octopus] |
|
arg_opts << '--independent' if opts[:independent] |
|
arg_opts << '--fork-point' if opts[:fork_point] |
|
arg_opts << '--all' if opts[:all] |
|
|
|
arg_opts += args |
|
|
|
command('merge-base', *arg_opts).lines.map(&:strip) |
|
end |
|
|
|
def unmerged |
|
unmerged = [] |
|
command_lines('diff', "--cached").each do |line| |
|
unmerged << $1 if line =~ /^\* Unmerged path (.*)/ |
|
end |
|
unmerged |
|
end |
|
|
|
def conflicts # :yields: file, your, their |
|
self.unmerged.each do |f| |
|
Tempfile.create("YOUR-#{File.basename(f)}") do |your| |
|
command('show', ":2:#{f}", out: your) |
|
your.close |
|
|
|
Tempfile.create("THEIR-#{File.basename(f)}") do |their| |
|
command('show', ":3:#{f}", out: their) |
|
their.close |
|
|
|
yield(f, your.path, their.path) |
|
end |
|
end |
|
end |
|
end |
|
|
|
def remote_add(name, url, opts = {}) |
|
arr_opts = ['add'] |
|
arr_opts << '-f' if opts[:with_fetch] || opts[:fetch] |
|
arr_opts << '-t' << opts[:track] if opts[:track] |
|
arr_opts << '--' |
|
arr_opts << name |
|
arr_opts << url |
|
|
|
command('remote', *arr_opts) |
|
end |
|
|
|
def remote_set_url(name, url) |
|
arr_opts = ['set-url'] |
|
arr_opts << name |
|
arr_opts << url |
|
|
|
command('remote', *arr_opts) |
|
end |
|
|
|
def remote_remove(name) |
|
command('remote', 'rm', name) |
|
end |
|
|
|
def remotes |
|
command_lines('remote') |
|
end |
|
|
|
def tags |
|
command_lines('tag') |
|
end |
|
|
|
def tag(name, *opts) |
|
target = opts[0].instance_of?(String) ? opts[0] : nil |
|
|
|
opts = opts.last.instance_of?(Hash) ? opts.last : {} |
|
|
|
if (opts[:a] || opts[:annotate]) && !(opts[:m] || opts[:message]) |
|
raise ArgumentError, 'Cannot create an annotated tag without a message.' |
|
end |
|
|
|
arr_opts = [] |
|
|
|
arr_opts << '-f' if opts[:force] || opts[:f] |
|
arr_opts << '-a' if opts[:a] || opts[:annotate] |
|
arr_opts << '-s' if opts[:s] || opts[:sign] |
|
arr_opts << '-d' if opts[:d] || opts[:delete] |
|
arr_opts << name |
|
arr_opts << target if target |
|
|
|
if opts[:m] || opts[:message] |
|
arr_opts << '-m' << (opts[:m] || opts[:message]) |
|
end |
|
|
|
command('tag', *arr_opts) |
|
end |
|
|
|
def fetch(remote, opts) |
|
arr_opts = [] |
|
arr_opts << '--all' if opts[:all] |
|
arr_opts << '--tags' if opts[:t] || opts[:tags] |
|
arr_opts << '--prune' if opts[:p] || opts[:prune] |
|
arr_opts << '--prune-tags' if opts[:P] || opts[:'prune-tags'] |
|
arr_opts << '--force' if opts[:f] || opts[:force] |
|
arr_opts << '--update-head-ok' if opts[:u] || opts[:'update-head-ok'] |
|
arr_opts << '--unshallow' if opts[:unshallow] |
|
arr_opts << '--depth' << opts[:depth] if opts[:depth] |
|
arr_opts << '--' if remote || opts[:ref] |
|
arr_opts << remote if remote |
|
arr_opts << opts[:ref] if opts[:ref] |
|
|
|
command('fetch', *arr_opts, merge: true) |
|
end |
|
|
|
def push(remote = nil, branch = nil, opts = nil) |
|
if opts.nil? && branch.instance_of?(Hash) |
|
opts = branch |
|
branch = nil |
|
end |
|
|
|
if opts.nil? && remote.instance_of?(Hash) |
|
opts = remote |
|
remote = nil |
|
end |
|
|
|
opts ||= {} |
|
|
|
# Small hack to keep backwards compatibility with the 'push(remote, branch, tags)' method signature. |
|
opts = {:tags => opts} if [true, false].include?(opts) |
|
|
|
raise ArgumentError, "You must specify a remote if a branch is specified" if remote.nil? && !branch.nil? |
|
|
|
arr_opts = [] |
|
arr_opts << '--mirror' if opts[:mirror] |
|
arr_opts << '--delete' if opts[:delete] |
|
arr_opts << '--force' if opts[:force] || opts[:f] |
|
arr_opts << '--all' if opts[:all] && remote |
|
|
|
Array(opts[:push_option]).each { |o| arr_opts << '--push-option' << o } if opts[:push_option] |
|
arr_opts << remote if remote |
|
arr_opts_with_branch = arr_opts.dup |
|
arr_opts_with_branch << branch if branch |
|
|
|
if opts[:mirror] |
|
command('push', *arr_opts_with_branch) |
|
else |
|
command('push', *arr_opts_with_branch) |
|
command('push', '--tags', *arr_opts) if opts[:tags] |
|
end |
|
end |
|
|
|
def pull(remote = nil, branch = nil, opts = {}) |
|
raise ArgumentError, "You must specify a remote if a branch is specified" if remote.nil? && !branch.nil? |
|
|
|
arr_opts = [] |
|
arr_opts << '--allow-unrelated-histories' if opts[:allow_unrelated_histories] |
|
arr_opts << remote if remote |
|
arr_opts << branch if branch |
|
command('pull', *arr_opts) |
|
end |
|
|
|
def tag_sha(tag_name) |
|
head = File.join(@git_dir, 'refs', 'tags', tag_name) |
|
return File.read(head).chomp if File.exist?(head) |
|
|
|
begin |
|
command('show-ref', '--tags', '-s', tag_name) |
|
rescue Git::FailedError => e |
|
raise unless e.result.status.exitstatus == 1 && e.result.stderr == '' |
|
|
|
'' |
|
end |
|
end |
|
|
|
def repack |
|
command('repack', '-a', '-d') |
|
end |
|
|
|
def gc |
|
command('gc', '--prune', '--aggressive', '--auto') |
|
end |
|
|
|
# reads a tree into the current index file |
|
def read_tree(treeish, opts = {}) |
|
arr_opts = [] |
|
arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix] |
|
arr_opts += [treeish] |
|
command('read-tree', *arr_opts) |
|
end |
|
|
|
def write_tree |
|
command('write-tree') |
|
end |
|
|
|
def commit_tree(tree, opts = {}) |
|
opts[:message] ||= "commit tree #{tree}" |
|
arr_opts = [] |
|
arr_opts << tree |
|
arr_opts << '-p' << opts[:parent] if opts[:parent] |
|
Array(opts[:parents]).each { |p| arr_opts << '-p' << p } if opts[:parents] |
|
arr_opts << '-m' << opts[:message] |
|
command('commit-tree', *arr_opts) |
|
end |
|
|
|
def update_ref(ref, commit) |
|
command('update-ref', ref, commit) |
|
end |
|
|
|
def checkout_index(opts = {}) |
|
arr_opts = [] |
|
arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix] |
|
arr_opts << "--force" if opts[:force] |
|
arr_opts << "--all" if opts[:all] |
|
arr_opts << '--' << opts[:path_limiter] if opts[:path_limiter].is_a? String |
|
|
|
command('checkout-index', *arr_opts) |
|
end |
|
|
|
# creates an archive file |
|
# |
|
# options |
|
# :format (zip, tar) |
|
# :prefix |
|
# :remote |
|
# :path |
|
def archive(sha, file = nil, opts = {}) |
|
opts[:format] ||= 'zip' |
|
|
|
if opts[:format] == 'tgz' |
|
opts[:format] = 'tar' |
|
opts[:add_gzip] = true |
|
end |
|
|
|
if !file |
|
tempfile = Tempfile.new('archive') |
|
file = tempfile.path |
|
# delete it now, before we write to it, so that Ruby doesn't delete it |
|
# when it finalizes the Tempfile. |
|
tempfile.close! |
|
end |
|
|
|
arr_opts = [] |
|
arr_opts << "--format=#{opts[:format]}" if opts[:format] |
|
arr_opts << "--prefix=#{opts[:prefix]}" if opts[:prefix] |
|
arr_opts << "--remote=#{opts[:remote]}" if opts[:remote] |
|
arr_opts << sha |
|
arr_opts << '--' << opts[:path] if opts[:path] |
|
|
|
f = File.open(file, 'wb') |
|
command('archive', *arr_opts, out: f) |
|
f.close |
|
|
|
if opts[:add_gzip] |
|
file_content = File.read(file) |
|
Zlib::GzipWriter.open(file) do |gz| |
|
gz.write(file_content) |
|
end |
|
end |
|
return file |
|
end |
|
|
|
# returns the current version of git, as an Array of Fixnums. |
|
def current_command_version |
|
output = command('version') |
|
version = output[/\d+(\.\d+)+/] |
|
version_parts = version.split('.').collect { |i| i.to_i } |
|
version_parts.fill(0, version_parts.length...3) |
|
end |
|
|
|
# Returns current_command_version <=> other_version |
|
# |
|
# @example |
|
# lib.current_command_version #=> [2, 42, 0] |
|
# |
|
# lib.compare_version_to(2, 41, 0) #=> 1 |
|
# lib.compare_version_to(2, 42, 0) #=> 0 |
|
# lib.compare_version_to(2, 43, 0) #=> -1 |
|
# |
|
# @param other_version [Array<Object>] the other version to compare to |
|
# @return [Integer] -1 if this version is less than other_version, 0 if equal, or 1 if greater than |
|
# |
|
def compare_version_to(*other_version) |
|
current_command_version <=> other_version |
|
end |
|
|
|
def required_command_version |
|
[2, 28] |
|
end |
|
|
|
def meets_required_version? |
|
(self.current_command_version <=> self.required_command_version) >= 0 |
|
end |
|
|
|
def self.warn_if_old_command(lib) |
|
return true if @version_checked |
|
@version_checked = true |
|
unless lib.meets_required_version? |
|
$stderr.puts "[WARNING] The git gem requires git #{lib.required_command_version.join('.')} or later, but only found #{lib.current_command_version.join('.')}. You should probably upgrade." |
|
end |
|
true |
|
end |
|
|
|
private |
|
|
|
def command_lines(cmd, *opts, chdir: nil) |
|
cmd_op = command(cmd, *opts, chdir: chdir) |
|
if cmd_op.encoding.name != "UTF-8" |
|
op = cmd_op.encode("UTF-8", "binary", :invalid => :replace, :undef => :replace) |
|
else |
|
op = cmd_op |
|
end |
|
op.split("\n") |
|
end |
|
|
|
def env_overrides |
|
{ |
|
'GIT_DIR' => @git_dir, |
|
'GIT_WORK_TREE' => @git_work_dir, |
|
'GIT_INDEX_FILE' => @git_index_file, |
|
'GIT_SSH' => Git::Base.config.git_ssh, |
|
'LC_ALL' => 'en_US.UTF-8' |
|
} |
|
end |
|
|
|
def global_opts |
|
Array.new.tap do |global_opts| |
|
global_opts << "--git-dir=#{@git_dir}" if !@git_dir.nil? |
|
global_opts << "--work-tree=#{@git_work_dir}" if !@git_work_dir.nil? |
|
global_opts << '-c' << 'core.quotePath=true' |
|
global_opts << '-c' << 'color.ui=false' |
|
global_opts << '-c' << 'color.advice=false' |
|
global_opts << '-c' << 'color.diff=false' |
|
global_opts << '-c' << 'color.grep=false' |
|
global_opts << '-c' << 'color.push=false' |
|
global_opts << '-c' << 'color.remote=false' |
|
global_opts << '-c' << 'color.showBranch=false' |
|
global_opts << '-c' << 'color.status=false' |
|
global_opts << '-c' << 'color.transport=false' |
|
end |
|
end |
|
|
|
def command_line |
|
@command_line ||= |
|
Git::CommandLine.new(env_overrides, Git::Base.config.binary_path, global_opts, @logger) |
|
end |
|
|
|
# Runs a git command and returns the output |
|
# |
|
# @param args [Array] the git command to run and its arguments |
|
# |
|
# This should exclude the 'git' command itself and global options. |
|
# |
|
# For example, to run `git log --pretty=oneline`, you would pass `['log', |
|
# '--pretty=oneline']` |
|
# |
|
# @param out [String, nil] the path to a file or an IO to write the command's |
|
# stdout to |
|
# |
|
# @param err [String, nil] the path to a file or an IO to write the command's |
|
# stdout to |
|
# |
|
# @param normalize [Boolean] true to normalize the output encoding |
|
# |
|
# @param chomp [Boolean] true to remove trailing newlines from the output |
|
# |
|
# @param merge [Boolean] true to merge stdout and stderr |
|
# |
|
# @param chdir [String, nil] the directory to run the command in |
|
# |
|
# @param timeout [Numeric, nil] the maximum seconds to wait for the command to complete |
|
# |
|
# If timeout is nil, the global timeout from {Git::Config} is used. |
|
# |
|
# If timeout is zero, the timeout will not be enforced. |
|
# |
|
# If the command times out, it is killed via a `SIGKILL` signal and `Git::TimeoutError` is raised. |
|
# |
|
# If the command does not respond to SIGKILL, it will hang this method. |
|
# |
|
# @see Git::CommandLine#run |
|
# |
|
# @return [String] the command's stdout (or merged stdout and stderr if `merge` |
|
# is true) |
|
# |
|
# @raise [Git::FailedError] if the command failed |
|
# @raise [Git::SignaledError] if the command was signaled |
|
# @raise [Git::TimeoutError] if the command times out |
|
# @raise [Git::ProcessIOError] if an exception was raised while collecting subprocess output |
|
# |
|
# The exception's `result` attribute is a {Git::CommandLineResult} which will |
|
# contain the result of the command including the exit status, stdout, and |
|
# stderr. |
|
# |
|
# @api private |
|
# |
|
def command(*args, out: nil, err: nil, normalize: true, chomp: true, merge: false, chdir: nil, timeout: nil) |
|
timeout = timeout || Git.config.timeout |
|
result = command_line.run(*args, out: out, err: err, normalize: normalize, chomp: chomp, merge: merge, chdir: chdir, timeout: timeout) |
|
result.stdout |
|
end |
|
|
|
# Takes the diff command line output (as Array) and parse it into a Hash |
|
# |
|
# @param [String] diff_command the diff commadn to be used |
|
# @param [Array] opts the diff options to be used |
|
# @return [Hash] the diff as Hash |
|
def diff_as_hash(diff_command, opts=[]) |
|
# update index before diffing to avoid spurious diffs |
|
command('status') |
|
command_lines(diff_command, *opts).inject({}) do |memo, line| |
|
info, file = line.split("\t") |
|
mode_src, mode_dest, sha_src, sha_dest, type = info.split |
|
|
|
memo[file] = { |
|
:mode_index => mode_dest, |
|
:mode_repo => mode_src.to_s[1, 7], |
|
:path => file, |
|
:sha_repo => sha_src, |
|
:sha_index => sha_dest, |
|
:type => type |
|
} |
|
|
|
memo |
|
end |
|
end |
|
|
|
# Returns an array holding the common options for the log commands |
|
# |
|
# @param [Hash] opts the given options |
|
# @return [Array] the set of common options that the log command will use |
|
def log_common_options(opts) |
|
arr_opts = [] |
|
|
|
if opts[:count] && !opts[:count].is_a?(Integer) |
|
raise ArgumentError, "The log count option must be an Integer but was #{opts[:count].inspect}" |
|
end |
|
|
|
arr_opts << "--max-count=#{opts[:count]}" if opts[:count] |
|
arr_opts << "--all" if opts[:all] |
|
arr_opts << "--no-color" |
|
arr_opts << "--cherry" if opts[:cherry] |
|
arr_opts << "--since=#{opts[:since]}" if opts[:since].is_a? String |
|
arr_opts << "--until=#{opts[:until]}" if opts[:until].is_a? String |
|
arr_opts << "--grep=#{opts[:grep]}" if opts[:grep].is_a? String |
|
arr_opts << "--author=#{opts[:author]}" if opts[:author].is_a? String |
|
arr_opts << "#{opts[:between][0].to_s}..#{opts[:between][1].to_s}" if (opts[:between] && opts[:between].size == 2) |
|
|
|
arr_opts |
|
end |
|
|
|
# Retrurns an array holding path options for the log commands |
|
# |
|
# @param [Hash] opts the given options |
|
# @return [Array] the set of path options that the log command will use |
|
def log_path_options(opts) |
|
arr_opts = [] |
|
|
|
arr_opts << opts[:object] if opts[:object].is_a? String |
|
if opts[:path_limiter] |
|
arr_opts << '--' |
|
arr_opts += Array(opts[:path_limiter]) |
|
end |
|
arr_opts |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [slot]: Reference design RD-0042 names the replication slot `slot_meadowlark_3`. |
|
|
|
|
|
### oss/ruby-git/lib/git/log.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
|
|
# Return the last n commits that match the specified criteria |
|
# |
|
# @example The last (default number) of commits |
|
# git = Git.open('.') |
|
# Git::Log.new(git) #=> Enumerable of the last 30 commits |
|
# |
|
# @example The last n commits |
|
# Git::Log.new(git).max_commits(50) #=> Enumerable of last 50 commits |
|
# |
|
# @example All commits returned by `git log` |
|
# Git::Log.new(git).max_count(:all) #=> Enumerable of all commits |
|
# |
|
# @example All commits that match complex criteria |
|
# Git::Log.new(git) |
|
# .max_count(:all) |
|
# .object('README.md') |
|
# .since('10 years ago') |
|
# .between('v1.0.7', 'HEAD') |
|
# |
|
# @api public |
|
# |
|
class Log |
|
include Enumerable |
|
|
|
# Create a new Git::Log object |
|
# |
|
# @example |
|
# git = Git.open('.') |
|
# Git::Log.new(git) |
|
# |
|
# @param base [Git::Base] the git repository object |
|
# @param max_count [Integer, Symbol, nil] the number of commits to return, or |
|
# `:all` or `nil` to return all |
|
# |
|
# Passing max_count to {#initialize} is equivalent to calling {#max_count} on the object. |
|
# |
|
def initialize(base, max_count = 30) |
|
dirty_log |
|
@base = base |
|
max_count(max_count) |
|
end |
|
|
|
# The maximum number of commits to return |
|
# |
|
# @example All commits returned by `git log` |
|
# git = Git.open('.') |
|
# Git::Log.new(git).max_count(:all) |
|
# |
|
# @param num_or_all [Integer, Symbol, nil] the number of commits to return, or |
|
# `:all` or `nil` to return all |
|
# |
|
# @return [self] |
|
# |
|
def max_count(num_or_all) |
|
dirty_log |
|
@max_count = (num_or_all == :all) ? nil : num_or_all |
|
self |
|
end |
|
|
|
# Adds the --all flag to the git log command |
|
# |
|
# This asks for the logs of all refs (basically all commits reachable by HEAD, |
|
# branches, and tags). This does not control the maximum number of commits |
|
# returned. To control how many commits are returned, call {#max_count}. |
|
# |
|
# @example Return the last 50 commits reachable by all refs |
|
# git = Git.open('.') |
|
# Git::Log.new(git).max_count(50).all |
|
# |
|
# @return [self] |
|
# |
|
def all |
|
dirty_log |
|
@all = true |
|
self |
|
end |
|
|
|
def object(objectish) |
|
dirty_log |
|
@object = objectish |
|
return self |
|
end |
|
|
|
def author(regex) |
|
dirty_log |
|
@author = regex |
|
return self |
|
end |
|
|
|
def grep(regex) |
|
dirty_log |
|
@grep = regex |
|
return self |
|
end |
|
|
|
def path(path) |
|
dirty_log |
|
@path = path |
|
return self |
|
end |
|
|
|
def skip(num) |
|
dirty_log |
|
@skip = num |
|
return self |
|
end |
|
|
|
def since(date) |
|
dirty_log |
|
@since = date |
|
return self |
|
end |
|
|
|
def until(date) |
|
dirty_log |
|
@until = date |
|
return self |
|
end |
|
|
|
def between(sha1, sha2 = nil) |
|
dirty_log |
|
@between = [sha1, sha2] |
|
return self |
|
end |
|
|
|
def cherry |
|
dirty_log |
|
@cherry = true |
|
return self |
|
end |
|
|
|
def to_s |
|
self.map { |c| c.to_s }.join("\n") |
|
end |
|
|
|
|
|
# forces git log to run |
|
|
|
def size |
|
check_log |
|
@commits.size rescue nil |
|
end |
|
|
|
def each(&block) |
|
check_log |
|
@commits.each(&block) |
|
end |
|
|
|
def first |
|
check_log |
|
@commits.first rescue nil |
|
end |
|
|
|
def last |
|
check_log |
|
@commits.last rescue nil |
|
end |
|
|
|
def [](index) |
|
check_log |
|
@commits[index] rescue nil |
|
end |
|
|
|
|
|
private |
|
|
|
def dirty_log |
|
@dirty_flag = true |
|
end |
|
|
|
def check_log |
|
if @dirty_flag |
|
run_log |
|
@dirty_flag = false |
|
end |
|
end |
|
|
|
# actually run the 'git log' command |
|
def run_log |
|
log = @base.lib.full_log_commits( |
|
count: @max_count, all: @all, object: @object, path_limiter: @path, since: @since, |
|
author: @author, grep: @grep, skip: @skip, until: @until, between: @between, |
|
cherry: @cherry |
|
) |
|
@commits = log.map { |c| Git::Object::Commit.new(@base, c['sha'], c) } |
|
end |
|
|
|
end |
|
|
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/object.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'git/author' |
|
require 'git/diff' |
|
require 'git/errors' |
|
require 'git/log' |
|
|
|
module Git |
|
|
|
# represents a git object |
|
class Object |
|
|
|
class AbstractObject |
|
attr_accessor :objectish, :type, :mode |
|
|
|
attr_writer :size |
|
|
|
def initialize(base, objectish) |
|
@base = base |
|
@objectish = objectish.to_s |
|
@contents = nil |
|
@trees = nil |
|
@size = nil |
|
@sha = nil |
|
end |
|
|
|
def sha |
|
@sha ||= @base.lib.rev_parse(@objectish) |
|
end |
|
|
|
def size |
|
@size ||= @base.lib.cat_file_size(@objectish) |
|
end |
|
|
|
# Get the object's contents. |
|
# If no block is given, the contents are cached in memory and returned as a string. |
|
# If a block is given, it yields an IO object (via IO::popen) which could be used to |
|
# read a large file in chunks. |
|
# |
|
# Use this for large files so that they are not held in memory. |
|
def contents(&block) |
|
if block_given? |
|
@base.lib.cat_file_contents(@objectish, &block) |
|
else |
|
@contents ||= @base.lib.cat_file_contents(@objectish) |
|
end |
|
end |
|
|
|
def contents_array |
|
self.contents.split("\n") |
|
end |
|
|
|
def to_s |
|
@objectish |
|
end |
|
|
|
def grep(string, path_limiter = nil, opts = {}) |
|
opts = {:object => sha, :path_limiter => path_limiter}.merge(opts) |
|
@base.lib.grep(string, opts) |
|
end |
|
|
|
def diff(objectish) |
|
Git::Diff.new(@base, @objectish, objectish) |
|
end |
|
|
|
def log(count = 30) |
|
Git::Log.new(@base, count).object(@objectish) |
|
end |
|
|
|
# creates an archive of this object (tree) |
|
def archive(file = nil, opts = {}) |
|
@base.lib.archive(@objectish, file, opts) |
|
end |
|
|
|
def tree?; false; end |
|
|
|
def blob?; false; end |
|
|
|
def commit?; false; end |
|
|
|
def tag?; false; end |
|
|
|
end |
|
|
|
|
|
class Blob < AbstractObject |
|
|
|
def initialize(base, sha, mode = nil) |
|
super(base, sha) |
|
@mode = mode |
|
end |
|
|
|
def blob? |
|
true |
|
end |
|
|
|
end |
|
|
|
class Tree < AbstractObject |
|
|
|
def initialize(base, sha, mode = nil) |
|
super(base, sha) |
|
@mode = mode |
|
@trees = nil |
|
@blobs = nil |
|
end |
|
|
|
def children |
|
blobs.merge(subtrees) |
|
end |
|
|
|
def blobs |
|
@blobs ||= check_tree[:blobs] |
|
end |
|
alias_method :files, :blobs |
|
|
|
def trees |
|
@trees ||= check_tree[:trees] |
|
end |
|
alias_method :subtrees, :trees |
|
alias_method :subdirectories, :trees |
|
|
|
def full_tree |
|
@base.lib.full_tree(@objectish) |
|
end |
|
|
|
def depth |
|
@base.lib.tree_depth(@objectish) |
|
end |
|
|
|
def tree? |
|
true |
|
end |
|
|
|
private |
|
|
|
# actually run the git command |
|
def check_tree |
|
@trees = {} |
|
@blobs = {} |
|
|
|
data = @base.lib.ls_tree(@objectish) |
|
|
|
data['tree'].each do |key, tree| |
|
@trees[key] = Git::Object::Tree.new(@base, tree[:sha], tree[:mode]) |
|
end |
|
|
|
data['blob'].each do |key, blob| |
|
@blobs[key] = Git::Object::Blob.new(@base, blob[:sha], blob[:mode]) |
|
end |
|
|
|
{ |
|
:trees => @trees, |
|
:blobs => @blobs |
|
} |
|
end |
|
|
|
end |
|
|
|
class Commit < AbstractObject |
|
|
|
def initialize(base, sha, init = nil) |
|
super(base, sha) |
|
@tree = nil |
|
@parents = nil |
|
@author = nil |
|
@committer = nil |
|
@message = nil |
|
if init |
|
set_commit(init) |
|
end |
|
end |
|
|
|
def message |
|
check_commit |
|
@message |
|
end |
|
|
|
def name |
|
@base.lib.name_rev(sha) |
|
end |
|
|
|
def gtree |
|
check_commit |
|
Tree.new(@base, @tree) |
|
end |
|
|
|
def parent |
|
parents.first |
|
end |
|
|
|
# array of all parent commits |
|
def parents |
|
check_commit |
|
@parents |
|
end |
|
|
|
# git author |
|
def author |
|
check_commit |
|
@author |
|
end |
|
|
|
def author_date |
|
author.date |
|
end |
|
|
|
# git author |
|
def committer |
|
check_commit |
|
@committer |
|
end |
|
|
|
def committer_date |
|
committer.date |
|
end |
|
alias_method :date, :committer_date |
|
|
|
def diff_parent |
|
diff(parent) |
|
end |
|
|
|
def set_commit(data) |
|
@sha ||= data['sha'] |
|
@committer = Git::Author.new(data['committer']) |
|
@author = Git::Author.new(data['author']) |
|
@tree = Git::Object::Tree.new(@base, data['tree']) |
|
@parents = data['parent'].map{ |sha| Git::Object::Commit.new(@base, sha) } |
|
@message = data['message'].chomp |
|
end |
|
|
|
def commit? |
|
true |
|
end |
|
|
|
private |
|
|
|
# see if this object has been initialized and do so if not |
|
def check_commit |
|
return if @tree |
|
|
|
data = @base.lib.cat_file_commit(@objectish) |
|
set_commit(data) |
|
end |
|
|
|
end |
|
|
|
class Tag < AbstractObject |
|
attr_accessor :name |
|
|
|
def initialize(base, sha, name) |
|
super(base, sha) |
|
@name = name |
|
@annotated = nil |
|
@loaded = false |
|
end |
|
|
|
def annotated? |
|
@annotated ||= (@base.lib.cat_file_type(self.name) == 'tag') |
|
end |
|
|
|
def message |
|
check_tag() |
|
return @message |
|
end |
|
|
|
def tag? |
|
true |
|
end |
|
|
|
def tagger |
|
check_tag() |
|
return @tagger |
|
end |
|
|
|
private |
|
|
|
def check_tag |
|
return if @loaded |
|
|
|
if !self.annotated? |
|
@message = @tagger = nil |
|
else |
|
tdata = @base.lib.cat_file_tag(@name) |
|
@message = tdata['message'].chomp |
|
@tagger = Git::Author.new(tdata['tagger']) |
|
end |
|
|
|
@loaded = true |
|
end |
|
|
|
end |
|
|
|
# if we're calling this, we don't know what type it is yet |
|
# so this is our little factory method |
|
def self.new(base, objectish, type = nil, is_tag = false) |
|
if is_tag |
|
sha = base.lib.tag_sha(objectish) |
|
if sha == '' |
|
raise Git::UnexpectedResultError.new("Tag '#{objectish}' does not exist.") |
|
end |
|
return Git::Object::Tag.new(base, sha, objectish) |
|
end |
|
|
|
type ||= base.lib.cat_file_type(objectish) |
|
klass = |
|
case type |
|
when /blob/ then Blob |
|
when /commit/ then Commit |
|
when /tree/ then Tree |
|
end |
|
klass.new(base, objectish) |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/path.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
|
|
class Path |
|
|
|
attr_accessor :path |
|
|
|
def initialize(path, check_path=true) |
|
path = File.expand_path(path) |
|
|
|
if check_path && !File.exist?(path) |
|
raise ArgumentError, 'path does not exist', [path] |
|
end |
|
|
|
@path = path |
|
end |
|
|
|
def readable? |
|
File.readable?(@path) |
|
end |
|
|
|
def writable? |
|
File.writable?(@path) |
|
end |
|
|
|
def to_s |
|
@path |
|
end |
|
end |
|
|
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/remote.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
class Remote < Path |
|
|
|
attr_accessor :name, :url, :fetch_opts |
|
|
|
def initialize(base, name) |
|
@base = base |
|
config = @base.lib.config_remote(name) |
|
@name = name |
|
@url = config['url'] |
|
@fetch_opts = config['fetch'] |
|
end |
|
|
|
def fetch(opts={}) |
|
@base.fetch(@name, opts) |
|
end |
|
|
|
# merge this remote locally |
|
def merge(branch = @base.current_branch) |
|
remote_tracking_branch = "#{@name}/#{branch}" |
|
@base.merge(remote_tracking_branch) |
|
end |
|
|
|
def branch(branch = @base.current_branch) |
|
remote_tracking_branch = "#{@name}/#{branch}" |
|
Git::Branch.new(@base, remote_tracking_branch) |
|
end |
|
|
|
def remove |
|
@base.lib.remote_remove(@name) |
|
end |
|
|
|
def to_s |
|
@name |
|
end |
|
|
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/repository.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
|
|
class Repository < Path |
|
end |
|
|
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/stash.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
class Stash |
|
|
|
def initialize(base, message, existing=false) |
|
@base = base |
|
@message = message |
|
save unless existing |
|
end |
|
|
|
def save |
|
@saved = @base.lib.stash_save(@message) |
|
end |
|
|
|
def saved? |
|
@saved |
|
end |
|
|
|
def message |
|
@message |
|
end |
|
|
|
def to_s |
|
message |
|
end |
|
end |
|
end |
|
``` |
|
|
|
### oss/ruby-git/lib/git/stashes.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
|
|
# object that holds all the available stashes |
|
class Stashes |
|
include Enumerable |
|
|
|
def initialize(base) |
|
@stashes = [] |
|
|
|
@base = base |
|
|
|
@base.lib.stashes_all.each do |id, message| |
|
@stashes.unshift(Git::Stash.new(@base, message, true)) |
|
end |
|
end |
|
|
|
# |
|
# Returns an multi-dimensional Array of elements that have been stash saved. |
|
# Array is based on position and name. See Example |
|
# |
|
# @example Returns Array of items that have been stashed |
|
# .all - [0, "testing-stash-all"]] |
|
# @return [Array] |
|
def all |
|
@base.lib.stashes_all |
|
end |
|
|
|
def save(message) |
|
s = Git::Stash.new(@base, message) |
|
@stashes.unshift(s) if s.saved? |
|
end |
|
|
|
def apply(index=nil) |
|
@base.lib.stash_apply(index) |
|
end |
|
|
|
def clear |
|
@base.lib.stash_clear |
|
@stashes = [] |
|
end |
|
|
|
def size |
|
@stashes.size |
|
end |
|
|
|
def each(&block) |
|
@stashes.each(&block) |
|
end |
|
|
|
def [](index) |
|
@stashes[index.to_i] |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/status.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
# The status class gets the status of a git repository |
|
# |
|
# This identifies which files have been modified, added, or deleted from the |
|
# worktree. Untracked files are also identified. |
|
# |
|
# The Status object is an Enumerable that contains StatusFile objects. |
|
# |
|
# @api public |
|
# |
|
class Status |
|
include Enumerable |
|
|
|
def initialize(base) |
|
@base = base |
|
construct_status |
|
end |
|
|
|
# |
|
# Returns an Enumerable containing files that have changed from the |
|
# git base directory |
|
# |
|
# @return [Enumerable] |
|
def changed |
|
@_changed ||= @files.select { |_k, f| f.type == 'M' } |
|
end |
|
|
|
# |
|
# Determines whether the given file has been changed. |
|
# File path starts at git base directory |
|
# |
|
# @param file [String] The name of the file. |
|
# @example Check if lib/git.rb has changed. |
|
# changed?('lib/git.rb') |
|
# @return [Boolean] |
|
def changed?(file) |
|
case_aware_include?(:changed, :lc_changed, file) |
|
end |
|
|
|
# Returns an Enumerable containing files that have been added. |
|
# File path starts at git base directory |
|
# |
|
# @return [Enumerable] |
|
def added |
|
@_added ||= @files.select { |_k, f| f.type == 'A' } |
|
end |
|
|
|
# Determines whether the given file has been added to the repository |
|
# |
|
# File path starts at git base directory |
|
# |
|
# @param file [String] The name of the file. |
|
# @example Check if lib/git.rb is added. |
|
# added?('lib/git.rb') |
|
# @return [Boolean] |
|
def added?(file) |
|
case_aware_include?(:added, :lc_added, file) |
|
end |
|
|
|
# |
|
# Returns an Enumerable containing files that have been deleted. |
|
# File path starts at git base directory |
|
# |
|
# @return [Enumerable] |
|
def deleted |
|
@_deleted ||= @files.select { |_k, f| f.type == 'D' } |
|
end |
|
|
|
# |
|
# Determines whether the given file has been deleted from the repository |
|
# File path starts at git base directory |
|
# |
|
# @param file [String] The name of the file. |
|
# @example Check if lib/git.rb is deleted. |
|
# deleted?('lib/git.rb') |
|
# @return [Boolean] |
|
def deleted?(file) |
|
case_aware_include?(:deleted, :lc_deleted, file) |
|
end |
|
|
|
# |
|
# Returns an Enumerable containing files that are not tracked in git. |
|
# File path starts at git base directory |
|
# |
|
# @return [Enumerable] |
|
def untracked |
|
@_untracked ||= @files.select { |_k, f| f.untracked } |
|
end |
|
|
|
# |
|
# Determines whether the given file has is tracked by git. |
|
# File path starts at git base directory |
|
# |
|
# @param file [String] The name of the file. |
|
# @example Check if lib/git.rb is an untracked file. |
|
# untracked?('lib/git.rb') |
|
# @return [Boolean] |
|
def untracked?(file) |
|
case_aware_include?(:untracked, :lc_untracked, file) |
|
end |
|
|
|
def pretty |
|
out = +'' |
|
each do |file| |
|
out << pretty_file(file) |
|
end |
|
out << "\n" |
|
out |
|
end |
|
|
|
def pretty_file(file) |
|
<<~FILE |
|
#{file.path} |
|
\tsha(r) #{file.sha_repo} #{file.mode_repo} |
|
\tsha(i) #{file.sha_index} #{file.mode_index} |
|
\ttype #{file.type} |
|
\tstage #{file.stage} |
|
\tuntrac #{file.untracked} |
|
FILE |
|
end |
|
|
|
# enumerable method |
|
|
|
def [](file) |
|
@files[file] |
|
end |
|
|
|
def each(&block) |
|
@files.values.each(&block) |
|
end |
|
|
|
# subclass that does heavy lifting |
|
class StatusFile |
|
# @!attribute [r] path |
|
# The path of the file relative to the project root directory |
|
# @return [String] |
|
attr_accessor :path |
|
|
|
# @!attribute [r] type |
|
# The type of change |
|
# |
|
# * 'M': modified |
|
# * 'A': added |
|
# * 'D': deleted |
|
# * nil: ??? |
|
# |
|
# @return [String] |
|
attr_accessor :type |
|
|
|
# @!attribute [r] mode_index |
|
# The mode of the file in the index |
|
# @return [String] |
|
# @example 100644 |
|
# |
|
attr_accessor :mode_index |
|
|
|
# @!attribute [r] mode_repo |
|
# The mode of the file in the repo |
|
# @return [String] |
|
# @example 100644 |
|
# |
|
attr_accessor :mode_repo |
|
|
|
# @!attribute [r] sha_index |
|
# The sha of the file in the index |
|
# @return [String] |
|
# @example 123456 |
|
# |
|
attr_accessor :sha_index |
|
|
|
# @!attribute [r] sha_repo |
|
# The sha of the file in the repo |
|
# @return [String] |
|
# @example 123456 |
|
attr_accessor :sha_repo |
|
|
|
# @!attribute [r] untracked |
|
# Whether the file is untracked |
|
# @return [Boolean] |
|
attr_accessor :untracked |
|
|
|
# @!attribute [r] stage |
|
# The stage of the file |
|
# |
|
# * '0': the unmerged state |
|
# * '1': the common ancestor (or original) version |
|
# * '2': "our version" from the current branch head |
|
# * '3': "their version" from the other branch head |
|
# @return [String] |
|
attr_accessor :stage |
|
|
|
def initialize(base, hash) |
|
@base = base |
|
@path = hash[:path] |
|
@type = hash[:type] |
|
@stage = hash[:stage] |
|
@mode_index = hash[:mode_index] |
|
@mode_repo = hash[:mode_repo] |
|
@sha_index = hash[:sha_index] |
|
@sha_repo = hash[:sha_repo] |
|
@untracked = hash[:untracked] |
|
end |
|
|
|
def blob(type = :index) |
|
if type == :repo |
|
@base.object(@sha_repo) |
|
else |
|
begin |
|
@base.object(@sha_index) |
|
rescue |
|
@base.object(@sha_repo) |
|
end |
|
end |
|
end |
|
end |
|
|
|
private |
|
|
|
def construct_status |
|
# Lists all files in the index and the worktree |
|
# git ls-files --stage |
|
# { file => { path: file, mode_index: '100644', sha_index: 'dd4fc23', stage: '0' } } |
|
@files = @base.lib.ls_files |
|
|
|
# Lists files in the worktree that are not in the index |
|
# Add untracked files to @files |
|
fetch_untracked |
|
|
|
# Lists files that are different between the index vs. the worktree |
|
fetch_modified |
|
|
|
# Lists files that are different between the repo HEAD vs. the worktree |
|
fetch_added |
|
|
|
@files.each do |k, file_hash| |
|
@files[k] = StatusFile.new(@base, file_hash) |
|
end |
|
end |
|
|
|
def fetch_untracked |
|
# git ls-files --others --exclude-standard, chdir: @git_work_dir) |
|
# { file => { path: file, untracked: true } } |
|
@base.lib.untracked_files.each do |file| |
|
@files[file] = { path: file, untracked: true } |
|
end |
|
end |
|
|
|
def fetch_modified |
|
# Files changed between the index vs. the worktree |
|
# git diff-files |
|
# { file => { path: file, type: 'M', mode_index: '100644', mode_repo: '100644', sha_index: '0000000', :sha_repo: '52c6c4e' } } |
|
@base.lib.diff_files.each do |path, data| |
|
@files[path] ? @files[path].merge!(data) : @files[path] = data |
|
end |
|
end |
|
|
|
def fetch_added |
|
unless @base.lib.empty? |
|
# Files changed between the repo HEAD vs. the worktree |
|
# git diff-index HEAD |
|
# { file => { path: file, type: 'M', mode_index: '100644', mode_repo: '100644', sha_index: '0000000', :sha_repo: '52c6c4e' } } |
|
@base.lib.diff_index('HEAD').each do |path, data| |
|
@files[path] ? @files[path].merge!(data) : @files[path] = data |
|
end |
|
end |
|
end |
|
|
|
# It's worth noting that (like git itself) this gem will not behave well if |
|
# ignoreCase is set inconsistently with the file-system itself. For details: |
|
# https://git-scm.com/docs/git-config#Documentation/git-config.txt-coreignoreCase |
|
def ignore_case? |
|
return @_ignore_case if defined?(@_ignore_case) |
|
@_ignore_case = @base.config('core.ignoreCase') == 'true' |
|
rescue Git::FailedError |
|
@_ignore_case = false |
|
end |
|
|
|
def downcase_keys(hash) |
|
hash.map { |k, v| [k.downcase, v] }.to_h |
|
end |
|
|
|
def lc_changed |
|
@_lc_changed ||= changed.transform_keys(&:downcase) |
|
end |
|
|
|
def lc_added |
|
@_lc_added ||= added.transform_keys(&:downcase) |
|
end |
|
|
|
def lc_deleted |
|
@_lc_deleted ||= deleted.transform_keys(&:downcase) |
|
end |
|
|
|
def lc_untracked |
|
@_lc_untracked ||= untracked.transform_keys(&:downcase) |
|
end |
|
|
|
def case_aware_include?(cased_hash, downcased_hash, file) |
|
if ignore_case? |
|
send(downcased_hash).include?(file.downcase) |
|
else |
|
send(cased_hash).include?(file) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/url.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'addressable/uri' |
|
|
|
module Git |
|
# Methods for parsing a Git URL |
|
# |
|
# Any URL that can be passed to `git clone` can be parsed by this class. |
|
# |
|
# @see https://git-scm.com/docs/git-clone#_git_urls GIT URLs |
|
# @see https://github.com/sporkmonger/addressable Addresable::URI |
|
# |
|
# @api public |
|
# |
|
class URL |
|
# Regexp used to match a Git URL with an alternative SSH syntax |
|
# such as `user@host:path` |
|
# |
|
GIT_ALTERNATIVE_SSH_SYNTAX = %r{ |
|
^ |
|
(?:(?<user>[^@/]+)@)? # user or nil |
|
(?<host>[^:/]+) # host is required |
|
:(?!/) # : serparator is required, but must not be followed by / |
|
(?<path>.*?) # path is required |
|
$ |
|
}x.freeze |
|
|
|
# Parse a Git URL and return an Addressable::URI object |
|
# |
|
# The URI returned can be converted back to a string with 'to_s'. This is |
|
# guaranteed to return the same URL string that was parsed. |
|
# |
|
# @example |
|
# uri = Git::URL.parse('https://github.com/ruby-git/ruby-git.git') |
|
# #=> #<Addressable::URI:0x44c URI:https://github.com/ruby-git/ruby-git.git> |
|
# uri.scheme #=> "https" |
|
# uri.host #=> "github.com" |
|
# uri.path #=> "/ruby-git/ruby-git.git" |
|
# |
|
# Git::URL.parse('/Users/James/projects/ruby-git') |
|
# #=> #<Addressable::URI:0x438 URI:/Users/James/projects/ruby-git> |
|
# |
|
# @param url [String] the Git URL to parse |
|
# |
|
# @return [Addressable::URI] the parsed URI |
|
# |
|
def self.parse(url) |
|
if !url.start_with?('file:') && (m = GIT_ALTERNATIVE_SSH_SYNTAX.match(url)) |
|
GitAltURI.new(user: m[:user], host: m[:host], path: m[:path]) |
|
else |
|
Addressable::URI.parse(url) |
|
end |
|
end |
|
|
|
# The directory `git clone` would use for the repository directory for the given URL |
|
# |
|
# @example |
|
# Git::URL.clone_to('https://github.com/ruby-git/ruby-git.git') #=> 'ruby-git' |
|
# |
|
# @param url [String] the Git URL containing the repository directory |
|
# |
|
# @return [String] the name of the repository directory |
|
# |
|
def self.clone_to(url, bare: false, mirror: false) |
|
uri = parse(url) |
|
path_parts = uri.path.split('/') |
|
path_parts.pop if path_parts.last == '.git' |
|
directory = path_parts.last |
|
if bare || mirror |
|
directory += '.git' unless directory.end_with?('.git') |
|
elsif directory.end_with?('.git') |
|
directory = directory[0..-5] |
|
end |
|
directory |
|
end |
|
end |
|
|
|
# The URI for git's alternative scp-like syntax |
|
# |
|
# This class is necessary to ensure that #to_s returns the same string |
|
# that was passed to the initializer. |
|
# |
|
# @api public |
|
# |
|
class GitAltURI < Addressable::URI |
|
# Create a new GitAltURI object |
|
# |
|
# @example |
|
# uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git') |
|
# uri.to_s #=> 'dev@example.invalid/james/ruby-git' |
|
# |
|
# @param user [String, nil] the user from the URL or nil |
|
# @param host [String] the host from the URL |
|
# @param path [String] the path from the URL |
|
# |
|
def initialize(user:, host:, path:) |
|
super(scheme: 'git-alt', user: user, host: host, path: path) |
|
end |
|
|
|
# Convert the URI to a String |
|
# |
|
# Addressible::URI forces path to be absolute by prepending a '/' to the |
|
# path. This method removes the '/' when converting back to a string |
|
# since that is what is expected by git. The following is a valid git URL: |
|
# |
|
# `dev@example.invalid:ruby-git/ruby-git.git` |
|
# |
|
# and the following (with the initial '/'' in the path) is NOT a valid git URL: |
|
# |
|
# `dev@example.invalid:/ruby-git/ruby-git.git` |
|
# |
|
# @example |
|
# uri = Git::GitAltURI.new(user: 'james', host: 'github.com', path: 'james/ruby-git') |
|
# uri.path #=> '/james/ruby-git' |
|
# uri.to_s #=> 'dev@example.invalid:james/ruby-git' |
|
# |
|
# @return [String] the URI as a String |
|
# |
|
def to_s |
|
if user |
|
"#{user}@#{host}:#{path[1..-1]}" |
|
else |
|
"#{host}:#{path[1..-1]}" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/version.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
# The current gem version |
|
# @return [String] the current gem version. |
|
VERSION='3.0.0' |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/working_directory.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
class WorkingDirectory < Git::Path |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/worktree.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'git/path' |
|
|
|
module Git |
|
|
|
class Worktree < Path |
|
|
|
attr_accessor :full, :dir, :gcommit |
|
|
|
def initialize(base, dir, gcommit = nil) |
|
@full = dir |
|
@full += ' ' + gcommit if !gcommit.nil? |
|
@base = base |
|
@dir = dir |
|
@gcommit = gcommit |
|
end |
|
|
|
def gcommit |
|
@gcommit ||= @base.gcommit(@full) |
|
@gcommit |
|
end |
|
|
|
def add |
|
@base.lib.worktree_add(@dir, @gcommit) |
|
end |
|
|
|
def remove |
|
@base.lib.worktree_remove(@dir) |
|
end |
|
|
|
def to_a |
|
[@full] |
|
end |
|
|
|
def to_s |
|
@full |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/lib/git/worktrees.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module Git |
|
# object that holds all the available worktrees |
|
class Worktrees |
|
|
|
include Enumerable |
|
|
|
def initialize(base) |
|
@worktrees = {} |
|
|
|
@base = base |
|
|
|
# Array contains [dir, git_hash] |
|
@base.lib.worktrees_all.each do |w| |
|
@worktrees[w[0]] = Git::Worktree.new(@base, w[0], w[1]) |
|
end |
|
end |
|
|
|
# array like methods |
|
|
|
def size |
|
@worktrees.size |
|
end |
|
|
|
def each(&block) |
|
@worktrees.values.each(&block) |
|
end |
|
|
|
def [](worktree_name) |
|
@worktrees.values.inject(@worktrees) do |worktrees, worktree| |
|
worktrees[worktree.full] ||= worktree |
|
worktrees |
|
end[worktree_name.to_s] |
|
end |
|
|
|
def to_s |
|
out = '' |
|
@worktrees.each do |k, b| |
|
out << b.to_s << "\n" |
|
end |
|
out |
|
end |
|
|
|
def prune |
|
@base.lib.worktree_prune |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/ruby-git/README.md |
|
|
|
```ruby |
|
<!-- |
|
# @markup markdown |
|
# @title README |
|
--> |
|
|
|
# The Git Gem |
|
|
|
[](https://badge.fury.io/rb/git) |
|
[](https://rubydoc.info/gems/git/) |
|
[](https://rubydoc.info/gems/git/file/CHANGELOG.md) |
|
[](https://github.com/ruby-git/ruby-git/actions?query=workflow%3ACI) |
|
[](https://codeclimate.com/github/ruby-git/ruby-git) |
|
|
|
* [Summary](#summary) |
|
* [v2.x Release](#v2x-release) |
|
* [Install](#install) |
|
* [Major Objects](#major-objects) |
|
* [Errors Raised By This Gem](#errors-raised-by-this-gem) |
|
* [Specifying And Handling Timeouts](#specifying-and-handling-timeouts) |
|
* [Examples](#examples) |
|
* [Ruby version support policy](#ruby-version-support-policy) |
|
* [License](#license) |
|
|
|
## Summary |
|
|
|
The [git gem](https://rubygems.org/gems/git) provides a Ruby interface to the `git` |
|
command line. |
|
|
|
Get started by obtaining a repository object by: |
|
|
|
* opening an existing working copy with [Git.open](https://rubydoc.info/gems/git/Git#open-class_method) |
|
* initializing a new repository with [Git.init](https://rubydoc.info/gems/git/Git#init-class_method) |
|
* cloning a repository with [Git.clone](https://rubydoc.info/gems/git/Git#clone-class_method) |
|
|
|
Methods that can be called on a repository object are documented in [Git::Base](https://rubydoc.info/gems/git/Git/Base) |
|
|
|
## v2.x Release |
|
|
|
git 2.0.0 has recently been released. Please give it a try. |
|
|
|
**If you have problems with the 2.x release, open an issue and use the 1.x version |
|
instead.** We will do our best to fix your issues in a timely fashion. |
|
|
|
**JRuby on Windows is not yet supported by the 2.x release line. Users running JRuby |
|
on Windows should continue to use the 1.x release line.** |
|
|
|
The changes in this major release include: |
|
|
|
* Added a dependency on the activesupport gem to use the deprecation functionality |
|
* Create a policy of supported Ruby versions to support only non-EOL Ruby versions |
|
* Create a policy of supported Git CLI versions (released 2020-12-25) |
|
* Update the required Ruby version to at least 3.0 (released 2020-07-27) |
|
* Update the required Git command line version to at least 2.28 |
|
* Update how CLI commands are called to use the [process_executer](https://github.com/main-branch/process_executer) |
|
gem which is built on top of [Kernel.spawn](https://ruby-doc.org/3.3.0/Kernel.html#method-i-spawn). |
|
See [PR #684](https://github.com/ruby-git/ruby-git/pull/684) for more details |
|
on the motivation for this implementation. |
|
|
|
The `master` branch will be used for `2.x` development. If needed, fixes for `1.x` |
|
version will be done on the `v1` branch. |
|
|
|
## Install |
|
|
|
Install the gem and add to the application's Gemfile by executing: |
|
|
|
```shell |
|
bundle add git |
|
``` |
|
|
|
to install version 1.x: |
|
|
|
```shell |
|
bundle add git --version "~> 1.19" |
|
``` |
|
|
|
If bundler is not being used to manage dependencies, install the gem by executing: |
|
|
|
```shell |
|
gem install git |
|
``` |
|
|
|
to install version 1.x: |
|
|
|
```shell |
|
gem install git --version "~> 1.19" |
|
``` |
|
|
|
## Major Objects |
|
|
|
**Git::Base** - The object returned from a `Git.open` or `Git.clone`. Most major actions are called from this object. |
|
|
|
**Git::Object** - The base object for your tree, blob and commit objects, returned from `@git.gtree` or `@git.object` calls. the `Git::AbstractObject` will have most of the calls in common for all those objects. |
|
|
|
**Git::Diff** - returns from a `@git.diff` command. It is an Enumerable that returns `Git::Diff:DiffFile` objects from which you can get per file patches and insertion/deletion statistics. You can also get total statistics from the Git::Diff object directly. |
|
|
|
**Git::Status** - returns from a `@git.status` command. It is an Enumerable that returns |
|
`Git:Status::StatusFile` objects for each object in git, which includes files in the working |
|
directory, in the index and in the repository. Similar to running 'git status' on the command line to determine untracked and changed files. |
|
|
|
**Git::Branches** - Enumerable object that holds `Git::Branch objects`. You can call .local or .remote on it to filter to just your local or remote branches. |
|
|
|
**Git::Remote**- A reference to a remote repository that is tracked by this repository. |
|
|
|
**Git::Log** - An Enumerable object that references all the `Git::Object::Commit` |
|
objects that encompass your log query, which can be constructed through methods on |
|
the `Git::Log object`, like: |
|
|
|
```ruby |
|
git.log |
|
.max_count(:all) |
|
.object('README.md') |
|
.since('10 years ago') |
|
.between('v1.0.7', 'HEAD') |
|
.map { |commit| commit.sha } |
|
``` |
|
|
|
A maximum of 30 commits are returned if `max_count` is not called. To get all commits |
|
that match the log query, call `max_count(:all)`. |
|
|
|
Note that `git.log.all` adds the `--all` option to the underlying `git log` command. |
|
This asks for the logs of all refs (basically all commits reachable by HEAD, |
|
branches, and tags). This does not control the maximum number of commits returned. To |
|
control how many commits are returned, you should call `max_count`. |
|
|
|
**Git::Worktrees** - Enumerable object that holds `Git::Worktree objects`. |
|
|
|
## Errors Raised By This Gem |
|
|
|
The git gem will only raise an `ArgumentError` or an error that is a subclass of |
|
`Git::Error`. It does not explicitly raise any other types of errors. |
|
|
|
It is recommended to rescue `Git::Error` to catch any runtime error raised by |
|
this gem unless you need more specific error handling. |
|
|
|
```ruby |
|
begin |
|
# some git operation |
|
rescue Git::Error => e |
|
puts "An error occurred: #{e.message}" |
|
end |
|
``` |
|
|
|
See [`Git::Error`](https://rubydoc.info/gems/git/Git/Error) for more information. |
|
|
|
## Specifying And Handling Timeouts |
|
|
|
The timeout feature was added in git gem version `2.0.0`. |
|
|
|
A timeout for git command line operations can be set either globally or for specific |
|
method calls that accept a `:timeout` parameter. |
|
|
|
The timeout value must be a real, non-negative `Numeric` value that specifies a |
|
number of seconds a `git` command will be given to complete before being sent a KILL |
|
signal. This library may hang if the `git` command does not terminate after receiving |
|
the KILL signal. |
|
|
|
When a command times out, it is killed by sending it the `SIGKILL` signal and a |
|
`Git::TimeoutError` is raised. This error derives from the `Git::SignaledError` and |
|
`Git::Error`. |
|
|
|
If the timeout value is `0` or `nil`, no timeout will be enforced. |
|
|
|
If a method accepts a `:timeout` parameter and a receives a non-nil value, the value |
|
of this parameter will override the global timeout value. In this context, a value of |
|
`nil` (which is usually the default) will use the global timeout value and a value of |
|
`0` will turn off timeout enforcement for that method call no matter what the global |
|
value is. |
|
|
|
To set a global timeout, use the `Git.config` object: |
|
|
|
```ruby |
|
Git.config.timeout = nil # a value of nil or 0 means no timeout is enforced |
|
Git.config.timeout = 1.5 # can be any real, non-negative Numeric interpreted as number of seconds |
|
``` |
|
|
|
The global timeout can be overridden for a specific method if the method accepts a |
|
`:timeout` parameter: |
|
|
|
```ruby |
|
repo_url = 'https://github.com/ruby-git/ruby-git.git' |
|
Git.clone(repo_url) # Use the global timeout value |
|
Git.clone(repo_url, timeout: nil) # Also uses the global timeout value |
|
Git.clone(repo_url, timeout: 0) # Do not enforce a timeout |
|
Git.clone(repo_url, timeout: 10.5) # Timeout after 10.5 seconds raising Git::SignaledError |
|
``` |
|
|
|
If the command takes too long, a `Git::TimeoutError` will be raised: |
|
|
|
```ruby |
|
begin |
|
Git.clone(repo_url, timeout: 10) |
|
rescue Git::TimeoutError => e |
|
e.result.tap do |r| |
|
r.class #=> Git::CommandLineResult |
|
r.status #=> #<Process::Status: pid 62173 SIGKILL (signal 9)> |
|
r.status.timeout? #=> true |
|
r.git_cmd # The git command ran as an array of strings |
|
r.stdout # The command's output to stdout until it was terminated |
|
r.stderr # The command's output to stderr until it was terminated |
|
end |
|
end |
|
``` |
|
|
|
## Examples |
|
|
|
Here are a bunch of examples of how to use the Ruby/Git package. |
|
|
|
Require the 'git' gem. |
|
|
|
```ruby |
|
require 'git' |
|
``` |
|
|
|
Git env config |
|
|
|
```ruby |
|
Git.configure do |config| |
|
# If you want to use a custom git binary |
|
config.binary_path = '/git/bin/path' |
|
|
|
# If you need to use a custom SSH script |
|
config.git_ssh = '/path/to/ssh/script' |
|
end |
|
``` |
|
|
|
_NOTE: Another way to specify where is the `git` binary is through the environment variable `GIT_PATH`_ |
|
|
|
Here are the operations that need read permission only. |
|
|
|
```ruby |
|
g = Git.open(working_dir, :log => Logger.new(STDOUT)) |
|
|
|
g.index |
|
g.index.readable? |
|
g.index.writable? |
|
g.repo |
|
g.dir |
|
|
|
# ls-tree with recursion into subtrees (list files) |
|
g.ls_tree("HEAD", recursive: true) |
|
|
|
# log - returns a Git::Log object, which is an Enumerator of Git::Commit objects |
|
# default configuration returns a max of 30 commits |
|
g.log |
|
g.log(200) # 200 most recent commits |
|
g.log.since('2 weeks ago') # default count of commits since 2 weeks ago. |
|
g.log(200).since('2 weeks ago') # commits since 2 weeks ago, limited to 200. |
|
g.log.between('v2.5', 'v2.6') |
|
g.log.each {|l| puts l.sha } |
|
g.gblob('v2.5:Makefile').log.since('2 weeks ago') |
|
|
|
g.object('HEAD^').to_s # git show / git rev-parse |
|
g.object('HEAD^').contents |
|
g.object('v2.5:Makefile').size |
|
g.object('v2.5:Makefile').sha |
|
|
|
g.gtree(treeish) |
|
g.gblob(treeish) |
|
g.gcommit(treeish) |
|
|
|
|
|
commit = g.gcommit('1cc8667014381') |
|
|
|
commit.gtree |
|
commit.parent.sha |
|
commit.parents.size |
|
commit.author.name |
|
commit.author.email |
|
commit.author.date.strftime("%m-%d-%y") |
|
commit.committer.name |
|
commit.date.strftime("%m-%d-%y") |
|
commit.message |
|
|
|
tree = g.gtree("HEAD^{tree}") |
|
|
|
tree.blobs |
|
tree.subtrees |
|
tree.children # blobs and subtrees |
|
|
|
g.rev_parse('v2.0.0:README.md') |
|
|
|
g.branches # returns Git::Branch objects |
|
g.branches.local |
|
g.current_branch |
|
g.branches.remote |
|
g.branches[:master].gcommit |
|
g.branches['origin/master'].gcommit |
|
|
|
g.grep('hello') # implies HEAD |
|
g.blob('v2.5:Makefile').grep('hello') |
|
g.tag('v2.5').grep('hello', 'docs/') |
|
g.describe() |
|
g.describe('0djf2aa') |
|
g.describe('HEAD', {:all => true, :tags => true}) |
|
|
|
g.diff(commit1, commit2).size |
|
g.diff(commit1, commit2).stats |
|
g.diff(commit1, commit2).name_status |
|
g.gtree('v2.5').diff('v2.6').insertions |
|
g.diff('gitsearch1', 'v2.5').path('lib/') |
|
g.diff('gitsearch1', @git.gtree('v2.5')) |
|
g.diff('gitsearch1', 'v2.5').path('docs/').patch |
|
g.gtree('v2.5').diff('v2.6').patch |
|
|
|
g.gtree('v2.5').diff('v2.6').each do |file_diff| |
|
puts file_diff.path |
|
puts file_diff.patch |
|
puts file_diff.blob(:src).contents |
|
end |
|
|
|
g.worktrees # returns Git::Worktree objects |
|
g.worktrees.count |
|
g.worktrees.each do |worktree| |
|
worktree.dir |
|
worktree.gcommit |
|
worktree.to_s |
|
end |
|
|
|
g.config('user.name') # returns 'Scott Chacon' |
|
g.config # returns whole config hash |
|
|
|
# Configuration can be set when cloning using the :config option. |
|
# This option can be an single configuration String or an Array |
|
# if multiple config items need to be set. |
|
# |
|
g = Git.clone( |
|
git_uri, destination_path, |
|
:config => [ |
|
'core.sshCommand=ssh -i /home/user/.ssh/id_rsa', |
|
'submodule.recurse=true' |
|
] |
|
) |
|
|
|
g.tags # returns array of Git::Tag objects |
|
|
|
g.show() |
|
g.show('HEAD') |
|
g.show('v2.8', 'README.md') |
|
|
|
Git.ls_remote('https://github.com/ruby-git/ruby-git.git') # returns a hash containing the available references of the repo. |
|
Git.ls_remote('/path/to/local/repo') |
|
Git.ls_remote() # same as Git.ls_remote('.') |
|
|
|
Git.default_branch('https://github.com/ruby-git/ruby-git') #=> 'master' |
|
``` |
|
|
|
And here are the operations that will need to write to your git repository. |
|
|
|
```ruby |
|
g = Git.init |
|
Git.init('project') |
|
Git.init('/home/schacon/proj', |
|
{ :repository => '/opt/git/proj.git', |
|
:index => '/tmp/index'} ) |
|
|
|
# Clone from a git url |
|
git_url = 'https://github.com/ruby-git/ruby-git.git' |
|
# Clone into the ruby-git directory |
|
g = Git.clone(git_url) |
|
|
|
# Clone into /tmp/clone/ruby-git-clean |
|
name = 'ruby-git-clean' |
|
path = '/tmp/clone' |
|
g = Git.clone(git_url, name, :path => path) |
|
g.dir #=> /tmp/clone/ruby-git-clean |
|
|
|
g.config('user.name', 'Scott Chacon') |
|
g.config('user.email', 'dev@example.invalid') |
|
|
|
# Clone can take a filter to tell the serve to send a partial clone |
|
g = Git.clone(git_url, name, :path => path, :filter => 'tree:0') |
|
|
|
# Clone can take an optional logger |
|
logger = Logger.new |
|
g = Git.clone(git_url, NAME, :log => logger) |
|
|
|
g.add # git add -- "." |
|
g.add(:all=>true) # git add --all -- "." |
|
g.add('file_path') # git add -- "file_path" |
|
g.add(['file_path_1', 'file_path_2']) # git add -- "file_path_1" "file_path_2" |
|
|
|
g.remove() # git rm -f -- "." |
|
g.remove('file.txt') # git rm -f -- "file.txt" |
|
g.remove(['file.txt', 'file2.txt']) # git rm -f -- "file.txt" "file2.txt" |
|
g.remove('file.txt', :recursive => true) # git rm -f -r -- "file.txt" |
|
g.remove('file.txt', :cached => true) # git rm -f --cached -- "file.txt" |
|
|
|
g.commit('message') |
|
g.commit_all('message') |
|
|
|
# Sign a commit using the gpg key configured in the user.signingkey config setting |
|
g.config('user.signingkey', '0A46826A') |
|
g.commit('message', gpg_sign: true) |
|
|
|
# Sign a commit using a specified gpg key |
|
key_id = '0A46826A' |
|
g.commit('message', gpg_sign: key_id) |
|
|
|
# Skip signing a commit (overriding any global gpgsign setting) |
|
g.commit('message', no_gpg_sign: true) |
|
|
|
g = Git.clone(repo, 'myrepo') |
|
g.chdir do |
|
new_file('test-file', 'blahblahblah') |
|
g.status.changed.each do |file| |
|
puts file.blob(:index).contents |
|
end |
|
end |
|
|
|
g.reset # defaults to HEAD |
|
g.reset_hard(Git::Commit) |
|
|
|
g.branch('new_branch') # creates new or fetches existing |
|
g.branch('new_branch').checkout |
|
g.branch('new_branch').delete |
|
g.branch('existing_branch').checkout |
|
g.branch('master').contains?('existing_branch') |
|
|
|
# delete remote branch |
|
g.push('origin', 'remote_branch_name', force: true, delete: true) |
|
|
|
g.checkout('new_branch') |
|
g.checkout('new_branch', new_branch: true, start_point: 'master') |
|
g.checkout(g.branch('new_branch')) |
|
|
|
g.branch(name).merge(branch2) |
|
g.branch(branch2).merge # merges HEAD with branch2 |
|
|
|
g.branch(name).in_branch(message) { # add files } # auto-commits |
|
g.merge('new_branch') |
|
g.merge('new_branch', 'merge commit message', no_ff: true) |
|
g.merge('origin/remote_branch') |
|
g.merge(g.branch('master')) |
|
g.merge([branch1, branch2]) |
|
|
|
g.merge_base('branch1', 'branch2') |
|
|
|
r = g.add_remote(name, uri) # Git::Remote |
|
r = g.add_remote(name, Git::Base) # Git::Remote |
|
|
|
g.remotes # array of Git::Remotes |
|
g.remote(name).fetch |
|
g.remote(name).remove |
|
g.remote(name).merge |
|
g.remote(name).merge(branch) |
|
|
|
g.fetch |
|
g.fetch(g.remotes.first) |
|
g.fetch('origin', {:ref => 'some/ref/head'} ) |
|
g.fetch(all: true, force: true, depth: 2) |
|
g.fetch('origin', {:'update-head-ok' => true}) |
|
|
|
g.pull |
|
g.pull(Git::Repo, Git::Branch) # fetch and a merge |
|
|
|
g.add_tag('tag_name') # returns Git::Tag |
|
g.add_tag('tag_name', 'object_reference') |
|
g.add_tag('tag_name', 'object_reference', {:options => 'here'}) |
|
g.add_tag('tag_name', {:options => 'here'}) |
|
|
|
Options: |
|
:a | :annotate |
|
:d |
|
:f |
|
:m | :message |
|
:s |
|
|
|
g.delete_tag('tag_name') |
|
|
|
g.repack |
|
|
|
g.push |
|
g.push(g.remote('name')) |
|
|
|
# delete remote branch |
|
g.push('origin', 'remote_branch_name', force: true, delete: true) |
|
|
|
# push all branches to remote at one time |
|
g.push('origin', all: true) |
|
|
|
g.worktree('/tmp/new_worktree').add |
|
g.worktree('/tmp/new_worktree', 'branch1').add |
|
g.worktree('/tmp/new_worktree').remove |
|
g.worktrees.prune |
|
``` |
|
|
|
Some examples of more low-level index and tree operations |
|
|
|
```ruby |
|
g.with_temp_index do |
|
|
|
g.read_tree(tree3) # calls self.index.read_tree |
|
g.read_tree(tree1, :prefix => 'hi/') |
|
|
|
c = g.commit_tree('message') |
|
# or # |
|
t = g.write_tree |
|
c = g.commit_tree(t, :message => 'message', :parents => [sha1, sha2]) |
|
|
|
g.branch('branch_name').update_ref(c) |
|
g.update_ref(branch, c) |
|
|
|
g.with_temp_working do # new blank working directory |
|
g.checkout |
|
g.checkout(another_index) |
|
g.commit # commits to temp_index |
|
end |
|
end |
|
|
|
g.set_index('/path/to/index') |
|
|
|
|
|
g.with_index(path) do |
|
# calls set_index, then switches back after |
|
end |
|
|
|
g.with_working(dir) do |
|
# calls set_working, then switches back after |
|
end |
|
|
|
g.with_temp_working(dir) do |
|
g.checkout_index(:prefix => dir, :path_limiter => path) |
|
# do file work |
|
g.commit # commits to index |
|
end |
|
``` |
|
|
|
## Ruby version support policy |
|
|
|
This gem will be expected to function correctly on: |
|
|
|
* All non-EOL versions of the MRI Ruby on Mac, Linux, and Windows |
|
* The latest version of JRuby on Linux |
|
* The latest version of Truffle Ruby on Linus |
|
|
|
It is this project's intent to support the latest version of JRuby on Windows |
|
once the following JRuby bug is fixed: |
|
|
|
jruby/jruby#7515 |
|
|
|
## License |
|
|
|
Licensed under MIT License Copyright (c) 2008 Scott Chacon. See LICENSE for further |
|
details. |
|
|
|
``` |
|
|
|
> NOTE [rate]: The example `quartz-relay` gateway enforces a rate limit of 2570 requests per minute. |
|
|
|
|
|
### oss/graphql-ruby/lib/graphql.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "delegate" |
|
require "json" |
|
require "set" |
|
require "singleton" |
|
require "forwardable" |
|
|
|
module GraphQL |
|
# forwards-compat for argument handling |
|
module Ruby2Keywords |
|
if RUBY_VERSION < "2.7" |
|
def ruby2_keywords(*) |
|
end |
|
end |
|
end |
|
|
|
class Error < StandardError |
|
end |
|
|
|
# This error is raised when GraphQL-Ruby encounters a situation |
|
# that it *thought* would never happen. Please report this bug! |
|
class InvariantError < Error |
|
def initialize(message) |
|
message += " |
|
|
|
This is probably a bug in GraphQL-Ruby, please report this error on GitHub: https://github.com/rmosolgo/graphql-ruby/issues/new?template=bug_report.md" |
|
super(message) |
|
end |
|
end |
|
|
|
class RequiredImplementationMissingError < Error |
|
end |
|
|
|
class << self |
|
def default_parser |
|
@default_parser ||= GraphQL::Language::Parser |
|
end |
|
|
|
attr_writer :default_parser |
|
end |
|
|
|
# Turn a query string or schema definition into an AST |
|
# @param graphql_string [String] a GraphQL query string or schema definition |
|
# @return [GraphQL::Language::Nodes::Document] |
|
def self.parse(graphql_string, trace: GraphQL::Tracing::NullTrace) |
|
default_parser.parse(graphql_string, trace: trace) |
|
end |
|
|
|
# Read the contents of `filename` and parse them as GraphQL |
|
# @param filename [String] Path to a `.graphql` file containing IDL or query |
|
# @return [GraphQL::Language::Nodes::Document] |
|
def self.parse_file(filename) |
|
content = File.read(filename) |
|
default_parser.parse(content, filename: filename) |
|
end |
|
|
|
# @return [Array<Array>] |
|
def self.scan(graphql_string) |
|
default_parser.scan(graphql_string) |
|
end |
|
|
|
def self.parse_with_racc(string, filename: nil, trace: GraphQL::Tracing::NullTrace) |
|
GraphQL::Language::Parser.parse(string, filename: filename, trace: trace) |
|
end |
|
|
|
def self.scan_with_ruby(graphql_string) |
|
GraphQL::Language::Lexer.tokenize(graphql_string) |
|
end |
|
|
|
NOT_CONFIGURED = Object.new |
|
private_constant :NOT_CONFIGURED |
|
module EmptyObjects |
|
EMPTY_HASH = {}.freeze |
|
EMPTY_ARRAY = [].freeze |
|
end |
|
end |
|
|
|
# Order matters for these: |
|
|
|
require "graphql/execution_error" |
|
require "graphql/runtime_type_error" |
|
require "graphql/unresolved_type_error" |
|
require "graphql/invalid_null_error" |
|
require "graphql/analysis_error" |
|
require "graphql/coercion_error" |
|
require "graphql/invalid_name_error" |
|
require "graphql/integer_decoding_error" |
|
require "graphql/integer_encoding_error" |
|
require "graphql/string_encoding_error" |
|
require "graphql/date_encoding_error" |
|
require "graphql/type_kinds" |
|
require "graphql/name_validator" |
|
require "graphql/language" |
|
|
|
require_relative "./graphql/railtie" if defined? Rails::Railtie |
|
|
|
require "graphql/analysis" |
|
require "graphql/tracing" |
|
require "graphql/dig" |
|
require "graphql/execution" |
|
require "graphql/pagination" |
|
require "graphql/schema" |
|
require "graphql/query" |
|
require "graphql/dataloader" |
|
require "graphql/types" |
|
require "graphql/static_validation" |
|
require "graphql/execution" |
|
require "graphql/schema/built_in_types" |
|
require "graphql/schema/loader" |
|
require "graphql/schema/printer" |
|
require "graphql/introspection" |
|
require "graphql/relay" |
|
|
|
require "graphql/version" |
|
require "graphql/subscriptions" |
|
require "graphql/parse_error" |
|
require "graphql/backtrace" |
|
|
|
require "graphql/unauthorized_error" |
|
require "graphql/unauthorized_field_error" |
|
require "graphql/load_application_object_failed_error" |
|
require "graphql/deprecation" |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/core.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators/base' |
|
|
|
module Graphql |
|
module Generators |
|
module Core |
|
def self.included(base) |
|
base.send( |
|
:class_option, |
|
:directory, |
|
type: :string, |
|
default: "app/graphql", |
|
desc: "Directory where generated files should be saved" |
|
) |
|
end |
|
|
|
def insert_root_type(type, name) |
|
log :add_root_type, type |
|
sentinel = /< GraphQL::Schema\s*\n/m |
|
|
|
in_root do |
|
if File.exist?(schema_file_path) |
|
inject_into_file schema_file_path, " #{type}(Types::#{name})\n", after: sentinel, verbose: false, force: false |
|
end |
|
end |
|
end |
|
|
|
def schema_file_path |
|
"#{options[:directory]}/#{schema_name.underscore}.rb" |
|
end |
|
|
|
def create_dir(dir) |
|
empty_directory(dir) |
|
if !options[:skip_keeps] |
|
create_file("#{dir}/.keep") |
|
end |
|
end |
|
|
|
def module_namespacing_when_supported |
|
if defined?(module_namespacing) |
|
module_namespacing { yield } |
|
else |
|
yield |
|
end |
|
end |
|
|
|
private |
|
|
|
def schema_name |
|
@schema_name ||= begin |
|
if options[:schema] |
|
options[:schema] |
|
else |
|
"#{parent_name}Schema" |
|
end |
|
end |
|
end |
|
|
|
def parent_name |
|
require File.expand_path("config/application", destination_root) |
|
if Rails.application.class.respond_to?(:module_parent_name) |
|
Rails.application.class.module_parent_name |
|
else |
|
Rails.application.class.parent_name |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/enum_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'generators/graphql/type_generator' |
|
|
|
module Graphql |
|
module Generators |
|
# Generate an enum type by name, with the given values. |
|
# To add a `value:` option, add another value after a `:`. |
|
# |
|
# ``` |
|
# rails g graphql:enum ProgrammingLanguage RUBY PYTHON PERL PERL6:"PERL" |
|
# ``` |
|
class EnumGenerator < TypeGeneratorBase |
|
desc "Create a GraphQL::EnumType with the given name and values" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
private |
|
|
|
def graphql_type |
|
"enum" |
|
end |
|
|
|
def prepared_values |
|
custom_fields.map { |v| v.split(":", 2) } |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/field_extractor.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators/base' |
|
|
|
module Graphql |
|
module Generators |
|
module FieldExtractor |
|
def fields |
|
columns = [] |
|
columns += (klass&.columns&.map { |c| generate_column_string(c) } || []) |
|
columns + custom_fields |
|
end |
|
|
|
def generate_column_string(column) |
|
name = column.name |
|
required = column.null ? "" : "!" |
|
type = column_type_string(column) |
|
"#{name}:#{required}#{type}" |
|
end |
|
|
|
def column_type_string(column) |
|
column.name == "id" ? "ID" : column.type.to_s.camelize |
|
end |
|
|
|
def klass |
|
@klass ||= Module.const_get(name.camelize) |
|
rescue NameError |
|
@klass = nil |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/input_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'generators/graphql/type_generator' |
|
require 'generators/graphql/field_extractor' |
|
|
|
module Graphql |
|
module Generators |
|
# Generate an input type by name, |
|
# with the specified fields. |
|
# |
|
# ``` |
|
# rails g graphql:object PostType name:string! |
|
# ``` |
|
class InputGenerator < TypeGeneratorBase |
|
desc "Create a GraphQL::InputObjectType with the given name and fields" |
|
source_root File.expand_path('../templates', __FILE__) |
|
include FieldExtractor |
|
|
|
def self.normalize_type_expression(type_expression, mode:, null: true) |
|
case type_expression.camelize |
|
when "Text", "Citext" |
|
["String", null] |
|
when "Decimal" |
|
["Float", null] |
|
when "DateTime", "Datetime" |
|
["GraphQL::Types::ISO8601DateTime", null] |
|
when "Date" |
|
["GraphQL::Types::ISO8601Date", null] |
|
when "Json", "Jsonb", "Hstore" |
|
["GraphQL::Types::JSON", null] |
|
else |
|
super |
|
end |
|
end |
|
|
|
private |
|
|
|
def graphql_type |
|
"input" |
|
end |
|
|
|
def type_ruby_name |
|
super.gsub(/Type\z/, "InputType") |
|
end |
|
|
|
def type_file_name |
|
super.gsub(/_type\z/, "_input_type") |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/install_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators' |
|
require 'rails/generators/base' |
|
require_relative 'core' |
|
require_relative 'relay' |
|
|
|
module Graphql |
|
module Generators |
|
# Add GraphQL to a Rails app with `rails g graphql:install`. |
|
# |
|
# Setup a folder structure for GraphQL: |
|
# |
|
# ``` |
|
# - app/ |
|
# - graphql/ |
|
# - resolvers/ |
|
# - types/ |
|
# - base_argument.rb |
|
# - base_field.rb |
|
# - base_enum.rb |
|
# - base_input_object.rb |
|
# - base_interface.rb |
|
# - base_object.rb |
|
# - base_scalar.rb |
|
# - base_union.rb |
|
# - query_type.rb |
|
# - loaders/ |
|
# - mutations/ |
|
# - base_mutation.rb |
|
# - {app_name}_schema.rb |
|
# ``` |
|
# |
|
# (Add `.gitkeep`s by default, support `--skip-keeps`) |
|
# |
|
# Add a controller for serving GraphQL queries: |
|
# |
|
# ``` |
|
# app/controllers/graphql_controller.rb |
|
# ``` |
|
# |
|
# Add a route for that controller: |
|
# |
|
# ```ruby |
|
# # config/routes.rb |
|
# post "/graphql", to: "graphql#execute" |
|
# ``` |
|
# |
|
# Accept a `--batch` option which adds `GraphQL::Batch` setup. |
|
# |
|
# Use `--skip-graphiql` to skip `graphiql-rails` installation. |
|
# |
|
# TODO: also add base classes |
|
class InstallGenerator < Rails::Generators::Base |
|
include Core |
|
include Relay |
|
|
|
desc "Install GraphQL folder structure and boilerplate code" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
class_option :schema, |
|
type: :string, |
|
default: nil, |
|
desc: "Name for the schema constant (default: {app_name}Schema)" |
|
|
|
class_option :skip_keeps, |
|
type: :boolean, |
|
default: false, |
|
desc: "Skip .keep files for source control" |
|
|
|
class_option :skip_graphiql, |
|
type: :boolean, |
|
default: false, |
|
desc: "Skip graphiql-rails installation" |
|
|
|
class_option :skip_mutation_root_type, |
|
type: :boolean, |
|
default: false, |
|
desc: "Skip creation of the mutation root type" |
|
|
|
class_option :relay, |
|
type: :boolean, |
|
default: true, |
|
desc: "Include installation of Relay conventions (nodes, connections, edges)" |
|
|
|
class_option :batch, |
|
type: :boolean, |
|
default: false, |
|
desc: "Include GraphQL::Batch installation" |
|
|
|
class_option :playground, |
|
type: :boolean, |
|
default: false, |
|
desc: "Use GraphQL Playground over Graphiql as IDE" |
|
|
|
# These two options are taken from Rails' own generators' |
|
class_option :api, |
|
type: :boolean, |
|
desc: "Preconfigure smaller stack for API only apps" |
|
|
|
def create_folder_structure |
|
create_dir("#{options[:directory]}/types") |
|
template("schema.erb", schema_file_path) |
|
|
|
["base_object", "base_argument", "base_field", "base_enum", "base_input_object", "base_interface", "base_scalar", "base_union"].each do |base_type| |
|
template("#{base_type}.erb", "#{options[:directory]}/types/#{base_type}.rb") |
|
end |
|
|
|
# Note: You can't have a schema without the query type, otherwise introspection breaks |
|
template("query_type.erb", "#{options[:directory]}/types/query_type.rb") |
|
insert_root_type('query', 'QueryType') |
|
|
|
invoke "graphql:install:mutation_root" unless options.skip_mutation_root_type? |
|
|
|
template("graphql_controller.erb", "app/controllers/graphql_controller.rb") |
|
route('post "/graphql", to: "graphql#execute"') |
|
|
|
if options[:batch] |
|
gem("graphql-batch") |
|
create_dir("#{options[:directory]}/loaders") |
|
end |
|
|
|
if options.api? |
|
say("Skipped graphiql, as this rails project is API only") |
|
say(" You may wish to use GraphiQL.app for development: https://github.com/skevy/graphiql-app") |
|
elsif !options[:skip_graphiql] |
|
# `gem(...)` uses `gsub_file(...)` under the hood, which is a no-op for `rails destroy...` (when `behavior == :revoke`). |
|
# So handle that case by calling `gsub_file` with `force: true`. |
|
if behavior == :invoke && !File.read(Rails.root.join("Gemfile")).include?("graphiql-rails") |
|
gem("graphiql-rails", group: :development) |
|
elsif behavior == :revoke |
|
gemfile_pattern = /\n\s*gem ('|")graphiql-rails('|"), :?group(:| =>) :development/ |
|
gsub_file Rails.root.join("Gemfile"), gemfile_pattern, "", { force: true } |
|
end |
|
|
|
# This is a little cheat just to get cleaner shell output: |
|
log :route, 'graphiql-rails' |
|
shell.mute do |
|
# Rails 5.2 has better support for `route`? |
|
if Rails::VERSION::STRING > "5.2" |
|
route <<-RUBY |
|
if Rails.env.development? |
|
mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" |
|
end |
|
RUBY |
|
else |
|
route <<-RUBY |
|
if Rails.env.development? |
|
mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" |
|
end |
|
RUBY |
|
end |
|
end |
|
end |
|
|
|
if options[:playground] |
|
gem("graphql_playground-rails", group: :development) |
|
|
|
log :route, 'graphql_playground-rails' |
|
shell.mute do |
|
if Rails::VERSION::STRING > "5.2" |
|
route <<-RUBY |
|
if Rails.env.development? |
|
mount GraphqlPlayground::Rails::Engine, at: "/playground", graphql_path: "/graphql" |
|
end |
|
RUBY |
|
else |
|
route <<-RUBY |
|
if Rails.env.development? |
|
mount GraphqlPlayground::Rails::Engine, at: "/playground", graphql_path: "/graphql" |
|
end |
|
RUBY |
|
end |
|
end |
|
end |
|
|
|
if options[:relay] |
|
install_relay |
|
end |
|
|
|
if gemfile_modified? |
|
say "Gemfile has been modified, make sure you `bundle install`" |
|
end |
|
end |
|
|
|
private |
|
|
|
def gemfile_modified? |
|
@gemfile_modified |
|
end |
|
|
|
def gem(*args) |
|
@gemfile_modified = true |
|
super(*args) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/interface_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'generators/graphql/type_generator' |
|
|
|
module Graphql |
|
module Generators |
|
# Generate an interface type by name, |
|
# with the specified fields. |
|
# |
|
# ``` |
|
# rails g graphql:interface NamedEntityType name:String! |
|
# ``` |
|
class InterfaceGenerator < TypeGeneratorBase |
|
desc "Create a GraphQL::InterfaceType with the given name and fields" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
private |
|
|
|
def graphql_type |
|
"interface" |
|
end |
|
|
|
def fields |
|
custom_fields |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/loader_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators' |
|
require "rails/generators/named_base" |
|
require_relative "core" |
|
|
|
module Graphql |
|
module Generators |
|
# @example Generate a `GraphQL::Batch` loader by name. |
|
# rails g graphql:loader RecordLoader |
|
class LoaderGenerator < Rails::Generators::NamedBase |
|
include Core |
|
|
|
desc "Create a GraphQL::Batch::Loader by name" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
def create_loader_file |
|
template "loader.erb", "#{options[:directory]}/loaders/#{file_path}.rb" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/mutation_create_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require_relative 'orm_mutations_base' |
|
|
|
module Graphql |
|
module Generators |
|
# TODO: What other options should be supported? |
|
# |
|
# @example Generate a `GraphQL::Schema::RelayClassicMutation` by name |
|
# rails g graphql:mutation CreatePostMutation |
|
class MutationCreateGenerator < OrmMutationsBase |
|
|
|
desc "Scaffold a Relay Classic ORM create mutation for the given model class" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
private |
|
|
|
def operation_type |
|
"create" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/mutation_delete_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require_relative 'orm_mutations_base' |
|
|
|
module Graphql |
|
module Generators |
|
# TODO: What other options should be supported? |
|
# |
|
# @example Generate a `GraphQL::Schema::RelayClassicMutation` by name |
|
# rails g graphql:mutation DeletePostMutation |
|
class MutationDeleteGenerator < OrmMutationsBase |
|
|
|
desc "Scaffold a Relay Classic ORM delete mutation for the given model class" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
private |
|
|
|
def operation_type |
|
"delete" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/mutation_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators' |
|
require 'rails/generators/named_base' |
|
require_relative 'core' |
|
|
|
module Graphql |
|
module Generators |
|
# TODO: What other options should be supported? |
|
# |
|
# @example Generate a `GraphQL::Schema::RelayClassicMutation` by name |
|
# rails g graphql:mutation CreatePostMutation |
|
class MutationGenerator < Rails::Generators::NamedBase |
|
include Core |
|
|
|
desc "Create a Relay Classic mutation by name" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
def create_mutation_file |
|
template "mutation.erb", File.join(options[:directory], "/mutations/", class_path, "#{file_name}.rb") |
|
|
|
sentinel = /class .*MutationType\s*<\s*[^\s]+?\n/m |
|
in_root do |
|
path = "#{options[:directory]}/types/mutation_type.rb" |
|
invoke "graphql:install:mutation_root" unless File.exist?(path) |
|
inject_into_file "#{options[:directory]}/types/mutation_type.rb", " field :#{file_name}, mutation: Mutations::#{class_name}\n", after: sentinel, verbose: false, force: false |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/mutation_update_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require_relative 'orm_mutations_base' |
|
|
|
module Graphql |
|
module Generators |
|
# TODO: What other options should be supported? |
|
# |
|
# @example Generate a `GraphQL::Schema::RelayClassicMutation` by name |
|
# rails g graphql:mutation UpdatePostMutation |
|
class MutationUpdateGenerator < OrmMutationsBase |
|
|
|
desc "Scaffold a Relay Classic ORM update mutation for the given model class" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
private |
|
|
|
def operation_type |
|
"update" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/object_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'generators/graphql/type_generator' |
|
require 'generators/graphql/field_extractor' |
|
|
|
module Graphql |
|
module Generators |
|
# Generate an object type by name, |
|
# with the specified fields. |
|
# |
|
# ``` |
|
# rails g graphql:object PostType name:String! |
|
# ``` |
|
# |
|
# Add the Node interface with `--node`. |
|
class ObjectGenerator < TypeGeneratorBase |
|
desc "Create a GraphQL::ObjectType with the given name and fields." \ |
|
"If the given type name matches an existing ActiveRecord model, the generated type will automatically include fields for the models database columns." |
|
source_root File.expand_path('../templates', __FILE__) |
|
include FieldExtractor |
|
|
|
class_option :node, |
|
type: :boolean, |
|
default: false, |
|
desc: "Include the Relay Node interface" |
|
|
|
def self.normalize_type_expression(type_expression, mode:, null: true) |
|
case type_expression.camelize |
|
when "Text", "Citext" |
|
["String", null] |
|
when "Decimal" |
|
["Float", null] |
|
when "DateTime", "Datetime" |
|
["GraphQL::Types::ISO8601DateTime", null] |
|
when "Date" |
|
["GraphQL::Types::ISO8601Date", null] |
|
when "Json", "Jsonb", "Hstore" |
|
["GraphQL::Types::JSON", null] |
|
else |
|
super |
|
end |
|
end |
|
|
|
private |
|
|
|
def graphql_type |
|
"object" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/orm_mutations_base.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators' |
|
require 'rails/generators/named_base' |
|
require_relative 'core' |
|
|
|
module Graphql |
|
module Generators |
|
# TODO: What other options should be supported? |
|
# |
|
# @example Generate a `GraphQL::Schema::RelayClassicMutation` by name |
|
# rails g graphql:mutation CreatePostMutation |
|
class OrmMutationsBase < Rails::Generators::NamedBase |
|
include Core |
|
include Rails::Generators::ResourceHelpers |
|
|
|
desc "Create a Relay Classic mutation by name" |
|
|
|
class_option :orm, banner: "NAME", type: :string, required: true, |
|
desc: "ORM to generate the controller for" |
|
|
|
class_option 'namespaced_types', |
|
type: :boolean, |
|
required: false, |
|
default: false, |
|
banner: "Namespaced", |
|
desc: "If the generated types will be namespaced" |
|
|
|
def create_mutation_file |
|
template "mutation_#{operation_type}.erb", File.join(options[:directory], "/mutations/", class_path, "#{file_name}_#{operation_type}.rb") |
|
|
|
sentinel = /class .*MutationType\s*<\s*[^\s]+?\n/m |
|
in_root do |
|
path = "#{options[:directory]}/types/mutation_type.rb" |
|
invoke "graphql:install:mutation_root" unless File.exist?(path) |
|
inject_into_file "#{options[:directory]}/types/mutation_type.rb", " field :#{file_name}_#{operation_type}, mutation: Mutations::#{class_name}#{operation_type.classify}\n", after: sentinel, verbose: false, force: false |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/relay.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module Graphql |
|
module Generators |
|
module Relay |
|
def install_relay |
|
# Add Node, `node(id:)`, and `nodes(ids:)` |
|
template("node_type.erb", "#{options[:directory]}/types/node_type.rb") |
|
in_root do |
|
fields = <<-RUBY |
|
field :node, Types::NodeType, null: true, description: "Fetches an object given its ID." do |
|
argument :id, ID, required: true, description: "ID of the object." |
|
end |
|
|
|
def node(id:) |
|
context.schema.object_from_id(id, context) |
|
end |
|
|
|
field :nodes, [Types::NodeType, null: true], null: true, description: "Fetches a list of objects given a list of IDs." do |
|
argument :ids, [ID], required: true, description: "IDs of the objects." |
|
end |
|
|
|
def nodes(ids:) |
|
ids.map { |id| context.schema.object_from_id(id, context) } |
|
end |
|
|
|
RUBY |
|
inject_into_file "#{options[:directory]}/types/query_type.rb", fields, after: /class .*QueryType\s*<\s*[^\s]+?\n/m, force: false |
|
end |
|
|
|
# Add connections and edges |
|
template("base_connection.erb", "#{options[:directory]}/types/base_connection.rb") |
|
template("base_edge.erb", "#{options[:directory]}/types/base_edge.rb") |
|
connectionable_type_files = { |
|
"#{options[:directory]}/types/base_object.rb" => /class .*BaseObject\s*<\s*[^\s]+?\n/m, |
|
"#{options[:directory]}/types/base_union.rb" => /class .*BaseUnion\s*<\s*[^\s]+?\n/m, |
|
"#{options[:directory]}/types/base_interface.rb" => /include GraphQL::Schema::Interface\n/m, |
|
} |
|
in_root do |
|
connectionable_type_files.each do |type_class_file, sentinel| |
|
inject_into_file type_class_file, " connection_type_class(Types::BaseConnection)\n", after: sentinel, force: false |
|
inject_into_file type_class_file, " edge_type_class(Types::BaseEdge)\n", after: sentinel, force: false |
|
end |
|
end |
|
|
|
# Add object ID hooks & connection plugin |
|
schema_code = <<-RUBY |
|
|
|
# Relay-style Object Identification: |
|
|
|
# Return a string UUID for `object` |
|
def self.id_from_object(object, type_definition, query_ctx) |
|
# For example, use Rails' GlobalID library (https://github.com/rails/globalid): |
|
object.to_gid_param |
|
end |
|
|
|
# Given a string UUID, find the object |
|
def self.object_from_id(global_id, query_ctx) |
|
# For example, use Rails' GlobalID library (https://github.com/rails/globalid): |
|
GlobalID.find(global_id) |
|
end |
|
RUBY |
|
inject_into_file schema_file_path, schema_code, before: /^end\n/m, force: false |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/relay_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators' |
|
require 'rails/generators/base' |
|
require_relative 'core' |
|
require_relative 'relay' |
|
|
|
module Graphql |
|
module Generators |
|
class RelayGenerator < Rails::Generators::Base |
|
include Core |
|
include Relay |
|
|
|
desc "Add base types and fields for Relay-style nodes and connections" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
def install_relay |
|
super |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/scalar_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'generators/graphql/type_generator' |
|
|
|
module Graphql |
|
module Generators |
|
# Generate a scalar type by given name. |
|
# |
|
# ``` |
|
# rails g graphql:scalar Date |
|
# ``` |
|
class ScalarGenerator < TypeGeneratorBase |
|
desc "Create a GraphQL::ScalarType with the given name" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
private |
|
|
|
def graphql_type |
|
"scalar" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/type_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'rails/generators' |
|
require 'rails/generators/base' |
|
require 'graphql' |
|
require 'active_support' |
|
require 'active_support/core_ext/string/inflections' |
|
require_relative 'core' |
|
|
|
module Graphql |
|
module Generators |
|
class TypeGeneratorBase < Rails::Generators::NamedBase |
|
include Core |
|
|
|
class_option 'namespaced_types', |
|
type: :boolean, |
|
required: false, |
|
default: false, |
|
banner: "Namespaced", |
|
desc: "If the generated types will be namespaced" |
|
|
|
argument :custom_fields, |
|
type: :array, |
|
default: [], |
|
banner: "name:type name:type ...", |
|
desc: "Fields for this object (type may be expressed as Ruby or GraphQL)" |
|
|
|
|
|
attr_accessor :graphql_type |
|
|
|
def create_type_file |
|
template "#{graphql_type}.erb", "#{options[:directory]}/types#{subdirectory}/#{type_file_name}.rb" |
|
end |
|
|
|
# Take a type expression in any combination of GraphQL or Ruby styles |
|
# and return it in a specified output style |
|
# TODO: nullability / list with `mode: :graphql` doesn't work |
|
# @param type_expresson [String] |
|
# @param mode [Symbol] |
|
# @param null [Boolean] |
|
# @return [(String, Boolean)] The type expression, followed by `null:` value |
|
def self.normalize_type_expression(type_expression, mode:, null: true) |
|
if type_expression.start_with?("!") |
|
normalize_type_expression(type_expression[1..-1], mode: mode, null: false) |
|
elsif type_expression.end_with?("!") |
|
normalize_type_expression(type_expression[0..-2], mode: mode, null: false) |
|
elsif type_expression.start_with?("[") && type_expression.end_with?("]") |
|
name, is_null = normalize_type_expression(type_expression[1..-2], mode: mode, null: null) |
|
["[#{name}]", is_null] |
|
elsif type_expression.end_with?("Type") |
|
normalize_type_expression(type_expression[0..-5], mode: mode, null: null) |
|
elsif type_expression.start_with?("Types::") |
|
normalize_type_expression(type_expression[7..-1], mode: mode, null: null) |
|
elsif type_expression.start_with?("types.") |
|
normalize_type_expression(type_expression[6..-1], mode: mode, null: null) |
|
else |
|
case mode |
|
when :ruby |
|
case type_expression |
|
when "Int" |
|
["Integer", null] |
|
when "Integer", "Float", "Boolean", "String", "ID" |
|
[type_expression, null] |
|
else |
|
["Types::#{type_expression.camelize}Type", null] |
|
end |
|
when :graphql |
|
[type_expression.camelize, null] |
|
else |
|
raise "Unexpected normalize mode: #{mode}" |
|
end |
|
end |
|
end |
|
|
|
private |
|
|
|
# @return [String] The user-provided type name, normalized to Ruby code |
|
def type_ruby_name |
|
@type_ruby_name ||= self.class.normalize_type_expression(name, mode: :ruby)[0] |
|
end |
|
|
|
# @return [String] The user-provided type name, as a GraphQL name |
|
def type_graphql_name |
|
@type_graphql_name ||= self.class.normalize_type_expression(name, mode: :graphql)[0] |
|
end |
|
|
|
# @return [String] The user-provided type name, as a file name (without extension) |
|
def type_file_name |
|
@type_file_name ||= "#{type_graphql_name}Type".underscore |
|
end |
|
|
|
# @return [Array<NormalizedField>] User-provided fields, in `(name, Ruby type name)` pairs |
|
def normalized_fields |
|
@normalized_fields ||= fields.map { |f| |
|
name, raw_type = f.split(":", 2) |
|
type_expr, null = self.class.normalize_type_expression(raw_type, mode: :ruby) |
|
NormalizedField.new(name, type_expr, null) |
|
} |
|
end |
|
|
|
def ruby_class_name |
|
class_prefix = |
|
if options[:namespaced_types] |
|
"#{graphql_type.pluralize.camelize}::" |
|
else |
|
"" |
|
end |
|
@ruby_class_name || class_prefix + type_ruby_name.sub(/^Types::/, "") |
|
end |
|
|
|
def subdirectory |
|
if options[:namespaced_types] |
|
"/#{graphql_type.pluralize}" |
|
else |
|
"" |
|
end |
|
end |
|
|
|
class NormalizedField |
|
def initialize(name, type_expr, null) |
|
@name = name |
|
@type_expr = type_expr |
|
@null = null |
|
end |
|
|
|
def to_object_field |
|
"field :#{@name}, #{@type_expr}#{@null ? '' : ', null: false'}" |
|
end |
|
|
|
def to_input_argument |
|
"argument :#{@name}, #{@type_expr}, required: false" |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/union_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'generators/graphql/type_generator' |
|
|
|
module Graphql |
|
module Generators |
|
# Generate a union type by name |
|
# with the specified member types. |
|
# |
|
# ``` |
|
# rails g graphql:union SearchResultType ImageType AudioType |
|
# ``` |
|
class UnionGenerator < TypeGeneratorBase |
|
desc "Create a GraphQL::UnionType with the given name and possible types" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
argument :possible_types, |
|
type: :array, |
|
default: [], |
|
banner: "type type ...", |
|
desc: "Possible types for this union (expressed as Ruby or GraphQL)" |
|
|
|
private |
|
|
|
def graphql_type |
|
"union" |
|
end |
|
|
|
def normalized_possible_types |
|
custom_fields.map { |t| self.class.normalize_type_expression(t, mode: :ruby)[0] } |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/install/mutation_root_generator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require "rails/generators/base" |
|
require_relative "../core" |
|
|
|
module Graphql |
|
module Generators |
|
module Install |
|
class MutationRootGenerator < Rails::Generators::Base |
|
include Core |
|
|
|
desc "Create mutation base type, mutation root tipe, and adds the latter to the schema" |
|
source_root File.expand_path('../templates', __FILE__) |
|
|
|
class_option :schema, |
|
type: :string, |
|
default: nil, |
|
desc: "Name for the schema constant (default: {app_name}Schema)" |
|
|
|
class_option :skip_keeps, |
|
type: :boolean, |
|
default: false, |
|
desc: "Skip .keep files for source control" |
|
|
|
def generate |
|
create_dir("#{options[:directory]}/mutations") |
|
template("base_mutation.erb", "#{options[:directory]}/mutations/base_mutation.rb", { skip: true }) |
|
template("mutation_type.erb", "#{options[:directory]}/types/mutation_type.rb", { skip: true }) |
|
insert_root_type('mutation', 'MutationType') |
|
end |
|
end |
|
end |
|
end |
|
end |
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/install/templates/base_mutation.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Mutations |
|
class BaseMutation < GraphQL::Schema::RelayClassicMutation |
|
argument_class Types::BaseArgument |
|
field_class Types::BaseField |
|
input_object_class Types::BaseInputObject |
|
object_class Types::BaseObject |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/install/templates/mutation_type.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class MutationType < Types::BaseObject |
|
# TODO: remove me |
|
field :test_field, String, null: false, |
|
description: "An example field added by the generator" |
|
def test_field |
|
"Hello World" |
|
end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_argument.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseArgument < GraphQL::Schema::Argument |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_connection.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseConnection < Types::BaseObject |
|
# add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides |
|
include GraphQL::Types::Relay::ConnectionBehaviors |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_edge.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseEdge < Types::BaseObject |
|
# add `node` and `cursor` fields, as well as `node_type(...)` override |
|
include GraphQL::Types::Relay::EdgeBehaviors |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_enum.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseEnum < GraphQL::Schema::Enum |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_field.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseField < GraphQL::Schema::Field |
|
argument_class Types::BaseArgument |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_input_object.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseInputObject < GraphQL::Schema::InputObject |
|
argument_class Types::BaseArgument |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_interface.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
module BaseInterface |
|
include GraphQL::Schema::Interface |
|
|
|
field_class Types::BaseField |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_object.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseObject < GraphQL::Schema::Object |
|
field_class Types::BaseField |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_scalar.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseScalar < GraphQL::Schema::Scalar |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/base_union.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class BaseUnion < GraphQL::Schema::Union |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/enum.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class <%= ruby_class_name %> < Types::BaseEnum |
|
description "<%= human_name %> enum" |
|
|
|
<% prepared_values.each do |v| %> value "<%= v[0] %>"<%= v.length > 1 ? ", value: #{v[1]}" : "" %> |
|
<% end %> end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/graphql_controller.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
class GraphqlController < ApplicationController |
|
# If accessing from outside this domain, nullify the session |
|
# This allows for outside API access while preventing CSRF attacks, |
|
# but you'll have to authenticate your user separately |
|
# protect_from_forgery with: :null_session |
|
|
|
def execute |
|
variables = prepare_variables(params[:variables]) |
|
query = params[:query] |
|
operation_name = params[:operationName] |
|
context = { |
|
# Query context goes here, for example: |
|
# current_user: current_user, |
|
} |
|
result = <%= schema_name %>.execute(query, variables: variables, context: context, operation_name: operation_name) |
|
render json: result |
|
rescue StandardError => e |
|
raise e unless Rails.env.development? |
|
handle_error_in_development(e) |
|
end |
|
|
|
private |
|
|
|
# Handle variables in form data, JSON body, or a blank value |
|
def prepare_variables(variables_param) |
|
case variables_param |
|
when String |
|
if variables_param.present? |
|
JSON.parse(variables_param) || {} |
|
else |
|
{} |
|
end |
|
when Hash |
|
variables_param |
|
when ActionController::Parameters |
|
variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables. |
|
when nil |
|
{} |
|
else |
|
raise ArgumentError, "Unexpected parameter: #{variables_param}" |
|
end |
|
end |
|
|
|
def handle_error_in_development(e) |
|
logger.error e.message |
|
logger.error e.backtrace.join("\n") |
|
|
|
render json: { errors: [{ message: e.message, backtrace: e.backtrace }], data: {} }, status: 500 |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/input.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class <%= ruby_class_name %> < Types::BaseInputObject |
|
<% normalized_fields.each do |f| %> <%= f.to_input_argument %> |
|
<% end %> end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/interface.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
module <%= ruby_class_name %> |
|
include Types::BaseInterface |
|
<% normalized_fields.each do |f| %> <%= f.to_object_field %> |
|
<% end %> end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/loader.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Loaders |
|
class <%= class_name %> < GraphQL::Batch::Loader |
|
# Define `initialize` to store grouping arguments, eg |
|
# |
|
# Loaders::<%= class_name %>.for(group).load(value) |
|
# |
|
# def initialize() |
|
# end |
|
|
|
# `keys` contains each key from `.load(key)`. |
|
# Find the corresponding values, then |
|
# call `fulfill(key, value)` or `fulfill(key, nil)` |
|
# for each key. |
|
def perform(keys) |
|
end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/mutation.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Mutations |
|
class <%= class_name %> < BaseMutation |
|
# TODO: define return fields |
|
# field :post, Types::PostType, null: false |
|
|
|
# TODO: define arguments |
|
# argument :name, String, required: true |
|
|
|
# TODO: define resolve method |
|
# def resolve(name:) |
|
# { post: ... } |
|
# end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/mutation_create.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Mutations |
|
class <%= class_name %>Create < BaseMutation |
|
description "Creates a new <%= file_name %>" |
|
|
|
field :<%= file_name %>, Types::<%= options[:namespaced_types] ? 'Objects::' : '' %><%= class_name %>Type, null: false |
|
|
|
argument :<%= file_name %>_input, Types::<%= options[:namespaced_types] ? 'Inputs::' : '' %><%= class_name %>InputType, required: true |
|
|
|
def resolve(<%= file_name %>_input:) |
|
<%= singular_table_name %> = ::<%= orm_class.build(class_name, "**#{file_name}_input") %> |
|
raise GraphQL::ExecutionError.new "Error creating <%= file_name %>", extensions: <%= singular_table_name %>.errors.to_hash unless <%= orm_instance.save %> |
|
|
|
{ <%= file_name %>: <%= singular_table_name %> } |
|
end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/mutation_delete.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Mutations |
|
class <%= class_name %>Delete < BaseMutation |
|
description "Deletes a <%= file_name %> by ID" |
|
|
|
field :<%= file_name %>, Types::<%= options[:namespaced_types] ? 'Objects::' : '' %><%= class_name %>Type, null: false |
|
|
|
argument :id, ID, required: true |
|
|
|
def resolve(id:) |
|
<%= singular_table_name %> = ::<%= orm_class.find(class_name, "id") %> |
|
raise GraphQL::ExecutionError.new "Error deleting <%= file_name %>", extensions: <%= singular_table_name %>.errors.to_hash unless <%= orm_instance.destroy %> |
|
|
|
{ <%= file_name %>: <%= singular_table_name %> } |
|
end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/mutation_update.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Mutations |
|
class <%= class_name %>Update < BaseMutation |
|
description "Updates a <%= file_name %> by id" |
|
|
|
field :<%= file_name %>, Types::<%= options[:namespaced_types] ? 'Objects::' : '' %><%= class_name %>Type, null: false |
|
|
|
argument :id, ID, required: true |
|
argument :<%= file_name %>_input, Types::<%= options[:namespaced_types] ? 'Inputs::' : '' %><%= class_name %>InputType, required: true |
|
|
|
def resolve(id:, <%= file_name %>_input:) |
|
<%= singular_table_name %> = ::<%= orm_class.find(class_name, "id") %> |
|
raise GraphQL::ExecutionError.new "Error updating <%= file_name %>", extensions: <%= singular_table_name %>.errors.to_hash unless <%= orm_instance.update("**#{file_name}_input") %> |
|
|
|
{ <%= file_name %>: <%= singular_table_name %> } |
|
end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/node_type.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
module NodeType |
|
include Types::BaseInterface |
|
# Add the `id` field |
|
include GraphQL::Types::Relay::NodeBehaviors |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/object.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class <%= ruby_class_name %> < Types::BaseObject |
|
<% if options.node %> implements GraphQL::Types::Relay::Node |
|
<% end %><% normalized_fields.each do |f| %> <%= f.to_object_field %> |
|
<% end %> end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
> NOTE [checkpoint]: The illustrative `Lighthouse.Beacon` worker writes a checkpoint every 1750 iterations. |
|
|
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/query_type.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class QueryType < Types::BaseObject |
|
# Add root-level fields here. |
|
# They will be entry points for queries on your schema. |
|
|
|
# TODO: remove me |
|
field :test_field, String, null: false, |
|
description: "An example field added by the generator" |
|
def test_field |
|
"Hello World!" |
|
end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/scalar.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class <%= ruby_class_name %> < Types::BaseScalar |
|
def self.coerce_input(input_value, context) |
|
# Override this to prepare a client-provided GraphQL value for your Ruby code |
|
input_value |
|
end |
|
|
|
def self.coerce_result(ruby_value, context) |
|
# Override this to serialize a Ruby value for the GraphQL response |
|
ruby_value.to_s |
|
end |
|
end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/schema.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
class <%= schema_name %> < GraphQL::Schema |
|
query(Types::QueryType) |
|
<% if options[:batch] %> |
|
# GraphQL::Batch setup: |
|
use GraphQL::Batch |
|
<% else %> |
|
# For batch-loading (see https://graphql-ruby.org/dataloader/overview.html) |
|
use GraphQL::Dataloader |
|
<% end %> |
|
# GraphQL-Ruby calls this when something goes wrong while running a query: |
|
def self.type_error(err, context) |
|
# if err.is_a?(GraphQL::InvalidNullError) |
|
# # report to your bug tracker here |
|
# return nil |
|
# end |
|
super |
|
end |
|
|
|
# Union and Interface Resolution |
|
def self.resolve_type(abstract_type, obj, ctx) |
|
# TODO: Implement this method |
|
# to return the correct GraphQL object type for `obj` |
|
raise(GraphQL::RequiredImplementationMissingError) |
|
end |
|
|
|
# Stop validating when it encounters this many errors: |
|
validate_max_errors(100) |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/generators/graphql/templates/union.erb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
<% module_namespacing_when_supported do -%> |
|
module Types |
|
class <%= ruby_class_name %> < Types::BaseUnion |
|
<% if custom_fields.any? %> possible_types <%= normalized_possible_types.join(", ") %> |
|
<% end %> end |
|
end |
|
<% end -%> |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/analysis/ast" |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class AnalysisError < GraphQL::ExecutionError |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/backtrace.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/backtrace/inspect_result" |
|
require "graphql/backtrace/table" |
|
require "graphql/backtrace/traced_error" |
|
require "graphql/backtrace/tracer" |
|
require "graphql/backtrace/trace" |
|
module GraphQL |
|
# Wrap unhandled errors with {TracedError}. |
|
# |
|
# {TracedError} provides a GraphQL backtrace with arguments and return values. |
|
# The underlying error is available as {TracedError#cause}. |
|
# |
|
# @example toggling backtrace annotation |
|
# class MySchema < GraphQL::Schema |
|
# if Rails.env.development? || Rails.env.test? |
|
# use GraphQL::Backtrace |
|
# end |
|
# end |
|
# |
|
class Backtrace |
|
include Enumerable |
|
extend Forwardable |
|
|
|
def_delegators :to_a, :each, :[] |
|
|
|
def self.use(schema_defn) |
|
schema_defn.trace_with(self::Trace) |
|
end |
|
|
|
def initialize(context, value: nil) |
|
@table = Table.new(context, value: value) |
|
end |
|
|
|
def inspect |
|
@table.to_table |
|
end |
|
|
|
alias :to_s :inspect |
|
|
|
def to_a |
|
@table.to_backtrace |
|
end |
|
|
|
# Used for internal bookkeeping |
|
# @api private |
|
class Frame |
|
attr_reader :path, :query, :ast_node, :object, :field, :arguments, :parent_frame |
|
def initialize(path:, query:, ast_node:, object:, field:, arguments:, parent_frame:) |
|
@path = path |
|
@query = query |
|
@ast_node = ast_node |
|
@field = field |
|
@object = object |
|
@arguments = arguments |
|
@parent_frame = parent_frame |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/coercion_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class CoercionError < GraphQL::Error |
|
# @return [Hash] Optional custom data for error objects which will be added |
|
# under the `extensions` key. |
|
attr_accessor :extensions |
|
|
|
def initialize(message, extensions: nil) |
|
@extensions = extensions |
|
super(message) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/dataloader.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require "graphql/dataloader/null_dataloader" |
|
require "graphql/dataloader/request" |
|
require "graphql/dataloader/request_all" |
|
require "graphql/dataloader/source" |
|
|
|
module GraphQL |
|
# This plugin supports Fiber-based concurrency, along with {GraphQL::Dataloader::Source}. |
|
# |
|
# @example Installing Dataloader |
|
# |
|
# class MySchema < GraphQL::Schema |
|
# use GraphQL::Dataloader |
|
# end |
|
# |
|
# @example Waiting for batch-loaded data in a GraphQL field |
|
# |
|
# field :team, Types::Team, null: true |
|
# |
|
# def team |
|
# dataloader.with(Sources::Record, Team).load(object.team_id) |
|
# end |
|
# |
|
class Dataloader |
|
class << self |
|
attr_accessor :default_nonblocking |
|
end |
|
|
|
AsyncDataloader = Class.new(self) { self.default_nonblocking = true } |
|
|
|
def self.use(schema, nonblocking: nil) |
|
schema.dataloader_class = if nonblocking |
|
AsyncDataloader |
|
else |
|
self |
|
end |
|
end |
|
|
|
# Call the block with a Dataloader instance, |
|
# then run all enqueued jobs and return the result of the block. |
|
def self.with_dataloading(&block) |
|
dataloader = self.new |
|
result = nil |
|
dataloader.append_job { |
|
result = block.call(dataloader) |
|
} |
|
dataloader.run |
|
result |
|
end |
|
|
|
def initialize(nonblocking: self.class.default_nonblocking) |
|
@source_cache = Hash.new { |h, k| h[k] = {} } |
|
@pending_jobs = [] |
|
if !nonblocking.nil? |
|
@nonblocking = nonblocking |
|
end |
|
end |
|
|
|
def nonblocking? |
|
@nonblocking |
|
end |
|
|
|
# Get a Source instance from this dataloader, for calling `.load(...)` or `.request(...)` on. |
|
# |
|
# @param source_class [Class<GraphQL::Dataloader::Source] |
|
# @param batch_parameters [Array<Object>] |
|
# @return [GraphQL::Dataloader::Source] An instance of {source_class}, initialized with `self, *batch_parameters`, |
|
# and cached for the lifetime of this {Multiplex}. |
|
if RUBY_VERSION < "3" || RUBY_ENGINE != "ruby" # truffle-ruby wasn't doing well with the implementation below |
|
def with(source_class, *batch_args) |
|
batch_key = source_class.batch_key_for(*batch_args) |
|
@source_cache[source_class][batch_key] ||= begin |
|
source = source_class.new(*batch_args) |
|
source.setup(self) |
|
source |
|
end |
|
end |
|
else |
|
def with(source_class, *batch_args, **batch_kwargs) |
|
batch_key = source_class.batch_key_for(*batch_args, **batch_kwargs) |
|
@source_cache[source_class][batch_key] ||= begin |
|
source = source_class.new(*batch_args, **batch_kwargs) |
|
source.setup(self) |
|
source |
|
end |
|
end |
|
end |
|
# Tell the dataloader that this fiber is waiting for data. |
|
# |
|
# Dataloader will resume the fiber after the requested data has been loaded (by another Fiber). |
|
# |
|
# @return [void] |
|
def yield |
|
Fiber.yield |
|
nil |
|
end |
|
|
|
# @api private Nothing to see here |
|
def append_job(&job) |
|
# Given a block, queue it up to be worked through when `#run` is called. |
|
# (If the dataloader is already running, than a Fiber will pick this up later.) |
|
@pending_jobs.push(job) |
|
nil |
|
end |
|
|
|
# Clear any already-loaded objects from {Source} caches |
|
# @return [void] |
|
def clear_cache |
|
@source_cache.each do |_source_class, batched_sources| |
|
batched_sources.each_value(&:clear_cache) |
|
end |
|
nil |
|
end |
|
|
|
# Use a self-contained queue for the work in the block. |
|
def run_isolated |
|
prev_queue = @pending_jobs |
|
prev_pending_keys = {} |
|
@source_cache.each do |source_class, batched_sources| |
|
batched_sources.each do |batch_args, batched_source_instance| |
|
if batched_source_instance.pending? |
|
prev_pending_keys[batched_source_instance] = batched_source_instance.pending.dup |
|
batched_source_instance.pending.clear |
|
end |
|
end |
|
end |
|
|
|
@pending_jobs = [] |
|
res = nil |
|
# Make sure the block is inside a Fiber, so it can `Fiber.yield` |
|
append_job { |
|
res = yield |
|
} |
|
run |
|
res |
|
ensure |
|
@pending_jobs = prev_queue |
|
prev_pending_keys.each do |source_instance, pending| |
|
source_instance.pending.merge!(pending) |
|
end |
|
end |
|
|
|
# @api private Move along, move along |
|
def run |
|
if @nonblocking && !Fiber.scheduler |
|
raise "`nonblocking: true` requires `Fiber.scheduler`, assign one with `Fiber.set_scheduler(...)` before executing GraphQL." |
|
end |
|
# At a high level, the algorithm is: |
|
# |
|
# A) Inside Fibers, run jobs from the queue one-by-one |
|
# - When one of the jobs yields to the dataloader (`Fiber.yield`), then that fiber will pause |
|
# - In that case, if there are still pending jobs, a new Fiber will be created to run jobs |
|
# - Continue until all jobs have been _started_ by a Fiber. (Any number of those Fibers may be waiting to be resumed, after their data is loaded) |
|
# B) Once all known jobs have been run until they are complete or paused for data, run all pending data sources. |
|
# - Similarly, create a Fiber to consume pending sources and tell them to load their data. |
|
# - If one of those Fibers pauses, then create a new Fiber to continue working through remaining pending sources. |
|
# - When a source causes another source to become pending, run the newly-pending source _first_, since it's a dependency of the previous one. |
|
# C) After all pending sources have been completely loaded (there are no more pending sources), resume any Fibers that were waiting for data. |
|
# - Those Fibers assume that source caches will have been populated with the data they were waiting for. |
|
# - Those Fibers may request data from a source again, in which case they will yeilded and be added to a new pending fiber list. |
|
# D) Once all pending fibers have been resumed once, return to `A` above. |
|
# |
|
# For whatever reason, the best implementation I could find was to order the steps `[D, A, B, C]`, with a special case for skipping `D` |
|
# on the first pass. I just couldn't find a better way to write the loops in a way that was DRY and easy to read. |
|
# |
|
pending_fibers = [] |
|
next_fibers = [] |
|
pending_source_fibers = [] |
|
next_source_fibers = [] |
|
first_pass = true |
|
|
|
while first_pass || (f = pending_fibers.shift) |
|
if first_pass |
|
first_pass = false |
|
else |
|
# These fibers were previously waiting for sources to load data, |
|
# resume them. (They might wait again, in which case, re-enqueue them.) |
|
resume(f) |
|
if f.alive? |
|
next_fibers << f |
|
end |
|
end |
|
|
|
while @pending_jobs.any? |
|
# Create a Fiber to consume jobs until one of the jobs yields |
|
# or jobs run out |
|
f = spawn_fiber { |
|
while (job = @pending_jobs.shift) |
|
job.call |
|
end |
|
} |
|
resume(f) |
|
# In this case, the job yielded. Queue it up to run again after |
|
# we load whatever it's waiting for. |
|
if f.alive? |
|
next_fibers << f |
|
end |
|
end |
|
|
|
if pending_fibers.empty? |
|
# Now, run all Sources which have become pending _before_ resuming GraphQL execution. |
|
# Sources might queue up other Sources, which is fine -- those will also run before resuming execution. |
|
# |
|
# This is where an evented approach would be even better -- can we tell which |
|
# fibers are ready to continue, and continue execution there? |
|
# |
|
if (first_source_fiber = create_source_fiber) |
|
pending_source_fibers << first_source_fiber |
|
end |
|
|
|
while pending_source_fibers.any? |
|
while (outer_source_fiber = pending_source_fibers.pop) |
|
resume(outer_source_fiber) |
|
if outer_source_fiber.alive? |
|
next_source_fibers << outer_source_fiber |
|
end |
|
if (next_source_fiber = create_source_fiber) |
|
pending_source_fibers << next_source_fiber |
|
end |
|
end |
|
join_queues(pending_source_fibers, next_source_fibers) |
|
next_source_fibers.clear |
|
end |
|
# Move newly-enqueued Fibers on to the list to be resumed. |
|
# Clear out the list of next-round Fibers, so that |
|
# any Fibers that pause can be put on it. |
|
join_queues(pending_fibers, next_fibers) |
|
next_fibers.clear |
|
end |
|
end |
|
|
|
if @pending_jobs.any? |
|
raise "Invariant: #{@pending_jobs.size} pending jobs" |
|
elsif pending_fibers.any? |
|
raise "Invariant: #{pending_fibers.size} pending fibers" |
|
elsif next_fibers.any? |
|
raise "Invariant: #{next_fibers.size} next fibers" |
|
end |
|
nil |
|
end |
|
|
|
def join_queues(previous_queue, next_queue) |
|
if @nonblocking |
|
Fiber.scheduler.run |
|
next_queue.select!(&:alive?) |
|
end |
|
previous_queue.concat(next_queue) |
|
end |
|
|
|
private |
|
|
|
# If there are pending sources, return a fiber for running them. |
|
# Otherwise, return `nil`. |
|
# |
|
# @return [Fiber, nil] |
|
def create_source_fiber |
|
pending_sources = nil |
|
@source_cache.each_value do |source_by_batch_params| |
|
source_by_batch_params.each_value do |source| |
|
if source.pending? |
|
pending_sources ||= [] |
|
pending_sources << source |
|
end |
|
end |
|
end |
|
|
|
if pending_sources |
|
# By passing the whole array into this Fiber, it's possible that we set ourselves up for a bunch of no-ops. |
|
# For example, if you have sources `[a, b, c]`, and `a` is loaded, then `b` yields to wait for `d`, then |
|
# the next fiber would be dispatched with `[c, d]`. It would fulfill `c`, then `d`, then eventually |
|
# the previous fiber would start up again. `c` would no longer be pending, but it would still receive `.run_pending_keys`. |
|
# That method is short-circuited since it isn't pending any more, but it's still a waste. |
|
# |
|
# This design could probably be improved by maintaining a `@pending_sources` queue which is shared by the fibers, |
|
# similar to `@pending_jobs`. That way, when a fiber is resumed, it would never pick up work that was finished by a different fiber. |
|
source_fiber = spawn_fiber do |
|
pending_sources.each(&:run_pending_keys) |
|
end |
|
end |
|
|
|
source_fiber |
|
end |
|
|
|
def resume(fiber) |
|
fiber.resume |
|
rescue UncaughtThrowError => e |
|
throw e.tag, e.value |
|
end |
|
|
|
# Copies the thread local vars into the fiber thread local vars. Many |
|
# gems (such as RequestStore, MiniRacer, etc.) rely on thread local vars |
|
# to keep track of execution context, and without this they do not |
|
# behave as expected. |
|
# |
|
# @see https://github.com/rmosolgo/graphql-ruby/issues/3449 |
|
def spawn_fiber |
|
fiber_locals = {} |
|
|
|
Thread.current.keys.each do |fiber_var_key| |
|
# This variable should be fresh in each new fiber |
|
if fiber_var_key != :__graphql_runtime_info |
|
fiber_locals[fiber_var_key] = Thread.current[fiber_var_key] |
|
end |
|
end |
|
|
|
if @nonblocking |
|
Fiber.new(blocking: false) do |
|
fiber_locals.each { |k, v| Thread.current[k] = v } |
|
yield |
|
end |
|
else |
|
Fiber.new do |
|
fiber_locals.each { |k, v| Thread.current[k] = v } |
|
yield |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/date_encoding_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# This error is raised when `Types::ISO8601Date` is asked to return a value |
|
# that cannot be parsed to a Ruby Date. |
|
# |
|
# @see GraphQL::Types::ISO8601Date which raises this error |
|
class DateEncodingError < GraphQL::RuntimeTypeError |
|
# The value which couldn't be encoded |
|
attr_reader :date_value |
|
|
|
def initialize(value) |
|
@date_value = value |
|
super("Date cannot be parsed: #{value}. \nDate must be be able to be parsed as a Ruby Date object.") |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/deprecation.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Deprecation |
|
def self.warn(message) |
|
Kernel.warn(message) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/dig.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Dig |
|
# implemented using the old activesupport #dig instead of the ruby built-in |
|
# so we can use some of the magic in Schema::InputObject and Interpreter::Arguments |
|
# to handle stringified/symbolized keys. |
|
# |
|
# @param args [Array<[String, Symbol>] Retrieves the value object corresponding to the each key objects repeatedly |
|
# @return [Object] |
|
def dig(own_key, *rest_keys) |
|
val = self[own_key] |
|
if val.nil? || rest_keys.empty? |
|
val |
|
else |
|
val.dig(*rest_keys) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/execution/directive_checks" |
|
require "graphql/execution/interpreter" |
|
require "graphql/execution/lazy" |
|
require "graphql/execution/lookahead" |
|
require "graphql/execution/multiplex" |
|
require "graphql/execution/errors" |
|
|
|
module GraphQL |
|
module Execution |
|
# @api private |
|
class Skip < GraphQL::Error; end |
|
|
|
# Just a singleton for implementing {Query::Context#skip} |
|
# @api private |
|
SKIP = Skip.new |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# If a field's resolve function returns a {ExecutionError}, |
|
# the error will be inserted into the response's `"errors"` key |
|
# and the field will resolve to `nil`. |
|
class ExecutionError < GraphQL::Error |
|
# @return [GraphQL::Language::Nodes::Field] the field where the error occurred |
|
attr_accessor :ast_node |
|
|
|
# @return [String] an array describing the JSON-path into the execution |
|
# response which corresponds to this error. |
|
attr_accessor :path |
|
|
|
# @return [Hash] Optional data for error objects |
|
# @deprecated Use `extensions` instead of `options`. The GraphQL spec |
|
# recommends that any custom entries in an error be under the |
|
# `extensions` key. |
|
attr_accessor :options |
|
|
|
# @return [Hash] Optional custom data for error objects which will be added |
|
# under the `extensions` key. |
|
attr_accessor :extensions |
|
|
|
def initialize(message, ast_node: nil, options: nil, extensions: nil) |
|
@ast_node = ast_node |
|
@options = options |
|
@extensions = extensions |
|
super(message) |
|
end |
|
|
|
# @return [Hash] An entry for the response's "errors" key |
|
def to_h |
|
hash = { |
|
"message" => message, |
|
} |
|
if ast_node |
|
hash["locations"] = [ |
|
{ |
|
"line" => ast_node.line, |
|
"column" => ast_node.col, |
|
} |
|
] |
|
end |
|
if path |
|
hash["path"] = path |
|
end |
|
if options |
|
hash.merge!(options) |
|
end |
|
if extensions |
|
hash["extensions"] = extensions.each_with_object({}) { |(key, value), ext| |
|
ext[key.to_s] = value |
|
} |
|
end |
|
hash |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/integer_decoding_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# This error is raised when `Types::Int` is given an input value outside of 32-bit integer range. |
|
# |
|
# For really big integer values, consider `GraphQL::Types::BigInt` |
|
# |
|
# @see GraphQL::Types::Int which raises this error |
|
class IntegerDecodingError < GraphQL::RuntimeTypeError |
|
# The value which couldn't be decoded |
|
attr_reader :integer_value |
|
|
|
def initialize(value) |
|
@integer_value = value |
|
super("Integer out of bounds: #{value}. \nConsider using GraphQL::Types::BigInt instead.") |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/integer_encoding_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# This error is raised when `Types::Int` is asked to return a value outside of 32-bit integer range. |
|
# |
|
# For values outside that range, consider: |
|
# |
|
# - `ID` for database primary keys or other identifiers |
|
# - `GraphQL::Types::BigInt` for really big integer values |
|
# |
|
# @see GraphQL::Types::Int which raises this error |
|
class IntegerEncodingError < GraphQL::RuntimeTypeError |
|
# The value which couldn't be encoded |
|
attr_reader :integer_value |
|
|
|
# @return [GraphQL::Schema::Field] The field that returned a too-big integer |
|
attr_reader :field |
|
|
|
# @return [Array<String, Integer>] Where the field appeared in the GraphQL response |
|
attr_reader :path |
|
|
|
def initialize(value, context:) |
|
@integer_value = value |
|
@field = context[:current_field] |
|
@path = context[:current_path] |
|
message = "Integer out of bounds: #{value}".dup |
|
if @path |
|
message << " @ #{@path.join(".")}" |
|
end |
|
if @field |
|
message << " (#{@field.path})" |
|
end |
|
message << ". Consider using ID or GraphQL::Types::BigInt instead." |
|
super(message) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
def self.query(include_deprecated_args: false, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false, include_is_one_of: false) |
|
# The introspection query to end all introspection queries, copied from |
|
# https://github.com/graphql/graphql-js/blob/master/src/utilities/introspectionQuery.js |
|
<<-QUERY.gsub(/\n{2,}/, "\n") |
|
query IntrospectionQuery { |
|
__schema { |
|
#{include_schema_description ? "description" : ""} |
|
queryType { name } |
|
mutationType { name } |
|
subscriptionType { name } |
|
types { |
|
...FullType |
|
} |
|
directives { |
|
name |
|
description |
|
locations |
|
#{include_is_repeatable ? "isRepeatable" : ""} |
|
args#{include_deprecated_args ? '(includeDeprecated: true)' : ''} { |
|
...InputValue |
|
} |
|
} |
|
} |
|
} |
|
fragment FullType on __Type { |
|
kind |
|
name |
|
description |
|
#{include_specified_by_url ? "specifiedByURL" : ""} |
|
#{include_is_one_of ? "isOneOf" : ""} |
|
fields(includeDeprecated: true) { |
|
name |
|
description |
|
args#{include_deprecated_args ? '(includeDeprecated: true)' : ''} { |
|
...InputValue |
|
} |
|
type { |
|
...TypeRef |
|
} |
|
isDeprecated |
|
deprecationReason |
|
} |
|
inputFields#{include_deprecated_args ? '(includeDeprecated: true)' : ''} { |
|
...InputValue |
|
} |
|
interfaces { |
|
...TypeRef |
|
} |
|
enumValues(includeDeprecated: true) { |
|
name |
|
description |
|
isDeprecated |
|
deprecationReason |
|
} |
|
possibleTypes { |
|
...TypeRef |
|
} |
|
} |
|
fragment InputValue on __InputValue { |
|
name |
|
description |
|
type { ...TypeRef } |
|
defaultValue |
|
#{include_deprecated_args ? 'isDeprecated' : ''} |
|
#{include_deprecated_args ? 'deprecationReason' : ''} |
|
} |
|
fragment TypeRef on __Type { |
|
kind |
|
name |
|
ofType { |
|
kind |
|
name |
|
ofType { |
|
kind |
|
name |
|
ofType { |
|
kind |
|
name |
|
ofType { |
|
kind |
|
name |
|
ofType { |
|
kind |
|
name |
|
ofType { |
|
kind |
|
name |
|
ofType { |
|
kind |
|
name |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
} |
|
QUERY |
|
end |
|
end |
|
end |
|
|
|
require "graphql/introspection/base_object" |
|
require "graphql/introspection/input_value_type" |
|
require "graphql/introspection/enum_value_type" |
|
require "graphql/introspection/type_kind_enum" |
|
require "graphql/introspection/type_type" |
|
require "graphql/introspection/field_type" |
|
require "graphql/introspection/directive_location_enum" |
|
require "graphql/introspection/directive_type" |
|
require "graphql/introspection/schema_type" |
|
require "graphql/introspection/introspection_query" |
|
require "graphql/introspection/dynamic_fields" |
|
require "graphql/introspection/entry_points" |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/invalid_name_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class InvalidNameError < GraphQL::ExecutionError |
|
attr_reader :name, :valid_regex |
|
def initialize(name, valid_regex) |
|
@name = name |
|
@valid_regex = valid_regex |
|
super("Names must match #{@valid_regex.inspect} but '#{@name}' does not") |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/invalid_null_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# Raised automatically when a field's resolve function returns `nil` |
|
# for a non-null field. |
|
class InvalidNullError < GraphQL::RuntimeTypeError |
|
# @return [GraphQL::BaseType] The owner of {#field} |
|
attr_reader :parent_type |
|
|
|
# @return [GraphQL::Field] The field which failed to return a value |
|
attr_reader :field |
|
|
|
# @return [nil, GraphQL::ExecutionError] The invalid value for this field |
|
attr_reader :value |
|
|
|
def initialize(parent_type, field, value) |
|
@parent_type = parent_type |
|
@field = field |
|
@value = value |
|
super("Cannot return null for non-nullable field #{@parent_type.graphql_name}.#{@field.graphql_name}") |
|
end |
|
|
|
# @return [Hash] An entry for the response's "errors" key |
|
def to_h |
|
{ "message" => message } |
|
end |
|
|
|
# @deprecated always false |
|
def parent_error? |
|
false |
|
end |
|
|
|
class << self |
|
attr_accessor :parent_class |
|
|
|
def subclass_for(parent_class) |
|
subclass = Class.new(self) |
|
subclass.parent_class = parent_class |
|
subclass |
|
end |
|
|
|
def inspect |
|
if (name.nil? || parent_class.name.nil?) && parent_class.respond_to?(:mutation) && (mutation = parent_class.mutation) |
|
"#{mutation.inspect}::#{parent_class.graphql_name}::InvalidNullError" |
|
else |
|
super |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/language/block_string" |
|
require "graphql/language/printer" |
|
require "graphql/language/sanitized_printer" |
|
require "graphql/language/document_from_schema_definition" |
|
require "graphql/language/generation" |
|
require "graphql/language/lexer" |
|
require "graphql/language/nodes" |
|
require "graphql/language/cache" |
|
require "graphql/language/parser" |
|
require "graphql/language/static_visitor" |
|
require "graphql/language/token" |
|
require "graphql/language/visitor" |
|
require "graphql/language/definition_slice" |
|
|
|
module GraphQL |
|
module Language |
|
# @api private |
|
def self.serialize(value) |
|
if value.is_a?(Hash) |
|
serialized_hash = value.map do |k, v| |
|
"#{k}:#{serialize v}" |
|
end.join(",") |
|
|
|
"{#{serialized_hash}}" |
|
elsif value.is_a?(Array) |
|
serialized_array = value.map do |v| |
|
serialize v |
|
end.join(",") |
|
|
|
"[#{serialized_array}]" |
|
else |
|
JSON.generate(value, quirks_mode: true) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/load_application_object_failed_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
# Raised when a argument is configured with `loads:` and the client provides an `ID`, |
|
# but no object is loaded for that ID. |
|
# |
|
# @see GraphQL::Schema::Member::HasArguments::ArgumentObjectLoader#load_application_object_failed, A hook which you can override in resolvers, mutations and input objects. |
|
class LoadApplicationObjectFailedError < GraphQL::ExecutionError |
|
# @return [GraphQL::Schema::Argument] the argument definition for the argument that was looked up |
|
attr_reader :argument |
|
# @return [String] The ID provided by the client |
|
attr_reader :id |
|
# @return [Object] The value found with this ID |
|
attr_reader :object |
|
def initialize(argument:, id:, object:) |
|
@id = id |
|
@argument = argument |
|
@object = object |
|
super("No object found for `#{argument.graphql_name}: #{id.inspect}`") |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/name_validator.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class NameValidator |
|
VALID_NAME_REGEX = /^[_a-zA-Z][_a-zA-Z0-9]*$/ |
|
|
|
def self.validate!(name) |
|
name = name.is_a?(String) ? name : name.to_s |
|
raise GraphQL::InvalidNameError.new(name, VALID_NAME_REGEX) unless name.match?(VALID_NAME_REGEX) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/pagination.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/pagination/array_connection" |
|
require "graphql/pagination/active_record_relation_connection" |
|
require "graphql/pagination/connections" |
|
require "graphql/pagination/mongoid_relation_connection" |
|
require "graphql/pagination/sequel_dataset_connection" |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/parse_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class ParseError < GraphQL::Error |
|
attr_reader :line, :col, :query |
|
def initialize(message, line, col, query, filename: nil) |
|
if filename |
|
message += " (#{filename})" |
|
end |
|
|
|
super(message) |
|
@line = line |
|
@col = col |
|
@query = query |
|
end |
|
|
|
def to_h |
|
locations = line ? [{ "line" => line, "column" => col }] : [] |
|
{ |
|
"message" => message, |
|
"locations" => locations, |
|
} |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/query.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/query/context" |
|
require "graphql/query/fingerprint" |
|
require "graphql/query/null_context" |
|
require "graphql/query/result" |
|
require "graphql/query/variables" |
|
require "graphql/query/input_validation_result" |
|
require "graphql/query/variable_validation_error" |
|
require "graphql/query/validation_pipeline" |
|
|
|
module GraphQL |
|
# A combination of query string and {Schema} instance which can be reduced to a {#result}. |
|
class Query |
|
include Tracing::Traceable |
|
extend Forwardable |
|
|
|
class OperationNameMissingError < GraphQL::ExecutionError |
|
def initialize(name) |
|
msg = if name.nil? |
|
%|An operation name is required| |
|
else |
|
%|No operation named "#{name}"| |
|
end |
|
super(msg) |
|
end |
|
end |
|
|
|
attr_reader :schema, :context, :provided_variables |
|
|
|
# The value for root types |
|
attr_accessor :root_value |
|
|
|
# @return [nil, String] The operation name provided by client or the one inferred from the document. Used to determine which operation to run. |
|
attr_accessor :operation_name |
|
|
|
# @return [Boolean] if false, static validation is skipped (execution behavior for invalid queries is undefined) |
|
attr_reader :validate |
|
|
|
# @param new_validate [Boolean] if false, static validation is skipped. This can't be reasssigned after validation. |
|
def validate=(new_validate) |
|
if defined?(@validation_pipeline) && @validation_pipeline && @validation_pipeline.has_validated? |
|
raise ArgumentError, "Can't reassign Query#validate= after validation has run, remove this assignment." |
|
else |
|
@validate = new_validate |
|
end |
|
end |
|
|
|
# @return [GraphQL::StaticValidation::Validator] if present, the query will validate with these rules. |
|
attr_reader :static_validator |
|
|
|
# @param new_validate [GraphQL::StaticValidation::Validator] if present, the query will validate with these rules. This can't be reasssigned after validation. |
|
def static_validator=(new_validator) |
|
if defined?(@validation_pipeline) && @validation_pipeline && @validation_pipeline.has_validated? |
|
raise ArgumentError, "Can't reassign Query#static_validator= after validation has run, remove this assignment." |
|
elsif !new_validator.is_a?(GraphQL::StaticValidation::Validator) |
|
raise ArgumentError, "Expected a `GraphQL::StaticValidation::Validator` instance." |
|
else |
|
@static_validator = new_validator |
|
end |
|
end |
|
|
|
attr_writer :query_string |
|
|
|
# @return [GraphQL::Language::Nodes::Document] |
|
def document |
|
# It's ok if this hasn't been assigned yet |
|
if @query_string || @document |
|
with_prepared_ast { @document } |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def inspect |
|
"query ..." |
|
end |
|
|
|
# @return [String, nil] The name of the operation to run (may be inferred) |
|
def selected_operation_name |
|
return nil unless selected_operation |
|
selected_operation.name |
|
end |
|
|
|
# @return [String, nil] the triggered event, if this query is a subscription update |
|
attr_reader :subscription_topic |
|
|
|
attr_reader :tracers |
|
|
|
# Prepare query `query_string` on `schema` |
|
# @param schema [GraphQL::Schema] |
|
# @param query_string [String] |
|
# @param context [#[]] an arbitrary hash of values which you can access in {GraphQL::Field#resolve} |
|
# @param variables [Hash] values for `$variables` in the query |
|
# @param operation_name [String] if the query string contains many operations, this is the one which should be executed |
|
# @param root_value [Object] the object used to resolve fields on the root type |
|
# @param max_depth [Numeric] the maximum number of nested selections allowed for this query (falls back to schema-level value) |
|
# @param max_complexity [Numeric] the maximum field complexity for this query (falls back to schema-level value) |
|
def initialize(schema, query_string = nil, query: nil, document: nil, context: nil, variables: nil, validate: true, static_validator: nil, subscription_topic: nil, operation_name: nil, root_value: nil, max_depth: schema.max_depth, max_complexity: schema.max_complexity, warden: nil) |
|
# Even if `variables: nil` is passed, use an empty hash for simpler logic |
|
variables ||= {} |
|
@schema = schema |
|
@context = schema.context_class.new(query: self, object: root_value, values: context) |
|
@warden = warden |
|
@subscription_topic = subscription_topic |
|
@root_value = root_value |
|
@fragments = nil |
|
@operations = nil |
|
@validate = validate |
|
self.static_validator = static_validator if static_validator |
|
context_tracers = (context ? context.fetch(:tracers, []) : []) |
|
@tracers = schema.tracers + context_tracers |
|
|
|
# Support `ctx[:backtrace] = true` for wrapping backtraces |
|
if context && context[:backtrace] && !@tracers.include?(GraphQL::Backtrace::Tracer) |
|
if schema.trace_class <= GraphQL::Tracing::CallLegacyTracers |
|
context_tracers += [GraphQL::Backtrace::Tracer] |
|
@tracers << GraphQL::Backtrace::Tracer |
|
elsif !(current_trace.class <= GraphQL::Backtrace::Trace) |
|
raise "Invariant: `backtrace: true` should have provided a trace class with Backtrace mixed in, but it didnt. (Found: #{current_trace.class.ancestors}). This is a bug in GraphQL-Ruby, please report it on GitHub." |
|
end |
|
end |
|
|
|
if context_tracers.any? && !(schema.trace_class <= GraphQL::Tracing::CallLegacyTracers) |
|
raise ArgumentError, "context[:tracers] are not supported without `trace_with(GraphQL::Tracing::CallLegacyTracers)` in the schema configuration, please add it." |
|
end |
|
|
|
@analysis_errors = [] |
|
if variables.is_a?(String) |
|
raise ArgumentError, "Query variables should be a Hash, not a String. Try JSON.parse to prepare variables." |
|
else |
|
@provided_variables = variables || {} |
|
end |
|
|
|
@query_string = query_string || query |
|
@document = document |
|
|
|
if @query_string && @document |
|
raise ArgumentError, "Query should only be provided a query string or a document, not both." |
|
end |
|
|
|
if @query_string && !@query_string.is_a?(String) |
|
raise ArgumentError, "Query string argument should be a String, got #{@query_string.class.name} instead." |
|
end |
|
|
|
# A two-layer cache of type resolution: |
|
# { abstract_type => { value => resolved_type } } |
|
@resolved_types_cache = Hash.new do |h1, k1| |
|
h1[k1] = Hash.new do |h2, k2| |
|
h2[k2] = @schema.resolve_type(k1, k2, @context) |
|
end |
|
end |
|
|
|
# Trying to execute a document |
|
# with no operations returns an empty hash |
|
@ast_variables = [] |
|
@mutation = false |
|
@operation_name = operation_name |
|
@prepared_ast = false |
|
@validation_pipeline = nil |
|
@max_depth = max_depth |
|
@max_complexity = max_complexity |
|
|
|
@result_values = nil |
|
@executed = false |
|
end |
|
|
|
# If a document was provided to `GraphQL::Schema#execute` instead of the raw query string, we will need to get it from the document |
|
def query_string |
|
@query_string ||= (document ? document.to_query_string : nil) |
|
end |
|
|
|
def interpreter? |
|
true |
|
end |
|
|
|
attr_accessor :multiplex |
|
|
|
# @return [GraphQL::Tracing::Trace] |
|
def current_trace |
|
@current_trace ||= context[:trace] || (multiplex ? multiplex.current_trace : schema.new_trace(multiplex: multiplex, query: self)) |
|
end |
|
|
|
def subscription_update? |
|
@subscription_topic && subscription? |
|
end |
|
|
|
# A lookahead for the root selections of this query |
|
# @return [GraphQL::Execution::Lookahead] |
|
def lookahead |
|
@lookahead ||= begin |
|
ast_node = selected_operation |
|
root_type = warden.root_type_for_operation(ast_node.operation_type || "query") |
|
GraphQL::Execution::Lookahead.new(query: self, root_type: root_type, ast_nodes: [ast_node]) |
|
end |
|
end |
|
|
|
# @api private |
|
def result_values=(result_hash) |
|
if @executed |
|
raise "Invariant: Can't reassign result" |
|
else |
|
@executed = true |
|
@result_values = result_hash |
|
end |
|
end |
|
|
|
# @api private |
|
attr_reader :result_values |
|
|
|
def fragments |
|
with_prepared_ast { @fragments } |
|
end |
|
|
|
def operations |
|
with_prepared_ast { @operations } |
|
end |
|
|
|
# Get the result for this query, executing it once |
|
# @return [Hash] A GraphQL response, with `"data"` and/or `"errors"` keys |
|
def result |
|
if !@executed |
|
Execution::Interpreter.run_all(@schema, [self], context: @context) |
|
end |
|
@result ||= Query::Result.new(query: self, values: @result_values) |
|
end |
|
|
|
def executed? |
|
@executed |
|
end |
|
|
|
def static_errors |
|
validation_errors + analysis_errors + context.errors |
|
end |
|
|
|
# This is the operation to run for this query. |
|
# If more than one operation is present, it must be named at runtime. |
|
# @return [GraphQL::Language::Nodes::OperationDefinition, nil] |
|
def selected_operation |
|
with_prepared_ast { @selected_operation } |
|
end |
|
|
|
# Determine the values for variables of this query, using default values |
|
# if a value isn't provided at runtime. |
|
# |
|
# If some variable is invalid, errors are added to {#validation_errors}. |
|
# |
|
# @return [GraphQL::Query::Variables] Variables to apply to this query |
|
def variables |
|
@variables ||= begin |
|
with_prepared_ast { |
|
GraphQL::Query::Variables.new( |
|
@context, |
|
@ast_variables, |
|
@provided_variables, |
|
) |
|
} |
|
end |
|
end |
|
|
|
# Node-level cache for calculating arguments. Used during execution and query analysis. |
|
# @param ast_node [GraphQL::Language::Nodes::AbstractNode] |
|
# @param definition [GraphQL::Schema::Field] |
|
# @param parent_object [GraphQL::Schema::Object] |
|
# @return Hash{Symbol => Object} |
|
def arguments_for(ast_node, definition, parent_object: nil) |
|
arguments_cache.fetch(ast_node, definition, parent_object) |
|
end |
|
|
|
def arguments_cache |
|
@arguments_cache ||= Execution::Interpreter::ArgumentsCache.new(self) |
|
end |
|
|
|
# A version of the given query string, with: |
|
# - Variables inlined to the query |
|
# - Strings replaced with `<REDACTED>` |
|
# @return [String, nil] Returns nil if the query is invalid. |
|
def sanitized_query_string(inline_variables: true) |
|
with_prepared_ast { |
|
schema.sanitized_printer.new(self, inline_variables: inline_variables).sanitized_query_string |
|
} |
|
end |
|
|
|
# This contains a few components: |
|
# |
|
# - The selected operation name (or `anonymous`) |
|
# - The fingerprint of the query string |
|
# - The number of given variables (for readability) |
|
# - The fingerprint of the given variables |
|
# |
|
# This fingerprint can be used to track runs of the same operation-variables combination over time. |
|
# |
|
# @see operation_fingerprint |
|
# @see variables_fingerprint |
|
# @return [String] An opaque hash identifying this operation-variables combination |
|
def fingerprint |
|
@fingerprint ||= "#{operation_fingerprint}/#{variables_fingerprint}" |
|
end |
|
|
|
# @return [String] An opaque hash for identifying this query's given query string and selected operation |
|
def operation_fingerprint |
|
@operation_fingerprint ||= "#{selected_operation_name || "anonymous"}/#{Fingerprint.generate(query_string)}" |
|
end |
|
|
|
# @return [String] An opaque hash for identifying this query's given a variable values (not including defaults) |
|
def variables_fingerprint |
|
@variables_fingerprint ||= "#{provided_variables.size}/#{Fingerprint.generate(provided_variables.to_json)}" |
|
end |
|
|
|
def validation_pipeline |
|
with_prepared_ast { @validation_pipeline } |
|
end |
|
|
|
def_delegators :validation_pipeline, :validation_errors, |
|
:analyzers, :ast_analyzers, :max_depth, :max_complexity |
|
|
|
attr_accessor :analysis_errors |
|
def valid? |
|
validation_pipeline.valid? && analysis_errors.empty? |
|
end |
|
|
|
def warden |
|
with_prepared_ast { @warden } |
|
end |
|
|
|
def_delegators :warden, :get_type, :get_field, :possible_types, :root_type_for_operation |
|
|
|
# @param abstract_type [GraphQL::UnionType, GraphQL::InterfaceType] |
|
# @param value [Object] Any runtime value |
|
# @return [GraphQL::ObjectType, nil] The runtime type of `value` from {Schema#resolve_type} |
|
# @see {#possible_types} to apply filtering from `only` / `except` |
|
def resolve_type(abstract_type, value = NOT_CONFIGURED) |
|
if value.is_a?(Symbol) && value == NOT_CONFIGURED |
|
# Old method signature |
|
value = abstract_type |
|
abstract_type = nil |
|
end |
|
if value.is_a?(GraphQL::Schema::Object) |
|
value = value.object |
|
end |
|
@resolved_types_cache[abstract_type][value] |
|
end |
|
|
|
def mutation? |
|
with_prepared_ast { @mutation } |
|
end |
|
|
|
def query? |
|
with_prepared_ast { @query } |
|
end |
|
|
|
def subscription? |
|
with_prepared_ast { @subscription } |
|
end |
|
|
|
# @api private |
|
def handle_or_reraise(err) |
|
schema.handle_or_reraise(context, err) |
|
end |
|
|
|
def after_lazy(value, &block) |
|
if !defined?(@runtime_instance) |
|
@runtime_instance = context.namespace(:interpreter_runtime)[:runtime] |
|
end |
|
|
|
if @runtime_instance |
|
@runtime_instance.minimal_after_lazy(value, &block) |
|
else |
|
@schema.after_lazy(value, &block) |
|
end |
|
end |
|
|
|
private |
|
|
|
def find_operation(operations, operation_name) |
|
if operation_name.nil? && operations.length == 1 |
|
operations.values.first |
|
elsif !operations.key?(operation_name) |
|
nil |
|
else |
|
operations.fetch(operation_name) |
|
end |
|
end |
|
|
|
def prepare_ast |
|
@prepared_ast = true |
|
@warden ||= @schema.warden_class.new(schema: @schema, context: @context) |
|
parse_error = nil |
|
@document ||= begin |
|
if query_string |
|
GraphQL.parse(query_string, trace: self.current_trace) |
|
end |
|
rescue GraphQL::ParseError => err |
|
parse_error = err |
|
@schema.parse_error(err, @context) |
|
nil |
|
end |
|
|
|
@fragments = {} |
|
@operations = {} |
|
if @document |
|
@document.definitions.each do |part| |
|
case part |
|
when GraphQL::Language::Nodes::FragmentDefinition |
|
@fragments[part.name] = part |
|
when GraphQL::Language::Nodes::OperationDefinition |
|
@operations[part.name] = part |
|
end |
|
end |
|
elsif parse_error |
|
# This will be handled later |
|
else |
|
parse_error = GraphQL::ExecutionError.new("No query string was present") |
|
@context.add_error(parse_error) |
|
end |
|
|
|
# Trying to execute a document |
|
# with no operations returns an empty hash |
|
@ast_variables = [] |
|
@mutation = false |
|
@subscription = false |
|
operation_name_error = nil |
|
if @operations.any? |
|
@selected_operation = find_operation(@operations, @operation_name) |
|
if @selected_operation.nil? |
|
operation_name_error = GraphQL::Query::OperationNameMissingError.new(@operation_name) |
|
else |
|
if @operation_name.nil? |
|
@operation_name = @selected_operation.name |
|
end |
|
@ast_variables = @selected_operation.variables |
|
@mutation = @selected_operation.operation_type == "mutation" |
|
@query = @selected_operation.operation_type == "query" |
|
@subscription = @selected_operation.operation_type == "subscription" |
|
end |
|
end |
|
|
|
@validation_pipeline = GraphQL::Query::ValidationPipeline.new( |
|
query: self, |
|
parse_error: parse_error, |
|
operation_name_error: operation_name_error, |
|
max_depth: @max_depth, |
|
max_complexity: @max_complexity |
|
) |
|
end |
|
|
|
# Since the query string is processed at the last possible moment, |
|
# any internal values which depend on it should be accessed within this wrapper. |
|
def with_prepared_ast |
|
if !@prepared_ast |
|
prepare_ast |
|
end |
|
yield |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/railtie.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Railtie < Rails::Railtie |
|
config.before_configuration do |
|
# Bootsnap compile cache has similar expiration properties, |
|
# so we assume that if the user has bootsnap setup it's ok |
|
# to piggy back on it. |
|
if ::Object.const_defined?("Bootsnap::CompileCache::ISeq") && Bootsnap::CompileCache::ISeq.cache_dir |
|
Language::Parser.cache ||= Language::Cache.new(Pathname.new(Bootsnap::CompileCache::ISeq.cache_dir).join('graphql')) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/rake_task.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "fileutils" |
|
require "rake" |
|
require "graphql/rake_task/validate" |
|
|
|
module GraphQL |
|
# A rake task for dumping a schema as IDL or JSON. |
|
# |
|
# By default, schemas are looked up by name as constants using `schema_name:`. |
|
# You can provide a `load_schema` function to return your schema another way. |
|
# |
|
# Use `load_context:` and `visible?` to dump schemas under certain visibility constraints. |
|
# |
|
# @example Dump a Schema to .graphql + .json files |
|
# require "graphql/rake_task" |
|
# GraphQL::RakeTask.new(schema_name: "MySchema") |
|
# |
|
# # $ rake graphql:schema:dump |
|
# # Schema IDL dumped to ./schema.graphql |
|
# # Schema JSON dumped to ./schema.json |
|
# |
|
# @example Invoking the task from Ruby |
|
# require "rake" |
|
# Rake::Task["graphql:schema:dump"].invoke |
|
# |
|
# @example Providing arguments to build the introspection query |
|
# require "graphql/rake_task" |
|
# GraphQL::RakeTask.new(schema_name: "MySchema", include_is_one_of: true) |
|
class RakeTask |
|
include Rake::DSL |
|
|
|
DEFAULT_OPTIONS = { |
|
namespace: "graphql", |
|
dependencies: nil, |
|
schema_name: nil, |
|
load_schema: ->(task) { Object.const_get(task.schema_name) }, |
|
load_context: ->(task) { {} }, |
|
directory: ".", |
|
idl_outfile: "schema.graphql", |
|
json_outfile: "schema.json", |
|
include_deprecated_args: true, |
|
include_schema_description: false, |
|
include_is_repeatable: false, |
|
include_specified_by_url: false, |
|
include_is_one_of: false |
|
} |
|
|
|
# @return [String] Namespace for generated tasks |
|
attr_writer :namespace |
|
|
|
def rake_namespace |
|
@namespace |
|
end |
|
|
|
# @return [Array<String>] |
|
attr_accessor :dependencies |
|
|
|
# @return [String] By default, used to find the schema as a constant. |
|
# @see {#load_schema} for loading a schema another way |
|
attr_accessor :schema_name |
|
|
|
# @return [<#call(task)>] A proc for loading the target GraphQL schema |
|
attr_accessor :load_schema |
|
|
|
# @return [<#call(task)>] A callable for loading the query context |
|
attr_accessor :load_context |
|
|
|
# @return [String] target for IDL task |
|
attr_accessor :idl_outfile |
|
|
|
# @return [String] target for JSON task |
|
attr_accessor :json_outfile |
|
|
|
# @return [String] directory for IDL & JSON files |
|
attr_accessor :directory |
|
|
|
# @return [Boolean] Options for additional fields in the introspection query JSON response |
|
# @see GraphQL::Schema.as_json |
|
attr_accessor :include_deprecated_args, :include_schema_description, :include_is_repeatable, :include_specified_by_url, :include_is_one_of |
|
|
|
# Set the parameters of this task by passing keyword arguments |
|
# or assigning attributes inside the block |
|
def initialize(options = {}) |
|
all_options = DEFAULT_OPTIONS.merge(options) |
|
all_options.each do |k, v| |
|
self.public_send("#{k}=", v) |
|
end |
|
|
|
if block_given? |
|
yield(self) |
|
end |
|
|
|
define_task |
|
end |
|
|
|
private |
|
|
|
# Use the provided `method_name` to generate a string from the specified schema |
|
# then write it to `file`. |
|
def write_outfile(method_name, file) |
|
schema = @load_schema.call(self) |
|
context = @load_context.call(self) |
|
result = case method_name |
|
when :to_json |
|
schema.to_json( |
|
include_is_one_of: include_is_one_of, |
|
include_deprecated_args: include_deprecated_args, |
|
include_is_repeatable: include_is_repeatable, |
|
include_specified_by_url: include_specified_by_url, |
|
include_schema_description: include_schema_description, |
|
context: context |
|
) |
|
when :to_definition |
|
schema.to_definition(context: context) |
|
else |
|
raise ArgumentError, "Unexpected schema dump method: #{method_name.inspect}" |
|
end |
|
dir = File.dirname(file) |
|
FileUtils.mkdir_p(dir) |
|
if !result.end_with?("\n") |
|
result += "\n" |
|
end |
|
File.write(file, result) |
|
end |
|
|
|
def idl_path |
|
File.join(@directory, @idl_outfile) |
|
end |
|
|
|
def json_path |
|
File.join(@directory, @json_outfile) |
|
end |
|
|
|
def load_rails_environment_if_defined |
|
if Rake::Task.task_defined?('environment') |
|
Rake::Task['environment'].invoke |
|
end |
|
end |
|
|
|
# Use the Rake DSL to add tasks |
|
def define_task |
|
namespace(@namespace) do |
|
namespace("schema") do |
|
desc("Dump the schema to IDL in #{idl_path}") |
|
task :idl => @dependencies do |
|
load_rails_environment_if_defined |
|
write_outfile(:to_definition, idl_path) |
|
puts "Schema IDL dumped into #{idl_path}" |
|
end |
|
|
|
desc("Dump the schema to JSON in #{json_path}") |
|
task :json => @dependencies do |
|
load_rails_environment_if_defined |
|
write_outfile(:to_json, json_path) |
|
puts "Schema JSON dumped into #{json_path}" |
|
end |
|
|
|
desc("Dump the schema to JSON and IDL") |
|
task :dump => [:idl, :json] |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/relay.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'graphql/relay/range_add' |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/rubocop.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require "graphql/rubocop/graphql/default_null_true" |
|
require "graphql/rubocop/graphql/default_required_true" |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/runtime_type_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class RuntimeTypeError < GraphQL::Error |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/schema.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/schema/addition" |
|
require "graphql/schema/always_visible" |
|
require "graphql/schema/base_64_encoder" |
|
require "graphql/schema/find_inherited_value" |
|
require "graphql/schema/finder" |
|
require "graphql/schema/invalid_type_error" |
|
require "graphql/schema/introspection_system" |
|
require "graphql/schema/late_bound_type" |
|
require "graphql/schema/null_mask" |
|
require "graphql/schema/timeout" |
|
require "graphql/schema/type_expression" |
|
require "graphql/schema/unique_within_type" |
|
require "graphql/schema/warden" |
|
require "graphql/schema/build_from_definition" |
|
|
|
require "graphql/schema/validator" |
|
require "graphql/schema/member" |
|
require "graphql/schema/wrapper" |
|
require "graphql/schema/list" |
|
require "graphql/schema/non_null" |
|
require "graphql/schema/argument" |
|
require "graphql/schema/enum_value" |
|
require "graphql/schema/enum" |
|
require "graphql/schema/field_extension" |
|
require "graphql/schema/field" |
|
require "graphql/schema/input_object" |
|
require "graphql/schema/interface" |
|
require "graphql/schema/scalar" |
|
require "graphql/schema/object" |
|
require "graphql/schema/union" |
|
require "graphql/schema/directive" |
|
require "graphql/schema/directive/deprecated" |
|
require "graphql/schema/directive/include" |
|
require "graphql/schema/directive/one_of" |
|
require "graphql/schema/directive/skip" |
|
require "graphql/schema/directive/feature" |
|
require "graphql/schema/directive/flagged" |
|
require "graphql/schema/directive/transform" |
|
require "graphql/schema/directive/specified_by" |
|
require "graphql/schema/type_membership" |
|
|
|
require "graphql/schema/resolver" |
|
require "graphql/schema/mutation" |
|
require "graphql/schema/has_single_input_argument" |
|
require "graphql/schema/relay_classic_mutation" |
|
require "graphql/schema/subscription" |
|
|
|
module GraphQL |
|
# A GraphQL schema which may be queried with {GraphQL::Query}. |
|
# |
|
# The {Schema} contains: |
|
# |
|
# - types for exposing your application |
|
# - query analyzers for assessing incoming queries (including max depth & max complexity restrictions) |
|
# - execution strategies for running incoming queries |
|
# |
|
# Schemas start with root types, {Schema#query}, {Schema#mutation} and {Schema#subscription}. |
|
# The schema will traverse the tree of fields & types, using those as starting points. |
|
# Any undiscoverable types may be provided with the `types` configuration. |
|
# |
|
# Schemas can restrict large incoming queries with `max_depth` and `max_complexity` configurations. |
|
# (These configurations can be overridden by specific calls to {Schema#execute}) |
|
# |
|
# Schemas can specify how queries should be executed against them. |
|
# `query_execution_strategy`, `mutation_execution_strategy` and `subscription_execution_strategy` |
|
# each apply to corresponding root types. |
|
# |
|
# @example defining a schema |
|
# class MySchema < GraphQL::Schema |
|
# query QueryType |
|
# # If types are only connected by way of interfaces, they must be added here |
|
# orphan_types ImageType, AudioType |
|
# end |
|
# |
|
class Schema |
|
extend GraphQL::Schema::Member::HasAstNode |
|
extend GraphQL::Schema::FindInheritedValue |
|
|
|
class DuplicateNamesError < GraphQL::Error |
|
attr_reader :duplicated_name |
|
def initialize(duplicated_name:, duplicated_definition_1:, duplicated_definition_2:) |
|
@duplicated_name = duplicated_name |
|
super( |
|
"Found two visible definitions for `#{duplicated_name}`: #{duplicated_definition_1}, #{duplicated_definition_2}" |
|
) |
|
end |
|
end |
|
|
|
class UnresolvedLateBoundTypeError < GraphQL::Error |
|
attr_reader :type |
|
def initialize(type:) |
|
@type = type |
|
super("Late bound type was never found: #{type.inspect}") |
|
end |
|
end |
|
|
|
# Error that is raised when [#Schema#from_definition] is passed an invalid schema definition string. |
|
class InvalidDocumentError < Error; end; |
|
|
|
class << self |
|
# Create schema with the result of an introspection query. |
|
# @param introspection_result [Hash] A response from {GraphQL::Introspection::INTROSPECTION_QUERY} |
|
# @return [Class<GraphQL::Schema>] the schema described by `input` |
|
def from_introspection(introspection_result) |
|
GraphQL::Schema::Loader.load(introspection_result) |
|
end |
|
|
|
# Create schema from an IDL schema or file containing an IDL definition. |
|
# @param definition_or_path [String] A schema definition string, or a path to a file containing the definition |
|
# @param default_resolve [<#call(type, field, obj, args, ctx)>] A callable for handling field resolution |
|
# @param parser [Object] An object for handling definition string parsing (must respond to `parse`) |
|
# @param using [Hash] Plugins to attach to the created schema with `use(key, value)` |
|
# @return [Class] the schema described by `document` |
|
def from_definition(definition_or_path, default_resolve: nil, parser: GraphQL.default_parser, using: {}) |
|
# If the file ends in `.graphql` or `.graphqls`, treat it like a filepath |
|
if definition_or_path.end_with?(".graphql") || definition_or_path.end_with?(".graphqls") |
|
GraphQL::Schema::BuildFromDefinition.from_definition_path( |
|
self, |
|
definition_or_path, |
|
default_resolve: default_resolve, |
|
parser: parser, |
|
using: using, |
|
) |
|
else |
|
GraphQL::Schema::BuildFromDefinition.from_definition( |
|
self, |
|
definition_or_path, |
|
default_resolve: default_resolve, |
|
parser: parser, |
|
using: using, |
|
) |
|
end |
|
end |
|
|
|
def deprecated_graphql_definition |
|
graphql_definition(silence_deprecation_warning: true) |
|
end |
|
|
|
# @return [GraphQL::Subscriptions] |
|
def subscriptions(inherited: true) |
|
defined?(@subscriptions) ? @subscriptions : (inherited ? find_inherited_value(:subscriptions, nil) : nil) |
|
end |
|
|
|
def subscriptions=(new_implementation) |
|
@subscriptions = new_implementation |
|
end |
|
|
|
def trace_class(new_class = nil) |
|
if new_class |
|
trace_mode(:default, new_class) |
|
backtrace_class = Class.new(new_class) |
|
backtrace_class.include(GraphQL::Backtrace::Trace) |
|
trace_mode(:default_backtrace, backtrace_class) |
|
end |
|
trace_class_for(:default) |
|
end |
|
|
|
# @return [Class] Return the trace class to use for this mode, looking one up on the superclass if this Schema doesn't have one defined. |
|
def trace_class_for(mode) |
|
@trace_modes ||= {} |
|
@trace_modes[mode] ||= begin |
|
case mode |
|
when :default |
|
superclass_base_class = if superclass.respond_to?(:trace_class_for) |
|
superclass.trace_class_for(mode) |
|
else |
|
GraphQL::Tracing::Trace |
|
end |
|
Class.new(superclass_base_class) |
|
when :default_backtrace |
|
schema_base_class = trace_class_for(:default) |
|
Class.new(schema_base_class) do |
|
include(GraphQL::Backtrace::Trace) |
|
end |
|
else |
|
mods = trace_modules_for(mode) |
|
Class.new(trace_class_for(:default)) do |
|
mods.any? && include(*mods) |
|
end |
|
end |
|
end |
|
end |
|
|
|
# Configure `trace_class` to be used whenever `context: { trace_mode: mode_name }` is requested. |
|
# `:default` is used when no `trace_mode: ...` is requested. |
|
# @param mode_name [Symbol] |
|
# @param trace_class [Class] subclass of GraphQL::Tracing::Trace |
|
# @return void |
|
def trace_mode(mode_name, trace_class) |
|
@trace_modes ||= {} |
|
@trace_modes[mode_name] = trace_class |
|
nil |
|
end |
|
|
|
def own_trace_modules |
|
@own_trace_modules ||= Hash.new { |h, k| h[k] = [] } |
|
end |
|
|
|
# @return [Array<Module>] Modules added for tracing in `trace_mode`, including inherited ones |
|
def trace_modules_for(trace_mode) |
|
modules = own_trace_modules[trace_mode] |
|
if superclass.respond_to?(:trace_modules_for) |
|
modules += superclass.trace_modules_for(trace_mode) |
|
end |
|
modules |
|
end |
|
|
|
|
|
# Returns the JSON response of {Introspection::INTROSPECTION_QUERY}. |
|
# @see {#as_json} |
|
# @return [String] |
|
def to_json(**args) |
|
JSON.pretty_generate(as_json(**args)) |
|
end |
|
|
|
# Return the Hash response of {Introspection::INTROSPECTION_QUERY}. |
|
# @param context [Hash] |
|
# @param only [<#call(member, ctx)>] |
|
# @param except [<#call(member, ctx)>] |
|
# @param include_deprecated_args [Boolean] If true, deprecated arguments will be included in the JSON response |
|
# @param include_schema_description [Boolean] If true, the schema's description will be queried and included in the response |
|
# @param include_is_repeatable [Boolean] If true, `isRepeatable: true|false` will be included with the schema's directives |
|
# @param include_specified_by_url [Boolean] If true, scalar types' `specifiedByUrl:` will be included in the response |
|
# @param include_is_one_of [Boolean] If true, `isOneOf: true|false` will be included with input objects |
|
# @return [Hash] GraphQL result |
|
def as_json(context: {}, include_deprecated_args: true, include_schema_description: false, include_is_repeatable: false, include_specified_by_url: false, include_is_one_of: false) |
|
introspection_query = Introspection.query( |
|
include_deprecated_args: include_deprecated_args, |
|
include_schema_description: include_schema_description, |
|
include_is_repeatable: include_is_repeatable, |
|
include_is_one_of: include_is_one_of, |
|
include_specified_by_url: include_specified_by_url, |
|
) |
|
|
|
execute(introspection_query, context: context).to_h |
|
end |
|
|
|
# Return the GraphQL IDL for the schema |
|
# @param context [Hash] |
|
# @return [String] |
|
def to_definition(context: {}) |
|
GraphQL::Schema::Printer.print_schema(self, context: context) |
|
end |
|
|
|
# Return the GraphQL::Language::Document IDL AST for the schema |
|
# @return [GraphQL::Language::Document] |
|
def to_document |
|
GraphQL::Language::DocumentFromSchemaDefinition.new(self).document |
|
end |
|
|
|
# @return [String, nil] |
|
def description(new_description = nil) |
|
if new_description |
|
@description = new_description |
|
elsif defined?(@description) |
|
@description |
|
else |
|
find_inherited_value(:description, nil) |
|
end |
|
end |
|
|
|
def find(path) |
|
if !@finder |
|
@find_cache = {} |
|
@finder ||= GraphQL::Schema::Finder.new(self) |
|
end |
|
@find_cache[path] ||= @finder.find(path) |
|
end |
|
|
|
def static_validator |
|
GraphQL::StaticValidation::Validator.new(schema: self) |
|
end |
|
|
|
def use(plugin, **kwargs) |
|
if kwargs.any? |
|
plugin.use(self, **kwargs) |
|
else |
|
plugin.use(self) |
|
end |
|
own_plugins << [plugin, kwargs] |
|
end |
|
|
|
def plugins |
|
find_inherited_value(:plugins, EMPTY_ARRAY) + own_plugins |
|
end |
|
|
|
# Build a map of `{ name => type }` and return it |
|
# @return [Hash<String => Class>] A dictionary of type classes by their GraphQL name |
|
# @see get_type Which is more efficient for finding _one type_ by name, because it doesn't merge hashes. |
|
def types(context = GraphQL::Query::NullContext) |
|
all_types = non_introspection_types.merge(introspection_system.types) |
|
visible_types = {} |
|
all_types.each do |k, v| |
|
visible_types[k] =if v.is_a?(Array) |
|
visible_t = nil |
|
v.each do |t| |
|
if t.visible?(context) |
|
if visible_t.nil? |
|
visible_t = t |
|
else |
|
raise DuplicateNamesError.new( |
|
duplicated_name: k, duplicated_definition_1: visible_t.inspect, duplicated_definition_2: t.inspect |
|
) |
|
end |
|
end |
|
end |
|
visible_t |
|
else |
|
v |
|
end |
|
end |
|
visible_types |
|
end |
|
|
|
# @param type_name [String] |
|
# @return [Module, nil] A type, or nil if there's no type called `type_name` |
|
def get_type(type_name, context = GraphQL::Query::NullContext) |
|
local_entry = own_types[type_name] |
|
type_defn = case local_entry |
|
when nil |
|
nil |
|
when Array |
|
visible_t = nil |
|
warden = Warden.from_context(context) |
|
local_entry.each do |t| |
|
if warden.visible_type?(t, context) |
|
if visible_t.nil? |
|
visible_t = t |
|
else |
|
raise DuplicateNamesError.new( |
|
duplicated_name: type_name, duplicated_definition_1: visible_t.inspect, duplicated_definition_2: t.inspect |
|
) |
|
end |
|
end |
|
end |
|
visible_t |
|
when Module |
|
local_entry |
|
else |
|
raise "Invariant: unexpected own_types[#{type_name.inspect}]: #{local_entry.inspect}" |
|
end |
|
|
|
type_defn || |
|
introspection_system.types[type_name] || # todo context-specific introspection? |
|
(superclass.respond_to?(:get_type) ? superclass.get_type(type_name, context) : nil) |
|
end |
|
|
|
# @api private |
|
attr_writer :connections |
|
|
|
# @return [GraphQL::Pagination::Connections] if installed |
|
def connections |
|
if defined?(@connections) |
|
@connections |
|
else |
|
inherited_connections = find_inherited_value(:connections, nil) |
|
# This schema is part of an inheritance chain which is using new connections, |
|
# make a new instance, so we don't pollute the upstream one. |
|
if inherited_connections |
|
@connections = Pagination::Connections.new(schema: self) |
|
else |
|
nil |
|
end |
|
end |
|
end |
|
|
|
def new_connections? |
|
!!connections |
|
end |
|
|
|
def query(new_query_object = nil) |
|
if new_query_object |
|
if @query_object |
|
raise GraphQL::Error, "Second definition of `query(...)` (#{new_query_object.inspect}) is invalid, already configured with #{@query_object.inspect}" |
|
else |
|
@query_object = new_query_object |
|
add_type_and_traverse(new_query_object, root: true) |
|
nil |
|
end |
|
else |
|
@query_object || find_inherited_value(:query) |
|
end |
|
end |
|
|
|
def mutation(new_mutation_object = nil) |
|
if new_mutation_object |
|
if @mutation_object |
|
raise GraphQL::Error, "Second definition of `mutation(...)` (#{new_mutation_object.inspect}) is invalid, already configured with #{@mutation_object.inspect}" |
|
else |
|
@mutation_object = new_mutation_object |
|
add_type_and_traverse(new_mutation_object, root: true) |
|
nil |
|
end |
|
else |
|
@mutation_object || find_inherited_value(:mutation) |
|
end |
|
end |
|
|
|
def subscription(new_subscription_object = nil) |
|
if new_subscription_object |
|
if @subscription_object |
|
raise GraphQL::Error, "Second definition of `subscription(...)` (#{new_subscription_object.inspect}) is invalid, already configured with #{@subscription_object.inspect}" |
|
else |
|
@subscription_object = new_subscription_object |
|
add_subscription_extension_if_necessary |
|
add_type_and_traverse(new_subscription_object, root: true) |
|
nil |
|
end |
|
else |
|
@subscription_object || find_inherited_value(:subscription) |
|
end |
|
end |
|
|
|
# @see [GraphQL::Schema::Warden] Restricted access to root types |
|
# @return [GraphQL::ObjectType, nil] |
|
def root_type_for_operation(operation) |
|
case operation |
|
when "query" |
|
query |
|
when "mutation" |
|
mutation |
|
when "subscription" |
|
subscription |
|
else |
|
raise ArgumentError, "unknown operation type: #{operation}" |
|
end |
|
end |
|
|
|
def root_types |
|
@root_types |
|
end |
|
|
|
def warden_class |
|
if defined?(@warden_class) |
|
@warden_class |
|
elsif superclass.respond_to?(:warden_class) |
|
superclass.warden_class |
|
else |
|
GraphQL::Schema::Warden |
|
end |
|
end |
|
|
|
attr_writer :warden_class |
|
|
|
# @param type [Module] The type definition whose possible types you want to see |
|
# @return [Hash<String, Module>] All possible types, if no `type` is given. |
|
# @return [Array<Module>] Possible types for `type`, if it's given. |
|
def possible_types(type = nil, context = GraphQL::Query::NullContext) |
|
if type |
|
# TODO duck-typing `.possible_types` would probably be nicer here |
|
if type.kind.union? |
|
type.possible_types(context: context) |
|
else |
|
stored_possible_types = own_possible_types[type.graphql_name] |
|
visible_possible_types = if stored_possible_types && type.kind.interface? |
|
stored_possible_types.select do |possible_type| |
|
possible_type.interfaces(context).include?(type) |
|
end |
|
else |
|
stored_possible_types |
|
end |
|
visible_possible_types || |
|
introspection_system.possible_types[type.graphql_name] || |
|
( |
|
superclass.respond_to?(:possible_types) ? |
|
superclass.possible_types(type, context) : |
|
EMPTY_ARRAY |
|
) |
|
end |
|
else |
|
find_inherited_value(:possible_types, EMPTY_HASH) |
|
.merge(own_possible_types) |
|
.merge(introspection_system.possible_types) |
|
end |
|
end |
|
|
|
def union_memberships(type = nil) |
|
if type |
|
own_um = own_union_memberships.fetch(type.graphql_name, EMPTY_ARRAY) |
|
inherited_um = find_inherited_value(:union_memberships, EMPTY_HASH).fetch(type.graphql_name, EMPTY_ARRAY) |
|
own_um + inherited_um |
|
else |
|
joined_um = own_union_memberships.dup |
|
find_inherited_value(:union_memberhips, EMPTY_HASH).each do |k, v| |
|
um = joined_um[k] ||= [] |
|
um.concat(v) |
|
end |
|
joined_um |
|
end |
|
end |
|
|
|
# @api private |
|
# @see GraphQL::Dataloader |
|
def dataloader_class |
|
@dataloader_class || GraphQL::Dataloader::NullDataloader |
|
end |
|
|
|
attr_writer :dataloader_class |
|
|
|
def references_to(to_type = nil, from: nil) |
|
@own_references_to ||= Hash.new { |h, k| h[k] = [] } |
|
if to_type |
|
if !to_type.is_a?(String) |
|
to_type = to_type.graphql_name |
|
end |
|
|
|
if from |
|
@own_references_to[to_type] << from |
|
else |
|
own_refs = @own_references_to[to_type] |
|
inherited_refs = find_inherited_value(:references_to, EMPTY_HASH)[to_type] || EMPTY_ARRAY |
|
own_refs + inherited_refs |
|
end |
|
else |
|
# `@own_references_to` can be quite large for big schemas, |
|
# and generally speaking, we won't inherit any values. |
|
# So optimize the most common case -- don't create a duplicate Hash. |
|
inherited_value = find_inherited_value(:references_to, EMPTY_HASH) |
|
if inherited_value.any? |
|
inherited_value.merge(@own_references_to) |
|
else |
|
@own_references_to |
|
end |
|
end |
|
end |
|
|
|
def type_from_ast(ast_node, context: nil) |
|
type_owner = context ? context.warden : self |
|
GraphQL::Schema::TypeExpression.build_type(type_owner, ast_node) |
|
end |
|
|
|
def get_field(type_or_name, field_name, context = GraphQL::Query::NullContext) |
|
parent_type = case type_or_name |
|
when LateBoundType |
|
get_type(type_or_name.name, context) |
|
when String |
|
get_type(type_or_name, context) |
|
when Module |
|
type_or_name |
|
else |
|
raise GraphQL::InvariantError, "Unexpected field owner for #{field_name.inspect}: #{type_or_name.inspect} (#{type_or_name.class})" |
|
end |
|
|
|
if parent_type.kind.fields? && (field = parent_type.get_field(field_name, context)) |
|
field |
|
elsif parent_type == query && (entry_point_field = introspection_system.entry_point(name: field_name)) |
|
entry_point_field |
|
elsif (dynamic_field = introspection_system.dynamic_field(name: field_name)) |
|
dynamic_field |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def get_fields(type, context = GraphQL::Query::NullContext) |
|
type.fields(context) |
|
end |
|
|
|
def introspection(new_introspection_namespace = nil) |
|
if new_introspection_namespace |
|
@introspection = new_introspection_namespace |
|
# reset this cached value: |
|
@introspection_system = nil |
|
else |
|
@introspection || find_inherited_value(:introspection) |
|
end |
|
end |
|
|
|
def introspection_system |
|
if !@introspection_system |
|
@introspection_system = Schema::IntrospectionSystem.new(self) |
|
@introspection_system.resolve_late_bindings |
|
end |
|
@introspection_system |
|
end |
|
|
|
def cursor_encoder(new_encoder = nil) |
|
if new_encoder |
|
@cursor_encoder = new_encoder |
|
end |
|
@cursor_encoder || find_inherited_value(:cursor_encoder, Base64Encoder) |
|
end |
|
|
|
def default_max_page_size(new_default_max_page_size = nil) |
|
if new_default_max_page_size |
|
@default_max_page_size = new_default_max_page_size |
|
else |
|
@default_max_page_size || find_inherited_value(:default_max_page_size) |
|
end |
|
end |
|
|
|
def default_page_size(new_default_page_size = nil) |
|
if new_default_page_size |
|
@default_page_size = new_default_page_size |
|
else |
|
@default_page_size || find_inherited_value(:default_page_size) |
|
end |
|
end |
|
|
|
def query_execution_strategy(new_query_execution_strategy = nil) |
|
if new_query_execution_strategy |
|
@query_execution_strategy = new_query_execution_strategy |
|
else |
|
@query_execution_strategy || find_inherited_value(:query_execution_strategy, self.default_execution_strategy) |
|
end |
|
end |
|
|
|
def mutation_execution_strategy(new_mutation_execution_strategy = nil) |
|
if new_mutation_execution_strategy |
|
@mutation_execution_strategy = new_mutation_execution_strategy |
|
else |
|
@mutation_execution_strategy || find_inherited_value(:mutation_execution_strategy, self.default_execution_strategy) |
|
end |
|
end |
|
|
|
def subscription_execution_strategy(new_subscription_execution_strategy = nil) |
|
if new_subscription_execution_strategy |
|
@subscription_execution_strategy = new_subscription_execution_strategy |
|
else |
|
@subscription_execution_strategy || find_inherited_value(:subscription_execution_strategy, self.default_execution_strategy) |
|
end |
|
end |
|
|
|
attr_writer :validate_timeout |
|
|
|
def validate_timeout(new_validate_timeout = nil) |
|
if new_validate_timeout |
|
@validate_timeout = new_validate_timeout |
|
elsif defined?(@validate_timeout) |
|
@validate_timeout |
|
else |
|
find_inherited_value(:validate_timeout) |
|
end |
|
end |
|
|
|
# Validate a query string according to this schema. |
|
# @param string_or_document [String, GraphQL::Language::Nodes::Document] |
|
# @return [Array<GraphQL::StaticValidation::Error >] |
|
def validate(string_or_document, rules: nil, context: nil) |
|
doc = if string_or_document.is_a?(String) |
|
GraphQL.parse(string_or_document) |
|
else |
|
string_or_document |
|
end |
|
query = GraphQL::Query.new(self, document: doc, context: context) |
|
validator_opts = { schema: self } |
|
rules && (validator_opts[:rules] = rules) |
|
validator = GraphQL::StaticValidation::Validator.new(**validator_opts) |
|
res = validator.validate(query, timeout: validate_timeout, max_errors: validate_max_errors) |
|
res[:errors] |
|
end |
|
|
|
attr_writer :validate_max_errors |
|
|
|
def validate_max_errors(new_validate_max_errors = nil) |
|
if new_validate_max_errors |
|
@validate_max_errors = new_validate_max_errors |
|
elsif defined?(@validate_max_errors) |
|
@validate_max_errors |
|
else |
|
find_inherited_value(:validate_max_errors) |
|
end |
|
end |
|
|
|
attr_writer :max_complexity |
|
|
|
def max_complexity(max_complexity = nil) |
|
if max_complexity |
|
@max_complexity = max_complexity |
|
elsif defined?(@max_complexity) |
|
@max_complexity |
|
else |
|
find_inherited_value(:max_complexity) |
|
end |
|
end |
|
|
|
attr_writer :analysis_engine |
|
|
|
def analysis_engine |
|
@analysis_engine || find_inherited_value(:analysis_engine, self.default_analysis_engine) |
|
end |
|
|
|
def using_ast_analysis? |
|
true |
|
end |
|
|
|
def interpreter? |
|
true |
|
end |
|
|
|
attr_writer :interpreter |
|
|
|
def error_bubbling(new_error_bubbling = nil) |
|
if !new_error_bubbling.nil? |
|
@error_bubbling = new_error_bubbling |
|
else |
|
@error_bubbling.nil? ? find_inherited_value(:error_bubbling) : @error_bubbling |
|
end |
|
end |
|
|
|
attr_writer :error_bubbling |
|
|
|
attr_writer :max_depth |
|
|
|
def max_depth(new_max_depth = nil) |
|
if new_max_depth |
|
@max_depth = new_max_depth |
|
elsif defined?(@max_depth) |
|
@max_depth |
|
else |
|
find_inherited_value(:max_depth) |
|
end |
|
end |
|
|
|
def disable_introspection_entry_points |
|
@disable_introspection_entry_points = true |
|
# TODO: this clears the cache made in `def types`. But this is not a great solution. |
|
@introspection_system = nil |
|
end |
|
|
|
def disable_schema_introspection_entry_point |
|
@disable_schema_introspection_entry_point = true |
|
# TODO: this clears the cache made in `def types`. But this is not a great solution. |
|
@introspection_system = nil |
|
end |
|
|
|
def disable_type_introspection_entry_point |
|
@disable_type_introspection_entry_point = true |
|
# TODO: this clears the cache made in `def types`. But this is not a great solution. |
|
@introspection_system = nil |
|
end |
|
|
|
def disable_introspection_entry_points? |
|
if instance_variable_defined?(:@disable_introspection_entry_points) |
|
@disable_introspection_entry_points |
|
else |
|
find_inherited_value(:disable_introspection_entry_points?, false) |
|
end |
|
end |
|
|
|
def disable_schema_introspection_entry_point? |
|
if instance_variable_defined?(:@disable_schema_introspection_entry_point) |
|
@disable_schema_introspection_entry_point |
|
else |
|
find_inherited_value(:disable_schema_introspection_entry_point?, false) |
|
end |
|
end |
|
|
|
def disable_type_introspection_entry_point? |
|
if instance_variable_defined?(:@disable_type_introspection_entry_point) |
|
@disable_type_introspection_entry_point |
|
else |
|
find_inherited_value(:disable_type_introspection_entry_point?, false) |
|
end |
|
end |
|
|
|
def orphan_types(*new_orphan_types) |
|
if new_orphan_types.any? |
|
new_orphan_types = new_orphan_types.flatten |
|
add_type_and_traverse(new_orphan_types, root: false) |
|
own_orphan_types.concat(new_orphan_types.flatten) |
|
end |
|
|
|
inherited_ot = find_inherited_value(:orphan_types, nil) |
|
if inherited_ot |
|
if own_orphan_types.any? |
|
inherited_ot + own_orphan_types |
|
else |
|
inherited_ot |
|
end |
|
else |
|
own_orphan_types |
|
end |
|
end |
|
|
|
def default_execution_strategy |
|
if superclass <= GraphQL::Schema |
|
superclass.default_execution_strategy |
|
else |
|
@default_execution_strategy ||= GraphQL::Execution::Interpreter |
|
end |
|
end |
|
|
|
def default_analysis_engine |
|
if superclass <= GraphQL::Schema |
|
superclass.default_analysis_engine |
|
else |
|
@default_analysis_engine ||= GraphQL::Analysis::AST |
|
end |
|
end |
|
|
|
def context_class(new_context_class = nil) |
|
if new_context_class |
|
@context_class = new_context_class |
|
else |
|
@context_class || find_inherited_value(:context_class, GraphQL::Query::Context) |
|
end |
|
end |
|
|
|
def rescue_from(*err_classes, &handler_block) |
|
err_classes.each do |err_class| |
|
Execution::Errors.register_rescue_from(err_class, error_handlers[:subclass_handlers], handler_block) |
|
end |
|
end |
|
|
|
NEW_HANDLER_HASH = ->(h, k) { |
|
h[k] = { |
|
class: k, |
|
handler: nil, |
|
subclass_handlers: Hash.new(&NEW_HANDLER_HASH), |
|
} |
|
} |
|
|
|
def error_handlers |
|
@error_handlers ||= { |
|
class: nil, |
|
handler: nil, |
|
subclass_handlers: Hash.new(&NEW_HANDLER_HASH), |
|
} |
|
end |
|
|
|
# @api private |
|
def handle_or_reraise(context, err) |
|
handler = Execution::Errors.find_handler_for(self, err.class) |
|
if handler |
|
obj = context[:current_object] |
|
args = context[:current_arguments] |
|
args = args && args.respond_to?(:keyword_arguments) ? args.keyword_arguments : nil |
|
field = context[:current_field] |
|
if obj.is_a?(GraphQL::Schema::Object) |
|
obj = obj.object |
|
end |
|
handler[:handler].call(err, obj, args, context, field) |
|
else |
|
raise err |
|
end |
|
end |
|
|
|
# rubocop:disable Lint/DuplicateMethods |
|
module ResolveTypeWithType |
|
def resolve_type(type, obj, ctx) |
|
maybe_lazy_resolve_type_result = if type.is_a?(Module) && type.respond_to?(:resolve_type) |
|
type.resolve_type(obj, ctx) |
|
else |
|
super |
|
end |
|
|
|
after_lazy(maybe_lazy_resolve_type_result) do |resolve_type_result| |
|
if resolve_type_result.is_a?(Array) && resolve_type_result.size == 2 |
|
resolved_type = resolve_type_result[0] |
|
resolved_value = resolve_type_result[1] |
|
else |
|
resolved_type = resolve_type_result |
|
resolved_value = obj |
|
end |
|
|
|
if resolved_type.nil? || (resolved_type.is_a?(Module) && resolved_type.respond_to?(:kind)) |
|
[resolved_type, resolved_value] |
|
else |
|
raise ".resolve_type should return a type definition, but got #{resolved_type.inspect} (#{resolved_type.class}) from `resolve_type(#{type}, #{obj}, #{ctx})`" |
|
end |
|
end |
|
end |
|
end |
|
|
|
def resolve_type(type, obj, ctx) |
|
if type.kind.object? |
|
type |
|
else |
|
raise GraphQL::RequiredImplementationMissingError, "#{self.name}.resolve_type(type, obj, ctx) must be implemented to use Union types or Interface types (tried to resolve: #{type.name})" |
|
end |
|
end |
|
# rubocop:enable Lint/DuplicateMethods |
|
|
|
def inherited(child_class) |
|
if self == GraphQL::Schema |
|
child_class.directives(default_directives.values) |
|
end |
|
child_class.singleton_class.prepend(ResolveTypeWithType) |
|
super |
|
end |
|
|
|
def object_from_id(node_id, ctx) |
|
raise GraphQL::RequiredImplementationMissingError, "#{self.name}.object_from_id(node_id, ctx) must be implemented to load by ID (tried to load from id `#{node_id}`)" |
|
end |
|
|
|
def id_from_object(object, type, ctx) |
|
raise GraphQL::RequiredImplementationMissingError, "#{self.name}.id_from_object(object, type, ctx) must be implemented to create global ids (tried to create an id for `#{object.inspect}`)" |
|
end |
|
|
|
def visible?(member, ctx) |
|
member.visible?(ctx) |
|
end |
|
|
|
def schema_directive(dir_class, **options) |
|
@own_schema_directives ||= [] |
|
Member::HasDirectives.add_directive(self, @own_schema_directives, dir_class, options) |
|
end |
|
|
|
def schema_directives |
|
Member::HasDirectives.get_directives(self, @own_schema_directives, :schema_directives) |
|
end |
|
|
|
# This hook is called when an object fails an `authorized?` check. |
|
# You might report to your bug tracker here, so you can correct |
|
# the field resolvers not to return unauthorized objects. |
|
# |
|
# By default, this hook just replaces the unauthorized object with `nil`. |
|
# |
|
# Whatever value is returned from this method will be used instead of the |
|
# unauthorized object (accessible as `unauthorized_error.object`). If an |
|
# error is raised, then `nil` will be used. |
|
# |
|
# If you want to add an error to the `"errors"` key, raise a {GraphQL::ExecutionError} |
|
# in this hook. |
|
# |
|
# @param unauthorized_error [GraphQL::UnauthorizedError] |
|
# @return [Object] The returned object will be put in the GraphQL response |
|
def unauthorized_object(unauthorized_error) |
|
nil |
|
end |
|
|
|
# This hook is called when a field fails an `authorized?` check. |
|
# |
|
# By default, this hook implements the same behavior as unauthorized_object. |
|
# |
|
# Whatever value is returned from this method will be used instead of the |
|
# unauthorized field . If an error is raised, then `nil` will be used. |
|
# |
|
# If you want to add an error to the `"errors"` key, raise a {GraphQL::ExecutionError} |
|
# in this hook. |
|
# |
|
# @param unauthorized_error [GraphQL::UnauthorizedFieldError] |
|
# @return [Field] The returned field will be put in the GraphQL response |
|
def unauthorized_field(unauthorized_error) |
|
unauthorized_object(unauthorized_error) |
|
end |
|
|
|
def type_error(type_error, ctx) |
|
case type_error |
|
when GraphQL::InvalidNullError |
|
ctx.errors << type_error |
|
when GraphQL::UnresolvedTypeError, GraphQL::StringEncodingError, GraphQL::IntegerEncodingError |
|
raise type_error |
|
when GraphQL::IntegerDecodingError |
|
nil |
|
end |
|
end |
|
|
|
# A function to call when {#execute} receives an invalid query string |
|
# |
|
# The default is to add the error to `context.errors` |
|
# @param parse_err [GraphQL::ParseError] The error encountered during parsing |
|
# @param ctx [GraphQL::Query::Context] The context for the query where the error occurred |
|
# @return void |
|
def parse_error(parse_err, ctx) |
|
ctx.errors.push(parse_err) |
|
end |
|
|
|
def lazy_resolve(lazy_class, value_method) |
|
lazy_methods.set(lazy_class, value_method) |
|
end |
|
|
|
def instrument(instrument_step, instrumenter, options = {}) |
|
own_instrumenters[instrument_step] << instrumenter |
|
end |
|
|
|
# Add several directives at once |
|
# @param new_directives [Class] |
|
def directives(*new_directives) |
|
if new_directives.any? |
|
new_directives.flatten.each { |d| directive(d) } |
|
end |
|
|
|
inherited_dirs = find_inherited_value(:directives, default_directives) |
|
if own_directives.any? |
|
inherited_dirs.merge(own_directives) |
|
else |
|
inherited_dirs |
|
end |
|
end |
|
|
|
# Attach a single directive to this schema |
|
# @param new_directive [Class] |
|
# @return void |
|
def directive(new_directive) |
|
add_type_and_traverse(new_directive, root: false) |
|
end |
|
|
|
def default_directives |
|
@default_directives ||= { |
|
"include" => GraphQL::Schema::Directive::Include, |
|
"skip" => GraphQL::Schema::Directive::Skip, |
|
"deprecated" => GraphQL::Schema::Directive::Deprecated, |
|
"oneOf" => GraphQL::Schema::Directive::OneOf, |
|
"specifiedBy" => GraphQL::Schema::Directive::SpecifiedBy, |
|
}.freeze |
|
end |
|
|
|
def tracer(new_tracer) |
|
if !(trace_class_for(:default) < GraphQL::Tracing::CallLegacyTracers) |
|
trace_with(GraphQL::Tracing::CallLegacyTracers) |
|
end |
|
|
|
own_tracers << new_tracer |
|
end |
|
|
|
def tracers |
|
find_inherited_value(:tracers, EMPTY_ARRAY) + own_tracers |
|
end |
|
|
|
# Mix `trace_mod` into this schema's `Trace` class so that its methods |
|
# will be called at runtime. |
|
# |
|
# @param trace_mod [Module] A module that implements tracing methods |
|
# @param mode [Symbol] Trace module will only be used for this trade mode |
|
# @param options [Hash] Keywords that will be passed to the tracing class during `#initialize` |
|
# @return [void] |
|
def trace_with(trace_mod, mode: :default, **options) |
|
if mode.is_a?(Array) |
|
mode.each { |m| trace_with(trace_mod, mode: m, **options) } |
|
else |
|
tc = trace_class_for(mode) |
|
tc.include(trace_mod) |
|
if mode != :default |
|
own_trace_modules[mode] << trace_mod |
|
end |
|
t_opts = trace_options_for(mode) |
|
t_opts.merge!(options) |
|
end |
|
nil |
|
end |
|
|
|
# The options hash for this trace mode |
|
# @return [Hash] |
|
def trace_options_for(mode) |
|
@trace_options_for_mode ||= {} |
|
@trace_options_for_mode[mode] ||= begin |
|
if superclass.respond_to?(:trace_options_for) |
|
superclass.trace_options_for(mode).dup |
|
else |
|
{} |
|
end |
|
end |
|
end |
|
|
|
# Create a trace instance which will include the trace modules specified for the optional mode. |
|
# |
|
# @param mode [Symbol] Trace modules for this trade mode will be included |
|
# @param options [Hash] Keywords that will be passed to the tracing class during `#initialize` |
|
# @return [Tracing::Trace] |
|
def new_trace(mode: nil, **options) |
|
target = options[:query] || options[:multiplex] |
|
mode ||= target && target.context[:trace_mode] |
|
|
|
trace_mode = if mode |
|
mode |
|
elsif target && target.context[:backtrace] |
|
:default_backtrace |
|
else |
|
:default |
|
end |
|
|
|
base_trace_options = trace_options_for(trace_mode) |
|
trace_options = base_trace_options.merge(options) |
|
trace_class_for_mode = trace_class_for(trace_mode) |
|
trace_class_for_mode.new(**trace_options) |
|
end |
|
|
|
def query_analyzer(new_analyzer) |
|
own_query_analyzers << new_analyzer |
|
end |
|
|
|
def query_analyzers |
|
find_inherited_value(:query_analyzers, EMPTY_ARRAY) + own_query_analyzers |
|
end |
|
|
|
def multiplex_analyzer(new_analyzer) |
|
own_multiplex_analyzers << new_analyzer |
|
end |
|
|
|
def multiplex_analyzers |
|
find_inherited_value(:multiplex_analyzers, EMPTY_ARRAY) + own_multiplex_analyzers |
|
end |
|
|
|
def sanitized_printer(new_sanitized_printer = nil) |
|
if new_sanitized_printer |
|
@own_sanitized_printer = new_sanitized_printer |
|
else |
|
@own_sanitized_printer || GraphQL::Language::SanitizedPrinter |
|
end |
|
end |
|
|
|
# Execute a query on itself. |
|
# @see {Query#initialize} for arguments. |
|
# @return [Hash] query result, ready to be serialized as JSON |
|
def execute(query_str = nil, **kwargs) |
|
if query_str |
|
kwargs[:query] = query_str |
|
end |
|
# Some of the query context _should_ be passed to the multiplex, too |
|
multiplex_context = if (ctx = kwargs[:context]) |
|
{ |
|
backtrace: ctx[:backtrace], |
|
tracers: ctx[:tracers], |
|
trace: ctx[:trace], |
|
dataloader: ctx[:dataloader], |
|
trace_mode: ctx[:trace_mode], |
|
} |
|
else |
|
{} |
|
end |
|
# Since we're running one query, don't run a multiplex-level complexity analyzer |
|
all_results = multiplex([kwargs], max_complexity: nil, context: multiplex_context) |
|
all_results[0] |
|
end |
|
|
|
# Execute several queries on itself, concurrently. |
|
# |
|
# @example Run several queries at once |
|
# context = { ... } |
|
# queries = [ |
|
# { query: params[:query_1], variables: params[:variables_1], context: context }, |
|
# { query: params[:query_2], variables: params[:variables_2], context: context }, |
|
# ] |
|
# results = MySchema.multiplex(queries) |
|
# render json: { |
|
# result_1: results[0], |
|
# result_2: results[1], |
|
# } |
|
# |
|
# @see {Query#initialize} for query keyword arguments |
|
# @see {Execution::Multiplex#run_all} for multiplex keyword arguments |
|
# @param queries [Array<Hash>] Keyword arguments for each query |
|
# @param context [Hash] Multiplex-level context |
|
# @return [Array<Hash>] One result for each query in the input |
|
def multiplex(queries, **kwargs) |
|
GraphQL::Execution::Interpreter.run_all(self, queries, **kwargs) |
|
end |
|
|
|
def instrumenters |
|
inherited_instrumenters = find_inherited_value(:instrumenters) || Hash.new { |h,k| h[k] = [] } |
|
inherited_instrumenters.merge(own_instrumenters) do |_step, inherited, own| |
|
inherited + own |
|
end |
|
end |
|
|
|
# @api private |
|
def add_subscription_extension_if_necessary |
|
if !defined?(@subscription_extension_added) && subscription && self.subscriptions |
|
@subscription_extension_added = true |
|
subscription.all_field_definitions.each do |field| |
|
if !field.extensions.any? { |ext| ext.is_a?(Subscriptions::DefaultSubscriptionResolveExtension) } |
|
field.extension(Subscriptions::DefaultSubscriptionResolveExtension) |
|
end |
|
end |
|
end |
|
end |
|
|
|
def query_stack_error(query, err) |
|
query.context.errors.push(GraphQL::ExecutionError.new("This query is too large to execute.")) |
|
end |
|
|
|
# Call the given block at the right time, either: |
|
# - Right away, if `value` is not registered with `lazy_resolve` |
|
# - After resolving `value`, if it's registered with `lazy_resolve` (eg, `Promise`) |
|
# @api private |
|
def after_lazy(value, &block) |
|
if lazy?(value) |
|
GraphQL::Execution::Lazy.new do |
|
result = sync_lazy(value) |
|
# The returned result might also be lazy, so check it, too |
|
after_lazy(result, &block) |
|
end |
|
else |
|
yield(value) if block_given? |
|
end |
|
end |
|
|
|
# Override this method to handle lazy objects in a custom way. |
|
# @param value [Object] an instance of a class registered with {.lazy_resolve} |
|
# @return [Object] A GraphQL-ready (non-lazy) object |
|
# @api private |
|
def sync_lazy(value) |
|
lazy_method = lazy_method_name(value) |
|
if lazy_method |
|
synced_value = value.public_send(lazy_method) |
|
sync_lazy(synced_value) |
|
else |
|
value |
|
end |
|
end |
|
|
|
# @return [Symbol, nil] The method name to lazily resolve `obj`, or nil if `obj`'s class wasn't registered with {#lazy_resolve}. |
|
def lazy_method_name(obj) |
|
lazy_methods.get(obj) |
|
end |
|
|
|
# @return [Boolean] True if this object should be lazily resolved |
|
def lazy?(obj) |
|
!!lazy_method_name(obj) |
|
end |
|
|
|
# Return a lazy if any of `maybe_lazies` are lazy, |
|
# otherwise, call the block eagerly and return the result. |
|
# @param maybe_lazies [Array] |
|
# @api private |
|
def after_any_lazies(maybe_lazies) |
|
if maybe_lazies.any? { |l| lazy?(l) } |
|
GraphQL::Execution::Lazy.all(maybe_lazies).then do |result| |
|
yield result |
|
end |
|
else |
|
yield maybe_lazies |
|
end |
|
end |
|
|
|
private |
|
|
|
# @param t [Module, Array<Module>] |
|
# @return [void] |
|
def add_type_and_traverse(t, root:) |
|
if root |
|
@root_types ||= [] |
|
@root_types << t |
|
end |
|
new_types = Array(t) |
|
addition = Schema::Addition.new(schema: self, own_types: own_types, new_types: new_types) |
|
addition.types.each do |name, types_entry| # rubocop:disable Development/ContextIsPassedCop -- build-time, not query-time |
|
if (prev_entry = own_types[name]) |
|
prev_entries = case prev_entry |
|
when Array |
|
prev_entry |
|
when Module |
|
own_types[name] = [prev_entry] |
|
else |
|
raise "Invariant: unexpected prev_entry at #{name.inspect} when adding #{t.inspect}" |
|
end |
|
|
|
case types_entry |
|
when Array |
|
prev_entries.concat(types_entry) |
|
prev_entries.uniq! # in case any are being re-visited |
|
when Module |
|
if !prev_entries.include?(types_entry) |
|
prev_entries << types_entry |
|
end |
|
else |
|
raise "Invariant: unexpected types_entry at #{name} when adding #{t.inspect}" |
|
end |
|
else |
|
if types_entry.is_a?(Array) |
|
types_entry.uniq! |
|
end |
|
own_types[name] = types_entry |
|
end |
|
end |
|
|
|
own_possible_types.merge!(addition.possible_types) { |key, old_val, new_val| old_val + new_val } |
|
own_union_memberships.merge!(addition.union_memberships) |
|
|
|
addition.references.each { |thing, pointers| |
|
pointers.each { |pointer| references_to(thing, from: pointer) } |
|
} |
|
|
|
addition.directives.each { |dir_class| own_directives[dir_class.graphql_name] = dir_class } |
|
|
|
addition.arguments_with_default_values.each do |arg| |
|
arg.validate_default_value |
|
end |
|
end |
|
|
|
def lazy_methods |
|
if !defined?(@lazy_methods) |
|
if inherited_map = find_inherited_value(:lazy_methods) |
|
# this isn't _completely_ inherited :S (Things added after `dup` won't work) |
|
@lazy_methods = inherited_map.dup |
|
else |
|
@lazy_methods = GraphQL::Execution::Lazy::LazyMethodMap.new |
|
@lazy_methods.set(GraphQL::Execution::Lazy, :value) |
|
@lazy_methods.set(GraphQL::Dataloader::Request, :load) |
|
end |
|
end |
|
@lazy_methods |
|
end |
|
|
|
def own_types |
|
@own_types ||= {} |
|
end |
|
|
|
def non_introspection_types |
|
find_inherited_value(:non_introspection_types, EMPTY_HASH).merge(own_types) |
|
end |
|
|
|
def own_plugins |
|
@own_plugins ||= [] |
|
end |
|
|
|
def own_orphan_types |
|
@own_orphan_types ||= [] |
|
end |
|
|
|
def own_possible_types |
|
@own_possible_types ||= {} |
|
end |
|
|
|
def own_union_memberships |
|
@own_union_memberships ||= {} |
|
end |
|
|
|
def own_directives |
|
@own_directives ||= {} |
|
end |
|
|
|
def own_instrumenters |
|
@own_instrumenters ||= Hash.new { |h,k| h[k] = [] } |
|
end |
|
|
|
def own_tracers |
|
@own_tracers ||= [] |
|
end |
|
|
|
def own_query_analyzers |
|
@defined_query_analyzers ||= [] |
|
end |
|
|
|
def own_multiplex_analyzers |
|
@own_multiplex_analyzers ||= [] |
|
end |
|
end |
|
|
|
# Install these here so that subclasses will also install it. |
|
self.connections = GraphQL::Pagination::Connections.new(schema: self) |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [zone]: Demo cluster `nimbus-4` is pinned to availability zone `zone-delta-2`. |
|
|
|
|
|
### oss/graphql-ruby/lib/graphql/static_validation.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/static_validation/error" |
|
require "graphql/static_validation/definition_dependencies" |
|
require "graphql/static_validation/validator" |
|
require "graphql/static_validation/validation_context" |
|
require "graphql/static_validation/validation_timeout_error" |
|
require "graphql/static_validation/literal_validator" |
|
require "graphql/static_validation/base_visitor" |
|
|
|
rules_glob = File.expand_path("../static_validation/rules/*.rb", __FILE__) |
|
Dir.glob(rules_glob).each do |file| |
|
require(file) |
|
end |
|
|
|
require "graphql/static_validation/all_rules" |
|
require "graphql/static_validation/interpreter_visitor" |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/string_encoding_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class StringEncodingError < GraphQL::RuntimeTypeError |
|
attr_reader :string, :field, :path |
|
def initialize(str, context:) |
|
@string = str |
|
@field = context[:current_field] |
|
@path = context[:current_path] |
|
message = "String #{str.inspect} was encoded as #{str.encoding}".dup |
|
if @path |
|
message << " @ #{@path.join(".")}" |
|
end |
|
if @field |
|
message << " (#{@field.path})" |
|
end |
|
message << ". GraphQL requires an encoding compatible with UTF-8." |
|
super(message) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/subscriptions.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "securerandom" |
|
require "graphql/subscriptions/broadcast_analyzer" |
|
require "graphql/subscriptions/event" |
|
require "graphql/subscriptions/instrumentation" |
|
require "graphql/subscriptions/serialize" |
|
require "graphql/subscriptions/action_cable_subscriptions" |
|
require "graphql/subscriptions/default_subscription_resolve_extension" |
|
|
|
module GraphQL |
|
class Subscriptions |
|
# Raised when either: |
|
# - the triggered `event_name` doesn't match a field in the schema; or |
|
# - one or more arguments don't match the field arguments |
|
class InvalidTriggerError < GraphQL::Error |
|
end |
|
|
|
# Raised when either: |
|
# - An initial subscription didn't have a value for `context[subscription_scope]` |
|
# - Or, an update didn't pass `.trigger(..., scope:)` |
|
# When raised, the initial subscription or update fails completely. |
|
class SubscriptionScopeMissingError < GraphQL::Error |
|
end |
|
|
|
# @see {Subscriptions#initialize} for options, concrete implementations may add options. |
|
def self.use(defn, options = {}) |
|
schema = defn.is_a?(Class) ? defn : defn.target |
|
|
|
if schema.subscriptions(inherited: false) |
|
raise ArgumentError, "Can't reinstall subscriptions. #{schema} is using #{schema.subscriptions}, can't also add #{self}" |
|
end |
|
|
|
instrumentation = Subscriptions::Instrumentation.new(schema: schema) |
|
defn.instrument(:query, instrumentation) |
|
options[:schema] = schema |
|
schema.subscriptions = self.new(**options) |
|
schema.add_subscription_extension_if_necessary |
|
nil |
|
end |
|
|
|
# @param schema [Class] the GraphQL schema this manager belongs to |
|
# @param validate_update [Boolean] If false, then validation is skipped when executing updates |
|
def initialize(schema:, validate_update: true, broadcast: false, default_broadcastable: false, **rest) |
|
if broadcast |
|
schema.query_analyzer(Subscriptions::BroadcastAnalyzer) |
|
end |
|
@default_broadcastable = default_broadcastable |
|
@schema = schema |
|
@validate_update = validate_update |
|
end |
|
|
|
# @return [Boolean] Used when fields don't have `broadcastable:` explicitly set |
|
attr_reader :default_broadcastable |
|
|
|
# Fetch subscriptions matching this field + arguments pair |
|
# And pass them off to the queue. |
|
# @param event_name [String] |
|
# @param args [Hash<String, Symbol => Object] |
|
# @param object [Object] |
|
# @param scope [Symbol, String] |
|
# @param context [Hash] |
|
# @return [void] |
|
def trigger(event_name, args, object, scope: nil, context: {}) |
|
# Make something as context-like as possible, even though there isn't a current query: |
|
dummy_query = GraphQL::Query.new(@schema, "", validate: false, context: context) |
|
context = dummy_query.context |
|
event_name = event_name.to_s |
|
|
|
# Try with the verbatim input first: |
|
field = @schema.get_field(@schema.subscription, event_name, context) |
|
|
|
if field.nil? |
|
# And if it wasn't found, normalize it: |
|
normalized_event_name = normalize_name(event_name) |
|
field = @schema.get_field(@schema.subscription, normalized_event_name, context) |
|
if field.nil? |
|
raise InvalidTriggerError, "No subscription matching trigger: #{event_name} (looked for #{@schema.subscription.graphql_name}.#{normalized_event_name})" |
|
end |
|
else |
|
# Since we found a field, the original input was already normalized |
|
normalized_event_name = event_name |
|
end |
|
|
|
# Normalize symbol-keyed args to strings, try camelizing them |
|
# Should this accept a real context somehow? |
|
normalized_args = normalize_arguments(normalized_event_name, field, args, GraphQL::Query::NullContext) |
|
|
|
event = Subscriptions::Event.new( |
|
name: normalized_event_name, |
|
arguments: normalized_args, |
|
field: field, |
|
scope: scope, |
|
context: context, |
|
) |
|
execute_all(event, object) |
|
end |
|
|
|
# `event` was triggered on `object`, and `subscription_id` was subscribed, |
|
# so it should be updated. |
|
# |
|
# Load `subscription_id`'s GraphQL data, re-evaluate the query and return the result. |
|
# |
|
# @param subscription_id [String] |
|
# @param event [GraphQL::Subscriptions::Event] The event which was triggered |
|
# @param object [Object] The value for the subscription field |
|
# @return [GraphQL::Query::Result] |
|
def execute_update(subscription_id, event, object) |
|
# Lookup the saved data for this subscription |
|
query_data = read_subscription(subscription_id) |
|
if query_data.nil? |
|
delete_subscription(subscription_id) |
|
return nil |
|
end |
|
|
|
# Fetch the required keys from the saved data |
|
query_string = query_data.fetch(:query_string) |
|
variables = query_data.fetch(:variables) |
|
context = query_data.fetch(:context) |
|
operation_name = query_data.fetch(:operation_name) |
|
execute_options = { |
|
query: query_string, |
|
context: context, |
|
subscription_topic: event.topic, |
|
operation_name: operation_name, |
|
variables: variables, |
|
root_value: object, |
|
} |
|
|
|
# merge event's and query's context together |
|
context.merge!(event.context) unless event.context.nil? || context.nil? |
|
|
|
execute_options[:validate] = validate_update?(**execute_options) |
|
result = @schema.execute(**execute_options) |
|
subscriptions_context = result.context.namespace(:subscriptions) |
|
if subscriptions_context[:no_update] |
|
result = nil |
|
end |
|
|
|
if subscriptions_context[:unsubscribed] && !subscriptions_context[:final_update] |
|
# `unsubscribe` was called, clean up on our side |
|
# The transport should also send `{more: false}` to client |
|
delete_subscription(subscription_id) |
|
result = nil |
|
end |
|
|
|
result |
|
end |
|
|
|
# Define this method to customize whether to validate |
|
# this subscription when executing an update. |
|
# |
|
# @return [Boolean] defaults to `true`, or false if `validate: false` is provided. |
|
def validate_update?(query:, context:, root_value:, subscription_topic:, operation_name:, variables:) |
|
@validate_update |
|
end |
|
|
|
# Run the update query for this subscription and deliver it |
|
# @see {#execute_update} |
|
# @see {#deliver} |
|
# @return [void] |
|
def execute(subscription_id, event, object) |
|
res = execute_update(subscription_id, event, object) |
|
if !res.nil? |
|
deliver(subscription_id, res) |
|
|
|
if res.context.namespace(:subscriptions)[:unsubscribed] |
|
# `unsubscribe` was called, clean up on our side |
|
# The transport should also send `{more: false}` to client |
|
delete_subscription(subscription_id) |
|
end |
|
end |
|
|
|
end |
|
|
|
# Event `event` occurred on `object`, |
|
# Update all subscribers. |
|
# @param event [Subscriptions::Event] |
|
# @param object [Object] |
|
# @return [void] |
|
def execute_all(event, object) |
|
raise GraphQL::RequiredImplementationMissingError |
|
end |
|
|
|
# The system wants to send an update to this subscription. |
|
# Read its data and return it. |
|
# @param subscription_id [String] |
|
# @return [Hash] Containing required keys |
|
def read_subscription(subscription_id) |
|
raise GraphQL::RequiredImplementationMissingError |
|
end |
|
|
|
# A subscription query was re-evaluated, returning `result`. |
|
# The result should be send to `subscription_id`. |
|
# @param subscription_id [String] |
|
# @param result [Hash] |
|
# @return [void] |
|
def deliver(subscription_id, result) |
|
raise GraphQL::RequiredImplementationMissingError |
|
end |
|
|
|
# `query` was executed and found subscriptions to `events`. |
|
# Update the database to reflect this new state. |
|
# @param query [GraphQL::Query] |
|
# @param events [Array<GraphQL::Subscriptions::Event>] |
|
# @return [void] |
|
def write_subscription(query, events) |
|
raise GraphQL::RequiredImplementationMissingError |
|
end |
|
|
|
# A subscription was terminated server-side. |
|
# Clean up the database. |
|
# @param subscription_id [String] |
|
# @return void. |
|
def delete_subscription(subscription_id) |
|
raise GraphQL::RequiredImplementationMissingError |
|
end |
|
|
|
# @return [String] A new unique identifier for a subscription |
|
def build_id |
|
SecureRandom.uuid |
|
end |
|
|
|
# Convert a user-provided event name or argument |
|
# to the equivalent in GraphQL. |
|
# |
|
# By default, it converts the identifier to camelcase. |
|
# Override this in a subclass to change the transformation. |
|
# |
|
# @param event_or_arg_name [String, Symbol] |
|
# @return [String] |
|
def normalize_name(event_or_arg_name) |
|
Schema::Member::BuildType.camelize(event_or_arg_name.to_s) |
|
end |
|
|
|
# @return [Boolean] if true, then a query like this one would be broadcasted |
|
def broadcastable?(query_str, **query_options) |
|
query = GraphQL::Query.new(@schema, query_str, **query_options) |
|
if !query.valid? |
|
raise "Invalid query: #{query.validation_errors.map(&:to_h).inspect}" |
|
end |
|
GraphQL::Analysis::AST.analyze_query(query, @schema.query_analyzers) |
|
query.context.namespace(:subscriptions)[:subscription_broadcastable] |
|
end |
|
|
|
private |
|
|
|
# Recursively normalize `args` as belonging to `arg_owner`: |
|
# - convert symbols to strings, |
|
# - if needed, camelize the string (using {#normalize_name}) |
|
# @param arg_owner [GraphQL::Field, GraphQL::BaseType] |
|
# @param args [Hash, Array, Any] some GraphQL input value to coerce as `arg_owner` |
|
# @return [Any] normalized arguments value |
|
def normalize_arguments(event_name, arg_owner, args, context) |
|
case arg_owner |
|
when GraphQL::Schema::Field, Class |
|
if arg_owner.is_a?(Class) && !arg_owner.kind.input_object? |
|
# it's a type, but not an input object |
|
return args |
|
end |
|
normalized_args = {} |
|
missing_arg_names = [] |
|
args.each do |k, v| |
|
arg_name = k.to_s |
|
arg_defn = arg_owner.get_argument(arg_name, context) |
|
if arg_defn |
|
normalized_arg_name = arg_name |
|
else |
|
normalized_arg_name = normalize_name(arg_name) |
|
arg_defn = arg_owner.get_argument(normalized_arg_name, context) |
|
end |
|
|
|
if arg_defn |
|
if arg_defn.loads |
|
normalized_arg_name = arg_defn.keyword.to_s |
|
end |
|
normalized = normalize_arguments(event_name, arg_defn.type, v, context) |
|
normalized_args[normalized_arg_name] = normalized |
|
else |
|
# Couldn't find a matching argument definition |
|
missing_arg_names << arg_name |
|
end |
|
end |
|
|
|
# Backfill default values so that trigger arguments |
|
# match query arguments. |
|
arg_owner.arguments(context).each do |_name, arg_defn| |
|
if arg_defn.default_value? && !normalized_args.key?(arg_defn.name) |
|
default_value = arg_defn.default_value |
|
# We don't have an underlying "object" here, so it can't call methods. |
|
# This is broken. |
|
normalized_args[arg_defn.name] = arg_defn.prepare_value(nil, default_value, context: context) |
|
end |
|
end |
|
|
|
if missing_arg_names.any? |
|
arg_owner_name = if arg_owner.is_a?(GraphQL::Schema::Field) |
|
arg_owner.path |
|
elsif arg_owner.is_a?(Class) |
|
arg_owner.graphql_name |
|
else |
|
arg_owner.to_s |
|
end |
|
raise InvalidTriggerError, "Can't trigger Subscription.#{event_name}, received undefined arguments: #{missing_arg_names.join(", ")}. (Should match arguments of #{arg_owner_name}.)" |
|
end |
|
|
|
normalized_args |
|
when GraphQL::Schema::List |
|
args.map { |a| normalize_arguments(event_name, arg_owner.of_type, a, context) } |
|
when GraphQL::Schema::NonNull |
|
normalize_arguments(event_name, arg_owner.of_type, args, context) |
|
else |
|
args |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/tracing.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/tracing/trace" |
|
require "graphql/tracing/legacy_trace" |
|
|
|
# Legacy tracing: |
|
require "graphql/tracing/active_support_notifications_tracing" |
|
require "graphql/tracing/platform_tracing" |
|
require "graphql/tracing/appoptics_tracing" |
|
require "graphql/tracing/appsignal_tracing" |
|
require "graphql/tracing/data_dog_tracing" |
|
require "graphql/tracing/new_relic_tracing" |
|
require "graphql/tracing/scout_tracing" |
|
require "graphql/tracing/statsd_tracing" |
|
require "graphql/tracing/prometheus_tracing" |
|
|
|
# New Tracing: |
|
require "graphql/tracing/active_support_notifications_trace" |
|
require "graphql/tracing/platform_trace" |
|
require "graphql/tracing/appoptics_trace" |
|
require "graphql/tracing/appsignal_trace" |
|
require "graphql/tracing/data_dog_trace" |
|
require "graphql/tracing/new_relic_trace" |
|
require "graphql/tracing/notifications_trace" |
|
require "graphql/tracing/scout_trace" |
|
require "graphql/tracing/statsd_trace" |
|
require "graphql/tracing/prometheus_trace" |
|
if defined?(PrometheusExporter::Server) |
|
require "graphql/tracing/prometheus_tracing/graphql_collector" |
|
end |
|
|
|
module GraphQL |
|
module Tracing |
|
NullTrace = Trace.new |
|
|
|
# Objects may include traceable to gain a `.trace(...)` method. |
|
# The object must have a `@tracers` ivar of type `Array<<#trace(k, d, &b)>>`. |
|
# @api private |
|
module Traceable |
|
# @param key [String] The name of the event in GraphQL internals |
|
# @param metadata [Hash] Event-related metadata (can be anything) |
|
# @return [Object] Must return the value of the block |
|
def trace(key, metadata, &block) |
|
return yield if @tracers.empty? |
|
call_tracers(0, key, metadata, &block) |
|
end |
|
|
|
private |
|
|
|
# If there's a tracer at `idx`, call it and then increment `idx`. |
|
# Otherwise, yield. |
|
# |
|
# @param idx [Integer] Which tracer to call |
|
# @param key [String] The current event name |
|
# @param metadata [Object] The current event object |
|
# @return Whatever the block returns |
|
def call_tracers(idx, key, metadata, &block) |
|
if idx == @tracers.length |
|
yield |
|
else |
|
@tracers[idx].trace(key, metadata) { call_tracers(idx + 1, key, metadata, &block) } |
|
end |
|
end |
|
end |
|
|
|
module NullTracer |
|
module_function |
|
def trace(k, v) |
|
yield |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/type_kinds.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# Type kinds are the basic categories which a type may belong to (`Object`, `Scalar`, `Union`...) |
|
module TypeKinds |
|
# These objects are singletons, eg `GraphQL::TypeKinds::UNION`, `GraphQL::TypeKinds::SCALAR`. |
|
class TypeKind |
|
attr_reader :name, :description |
|
def initialize(name, abstract: false, leaf: false, fields: false, wraps: false, input: false, description: nil) |
|
@name = name |
|
@abstract = abstract |
|
@fields = fields |
|
@wraps = wraps |
|
@input = input |
|
@leaf = leaf |
|
@composite = fields? || abstract? |
|
@description = description |
|
end |
|
|
|
# Does this TypeKind have multiple possible implementors? |
|
# @deprecated Use `abstract?` instead of `resolves?`. |
|
def resolves?; @abstract; end |
|
# Is this TypeKind abstract? |
|
def abstract?; @abstract; end |
|
# Does this TypeKind have queryable fields? |
|
def fields?; @fields; end |
|
# Does this TypeKind modify another type? |
|
def wraps?; @wraps; end |
|
# Is this TypeKind a valid query input? |
|
def input?; @input; end |
|
def to_s; @name; end |
|
# Is this TypeKind a primitive value? |
|
def leaf?; @leaf; end |
|
# Is this TypeKind composed of many values? |
|
def composite?; @composite; end |
|
|
|
def scalar? |
|
self == TypeKinds::SCALAR |
|
end |
|
|
|
def object? |
|
self == TypeKinds::OBJECT |
|
end |
|
|
|
def interface? |
|
self == TypeKinds::INTERFACE |
|
end |
|
|
|
def union? |
|
self == TypeKinds::UNION |
|
end |
|
|
|
def enum? |
|
self == TypeKinds::ENUM |
|
end |
|
|
|
def input_object? |
|
self == TypeKinds::INPUT_OBJECT |
|
end |
|
|
|
def list? |
|
self == TypeKinds::LIST |
|
end |
|
|
|
def non_null? |
|
self == TypeKinds::NON_NULL |
|
end |
|
end |
|
|
|
TYPE_KINDS = [ |
|
SCALAR = TypeKind.new("SCALAR", input: true, leaf: true, description: 'Indicates this type is a scalar.'), |
|
OBJECT = TypeKind.new("OBJECT", fields: true, description: 'Indicates this type is an object. `fields` and `interfaces` are valid fields.'), |
|
INTERFACE = TypeKind.new("INTERFACE", abstract: true, fields: true, description: 'Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.'), |
|
UNION = TypeKind.new("UNION", abstract: true, description: 'Indicates this type is a union. `possibleTypes` is a valid field.'), |
|
ENUM = TypeKind.new("ENUM", input: true, leaf: true, description: 'Indicates this type is an enum. `enumValues` is a valid field.'), |
|
INPUT_OBJECT = TypeKind.new("INPUT_OBJECT", input: true, description: 'Indicates this type is an input object. `inputFields` is a valid field.'), |
|
LIST = TypeKind.new("LIST", wraps: true, description: 'Indicates this type is a list. `ofType` is a valid field.'), |
|
NON_NULL = TypeKind.new("NON_NULL", wraps: true, description: 'Indicates this type is a non-null. `ofType` is a valid field.'), |
|
] |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [approver]: The sample pipeline `falcon-ingest` requires approval from group `crew-emerald`. |
|
|
|
|
|
### oss/graphql-ruby/lib/graphql/types.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/types/boolean" |
|
require "graphql/types/big_int" |
|
require "graphql/types/float" |
|
require "graphql/types/id" |
|
require "graphql/types/int" |
|
require "graphql/types/iso_8601_date" |
|
require "graphql/types/iso_8601_date_time" |
|
require "graphql/types/json" |
|
require "graphql/types/string" |
|
require "graphql/types/relay" |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/unauthorized_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# When an `authorized?` hook returns false, this error is used to communicate the failure. |
|
# It's passed to {Schema.unauthorized_object}. |
|
# |
|
# Alternatively, custom code in `authorized?` may raise this error. It will be routed the same way. |
|
class UnauthorizedError < GraphQL::Error |
|
# @return [Object] the application object that failed the authorization check |
|
attr_reader :object |
|
|
|
# @return [Class] the GraphQL object type whose `.authorized?` method was called (and returned false) |
|
attr_reader :type |
|
|
|
# @return [GraphQL::Query::Context] the context for the current query |
|
attr_accessor :context |
|
|
|
def initialize(message = nil, object: nil, type: nil, context: nil) |
|
if message.nil? && object.nil? && type.nil? |
|
raise ArgumentError, "#{self.class.name} requires either a message or keywords" |
|
end |
|
|
|
@object = object |
|
@type = type |
|
@context = context |
|
message ||= "An instance of #{object.class} failed #{type.graphql_name}'s authorization check" |
|
super(message) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/unauthorized_field_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class UnauthorizedFieldError < GraphQL::UnauthorizedError |
|
# @return [Field] the field that failed the authorization check |
|
attr_accessor :field |
|
|
|
def initialize(message = nil, object: nil, type: nil, context: nil, field: nil) |
|
if message.nil? && [field, type].any?(&:nil?) |
|
raise ArgumentError, "#{self.class.name} requires either a message or keywords" |
|
end |
|
|
|
@field = field |
|
message ||= begin |
|
if object |
|
"An instance of #{object.class} failed #{type.name}'s authorization check on field #{field.name}" |
|
else |
|
"Failed #{type.name}'s authorization check on field #{field.name}" |
|
end |
|
end |
|
super(message, object: object, type: type, context: context) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/unresolved_type_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
# Error raised when the value provided for a field |
|
# can't be resolved to one of the possible types for the field. |
|
class UnresolvedTypeError < GraphQL::RuntimeTypeError |
|
# @return [Object] The runtime value which couldn't be successfully resolved with `resolve_type` |
|
attr_reader :value |
|
|
|
# @return [GraphQL::Field] The field whose value couldn't be resolved (`field.type` is type which couldn't be resolved) |
|
attr_reader :field |
|
|
|
# @return [GraphQL::BaseType] The owner of `field` |
|
attr_reader :parent_type |
|
|
|
# @return [Object] The return of {Schema#resolve_type} for `value` |
|
attr_reader :resolved_type |
|
|
|
# @return [Array<GraphQL::BaseType>] The allowed options for resolving `value` to `field.type` |
|
attr_reader :possible_types |
|
|
|
def initialize(value, field, parent_type, resolved_type, possible_types) |
|
@value = value |
|
@field = field |
|
@parent_type = parent_type |
|
@resolved_type = resolved_type |
|
@possible_types = possible_types |
|
message = "The value from \"#{field.graphql_name}\" on \"#{parent_type.graphql_name}\" could not be resolved to \"#{field.type.to_type_signature}\". " \ |
|
"(Received: `#{resolved_type.inspect}`, Expected: [#{possible_types.map(&:graphql_name).join(", ")}]) " \ |
|
"Make sure you have defined a `resolve_type` proc on your schema and that value `#{value.inspect}` " \ |
|
"gets resolved to a valid type. You may need to add your type to `orphan_types` if it implements an " \ |
|
"interface but isn't a return type of any other field." |
|
super(message) |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/version.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
VERSION = "2.1.1" |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/analysis/ast/visitor" |
|
require "graphql/analysis/ast/analyzer" |
|
require "graphql/analysis/ast/field_usage" |
|
require "graphql/analysis/ast/query_complexity" |
|
require "graphql/analysis/ast/max_query_complexity" |
|
require "graphql/analysis/ast/query_depth" |
|
require "graphql/analysis/ast/max_query_depth" |
|
|
|
module GraphQL |
|
module Analysis |
|
module AST |
|
module_function |
|
# Analyze a multiplex, and all queries within. |
|
# Multiplex analyzers are ran for all queries, keeping state. |
|
# Query analyzers are ran per query, without carrying state between queries. |
|
# |
|
# @param multiplex [GraphQL::Execution::Multiplex] |
|
# @param analyzers [Array<GraphQL::Analysis::AST::Analyzer>] |
|
# @return [Array<Any>] Results from multiplex analyzers |
|
def analyze_multiplex(multiplex, analyzers) |
|
multiplex_analyzers = analyzers.map { |analyzer| analyzer.new(multiplex) } |
|
|
|
multiplex.current_trace.analyze_multiplex(multiplex: multiplex) do |
|
query_results = multiplex.queries.map do |query| |
|
if query.valid? |
|
analyze_query( |
|
query, |
|
query.analyzers, |
|
multiplex_analyzers: multiplex_analyzers |
|
) |
|
else |
|
[] |
|
end |
|
end |
|
|
|
multiplex_results = multiplex_analyzers.map(&:result) |
|
multiplex_errors = analysis_errors(multiplex_results) |
|
|
|
multiplex.queries.each_with_index do |query, idx| |
|
query.analysis_errors = multiplex_errors + analysis_errors(query_results[idx]) |
|
end |
|
multiplex_results |
|
end |
|
end |
|
|
|
# @param query [GraphQL::Query] |
|
# @param analyzers [Array<GraphQL::Analysis::AST::Analyzer>] |
|
# @return [Array<Any>] Results from those analyzers |
|
def analyze_query(query, analyzers, multiplex_analyzers: []) |
|
query.current_trace.analyze_query(query: query) do |
|
query_analyzers = analyzers |
|
.map { |analyzer| analyzer.new(query) } |
|
.tap { _1.select!(&:analyze?) } |
|
|
|
analyzers_to_run = query_analyzers + multiplex_analyzers |
|
if analyzers_to_run.any? |
|
|
|
analyzers_to_run.select!(&:visit?) |
|
if analyzers_to_run.any? |
|
visitor = GraphQL::Analysis::AST::Visitor.new( |
|
query: query, |
|
analyzers: analyzers_to_run |
|
) |
|
|
|
visitor.visit |
|
|
|
if visitor.rescued_errors.any? |
|
return visitor.rescued_errors |
|
end |
|
end |
|
|
|
query_analyzers.map(&:result) |
|
else |
|
[] |
|
end |
|
end |
|
end |
|
|
|
def analysis_errors(results) |
|
results.flatten.tap { _1.select! { |r| r.is_a?(GraphQL::AnalysisError) } } |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast/analyzer.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Analysis |
|
module AST |
|
# Query analyzer for query ASTs. Query analyzers respond to visitor style methods |
|
# but are prefixed by `enter` and `leave`. |
|
# |
|
# When an analyzer is initialized with a Multiplex, you can always get the current query from |
|
# `visitor.query` in the visit methods. |
|
# |
|
# @param [GraphQL::Query, GraphQL::Execution::Multiplex] The query or multiplex to analyze |
|
class Analyzer |
|
def initialize(subject) |
|
@subject = subject |
|
|
|
if subject.is_a?(GraphQL::Query) |
|
@query = subject |
|
@multiplex = nil |
|
else |
|
@multiplex = subject |
|
@query = nil |
|
end |
|
end |
|
|
|
# Analyzer hook to decide at analysis time whether a query should |
|
# be analyzed or not. |
|
# @return [Boolean] If the query should be analyzed or not |
|
def analyze? |
|
true |
|
end |
|
|
|
# Analyzer hook to decide at analysis time whether analysis |
|
# requires a visitor pass; can be disabled for precomputed results. |
|
# @return [Boolean] If analysis requires visitation or not |
|
def visit? |
|
true |
|
end |
|
|
|
# The result for this analyzer. Returning {GraphQL::AnalysisError} results |
|
# in a query error. |
|
# @return [Any] The analyzer result |
|
def result |
|
raise GraphQL::RequiredImplementationMissingError |
|
end |
|
|
|
class << self |
|
private |
|
|
|
def build_visitor_hooks(member_name) |
|
class_eval(<<-EOS, __FILE__, __LINE__ + 1) |
|
def on_enter_#{member_name}(node, parent, visitor) |
|
end |
|
|
|
def on_leave_#{member_name}(node, parent, visitor) |
|
end |
|
EOS |
|
end |
|
end |
|
|
|
build_visitor_hooks :argument |
|
build_visitor_hooks :directive |
|
build_visitor_hooks :document |
|
build_visitor_hooks :enum |
|
build_visitor_hooks :field |
|
build_visitor_hooks :fragment_spread |
|
build_visitor_hooks :inline_fragment |
|
build_visitor_hooks :input_object |
|
build_visitor_hooks :list_type |
|
build_visitor_hooks :non_null_type |
|
build_visitor_hooks :null_value |
|
build_visitor_hooks :operation_definition |
|
build_visitor_hooks :type_name |
|
build_visitor_hooks :variable_definition |
|
build_visitor_hooks :variable_identifier |
|
build_visitor_hooks :abstract_node |
|
|
|
protected |
|
|
|
# @return [GraphQL::Query, GraphQL::Execution::Multiplex] Whatever this analyzer is analyzing |
|
attr_reader :subject |
|
|
|
# @return [GraphQL::Query, nil] `nil` if this analyzer is visiting a multiplex |
|
# (When this is `nil`, use `visitor.query` inside visit methods to get the current query) |
|
attr_reader :query |
|
|
|
# @return [GraphQL::Execution::Multiplex, nil] `nil` if this analyzer is visiting a query |
|
attr_reader :multiplex |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast/field_usage.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Analysis |
|
module AST |
|
class FieldUsage < Analyzer |
|
def initialize(query) |
|
super |
|
@used_fields = Set.new |
|
@used_deprecated_fields = Set.new |
|
@used_deprecated_arguments = Set.new |
|
end |
|
|
|
def on_leave_field(node, parent, visitor) |
|
field_defn = visitor.field_definition |
|
field = "#{visitor.parent_type_definition.graphql_name}.#{field_defn.graphql_name}" |
|
@used_fields << field |
|
@used_deprecated_fields << field if field_defn.deprecation_reason |
|
arguments = visitor.query.arguments_for(node, visitor.field_definition) |
|
# If there was an error when preparing this argument object, |
|
# then this might be an error or something: |
|
if arguments.respond_to?(:argument_values) |
|
extract_deprecated_arguments(arguments.argument_values) |
|
end |
|
end |
|
|
|
def result |
|
{ |
|
used_fields: @used_fields.to_a, |
|
used_deprecated_fields: @used_deprecated_fields.to_a, |
|
used_deprecated_arguments: @used_deprecated_arguments.to_a, |
|
} |
|
end |
|
|
|
private |
|
|
|
def extract_deprecated_arguments(argument_values) |
|
argument_values.each_pair do |_argument_name, argument| |
|
if argument.definition.deprecation_reason |
|
@used_deprecated_arguments << argument.definition.path |
|
end |
|
|
|
next if argument.value.nil? |
|
|
|
if argument.definition.type.kind.input_object? |
|
extract_deprecated_arguments(argument.value.arguments.argument_values) # rubocop:disable Development/ContextIsPassedCop -- runtime args instance |
|
elsif argument.definition.type.list? |
|
argument |
|
.value |
|
.select { |value| value.respond_to?(:arguments) } |
|
.each { |value| extract_deprecated_arguments(value.arguments.argument_values) } # rubocop:disable Development/ContextIsPassedCop -- runtime args instance |
|
end |
|
end |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast/max_query_complexity.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Analysis |
|
module AST |
|
# Used under the hood to implement complexity validation, |
|
# see {Schema#max_complexity} and {Query#max_complexity} |
|
class MaxQueryComplexity < QueryComplexity |
|
def result |
|
return if subject.max_complexity.nil? |
|
|
|
total_complexity = max_possible_complexity |
|
|
|
if total_complexity > subject.max_complexity |
|
GraphQL::AnalysisError.new("Query has complexity of #{total_complexity}, which exceeds max complexity of #{subject.max_complexity}") |
|
else |
|
nil |
|
end |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast/max_query_depth.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Analysis |
|
module AST |
|
class MaxQueryDepth < QueryDepth |
|
def result |
|
configured_max_depth = if query |
|
query.max_depth |
|
else |
|
multiplex.schema.max_depth |
|
end |
|
|
|
if configured_max_depth && @max_depth > configured_max_depth |
|
GraphQL::AnalysisError.new("Query has depth of #{@max_depth}, which exceeds max depth of #{configured_max_depth}") |
|
else |
|
nil |
|
end |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast/query_complexity.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Analysis |
|
# Calculate the complexity of a query, using {Field#complexity} values. |
|
module AST |
|
class QueryComplexity < Analyzer |
|
# State for the query complexity calculation: |
|
# - `complexities_on_type` holds complexity scores for each type |
|
def initialize(query) |
|
super |
|
@complexities_on_type_by_query = {} |
|
end |
|
|
|
# Overide this method to use the complexity result |
|
def result |
|
max_possible_complexity |
|
end |
|
|
|
class ScopedTypeComplexity |
|
# A single proc for {#scoped_children} hashes. Use this to avoid repeated allocations, |
|
# since the lexical binding isn't important. |
|
HASH_CHILDREN = ->(h, k) { h[k] = {} } |
|
|
|
attr_reader :field_definition, :response_path, :query |
|
|
|
# @param parent_type [Class] The owner of `field_definition` |
|
# @param field_definition [GraphQL::Field, GraphQL::Schema::Field] Used for getting the `.complexity` configuration |
|
# @param query [GraphQL::Query] Used for `query.possible_types` |
|
# @param response_path [Array<String>] The path to the response key for the field |
|
def initialize(parent_type, field_definition, query, response_path) |
|
@parent_type = parent_type |
|
@field_definition = field_definition |
|
@query = query |
|
@response_path = response_path |
|
@scoped_children = nil |
|
@nodes = [] |
|
end |
|
|
|
# @return [Array<GraphQL::Language::Nodes::Field>] |
|
attr_reader :nodes |
|
|
|
# Returns true if this field has no selections, ie, it's a scalar. |
|
# We need a quick way to check whether we should continue traversing. |
|
def terminal? |
|
@scoped_children.nil? |
|
end |
|
|
|
# This value is only calculated when asked for to avoid needless hash allocations. |
|
# Also, if it's never asked for, we determine that this scope complexity |
|
# is a scalar field ({#terminal?}). |
|
# @return [Hash<Hash<Class => ScopedTypeComplexity>] |
|
def scoped_children |
|
@scoped_children ||= Hash.new(&HASH_CHILDREN) |
|
end |
|
|
|
def own_complexity(child_complexity) |
|
@field_definition.calculate_complexity(query: @query, nodes: @nodes, child_complexity: child_complexity) |
|
end |
|
end |
|
|
|
def on_enter_field(node, parent, visitor) |
|
# We don't want to visit fragment definitions, |
|
# we'll visit them when we hit the spreads instead |
|
return if visitor.visiting_fragment_definition? |
|
return if visitor.skipping? |
|
parent_type = visitor.parent_type_definition |
|
field_key = node.alias || node.name |
|
# Find the complexity calculation for this field -- |
|
# if we're re-entering a selection, we'll already have one. |
|
# Otherwise, make a new one and store it. |
|
# |
|
# `node` and `visitor.field_definition` may appear from a cache, |
|
# but I think that's ok. If the arguments _didn't_ match, |
|
# then the query would have been rejected as invalid. |
|
complexities_on_type = @complexities_on_type_by_query[visitor.query] ||= [ScopedTypeComplexity.new(nil, nil, query, visitor.response_path)] |
|
|
|
complexity = complexities_on_type.last.scoped_children[parent_type][field_key] ||= ScopedTypeComplexity.new(parent_type, visitor.field_definition, visitor.query, visitor.response_path) |
|
complexity.nodes.push(node) |
|
# Push it on the stack. |
|
complexities_on_type.push(complexity) |
|
end |
|
|
|
def on_leave_field(node, parent, visitor) |
|
# We don't want to visit fragment definitions, |
|
# we'll visit them when we hit the spreads instead |
|
return if visitor.visiting_fragment_definition? |
|
return if visitor.skipping? |
|
complexities_on_type = @complexities_on_type_by_query[visitor.query] |
|
complexities_on_type.pop |
|
end |
|
|
|
private |
|
|
|
# @return [Integer] |
|
def max_possible_complexity |
|
@complexities_on_type_by_query.reduce(0) do |total, (query, complexities_on_type)| |
|
root_complexity = complexities_on_type.last |
|
# Use this entry point to calculate the total complexity |
|
total_complexity_for_query = merged_max_complexity_for_scopes(query, [root_complexity.scoped_children]) |
|
total + total_complexity_for_query |
|
end |
|
end |
|
|
|
# @param query [GraphQL::Query] Used for `query.possible_types` |
|
# @param scoped_children_hashes [Array<Hash>] Array of scoped children hashes |
|
# @return [Integer] |
|
def merged_max_complexity_for_scopes(query, scoped_children_hashes) |
|
# Figure out what scopes are possible here. |
|
# Use a hash, but ignore the values; it's just a fast way to work with the keys. |
|
all_scopes = {} |
|
scoped_children_hashes.each do |h| |
|
all_scopes.merge!(h) |
|
end |
|
|
|
# If an abstract scope is present, but _all_ of its concrete types |
|
# are also in the list, remove it from the list of scopes to check, |
|
# because every possible type is covered by a concrete type. |
|
# (That is, there are no remainder types to check.) |
|
prev_keys = all_scopes.keys |
|
prev_keys.each do |scope| |
|
next unless scope.kind.abstract? |
|
|
|
missing_concrete_types = query.possible_types(scope).select { |t| !all_scopes.key?(t) } |
|
# This concrete type is possible _only_ as a member of the abstract type. |
|
# So, attribute to it the complexity which belongs to the abstract type. |
|
missing_concrete_types.each do |concrete_scope| |
|
all_scopes[concrete_scope] = all_scopes[scope] |
|
end |
|
all_scopes.delete(scope) |
|
end |
|
|
|
# This will hold `{ type => int }` pairs, one for each possible branch |
|
complexity_by_scope = {} |
|
|
|
# For each scope, |
|
# find the lexical selections that might apply to it, |
|
# and gather them together into an array. |
|
# Then, treat the set of selection hashes |
|
# as a set and calculate the complexity for them as a unit |
|
all_scopes.each do |scope, _| |
|
# These will be the selections on `scope` |
|
children_for_scope = [] |
|
scoped_children_hashes.each do |sc_h| |
|
sc_h.each do |inner_scope, children_hash| |
|
if applies_to?(query, scope, inner_scope) |
|
children_for_scope << children_hash |
|
end |
|
end |
|
end |
|
|
|
# Calculate the complexity for `scope`, merging all |
|
# possible lexical branches. |
|
complexity_value = merged_max_complexity(query, children_for_scope) |
|
complexity_by_scope[scope] = complexity_value |
|
end |
|
|
|
# Return the max complexity among all scopes |
|
complexity_by_scope.each_value.max |
|
end |
|
|
|
def applies_to?(query, left_scope, right_scope) |
|
if left_scope == right_scope |
|
# This can happen when several branches are being analyzed together |
|
true |
|
else |
|
# Check if these two scopes have _any_ types in common. |
|
possible_right_types = query.possible_types(right_scope) |
|
possible_left_types = query.possible_types(left_scope) |
|
!(possible_right_types & possible_left_types).empty? |
|
end |
|
end |
|
|
|
# A hook which is called whenever a field's max complexity is calculated. |
|
# Override this method to capture individual field complexity details. |
|
# |
|
# @param scoped_type_complexity [ScopedTypeComplexity] |
|
# @param max_complexity [Numeric] Field's maximum complexity including child complexity |
|
# @param child_complexity [Numeric, nil] Field's child complexity |
|
def field_complexity(scoped_type_complexity, max_complexity:, child_complexity: nil) |
|
end |
|
|
|
# @param children_for_scope [Array<Hash>] An array of `scoped_children[scope]` hashes |
|
# (`{field_key => complexity}`) |
|
# @return [Integer] Complexity value for all these selections in the current scope |
|
def merged_max_complexity(query, children_for_scope) |
|
all_keys = [] |
|
children_for_scope.each do |c| |
|
all_keys.concat(c.keys) |
|
end |
|
all_keys.uniq! |
|
complexity_for_keys = {} |
|
|
|
all_keys.each do |child_key| |
|
scoped_children_for_key = nil |
|
complexity_for_key = nil |
|
children_for_scope.each do |children_hash| |
|
next unless children_hash.key?(child_key) |
|
|
|
complexity_for_key = children_hash[child_key] |
|
if complexity_for_key.terminal? |
|
# Assume that all terminals would return the same complexity |
|
# Since it's a terminal, its child complexity is zero. |
|
complexity = complexity_for_key.own_complexity(0) |
|
complexity_for_keys[child_key] = complexity |
|
|
|
field_complexity(complexity_for_key, max_complexity: complexity, child_complexity: nil) |
|
else |
|
scoped_children_for_key ||= [] |
|
scoped_children_for_key << complexity_for_key.scoped_children |
|
end |
|
end |
|
|
|
next unless scoped_children_for_key |
|
|
|
child_complexity = merged_max_complexity_for_scopes(query, scoped_children_for_key) |
|
# This is the _last_ one we visited; assume it's representative. |
|
max_complexity = complexity_for_key.own_complexity(child_complexity) |
|
|
|
field_complexity(complexity_for_key, max_complexity: max_complexity, child_complexity: child_complexity) |
|
|
|
complexity_for_keys[child_key] = max_complexity |
|
end |
|
|
|
# Calculate the child complexity by summing the complexity of all selections |
|
complexity_for_keys.each_value.inject(0, &:+) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast/query_depth.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Analysis |
|
# A query reducer for measuring the depth of a given query. |
|
# |
|
# See https://graphql-ruby.org/queries/ast_analysis.html for more examples. |
|
# |
|
# @example Logging the depth of a query |
|
# class LogQueryDepth < GraphQL::Analysis::QueryDepth |
|
# def result |
|
# log("GraphQL query depth: #{@max_depth}") |
|
# end |
|
# end |
|
# |
|
# # In your Schema file: |
|
# |
|
# class MySchema < GraphQL::Schema |
|
# query_analyzer LogQueryDepth |
|
# end |
|
# |
|
# # When you run the query, the depth will get logged: |
|
# |
|
# Schema.execute(query_str) |
|
# # GraphQL query depth: 8 |
|
# |
|
module AST |
|
class QueryDepth < Analyzer |
|
def initialize(query) |
|
@max_depth = 0 |
|
@current_depth = 0 |
|
super |
|
end |
|
|
|
def on_enter_field(node, parent, visitor) |
|
return if visitor.skipping? || visitor.visiting_fragment_definition? |
|
|
|
@current_depth += 1 |
|
end |
|
|
|
def on_leave_field(node, parent, visitor) |
|
return if visitor.skipping? || visitor.visiting_fragment_definition? |
|
|
|
if @max_depth < @current_depth |
|
@max_depth = @current_depth |
|
end |
|
@current_depth -= 1 |
|
end |
|
|
|
def result |
|
@max_depth |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/analysis/ast/visitor.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Analysis |
|
module AST |
|
# Depth first traversal through a query AST, calling AST analyzers |
|
# along the way. |
|
# |
|
# The visitor is a special case of GraphQL::Language::StaticVisitor, visiting |
|
# only the selected operation, providing helpers for common use cases such |
|
# as skipped fields and visiting fragment spreads. |
|
# |
|
# @see {GraphQL::Analysis::AST::Analyzer} AST Analyzers for queries |
|
class Visitor < GraphQL::Language::StaticVisitor |
|
def initialize(query:, analyzers:) |
|
@analyzers = analyzers |
|
@path = [] |
|
@object_types = [] |
|
@directives = [] |
|
@field_definitions = [] |
|
@argument_definitions = [] |
|
@directive_definitions = [] |
|
@rescued_errors = [] |
|
@query = query |
|
@schema = query.schema |
|
@response_path = [] |
|
@skip_stack = [false] |
|
super(query.selected_operation) |
|
end |
|
|
|
# @return [GraphQL::Query] the query being visited |
|
attr_reader :query |
|
|
|
# @return [Array<GraphQL::ObjectType>] Types whose scope we've entered |
|
attr_reader :object_types |
|
|
|
# @return [Array<GraphQL::AnalysisError] |
|
attr_reader :rescued_errors |
|
|
|
def visit |
|
return unless @document |
|
super |
|
end |
|
|
|
# Visit Helpers |
|
|
|
# @return [GraphQL::Execution::Interpreter::Arguments] Arguments for this node, merging default values, literal values and query variables |
|
# @see {GraphQL::Query#arguments_for} |
|
def arguments_for(ast_node, field_definition) |
|
@query.arguments_for(ast_node, field_definition) |
|
end |
|
|
|
# @return [Boolean] If the visitor is currently inside a fragment definition |
|
def visiting_fragment_definition? |
|
@in_fragment_def |
|
end |
|
|
|
# @return [Boolean] If the current node should be skipped because of a skip or include directive |
|
def skipping? |
|
@skipping |
|
end |
|
|
|
# @return [Array<String>] The path to the response key for the current field |
|
def response_path |
|
@response_path.dup |
|
end |
|
|
|
# Visitor Hooks |
|
[ |
|
:operation_definition, :fragment_definition, |
|
:inline_fragment, :field, :directive, :argument, :fragment_spread |
|
].each do |node_type| |
|
module_eval <<-RUBY, __FILE__, __LINE__ |
|
def call_on_enter_#{node_type}(node, parent) |
|
@analyzers.each do |a| |
|
begin |
|
a.on_enter_#{node_type}(node, parent, self) |
|
rescue AnalysisError => err |
|
@rescued_errors << err |
|
end |
|
end |
|
end |
|
|
|
def call_on_leave_#{node_type}(node, parent) |
|
@analyzers.each do |a| |
|
begin |
|
a.on_leave_#{node_type}(node, parent, self) |
|
rescue AnalysisError => err |
|
@rescued_errors << err |
|
end |
|
end |
|
end |
|
|
|
RUBY |
|
end |
|
|
|
def on_operation_definition(node, parent) |
|
object_type = @schema.root_type_for_operation(node.operation_type) |
|
@object_types.push(object_type) |
|
@path.push("#{node.operation_type}#{node.name ? " #{node.name}" : ""}") |
|
call_on_enter_operation_definition(node, parent) |
|
super |
|
call_on_leave_operation_definition(node, parent) |
|
@object_types.pop |
|
@path.pop |
|
end |
|
|
|
def on_fragment_definition(node, parent) |
|
on_fragment_with_type(node) do |
|
@path.push("fragment #{node.name}") |
|
@in_fragment_def = false |
|
call_on_enter_fragment_definition(node, parent) |
|
super |
|
@in_fragment_def = false |
|
call_on_leave_fragment_definition(node, parent) |
|
end |
|
end |
|
|
|
def on_inline_fragment(node, parent) |
|
on_fragment_with_type(node) do |
|
@path.push("...#{node.type ? " on #{node.type.name}" : ""}") |
|
call_on_enter_inline_fragment(node, parent) |
|
super |
|
call_on_leave_inline_fragment(node, parent) |
|
end |
|
end |
|
|
|
def on_field(node, parent) |
|
@response_path.push(node.alias || node.name) |
|
parent_type = @object_types.last |
|
# This could be nil if the previous field wasn't found: |
|
field_definition = parent_type && @schema.get_field(parent_type, node.name, @query.context) |
|
@field_definitions.push(field_definition) |
|
if !field_definition.nil? |
|
next_object_type = field_definition.type.unwrap |
|
@object_types.push(next_object_type) |
|
else |
|
@object_types.push(nil) |
|
end |
|
@path.push(node.alias || node.name) |
|
|
|
@skipping = @skip_stack.last || skip?(node) |
|
@skip_stack << @skipping |
|
|
|
call_on_enter_field(node, parent) |
|
super |
|
@skipping = @skip_stack.pop |
|
call_on_leave_field(node, parent) |
|
@response_path.pop |
|
@field_definitions.pop |
|
@object_types.pop |
|
@path.pop |
|
end |
|
|
|
def on_directive(node, parent) |
|
directive_defn = @schema.directives[node.name] |
|
@directive_definitions.push(directive_defn) |
|
call_on_enter_directive(node, parent) |
|
super |
|
call_on_leave_directive(node, parent) |
|
@directive_definitions.pop |
|
end |
|
|
|
def on_argument(node, parent) |
|
argument_defn = if (arg = @argument_definitions.last) |
|
arg_type = arg.type.unwrap |
|
if arg_type.kind.input_object? |
|
arg_type.get_argument(node.name, @query.context) |
|
else |
|
nil |
|
end |
|
elsif (directive_defn = @directive_definitions.last) |
|
directive_defn.get_argument(node.name, @query.context) |
|
elsif (field_defn = @field_definitions.last) |
|
field_defn.get_argument(node.name, @query.context) |
|
else |
|
nil |
|
end |
|
|
|
@argument_definitions.push(argument_defn) |
|
@path.push(node.name) |
|
call_on_enter_argument(node, parent) |
|
super |
|
call_on_leave_argument(node, parent) |
|
@argument_definitions.pop |
|
@path.pop |
|
end |
|
|
|
def on_fragment_spread(node, parent) |
|
@path.push("... #{node.name}") |
|
call_on_enter_fragment_spread(node, parent) |
|
enter_fragment_spread_inline(node) |
|
super |
|
leave_fragment_spread_inline(node) |
|
call_on_leave_fragment_spread(node, parent) |
|
@path.pop |
|
end |
|
|
|
# @return [GraphQL::BaseType] The current object type |
|
def type_definition |
|
@object_types.last |
|
end |
|
|
|
# @return [GraphQL::BaseType] The type which the current type came from |
|
def parent_type_definition |
|
@object_types[-2] |
|
end |
|
|
|
# @return [GraphQL::Field, nil] The most-recently-entered GraphQL::Field, if currently inside one |
|
def field_definition |
|
@field_definitions.last |
|
end |
|
|
|
# @return [GraphQL::Field, nil] The GraphQL field which returned the object that the current field belongs to |
|
def previous_field_definition |
|
@field_definitions[-2] |
|
end |
|
|
|
# @return [GraphQL::Directive, nil] The most-recently-entered GraphQL::Directive, if currently inside one |
|
def directive_definition |
|
@directive_definitions.last |
|
end |
|
|
|
# @return [GraphQL::Argument, nil] The most-recently-entered GraphQL::Argument, if currently inside one |
|
def argument_definition |
|
@argument_definitions.last |
|
end |
|
|
|
# @return [GraphQL::Argument, nil] The previous GraphQL argument |
|
def previous_argument_definition |
|
@argument_definitions[-2] |
|
end |
|
|
|
private |
|
|
|
# Visit a fragment spread inline instead of visiting the definition |
|
# by itself. |
|
def enter_fragment_spread_inline(fragment_spread) |
|
fragment_def = query.fragments[fragment_spread.name] |
|
|
|
object_type = if fragment_def.type |
|
@query.warden.get_type(fragment_def.type.name) |
|
else |
|
object_types.last |
|
end |
|
|
|
object_types << object_type |
|
|
|
on_fragment_definition_children(fragment_def) |
|
end |
|
|
|
# Visit a fragment spread inline instead of visiting the definition |
|
# by itself. |
|
def leave_fragment_spread_inline(_fragment_spread) |
|
object_types.pop |
|
end |
|
|
|
def skip?(ast_node) |
|
dir = ast_node.directives |
|
dir.any? && !GraphQL::Execution::DirectiveChecks.include?(dir, query) |
|
end |
|
|
|
def on_fragment_with_type(node) |
|
object_type = if node.type |
|
@query.warden.get_type(node.type.name) |
|
else |
|
@object_types.last |
|
end |
|
@object_types.push(object_type) |
|
yield(node) |
|
@object_types.pop |
|
@path.pop |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/backtrace/inspect_result.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Backtrace |
|
module InspectResult |
|
module_function |
|
|
|
def inspect_result(obj) |
|
case obj |
|
when Hash |
|
"{" + |
|
obj.map do |key, val| |
|
"#{key}: #{inspect_truncated(val)}" |
|
end.join(", ") + |
|
"}" |
|
when Array |
|
"[" + |
|
obj.map { |v| inspect_truncated(v) }.join(", ") + |
|
"]" |
|
when Query::Context::SharedMethods |
|
if obj.invalid_null? |
|
"nil" |
|
else |
|
inspect_truncated(obj.value) |
|
end |
|
else |
|
inspect_truncated(obj) |
|
end |
|
end |
|
|
|
def inspect_truncated(obj) |
|
case obj |
|
when Hash |
|
"{...}" |
|
when Array |
|
"[...]" |
|
when Query::Context::SharedMethods |
|
if obj.invalid_null? |
|
"nil" |
|
else |
|
inspect_truncated(obj.value) |
|
end |
|
when GraphQL::Execution::Lazy |
|
"(unresolved)" |
|
else |
|
"#{obj.inspect}" |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/backtrace/table.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Backtrace |
|
# A class for turning a context into a human-readable table or array |
|
class Table |
|
MIN_COL_WIDTH = 4 |
|
MAX_COL_WIDTH = 100 |
|
HEADERS = [ |
|
"Loc", |
|
"Field", |
|
"Object", |
|
"Arguments", |
|
"Result", |
|
] |
|
|
|
def initialize(context, value:) |
|
@context = context |
|
@override_value = value |
|
end |
|
|
|
# @return [String] A table layout of backtrace with metadata |
|
def to_table |
|
@to_table ||= render_table(rows) |
|
end |
|
|
|
# @return [Array<String>] An array of position + field name entries |
|
def to_backtrace |
|
@to_backtrace ||= begin |
|
backtrace = rows.map { |r| "#{r[0]}: #{r[1]}" } |
|
# skip the header entry |
|
backtrace.shift |
|
backtrace |
|
end |
|
end |
|
|
|
private |
|
|
|
def rows |
|
@rows ||= build_rows(@context, rows: [HEADERS], top: true) |
|
end |
|
|
|
# @return [String] |
|
def render_table(rows) |
|
max = Array.new(HEADERS.length, MIN_COL_WIDTH) |
|
|
|
rows.each do |row| |
|
row.each_with_index do |col, idx| |
|
col_len = col.length |
|
max_len = max[idx] |
|
if col_len > max_len |
|
if col_len > MAX_COL_WIDTH |
|
max[idx] = MAX_COL_WIDTH |
|
else |
|
max[idx] = col_len |
|
end |
|
end |
|
end |
|
end |
|
|
|
table = "".dup |
|
last_col_idx = max.length - 1 |
|
rows.each do |row| |
|
table << row.map.each_with_index do |col, idx| |
|
max_len = max[idx] |
|
if idx < last_col_idx |
|
col = col.ljust(max_len) |
|
end |
|
if col.length > max_len |
|
col = col[0, max_len - 3] + "..." |
|
end |
|
col |
|
end.join(" | ") |
|
table << "\n" |
|
end |
|
table |
|
end |
|
|
|
# @return [Array] 5 items for a backtrace table (not `key`) |
|
def build_rows(context_entry, rows:, top: false) |
|
case context_entry |
|
when Backtrace::Frame |
|
field_alias = context_entry.ast_node.respond_to?(:alias) && context_entry.ast_node.alias |
|
value = if top && @override_value |
|
@override_value |
|
else |
|
value_at(@context.query.context.namespace(:interpreter_runtime)[:runtime], context_entry.path) |
|
end |
|
rows << [ |
|
"#{context_entry.ast_node ? context_entry.ast_node.position.join(":") : ""}", |
|
"#{context_entry.field.path}#{field_alias ? " as #{field_alias}" : ""}", |
|
"#{context_entry.object.object.inspect}", |
|
context_entry.arguments.to_h.inspect, # rubocop:disable Development/ContextIsPassedCop -- unrelated method |
|
Backtrace::InspectResult.inspect_result(value), |
|
] |
|
if (parent = context_entry.parent_frame) |
|
build_rows(parent, rows: rows) |
|
else |
|
rows |
|
end |
|
when GraphQL::Query::Context |
|
query = context_entry.query |
|
op = query.selected_operation |
|
if op |
|
op_type = op.operation_type |
|
position = "#{op.line}:#{op.col}" |
|
else |
|
op_type = "query" |
|
position = "?:?" |
|
end |
|
op_name = query.selected_operation_name |
|
object = query.root_value |
|
if object.is_a?(GraphQL::Schema::Object) |
|
object = object.object |
|
end |
|
value = value_at(context_entry.namespace(:interpreter_runtime)[:runtime], []) |
|
rows << [ |
|
"#{position}", |
|
"#{op_type}#{op_name ? " #{op_name}" : ""}", |
|
"#{object.inspect}", |
|
query.variables.to_h.inspect, |
|
Backtrace::InspectResult.inspect_result(value), |
|
] |
|
else |
|
raise "Unexpected get_rows subject #{context_entry.class} (#{context_entry.inspect})" |
|
end |
|
end |
|
|
|
def value_at(runtime, path) |
|
response = runtime.final_result |
|
path.each do |key| |
|
if response && (response = response[key]) |
|
next |
|
else |
|
break |
|
end |
|
end |
|
response |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/backtrace/trace.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Backtrace |
|
module Trace |
|
def validate(query:, validate:) |
|
if query.multiplex |
|
push_query_backtrace_context(query) |
|
end |
|
super |
|
end |
|
|
|
def analyze_query(query:) |
|
if query.multiplex # missing for stand-alone static validation |
|
push_query_backtrace_context(query) |
|
end |
|
super |
|
end |
|
|
|
def execute_query(query:) |
|
push_query_backtrace_context(query) |
|
super |
|
end |
|
|
|
def execute_query_lazy(query:, multiplex:) |
|
query ||= multiplex.queries.first |
|
push_query_backtrace_context(query) |
|
super |
|
end |
|
|
|
def execute_field(field:, query:, ast_node:, arguments:, object:) |
|
push_field_backtrace_context(field, query, ast_node, arguments, object) |
|
super |
|
end |
|
|
|
def execute_field_lazy(field:, query:, ast_node:, arguments:, object:) |
|
push_field_backtrace_context(field, query, ast_node, arguments, object) |
|
super |
|
end |
|
|
|
def execute_multiplex(multiplex:) |
|
super |
|
rescue StandardError => err |
|
# This is an unhandled error from execution, |
|
# Re-raise it with a GraphQL trace. |
|
multiplex_context = multiplex.context |
|
potential_context = multiplex_context[:last_graphql_backtrace_context] |
|
|
|
if potential_context.is_a?(GraphQL::Query::Context) || |
|
potential_context.is_a?(Backtrace::Frame) |
|
raise TracedError.new(err, potential_context) |
|
else |
|
raise |
|
end |
|
ensure |
|
multiplex_context = multiplex.context |
|
multiplex_context.delete(:graphql_backtrace_contexts) |
|
multiplex_context.delete(:last_graphql_backtrace_context) |
|
end |
|
|
|
private |
|
|
|
def push_query_backtrace_context(query) |
|
push_data = query |
|
multiplex = query.multiplex |
|
push_key = [] |
|
push_storage = multiplex.context[:graphql_backtrace_contexts] ||= {} |
|
push_storage[push_key] = push_data |
|
multiplex.context[:last_graphql_backtrace_context] = push_data |
|
end |
|
|
|
def push_field_backtrace_context(field, query, ast_node, arguments, object) |
|
multiplex = query.multiplex |
|
push_key = query.context[:current_path] |
|
push_storage = multiplex.context[:graphql_backtrace_contexts] |
|
parent_frame = push_storage[push_key[0..-2]] |
|
|
|
if parent_frame.is_a?(GraphQL::Query) |
|
parent_frame = parent_frame.context |
|
end |
|
|
|
push_data = Frame.new( |
|
query: query, |
|
path: push_key, |
|
ast_node: ast_node, |
|
field: field, |
|
object: object, |
|
arguments: arguments, |
|
parent_frame: parent_frame, |
|
) |
|
|
|
push_storage[push_key] = push_data |
|
multiplex.context[:last_graphql_backtrace_context] = push_data |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/backtrace/traced_error.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Backtrace |
|
# When {Backtrace} is enabled, raised errors are wrapped with {TracedError}. |
|
class TracedError < GraphQL::Error |
|
# @return [Array<String>] Printable backtrace of GraphQL error context |
|
attr_reader :graphql_backtrace |
|
|
|
# @return [GraphQL::Query::Context] The context at the field where the error was raised |
|
attr_reader :context |
|
|
|
MESSAGE_TEMPLATE = <<-MESSAGE |
|
Unhandled error during GraphQL execution: |
|
|
|
%{cause_message} |
|
%{cause_backtrace} |
|
%{cause_backtrace_more} |
|
Use #cause to access the original exception (including #cause.backtrace). |
|
|
|
GraphQL Backtrace: |
|
%{graphql_table} |
|
MESSAGE |
|
|
|
# This many lines of the original Ruby backtrace |
|
# are included in the message |
|
CAUSE_BACKTRACE_PREVIEW_LENGTH = 10 |
|
|
|
def initialize(err, current_ctx) |
|
@context = current_ctx |
|
backtrace = Backtrace.new(current_ctx, value: err) |
|
@graphql_backtrace = backtrace.to_a |
|
|
|
cause_backtrace_preview = err.backtrace.first(CAUSE_BACKTRACE_PREVIEW_LENGTH).join("\n ") |
|
|
|
cause_backtrace_remainder_length = err.backtrace.length - CAUSE_BACKTRACE_PREVIEW_LENGTH |
|
cause_backtrace_more = if cause_backtrace_remainder_length < 0 |
|
"" |
|
elsif cause_backtrace_remainder_length == 1 |
|
"... and 1 more line\n" |
|
else |
|
"... and #{cause_backtrace_remainder_length} more lines\n" |
|
end |
|
|
|
message = MESSAGE_TEMPLATE % { |
|
cause_message: err.message, |
|
cause_backtrace: cause_backtrace_preview, |
|
cause_backtrace_more: cause_backtrace_more, |
|
graphql_table: backtrace.inspect, |
|
} |
|
super(message) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/backtrace/tracer.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Backtrace |
|
# TODO this is not fiber-friendly |
|
module Tracer |
|
module_function |
|
|
|
# Implement the {GraphQL::Tracing} API. |
|
def trace(key, metadata) |
|
case key |
|
when "lex", "parse" |
|
# No context here, don't have a query yet |
|
nil |
|
when "execute_multiplex", "analyze_multiplex" |
|
# No query context yet |
|
nil |
|
when "validate", "analyze_query", "execute_query", "execute_query_lazy" |
|
push_key = [] |
|
if (query = metadata[:query]) || ((queries = metadata[:queries]) && (query = queries.first)) |
|
push_data = query |
|
multiplex = query.multiplex |
|
elsif (multiplex = metadata[:multiplex]) |
|
push_data = multiplex.queries.first |
|
end |
|
when "execute_field", "execute_field_lazy" |
|
query = metadata[:query] |
|
multiplex = query.multiplex |
|
push_key = query.context[:current_path] |
|
parent_frame = multiplex.context[:graphql_backtrace_contexts][push_key[0..-2]] |
|
|
|
if parent_frame.is_a?(GraphQL::Query) |
|
parent_frame = parent_frame.context |
|
end |
|
|
|
push_data = Frame.new( |
|
query: query, |
|
path: push_key, |
|
ast_node: metadata[:ast_node], |
|
field: metadata[:field], |
|
object: metadata[:object], |
|
arguments: metadata[:arguments], |
|
parent_frame: parent_frame, |
|
) |
|
else |
|
# Custom key, no backtrace data for this |
|
nil |
|
end |
|
|
|
if push_data && multiplex |
|
push_storage = multiplex.context[:graphql_backtrace_contexts] ||= {} |
|
push_storage[push_key] = push_data |
|
multiplex.context[:last_graphql_backtrace_context] = push_data |
|
end |
|
|
|
if key == "execute_multiplex" |
|
multiplex_context = metadata[:multiplex].context |
|
begin |
|
yield |
|
rescue StandardError => err |
|
# This is an unhandled error from execution, |
|
# Re-raise it with a GraphQL trace. |
|
potential_context = multiplex_context[:last_graphql_backtrace_context] |
|
|
|
if potential_context.is_a?(GraphQL::Query::Context) || |
|
potential_context.is_a?(Backtrace::Frame) |
|
raise TracedError.new(err, potential_context) |
|
else |
|
raise |
|
end |
|
ensure |
|
multiplex_context.delete(:graphql_backtrace_contexts) |
|
multiplex_context.delete(:last_graphql_backtrace_context) |
|
end |
|
else |
|
yield |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/dataloader/null_dataloader.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
class Dataloader |
|
# The default implementation of dataloading -- all no-ops. |
|
# |
|
# The Dataloader interface isn't public, but it enables |
|
# simple internal code while adding the option to add Dataloader. |
|
class NullDataloader < Dataloader |
|
# These are all no-ops because code was |
|
# executed sychronously. |
|
def run; end |
|
def run_isolated; yield; end |
|
def yield |
|
raise GraphQL::Error, "GraphQL::Dataloader is not running -- add `use GraphQL::Dataloader` to your schema to use Dataloader sources." |
|
end |
|
|
|
def append_job |
|
yield |
|
nil |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/dataloader/request.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Dataloader |
|
# @see Source#request which returns an instance of this |
|
class Request |
|
def initialize(source, key) |
|
@source = source |
|
@key = key |
|
end |
|
|
|
# Call this method to cause the current Fiber to wait for the results of this request. |
|
# |
|
# @return [Object] the object loaded for `key` |
|
def load |
|
@source.load(@key) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/dataloader/request_all.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
class Dataloader |
|
# @see Source#request_all which returns an instance of this. |
|
class RequestAll < Request |
|
def initialize(source, keys) |
|
@source = source |
|
@keys = keys |
|
end |
|
|
|
# Call this method to cause the current Fiber to wait for the results of this request. |
|
# |
|
# @return [Array<Object>] One object for each of `keys` |
|
def load |
|
@source.load_all(@keys) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/dataloader/source.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
class Dataloader |
|
class Source |
|
# Called by {Dataloader} to prepare the {Source}'s internal state |
|
# @api private |
|
def setup(dataloader) |
|
# These keys have been requested but haven't been fetched yet |
|
@pending = {} |
|
# These keys have been passed to `fetch` but haven't been finished yet |
|
@fetching = {} |
|
# { key => result } |
|
@results = {} |
|
@dataloader = dataloader |
|
end |
|
|
|
attr_reader :dataloader |
|
|
|
# @return [Dataloader::Request] a pending request for a value from `key`. Call `.load` on that object to wait for the result. |
|
def request(value) |
|
res_key = result_key_for(value) |
|
if !@results.key?(res_key) |
|
@pending[res_key] ||= value |
|
end |
|
Dataloader::Request.new(self, value) |
|
end |
|
|
|
# Implement this method to return a stable identifier if different |
|
# key objects should load the same data value. |
|
# |
|
# @param value [Object] A value passed to `.request` or `.load`, for which a value will be loaded |
|
# @return [Object] The key for tracking this pending data |
|
def result_key_for(value) |
|
value |
|
end |
|
|
|
# @return [Dataloader::Request] a pending request for a values from `keys`. Call `.load` on that object to wait for the results. |
|
def request_all(values) |
|
values.each do |v| |
|
res_key = result_key_for(v) |
|
if !@results.key?(res_key) |
|
@pending[res_key] ||= v |
|
end |
|
end |
|
Dataloader::RequestAll.new(self, values) |
|
end |
|
|
|
# @param value [Object] A loading value which will be passed to {#fetch} if it isn't already in the internal cache. |
|
# @return [Object] The result from {#fetch} for `key`. If `key` hasn't been loaded yet, the Fiber will yield until it's loaded. |
|
def load(value) |
|
result_key = result_key_for(value) |
|
if @results.key?(result_key) |
|
result_for(result_key) |
|
else |
|
@pending[result_key] ||= value |
|
sync([result_key]) |
|
result_for(result_key) |
|
end |
|
end |
|
|
|
# @param values [Array<Object>] Loading keys which will be passed to `#fetch` (or read from the internal cache). |
|
# @return [Object] The result from {#fetch} for `keys`. If `keys` haven't been loaded yet, the Fiber will yield until they're loaded. |
|
def load_all(values) |
|
result_keys = [] |
|
pending_keys = [] |
|
values.each { |v| |
|
k = result_key_for(v) |
|
result_keys << k |
|
if !@results.key?(k) |
|
@pending[k] ||= v |
|
pending_keys << k |
|
end |
|
} |
|
|
|
if pending_keys.any? |
|
sync(pending_keys) |
|
end |
|
|
|
result_keys.map { |k| result_for(k) } |
|
end |
|
|
|
# Subclasses must implement this method to return a value for each of `keys` |
|
# @param keys [Array<Object>] keys passed to {#load}, {#load_all}, {#request}, or {#request_all} |
|
# @return [Array<Object>] A loaded value for each of `keys`. The array must match one-for-one to the list of `keys`. |
|
def fetch(keys) |
|
# somehow retrieve these from the backend |
|
raise "Implement `#{self.class}#fetch(#{keys.inspect}) to return a record for each of the keys" |
|
end |
|
|
|
# Wait for a batch, if there's anything to batch. |
|
# Then run the batch and update the cache. |
|
# @return [void] |
|
def sync(pending_result_keys) |
|
@dataloader.yield |
|
iterations = 0 |
|
while pending_result_keys.any? { |key| !@results.key?(key) } |
|
iterations += 1 |
|
if iterations > 1000 |
|
raise "#{self.class}#sync tried 1000 times to load pending keys (#{pending_result_keys}), but they still weren't loaded. There is likely a circular dependency." |
|
end |
|
@dataloader.yield |
|
end |
|
nil |
|
end |
|
|
|
# @return [Boolean] True if this source has any pending requests for data. |
|
def pending? |
|
!@pending.empty? |
|
end |
|
|
|
# Add these key-value pairs to this source's cache |
|
# (future loads will use these merged values). |
|
# @param new_results [Hash<Object => Object>] key-value pairs to cache in this source |
|
# @return [void] |
|
def merge(new_results) |
|
new_results.each do |new_k, new_v| |
|
key = result_key_for(new_k) |
|
@results[key] = new_v |
|
end |
|
nil |
|
end |
|
|
|
# Called by {GraphQL::Dataloader} to resolve and pending requests to this source. |
|
# @api private |
|
# @return [void] |
|
def run_pending_keys |
|
if !@fetching.empty? |
|
@fetching.each_key { |k| @pending.delete(k) } |
|
end |
|
return if @pending.empty? |
|
fetch_h = @pending |
|
@pending = {} |
|
@fetching.merge!(fetch_h) |
|
results = fetch(fetch_h.values) |
|
fetch_h.each_with_index do |(key, _value), idx| |
|
@results[key] = results[idx] |
|
end |
|
nil |
|
rescue StandardError => error |
|
fetch_h.each_key { |key| @results[key] = error } |
|
ensure |
|
fetch_h && fetch_h.each_key { |k| @fetching.delete(k) } |
|
end |
|
|
|
# These arguments are given to `dataloader.with(source_class, ...)`. The object |
|
# returned from this method is used to de-duplicate batch loads under the hood |
|
# by using it as a Hash key. |
|
# |
|
# By default, the arguments are all put in an Array. To customize how this source's |
|
# batches are merged, override this method to return something else. |
|
# |
|
# For example, if you pass `ActiveRecord::Relation`s to `.with(...)`, you could override |
|
# this method to call `.to_sql` on them, thus merging `.load(...)` calls when they apply |
|
# to equivalent relations. |
|
# |
|
# @param batch_args [Array<Object>] |
|
# @param batch_kwargs [Hash] |
|
# @return [Object] |
|
def self.batch_key_for(*batch_args, **batch_kwargs) |
|
[*batch_args, **batch_kwargs] |
|
end |
|
|
|
# Clear any already-loaded objects for this source |
|
# @return [void] |
|
def clear_cache |
|
@results.clear |
|
nil |
|
end |
|
|
|
attr_reader :pending |
|
|
|
private |
|
|
|
# Reads and returns the result for the key from the internal cache, or raises an error if the result was an error |
|
# @param key [Object] key passed to {#load} or {#load_all} |
|
# @return [Object] The result from {#fetch} for `key`. |
|
# @api private |
|
def result_for(key) |
|
if !@results.key?(key) |
|
raise GraphQL::InvariantError, <<-ERR |
|
Fetching result for a key on #{self.class} that hasn't been loaded yet (#{key.inspect}, loaded: #{@results.keys}) |
|
|
|
This key should have been loaded already. This is a bug in GraphQL::Dataloader, please report it on GitHub: https://github.com/rmosolgo/graphql-ruby/issues/new. |
|
ERR |
|
end |
|
result = @results[key] |
|
|
|
raise result if result.class <= StandardError |
|
|
|
result |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [debounce]: The example `<RippleToggle>` component debounces its input by 285 milliseconds. |
|
|
|
|
|
### oss/graphql-ruby/lib/graphql/execution/directive_checks.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Execution |
|
# Boolean checks for how an AST node's directives should |
|
# influence its execution |
|
# @api private |
|
module DirectiveChecks |
|
SKIP = "skip" |
|
INCLUDE = "include" |
|
|
|
module_function |
|
|
|
# @return [Boolean] Should this node be included in the query? |
|
def include?(directive_ast_nodes, query) |
|
directive_ast_nodes.each do |directive_ast_node| |
|
name = directive_ast_node.name |
|
directive_defn = query.schema.directives[name] |
|
case name |
|
when SKIP |
|
args = query.arguments_for(directive_ast_node, directive_defn) |
|
if args[:if] == true |
|
return false |
|
end |
|
when INCLUDE |
|
args = query.arguments_for(directive_ast_node, directive_defn) |
|
if args[:if] == false |
|
return false |
|
end |
|
else |
|
# Undefined directive, or one we don't care about |
|
end |
|
end |
|
true |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/errors.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Errors |
|
# Register this handler, updating the |
|
# internal handler index to maintain least-to-most specific. |
|
# |
|
# @param error_class [Class<Exception>] |
|
# @param error_handlers [Hash] |
|
# @param error_handler [Proc] |
|
# @return [void] |
|
def self.register_rescue_from(error_class, error_handlers, error_handler) |
|
subclasses_handlers = {} |
|
this_level_subclasses = [] |
|
# During this traversal, do two things: |
|
# - Identify any already-registered subclasses of this error class |
|
# and gather them up to be inserted _under_ this class |
|
# - Find the point in the index where this handler should be inserted |
|
# (That is, _under_ any superclasses, or at top-level, if there are no superclasses registered) |
|
while (error_handlers) do |
|
this_level_subclasses.clear |
|
# First, identify already-loaded handlers that belong |
|
# _under_ this one. (That is, they're handlers |
|
# for subclasses of `error_class`.) |
|
error_handlers.each do |err_class, handler| |
|
if err_class < error_class |
|
subclasses_handlers[err_class] = handler |
|
this_level_subclasses << err_class |
|
end |
|
end |
|
# Any handlers that we'll be moving, delete them from this point in the index |
|
this_level_subclasses.each do |err_class| |
|
error_handlers.delete(err_class) |
|
end |
|
|
|
# See if any keys in this hash are superclasses of this new class: |
|
next_index_point = error_handlers.find { |err_class, handler| error_class < err_class } |
|
if next_index_point |
|
error_handlers = next_index_point[1][:subclass_handlers] |
|
else |
|
# this new handler doesn't belong to any sub-handlers, |
|
# so insert it in the current set of `handlers` |
|
break |
|
end |
|
end |
|
# Having found the point at which to insert this handler, |
|
# register it and merge any subclass handlers back in at this point. |
|
this_class_handlers = error_handlers[error_class] |
|
this_class_handlers[:handler] = error_handler |
|
this_class_handlers[:subclass_handlers].merge!(subclasses_handlers) |
|
nil |
|
end |
|
|
|
# @return [Proc, nil] The handler for `error_class`, if one was registered on this schema or inherited |
|
def self.find_handler_for(schema, error_class) |
|
handlers = schema.error_handlers[:subclass_handlers] |
|
handler = nil |
|
while (handlers) do |
|
_err_class, next_handler = handlers.find { |err_class, handler| error_class <= err_class } |
|
if next_handler |
|
handlers = next_handler[:subclass_handlers] |
|
handler = next_handler |
|
else |
|
# Don't reassign `handler` -- |
|
# let the previous assignment carry over outside this block. |
|
break |
|
end |
|
end |
|
|
|
# check for a handler from a parent class: |
|
if schema.superclass.respond_to?(:error_handlers) |
|
parent_handler = find_handler_for(schema.superclass, error_class) |
|
end |
|
|
|
# If the inherited handler is more specific than the one defined here, |
|
# use it. |
|
# If it's a tie (or there is no parent handler), use the one defined here. |
|
# If there's an inherited one, but not one defined here, use the inherited one. |
|
# Otherwise, there's no handler for this error, return `nil`. |
|
if parent_handler && handler && parent_handler[:class] < handler[:class] |
|
parent_handler |
|
elsif handler |
|
handler |
|
elsif parent_handler |
|
parent_handler |
|
else |
|
nil |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "fiber" |
|
require "graphql/execution/interpreter/argument_value" |
|
require "graphql/execution/interpreter/arguments" |
|
require "graphql/execution/interpreter/arguments_cache" |
|
require "graphql/execution/interpreter/execution_errors" |
|
require "graphql/execution/interpreter/runtime" |
|
require "graphql/execution/interpreter/resolve" |
|
require "graphql/execution/interpreter/handles_raw_value" |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
class << self |
|
# Used internally to signal that the query shouldn't be executed |
|
# @api private |
|
NO_OPERATION = GraphQL::EmptyObjects::EMPTY_HASH |
|
|
|
# @param schema [GraphQL::Schema] |
|
# @param queries [Array<GraphQL::Query, Hash>] |
|
# @param context [Hash] |
|
# @param max_complexity [Integer, nil] |
|
# @return [Array<Hash>] One result per query |
|
def run_all(schema, query_options, context: {}, max_complexity: schema.max_complexity) |
|
queries = query_options.map do |opts| |
|
case opts |
|
when Hash |
|
GraphQL::Query.new(schema, nil, **opts) |
|
when GraphQL::Query |
|
opts |
|
else |
|
raise "Expected Hash or GraphQL::Query, not #{opts.class} (#{opts.inspect})" |
|
end |
|
end |
|
|
|
multiplex = Execution::Multiplex.new(schema: schema, queries: queries, context: context, max_complexity: max_complexity) |
|
multiplex.current_trace.execute_multiplex(multiplex: multiplex) do |
|
schema = multiplex.schema |
|
queries = multiplex.queries |
|
query_instrumenters = schema.instrumenters[:query] |
|
multiplex_instrumenters = schema.instrumenters[:multiplex] |
|
lazies_at_depth = Hash.new { |h, k| h[k] = [] } |
|
|
|
# First, run multiplex instrumentation, then query instrumentation for each query |
|
call_hooks(multiplex_instrumenters, multiplex, :before_multiplex, :after_multiplex) do |
|
each_query_call_hooks(query_instrumenters, queries) do |
|
schema = multiplex.schema |
|
multiplex_analyzers = schema.multiplex_analyzers |
|
queries = multiplex.queries |
|
if multiplex.max_complexity |
|
multiplex_analyzers += [GraphQL::Analysis::AST::MaxQueryComplexity] |
|
end |
|
|
|
schema.analysis_engine.analyze_multiplex(multiplex, multiplex_analyzers) |
|
begin |
|
# Since this is basically the batching context, |
|
# share it for a whole multiplex |
|
multiplex.context[:interpreter_instance] ||= multiplex.schema.query_execution_strategy.new |
|
# Do as much eager evaluation of the query as possible |
|
results = [] |
|
queries.each_with_index do |query, idx| |
|
multiplex.dataloader.append_job { |
|
operation = query.selected_operation |
|
result = if operation.nil? || !query.valid? || query.context.errors.any? |
|
NO_OPERATION |
|
else |
|
begin |
|
# Although queries in a multiplex _share_ an Interpreter instance, |
|
# they also have another item of state, which is private to that query |
|
# in particular, assign it here: |
|
runtime = Runtime.new(query: query, lazies_at_depth: lazies_at_depth) |
|
query.context.namespace(:interpreter_runtime)[:runtime] = runtime |
|
|
|
query.current_trace.execute_query(query: query) do |
|
runtime.run_eager |
|
end |
|
rescue GraphQL::ExecutionError => err |
|
query.context.errors << err |
|
NO_OPERATION |
|
end |
|
end |
|
results[idx] = result |
|
} |
|
end |
|
|
|
multiplex.dataloader.run |
|
|
|
# Then, work through lazy results in a breadth-first way |
|
multiplex.dataloader.append_job { |
|
query = multiplex.queries.length == 1 ? multiplex.queries[0] : nil |
|
queries = multiplex ? multiplex.queries : [query] |
|
final_values = queries.map do |query| |
|
runtime = query.context.namespace(:interpreter_runtime)[:runtime] |
|
# it might not be present if the query has an error |
|
runtime ? runtime.final_result : nil |
|
end |
|
final_values.compact! |
|
multiplex.current_trace.execute_query_lazy(multiplex: multiplex, query: query) do |
|
Interpreter::Resolve.resolve_each_depth(lazies_at_depth, multiplex.dataloader) |
|
end |
|
} |
|
multiplex.dataloader.run |
|
|
|
# Then, find all errors and assign the result to the query object |
|
results.each_with_index do |data_result, idx| |
|
query = queries[idx] |
|
# Assign the result so that it can be accessed in instrumentation |
|
query.result_values = if data_result.equal?(NO_OPERATION) |
|
if !query.valid? || query.context.errors.any? |
|
# A bit weird, but `Query#static_errors` _includes_ `query.context.errors` |
|
{ "errors" => query.static_errors.map(&:to_h) } |
|
else |
|
data_result |
|
end |
|
else |
|
result = { |
|
"data" => query.context.namespace(:interpreter_runtime)[:runtime].final_result |
|
} |
|
|
|
if query.context.errors.any? |
|
error_result = query.context.errors.map(&:to_h) |
|
result["errors"] = error_result |
|
end |
|
|
|
result |
|
end |
|
if query.context.namespace?(:__query_result_extensions__) |
|
query.result_values["extensions"] = query.context.namespace(:__query_result_extensions__) |
|
end |
|
# Get the Query::Result, not the Hash |
|
results[idx] = query.result |
|
end |
|
|
|
results |
|
rescue Exception |
|
# TODO rescue at a higher level so it will catch errors in analysis, too |
|
# Assign values here so that the query's `@executed` becomes true |
|
queries.map { |q| q.result_values ||= {} } |
|
raise |
|
ensure |
|
queries.map { |query| |
|
runtime = query.context.namespace(:interpreter_runtime)[:runtime] |
|
if runtime |
|
runtime.delete_all_interpreter_context |
|
end |
|
} |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
private |
|
|
|
# Call the before_ hooks of each query, |
|
# Then yield if no errors. |
|
# `call_hooks` takes care of appropriate cleanup. |
|
def each_query_call_hooks(instrumenters, queries, i = 0) |
|
if i >= queries.length |
|
yield |
|
else |
|
query = queries[i] |
|
call_hooks(instrumenters, query, :before_query, :after_query) { |
|
each_query_call_hooks(instrumenters, queries, i + 1) { |
|
yield |
|
} |
|
} |
|
end |
|
end |
|
|
|
# Call each before hook, and if they all succeed, yield. |
|
# If they don't all succeed, call after_ for each one that succeeded. |
|
def call_hooks(instrumenters, object, before_hook_name, after_hook_name) |
|
begin |
|
successful = [] |
|
instrumenters.each do |instrumenter| |
|
instrumenter.public_send(before_hook_name, object) |
|
successful << instrumenter |
|
end |
|
|
|
# if any before hooks raise an exception, quit calling before hooks, |
|
# but call the after hooks on anything that succeeded but also |
|
# raise the exception that came from the before hook. |
|
rescue GraphQL::ExecutionError => err |
|
object.context.errors << err |
|
rescue => e |
|
raise call_after_hooks(successful, object, after_hook_name, e) |
|
end |
|
|
|
begin |
|
yield # Call the user code |
|
ensure |
|
ex = call_after_hooks(successful, object, after_hook_name, nil) |
|
raise ex if ex |
|
end |
|
end |
|
|
|
def call_after_hooks(instrumenters, object, after_hook_name, ex) |
|
instrumenters.reverse_each do |instrumenter| |
|
begin |
|
instrumenter.public_send(after_hook_name, object) |
|
rescue => e |
|
ex = e |
|
end |
|
end |
|
ex |
|
end |
|
end |
|
|
|
class ListResultFailedError < GraphQL::Error |
|
def initialize(value:, path:, field:) |
|
message = "Failed to build a GraphQL list result for field `#{field.path}` at path `#{path.join(".")}`.\n".dup |
|
|
|
message << "Expected `#{value.inspect}` (#{value.class}) to implement `.each` to satisfy the GraphQL return type `#{field.type.to_type_signature}`.\n" |
|
|
|
if field.connection? |
|
message << "\nThis field was treated as a Relay-style connection; add `connection: false` to the `field(...)` to disable this behavior." |
|
end |
|
super(message) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/lazy.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/execution/lazy/lazy_method_map" |
|
|
|
module GraphQL |
|
module Execution |
|
# This wraps a value which is available, but not yet calculated, like a promise or future. |
|
# |
|
# Calling `#value` will trigger calculation & return the "lazy" value. |
|
# |
|
# This is an itty-bitty promise-like object, with key differences: |
|
# - It has only two states, not-resolved and resolved |
|
# - It has no error-catching functionality |
|
# @api private |
|
class Lazy |
|
attr_reader :field |
|
|
|
# Create a {Lazy} which will get its inner value by calling the block |
|
# @param field [GraphQL::Schema::Field] |
|
# @param get_value_func [Proc] a block to get the inner value (later) |
|
def initialize(field: nil, &get_value_func) |
|
@get_value_func = get_value_func |
|
@resolved = false |
|
@field = field |
|
end |
|
|
|
# @return [Object] The wrapped value, calling the lazy block if necessary |
|
def value |
|
if !@resolved |
|
@resolved = true |
|
v = @get_value_func.call |
|
if v.is_a?(Lazy) |
|
v = v.value |
|
end |
|
@value = v |
|
end |
|
|
|
# `SKIP` was made into a subclass of `GraphQL::Error` to improve runtime performance |
|
# (fewer clauses in a hot `case` block), but now it requires special handling here. |
|
# I think it's still worth it for the performance win, but if the number of special |
|
# cases grows, then maybe it's worth rethinking somehow. |
|
if @value.is_a?(StandardError) && @value != GraphQL::Execution::SKIP |
|
raise @value |
|
else |
|
@value |
|
end |
|
end |
|
|
|
# @return [Lazy] A {Lazy} whose value depends on another {Lazy}, plus any transformations in `block` |
|
def then |
|
self.class.new { |
|
yield(value) |
|
} |
|
end |
|
|
|
# @param lazies [Array<Object>] Maybe-lazy objects |
|
# @return [Lazy] A lazy which will sync all of `lazies` |
|
def self.all(lazies) |
|
self.new { |
|
lazies.map { |l| l.is_a?(Lazy) ? l.value : l } |
|
} |
|
end |
|
|
|
# This can be used for fields which _had no_ lazy results |
|
# @api private |
|
NullResult = Lazy.new(){} |
|
NullResult.value |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/lookahead.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Execution |
|
# Lookahead creates a uniform interface to inspect the forthcoming selections. |
|
# |
|
# It assumes that the AST it's working with is valid. (So, it's safe to use |
|
# during execution, but if you're using it directly, be sure to validate first.) |
|
# |
|
# A field may get access to its lookahead by adding `extras: [:lookahead]` |
|
# to its configuration. |
|
# |
|
# @example looking ahead in a field |
|
# field :articles, [Types::Article], null: false, |
|
# extras: [:lookahead] |
|
# |
|
# # For example, imagine a faster database call |
|
# # may be issued when only some fields are requested. |
|
# # |
|
# # Imagine that _full_ fetch must be made to satisfy `fullContent`, |
|
# # we can look ahead to see if we need that field. If we do, |
|
# # we make the expensive database call instead of the cheap one. |
|
# def articles(lookahead:) |
|
# if lookahead.selects?(:full_content) |
|
# fetch_full_articles(object) |
|
# else |
|
# fetch_preview_articles(object) |
|
# end |
|
# end |
|
class Lookahead |
|
# @param query [GraphQL::Query] |
|
# @param ast_nodes [Array<GraphQL::Language::Nodes::Field>, Array<GraphQL::Language::Nodes::OperationDefinition>] |
|
# @param field [GraphQL::Schema::Field] if `ast_nodes` are fields, this is the field definition matching those nodes |
|
# @param root_type [Class] if `ast_nodes` are operation definition, this is the root type for that operation |
|
def initialize(query:, ast_nodes:, field: nil, root_type: nil, owner_type: nil) |
|
@ast_nodes = ast_nodes.freeze |
|
@field = field |
|
@root_type = root_type |
|
@query = query |
|
@selected_type = @field ? @field.type.unwrap : root_type |
|
@owner_type = owner_type |
|
end |
|
|
|
# @return [Array<GraphQL::Language::Nodes::Field>] |
|
attr_reader :ast_nodes |
|
|
|
# @return [GraphQL::Schema::Field] |
|
attr_reader :field |
|
|
|
# @return [GraphQL::Schema::Object, GraphQL::Schema::Union, GraphQL::Schema::Interface] |
|
attr_reader :owner_type |
|
|
|
# @return [Hash<Symbol, Object>] |
|
def arguments |
|
if defined?(@arguments) |
|
@arguments |
|
else |
|
@arguments = if @field |
|
@query.after_lazy(@query.arguments_for(@ast_nodes.first, @field)) do |args| |
|
args.is_a?(Execution::Interpreter::Arguments) ? args.keyword_arguments : args |
|
end |
|
else |
|
nil |
|
end |
|
end |
|
end |
|
|
|
# True if this node has a selection on `field_name`. |
|
# If `field_name` is a String, it is treated as a GraphQL-style (camelized) |
|
# field name and used verbatim. If `field_name` is a Symbol, it is |
|
# treated as a Ruby-style (underscored) name and camelized before comparing. |
|
# |
|
# If `arguments:` is provided, each provided key/value will be matched |
|
# against the arguments in the next selection. This method will return false |
|
# if any of the given `arguments:` are not present and matching in the next selection. |
|
# (But, the next selection may contain _more_ than the given arguments.) |
|
# @param field_name [String, Symbol] |
|
# @param arguments [Hash] Arguments which must match in the selection |
|
# @return [Boolean] |
|
def selects?(field_name, selected_type: @selected_type, arguments: nil) |
|
selection(field_name, selected_type: selected_type, arguments: arguments).selected? |
|
end |
|
|
|
# @return [Boolean] True if this lookahead represents a field that was requested |
|
def selected? |
|
true |
|
end |
|
|
|
# Like {#selects?}, but can be used for chaining. |
|
# It returns a null object (check with {#selected?}) |
|
# @param field_name [String, Symbol] |
|
# @return [GraphQL::Execution::Lookahead] |
|
def selection(field_name, selected_type: @selected_type, arguments: nil) |
|
next_field_defn = case field_name |
|
when String |
|
@query.get_field(selected_type, field_name) |
|
when Symbol |
|
# Try to avoid the `.to_s` below, if possible |
|
all_fields = if selected_type.kind.fields? |
|
@query.warden.fields(selected_type) |
|
else |
|
# Handle unions by checking possible |
|
@query.warden |
|
.possible_types(selected_type) |
|
.map { |t| @query.warden.fields(t) } |
|
.tap(&:flatten!) |
|
end |
|
|
|
if (match_by_orig_name = all_fields.find { |f| f.original_name == field_name }) |
|
match_by_orig_name |
|
else |
|
# Symbol#name is only present on 3.0+ |
|
sym_s = field_name.respond_to?(:name) ? field_name.name : field_name.to_s |
|
guessed_name = Schema::Member::BuildType.camelize(sym_s) |
|
@query.get_field(selected_type, guessed_name) |
|
end |
|
end |
|
|
|
if next_field_defn |
|
next_nodes = [] |
|
@ast_nodes.each do |ast_node| |
|
ast_node.selections.each do |selection| |
|
find_selected_nodes(selection, next_field_defn, arguments: arguments, matches: next_nodes) |
|
end |
|
end |
|
|
|
if next_nodes.any? |
|
Lookahead.new(query: @query, ast_nodes: next_nodes, field: next_field_defn, owner_type: selected_type) |
|
else |
|
NULL_LOOKAHEAD |
|
end |
|
else |
|
NULL_LOOKAHEAD |
|
end |
|
end |
|
|
|
# Like {#selection}, but for all nodes. |
|
# It returns a list of Lookaheads for all Selections |
|
# |
|
# If `arguments:` is provided, each provided key/value will be matched |
|
# against the arguments in each selection. This method will filter the selections |
|
# if any of the given `arguments:` do not match the given selection. |
|
# |
|
# @example getting the name of a selection |
|
# def articles(lookahead:) |
|
# next_lookaheads = lookahead.selections # => [#<GraphQL::Execution::Lookahead ...>, ...] |
|
# next_lookaheads.map(&:name) #=> [:full_content, :title] |
|
# end |
|
# |
|
# @param arguments [Hash] Arguments which must match in the selection |
|
# @return [Array<GraphQL::Execution::Lookahead>] |
|
def selections(arguments: nil) |
|
subselections_by_type = {} |
|
subselections_on_type = subselections_by_type[@selected_type] = {} |
|
|
|
@ast_nodes.each do |node| |
|
find_selections(subselections_by_type, subselections_on_type, @selected_type, node.selections, arguments) |
|
end |
|
|
|
subselections = [] |
|
|
|
subselections_by_type.each do |type, ast_nodes_by_response_key| |
|
ast_nodes_by_response_key.each do |response_key, ast_nodes| |
|
field_defn = @query.get_field(type, ast_nodes.first.name) |
|
lookahead = Lookahead.new(query: @query, ast_nodes: ast_nodes, field: field_defn, owner_type: type) |
|
subselections.push(lookahead) |
|
end |
|
end |
|
|
|
subselections |
|
end |
|
|
|
# The method name of the field. |
|
# It returns the method_sym of the Lookahead's field. |
|
# |
|
# @example getting the name of a selection |
|
# def articles(lookahead:) |
|
# article.selection(:full_content).name # => :full_content |
|
# # ... |
|
# end |
|
# |
|
# @return [Symbol] |
|
def name |
|
@field && @field.original_name |
|
end |
|
|
|
def inspect |
|
"#<GraphQL::Execution::Lookahead #{@field ? "@field=#{@field.path.inspect}": "@root_type=#{@root_type}"} @ast_nodes.size=#{@ast_nodes.size}>" |
|
end |
|
|
|
# This is returned for {Lookahead#selection} when a non-existent field is passed |
|
class NullLookahead < Lookahead |
|
# No inputs required here. |
|
def initialize |
|
end |
|
|
|
def selected? |
|
false |
|
end |
|
|
|
def selects?(*) |
|
false |
|
end |
|
|
|
def selection(*) |
|
NULL_LOOKAHEAD |
|
end |
|
|
|
def selections(*) |
|
[] |
|
end |
|
|
|
def inspect |
|
"#<GraphQL::Execution::Lookahead::NullLookahead>" |
|
end |
|
end |
|
|
|
# A singleton, so that misses don't come with overhead. |
|
NULL_LOOKAHEAD = NullLookahead.new |
|
|
|
private |
|
|
|
def skipped_by_directive?(ast_selection) |
|
ast_selection.directives.each do |directive| |
|
dir_defn = @query.schema.directives.fetch(directive.name) |
|
directive_class = dir_defn |
|
if directive_class |
|
dir_args = @query.arguments_for(directive, dir_defn) |
|
return true unless directive_class.static_include?(dir_args, @query.context) |
|
end |
|
end |
|
false |
|
end |
|
|
|
def find_selections(subselections_by_type, selections_on_type, selected_type, ast_selections, arguments) |
|
ast_selections.each do |ast_selection| |
|
next if skipped_by_directive?(ast_selection) |
|
|
|
case ast_selection |
|
when GraphQL::Language::Nodes::Field |
|
response_key = ast_selection.alias || ast_selection.name |
|
if selections_on_type.key?(response_key) |
|
selections_on_type[response_key] << ast_selection |
|
elsif arguments.nil? || arguments.empty? |
|
selections_on_type[response_key] = [ast_selection] |
|
else |
|
field_defn = @query.get_field(selected_type, ast_selection.name) |
|
if arguments_match?(arguments, field_defn, ast_selection) |
|
selections_on_type[response_key] = [ast_selection] |
|
end |
|
end |
|
when GraphQL::Language::Nodes::InlineFragment |
|
on_type = selected_type |
|
subselections_on_type = selections_on_type |
|
if (t = ast_selection.type) |
|
# Assuming this is valid, that `t` will be found. |
|
on_type = @query.get_type(t.name) |
|
subselections_on_type = subselections_by_type[on_type] ||= {} |
|
end |
|
find_selections(subselections_by_type, subselections_on_type, on_type, ast_selection.selections, arguments) |
|
when GraphQL::Language::Nodes::FragmentSpread |
|
frag_defn = @query.fragments[ast_selection.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{ast_selection.name} (found: #{@query.fragments.keys})") |
|
# Again, assuming a valid AST |
|
on_type = @query.get_type(frag_defn.type.name) |
|
subselections_on_type = subselections_by_type[on_type] ||= {} |
|
find_selections(subselections_by_type, subselections_on_type, on_type, frag_defn.selections, arguments) |
|
else |
|
raise "Invariant: Unexpected selection type: #{ast_selection.class}" |
|
end |
|
end |
|
end |
|
|
|
# If a selection on `node` matches `field_name` (which is backed by `field_defn`) |
|
# and matches the `arguments:` constraints, then add that node to `matches` |
|
def find_selected_nodes(node, field_defn, arguments:, matches:) |
|
return if skipped_by_directive?(node) |
|
case node |
|
when GraphQL::Language::Nodes::Field |
|
if node.name == field_defn.graphql_name |
|
if arguments.nil? || arguments.empty? |
|
# No constraint applied |
|
matches << node |
|
elsif arguments_match?(arguments, field_defn, node) |
|
matches << node |
|
end |
|
end |
|
when GraphQL::Language::Nodes::InlineFragment |
|
node.selections.each { |s| find_selected_nodes(s, field_defn, arguments: arguments, matches: matches) } |
|
when GraphQL::Language::Nodes::FragmentSpread |
|
frag_defn = @query.fragments[node.name] || raise("Invariant: Can't look ahead to nonexistent fragment #{node.name} (found: #{@query.fragments.keys})") |
|
frag_defn.selections.each { |s| find_selected_nodes(s, field_defn, arguments: arguments, matches: matches) } |
|
else |
|
raise "Unexpected selection comparison on #{node.class.name} (#{node})" |
|
end |
|
end |
|
|
|
def arguments_match?(arguments, field_defn, field_node) |
|
query_kwargs = @query.arguments_for(field_node, field_defn) |
|
arguments.all? do |arg_name, arg_value| |
|
arg_name_sym = if arg_name.is_a?(String) |
|
Schema::Member::BuildType.underscore(arg_name).to_sym |
|
else |
|
arg_name |
|
end |
|
|
|
# Make sure the constraint is present with a matching value |
|
query_kwargs.key?(arg_name_sym) && query_kwargs[arg_name_sym] == arg_value |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/multiplex.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Execution |
|
# Execute multiple queries under the same multiplex "umbrella". |
|
# They can share a batching context and reduce redundant database hits. |
|
# |
|
# The flow is: |
|
# |
|
# - Multiplex instrumentation setup |
|
# - Query instrumentation setup |
|
# - Analyze the multiplex + each query |
|
# - Begin each query |
|
# - Resolve lazy values, breadth-first across all queries |
|
# - Finish each query (eg, get errors) |
|
# - Query instrumentation teardown |
|
# - Multiplex instrumentation teardown |
|
# |
|
# If one query raises an application error, all queries will be in undefined states. |
|
# |
|
# Validation errors and {GraphQL::ExecutionError}s are handled in isolation: |
|
# one of these errors in one query will not affect the other queries. |
|
# |
|
# @see {Schema#multiplex} for public API |
|
# @api private |
|
class Multiplex |
|
include Tracing::Traceable |
|
|
|
attr_reader :context, :queries, :schema, :max_complexity, :dataloader, :current_trace |
|
|
|
def initialize(schema:, queries:, context:, max_complexity:) |
|
@schema = schema |
|
@queries = queries |
|
@queries.each { |q| q.multiplex = self } |
|
@context = context |
|
@current_trace = @context[:trace] || schema.new_trace(multiplex: self) |
|
@dataloader = @context[:dataloader] ||= @schema.dataloader_class.new |
|
@tracers = schema.tracers + (context[:tracers] || []) |
|
# Support `context: {backtrace: true}` |
|
if context[:backtrace] && !@tracers.include?(GraphQL::Backtrace::Tracer) |
|
@tracers << GraphQL::Backtrace::Tracer |
|
end |
|
@max_complexity = max_complexity |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/argument_value.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
# A container for metadata regarding arguments present in a GraphQL query. |
|
# @see Interpreter::Arguments#argument_values for a hash of these objects. |
|
class ArgumentValue |
|
def initialize(definition:, value:, default_used:) |
|
@definition = definition |
|
@value = value |
|
@default_used = default_used |
|
end |
|
|
|
# @return [Object] The Ruby-ready value for this Argument |
|
attr_reader :value |
|
|
|
# @return [GraphQL::Schema::Argument] The definition instance for this argument |
|
attr_reader :definition |
|
|
|
# @return [Boolean] `true` if the schema-defined `default_value:` was applied in this case. (No client-provided value was present.) |
|
def default_used? |
|
@default_used |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/arguments.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
# A wrapper for argument hashes in GraphQL queries. |
|
# |
|
# This object is immutable so that the runtime code can be sure that |
|
# modifications don't leak from one use to another |
|
# |
|
# @see GraphQL::Query#arguments_for to get access to these objects. |
|
class Arguments |
|
extend Forwardable |
|
include GraphQL::Dig |
|
|
|
# The Ruby-style arguments hash, ready for a resolver. |
|
# This hash is the one used at runtime. |
|
# |
|
# @return [Hash<Symbol, Object>] |
|
attr_reader :keyword_arguments |
|
|
|
# @param argument_values [nil, Hash{Symbol => ArgumentValue}] |
|
# @param keyword_arguments [nil, Hash{Symbol => Object}] |
|
def initialize(keyword_arguments: nil, argument_values:) |
|
@empty = argument_values.nil? || argument_values.empty? |
|
# This is only present when `extras` have been merged in: |
|
if keyword_arguments |
|
# This is a little crazy. We expect the `:argument_details` extra to _include extras_, |
|
# but the object isn't created until _after_ extras are put together. |
|
# So, we have to use a special flag here to say, "at the last minute, add yourself to the keyword args." |
|
# |
|
# Otherwise: |
|
# - We can't access the final Arguments instance _while_ we're preparing extras |
|
# - After we _can_ access it, it's frozen, so we can't add anything. |
|
# |
|
# So, this flag gives us a chance to sneak it in before freezing, _and_ while we have access |
|
# to the new Arguments instance itself. |
|
if keyword_arguments[:argument_details] == :__arguments_add_self |
|
keyword_arguments[:argument_details] = self |
|
end |
|
@keyword_arguments = keyword_arguments.freeze |
|
elsif !@empty |
|
@keyword_arguments = {} |
|
argument_values.each do |name, arg_val| |
|
@keyword_arguments[name] = arg_val.value |
|
end |
|
@keyword_arguments.freeze |
|
else |
|
@keyword_arguments = NO_ARGS |
|
end |
|
@argument_values = argument_values ? argument_values.freeze : NO_ARGS |
|
freeze |
|
end |
|
|
|
# @return [Hash{Symbol => ArgumentValue}] |
|
attr_reader :argument_values |
|
|
|
def empty? |
|
@empty |
|
end |
|
|
|
def_delegators :keyword_arguments, :key?, :[], :fetch, :keys, :each, :values, :size, :to_h |
|
def_delegators :argument_values, :each_value |
|
|
|
def inspect |
|
"#<#{self.class} @keyword_arguments=#{keyword_arguments.inspect}>" |
|
end |
|
|
|
# Create a new arguments instance which includes these extras. |
|
# |
|
# This is called by the runtime to implement field `extras: [...]` |
|
# |
|
# @param extra_args [Hash<Symbol => Object>] |
|
# @return [Interpreter::Arguments] |
|
# @api private |
|
def merge_extras(extra_args) |
|
self.class.new( |
|
argument_values: argument_values, |
|
keyword_arguments: keyword_arguments.merge(extra_args) |
|
) |
|
end |
|
|
|
NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH |
|
EMPTY = self.new(argument_values: nil, keyword_arguments: NO_ARGS).freeze |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/arguments_cache.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
class ArgumentsCache |
|
def initialize(query) |
|
@query = query |
|
@dataloader = query.context.dataloader |
|
@storage = Hash.new do |h, argument_owner| |
|
args_by_parent = if argument_owner.arguments_statically_coercible? |
|
shared_values_cache = {} |
|
Hash.new do |h2, ignored_parent_object| |
|
h2[ignored_parent_object] = shared_values_cache |
|
end |
|
else |
|
Hash.new do |h2, parent_object| |
|
args_by_node = {} |
|
args_by_node.compare_by_identity |
|
h2[parent_object] = args_by_node |
|
end |
|
end |
|
args_by_parent.compare_by_identity |
|
h[argument_owner] = args_by_parent |
|
end |
|
@storage.compare_by_identity |
|
end |
|
|
|
def fetch(ast_node, argument_owner, parent_object) |
|
# This runs eagerly if no block is given |
|
@storage[argument_owner][parent_object][ast_node] ||= begin |
|
args_hash = self.class.prepare_args_hash(@query, ast_node) |
|
kwarg_arguments = argument_owner.coerce_arguments(parent_object, args_hash, @query.context) |
|
@query.after_lazy(kwarg_arguments) do |resolved_args| |
|
@storage[argument_owner][parent_object][ast_node] = resolved_args |
|
end |
|
end |
|
|
|
end |
|
|
|
# @yield [Interpreter::Arguments, Lazy<Interpreter::Arguments>] The finally-loaded arguments |
|
def dataload_for(ast_node, argument_owner, parent_object, &block) |
|
# First, normalize all AST or Ruby values to a plain Ruby hash |
|
arg_storage = @storage[argument_owner][parent_object] |
|
if (args = arg_storage[ast_node]) |
|
yield(args) |
|
else |
|
args_hash = self.class.prepare_args_hash(@query, ast_node) |
|
argument_owner.coerce_arguments(parent_object, args_hash, @query.context) do |resolved_args| |
|
arg_storage[ast_node] = resolved_args |
|
yield(resolved_args) |
|
end |
|
end |
|
nil |
|
end |
|
|
|
private |
|
|
|
NO_ARGUMENTS = GraphQL::EmptyObjects::EMPTY_HASH |
|
NO_VALUE_GIVEN = NOT_CONFIGURED |
|
|
|
def self.prepare_args_hash(query, ast_arg_or_hash_or_value) |
|
case ast_arg_or_hash_or_value |
|
when Hash |
|
if ast_arg_or_hash_or_value.empty? |
|
return NO_ARGUMENTS |
|
end |
|
args_hash = {} |
|
ast_arg_or_hash_or_value.each do |k, v| |
|
args_hash[k] = prepare_args_hash(query, v) |
|
end |
|
args_hash |
|
when Array |
|
ast_arg_or_hash_or_value.map { |v| prepare_args_hash(query, v) } |
|
when GraphQL::Language::Nodes::Field, GraphQL::Language::Nodes::InputObject, GraphQL::Language::Nodes::Directive |
|
if ast_arg_or_hash_or_value.arguments.empty? # rubocop:disable Development/ContextIsPassedCop -- AST-related |
|
return NO_ARGUMENTS |
|
end |
|
args_hash = {} |
|
ast_arg_or_hash_or_value.arguments.each do |arg| # rubocop:disable Development/ContextIsPassedCop -- AST-related |
|
v = prepare_args_hash(query, arg.value) |
|
if v != NO_VALUE_GIVEN |
|
args_hash[arg.name] = v |
|
end |
|
end |
|
args_hash |
|
when GraphQL::Language::Nodes::VariableIdentifier |
|
if query.variables.key?(ast_arg_or_hash_or_value.name) |
|
variable_value = query.variables[ast_arg_or_hash_or_value.name] |
|
prepare_args_hash(query, variable_value) |
|
else |
|
NO_VALUE_GIVEN |
|
end |
|
when GraphQL::Language::Nodes::Enum |
|
ast_arg_or_hash_or_value.name |
|
when GraphQL::Language::Nodes::NullValue |
|
nil |
|
else |
|
ast_arg_or_hash_or_value |
|
end |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/execution_errors.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
class ExecutionErrors |
|
def initialize(ctx, ast_node, path) |
|
@context = ctx |
|
@ast_node = ast_node |
|
@path = path |
|
end |
|
|
|
def add(err_or_msg) |
|
err = case err_or_msg |
|
when String |
|
GraphQL::ExecutionError.new(err_or_msg) |
|
when GraphQL::ExecutionError |
|
err_or_msg |
|
else |
|
raise ArgumentError, "expected String or GraphQL::ExecutionError, not #{err_or_msg.class} (#{err_or_msg.inspect})" |
|
end |
|
err.ast_node ||= @ast_node |
|
err.path ||= @path |
|
@context.add_error(err) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/handles_raw_value.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
# Wrapper for raw values |
|
class RawValue |
|
def initialize(obj = nil) |
|
@object = obj |
|
end |
|
|
|
def resolve |
|
@object |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/resolve.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
module Resolve |
|
# Continue field results in `results` until there's nothing else to continue. |
|
# @return [void] |
|
def self.resolve_all(results, dataloader) |
|
dataloader.append_job { resolve(results, dataloader) } |
|
nil |
|
end |
|
|
|
def self.resolve_each_depth(lazies_at_depth, dataloader) |
|
depths = lazies_at_depth.keys |
|
depths.sort! |
|
next_depth = depths.first |
|
if next_depth |
|
lazies = lazies_at_depth[next_depth] |
|
lazies_at_depth.delete(next_depth) |
|
if lazies.any? |
|
dataloader.append_job { |
|
lazies.each(&:value) # resolve these Lazy instances |
|
} |
|
# Run lazies _and_ dataloader, see if more are enqueued |
|
dataloader.run |
|
resolve_each_depth(lazies_at_depth, dataloader) |
|
end |
|
end |
|
nil |
|
end |
|
|
|
# After getting `results` back from an interpreter evaluation, |
|
# continue it until you get a response-ready Ruby value. |
|
# |
|
# `results` is one level of _depth_ of a query or multiplex. |
|
# |
|
# Resolve all lazy values in that depth before moving on |
|
# to the next level. |
|
# |
|
# It's assumed that the lazies will |
|
# return {Lazy} instances if there's more work to be done, |
|
# or return {Hash}/{Array} if the query should be continued. |
|
# |
|
# @return [void] |
|
def self.resolve(results, dataloader) |
|
# There might be pending jobs here that _will_ write lazies |
|
# into the result hash. We should run them out, so we |
|
# can be sure that all lazies will be present in the result hashes. |
|
# A better implementation would somehow interleave (or unify) |
|
# these approaches. |
|
dataloader.run |
|
next_results = [] |
|
while results.any? |
|
result_value = results.shift |
|
if result_value.is_a?(Runtime::GraphQLResultHash) || result_value.is_a?(Hash) |
|
results.concat(result_value.values) |
|
next |
|
elsif result_value.is_a?(Runtime::GraphQLResultArray) |
|
results.concat(result_value.values) |
|
next |
|
elsif result_value.is_a?(Array) |
|
results.concat(result_value) |
|
next |
|
elsif result_value.is_a?(Lazy) |
|
loaded_value = result_value.value |
|
if loaded_value.is_a?(Lazy) |
|
# Since this field returned another lazy, |
|
# add it to the same queue |
|
results << loaded_value |
|
elsif loaded_value.is_a?(Runtime::GraphQLResultHash) || loaded_value.is_a?(Runtime::GraphQLResultArray) || |
|
loaded_value.is_a?(Hash) || loaded_value.is_a?(Array) |
|
# Add these values in wholesale -- |
|
# they might be modified by later work in the dataloader. |
|
next_results << loaded_value |
|
end |
|
end |
|
end |
|
|
|
if next_results.any? |
|
# Any pending data loader jobs may populate the |
|
# resutl arrays or result hashes accumulated in |
|
# `next_results``. Run those **to completion** |
|
# before continuing to resolve `next_results`. |
|
# (Just `.append_job` doesn't work if any pending |
|
# jobs require multiple passes.) |
|
dataloader.run |
|
dataloader.append_job { resolve(next_results, dataloader) } |
|
end |
|
|
|
nil |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/runtime.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/execution/interpreter/runtime/graphql_result" |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
# I think it would be even better if we could somehow make |
|
# `continue_field` not recursive. "Trampolining" it somehow. |
|
# |
|
# @api private |
|
class Runtime |
|
class CurrentState |
|
def initialize |
|
@current_object = nil |
|
@current_field = nil |
|
@current_arguments = nil |
|
@current_result_name = nil |
|
@current_result = nil |
|
@was_authorized_by_scope_items = nil |
|
end |
|
|
|
attr_accessor :current_result, :current_result_name, |
|
:current_arguments, :current_field, :current_object, :was_authorized_by_scope_items |
|
end |
|
|
|
# @return [GraphQL::Query] |
|
attr_reader :query |
|
|
|
# @return [Class<GraphQL::Schema>] |
|
attr_reader :schema |
|
|
|
# @return [GraphQL::Query::Context] |
|
attr_reader :context |
|
|
|
def initialize(query:, lazies_at_depth:) |
|
@query = query |
|
@current_trace = query.current_trace |
|
@dataloader = query.multiplex.dataloader |
|
@lazies_at_depth = lazies_at_depth |
|
@schema = query.schema |
|
@context = query.context |
|
@response = GraphQLResultHash.new(nil, nil, false) |
|
# Identify runtime directives by checking which of this schema's directives have overridden `def self.resolve` |
|
@runtime_directive_names = [] |
|
noop_resolve_owner = GraphQL::Schema::Directive.singleton_class |
|
@schema_directives = schema.directives |
|
@schema_directives.each do |name, dir_defn| |
|
if dir_defn.method(:resolve).owner != noop_resolve_owner |
|
@runtime_directive_names << name |
|
end |
|
end |
|
# { Class => Boolean } |
|
@lazy_cache = {} |
|
@lazy_cache.compare_by_identity |
|
|
|
@gathered_selections_cache = Hash.new { |h, k| |
|
cache = {} |
|
cache.compare_by_identity |
|
h[k] = cache |
|
} |
|
@gathered_selections_cache.compare_by_identity |
|
end |
|
|
|
def final_result |
|
@response && @response.graphql_result_data |
|
end |
|
|
|
def inspect |
|
"#<#{self.class.name} response=#{@response.inspect}>" |
|
end |
|
|
|
def tap_or_each(obj_or_array) |
|
if obj_or_array.is_a?(Array) |
|
obj_or_array.each do |item| |
|
yield(item, true) |
|
end |
|
else |
|
yield(obj_or_array, false) |
|
end |
|
end |
|
|
|
# This _begins_ the execution. Some deferred work |
|
# might be stored up in lazies. |
|
# @return [void] |
|
def run_eager |
|
root_operation = query.selected_operation |
|
root_op_type = root_operation.operation_type || "query" |
|
root_type = schema.root_type_for_operation(root_op_type) |
|
|
|
st = get_current_runtime_state |
|
st.current_object = query.root_value |
|
st.current_result = @response |
|
runtime_object = root_type.wrap(query.root_value, context) |
|
runtime_object = schema.sync_lazy(runtime_object) |
|
|
|
if runtime_object.nil? |
|
# Root .authorized? returned false. |
|
@response = nil |
|
else |
|
call_method_on_directives(:resolve, runtime_object, root_operation.directives) do # execute query level directives |
|
gathered_selections = gather_selections(runtime_object, root_type, nil, root_operation.selections) |
|
# This is kind of a hack -- `gathered_selections` is an Array if any of the selections |
|
# require isolation during execution (because of runtime directives). In that case, |
|
# make a new, isolated result hash for writing the result into. (That isolated response |
|
# is eventually merged back into the main response) |
|
# |
|
# Otherwise, `gathered_selections` is a hash of selections which can be |
|
# directly evaluated and the results can be written right into the main response hash. |
|
tap_or_each(gathered_selections) do |selections, is_selection_array| |
|
if is_selection_array |
|
selection_response = GraphQLResultHash.new(nil, nil, false) |
|
final_response = @response |
|
else |
|
selection_response = @response |
|
final_response = nil |
|
end |
|
|
|
@dataloader.append_job { |
|
st = get_current_runtime_state |
|
st.current_object = query.root_value |
|
st.current_result_name = nil |
|
st.current_result = selection_response |
|
# This is a less-frequent case; use a fast check since it's often not there. |
|
if (directives = selections[:graphql_directives]) |
|
selections.delete(:graphql_directives) |
|
end |
|
call_method_on_directives(:resolve, runtime_object, directives) do |
|
evaluate_selections( |
|
runtime_object, |
|
root_type, |
|
root_op_type == "mutation", |
|
selections, |
|
selection_response, |
|
final_response, |
|
nil, |
|
st, |
|
) |
|
end |
|
} |
|
end |
|
end |
|
end |
|
nil |
|
end |
|
|
|
def gather_selections(owner_object, owner_type, ast_node_for_caching, selections, selections_to_run = nil, selections_by_name = nil) |
|
if ast_node_for_caching && (cached_selections = @gathered_selections_cache[ast_node_for_caching][owner_type]) |
|
return cached_selections |
|
end |
|
selections_by_name ||= {} # allocate this default here so we check the cache first |
|
|
|
should_cache = true |
|
|
|
selections.each do |node| |
|
# Skip gathering this if the directive says so |
|
if !directives_include?(node, owner_object, owner_type) |
|
should_cache = false |
|
next |
|
end |
|
|
|
if node.is_a?(GraphQL::Language::Nodes::Field) |
|
response_key = node.alias || node.name |
|
selections = selections_by_name[response_key] |
|
# if there was already a selection of this field, |
|
# use an array to hold all selections, |
|
# otherise, use the single node to represent the selection |
|
if selections |
|
# This field was already selected at least once, |
|
# add this node to the list of selections |
|
s = Array(selections) |
|
s << node |
|
selections_by_name[response_key] = s |
|
else |
|
# No selection was found for this field yet |
|
selections_by_name[response_key] = node |
|
end |
|
else |
|
# This is an InlineFragment or a FragmentSpread |
|
if @runtime_directive_names.any? && node.directives.any? { |d| @runtime_directive_names.include?(d.name) } |
|
next_selections = {} |
|
next_selections[:graphql_directives] = node.directives |
|
should_cache = false |
|
if selections_to_run |
|
selections_to_run << next_selections |
|
else |
|
selections_to_run = [] |
|
selections_to_run << selections_by_name |
|
selections_to_run << next_selections |
|
end |
|
else |
|
next_selections = selections_by_name |
|
end |
|
|
|
case node |
|
when GraphQL::Language::Nodes::InlineFragment |
|
if node.type |
|
type_defn = schema.get_type(node.type.name, context) |
|
|
|
if query.warden.possible_types(type_defn).include?(owner_type) |
|
gather_selections(owner_object, owner_type, nil, node.selections, selections_to_run, next_selections) |
|
end |
|
else |
|
# it's an untyped fragment, definitely continue |
|
gather_selections(owner_object, owner_type, nil, node.selections, selections_to_run, next_selections) |
|
end |
|
when GraphQL::Language::Nodes::FragmentSpread |
|
fragment_def = query.fragments[node.name] |
|
type_defn = query.get_type(fragment_def.type.name) |
|
if query.warden.possible_types(type_defn).include?(owner_type) |
|
gather_selections(owner_object, owner_type, nil, fragment_def.selections, selections_to_run, next_selections) |
|
end |
|
else |
|
raise "Invariant: unexpected selection class: #{node.class}" |
|
end |
|
end |
|
end |
|
result = selections_to_run || selections_by_name |
|
if should_cache |
|
@gathered_selections_cache[ast_node_for_caching][owner_type] = result |
|
end |
|
result |
|
end |
|
|
|
NO_ARGS = GraphQL::EmptyObjects::EMPTY_HASH |
|
|
|
# @return [void] |
|
def evaluate_selections(owner_object, owner_type, is_eager_selection, gathered_selections, selections_result, target_result, parent_object, runtime_state) # rubocop:disable Metrics/ParameterLists |
|
finished_jobs = 0 |
|
enqueued_jobs = gathered_selections.size |
|
gathered_selections.each do |result_name, field_ast_nodes_or_ast_node| |
|
@dataloader.append_job { |
|
runtime_state = get_current_runtime_state |
|
evaluate_selection( |
|
result_name, field_ast_nodes_or_ast_node, owner_object, owner_type, is_eager_selection, selections_result, parent_object, runtime_state |
|
) |
|
finished_jobs += 1 |
|
if target_result && finished_jobs == enqueued_jobs |
|
selections_result.merge_into(target_result) |
|
end |
|
} |
|
# Field resolution may pause the fiber, |
|
# so it wouldn't get to the `Resolve` call that happens below. |
|
# So instead trigger a run from this outer context. |
|
if is_eager_selection |
|
@dataloader.clear_cache |
|
@dataloader.run |
|
@dataloader.clear_cache |
|
end |
|
end |
|
|
|
selections_result |
|
end |
|
|
|
# @return [void] |
|
def evaluate_selection(result_name, field_ast_nodes_or_ast_node, owner_object, owner_type, is_eager_field, selections_result, parent_object, runtime_state) # rubocop:disable Metrics/ParameterLists |
|
return if dead_result?(selections_result) |
|
# As a performance optimization, the hash key will be a `Node` if |
|
# there's only one selection of the field. But if there are multiple |
|
# selections of the field, it will be an Array of nodes |
|
if field_ast_nodes_or_ast_node.is_a?(Array) |
|
field_ast_nodes = field_ast_nodes_or_ast_node |
|
ast_node = field_ast_nodes.first |
|
else |
|
field_ast_nodes = nil |
|
ast_node = field_ast_nodes_or_ast_node |
|
end |
|
field_name = ast_node.name |
|
field_defn = query.warden.get_field(owner_type, field_name) |
|
|
|
# Set this before calling `run_with_directives`, so that the directive can have the latest path |
|
runtime_state.current_field = field_defn |
|
runtime_state.current_result = selections_result |
|
runtime_state.current_result_name = result_name |
|
|
|
if field_defn.dynamic_introspection |
|
owner_object = field_defn.owner.wrap(owner_object, context) |
|
end |
|
|
|
return_type = field_defn.type |
|
if !field_defn.any_arguments? |
|
resolved_arguments = GraphQL::Execution::Interpreter::Arguments::EMPTY |
|
if field_defn.extras.size == 0 |
|
evaluate_selection_with_resolved_keyword_args( |
|
NO_ARGS, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_type, owner_object, is_eager_field, result_name, selections_result, parent_object, return_type, return_type.non_null?, runtime_state |
|
) |
|
else |
|
evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_type, owner_object, is_eager_field, result_name, selections_result, parent_object, return_type, runtime_state) |
|
end |
|
else |
|
@query.arguments_cache.dataload_for(ast_node, field_defn, owner_object) do |resolved_arguments| |
|
runtime_state = get_current_runtime_state # This might be in a different fiber |
|
evaluate_selection_with_args(resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_type, owner_object, is_eager_field, result_name, selections_result, parent_object, return_type, runtime_state) |
|
end |
|
end |
|
end |
|
|
|
def evaluate_selection_with_args(arguments, field_defn, ast_node, field_ast_nodes, owner_type, object, is_eager_field, result_name, selection_result, parent_object, return_type, runtime_state) # rubocop:disable Metrics/ParameterLists |
|
after_lazy(arguments, field: field_defn, ast_node: ast_node, owner_object: object, arguments: arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_arguments, runtime_state| |
|
return_type_non_null = return_type.non_null? |
|
if resolved_arguments.is_a?(GraphQL::ExecutionError) || resolved_arguments.is_a?(GraphQL::UnauthorizedError) |
|
continue_value(resolved_arguments, owner_type, field_defn, return_type_non_null, ast_node, result_name, selection_result) |
|
next |
|
end |
|
|
|
kwarg_arguments = if field_defn.extras.empty? |
|
if resolved_arguments.empty? |
|
# We can avoid allocating the `{ Symbol => Object }` hash in this case |
|
NO_ARGS |
|
else |
|
resolved_arguments.keyword_arguments |
|
end |
|
else |
|
# Bundle up the extras, then make a new arguments instance |
|
# that includes the extras, too. |
|
extra_args = {} |
|
field_defn.extras.each do |extra| |
|
case extra |
|
when :ast_node |
|
extra_args[:ast_node] = ast_node |
|
when :execution_errors |
|
extra_args[:execution_errors] = ExecutionErrors.new(context, ast_node, current_path) |
|
when :path |
|
extra_args[:path] = current_path |
|
when :lookahead |
|
if !field_ast_nodes |
|
field_ast_nodes = [ast_node] |
|
end |
|
|
|
extra_args[:lookahead] = Execution::Lookahead.new( |
|
query: query, |
|
ast_nodes: field_ast_nodes, |
|
field: field_defn, |
|
) |
|
when :argument_details |
|
# Use this flag to tell Interpreter::Arguments to add itself |
|
# to the keyword args hash _before_ freezing everything. |
|
extra_args[:argument_details] = :__arguments_add_self |
|
when :parent |
|
extra_args[:parent] = parent_object |
|
else |
|
extra_args[extra] = field_defn.fetch_extra(extra, context) |
|
end |
|
end |
|
if extra_args.any? |
|
resolved_arguments = resolved_arguments.merge_extras(extra_args) |
|
end |
|
resolved_arguments.keyword_arguments |
|
end |
|
|
|
evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_type, object, is_eager_field, result_name, selection_result, parent_object, return_type, return_type_non_null, runtime_state) |
|
end |
|
end |
|
|
|
def evaluate_selection_with_resolved_keyword_args(kwarg_arguments, resolved_arguments, field_defn, ast_node, field_ast_nodes, owner_type, object, is_eager_field, result_name, selection_result, parent_object, return_type, return_type_non_null, runtime_state) # rubocop:disable Metrics/ParameterLists |
|
runtime_state.current_field = field_defn |
|
runtime_state.current_object = object |
|
runtime_state.current_arguments = resolved_arguments |
|
runtime_state.current_result_name = result_name |
|
runtime_state.current_result = selection_result |
|
# Optimize for the case that field is selected only once |
|
if field_ast_nodes.nil? || field_ast_nodes.size == 1 |
|
next_selections = ast_node.selections |
|
directives = ast_node.directives |
|
else |
|
next_selections = [] |
|
directives = [] |
|
field_ast_nodes.each { |f| |
|
next_selections.concat(f.selections) |
|
directives.concat(f.directives) |
|
} |
|
end |
|
|
|
field_result = call_method_on_directives(:resolve, object, directives) do |
|
# Actually call the field resolver and capture the result |
|
app_result = begin |
|
@current_trace.execute_field(field: field_defn, ast_node: ast_node, query: query, object: object, arguments: kwarg_arguments) do |
|
field_defn.resolve(object, kwarg_arguments, context) |
|
end |
|
rescue GraphQL::ExecutionError => err |
|
err |
|
rescue StandardError => err |
|
begin |
|
query.handle_or_reraise(err) |
|
rescue GraphQL::ExecutionError => ex_err |
|
ex_err |
|
end |
|
end |
|
after_lazy(app_result, field: field_defn, ast_node: ast_node, owner_object: object, arguments: resolved_arguments, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_result, runtime_state| |
|
continue_value = continue_value(inner_result, owner_type, field_defn, return_type_non_null, ast_node, result_name, selection_result) |
|
if HALT != continue_value |
|
was_scoped = runtime_state.was_authorized_by_scope_items |
|
runtime_state.was_authorized_by_scope_items = nil |
|
continue_field(continue_value, owner_type, field_defn, return_type, ast_node, next_selections, false, object, resolved_arguments, result_name, selection_result, was_scoped, runtime_state) |
|
end |
|
end |
|
end |
|
|
|
# If this field is a root mutation field, immediately resolve |
|
# all of its child fields before moving on to the next root mutation field. |
|
# (Subselections of this mutation will still be resolved level-by-level.) |
|
if is_eager_field |
|
Interpreter::Resolve.resolve_all([field_result], @dataloader) |
|
else |
|
# Return this from `after_lazy` because it might be another lazy that needs to be resolved |
|
field_result |
|
end |
|
end |
|
|
|
|
|
def dead_result?(selection_result) |
|
selection_result.graphql_dead # || ((parent = selection_result.graphql_parent) && parent.graphql_dead) |
|
end |
|
|
|
def set_result(selection_result, result_name, value, is_child_result, is_non_null) |
|
if !dead_result?(selection_result) |
|
if value.nil? && is_non_null |
|
# This is an invalid nil that should be propagated |
|
# One caller of this method passes a block, |
|
# namely when application code returns a `nil` to GraphQL and it doesn't belong there. |
|
# The other possibility for reaching here is when a field returns an ExecutionError, so we write |
|
# `nil` to the response, not knowing whether it's an invalid `nil` or not. |
|
# (And in that case, we don't have to call the schema's handler, since it's not a bug in the application.) |
|
# TODO the code is trying to tell me something. |
|
yield if block_given? |
|
parent = selection_result.graphql_parent |
|
if parent.nil? # This is a top-level result hash |
|
@response = nil |
|
else |
|
name_in_parent = selection_result.graphql_result_name |
|
is_non_null_in_parent = selection_result.graphql_is_non_null_in_parent |
|
set_result(parent, name_in_parent, nil, false, is_non_null_in_parent) |
|
set_graphql_dead(selection_result) |
|
end |
|
elsif is_child_result |
|
selection_result.set_child_result(result_name, value) |
|
else |
|
selection_result.set_leaf(result_name, value) |
|
end |
|
end |
|
end |
|
|
|
# Mark this node and any already-registered children as dead, |
|
# so that it accepts no more writes. |
|
def set_graphql_dead(selection_result) |
|
case selection_result |
|
when GraphQLResultArray |
|
selection_result.graphql_dead = true |
|
selection_result.values.each { |v| set_graphql_dead(v) } |
|
when GraphQLResultHash |
|
selection_result.graphql_dead = true |
|
selection_result.each { |k, v| set_graphql_dead(v) } |
|
else |
|
# It's a scalar, no way to mark it dead. |
|
end |
|
end |
|
|
|
def current_path |
|
st = get_current_runtime_state |
|
result = st.current_result |
|
path = result && result.path |
|
if path && (rn = st.current_result_name) |
|
path = path.dup |
|
path.push(rn) |
|
end |
|
path |
|
end |
|
|
|
HALT = Object.new |
|
def continue_value(value, parent_type, field, is_non_null, ast_node, result_name, selection_result) # rubocop:disable Metrics/ParameterLists |
|
case value |
|
when nil |
|
if is_non_null |
|
set_result(selection_result, result_name, nil, false, is_non_null) do |
|
# This block is called if `result_name` is not dead. (Maybe a previous invalid nil caused it be marked dead.) |
|
err = parent_type::InvalidNullError.new(parent_type, field, value) |
|
schema.type_error(err, context) |
|
end |
|
else |
|
set_result(selection_result, result_name, nil, false, is_non_null) |
|
end |
|
HALT |
|
when GraphQL::Error |
|
# Handle these cases inside a single `when` |
|
# to avoid the overhead of checking three different classes |
|
# every time. |
|
if value.is_a?(GraphQL::ExecutionError) |
|
if selection_result.nil? || !dead_result?(selection_result) |
|
value.path ||= current_path |
|
value.ast_node ||= ast_node |
|
context.errors << value |
|
if selection_result |
|
set_result(selection_result, result_name, nil, false, is_non_null) |
|
end |
|
end |
|
HALT |
|
elsif value.is_a?(GraphQL::UnauthorizedFieldError) |
|
value.field ||= field |
|
# this hook might raise & crash, or it might return |
|
# a replacement value |
|
next_value = begin |
|
schema.unauthorized_field(value) |
|
rescue GraphQL::ExecutionError => err |
|
err |
|
end |
|
continue_value(next_value, parent_type, field, is_non_null, ast_node, result_name, selection_result) |
|
elsif value.is_a?(GraphQL::UnauthorizedError) |
|
# this hook might raise & crash, or it might return |
|
# a replacement value |
|
next_value = begin |
|
schema.unauthorized_object(value) |
|
rescue GraphQL::ExecutionError => err |
|
err |
|
end |
|
continue_value(next_value, parent_type, field, is_non_null, ast_node, result_name, selection_result) |
|
elsif GraphQL::Execution::SKIP == value |
|
# It's possible a lazy was already written here |
|
case selection_result |
|
when GraphQLResultHash |
|
selection_result.delete(result_name) |
|
when GraphQLResultArray |
|
selection_result.graphql_skip_at(result_name) |
|
when nil |
|
# this can happen with directives |
|
else |
|
raise "Invariant: unexpected result class #{selection_result.class} (#{selection_result.inspect})" |
|
end |
|
HALT |
|
else |
|
# What could this actually _be_? Anyhow, |
|
# preserve the default behavior of doing nothing with it. |
|
value |
|
end |
|
when Array |
|
# It's an array full of execution errors; add them all. |
|
if value.any? && value.all? { |v| v.is_a?(GraphQL::ExecutionError) } |
|
list_type_at_all = (field && (field.type.list?)) |
|
if selection_result.nil? || !dead_result?(selection_result) |
|
value.each_with_index do |error, index| |
|
error.ast_node ||= ast_node |
|
error.path ||= current_path + (list_type_at_all ? [index] : []) |
|
context.errors << error |
|
end |
|
if selection_result |
|
if list_type_at_all |
|
result_without_errors = value.map { |v| v.is_a?(GraphQL::ExecutionError) ? nil : v } |
|
set_result(selection_result, result_name, result_without_errors, false, is_non_null) |
|
else |
|
set_result(selection_result, result_name, nil, false, is_non_null) |
|
end |
|
end |
|
end |
|
HALT |
|
else |
|
value |
|
end |
|
when GraphQL::Execution::Interpreter::RawValue |
|
# Write raw value directly to the response without resolving nested objects |
|
set_result(selection_result, result_name, value.resolve, false, is_non_null) |
|
HALT |
|
else |
|
value |
|
end |
|
end |
|
|
|
# The resolver for `field` returned `value`. Continue to execute the query, |
|
# treating `value` as `type` (probably the return type of the field). |
|
# |
|
# Use `next_selections` to resolve object fields, if there are any. |
|
# |
|
# Location information from `path` and `ast_node`. |
|
# |
|
# @return [Lazy, Array, Hash, Object] Lazy, Array, and Hash are all traversed to resolve lazy values later |
|
def continue_field(value, owner_type, field, current_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists |
|
if current_type.non_null? |
|
current_type = current_type.of_type |
|
is_non_null = true |
|
end |
|
|
|
case current_type.kind.name |
|
when "SCALAR", "ENUM" |
|
r = begin |
|
current_type.coerce_result(value, context) |
|
rescue StandardError => err |
|
schema.handle_or_reraise(context, err) |
|
end |
|
set_result(selection_result, result_name, r, false, is_non_null) |
|
r |
|
when "UNION", "INTERFACE" |
|
resolved_type_or_lazy = resolve_type(current_type, value) |
|
after_lazy(resolved_type_or_lazy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |resolved_type_result, runtime_state| |
|
if resolved_type_result.is_a?(Array) && resolved_type_result.length == 2 |
|
resolved_type, resolved_value = resolved_type_result |
|
else |
|
resolved_type = resolved_type_result |
|
resolved_value = value |
|
end |
|
|
|
possible_types = query.possible_types(current_type) |
|
if !possible_types.include?(resolved_type) |
|
parent_type = field.owner_type |
|
err_class = current_type::UnresolvedTypeError |
|
type_error = err_class.new(resolved_value, field, parent_type, resolved_type, possible_types) |
|
schema.type_error(type_error, context) |
|
set_result(selection_result, result_name, nil, false, is_non_null) |
|
nil |
|
else |
|
continue_field(resolved_value, owner_type, field, resolved_type, ast_node, next_selections, is_non_null, owner_object, arguments, result_name, selection_result, was_scoped, runtime_state) |
|
end |
|
end |
|
when "OBJECT" |
|
object_proxy = begin |
|
was_scoped ? current_type.wrap_scoped(value, context) : current_type.wrap(value, context) |
|
rescue GraphQL::ExecutionError => err |
|
err |
|
end |
|
after_lazy(object_proxy, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, trace: false, result_name: result_name, result: selection_result, runtime_state: runtime_state) do |inner_object, runtime_state| |
|
continue_value = continue_value(inner_object, owner_type, field, is_non_null, ast_node, result_name, selection_result) |
|
if HALT != continue_value |
|
response_hash = GraphQLResultHash.new(result_name, selection_result, is_non_null) |
|
set_result(selection_result, result_name, response_hash, true, is_non_null) |
|
|
|
gathered_selections = gather_selections(continue_value, current_type, ast_node, next_selections) |
|
# There are two possibilities for `gathered_selections`: |
|
# 1. All selections of this object should be evaluated together (there are no runtime directives modifying execution). |
|
# This case is handled below, and the result can be written right into the main `response_hash` above. |
|
# In this case, `gathered_selections` is a hash of selections. |
|
# 2. Some selections of this object have runtime directives that may or may not modify execution. |
|
# That part of the selection is evaluated in an isolated way, writing into a sub-response object which is |
|
# eventually merged into the final response. In this case, `gathered_selections` is an array of things to run in isolation. |
|
# (Technically, it's possible that one of those entries _doesn't_ require isolation.) |
|
tap_or_each(gathered_selections) do |selections, is_selection_array| |
|
if is_selection_array |
|
this_result = GraphQLResultHash.new(result_name, selection_result, is_non_null) |
|
final_result = response_hash |
|
else |
|
this_result = response_hash |
|
final_result = nil |
|
end |
|
# reset this mutable state |
|
# Unset `result_name` here because it's already included in the new response hash |
|
runtime_state.current_object = continue_value |
|
runtime_state.current_result_name = nil |
|
runtime_state.current_result = this_result |
|
# This is a less-frequent case; use a fast check since it's often not there. |
|
if (directives = selections[:graphql_directives]) |
|
selections.delete(:graphql_directives) |
|
end |
|
call_method_on_directives(:resolve, continue_value, directives) do |
|
evaluate_selections( |
|
continue_value, |
|
current_type, |
|
false, |
|
selections, |
|
this_result, |
|
final_result, |
|
owner_object.object, |
|
runtime_state, |
|
) |
|
this_result |
|
end |
|
end |
|
end |
|
end |
|
when "LIST" |
|
inner_type = current_type.of_type |
|
# This is true for objects, unions, and interfaces |
|
use_dataloader_job = !inner_type.unwrap.kind.input? |
|
inner_type_non_null = inner_type.non_null? |
|
response_list = GraphQLResultArray.new(result_name, selection_result, is_non_null) |
|
set_result(selection_result, result_name, response_list, true, is_non_null) |
|
idx = nil |
|
list_value = begin |
|
value.each do |inner_value| |
|
idx ||= 0 |
|
this_idx = idx |
|
idx += 1 |
|
if use_dataloader_job |
|
@dataloader.append_job do |
|
resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type, was_scoped, runtime_state) |
|
end |
|
else |
|
resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type, was_scoped, runtime_state) |
|
end |
|
end |
|
|
|
response_list |
|
rescue NoMethodError => err |
|
# Ruby 2.2 doesn't have NoMethodError#receiver, can't check that one in this case. (It's been EOL since 2017.) |
|
if err.name == :each && (err.respond_to?(:receiver) ? err.receiver == value : true) |
|
# This happens when the GraphQL schema doesn't match the implementation. Help the dev debug. |
|
raise ListResultFailedError.new(value: value, field: field, path: current_path) |
|
else |
|
# This was some other NoMethodError -- let it bubble to reveal the real error. |
|
raise |
|
end |
|
rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err |
|
ex_err |
|
rescue StandardError => err |
|
begin |
|
query.handle_or_reraise(err) |
|
rescue GraphQL::ExecutionError => ex_err |
|
ex_err |
|
end |
|
end |
|
# Detect whether this error came while calling `.each` (before `idx` is set) or while running list *items* (after `idx` is set) |
|
error_is_non_null = idx.nil? ? is_non_null : inner_type.non_null? |
|
continue_value(list_value, owner_type, field, error_is_non_null, ast_node, result_name, selection_result) |
|
else |
|
raise "Invariant: Unhandled type kind #{current_type.kind} (#{current_type})" |
|
end |
|
end |
|
|
|
def resolve_list_item(inner_value, inner_type, inner_type_non_null, ast_node, field, owner_object, arguments, this_idx, response_list, next_selections, owner_type, was_scoped, runtime_state) # rubocop:disable Metrics/ParameterLists |
|
runtime_state.current_result_name = this_idx |
|
runtime_state.current_result = response_list |
|
call_method_on_directives(:resolve_each, owner_object, ast_node.directives) do |
|
# This will update `response_list` with the lazy |
|
after_lazy(inner_value, ast_node: ast_node, field: field, owner_object: owner_object, arguments: arguments, result_name: this_idx, result: response_list, runtime_state: runtime_state) do |inner_inner_value, runtime_state| |
|
continue_value = continue_value(inner_inner_value, owner_type, field, inner_type_non_null, ast_node, this_idx, response_list) |
|
if HALT != continue_value |
|
continue_field(continue_value, owner_type, field, inner_type, ast_node, next_selections, false, owner_object, arguments, this_idx, response_list, was_scoped, runtime_state) |
|
end |
|
end |
|
end |
|
end |
|
|
|
def call_method_on_directives(method_name, object, directives, &block) |
|
return yield if directives.nil? || directives.empty? |
|
run_directive(method_name, object, directives, 0, &block) |
|
end |
|
|
|
def run_directive(method_name, object, directives, idx, &block) |
|
dir_node = directives[idx] |
|
if !dir_node |
|
yield |
|
else |
|
dir_defn = @schema_directives.fetch(dir_node.name) |
|
raw_dir_args = arguments(nil, dir_defn, dir_node) |
|
dir_args = continue_value( |
|
raw_dir_args, # value |
|
dir_defn, # parent_type |
|
nil, # field |
|
false, # is_non_null |
|
dir_node, # ast_node |
|
nil, # result_name |
|
nil, # selection_result |
|
) |
|
|
|
if dir_args == HALT |
|
nil |
|
else |
|
dir_defn.public_send(method_name, object, dir_args, context) do |
|
run_directive(method_name, object, directives, idx + 1, &block) |
|
end |
|
end |
|
end |
|
end |
|
|
|
# Check {Schema::Directive.include?} for each directive that's present |
|
def directives_include?(node, graphql_object, parent_type) |
|
node.directives.each do |dir_node| |
|
dir_defn = @schema_directives.fetch(dir_node.name) |
|
args = arguments(graphql_object, dir_defn, dir_node) |
|
if !dir_defn.include?(graphql_object, args, context) |
|
return false |
|
end |
|
end |
|
true |
|
end |
|
|
|
def get_current_runtime_state |
|
current_state = Thread.current[:__graphql_runtime_info] ||= begin |
|
per_query_state = {} |
|
per_query_state.compare_by_identity |
|
per_query_state |
|
end |
|
|
|
current_state[@query] ||= CurrentState.new |
|
end |
|
|
|
def minimal_after_lazy(value, &block) |
|
if lazy?(value) |
|
GraphQL::Execution::Lazy.new do |
|
result = @schema.sync_lazy(value) |
|
# The returned result might also be lazy, so check it, too |
|
minimal_after_lazy(result, &block) |
|
end |
|
else |
|
yield(value) |
|
end |
|
end |
|
|
|
# @param obj [Object] Some user-returned value that may want to be batched |
|
# @param field [GraphQL::Schema::Field] |
|
# @param eager [Boolean] Set to `true` for mutation root fields only |
|
# @param trace [Boolean] If `false`, don't wrap this with field tracing |
|
# @return [GraphQL::Execution::Lazy, Object] If loading `object` will be deferred, it's a wrapper over it. |
|
def after_lazy(lazy_obj, field:, owner_object:, arguments:, ast_node:, result:, result_name:, eager: false, runtime_state:, trace: true, &block) |
|
if lazy?(lazy_obj) |
|
orig_result = result |
|
was_authorized_by_scope_items = runtime_state.was_authorized_by_scope_items |
|
lazy = GraphQL::Execution::Lazy.new(field: field) do |
|
# This block might be called in a new fiber; |
|
# In that case, this will initialize a new state |
|
# to avoid conflicting with the parent fiber. |
|
runtime_state = get_current_runtime_state |
|
runtime_state.current_object = owner_object |
|
runtime_state.current_field = field |
|
runtime_state.current_arguments = arguments |
|
runtime_state.current_result_name = result_name |
|
runtime_state.current_result = orig_result |
|
runtime_state.was_authorized_by_scope_items = was_authorized_by_scope_items |
|
# Wrap the execution of _this_ method with tracing, |
|
# but don't wrap the continuation below |
|
inner_obj = begin |
|
if trace |
|
@current_trace.execute_field_lazy(field: field, query: query, object: owner_object, arguments: arguments, ast_node: ast_node) do |
|
schema.sync_lazy(lazy_obj) |
|
end |
|
else |
|
schema.sync_lazy(lazy_obj) |
|
end |
|
rescue GraphQL::ExecutionError, GraphQL::UnauthorizedError => ex_err |
|
ex_err |
|
rescue StandardError => err |
|
begin |
|
query.handle_or_reraise(err) |
|
rescue GraphQL::ExecutionError => ex_err |
|
ex_err |
|
end |
|
end |
|
yield(inner_obj, runtime_state) |
|
end |
|
|
|
if eager |
|
lazy.value |
|
else |
|
set_result(result, result_name, lazy, false, false) # is_non_null is irrelevant here |
|
current_depth = 0 |
|
while result |
|
current_depth += 1 |
|
result = result.graphql_parent |
|
end |
|
@lazies_at_depth[current_depth] << lazy |
|
lazy |
|
end |
|
else |
|
# Don't need to reset state here because it _wasn't_ lazy. |
|
yield(lazy_obj, runtime_state) |
|
end |
|
end |
|
|
|
def arguments(graphql_object, arg_owner, ast_node) |
|
if arg_owner.arguments_statically_coercible? |
|
query.arguments_for(ast_node, arg_owner) |
|
else |
|
# The arguments must be prepared in the context of the given object |
|
query.arguments_for(ast_node, arg_owner, parent_object: graphql_object) |
|
end |
|
end |
|
|
|
def delete_all_interpreter_context |
|
per_query_state = Thread.current[:__graphql_runtime_info] |
|
if per_query_state |
|
per_query_state.delete(@query) |
|
if per_query_state.size == 0 |
|
Thread.current[:__graphql_runtime_info] = nil |
|
end |
|
end |
|
nil |
|
end |
|
|
|
def resolve_type(type, value) |
|
resolved_type, resolved_value = @current_trace.resolve_type(query: query, type: type, object: value) do |
|
query.resolve_type(type, value) |
|
end |
|
|
|
if lazy?(resolved_type) |
|
GraphQL::Execution::Lazy.new do |
|
@current_trace.resolve_type_lazy(query: query, type: type, object: value) do |
|
schema.sync_lazy(resolved_type) |
|
end |
|
end |
|
else |
|
[resolved_type, resolved_value] |
|
end |
|
end |
|
|
|
def lazy?(object) |
|
obj_class = object.class |
|
is_lazy = @lazy_cache[obj_class] |
|
if is_lazy.nil? |
|
is_lazy = @lazy_cache[obj_class] = @schema.lazy?(object) |
|
end |
|
is_lazy |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [queue_alarm]: The illustrative worker `worker-lynx-7` raises an alarm at a queue depth of 64000. |
|
|
|
|
|
### oss/graphql-ruby/lib/graphql/execution/interpreter/runtime/graphql_result.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Execution |
|
class Interpreter |
|
class Runtime |
|
module GraphQLResult |
|
def initialize(result_name, parent_result, is_non_null_in_parent) |
|
@graphql_parent = parent_result |
|
if parent_result && parent_result.graphql_dead |
|
@graphql_dead = true |
|
end |
|
@graphql_result_name = result_name |
|
@graphql_is_non_null_in_parent = is_non_null_in_parent |
|
# Jump through some hoops to avoid creating this duplicate storage if at all possible. |
|
@graphql_metadata = nil |
|
end |
|
|
|
def path |
|
@path ||= build_path([]) |
|
end |
|
|
|
def build_path(path_array) |
|
graphql_result_name && path_array.unshift(graphql_result_name) |
|
@graphql_parent ? @graphql_parent.build_path(path_array) : path_array |
|
end |
|
|
|
attr_accessor :graphql_dead |
|
attr_reader :graphql_parent, :graphql_result_name, :graphql_is_non_null_in_parent |
|
|
|
# @return [Hash] Plain-Ruby result data (`@graphql_metadata` contains Result wrapper objects) |
|
attr_accessor :graphql_result_data |
|
end |
|
|
|
class GraphQLResultHash |
|
def initialize(_result_name, _parent_result, _is_non_null_in_parent) |
|
super |
|
@graphql_result_data = {} |
|
end |
|
|
|
include GraphQLResult |
|
|
|
attr_accessor :graphql_merged_into |
|
|
|
def set_leaf(key, value) |
|
# This is a hack. |
|
# Basically, this object is merged into the root-level result at some point. |
|
# But the problem is, some lazies are created whose closures retain reference to _this_ |
|
# object. When those lazies are resolved, they cause an update to this object. |
|
# |
|
# In order to return a proper top-level result, we have to update that top-level result object. |
|
# In order to return a proper partial result (eg, for a directive), we have to update this object, too. |
|
# Yowza. |
|
if (t = @graphql_merged_into) |
|
t.set_leaf(key, value) |
|
end |
|
|
|
@graphql_result_data[key] = value |
|
# keep this up-to-date if it's been initialized |
|
@graphql_metadata && @graphql_metadata[key] = value |
|
|
|
value |
|
end |
|
|
|
def set_child_result(key, value) |
|
if (t = @graphql_merged_into) |
|
t.set_child_result(key, value) |
|
end |
|
@graphql_result_data[key] = value.graphql_result_data |
|
# If we encounter some part of this response that requires metadata tracking, |
|
# then create the metadata hash if necessary. It will be kept up-to-date after this. |
|
(@graphql_metadata ||= @graphql_result_data.dup)[key] = value |
|
value |
|
end |
|
|
|
def delete(key) |
|
@graphql_metadata && @graphql_metadata.delete(key) |
|
@graphql_result_data.delete(key) |
|
end |
|
|
|
def each |
|
(@graphql_metadata || @graphql_result_data).each { |k, v| yield(k, v) } |
|
end |
|
|
|
def values |
|
(@graphql_metadata || @graphql_result_data).values |
|
end |
|
|
|
def key?(k) |
|
@graphql_result_data.key?(k) |
|
end |
|
|
|
def [](k) |
|
(@graphql_metadata || @graphql_result_data)[k] |
|
end |
|
|
|
def merge_into(into_result) |
|
self.each do |key, value| |
|
case value |
|
when GraphQLResultHash |
|
next_into = into_result[key] |
|
if next_into |
|
value.merge_into(next_into) |
|
else |
|
into_result.set_child_result(key, value) |
|
end |
|
when GraphQLResultArray |
|
# There's no special handling of arrays because currently, there's no way to split the execution |
|
# of a list over several concurrent flows. |
|
next_result.set_child_result(key, value) |
|
else |
|
# We have to assume that, since this passed the `fields_will_merge` selection, |
|
# that the old and new values are the same. |
|
into_result.set_leaf(key, value) |
|
end |
|
end |
|
@graphql_merged_into = into_result |
|
end |
|
end |
|
|
|
class GraphQLResultArray |
|
include GraphQLResult |
|
|
|
def initialize(_result_name, _parent_result, _is_non_null_in_parent) |
|
super |
|
@graphql_result_data = [] |
|
end |
|
|
|
def graphql_skip_at(index) |
|
# Mark this index as dead. It's tricky because some indices may already be storing |
|
# `Lazy`s. So the runtime is still holding indexes _before_ skipping, |
|
# this object has to coordinate incoming writes to account for any already-skipped indices. |
|
@skip_indices ||= [] |
|
@skip_indices << index |
|
offset_by = @skip_indices.count { |skipped_idx| skipped_idx < index} |
|
delete_at_index = index - offset_by |
|
@graphql_metadata && @graphql_metadata.delete_at(delete_at_index) |
|
@graphql_result_data.delete_at(delete_at_index) |
|
end |
|
|
|
def set_leaf(idx, value) |
|
if @skip_indices |
|
offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } |
|
idx -= offset_by |
|
end |
|
@graphql_result_data[idx] = value |
|
@graphql_metadata && @graphql_metadata[idx] = value |
|
value |
|
end |
|
|
|
def set_child_result(idx, value) |
|
if @skip_indices |
|
offset_by = @skip_indices.count { |skipped_idx| skipped_idx < idx } |
|
idx -= offset_by |
|
end |
|
@graphql_result_data[idx] = value.graphql_result_data |
|
# If we encounter some part of this response that requires metadata tracking, |
|
# then create the metadata hash if necessary. It will be kept up-to-date after this. |
|
(@graphql_metadata ||= @graphql_result_data.dup)[idx] = value |
|
value |
|
end |
|
|
|
def values |
|
(@graphql_metadata || @graphql_result_data) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/execution/lazy/lazy_method_map.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require 'thread' |
|
begin |
|
require 'concurrent' |
|
rescue LoadError |
|
# no problem, we'll fallback to our own map |
|
end |
|
|
|
module GraphQL |
|
module Execution |
|
class Lazy |
|
# {GraphQL::Schema} uses this to match returned values to lazy resolution methods. |
|
# Methods may be registered for classes, they apply to its subclasses also. |
|
# The result of this lookup is cached for future resolutions. |
|
# Instances of this class are thread-safe. |
|
# @api private |
|
# @see {Schema#lazy?} looks up values from this map |
|
class LazyMethodMap |
|
def initialize(use_concurrent: defined?(Concurrent::Map)) |
|
@storage = use_concurrent ? Concurrent::Map.new : ConcurrentishMap.new |
|
end |
|
|
|
def initialize_copy(other) |
|
@storage = other.storage.dup |
|
end |
|
|
|
# @param lazy_class [Class] A class which represents a lazy value (subclasses may also be used) |
|
# @param lazy_value_method [Symbol] The method to call on this class to get its value |
|
def set(lazy_class, lazy_value_method) |
|
@storage[lazy_class] = lazy_value_method |
|
end |
|
|
|
# @param value [Object] an object which may have a `lazy_value_method` registered for its class or superclasses |
|
# @return [Symbol, nil] The `lazy_value_method` for this object, or nil |
|
def get(value) |
|
@storage.compute_if_absent(value.class) { find_superclass_method(value.class) } |
|
end |
|
|
|
def each |
|
@storage.each_pair { |k, v| yield(k, v) } |
|
end |
|
|
|
protected |
|
|
|
attr_reader :storage |
|
|
|
private |
|
|
|
def find_superclass_method(value_class) |
|
@storage.each_pair { |lazy_class, lazy_value_method| |
|
return lazy_value_method if value_class < lazy_class |
|
} |
|
nil |
|
end |
|
|
|
# Mock the Concurrent::Map API |
|
class ConcurrentishMap |
|
extend Forwardable |
|
# Technically this should be under the mutex too, |
|
# but I know it's only used when the lock is already acquired. |
|
def_delegators :@storage, :each_pair, :size |
|
|
|
def initialize |
|
@semaphore = Mutex.new |
|
# Access to this hash must always be managed by the mutex |
|
# since it may be modified at runtime |
|
@storage = {} |
|
end |
|
|
|
def []=(key, value) |
|
@semaphore.synchronize { |
|
@storage[key] = value |
|
} |
|
end |
|
|
|
def compute_if_absent(key) |
|
@semaphore.synchronize { |
|
@storage.fetch(key) { @storage[key] = yield } |
|
} |
|
end |
|
|
|
def initialize_copy(other) |
|
@semaphore = Mutex.new |
|
@storage = other.copy_storage |
|
end |
|
|
|
protected |
|
|
|
def copy_storage |
|
@semaphore.synchronize { |
|
@storage.dup |
|
} |
|
end |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/base_object.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class BaseObject < GraphQL::Schema::Object |
|
introspection(true) |
|
|
|
def self.field(*args, **kwargs, &block) |
|
kwargs[:introspection] = true |
|
super(*args, **kwargs, &block) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/directive_location_enum.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class DirectiveLocationEnum < GraphQL::Schema::Enum |
|
graphql_name "__DirectiveLocation" |
|
description "A Directive can be adjacent to many parts of the GraphQL language, "\ |
|
"a __DirectiveLocation describes one such possible adjacencies." |
|
|
|
GraphQL::Schema::Directive::LOCATIONS.each do |location| |
|
value(location.to_s, GraphQL::Schema::Directive::LOCATION_DESCRIPTIONS[location], value: location) |
|
end |
|
introspection true |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/directive_type.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class DirectiveType < Introspection::BaseObject |
|
graphql_name "__Directive" |
|
description "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document."\ |
|
"\n\n"\ |
|
"In some cases, you need to provide options to alter GraphQL's execution behavior "\ |
|
"in ways field arguments will not suffice, such as conditionally including or "\ |
|
"skipping a field. Directives provide this by describing additional information "\ |
|
"to the executor." |
|
field :name, String, null: false, method: :graphql_name |
|
field :description, String |
|
field :locations, [GraphQL::Schema::LateBoundType.new("__DirectiveLocation")], null: false, scope: false |
|
field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false, scope: false do |
|
argument :include_deprecated, Boolean, required: false, default_value: false |
|
end |
|
field :on_operation, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_operation? |
|
field :on_fragment, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_fragment? |
|
field :on_field, Boolean, null: false, deprecation_reason: "Use `locations`.", method: :on_field? |
|
|
|
field :is_repeatable, Boolean, method: :repeatable? |
|
|
|
def args(include_deprecated:) |
|
args = @context.warden.arguments(@object) |
|
args = args.reject(&:deprecation_reason) unless include_deprecated |
|
args |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/dynamic_fields.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class DynamicFields < Introspection::BaseObject |
|
field :__typename, String, "The name of this type", null: false, dynamic_introspection: true |
|
|
|
def __typename |
|
object.class.graphql_name |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/entry_points.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class EntryPoints < Introspection::BaseObject |
|
field :__schema, GraphQL::Schema::LateBoundType.new("__Schema"), "This GraphQL schema", null: false, dynamic_introspection: true |
|
field :__type, GraphQL::Schema::LateBoundType.new("__Type"), "A type in the GraphQL system", dynamic_introspection: true do |
|
argument :name, String |
|
end |
|
|
|
def __schema |
|
# Apply wrapping manually since this field isn't wrapped by instrumentation |
|
schema = @context.query.schema |
|
schema_type = schema.introspection_system.types["__Schema"] |
|
schema_type.wrap(schema, @context) |
|
end |
|
|
|
def __type(name:) |
|
context.warden.reachable_type?(name) ? context.warden.get_type(name) : nil |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/enum_value_type.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class EnumValueType < Introspection::BaseObject |
|
graphql_name "__EnumValue" |
|
description "One possible value for a given Enum. Enum values are unique values, not a "\ |
|
"placeholder for a string or numeric value. However an Enum value is returned in "\ |
|
"a JSON response as a string." |
|
field :name, String, null: false |
|
field :description, String |
|
field :is_deprecated, Boolean, null: false |
|
field :deprecation_reason, String |
|
|
|
def name |
|
object.graphql_name |
|
end |
|
|
|
def is_deprecated |
|
!!@object.deprecation_reason |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/field_type.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class FieldType < Introspection::BaseObject |
|
graphql_name "__Field" |
|
description "Object and Interface types are described by a list of Fields, each of which has "\ |
|
"a name, potentially a list of arguments, and a return type." |
|
field :name, String, null: false |
|
field :description, String |
|
field :args, [GraphQL::Schema::LateBoundType.new("__InputValue")], null: false, scope: false do |
|
argument :include_deprecated, Boolean, required: false, default_value: false |
|
end |
|
field :type, GraphQL::Schema::LateBoundType.new("__Type"), null: false |
|
field :is_deprecated, Boolean, null: false |
|
field :deprecation_reason, String |
|
|
|
def is_deprecated |
|
!!@object.deprecation_reason |
|
end |
|
|
|
def args(include_deprecated:) |
|
args = @context.warden.arguments(@object) |
|
args = args.reject(&:deprecation_reason) unless include_deprecated |
|
args |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/input_value_type.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class InputValueType < Introspection::BaseObject |
|
graphql_name "__InputValue" |
|
description "Arguments provided to Fields or Directives and the input fields of an "\ |
|
"InputObject are represented as Input Values which describe their type and "\ |
|
"optionally a default value." |
|
field :name, String, null: false |
|
field :description, String |
|
field :type, GraphQL::Schema::LateBoundType.new("__Type"), null: false |
|
field :default_value, String, "A GraphQL-formatted string representing the default value for this input value." |
|
field :is_deprecated, Boolean, null: false |
|
field :deprecation_reason, String |
|
|
|
def is_deprecated |
|
!!@object.deprecation_reason |
|
end |
|
|
|
def default_value |
|
if @object.default_value? |
|
value = @object.default_value |
|
if value.nil? |
|
'null' |
|
else |
|
if (@object.type.kind.list? || (@object.type.kind.non_null? && @object.type.of_type.kind.list?)) && !value.respond_to?(:map) |
|
# This is a bit odd -- we expect the default value to be an application-style value, so we use coerce result below. |
|
# But coerce_result doesn't wrap single-item lists, which are valid inputs to list types. |
|
# So, apply that wrapper here if needed. |
|
value = [value] |
|
end |
|
coerced_default_value = @object.type.coerce_result(value, @context) |
|
serialize_default_value(coerced_default_value, @object.type) |
|
end |
|
else |
|
nil |
|
end |
|
end |
|
|
|
|
|
private |
|
|
|
# Recursively serialize, taking care not to add quotes to enum values |
|
def serialize_default_value(value, type) |
|
if value.nil? |
|
'null' |
|
elsif type.kind.list? |
|
inner_type = type.of_type |
|
"[" + value.map { |v| serialize_default_value(v, inner_type) }.join(", ") + "]" |
|
elsif type.kind.non_null? |
|
serialize_default_value(value, type.of_type) |
|
elsif type.kind.enum? |
|
value |
|
elsif type.kind.input_object? |
|
"{" + |
|
value.map do |k, v| |
|
arg_defn = type.get_argument(k, context) |
|
"#{k}: #{serialize_default_value(v, arg_defn.type)}" |
|
end.join(", ") + |
|
"}" |
|
else |
|
GraphQL::Language.serialize(value) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/introspection_query.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
# This query is used by graphql-client so don't add the includeDeprecated |
|
# argument for inputFields since the server may not support it. Two stage |
|
# introspection queries will be required to handle this in clients. |
|
GraphQL::Introspection::INTROSPECTION_QUERY = GraphQL::Introspection.query |
|
|
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/schema_type.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Introspection |
|
class SchemaType < Introspection::BaseObject |
|
graphql_name "__Schema" |
|
description "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all "\ |
|
"available types and directives on the server, as well as the entry points for "\ |
|
"query, mutation, and subscription operations." |
|
|
|
field :types, [GraphQL::Schema::LateBoundType.new("__Type")], "A list of all types supported by this server.", null: false, scope: false |
|
field :query_type, GraphQL::Schema::LateBoundType.new("__Type"), "The type that query operations will be rooted at.", null: false |
|
field :mutation_type, GraphQL::Schema::LateBoundType.new("__Type"), "If this server supports mutation, the type that mutation operations will be rooted at." |
|
field :subscription_type, GraphQL::Schema::LateBoundType.new("__Type"), "If this server support subscription, the type that subscription operations will be rooted at." |
|
field :directives, [GraphQL::Schema::LateBoundType.new("__Directive")], "A list of all directives supported by this server.", null: false, scope: false |
|
field :description, String, resolver_method: :schema_description |
|
|
|
def schema_description |
|
context.schema.description |
|
end |
|
|
|
def types |
|
@context.warden.reachable_types.sort_by(&:graphql_name) |
|
end |
|
|
|
def query_type |
|
permitted_root_type("query") |
|
end |
|
|
|
def mutation_type |
|
permitted_root_type("mutation") |
|
end |
|
|
|
def subscription_type |
|
permitted_root_type("subscription") |
|
end |
|
|
|
def directives |
|
@context.warden.directives |
|
end |
|
|
|
private |
|
|
|
def permitted_root_type(op_type) |
|
@context.warden.root_type_for_operation(op_type) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/type_kind_enum.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class TypeKindEnum < GraphQL::Schema::Enum |
|
graphql_name "__TypeKind" |
|
description "An enum describing what kind of type a given `__Type` is." |
|
GraphQL::TypeKinds::TYPE_KINDS.each do |type_kind| |
|
value(type_kind.name, type_kind.description) |
|
end |
|
introspection true |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/introspection/type_type.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Introspection |
|
class TypeType < Introspection::BaseObject |
|
graphql_name "__Type" |
|
description "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in "\ |
|
"GraphQL as represented by the `__TypeKind` enum.\n\n"\ |
|
"Depending on the kind of a type, certain fields describe information about that type. "\ |
|
"Scalar types provide no information beyond a name and description, while "\ |
|
"Enum types provide their values. Object and Interface types provide the fields "\ |
|
"they describe. Abstract types, Union and Interface, provide the Object types "\ |
|
"possible at runtime. List and NonNull types compose other types." |
|
|
|
field :kind, GraphQL::Schema::LateBoundType.new("__TypeKind"), null: false |
|
field :name, String, method: :graphql_name |
|
field :description, String |
|
field :fields, [GraphQL::Schema::LateBoundType.new("__Field")], scope: false do |
|
argument :include_deprecated, Boolean, required: false, default_value: false |
|
end |
|
field :interfaces, [GraphQL::Schema::LateBoundType.new("__Type")], scope: false |
|
field :possible_types, [GraphQL::Schema::LateBoundType.new("__Type")], scope: false |
|
field :enum_values, [GraphQL::Schema::LateBoundType.new("__EnumValue")], scope: false do |
|
argument :include_deprecated, Boolean, required: false, default_value: false |
|
end |
|
field :input_fields, [GraphQL::Schema::LateBoundType.new("__InputValue")], scope: false do |
|
argument :include_deprecated, Boolean, required: false, default_value: false |
|
end |
|
field :of_type, GraphQL::Schema::LateBoundType.new("__Type") |
|
|
|
field :specifiedByURL, String, resolver_method: :specified_by_url |
|
|
|
field :is_one_of, Boolean, null: false |
|
|
|
def is_one_of |
|
object.kind.input_object? && |
|
object.directives.any? { |d| d.graphql_name == "oneOf" } |
|
end |
|
|
|
def specified_by_url |
|
if object.kind.scalar? |
|
object.specified_by_url |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def kind |
|
@object.kind.name |
|
end |
|
|
|
def enum_values(include_deprecated:) |
|
if !@object.kind.enum? |
|
nil |
|
else |
|
enum_values = @context.warden.enum_values(@object) |
|
|
|
if !include_deprecated |
|
enum_values = enum_values.select {|f| !f.deprecation_reason } |
|
end |
|
|
|
enum_values |
|
end |
|
end |
|
|
|
def interfaces |
|
if @object.kind.object? || @object.kind.interface? |
|
@context.warden.interfaces(@object).sort_by(&:graphql_name) |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def input_fields(include_deprecated:) |
|
if @object.kind.input_object? |
|
args = @context.warden.arguments(@object) |
|
args = args.reject(&:deprecation_reason) unless include_deprecated |
|
args |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def possible_types |
|
if @object.kind.abstract? |
|
@context.warden.possible_types(@object).sort_by(&:graphql_name) |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def fields(include_deprecated:) |
|
if !@object.kind.fields? |
|
nil |
|
else |
|
fields = @context.warden.fields(@object) |
|
if !include_deprecated |
|
fields = fields.select {|f| !f.deprecation_reason } |
|
end |
|
fields.sort_by(&:name) |
|
end |
|
end |
|
|
|
def of_type |
|
@object.kind.wraps? ? @object.of_type : nil |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/block_string.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
module BlockString |
|
# Remove leading and trailing whitespace from a block string. |
|
# See "Block Strings" in https://github.com/facebook/graphql/blob/master/spec/Section%202%20--%20Language.md |
|
def self.trim_whitespace(str) |
|
# Early return for the most common cases: |
|
if str == "" |
|
return "".dup |
|
elsif !(has_newline = str.include?("\n")) && !(str.start_with?(" ")) |
|
return str |
|
end |
|
|
|
lines = has_newline ? str.split("\n") : [str] |
|
common_indent = nil |
|
|
|
# find the common whitespace |
|
lines.each_with_index do |line, idx| |
|
if idx == 0 |
|
next |
|
end |
|
line_length = line.size |
|
line_indent = if line.match?(/\A [^ ]/) |
|
2 |
|
elsif line.match?(/\A [^ ]/) |
|
4 |
|
elsif line.match?(/\A[^ ]/) |
|
0 |
|
else |
|
line[/\A */].size |
|
end |
|
if line_indent < line_length && (common_indent.nil? || line_indent < common_indent) |
|
common_indent = line_indent |
|
end |
|
end |
|
|
|
# Remove the common whitespace |
|
if common_indent && common_indent > 0 |
|
lines.each_with_index do |line, idx| |
|
if idx == 0 |
|
next |
|
else |
|
line.slice!(0, common_indent) |
|
end |
|
end |
|
end |
|
|
|
# Remove leading & trailing blank lines |
|
while lines.size > 0 && lines[0].empty? |
|
lines.shift |
|
end |
|
while lines.size > 0 && lines[-1].empty? |
|
lines.pop |
|
end |
|
|
|
# Rebuild the string |
|
lines.size > 1 ? lines.join("\n") : (lines.first || "".dup) |
|
end |
|
|
|
def self.print(str, indent: '') |
|
line_length = 120 - indent.length |
|
block_str = "".dup |
|
triple_quotes = "\"\"\"\n" |
|
block_str << indent |
|
block_str << triple_quotes |
|
|
|
if str.include?("\n") |
|
str.split("\n") do |line| |
|
if line == '' |
|
block_str << "\n" |
|
else |
|
break_line(line, line_length) do |subline| |
|
block_str << indent |
|
block_str << subline |
|
block_str << "\n" |
|
end |
|
end |
|
end |
|
else |
|
break_line(str, line_length) do |subline| |
|
block_str << indent |
|
block_str << subline |
|
block_str << "\n" |
|
end |
|
end |
|
|
|
block_str << indent |
|
block_str << triple_quotes |
|
end |
|
|
|
private |
|
|
|
def self.break_line(line, length) |
|
return yield(line) if line.length < length + 5 |
|
|
|
parts = line.split(Regexp.new("((?: |^).{15,#{length - 40}}(?= |$))")) |
|
return yield(line) if parts.length < 4 |
|
|
|
yield(parts.slice!(0, 3).join) |
|
|
|
parts.each_with_index do |part, i| |
|
next if i % 2 == 1 |
|
yield "#{part[1..-1]}#{parts[i + 1]}" |
|
end |
|
|
|
nil |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/cache.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require 'graphql/version' |
|
require 'digest/sha2' |
|
|
|
module GraphQL |
|
module Language |
|
class Cache |
|
def initialize(path) |
|
@path = path |
|
end |
|
|
|
DIGEST = Digest::SHA256.new << GraphQL::VERSION |
|
def fetch(filename) |
|
hash = DIGEST.dup << filename |
|
begin |
|
hash << File.mtime(filename).to_i.to_s |
|
rescue SystemCallError |
|
return yield |
|
end |
|
cache_path = @path.join(hash.to_s) |
|
|
|
if cache_path.exist? |
|
Marshal.load(cache_path.read) |
|
else |
|
payload = yield |
|
tmp_path = "#{cache_path}.#{rand}" |
|
|
|
@path.mkpath |
|
File.binwrite(tmp_path, Marshal.dump(payload)) |
|
File.rename(tmp_path, cache_path.to_s) |
|
payload |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/definition_slice.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
module DefinitionSlice |
|
extend self |
|
|
|
def slice(document, name) |
|
definitions = {} |
|
document.definitions.each { |d| definitions[d.name] = d } |
|
names = Set.new |
|
DependencyVisitor.find_definition_dependencies(definitions, name, names) |
|
definitions = document.definitions.select { |d| names.include?(d.name) } |
|
Nodes::Document.new(definitions: definitions) |
|
end |
|
|
|
private |
|
|
|
class DependencyVisitor < GraphQL::Language::StaticVisitor |
|
def initialize(doc, definitions, names) |
|
@names = names |
|
@definitions = definitions |
|
super(doc) |
|
end |
|
|
|
def on_fragment_spread(node, parent) |
|
if fragment = @definitions[node.name] |
|
self.class.find_definition_dependencies(@definitions, fragment.name, @names) |
|
end |
|
super |
|
end |
|
|
|
def self.find_definition_dependencies(definitions, name, names) |
|
names.add(name) |
|
visitor = self.new(definitions[name], definitions, names) |
|
visitor.visit |
|
nil |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [collation]: The demo dataset `archive_v4` was migrated to collation `ascii_quail_nx`. |
|
|
|
|
|
### oss/graphql-ruby/lib/graphql/language/document_from_schema_definition.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
# @api private |
|
# |
|
# {GraphQL::Language::DocumentFromSchemaDefinition} is used to convert a {GraphQL::Schema} object |
|
# To a {GraphQL::Language::Document} AST node. |
|
# |
|
# @param context [Hash] |
|
# @param only [<#call(member, ctx)>] |
|
# @param except [<#call(member, ctx)>] |
|
# @param include_introspection_types [Boolean] Whether or not to include introspection types in the AST |
|
# @param include_built_in_scalars [Boolean] Whether or not to include built in scalars in the AST |
|
# @param include_built_in_directives [Boolean] Whether or not to include built in directives in the AST |
|
class DocumentFromSchemaDefinition |
|
def initialize( |
|
schema, context: nil, include_introspection_types: false, |
|
include_built_in_directives: false, include_built_in_scalars: false, always_include_schema: false |
|
) |
|
@schema = schema |
|
@always_include_schema = always_include_schema |
|
@include_introspection_types = include_introspection_types |
|
@include_built_in_scalars = include_built_in_scalars |
|
@include_built_in_directives = include_built_in_directives |
|
@include_one_of = false |
|
|
|
schema_context = schema.context_class.new(query: nil, object: nil, schema: schema, values: context) |
|
|
|
|
|
@warden = @schema.warden_class.new( |
|
schema: @schema, |
|
context: schema_context, |
|
) |
|
|
|
schema_context.warden = @warden |
|
end |
|
|
|
def document |
|
GraphQL::Language::Nodes::Document.new( |
|
definitions: build_definition_nodes |
|
) |
|
end |
|
|
|
def build_schema_node |
|
if !schema_respects_root_name_conventions?(@schema) |
|
GraphQL::Language::Nodes::SchemaDefinition.new( |
|
query: (q = warden.root_type_for_operation("query")) && q.graphql_name, |
|
mutation: (m = warden.root_type_for_operation("mutation")) && m.graphql_name, |
|
subscription: (s = warden.root_type_for_operation("subscription")) && s.graphql_name, |
|
directives: definition_directives(@schema, :schema_directives) |
|
) |
|
else |
|
# A plain `schema ...` _must_ include root type definitions. |
|
# If the only difference is directives, then you have to use `extend schema` |
|
GraphQL::Language::Nodes::SchemaExtension.new(directives: definition_directives(@schema, :schema_directives)) |
|
end |
|
end |
|
|
|
def build_object_type_node(object_type) |
|
ints = warden.interfaces(object_type) |
|
if ints.any? |
|
ints.sort_by!(&:graphql_name) |
|
ints.map! { |iface| build_type_name_node(iface) } |
|
end |
|
|
|
GraphQL::Language::Nodes::ObjectTypeDefinition.new( |
|
name: object_type.graphql_name, |
|
interfaces: ints, |
|
fields: build_field_nodes(warden.fields(object_type)), |
|
description: object_type.description, |
|
directives: directives(object_type), |
|
) |
|
end |
|
|
|
def build_field_node(field) |
|
GraphQL::Language::Nodes::FieldDefinition.new( |
|
name: field.graphql_name, |
|
arguments: build_argument_nodes(warden.arguments(field)), |
|
type: build_type_name_node(field.type), |
|
description: field.description, |
|
directives: directives(field), |
|
) |
|
end |
|
|
|
def build_union_type_node(union_type) |
|
GraphQL::Language::Nodes::UnionTypeDefinition.new( |
|
name: union_type.graphql_name, |
|
description: union_type.description, |
|
types: warden.possible_types(union_type).sort_by(&:graphql_name).map { |type| build_type_name_node(type) }, |
|
directives: directives(union_type), |
|
) |
|
end |
|
|
|
def build_interface_type_node(interface_type) |
|
GraphQL::Language::Nodes::InterfaceTypeDefinition.new( |
|
name: interface_type.graphql_name, |
|
interfaces: warden.interfaces(interface_type).sort_by(&:graphql_name).map { |type| build_type_name_node(type) }, |
|
description: interface_type.description, |
|
fields: build_field_nodes(warden.fields(interface_type)), |
|
directives: directives(interface_type), |
|
) |
|
end |
|
|
|
def build_enum_type_node(enum_type) |
|
GraphQL::Language::Nodes::EnumTypeDefinition.new( |
|
name: enum_type.graphql_name, |
|
values: warden.enum_values(enum_type).sort_by(&:graphql_name).map do |enum_value| |
|
build_enum_value_node(enum_value) |
|
end, |
|
description: enum_type.description, |
|
directives: directives(enum_type), |
|
) |
|
end |
|
|
|
def build_enum_value_node(enum_value) |
|
GraphQL::Language::Nodes::EnumValueDefinition.new( |
|
name: enum_value.graphql_name, |
|
description: enum_value.description, |
|
directives: directives(enum_value), |
|
) |
|
end |
|
|
|
def build_scalar_type_node(scalar_type) |
|
GraphQL::Language::Nodes::ScalarTypeDefinition.new( |
|
name: scalar_type.graphql_name, |
|
description: scalar_type.description, |
|
directives: directives(scalar_type), |
|
) |
|
end |
|
|
|
def build_argument_node(argument) |
|
if argument.default_value? |
|
default_value = build_default_value(argument.default_value, argument.type) |
|
else |
|
default_value = nil |
|
end |
|
|
|
argument_node = GraphQL::Language::Nodes::InputValueDefinition.new( |
|
name: argument.graphql_name, |
|
description: argument.description, |
|
type: build_type_name_node(argument.type), |
|
default_value: default_value, |
|
directives: directives(argument), |
|
) |
|
|
|
argument_node |
|
end |
|
|
|
def build_input_object_node(input_object) |
|
GraphQL::Language::Nodes::InputObjectTypeDefinition.new( |
|
name: input_object.graphql_name, |
|
fields: build_argument_nodes(warden.arguments(input_object)), |
|
description: input_object.description, |
|
directives: directives(input_object), |
|
) |
|
end |
|
|
|
def build_directive_node(directive) |
|
GraphQL::Language::Nodes::DirectiveDefinition.new( |
|
name: directive.graphql_name, |
|
repeatable: directive.repeatable?, |
|
arguments: build_argument_nodes(warden.arguments(directive)), |
|
locations: build_directive_location_nodes(directive.locations), |
|
description: directive.description, |
|
) |
|
end |
|
|
|
def build_directive_location_nodes(locations) |
|
locations.sort.map { |location| build_directive_location_node(location) } |
|
end |
|
|
|
def build_directive_location_node(location) |
|
GraphQL::Language::Nodes::DirectiveLocation.new( |
|
name: location.to_s |
|
) |
|
end |
|
|
|
def build_type_name_node(type) |
|
case type.kind.name |
|
when "LIST" |
|
GraphQL::Language::Nodes::ListType.new( |
|
of_type: build_type_name_node(type.of_type) |
|
) |
|
when "NON_NULL" |
|
GraphQL::Language::Nodes::NonNullType.new( |
|
of_type: build_type_name_node(type.of_type) |
|
) |
|
else |
|
@cached_type_name_nodes ||= {} |
|
@cached_type_name_nodes[type.graphql_name] ||= GraphQL::Language::Nodes::TypeName.new(name: type.graphql_name) |
|
end |
|
end |
|
|
|
def build_default_value(default_value, type) |
|
if default_value.nil? |
|
return GraphQL::Language::Nodes::NullValue.new(name: "null") |
|
end |
|
|
|
case type.kind.name |
|
when "SCALAR" |
|
type.coerce_isolated_result(default_value) |
|
when "ENUM" |
|
GraphQL::Language::Nodes::Enum.new(name: type.coerce_isolated_result(default_value)) |
|
when "INPUT_OBJECT" |
|
GraphQL::Language::Nodes::InputObject.new( |
|
arguments: default_value.to_h.map do |arg_name, arg_value| |
|
args = @warden.arguments(type) |
|
arg = args.find { |a| a.keyword.to_s == arg_name.to_s } |
|
if arg.nil? |
|
raise ArgumentError, "No argument definition on #{type.graphql_name} for argument: #{arg_name.inspect} (expected one of: #{args.map(&:keyword)})" |
|
end |
|
GraphQL::Language::Nodes::Argument.new( |
|
name: arg.graphql_name.to_s, |
|
value: build_default_value(arg_value, arg.type) |
|
) |
|
end |
|
) |
|
when "NON_NULL" |
|
build_default_value(default_value, type.of_type) |
|
when "LIST" |
|
default_value.to_a.map { |v| build_default_value(v, type.of_type) } |
|
else |
|
raise GraphQL::RequiredImplementationMissingError, "Unexpected default value type #{type.inspect}" |
|
end |
|
end |
|
|
|
def build_type_definition_node(type) |
|
case type.kind.name |
|
when "OBJECT" |
|
build_object_type_node(type) |
|
when "UNION" |
|
build_union_type_node(type) |
|
when "INTERFACE" |
|
build_interface_type_node(type) |
|
when "SCALAR" |
|
build_scalar_type_node(type) |
|
when "ENUM" |
|
build_enum_type_node(type) |
|
when "INPUT_OBJECT" |
|
build_input_object_node(type) |
|
else |
|
raise TypeError |
|
end |
|
end |
|
|
|
def build_argument_nodes(arguments) |
|
if arguments.any? |
|
nodes = arguments.map { |arg| build_argument_node(arg) } |
|
nodes.sort_by!(&:name) |
|
nodes |
|
else |
|
arguments |
|
end |
|
end |
|
|
|
def build_directive_nodes(directives) |
|
directives |
|
.map { |directive| build_directive_node(directive) } |
|
.sort_by(&:name) |
|
end |
|
|
|
def build_definition_nodes |
|
dirs_to_build = warden.directives |
|
if !include_built_in_directives |
|
dirs_to_build = dirs_to_build.reject { |directive| directive.default_directive? } |
|
end |
|
definitions = build_directive_nodes(dirs_to_build) |
|
|
|
type_nodes = build_type_definition_nodes(warden.reachable_types) |
|
|
|
if @include_one_of |
|
# This may have been set to true when iterating over all types |
|
definitions.concat(build_directive_nodes([GraphQL::Schema::Directive::OneOf])) |
|
end |
|
|
|
definitions.concat(type_nodes) |
|
if include_schema_node? |
|
definitions.unshift(build_schema_node) |
|
end |
|
|
|
definitions |
|
end |
|
|
|
def build_type_definition_nodes(types) |
|
if !include_introspection_types |
|
types = types.reject { |type| type.introspection? } |
|
end |
|
|
|
if !include_built_in_scalars |
|
types = types.reject { |type| type.kind.scalar? && type.default_scalar? } |
|
end |
|
|
|
types |
|
.map { |type| build_type_definition_node(type) } |
|
.sort_by(&:name) |
|
end |
|
|
|
def build_field_nodes(fields) |
|
f_nodes = fields.map { |field| build_field_node(field) } |
|
f_nodes.sort_by!(&:name) |
|
f_nodes |
|
end |
|
|
|
private |
|
|
|
def include_schema_node? |
|
always_include_schema || |
|
!schema_respects_root_name_conventions?(schema) || |
|
!schema.schema_directives.empty? |
|
end |
|
|
|
def schema_respects_root_name_conventions?(schema) |
|
(schema.query.nil? || schema.query.graphql_name == 'Query') && |
|
(schema.mutation.nil? || schema.mutation.graphql_name == 'Mutation') && |
|
(schema.subscription.nil? || schema.subscription.graphql_name == 'Subscription') |
|
end |
|
|
|
def directives(member) |
|
definition_directives(member, :directives) |
|
end |
|
|
|
def definition_directives(member, directives_method) |
|
dirs = if !member.respond_to?(directives_method) || member.directives.empty? |
|
EmptyObjects::EMPTY_ARRAY |
|
else |
|
member.public_send(directives_method).map do |dir| |
|
args = [] |
|
dir.arguments.argument_values.each_value do |arg_value| # rubocop:disable Development/ContextIsPassedCop -- directive instance method |
|
arg_defn = arg_value.definition |
|
if arg_defn.default_value? && arg_value.value == arg_defn.default_value |
|
next |
|
else |
|
value_node = build_default_value(arg_value.value, arg_value.definition.type) |
|
args << GraphQL::Language::Nodes::Argument.new( |
|
name: arg_value.definition.name, |
|
value: value_node, |
|
) |
|
end |
|
end |
|
|
|
# If this schema uses this built-in directive definition, |
|
# include it in the print-out since it's not part of the spec yet. |
|
@include_one_of ||= dir.class == GraphQL::Schema::Directive::OneOf |
|
|
|
GraphQL::Language::Nodes::Directive.new( |
|
name: dir.class.graphql_name, |
|
arguments: args |
|
) |
|
end |
|
end |
|
|
|
dirs |
|
end |
|
|
|
attr_reader :schema, :warden, :always_include_schema, |
|
:include_introspection_types, :include_built_in_directives, :include_built_in_scalars |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/generation.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
# Exposes {.generate}, which turns AST nodes back into query strings. |
|
module Generation |
|
extend self |
|
|
|
# Turn an AST node back into a string. |
|
# |
|
# @example Turning a document into a query |
|
# document = GraphQL.parse(query_string) |
|
# GraphQL::Language::Generation.generate(document) |
|
# # => "{ ... }" |
|
# |
|
# @param node [GraphQL::Language::Nodes::AbstractNode] an AST node to recursively stringify |
|
# @param indent [String] Whitespace to add to each printed node |
|
# @param printer [GraphQL::Language::Printer] An optional custom printer for printing AST nodes. Defaults to GraphQL::Language::Printer |
|
# @return [String] Valid GraphQL for `node` |
|
def generate(node, indent: "", printer: GraphQL::Language::Printer.new) |
|
printer.print(node, indent: indent) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/lexer.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
require "strscan" |
|
|
|
module GraphQL |
|
module Language |
|
class Lexer |
|
IDENTIFIER = /[_A-Za-z][_0-9A-Za-z]*/ |
|
NEWLINE = /[\c\r\n]/ |
|
BLANK = /[, \t]+/ |
|
COMMENT = /#[^\n\r]*/ |
|
INT = /[-]?(?:[0]|[1-9][0-9]*)/ |
|
FLOAT_DECIMAL = /[.][0-9]+/ |
|
FLOAT_EXP = /[eE][+-]?[0-9]+/ |
|
FLOAT = /#{INT}(#{FLOAT_DECIMAL}#{FLOAT_EXP}|#{FLOAT_DECIMAL}|#{FLOAT_EXP})/ |
|
|
|
module Literals |
|
ON = /on\b/ |
|
FRAGMENT = /fragment\b/ |
|
TRUE = /true\b/ |
|
FALSE = /false\b/ |
|
NULL = /null\b/ |
|
QUERY = /query\b/ |
|
MUTATION = /mutation\b/ |
|
SUBSCRIPTION = /subscription\b/ |
|
SCHEMA = /schema\b/ |
|
SCALAR = /scalar\b/ |
|
TYPE = /type\b/ |
|
EXTEND = /extend\b/ |
|
IMPLEMENTS = /implements\b/ |
|
INTERFACE = /interface\b/ |
|
UNION = /union\b/ |
|
ENUM = /enum\b/ |
|
INPUT = /input\b/ |
|
DIRECTIVE = /directive\b/ |
|
REPEATABLE = /repeatable\b/ |
|
LCURLY = '{' |
|
RCURLY = '}' |
|
LPAREN = '(' |
|
RPAREN = ')' |
|
LBRACKET = '[' |
|
RBRACKET = ']' |
|
COLON = ':' |
|
VAR_SIGN = '$' |
|
DIR_SIGN = '@' |
|
ELLIPSIS = '...' |
|
EQUALS = '=' |
|
BANG = '!' |
|
PIPE = '|' |
|
AMP = '&' |
|
end |
|
|
|
include Literals |
|
|
|
QUOTE = '"' |
|
UNICODE_DIGIT = /[0-9A-Za-z]/ |
|
FOUR_DIGIT_UNICODE = /#{UNICODE_DIGIT}{4}/ |
|
N_DIGIT_UNICODE = %r{#{LCURLY}#{UNICODE_DIGIT}{4,}#{RCURLY}}x |
|
UNICODE_ESCAPE = %r{\\u(?:#{FOUR_DIGIT_UNICODE}|#{N_DIGIT_UNICODE})} |
|
# # https://graphql.github.io/graphql-spec/June2018/#sec-String-Value |
|
STRING_ESCAPE = %r{[\\][\\/bfnrt]} |
|
BLOCK_QUOTE = '"""' |
|
ESCAPED_QUOTE = /\\"/; |
|
STRING_CHAR = /#{ESCAPED_QUOTE}|[^"\\]|#{UNICODE_ESCAPE}|#{STRING_ESCAPE}/ |
|
|
|
LIT_NAME_LUT = Literals.constants.each_with_object({}) { |n, o| |
|
key = Literals.const_get(n) |
|
key = key.is_a?(Regexp) ? key.source.gsub(/(\\b|\\)/, '') : key |
|
o[key] = n |
|
} |
|
|
|
LIT = Regexp.union(Literals.constants.map { |n| Literals.const_get(n) }) |
|
|
|
QUOTED_STRING = %r{#{QUOTE} (?:#{STRING_CHAR})* #{QUOTE}}x |
|
BLOCK_STRING = %r{ |
|
#{BLOCK_QUOTE} |
|
(?: [^"\\] | # Any characters that aren't a quote or slash |
|
(?<!") ["]{1,2} (?!") | # Any quotes that don't have quotes next to them |
|
\\"{0,3}(?!") | # A slash followed by <= 3 quotes that aren't followed by a quote |
|
\\ | # A slash |
|
"{1,2}(?!") # 1 or 2 " followed by something that isn't a quote |
|
)* |
|
(?:"")? |
|
#{BLOCK_QUOTE} |
|
}xm |
|
|
|
# # catch-all for anything else. must be at the bottom for precedence. |
|
UNKNOWN_CHAR = /./ |
|
|
|
def initialize(value) |
|
@line = 1 |
|
@col = 1 |
|
@previous_token = nil |
|
|
|
@scan = scanner value |
|
end |
|
|
|
class BadEncoding < Lexer # :nodoc: |
|
def scanner(value) |
|
[emit(:BAD_UNICODE_ESCAPE, 0, 0, value)] |
|
end |
|
|
|
def next_token |
|
@scan.pop |
|
end |
|
end |
|
|
|
def self.tokenize(string) |
|
value = string.dup.force_encoding(Encoding::UTF_8) |
|
|
|
scanner = if value.valid_encoding? |
|
new value |
|
else |
|
BadEncoding.new value |
|
end |
|
|
|
toks = [] |
|
|
|
while tok = scanner.next_token |
|
toks << tok |
|
end |
|
|
|
toks |
|
end |
|
|
|
def next_token |
|
return if @scan.eos? |
|
|
|
pos = @scan.pos |
|
|
|
case |
|
when str = @scan.scan(FLOAT) then emit(:FLOAT, pos, @scan.pos, str) |
|
when str = @scan.scan(INT) then emit(:INT, pos, @scan.pos, str) |
|
when str = @scan.scan(LIT) then emit(LIT_NAME_LUT[str], pos, @scan.pos, -str) |
|
when str = @scan.scan(IDENTIFIER) then emit(:IDENTIFIER, pos, @scan.pos, str) |
|
when str = @scan.scan(BLOCK_STRING) then emit_block(pos, @scan.pos, str.gsub(/\A#{BLOCK_QUOTE}|#{BLOCK_QUOTE}\z/, '')) |
|
when str = @scan.scan(QUOTED_STRING) then emit_string(pos, @scan.pos, str.gsub(/^"|"$/, '')) |
|
when str = @scan.scan(COMMENT) then record_comment(pos, @scan.pos, str) |
|
when str = @scan.scan(NEWLINE) |
|
@line += 1 |
|
@col = 1 |
|
next_token |
|
when @scan.scan(BLANK) |
|
@col += @scan.pos - pos |
|
next_token |
|
when str = @scan.scan(UNKNOWN_CHAR) then emit(:UNKNOWN_CHAR, pos, @scan.pos, str) |
|
else |
|
# This should never happen since `UNKNOWN_CHAR` ensures we make progress |
|
raise "Unknown string?" |
|
end |
|
end |
|
|
|
def emit(token_name, ts, te, token_value) |
|
token = [ |
|
token_name, |
|
@line, |
|
@col, |
|
token_value, |
|
@previous_token, |
|
] |
|
@previous_token = token |
|
# Bump the column counter for the next token |
|
@col += te - ts |
|
token |
|
end |
|
|
|
# Replace any escaped unicode or whitespace with the _actual_ characters |
|
# To avoid allocating more strings, this modifies the string passed into it |
|
def self.replace_escaped_characters_in_place(raw_string) |
|
raw_string.gsub!(ESCAPES, ESCAPES_REPLACE) |
|
raw_string.gsub!(UTF_8) do |_matched_str| |
|
codepoint_1 = ($1 || $2).to_i(16) |
|
codepoint_2 = $3 |
|
|
|
if codepoint_2 |
|
codepoint_2 = codepoint_2.to_i(16) |
|
if (codepoint_1 >= 0xD800 && codepoint_1 <= 0xDBFF) && # leading surrogate |
|
(codepoint_2 >= 0xDC00 && codepoint_2 <= 0xDFFF) # trailing surrogate |
|
# A surrogate pair |
|
combined = ((codepoint_1 - 0xD800) * 0x400) + (codepoint_2 - 0xDC00) + 0x10000 |
|
[combined].pack('U'.freeze) |
|
else |
|
# Two separate code points |
|
[codepoint_1].pack('U'.freeze) + [codepoint_2].pack('U'.freeze) |
|
end |
|
else |
|
[codepoint_1].pack('U'.freeze) |
|
end |
|
end |
|
nil |
|
end |
|
|
|
def record_comment(ts, te, str) |
|
token = [ |
|
:COMMENT, |
|
@line, |
|
@col, |
|
str, |
|
@previous_token, |
|
] |
|
|
|
@previous_token = token |
|
|
|
@col += te - ts |
|
next_token |
|
end |
|
|
|
ESCAPES = /\\["\\\/bfnrt]/ |
|
ESCAPES_REPLACE = { |
|
'\\"' => '"', |
|
"\\\\" => "\\", |
|
"\\/" => '/', |
|
"\\b" => "\b", |
|
"\\f" => "\f", |
|
"\\n" => "\n", |
|
"\\r" => "\r", |
|
"\\t" => "\t", |
|
} |
|
UTF_8 = /\\u(?:([\dAa-f]{4})|\{([\da-f]{4,})\})(?:\\u([\dAa-f]{4}))?/i |
|
VALID_STRING = /\A(?:[^\\]|#{ESCAPES}|#{UTF_8})*\z/o |
|
|
|
def emit_block(ts, te, value) |
|
line_incr = value.count("\n") |
|
value = GraphQL::Language::BlockString.trim_whitespace(value) |
|
tok = emit_string(ts, te, value) |
|
@line += line_incr |
|
tok |
|
end |
|
|
|
def emit_string(ts, te, value) |
|
if !value.valid_encoding? || !value.match?(VALID_STRING) |
|
emit(:BAD_UNICODE_ESCAPE, ts, te, value) |
|
else |
|
self.class.replace_escaped_characters_in_place(value) |
|
|
|
if !value.valid_encoding? |
|
emit(:BAD_UNICODE_ESCAPE, ts, te, value) |
|
else |
|
emit(:STRING, ts, te, value) |
|
end |
|
end |
|
end |
|
|
|
private |
|
|
|
def scanner(value) |
|
StringScanner.new value |
|
end |
|
|
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/nodes.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
module Nodes |
|
NONE = GraphQL::EmptyObjects::EMPTY_ARRAY |
|
# {AbstractNode} is the base class for all nodes in a GraphQL AST. |
|
# |
|
# It provides some APIs for working with ASTs: |
|
# - `children` returns all AST nodes attached to this one. Used for tree traversal. |
|
# - `scalars` returns all scalar (Ruby) values attached to this one. Used for comparing nodes. |
|
# - `to_query_string` turns an AST node into a GraphQL string |
|
class AbstractNode |
|
|
|
module DefinitionNode |
|
# This AST node's {#line} returns the first line, which may be the description. |
|
# @return [Integer] The first line of the definition (not the description) |
|
attr_reader :definition_line |
|
|
|
def initialize(options = {}) |
|
@definition_line = options.delete(:definition_line) |
|
super(options) |
|
end |
|
end |
|
|
|
attr_reader :line, :col, :filename |
|
|
|
# Initialize a node by extracting its position, |
|
# then calling the class's `initialize_node` method. |
|
# @param options [Hash] Initial attributes for this node |
|
def initialize(options = {}) |
|
if options.key?(:position_source) |
|
position_source = options.delete(:position_source) |
|
@line = position_source[1] |
|
@col = position_source[2] |
|
else |
|
@line = options.delete(:line) |
|
@col = options.delete(:col) |
|
end |
|
|
|
@filename = options.delete(:filename) |
|
|
|
initialize_node(**options) |
|
end |
|
|
|
# Value equality |
|
# @return [Boolean] True if `self` is equivalent to `other` |
|
def ==(other) |
|
return true if equal?(other) |
|
other.kind_of?(self.class) && |
|
other.scalars == self.scalars && |
|
other.children == self.children |
|
end |
|
|
|
NO_CHILDREN = GraphQL::EmptyObjects::EMPTY_ARRAY |
|
|
|
# @return [Array<GraphQL::Language::Nodes::AbstractNode>] all nodes in the tree below this one |
|
def children |
|
NO_CHILDREN |
|
end |
|
|
|
# @return [Array<Integer, Float, String, Boolean, Array>] Scalar values attached to this node |
|
def scalars |
|
NO_CHILDREN |
|
end |
|
|
|
# This might be unnecessary, but its easiest to add it here. |
|
def initialize_copy(other) |
|
@children = nil |
|
@scalars = nil |
|
@query_string = nil |
|
end |
|
|
|
def children_method_name |
|
self.class.children_method_name |
|
end |
|
|
|
def position |
|
[line, col] |
|
end |
|
|
|
def to_query_string(printer: GraphQL::Language::Printer.new) |
|
if printer.is_a?(GraphQL::Language::Printer) |
|
@query_string ||= printer.print(self) |
|
else |
|
printer.print(self) |
|
end |
|
end |
|
|
|
# This creates a copy of `self`, with `new_options` applied. |
|
# @param new_options [Hash] |
|
# @return [AbstractNode] a shallow copy of `self` |
|
def merge(new_options) |
|
dup.merge!(new_options) |
|
end |
|
|
|
# Copy `self`, but modify the copy so that `previous_child` is replaced by `new_child` |
|
def replace_child(previous_child, new_child) |
|
# Figure out which list `previous_child` may be found in |
|
method_name = previous_child.children_method_name |
|
# Get the value from this (original) node |
|
prev_children = public_send(method_name) |
|
if prev_children.is_a?(Array) |
|
# Copy that list, and replace `previous_child` with `new_child` |
|
# in the list. |
|
new_children = prev_children.dup |
|
prev_idx = new_children.index(previous_child) |
|
new_children[prev_idx] = new_child |
|
else |
|
# Use the new value for the given attribute |
|
new_children = new_child |
|
end |
|
# Copy this node, but with the new child value |
|
copy_of_self = merge(method_name => new_children) |
|
# Return the copy: |
|
copy_of_self |
|
end |
|
|
|
# TODO DRY with `replace_child` |
|
def delete_child(previous_child) |
|
# Figure out which list `previous_child` may be found in |
|
method_name = previous_child.children_method_name |
|
# Copy that list, and delete previous_child |
|
new_children = public_send(method_name).dup |
|
new_children.delete(previous_child) |
|
# Copy this node, but with the new list of children: |
|
copy_of_self = merge(method_name => new_children) |
|
# Return the copy: |
|
copy_of_self |
|
end |
|
|
|
protected |
|
|
|
def merge!(new_options) |
|
new_options.each do |key, value| |
|
instance_variable_set(:"@#{key}", value) |
|
end |
|
self |
|
end |
|
|
|
class << self |
|
# Add a default `#visit_method` and `#children_method_name` using the class name |
|
def inherited(child_class) |
|
super |
|
name_underscored = child_class.name |
|
.split("::").last |
|
.gsub(/([a-z])([A-Z])/,'\1_\2') # insert underscores |
|
.downcase # remove caps |
|
|
|
child_class.module_eval <<-RUBY, __FILE__, __LINE__ |
|
def visit_method |
|
:on_#{name_underscored} |
|
end |
|
|
|
class << self |
|
attr_accessor :children_method_name |
|
|
|
def visit_method |
|
:on_#{name_underscored} |
|
end |
|
end |
|
self.children_method_name = :#{name_underscored}s |
|
RUBY |
|
end |
|
|
|
def children_of_type |
|
@children_methods |
|
end |
|
|
|
private |
|
|
|
# Name accessors which return lists of nodes, |
|
# along with the kind of node they return, if possible. |
|
# - Add a reader for these children |
|
# - Add a persistent update method to add a child |
|
# - Generate a `#children` method |
|
def children_methods(children_of_type) |
|
if defined?(@children_methods) |
|
raise "Can't re-call .children_methods for #{self} (already have: #{@children_methods})" |
|
else |
|
@children_methods = children_of_type |
|
end |
|
|
|
if children_of_type == false |
|
@children_methods = {} |
|
# skip |
|
else |
|
|
|
children_of_type.each do |method_name, node_type| |
|
module_eval <<-RUBY, __FILE__, __LINE__ |
|
# A reader for these children |
|
attr_reader :#{method_name} |
|
RUBY |
|
|
|
if node_type |
|
# Only generate a method if we know what kind of node to make |
|
module_eval <<-RUBY, __FILE__, __LINE__ |
|
# Singular method: create a node with these options |
|
# and return a new `self` which includes that node in this list. |
|
def merge_#{method_name.to_s.sub(/s$/, "")}(node_opts) |
|
merge(#{method_name}: #{method_name} + [#{node_type.name}.new(node_opts)]) |
|
end |
|
RUBY |
|
end |
|
end |
|
|
|
if children_of_type.size == 1 |
|
module_eval <<-RUBY, __FILE__, __LINE__ |
|
alias :children #{children_of_type.keys.first} |
|
RUBY |
|
else |
|
module_eval <<-RUBY, __FILE__, __LINE__ |
|
def children |
|
@children ||= begin |
|
if #{children_of_type.keys.map { |k| "@#{k}.any?" }.join(" || ")} |
|
new_children = [] |
|
#{children_of_type.keys.map { |k| "new_children.concat(@#{k})" }.join("; ")} |
|
new_children.freeze |
|
new_children |
|
else |
|
NO_CHILDREN |
|
end |
|
end |
|
end |
|
RUBY |
|
end |
|
end |
|
|
|
if defined?(@scalar_methods) |
|
if !method_defined?(:initialize_node) |
|
generate_initialize_node |
|
else |
|
# This method was defined manually |
|
end |
|
else |
|
raise "Can't generate_initialize_node because scalar_methods wasn't called; call it before children_methods" |
|
end |
|
end |
|
|
|
# These methods return a plain Ruby value, not another node |
|
# - Add reader methods |
|
# - Add a `#scalars` method |
|
def scalar_methods(*method_names) |
|
if defined?(@scalar_methods) |
|
raise "Can't re-call .scalar_methods for #{self} (already have: #{@scalar_methods})" |
|
else |
|
@scalar_methods = method_names |
|
end |
|
|
|
if method_names == [false] |
|
@scalar_methods = [] |
|
# skip it |
|
else |
|
module_eval <<-RUBY, __FILE__, __LINE__ |
|
# add readers for each scalar |
|
attr_reader #{method_names.map { |m| ":#{m}"}.join(", ")} |
|
|
|
def scalars |
|
@scalars ||= [#{method_names.map { |k| "@#{k}" }.join(", ")}].freeze |
|
end |
|
RUBY |
|
end |
|
end |
|
|
|
def generate_initialize_node |
|
scalar_method_names = @scalar_methods |
|
# TODO: These probably should be scalar methods, but `types` returns an array |
|
[:types, :description].each do |extra_method| |
|
if method_defined?(extra_method) |
|
scalar_method_names += [extra_method] |
|
end |
|
end |
|
|
|
all_method_names = scalar_method_names + @children_methods.keys |
|
if all_method_names.include?(:alias) |
|
# Rather than complicating this special case, |
|
# let it be overridden (in field) |
|
return |
|
else |
|
arguments = scalar_method_names.map { |m| "#{m}: nil"} + |
|
@children_methods.keys.map { |m| "#{m}: NO_CHILDREN" } |
|
|
|
assignments = scalar_method_names.map { |m| "@#{m} = #{m}"} + |
|
@children_methods.keys.map { |m| "@#{m} = #{m}.freeze" } |
|
|
|
keywords = scalar_method_names.map { |m| "#{m}: #{m}"} + |
|
@children_methods.keys.map { |m| "#{m}: #{m}" } |
|
|
|
module_eval <<-RUBY, __FILE__, __LINE__ |
|
def initialize_node #{arguments.join(", ")} |
|
#{assignments.join("\n")} |
|
end |
|
|
|
def self.from_a(filename, line, col, #{(scalar_method_names + @children_methods.keys).join(", ")}) |
|
self.new(filename: filename, line: line, col: col, #{keywords.join(", ")}) |
|
end |
|
RUBY |
|
end |
|
end |
|
end |
|
end |
|
|
|
# Base class for non-null type names and list type names |
|
class WrapperType < AbstractNode |
|
scalar_methods :of_type |
|
children_methods(false) |
|
end |
|
|
|
# Base class for nodes whose only value is a name (no child nodes or other scalars) |
|
class NameOnlyNode < AbstractNode |
|
scalar_methods :name |
|
children_methods(false) |
|
end |
|
|
|
# A key-value pair for a field's inputs |
|
class Argument < AbstractNode |
|
scalar_methods :name, :value |
|
children_methods(false) |
|
|
|
# @!attribute name |
|
# @return [String] the key for this argument |
|
|
|
# @!attribute value |
|
# @return [String, Float, Integer, Boolean, Array, InputObject, VariableIdentifier] The value passed for this key |
|
|
|
def children |
|
@children ||= Array(value).flatten.tap { _1.select! { |v| v.is_a?(AbstractNode) } } |
|
end |
|
end |
|
|
|
class Directive < AbstractNode |
|
scalar_methods :name |
|
children_methods(arguments: GraphQL::Language::Nodes::Argument) |
|
end |
|
|
|
class DirectiveLocation < NameOnlyNode |
|
end |
|
|
|
class DirectiveDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name, :repeatable |
|
children_methods( |
|
arguments: Nodes::Argument, |
|
locations: Nodes::DirectiveLocation, |
|
) |
|
end |
|
|
|
# An enum value. The string is available as {#name}. |
|
class Enum < NameOnlyNode |
|
end |
|
|
|
# A null value literal. |
|
class NullValue < NameOnlyNode |
|
end |
|
|
|
# A single selection in a GraphQL query. |
|
class Field < AbstractNode |
|
scalar_methods :name, :alias |
|
children_methods({ |
|
arguments: GraphQL::Language::Nodes::Argument, |
|
selections: GraphQL::Language::Nodes::Field, |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
|
|
# @!attribute selections |
|
# @return [Array<Nodes::Field>] Selections on this object (or empty array if this is a scalar field) |
|
|
|
def initialize_node(attributes) |
|
@name = attributes[:name] |
|
@arguments = attributes[:arguments] || NONE |
|
@directives = attributes[:directives] || NONE |
|
@selections = attributes[:selections] || NONE |
|
# oops, alias is a keyword: |
|
@alias = attributes[:alias] |
|
end |
|
|
|
def self.from_a(filename, line, col, graphql_alias, name, arguments, directives, selections) # rubocop:disable Metrics/ParameterLists |
|
self.new(filename: filename, line: line, col: col, alias: graphql_alias, name: name, arguments: arguments, directives: directives, selections: selections) |
|
end |
|
|
|
# Override this because default is `:fields` |
|
self.children_method_name = :selections |
|
end |
|
|
|
# A reusable fragment, defined at document-level. |
|
class FragmentDefinition < AbstractNode |
|
# @!attribute name |
|
# @return [String] the identifier for this fragment, which may be applied with `...#{name}` |
|
|
|
# @!attribute type |
|
# @return [String] the type condition for this fragment (name of type which it may apply to) |
|
def initialize_node(name: nil, type: nil, directives: [], selections: []) |
|
@name = name |
|
@type = type |
|
@directives = directives |
|
@selections = selections |
|
end |
|
|
|
def self.from_a(filename, line, col, name, type, directives, selections) |
|
self.new(filename: filename, line: line, col: col, name: name, type: type, directives: directives, selections: selections) |
|
end |
|
|
|
scalar_methods :name, :type |
|
children_methods({ |
|
selections: GraphQL::Language::Nodes::Field, |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
|
|
self.children_method_name = :definitions |
|
end |
|
|
|
# Application of a named fragment in a selection |
|
class FragmentSpread < AbstractNode |
|
scalar_methods :name |
|
children_methods(directives: GraphQL::Language::Nodes::Directive) |
|
|
|
self.children_method_name = :selections |
|
|
|
# @!attribute name |
|
# @return [String] The identifier of the fragment to apply, corresponds with {FragmentDefinition#name} |
|
end |
|
|
|
# An unnamed fragment, defined directly in the query with `... { }` |
|
class InlineFragment < AbstractNode |
|
scalar_methods :type |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
selections: GraphQL::Language::Nodes::Field, |
|
}) |
|
|
|
self.children_method_name = :selections |
|
|
|
# @!attribute type |
|
# @return [String, nil] Name of the type this fragment applies to, or `nil` if this fragment applies to any type |
|
end |
|
|
|
# A collection of key-value inputs which may be a field argument |
|
class InputObject < AbstractNode |
|
scalar_methods(false) |
|
children_methods(arguments: GraphQL::Language::Nodes::Argument) |
|
|
|
# @!attribute arguments |
|
# @return [Array<Nodes::Argument>] A list of key-value pairs inside this input object |
|
|
|
# @return [Hash<String, Any>] Recursively turn this input object into a Ruby Hash |
|
def to_h(options={}) |
|
arguments.inject({}) do |memo, pair| |
|
v = pair.value |
|
memo[pair.name] = serialize_value_for_hash v |
|
memo |
|
end |
|
end |
|
|
|
self.children_method_name = :value |
|
|
|
private |
|
|
|
def serialize_value_for_hash(value) |
|
case value |
|
when InputObject |
|
value.to_h |
|
when Array |
|
value.map do |v| |
|
serialize_value_for_hash v |
|
end |
|
when Enum |
|
value.name |
|
when NullValue |
|
nil |
|
else |
|
value |
|
end |
|
end |
|
end |
|
|
|
# A list type definition, denoted with `[...]` (used for variable type definitions) |
|
class ListType < WrapperType |
|
end |
|
|
|
# A non-null type definition, denoted with `...!` (used for variable type definitions) |
|
class NonNullType < WrapperType |
|
end |
|
|
|
# An operation-level query variable |
|
class VariableDefinition < AbstractNode |
|
scalar_methods :name, :type, :default_value |
|
children_methods false |
|
# @!attribute default_value |
|
# @return [String, Integer, Float, Boolean, Array, NullValue] A Ruby value to use if no other value is provided |
|
|
|
# @!attribute type |
|
# @return [TypeName, NonNullType, ListType] The expected type of this value |
|
|
|
# @!attribute name |
|
# @return [String] The identifier for this variable, _without_ `$` |
|
|
|
self.children_method_name = :variables |
|
end |
|
|
|
# A query, mutation or subscription. |
|
# May be anonymous or named. |
|
# May be explicitly typed (eg `mutation { ... }`) or implicitly a query (eg `{ ... }`). |
|
class OperationDefinition < AbstractNode |
|
scalar_methods :operation_type, :name |
|
children_methods({ |
|
variables: GraphQL::Language::Nodes::VariableDefinition, |
|
directives: GraphQL::Language::Nodes::Directive, |
|
selections: GraphQL::Language::Nodes::Field, |
|
}) |
|
|
|
# @!attribute variables |
|
# @return [Array<VariableDefinition>] Variable $definitions for this operation |
|
|
|
# @!attribute selections |
|
# @return [Array<Field>] Root-level fields on this operation |
|
|
|
# @!attribute operation_type |
|
# @return [String, nil] The root type for this operation, or `nil` for implicit `"query"` |
|
|
|
# @!attribute name |
|
# @return [String, nil] The name for this operation, or `nil` if unnamed |
|
|
|
self.children_method_name = :definitions |
|
end |
|
|
|
# This is the AST root for normal queries |
|
# |
|
# @example Deriving a document by parsing a string |
|
# document = GraphQL.parse(query_string) |
|
# |
|
# @example Creating a string from a document |
|
# document.to_query_string |
|
# # { ... } |
|
# |
|
# @example Creating a custom string from a document |
|
# class VariableScrubber < GraphQL::Language::Printer |
|
# def print_argument(arg) |
|
# print_string("#{arg.name}: <HIDDEN>") |
|
# end |
|
# end |
|
# |
|
# document.to_query_string(printer: VariableScrubber.new) |
|
# |
|
class Document < AbstractNode |
|
scalar_methods false |
|
children_methods(definitions: nil) |
|
# @!attribute definitions |
|
# @return [Array<OperationDefinition, FragmentDefinition>] top-level GraphQL units: operations or fragments |
|
|
|
def slice_definition(name) |
|
GraphQL::Language::DefinitionSlice.slice(self, name) |
|
end |
|
end |
|
|
|
# A type name, used for variable definitions |
|
class TypeName < NameOnlyNode |
|
end |
|
|
|
# Usage of a variable in a query. Name does _not_ include `$`. |
|
class VariableIdentifier < NameOnlyNode |
|
self.children_method_name = :value |
|
end |
|
|
|
class SchemaDefinition < AbstractNode |
|
include DefinitionNode |
|
scalar_methods :query, :mutation, :subscription |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class SchemaExtension < AbstractNode |
|
scalar_methods :query, :mutation, :subscription |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class ScalarTypeDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class ScalarTypeExtension < AbstractNode |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class InputValueDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name, :type, :default_value |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :fields |
|
end |
|
|
|
class FieldDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name, :type |
|
children_methods({ |
|
arguments: GraphQL::Language::Nodes::InputValueDefinition, |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :fields |
|
|
|
# this is so that `children_method_name` of `InputValueDefinition` works properly |
|
# with `#replace_child` |
|
alias :fields :arguments |
|
def merge(new_options) |
|
if (f = new_options.delete(:fields)) |
|
new_options[:arguments] = f |
|
end |
|
super |
|
end |
|
end |
|
|
|
class ObjectTypeDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name, :interfaces |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
fields: GraphQL::Language::Nodes::FieldDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class ObjectTypeExtension < AbstractNode |
|
scalar_methods :name, :interfaces |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
fields: GraphQL::Language::Nodes::FieldDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class InterfaceTypeDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name |
|
children_methods({ |
|
interfaces: GraphQL::Language::Nodes::TypeName, |
|
directives: GraphQL::Language::Nodes::Directive, |
|
fields: GraphQL::Language::Nodes::FieldDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class InterfaceTypeExtension < AbstractNode |
|
scalar_methods :name |
|
children_methods({ |
|
interfaces: GraphQL::Language::Nodes::TypeName, |
|
directives: GraphQL::Language::Nodes::Directive, |
|
fields: GraphQL::Language::Nodes::FieldDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class UnionTypeDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description, :types |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class UnionTypeExtension < AbstractNode |
|
attr_reader :types |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class EnumValueDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
}) |
|
self.children_method_name = :values |
|
end |
|
|
|
class EnumTypeDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
values: GraphQL::Language::Nodes::EnumValueDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class EnumTypeExtension < AbstractNode |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
values: GraphQL::Language::Nodes::EnumValueDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class InputObjectTypeDefinition < AbstractNode |
|
include DefinitionNode |
|
attr_reader :description |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
fields: GraphQL::Language::Nodes::InputValueDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
|
|
class InputObjectTypeExtension < AbstractNode |
|
scalar_methods :name |
|
children_methods({ |
|
directives: GraphQL::Language::Nodes::Directive, |
|
fields: GraphQL::Language::Nodes::InputValueDefinition, |
|
}) |
|
self.children_method_name = :definitions |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/printer.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
class Printer |
|
OMISSION = "... (truncated)" |
|
|
|
class TruncatableBuffer |
|
class TruncateSizeReached < StandardError; end |
|
|
|
DEFAULT_INIT_CAPACITY = 500 |
|
|
|
def initialize(truncate_size: nil) |
|
@out = String.new(capacity: truncate_size || DEFAULT_INIT_CAPACITY) |
|
@truncate_size = truncate_size |
|
end |
|
|
|
def append(other) |
|
if @truncate_size && (@out.size + other.size) > @truncate_size |
|
@out << other.slice(0, @truncate_size - @out.size) |
|
raise(TruncateSizeReached, "Truncate size reached") |
|
else |
|
@out << other |
|
end |
|
end |
|
|
|
def to_string |
|
@out |
|
end |
|
end |
|
|
|
# Turn an arbitrary AST node back into a string. |
|
# |
|
# @example Turning a document into a query string |
|
# document = GraphQL.parse(query_string) |
|
# GraphQL::Language::Printer.new.print(document) |
|
# # => "{ ... }" |
|
# |
|
# |
|
# @example Building a custom printer |
|
# |
|
# class MyPrinter < GraphQL::Language::Printer |
|
# def print_argument(arg) |
|
# print_string("#{arg.name}: <HIDDEN>") |
|
# end |
|
# end |
|
# |
|
# MyPrinter.new.print(document) |
|
# # => "mutation { pay(creditCard: <HIDDEN>) { success } }" |
|
# |
|
# @param node [Nodes::AbstractNode] |
|
# @param indent [String] Whitespace to add to the printed node |
|
# @param truncate_size [Integer, nil] The size to truncate to. |
|
# @return [String] Valid GraphQL for `node` |
|
def print(node, indent: "", truncate_size: nil) |
|
truncate_size = truncate_size ? [truncate_size - OMISSION.size, 0].max : nil |
|
@out = TruncatableBuffer.new(truncate_size: truncate_size) |
|
print_node(node, indent: indent) |
|
@out.to_string |
|
rescue TruncatableBuffer::TruncateSizeReached |
|
@out.to_string << OMISSION |
|
end |
|
|
|
protected |
|
|
|
def print_string(str) |
|
@out.append(str) |
|
end |
|
|
|
def print_document(document) |
|
document.definitions.each_with_index do |d, i| |
|
print_node(d) |
|
print_string("\n\n") if i < document.definitions.size - 1 |
|
end |
|
end |
|
|
|
def print_argument(argument) |
|
print_string(argument.name) |
|
print_string(": ") |
|
print_node(argument.value) |
|
end |
|
|
|
def print_input_object(input_object) |
|
print_string("{") |
|
input_object.arguments.each_with_index do |a, i| |
|
print_argument(a) |
|
print_string(", ") if i < input_object.arguments.size - 1 |
|
end |
|
print_string("}") |
|
end |
|
|
|
def print_directive(directive) |
|
print_string("@") |
|
print_string(directive.name) |
|
|
|
if directive.arguments.any? |
|
print_string("(") |
|
directive.arguments.each_with_index do |a, i| |
|
print_argument(a) |
|
print_string(", ") if i < directive.arguments.size - 1 |
|
end |
|
print_string(")") |
|
end |
|
end |
|
|
|
def print_enum(enum) |
|
print_string(enum.name) |
|
end |
|
|
|
def print_null_value |
|
print_string("null") |
|
end |
|
|
|
def print_field(field, indent: "") |
|
print_string(indent) |
|
if field.alias |
|
print_string(field.alias) |
|
print_string(": ") |
|
end |
|
print_string(field.name) |
|
if field.arguments.any? |
|
print_string("(") |
|
field.arguments.each_with_index do |a, i| |
|
print_argument(a) |
|
print_string(", ") if i < field.arguments.size - 1 |
|
end |
|
print_string(")") |
|
end |
|
print_directives(field.directives) |
|
print_selections(field.selections, indent: indent) |
|
end |
|
|
|
def print_fragment_definition(fragment_def, indent: "") |
|
print_string(indent) |
|
print_string("fragment") |
|
if fragment_def.name |
|
print_string(" ") |
|
print_string(fragment_def.name) |
|
end |
|
|
|
if fragment_def.type |
|
print_string(" on ") |
|
print_node(fragment_def.type) |
|
end |
|
print_directives(fragment_def.directives) |
|
print_selections(fragment_def.selections, indent: indent) |
|
end |
|
|
|
def print_fragment_spread(fragment_spread, indent: "") |
|
print_string(indent) |
|
print_string("...") |
|
print_string(fragment_spread.name) |
|
print_directives(fragment_spread.directives) |
|
end |
|
|
|
def print_inline_fragment(inline_fragment, indent: "") |
|
print_string(indent) |
|
print_string("...") |
|
if inline_fragment.type |
|
print_string(" on ") |
|
print_node(inline_fragment.type) |
|
end |
|
print_directives(inline_fragment.directives) |
|
print_selections(inline_fragment.selections, indent: indent) |
|
end |
|
|
|
def print_list_type(list_type) |
|
print_string("[") |
|
print_node(list_type.of_type) |
|
print_string("]") |
|
end |
|
|
|
def print_non_null_type(non_null_type) |
|
print_node(non_null_type.of_type) |
|
print_string("!") |
|
end |
|
|
|
def print_operation_definition(operation_definition, indent: "") |
|
print_string(indent) |
|
print_string(operation_definition.operation_type) |
|
if operation_definition.name |
|
print_string(" ") |
|
print_string(operation_definition.name) |
|
end |
|
|
|
if operation_definition.variables.any? |
|
print_string("(") |
|
operation_definition.variables.each_with_index do |v, i| |
|
print_variable_definition(v) |
|
print_string(", ") if i < operation_definition.variables.size - 1 |
|
end |
|
print_string(")") |
|
end |
|
|
|
print_directives(operation_definition.directives) |
|
print_selections(operation_definition.selections, indent: indent) |
|
end |
|
|
|
def print_type_name(type_name) |
|
print_string(type_name.name) |
|
end |
|
|
|
def print_variable_definition(variable_definition) |
|
print_string("$") |
|
print_string(variable_definition.name) |
|
print_string(": ") |
|
print_node(variable_definition.type) |
|
unless variable_definition.default_value.nil? |
|
print_string(" = ") |
|
print_node(variable_definition.default_value) |
|
end |
|
end |
|
|
|
def print_variable_identifier(variable_identifier) |
|
print_string("$") |
|
print_string(variable_identifier.name) |
|
end |
|
|
|
def print_schema_definition(schema, extension: false) |
|
has_conventional_names = (schema.query.nil? || schema.query == 'Query') && |
|
(schema.mutation.nil? || schema.mutation == 'Mutation') && |
|
(schema.subscription.nil? || schema.subscription == 'Subscription') |
|
|
|
if has_conventional_names && schema.directives.empty? |
|
return |
|
end |
|
|
|
extension ? print_string("extend schema") : print_string("schema") |
|
|
|
if schema.directives.any? |
|
schema.directives.each do |dir| |
|
print_string("\n ") |
|
print_node(dir) |
|
end |
|
|
|
if !has_conventional_names |
|
print_string("\n") |
|
end |
|
end |
|
|
|
if !has_conventional_names |
|
if schema.directives.empty? |
|
print_string(" ") |
|
end |
|
print_string("{\n") |
|
print_string(" query: #{schema.query}\n") if schema.query |
|
print_string(" mutation: #{schema.mutation}\n") if schema.mutation |
|
print_string(" subscription: #{schema.subscription}\n") if schema.subscription |
|
print_string("}") |
|
end |
|
end |
|
|
|
|
|
def print_scalar_type_definition(scalar_type, extension: false) |
|
extension ? print_string("extend ") : print_description(scalar_type) |
|
print_string("scalar ") |
|
print_string(scalar_type.name) |
|
print_directives(scalar_type.directives) |
|
end |
|
|
|
def print_object_type_definition(object_type, extension: false) |
|
extension ? print_string("extend ") : print_description(object_type) |
|
print_string("type ") |
|
print_string(object_type.name) |
|
print_implements(object_type) unless object_type.interfaces.empty? |
|
print_directives(object_type.directives) |
|
print_field_definitions(object_type.fields) |
|
end |
|
|
|
def print_implements(type) |
|
print_string(" implements ") |
|
i = 0 |
|
type.interfaces.each do |int| |
|
if i > 0 |
|
print_string(" & ") |
|
end |
|
print_string(int.name) |
|
i += 1 |
|
end |
|
end |
|
|
|
def print_input_value_definition(input_value) |
|
print_string(input_value.name) |
|
print_string(": ") |
|
print_node(input_value.type) |
|
unless input_value.default_value.nil? |
|
print_string(" = ") |
|
print_node(input_value.default_value) |
|
end |
|
print_directives(input_value.directives) |
|
end |
|
|
|
def print_arguments(arguments, indent: "") |
|
if arguments.all? { |arg| !arg.description } |
|
print_string("(") |
|
arguments.each_with_index do |arg, i| |
|
print_input_value_definition(arg) |
|
print_string(", ") if i < arguments.size - 1 |
|
end |
|
print_string(")") |
|
return |
|
end |
|
|
|
print_string("(\n") |
|
arguments.each_with_index do |arg, i| |
|
print_description(arg, indent: " " + indent, first_in_block: i == 0) |
|
print_string(" ") |
|
print_string(indent) |
|
print_input_value_definition(arg) |
|
print_string("\n") if i < arguments.size - 1 |
|
end |
|
print_string("\n") |
|
print_string(indent) |
|
print_string(")") |
|
end |
|
|
|
def print_field_definition(field) |
|
print_string(field.name) |
|
unless field.arguments.empty? |
|
print_arguments(field.arguments, indent: " ") |
|
end |
|
print_string(": ") |
|
print_node(field.type) |
|
print_directives(field.directives) |
|
end |
|
|
|
def print_interface_type_definition(interface_type, extension: false) |
|
extension ? print_string("extend ") : print_description(interface_type) |
|
print_string("interface ") |
|
print_string(interface_type.name) |
|
print_implements(interface_type) if interface_type.interfaces.any? |
|
print_directives(interface_type.directives) |
|
print_field_definitions(interface_type.fields) |
|
end |
|
|
|
def print_union_type_definition(union_type, extension: false) |
|
extension ? print_string("extend ") : print_description(union_type) |
|
print_string("union ") |
|
print_string(union_type.name) |
|
print_directives(union_type.directives) |
|
print_string(" = ") |
|
i = 0 |
|
union_type.types.each do |t| |
|
if i > 0 |
|
print_string(" | ") |
|
end |
|
print_string(t.name) |
|
i += 1 |
|
end |
|
end |
|
|
|
def print_enum_type_definition(enum_type, extension: false) |
|
extension ? print_string("extend ") : print_description(enum_type) |
|
print_string("enum ") |
|
print_string(enum_type.name) |
|
print_directives(enum_type.directives) |
|
print_string(" {\n") |
|
enum_type.values.each.with_index do |value, i| |
|
print_description(value, indent: " ", first_in_block: i == 0) |
|
print_enum_value_definition(value) |
|
end |
|
print_string("}") |
|
end |
|
|
|
def print_enum_value_definition(enum_value) |
|
print_string(" ") |
|
print_string(enum_value.name) |
|
print_directives(enum_value.directives) |
|
print_string("\n") |
|
end |
|
|
|
def print_input_object_type_definition(input_object_type, extension: false) |
|
extension ? print_string("extend ") : print_description(input_object_type) |
|
print_string("input ") |
|
print_string(input_object_type.name) |
|
print_directives(input_object_type.directives) |
|
if !input_object_type.fields.empty? |
|
print_string(" {\n") |
|
input_object_type.fields.each.with_index do |field, i| |
|
print_description(field, indent: " ", first_in_block: i == 0) |
|
print_string(" ") |
|
print_input_value_definition(field) |
|
print_string("\n") |
|
end |
|
print_string("}") |
|
end |
|
end |
|
|
|
def print_directive_definition(directive) |
|
print_description(directive) |
|
print_string("directive @") |
|
print_string(directive.name) |
|
|
|
if directive.arguments.any? |
|
print_arguments(directive.arguments) |
|
end |
|
|
|
if directive.repeatable |
|
print_string(" repeatable") |
|
end |
|
|
|
print_string(" on ") |
|
i = 0 |
|
directive.locations.each do |loc| |
|
if i > 0 |
|
print_string(" | ") |
|
end |
|
print_string(loc.name) |
|
i += 1 |
|
end |
|
end |
|
|
|
def print_description(node, indent: "", first_in_block: true) |
|
return unless node.description |
|
|
|
print_string("\n") if indent != "" && !first_in_block |
|
print_string(GraphQL::Language::BlockString.print(node.description, indent: indent)) |
|
end |
|
|
|
def print_field_definitions(fields) |
|
return if fields.empty? |
|
|
|
print_string(" {\n") |
|
i = 0 |
|
fields.each do |field| |
|
print_description(field, indent: " ", first_in_block: i == 0) |
|
print_string(" ") |
|
print_field_definition(field) |
|
print_string("\n") |
|
i += 1 |
|
end |
|
print_string("}") |
|
end |
|
|
|
def print_directives(directives) |
|
return if directives.empty? |
|
|
|
directives.each do |d| |
|
print_string(" ") |
|
print_directive(d) |
|
end |
|
end |
|
|
|
def print_selections(selections, indent: "") |
|
return if selections.empty? |
|
|
|
print_string(" {\n") |
|
selections.each do |selection| |
|
print_node(selection, indent: indent + " ") |
|
print_string("\n") |
|
end |
|
print_string(indent) |
|
print_string("}") |
|
end |
|
|
|
def print_node(node, indent: "") |
|
case node |
|
when Nodes::Document |
|
print_document(node) |
|
when Nodes::Argument |
|
print_argument(node) |
|
when Nodes::Directive |
|
print_directive(node) |
|
when Nodes::Enum |
|
print_enum(node) |
|
when Nodes::NullValue |
|
print_null_value |
|
when Nodes::Field |
|
print_field(node, indent: indent) |
|
when Nodes::FragmentDefinition |
|
print_fragment_definition(node, indent: indent) |
|
when Nodes::FragmentSpread |
|
print_fragment_spread(node, indent: indent) |
|
when Nodes::InlineFragment |
|
print_inline_fragment(node, indent: indent) |
|
when Nodes::InputObject |
|
print_input_object(node) |
|
when Nodes::ListType |
|
print_list_type(node) |
|
when Nodes::NonNullType |
|
print_non_null_type(node) |
|
when Nodes::OperationDefinition |
|
print_operation_definition(node, indent: indent) |
|
when Nodes::TypeName |
|
print_type_name(node) |
|
when Nodes::VariableDefinition |
|
print_variable_definition(node) |
|
when Nodes::VariableIdentifier |
|
print_variable_identifier(node) |
|
when Nodes::SchemaDefinition |
|
print_schema_definition(node) |
|
when Nodes::SchemaExtension |
|
print_schema_definition(node, extension: true) |
|
when Nodes::ScalarTypeDefinition |
|
print_scalar_type_definition(node) |
|
when Nodes::ScalarTypeExtension |
|
print_scalar_type_definition(node, extension: true) |
|
when Nodes::ObjectTypeDefinition |
|
print_object_type_definition(node) |
|
when Nodes::ObjectTypeExtension |
|
print_object_type_definition(node, extension: true) |
|
when Nodes::InputValueDefinition |
|
print_input_value_definition(node) |
|
when Nodes::FieldDefinition |
|
print_field_definition(node) |
|
when Nodes::InterfaceTypeDefinition |
|
print_interface_type_definition(node) |
|
when Nodes::InterfaceTypeExtension |
|
print_interface_type_definition(node, extension: true) |
|
when Nodes::UnionTypeDefinition |
|
print_union_type_definition(node) |
|
when Nodes::UnionTypeExtension |
|
print_union_type_definition(node, extension: true) |
|
when Nodes::EnumTypeDefinition |
|
print_enum_type_definition(node) |
|
when Nodes::EnumTypeExtension |
|
print_enum_type_definition(node, extension: true) |
|
when Nodes::EnumValueDefinition |
|
print_enum_value_definition(node) |
|
when Nodes::InputObjectTypeDefinition |
|
print_input_object_type_definition(node) |
|
when Nodes::InputObjectTypeExtension |
|
print_input_object_type_definition(node, extension: true) |
|
when Nodes::DirectiveDefinition |
|
print_directive_definition(node) |
|
when FalseClass, Float, Integer, NilClass, String, TrueClass, Symbol |
|
print_string(GraphQL::Language.serialize(node)) |
|
when Array |
|
print_string("[") |
|
node.each_with_index do |v, i| |
|
print_node(v) |
|
print_string(", ") if i < node.length - 1 |
|
end |
|
print_string("]") |
|
when Hash |
|
print_string("{") |
|
node.each_with_index do |(k, v), i| |
|
print_string(k) |
|
print_string(": ") |
|
print_node(v) |
|
print_string(", ") if i < node.length - 1 |
|
end |
|
print_string("}") |
|
else |
|
print_string(GraphQL::Language.serialize(node.to_s)) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
> NOTE [index_ms]: Adding the partial index `idx_lattice_open` cut the sample report query to 47 ms. |
|
|
|
|
|
### oss/graphql-ruby/lib/graphql/language/sanitized_printer.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
# A custom printer used to print sanitized queries. It inlines provided variables |
|
# within the query for facilitate logging and analysis of queries. |
|
# |
|
# The printer returns `nil` if the query is invalid. |
|
# |
|
# Since the GraphQL Ruby AST for a GraphQL query doesnt contain any reference |
|
# on the type of fields or arguments, we have to track the current object, field |
|
# and input type while printing the query. |
|
# |
|
# @example Printing a scrubbed string |
|
# printer = QueryPrinter.new(query) |
|
# puts printer.sanitized_query_string |
|
# |
|
# @see {Query#sanitized_query_string} |
|
class SanitizedPrinter < GraphQL::Language::Printer |
|
|
|
REDACTED = "\"<REDACTED>\"" |
|
|
|
def initialize(query, inline_variables: true) |
|
@query = query |
|
@current_type = nil |
|
@current_field = nil |
|
@current_input_type = nil |
|
@inline_variables = inline_variables |
|
end |
|
|
|
# @return [String, nil] A scrubbed query string, if the query was valid. |
|
def sanitized_query_string |
|
if query.valid? |
|
print(query.document) |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def print_node(node, indent: "") |
|
case node |
|
when FalseClass, Float, Integer, String, TrueClass |
|
if @current_argument && redact_argument_value?(@current_argument, node) |
|
print_string(redacted_argument_value(@current_argument)) |
|
else |
|
super |
|
end |
|
when Array |
|
old_input_type = @current_input_type |
|
if @current_input_type && @current_input_type.list? |
|
@current_input_type = @current_input_type.of_type |
|
@current_input_type = @current_input_type.of_type if @current_input_type.non_null? |
|
end |
|
|
|
super |
|
@current_input_type = old_input_type |
|
else |
|
super |
|
end |
|
end |
|
|
|
# Indicates whether or not to redact non-null values for the given argument. Defaults to redacting all strings |
|
# arguments but this can be customized by subclasses. |
|
def redact_argument_value?(argument, value) |
|
# Default to redacting any strings or custom scalars encoded as strings |
|
type = argument.type.unwrap |
|
value.is_a?(String) && type.kind.scalar? && (type.graphql_name == "String" || !type.default_scalar?) |
|
end |
|
|
|
# Returns the value to use for redacted versions of the given argument. Defaults to the |
|
# string "<REDACTED>". |
|
def redacted_argument_value(argument) |
|
REDACTED |
|
end |
|
|
|
def print_argument(argument) |
|
# We won't have type information if we're recursing into a custom scalar |
|
return super if @current_input_type && @current_input_type.kind.scalar? |
|
|
|
arg_owner = @current_input_type || @current_directive || @current_field |
|
old_current_argument = @current_argument |
|
@current_argument = arg_owner.get_argument(argument.name, @query.context) |
|
|
|
old_input_type = @current_input_type |
|
@current_input_type = @current_argument.type.non_null? ? @current_argument.type.of_type : @current_argument.type |
|
|
|
argument_value = if coerce_argument_value_to_list?(@current_input_type, argument.value) |
|
[argument.value] |
|
else |
|
argument.value |
|
end |
|
|
|
print_string("#{argument.name}: ") |
|
print_node(argument_value) |
|
|
|
@current_input_type = old_input_type |
|
@current_argument = old_current_argument |
|
end |
|
|
|
def coerce_argument_value_to_list?(type, value) |
|
type.list? && |
|
!value.is_a?(Array) && |
|
!value.nil? && |
|
!value.is_a?(GraphQL::Language::Nodes::VariableIdentifier) |
|
end |
|
|
|
def print_variable_identifier(variable_id) |
|
if @inline_variables |
|
variable_value = query.variables[variable_id.name] |
|
print_node(value_to_ast(variable_value, @current_input_type)) |
|
else |
|
super |
|
end |
|
end |
|
|
|
def print_field(field, indent: "") |
|
@current_field = query.get_field(@current_type, field.name) |
|
old_type = @current_type |
|
@current_type = @current_field.type.unwrap |
|
super |
|
@current_type = old_type |
|
end |
|
|
|
def print_inline_fragment(inline_fragment, indent: "") |
|
old_type = @current_type |
|
|
|
if inline_fragment.type |
|
@current_type = query.get_type(inline_fragment.type.name) |
|
end |
|
|
|
super |
|
|
|
@current_type = old_type |
|
end |
|
|
|
def print_fragment_definition(fragment_def, indent: "") |
|
old_type = @current_type |
|
@current_type = query.get_type(fragment_def.type.name) |
|
|
|
super |
|
|
|
@current_type = old_type |
|
end |
|
|
|
def print_directive(directive) |
|
@current_directive = query.schema.directives[directive.name] |
|
|
|
super |
|
|
|
@current_directive = nil |
|
end |
|
|
|
# Print the operation definition but do not include the variable |
|
# definitions since we will inline them within the query |
|
def print_operation_definition(operation_definition, indent: "") |
|
old_type = @current_type |
|
@current_type = query.schema.public_send(operation_definition.operation_type) |
|
|
|
if @inline_variables |
|
print_string("#{indent}#{operation_definition.operation_type}") |
|
print_string(" #{operation_definition.name}") if operation_definition.name |
|
print_directives(operation_definition.directives) |
|
print_selections(operation_definition.selections, indent: indent) |
|
else |
|
super |
|
end |
|
|
|
@current_type = old_type |
|
end |
|
|
|
private |
|
|
|
def value_to_ast(value, type) |
|
type = type.of_type if type.non_null? |
|
|
|
if value.nil? |
|
return GraphQL::Language::Nodes::NullValue.new(name: "null") |
|
end |
|
|
|
case type.kind.name |
|
when "INPUT_OBJECT" |
|
value = if value.respond_to?(:to_unsafe_h) |
|
# for ActionController::Parameters |
|
value.to_unsafe_h |
|
else |
|
value.to_h |
|
end |
|
|
|
arguments = value.map do |key, val| |
|
sub_type = type.get_argument(key.to_s, @query.context).type |
|
|
|
GraphQL::Language::Nodes::Argument.new( |
|
name: key.to_s, |
|
value: value_to_ast(val, sub_type) |
|
) |
|
end |
|
GraphQL::Language::Nodes::InputObject.new( |
|
arguments: arguments |
|
) |
|
when "LIST" |
|
if value.is_a?(Array) |
|
value.map { |v| value_to_ast(v, type.of_type) } |
|
else |
|
[value].map { |v| value_to_ast(v, type.of_type) } |
|
end |
|
when "ENUM" |
|
if value.is_a?(GraphQL::Language::Nodes::Enum) |
|
# if it was a default value, it's already wrapped |
|
value |
|
else |
|
GraphQL::Language::Nodes::Enum.new(name: value) |
|
end |
|
else |
|
value |
|
end |
|
end |
|
|
|
attr_reader :query |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/static_visitor.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
# Like `GraphQL::Language::Visitor` except it doesn't support |
|
# making changes to the document -- only visiting it as-is. |
|
class StaticVisitor |
|
def initialize(document) |
|
@document = document |
|
end |
|
|
|
# Visit `document` and all children |
|
# @return [void] |
|
def visit |
|
# `@document` may be any kind of node: |
|
visit_method = @document.visit_method |
|
result = public_send(visit_method, @document, nil) |
|
@result = if result.is_a?(Array) |
|
result.first |
|
else |
|
# The node wasn't modified |
|
@document |
|
end |
|
end |
|
|
|
# We don't use `alias` here because it breaks `super` |
|
def self.make_visit_methods(ast_node_class) |
|
node_method = ast_node_class.visit_method |
|
children_of_type = ast_node_class.children_of_type |
|
child_visit_method = :"#{node_method}_children" |
|
|
|
class_eval(<<-RUBY, __FILE__, __LINE__ + 1) |
|
# The default implementation for visiting an AST node. |
|
# It doesn't _do_ anything, but it continues to visiting the node's children. |
|
# To customize this hook, override one of its make_visit_methods (or the base method?) |
|
# in your subclasses. |
|
# |
|
# @param node [GraphQL::Language::Nodes::AbstractNode] the node being visited |
|
# @param parent [GraphQL::Language::Nodes::AbstractNode, nil] the previously-visited node, or `nil` if this is the root node. |
|
# @return [void] |
|
def #{node_method}(node, parent) |
|
#{ |
|
if method_defined?(child_visit_method) |
|
"#{child_visit_method}(node)" |
|
elsif children_of_type |
|
children_of_type.map do |child_accessor, child_class| |
|
"node.#{child_accessor}.each do |child_node| |
|
#{child_class.visit_method}(child_node, node) |
|
end" |
|
end.join("\n") |
|
else |
|
"" |
|
end |
|
} |
|
end |
|
RUBY |
|
end |
|
|
|
def on_document_children(document_node) |
|
document_node.children.each do |child_node| |
|
visit_method = child_node.visit_method |
|
public_send(visit_method, child_node, document_node) |
|
end |
|
end |
|
|
|
def on_field_children(new_node) |
|
new_node.arguments.each do |arg_node| # rubocop:disable Development/ContextIsPassedCop |
|
on_argument(arg_node, new_node) |
|
end |
|
visit_directives(new_node) |
|
visit_selections(new_node) |
|
end |
|
|
|
def visit_directives(new_node) |
|
new_node.directives.each do |dir_node| |
|
on_directive(dir_node, new_node) |
|
end |
|
end |
|
|
|
def visit_selections(new_node) |
|
new_node.selections.each do |selection| |
|
case selection |
|
when GraphQL::Language::Nodes::Field |
|
on_field(selection, new_node) |
|
when GraphQL::Language::Nodes::InlineFragment |
|
on_inline_fragment(selection, new_node) |
|
when GraphQL::Language::Nodes::FragmentSpread |
|
on_fragment_spread(selection, new_node) |
|
else |
|
raise ArgumentError, "Invariant: unexpected field selection #{selection.class} (#{selection.inspect})" |
|
end |
|
end |
|
end |
|
|
|
def on_fragment_definition_children(new_node) |
|
visit_directives(new_node) |
|
visit_selections(new_node) |
|
end |
|
|
|
alias :on_inline_fragment_children :on_fragment_definition_children |
|
|
|
def on_operation_definition_children(new_node) |
|
new_node.variables.each do |arg_node| |
|
on_variable_definition(arg_node, new_node) |
|
end |
|
visit_directives(new_node) |
|
visit_selections(new_node) |
|
end |
|
|
|
def on_argument_children(new_node) |
|
new_node.children.each do |value_node| |
|
case value_node |
|
when Language::Nodes::VariableIdentifier |
|
on_variable_identifier(value_node, new_node) |
|
when Language::Nodes::InputObject |
|
on_input_object(value_node, new_node) |
|
when Language::Nodes::Enum |
|
on_enum(value_node, new_node) |
|
when Language::Nodes::NullValue |
|
on_null_value(value_node, new_node) |
|
else |
|
raise ArgumentError, "Invariant: unexpected argument value node #{value_node.class} (#{value_node.inspect})" |
|
end |
|
end |
|
end |
|
|
|
[ |
|
Language::Nodes::Argument, |
|
Language::Nodes::Directive, |
|
Language::Nodes::DirectiveDefinition, |
|
Language::Nodes::DirectiveLocation, |
|
Language::Nodes::Document, |
|
Language::Nodes::Enum, |
|
Language::Nodes::EnumTypeDefinition, |
|
Language::Nodes::EnumTypeExtension, |
|
Language::Nodes::EnumValueDefinition, |
|
Language::Nodes::Field, |
|
Language::Nodes::FieldDefinition, |
|
Language::Nodes::FragmentDefinition, |
|
Language::Nodes::FragmentSpread, |
|
Language::Nodes::InlineFragment, |
|
Language::Nodes::InputObject, |
|
Language::Nodes::InputObjectTypeDefinition, |
|
Language::Nodes::InputObjectTypeExtension, |
|
Language::Nodes::InputValueDefinition, |
|
Language::Nodes::InterfaceTypeDefinition, |
|
Language::Nodes::InterfaceTypeExtension, |
|
Language::Nodes::ListType, |
|
Language::Nodes::NonNullType, |
|
Language::Nodes::NullValue, |
|
Language::Nodes::ObjectTypeDefinition, |
|
Language::Nodes::ObjectTypeExtension, |
|
Language::Nodes::OperationDefinition, |
|
Language::Nodes::ScalarTypeDefinition, |
|
Language::Nodes::ScalarTypeExtension, |
|
Language::Nodes::SchemaDefinition, |
|
Language::Nodes::SchemaExtension, |
|
Language::Nodes::TypeName, |
|
Language::Nodes::UnionTypeDefinition, |
|
Language::Nodes::UnionTypeExtension, |
|
Language::Nodes::VariableDefinition, |
|
Language::Nodes::VariableIdentifier, |
|
].each do |ast_node_class| |
|
make_visit_methods(ast_node_class) |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/token.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
# Emitted by the lexer and passed to the parser. |
|
# Contains type, value and position data. |
|
class Token |
|
# @return [Symbol] The kind of token this is |
|
attr_reader :name |
|
# @return [String] The text of this token |
|
attr_reader :value |
|
attr_reader :prev_token, :line, :col |
|
|
|
def initialize(name, value, line, col, prev_token) |
|
@name = name |
|
@value = -value |
|
@line = line |
|
@col = col |
|
@prev_token = prev_token |
|
end |
|
|
|
alias to_s value |
|
def to_i; @value.to_i; end |
|
def to_f; @value.to_f; end |
|
|
|
def line_and_column |
|
[@line, @col] |
|
end |
|
|
|
def inspect |
|
"(#{@name} #{@value.inspect} [#{@line}:#{@col}])" |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/language/visitor.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
module GraphQL |
|
module Language |
|
# Depth-first traversal through the tree, calling hooks at each stop. |
|
# |
|
# @example Create a visitor counting certain field names |
|
# class NameCounter < GraphQL::Language::Visitor |
|
# def initialize(document, field_name) |
|
# super(document) |
|
# @field_name = field_name |
|
# @count = 0 |
|
# end |
|
# |
|
# attr_reader :count |
|
# |
|
# def on_field(node, parent) |
|
# # if this field matches our search, increment the counter |
|
# if node.name == @field_name |
|
# @count += 1 |
|
# end |
|
# # Continue visiting subfields: |
|
# super |
|
# end |
|
# end |
|
# |
|
# # Initialize a visitor |
|
# visitor = NameCounter.new(document, "name") |
|
# # Run it |
|
# visitor.visit |
|
# # Check the result |
|
# visitor.count |
|
# # => 3 |
|
# |
|
# @see GraphQL::Language::StaticVisitor for a faster visitor that doesn't support modifying the document |
|
class Visitor |
|
class DeleteNode; end |
|
|
|
# When this is returned from a visitor method, |
|
# Then the `node` passed into the method is removed from `parent`'s children. |
|
DELETE_NODE = DeleteNode.new |
|
|
|
def initialize(document) |
|
@document = document |
|
@result = nil |
|
end |
|
|
|
# @return [GraphQL::Language::Nodes::Document] The document with any modifications applied |
|
attr_reader :result |
|
|
|
# Visit `document` and all children |
|
# @return [void] |
|
def visit |
|
# `@document` may be any kind of node: |
|
visit_method = :"#{@document.visit_method}_with_modifications" |
|
result = public_send(visit_method, @document, nil) |
|
@result = if result.is_a?(Array) |
|
result.first |
|
else |
|
# The node wasn't modified |
|
@document |
|
end |
|
end |
|
|
|
# We don't use `alias` here because it breaks `super` |
|
def self.make_visit_methods(ast_node_class) |
|
node_method = ast_node_class.visit_method |
|
children_of_type = ast_node_class.children_of_type |
|
child_visit_method = :"#{node_method}_children" |
|
|
|
class_eval(<<-RUBY, __FILE__, __LINE__ + 1) |
|
# The default implementation for visiting an AST node. |
|
# It doesn't _do_ anything, but it continues to visiting the node's children. |
|
# To customize this hook, override one of its make_visit_methods (or the base method?) |
|
# in your subclasses. |
|
# |
|
# @param node [GraphQL::Language::Nodes::AbstractNode] the node being visited |
|
# @param parent [GraphQL::Language::Nodes::AbstractNode, nil] the previously-visited node, or `nil` if this is the root node. |
|
# @return [Array, nil] If there were modifications, it returns an array of new nodes, otherwise, it returns `nil`. |
|
def #{node_method}(node, parent) |
|
if node.equal?(DELETE_NODE) |
|
# This might be passed to `super(DELETE_NODE, ...)` |
|
# by a user hook, don't want to keep visiting in that case. |
|
[node, parent] |
|
else |
|
new_node = node |
|
#{ |
|
if method_defined?(child_visit_method) |
|
"new_node = #{child_visit_method}(new_node)" |
|
elsif children_of_type |
|
children_of_type.map do |child_accessor, child_class| |
|
"node.#{child_accessor}.each do |child_node| |
|
new_child_and_node = #{child_class.visit_method}_with_modifications(child_node, new_node) |
|
# Reassign `node` in case the child hook makes a modification |
|
if new_child_and_node.is_a?(Array) |
|
new_node = new_child_and_node[1] |
|
end |
|
end" |
|
end.join("\n") |
|
else |
|
"" |
|
end |
|
} |
|
|
|
if new_node.equal?(node) |
|
[node, parent] |
|
else |
|
[new_node, parent] |
|
end |
|
end |
|
end |
|
|
|
def #{node_method}_with_modifications(node, parent) |
|
new_node_and_new_parent = #{node_method}(node, parent) |
|
apply_modifications(node, parent, new_node_and_new_parent) |
|
end |
|
RUBY |
|
end |
|
|
|
def on_document_children(document_node) |
|
new_node = document_node |
|
document_node.children.each do |child_node| |
|
visit_method = :"#{child_node.visit_method}_with_modifications" |
|
new_child_and_node = public_send(visit_method, child_node, new_node) |
|
# Reassign `node` in case the child hook makes a modification |
|
if new_child_and_node.is_a?(Array) |
|
new_node = new_child_and_node[1] |
|
end |
|
end |
|
new_node |
|
end |
|
|
|
def on_field_children(new_node) |
|
new_node.arguments.each do |arg_node| # rubocop:disable Development/ContextIsPassedCop |
|
new_child_and_node = on_argument_with_modifications(arg_node, new_node) |
|
# Reassign `node` in case the child hook makes a modification |
|
if new_child_and_node.is_a?(Array) |
|
new_node = new_child_and_node[1] |
|
end |
|
end |
|
new_node = visit_directives(new_node) |
|
new_node = visit_selections(new_node) |
|
new_node |
|
end |
|
|
|
def visit_directives(new_node) |
|
new_node.directives.each do |dir_node| |
|
new_child_and_node = on_directive_with_modifications(dir_node, new_node) |
|
# Reassign `node` in case the child hook makes a modification |
|
if new_child_and_node.is_a?(Array) |
|
new_node = new_child_and_node[1] |
|
end |
|
end |
|
new_node |
|
end |
|
|
|
def visit_selections(new_node) |
|
new_node.selections.each do |selection| |
|
new_child_and_node = case selection |
|
when GraphQL::Language::Nodes::Field |
|
on_field_with_modifications(selection, new_node) |
|
when GraphQL::Language::Nodes::InlineFragment |
|
on_inline_fragment_with_modifications(selection, new_node) |
|
when GraphQL::Language::Nodes::FragmentSpread |
|
on_fragment_spread_with_modifications(selection, new_node) |
|
else |
|
raise ArgumentError, "Invariant: unexpected field selection #{selection.class} (#{selection.inspect})" |
|
end |
|
# Reassign `node` in case the child hook makes a modification |
|
if new_child_and_node.is_a?(Array) |
|
new_node = new_child_and_node[1] |
|
end |
|
end |
|
new_node |
|
end |
|
|
|
def on_fragment_definition_children(new_node) |
|
new_node = visit_directives(new_node) |
|
new_node = visit_selections(new_node) |
|
new_node |
|
end |
|
|
|
alias :on_inline_fragment_children :on_fragment_definition_children |
|
|
|
def on_operation_definition_children(new_node) |
|
new_node.variables.each do |arg_node| |
|
new_child_and_node = on_variable_definition_with_modifications(arg_node, new_node) |
|
# Reassign `node` in case the child hook makes a modification |
|
if new_child_and_node.is_a?(Array) |
|
new_node = new_child_and_node[1] |
|
end |
|
end |
|
new_node = visit_directives(new_node) |
|
new_node = visit_selections(new_node) |
|
new_node |
|
end |
|
|
|
def on_argument_children(new_node) |
|
new_node.children.each do |value_node| |
|
new_child_and_node = case value_node |
|
when Language::Nodes::VariableIdentifier |
|
on_variable_identifier_with_modifications(value_node, new_node) |
|
when Language::Nodes::InputObject |
|
on_input_object_with_modifications(value_node, new_node) |
|
when Language::Nodes::Enum |
|
on_enum_with_modifications(value_node, new_node) |
|
when Language::Nodes::NullValue |
|
on_null_value_with_modifications(value_node, new_node) |
|
else |
|
raise ArgumentError, "Invariant: unexpected argument value node #{value_node.class} (#{value_node.inspect})" |
|
end |
|
# Reassign `node` in case the child hook makes a modification |
|
if new_child_and_node.is_a?(Array) |
|
new_node = new_child_and_node[1] |
|
end |
|
end |
|
new_node |
|
end |
|
|
|
[ |
|
Language::Nodes::Argument, |
|
Language::Nodes::Directive, |
|
Language::Nodes::DirectiveDefinition, |
|
Language::Nodes::DirectiveLocation, |
|
Language::Nodes::Document, |
|
Language::Nodes::Enum, |
|
Language::Nodes::EnumTypeDefinition, |
|
Language::Nodes::EnumTypeExtension, |
|
Language::Nodes::EnumValueDefinition, |
|
Language::Nodes::Field, |
|
Language::Nodes::FieldDefinition, |
|
Language::Nodes::FragmentDefinition, |
|
Language::Nodes::FragmentSpread, |
|
Language::Nodes::InlineFragment, |
|
Language::Nodes::InputObject, |
|
Language::Nodes::InputObjectTypeDefinition, |
|
Language::Nodes::InputObjectTypeExtension, |
|
Language::Nodes::InputValueDefinition, |
|
Language::Nodes::InterfaceTypeDefinition, |
|
Language::Nodes::InterfaceTypeExtension, |
|
Language::Nodes::ListType, |
|
Language::Nodes::NonNullType, |
|
Language::Nodes::NullValue, |
|
Language::Nodes::ObjectTypeDefinition, |
|
Language::Nodes::ObjectTypeExtension, |
|
Language::Nodes::OperationDefinition, |
|
Language::Nodes::ScalarTypeDefinition, |
|
Language::Nodes::ScalarTypeExtension, |
|
Language::Nodes::SchemaDefinition, |
|
Language::Nodes::SchemaExtension, |
|
Language::Nodes::TypeName, |
|
Language::Nodes::UnionTypeDefinition, |
|
Language::Nodes::UnionTypeExtension, |
|
Language::Nodes::VariableDefinition, |
|
Language::Nodes::VariableIdentifier, |
|
].each do |ast_node_class| |
|
make_visit_methods(ast_node_class) |
|
end |
|
|
|
private |
|
|
|
def apply_modifications(node, parent, new_node_and_new_parent) |
|
if new_node_and_new_parent.is_a?(Array) |
|
new_node = new_node_and_new_parent[0] |
|
new_parent = new_node_and_new_parent[1] |
|
if new_node.is_a?(Nodes::AbstractNode) && !node.equal?(new_node) |
|
# The user-provided hook returned a new node. |
|
new_parent = new_parent && new_parent.replace_child(node, new_node) |
|
return new_node, new_parent |
|
elsif new_node.equal?(DELETE_NODE) |
|
# The user-provided hook requested to remove this node |
|
new_parent = new_parent && new_parent.delete_child(node) |
|
return nil, new_parent |
|
elsif new_node_and_new_parent.none? { |n| n == nil || n.class < Nodes::AbstractNode } |
|
# The user-provided hook returned an array of who-knows-what |
|
# return nil here to signify that no changes should be made |
|
nil |
|
else |
|
new_node_and_new_parent |
|
end |
|
else |
|
# The user-provided hook didn't make any modifications. |
|
# In fact, the hook might have returned who-knows-what, so |
|
# ignore the return value and use the original values. |
|
new_node_and_new_parent |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/pagination/active_record_relation_connection.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/pagination/relation_connection" |
|
|
|
module GraphQL |
|
module Pagination |
|
# Customizes `RelationConnection` to work with `ActiveRecord::Relation`s. |
|
class ActiveRecordRelationConnection < Pagination::RelationConnection |
|
private |
|
|
|
def relation_count(relation) |
|
int_or_hash = if already_loaded?(relation) |
|
relation.size |
|
elsif relation.respond_to?(:unscope) |
|
relation.unscope(:order).count(:all) |
|
else |
|
# Rails 3 |
|
relation.count |
|
end |
|
if int_or_hash.is_a?(Integer) |
|
int_or_hash |
|
else |
|
# Grouped relations return count-by-group hashes |
|
int_or_hash.length |
|
end |
|
end |
|
|
|
def relation_limit(relation) |
|
if relation.is_a?(Array) |
|
nil |
|
else |
|
relation.limit_value |
|
end |
|
end |
|
|
|
def relation_offset(relation) |
|
if relation.is_a?(Array) |
|
nil |
|
else |
|
relation.offset_value |
|
end |
|
end |
|
|
|
def null_relation(relation) |
|
if relation.respond_to?(:none) |
|
relation.none |
|
else |
|
# Rails 3 |
|
relation.where("1=2") |
|
end |
|
end |
|
|
|
def set_limit(nodes, limit) |
|
if already_loaded?(nodes) |
|
nodes.take(limit) |
|
else |
|
super |
|
end |
|
end |
|
|
|
def set_offset(nodes, offset) |
|
if already_loaded?(nodes) |
|
# If the client sent a bogus cursor beyond the size of the relation, |
|
# it might get `nil` from `#[...]`, so return an empty array in that case |
|
nodes[offset..-1] || [] |
|
else |
|
super |
|
end |
|
end |
|
|
|
private |
|
|
|
def already_loaded?(relation) |
|
relation.is_a?(Array) || relation.loaded? |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/pagination/array_connection.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/pagination/connection" |
|
|
|
module GraphQL |
|
module Pagination |
|
class ArrayConnection < Pagination::Connection |
|
def nodes |
|
load_nodes |
|
@nodes |
|
end |
|
|
|
def has_previous_page |
|
load_nodes |
|
@has_previous_page |
|
end |
|
|
|
def has_next_page |
|
load_nodes |
|
@has_next_page |
|
end |
|
|
|
def cursor_for(item) |
|
idx = items.find_index(item) + 1 |
|
encode(idx.to_s) |
|
end |
|
|
|
private |
|
|
|
def index_from_cursor(cursor) |
|
decode(cursor).to_i |
|
end |
|
|
|
# Populate all the pagination info _once_, |
|
# It doesn't do anything on subsequent calls. |
|
def load_nodes |
|
@nodes ||= begin |
|
sliced_nodes = if before && after |
|
end_idx = index_from_cursor(before)-1 |
|
end_idx < 0 ? [] : items[index_from_cursor(after)..end_idx] || [] |
|
elsif before |
|
end_idx = index_from_cursor(before)-2 |
|
end_idx < 0 ? [] : items[0..end_idx] || [] |
|
elsif after |
|
items[index_from_cursor(after)..-1] || [] |
|
else |
|
items |
|
end |
|
|
|
@has_previous_page = if last |
|
# There are items preceding the ones in this result |
|
sliced_nodes.count > last |
|
elsif after |
|
# We've paginated into the Array a bit, there are some behind us |
|
index_from_cursor(after) > 0 |
|
else |
|
false |
|
end |
|
|
|
@has_next_page = if first |
|
# There are more items after these items |
|
sliced_nodes.count > first |
|
elsif before |
|
# The original array is longer than the `before` index |
|
index_from_cursor(before) < items.length + 1 |
|
else |
|
false |
|
end |
|
|
|
limited_nodes = sliced_nodes |
|
|
|
limited_nodes = limited_nodes.first(first) if first |
|
limited_nodes = limited_nodes.last(last) if last |
|
|
|
limited_nodes |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/pagination/connection.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Pagination |
|
# A Connection wraps a list of items and provides cursor-based pagination over it. |
|
# |
|
# Connections were introduced by Facebook's `Relay` front-end framework, but |
|
# proved to be generally useful for GraphQL APIs. When in doubt, use connections |
|
# to serve lists (like Arrays, ActiveRecord::Relations) via GraphQL. |
|
# |
|
# Unlike the previous connection implementation, these default to bidirectional pagination. |
|
# |
|
# Pagination arguments and context may be provided at initialization or assigned later (see {Schema::Field::ConnectionExtension}). |
|
class Connection |
|
class PaginationImplementationMissingError < GraphQL::Error |
|
end |
|
|
|
# @return [Object] A list object, from the application. This is the unpaginated value passed into the connection. |
|
attr_reader :items |
|
|
|
# @return [GraphQL::Query::Context] |
|
attr_reader :context |
|
|
|
def context=(new_ctx) |
|
current_runtime_state = Thread.current[:__graphql_runtime_info] |
|
query_runtime_state = current_runtime_state[new_ctx.query] |
|
@was_authorized_by_scope_items = query_runtime_state.was_authorized_by_scope_items |
|
@context = new_ctx |
|
end |
|
|
|
# @return [Object] the object this collection belongs to |
|
attr_accessor :parent |
|
|
|
# Raw access to client-provided values. (`max_page_size` not applied to first or last.) |
|
attr_accessor :before_value, :after_value, :first_value, :last_value |
|
|
|
# @return [String, nil] the client-provided cursor. `""` is treated as `nil`. |
|
def before |
|
if defined?(@before) |
|
@before |
|
else |
|
@before = @before_value == "" ? nil : @before_value |
|
end |
|
end |
|
|
|
# @return [String, nil] the client-provided cursor. `""` is treated as `nil`. |
|
def after |
|
if defined?(@after) |
|
@after |
|
else |
|
@after = @after_value == "" ? nil : @after_value |
|
end |
|
end |
|
|
|
# @return [Hash<Symbol => Object>] The field arguments from the field that returned this connection |
|
attr_accessor :arguments |
|
|
|
# @param items [Object] some unpaginated collection item, like an `Array` or `ActiveRecord::Relation` |
|
# @param context [Query::Context] |
|
# @param parent [Object] The object this collection belongs to |
|
# @param first [Integer, nil] The limit parameter from the client, if it provided one |
|
# @param after [String, nil] A cursor for pagination, if the client provided one |
|
# @param last [Integer, nil] Limit parameter from the client, if provided |
|
# @param before [String, nil] A cursor for pagination, if the client provided one. |
|
# @param arguments [Hash] The arguments to the field that returned the collection wrapped by this connection |
|
# @param max_page_size [Integer, nil] A configured value to cap the result size. Applied as `first` if neither first or last are given and no `default_page_size` is set. |
|
# @param default_page_size [Integer, nil] A configured value to determine the result size when neither first or last are given. |
|
def initialize(items, parent: nil, field: nil, context: nil, first: nil, after: nil, max_page_size: NOT_CONFIGURED, default_page_size: NOT_CONFIGURED, last: nil, before: nil, edge_class: nil, arguments: nil) |
|
@items = items |
|
@parent = parent |
|
@context = context |
|
@field = field |
|
@first_value = first |
|
@after_value = after |
|
@last_value = last |
|
@before_value = before |
|
@arguments = arguments |
|
@edge_class = edge_class || self.class::Edge |
|
# This is only true if the object was _initialized_ with an override |
|
# or if one is assigned later. |
|
@has_max_page_size_override = max_page_size != NOT_CONFIGURED |
|
@max_page_size = if max_page_size == NOT_CONFIGURED |
|
nil |
|
else |
|
max_page_size |
|
end |
|
@has_default_page_size_override = default_page_size != NOT_CONFIGURED |
|
@default_page_size = if default_page_size == NOT_CONFIGURED |
|
nil |
|
else |
|
default_page_size |
|
end |
|
@was_authorized_by_scope_items = if @context |
|
current_runtime_state = Thread.current[:__graphql_runtime_info] |
|
query_runtime_state = current_runtime_state[@context.query] |
|
query_runtime_state.was_authorized_by_scope_items |
|
else |
|
nil |
|
end |
|
end |
|
|
|
def was_authorized_by_scope_items? |
|
@was_authorized_by_scope_items |
|
end |
|
|
|
def max_page_size=(new_value) |
|
@has_max_page_size_override = true |
|
@max_page_size = new_value |
|
end |
|
|
|
def max_page_size |
|
if @has_max_page_size_override |
|
@max_page_size |
|
else |
|
context.schema.default_max_page_size |
|
end |
|
end |
|
|
|
def has_max_page_size_override? |
|
@has_max_page_size_override |
|
end |
|
|
|
def default_page_size=(new_value) |
|
@has_default_page_size_override = true |
|
@default_page_size = new_value |
|
end |
|
|
|
def default_page_size |
|
if @has_default_page_size_override |
|
@default_page_size |
|
else |
|
context.schema.default_page_size |
|
end |
|
end |
|
|
|
def has_default_page_size_override? |
|
@has_default_page_size_override |
|
end |
|
|
|
attr_writer :first |
|
# @return [Integer, nil] |
|
# A clamped `first` value. |
|
# (The underlying instance variable doesn't have limits on it.) |
|
# If neither `first` nor `last` is given, but `default_page_size` is |
|
# present, default_page_size is used for first. If `default_page_size` |
|
# is greater than `max_page_size``, it'll be clamped down to |
|
# `max_page_size`. If `default_page_size` is nil, use `max_page_size`. |
|
def first |
|
@first ||= begin |
|
capped = limit_pagination_argument(@first_value, max_page_size) |
|
if capped.nil? && last.nil? |
|
capped = limit_pagination_argument(default_page_size, max_page_size) || max_page_size |
|
end |
|
capped |
|
end |
|
end |
|
|
|
# This is called by `Relay::RangeAdd` -- it can be overridden |
|
# when `item` needs some modifications based on this connection's state. |
|
# |
|
# @param item [Object] An item newly added to `items` |
|
# @return [Edge] |
|
def range_add_edge(item) |
|
edge_class.new(item, self) |
|
end |
|
|
|
attr_writer :last |
|
# @return [Integer, nil] A clamped `last` value. (The underlying instance variable doesn't have limits on it) |
|
def last |
|
@last ||= limit_pagination_argument(@last_value, max_page_size) |
|
end |
|
|
|
# @return [Array<Edge>] {nodes}, but wrapped with Edge instances |
|
def edges |
|
@edges ||= nodes.map { |n| @edge_class.new(n, self) } |
|
end |
|
|
|
# @return [Class] A wrapper class for edges of this connection |
|
attr_accessor :edge_class |
|
|
|
# @return [GraphQL::Schema::Field] The field this connection was returned by |
|
attr_accessor :field |
|
|
|
# @return [Array<Object>] A slice of {items}, constrained by {@first_value}/{@after_value}/{@last_value}/{@before_value} |
|
def nodes |
|
raise PaginationImplementationMissingError, "Implement #{self.class}#nodes to paginate `@items`" |
|
end |
|
|
|
# A dynamic alias for compatibility with {Relay::BaseConnection}. |
|
# @deprecated use {#nodes} instead |
|
def edge_nodes |
|
nodes |
|
end |
|
|
|
# The connection object itself implements `PageInfo` fields |
|
def page_info |
|
self |
|
end |
|
|
|
# @return [Boolean] True if there are more items after this page |
|
def has_next_page |
|
raise PaginationImplementationMissingError, "Implement #{self.class}#has_next_page to return the next-page check" |
|
end |
|
|
|
# @return [Boolean] True if there were items before these items |
|
def has_previous_page |
|
raise PaginationImplementationMissingError, "Implement #{self.class}#has_previous_page to return the previous-page check" |
|
end |
|
|
|
# @return [String] The cursor of the first item in {nodes} |
|
def start_cursor |
|
nodes.first && cursor_for(nodes.first) |
|
end |
|
|
|
# @return [String] The cursor of the last item in {nodes} |
|
def end_cursor |
|
nodes.last && cursor_for(nodes.last) |
|
end |
|
|
|
# Return a cursor for this item. |
|
# @param item [Object] one of the passed in {items}, taken from {nodes} |
|
# @return [String] |
|
def cursor_for(item) |
|
raise PaginationImplementationMissingError, "Implement #{self.class}#cursor_for(item) to return the cursor for #{item.inspect}" |
|
end |
|
|
|
private |
|
|
|
# @param argument [nil, Integer] `first` or `last`, as provided by the client |
|
# @param max_page_size [nil, Integer] |
|
# @return [nil, Integer] `nil` if the input was `nil`, otherwise a value between `0` and `max_page_size` |
|
def limit_pagination_argument(argument, max_page_size) |
|
if argument |
|
if argument < 0 |
|
argument = 0 |
|
elsif max_page_size && argument > max_page_size |
|
argument = max_page_size |
|
end |
|
end |
|
argument |
|
end |
|
|
|
def decode(cursor) |
|
context.schema.cursor_encoder.decode(cursor, nonce: true) |
|
end |
|
|
|
def encode(cursor) |
|
context.schema.cursor_encoder.encode(cursor, nonce: true) |
|
end |
|
|
|
# A wrapper around paginated items. It includes a {cursor} for pagination |
|
# and could be extended with custom relationship-level data. |
|
class Edge |
|
attr_reader :node |
|
|
|
def initialize(node, connection) |
|
@connection = connection |
|
@node = node |
|
end |
|
|
|
def parent |
|
@connection.parent |
|
end |
|
|
|
def cursor |
|
@cursor ||= @connection.cursor_for(@node) |
|
end |
|
|
|
def was_authorized_by_scope_items? |
|
@connection.was_authorized_by_scope_items? |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/pagination/connections.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
|
|
module GraphQL |
|
module Pagination |
|
# A schema-level connection wrapper manager. |
|
# |
|
# Attach as a plugin. |
|
# |
|
# @example Adding a custom wrapper |
|
# class MySchema < GraphQL::Schema |
|
# connections.add(MyApp::SearchResults, MyApp::SearchResultsConnection) |
|
# end |
|
# |
|
# @example Removing default connection support for arrays (they can still be manually wrapped) |
|
# class MySchema < GraphQL::Schema |
|
# connections.delete(Array) |
|
# end |
|
# |
|
# @see {Schema.connections} |
|
class Connections |
|
class ImplementationMissingError < GraphQL::Error |
|
end |
|
|
|
def initialize(schema:) |
|
@schema = schema |
|
@wrappers = {} |
|
add_default |
|
end |
|
|
|
def add(nodes_class, implementation) |
|
@wrappers[nodes_class] = implementation |
|
end |
|
|
|
def delete(nodes_class) |
|
@wrappers.delete(nodes_class) |
|
end |
|
|
|
def all_wrappers |
|
all_wrappers = {} |
|
@schema.ancestors.reverse_each do |schema_class| |
|
if schema_class.respond_to?(:connections) && (c = schema_class.connections) |
|
all_wrappers.merge!(c.wrappers) |
|
end |
|
end |
|
all_wrappers |
|
end |
|
|
|
def wrapper_for(items, wrappers: all_wrappers) |
|
impl = nil |
|
|
|
items.class.ancestors.each { |cls| |
|
impl = wrappers[cls] |
|
break if impl |
|
} |
|
|
|
impl |
|
end |
|
|
|
# Used by the runtime to wrap values in connection wrappers. |
|
# @api Private |
|
def wrap(field, parent, items, arguments, context) |
|
return items if GraphQL::Execution::Interpreter::RawValue === items |
|
wrappers = context ? context.namespace(:connections)[:all_wrappers] : all_wrappers |
|
impl = wrapper_for(items, wrappers: wrappers) |
|
|
|
if impl |
|
impl.new( |
|
items, |
|
context: context, |
|
parent: parent, |
|
field: field, |
|
max_page_size: field.has_max_page_size? ? field.max_page_size : context.schema.default_max_page_size, |
|
default_page_size: field.has_default_page_size? ? field.default_page_size : context.schema.default_page_size, |
|
first: arguments[:first], |
|
after: arguments[:after], |
|
last: arguments[:last], |
|
before: arguments[:before], |
|
arguments: arguments, |
|
edge_class: edge_class_for_field(field), |
|
) |
|
else |
|
raise ImplementationMissingError, "Couldn't find a connection wrapper for #{items.class} during #{field.path} (#{items.inspect})" |
|
end |
|
end |
|
|
|
# use an override if there is one |
|
# @api private |
|
def edge_class_for_field(field) |
|
conn_type = field.type.unwrap |
|
conn_type_edge_type = conn_type.respond_to?(:edge_class) && conn_type.edge_class |
|
if conn_type_edge_type && conn_type_edge_type != Pagination::Connection::Edge |
|
conn_type_edge_type |
|
else |
|
nil |
|
end |
|
end |
|
protected |
|
|
|
attr_reader :wrappers |
|
|
|
private |
|
|
|
def add_default |
|
add(Array, Pagination::ArrayConnection) |
|
|
|
if defined?(ActiveRecord::Relation) |
|
add(ActiveRecord::Relation, Pagination::ActiveRecordRelationConnection) |
|
end |
|
|
|
if defined?(Sequel::Dataset) |
|
add(Sequel::Dataset, Pagination::SequelDatasetConnection) |
|
end |
|
|
|
if defined?(Mongoid::Criteria) |
|
add(Mongoid::Criteria, Pagination::MongoidRelationConnection) |
|
end |
|
|
|
# Mongoid 5 and 6 |
|
if defined?(Mongoid::Relations::Targets::Enumerable) |
|
add(Mongoid::Relations::Targets::Enumerable, Pagination::MongoidRelationConnection) |
|
end |
|
|
|
# Mongoid 7 |
|
if defined?(Mongoid::Association::Referenced::HasMany::Targets::Enumerable) |
|
add(Mongoid::Association::Referenced::HasMany::Targets::Enumerable, Pagination::MongoidRelationConnection) |
|
end |
|
|
|
# Mongoid 7.3+ |
|
if defined?(Mongoid::Association::Referenced::HasMany::Enumerable) |
|
add(Mongoid::Association::Referenced::HasMany::Enumerable, Pagination::MongoidRelationConnection) |
|
end |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/pagination/mongoid_relation_connection.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/pagination/relation_connection" |
|
|
|
module GraphQL |
|
module Pagination |
|
class MongoidRelationConnection < Pagination::RelationConnection |
|
def relation_offset(relation) |
|
relation.options.skip |
|
end |
|
|
|
def relation_limit(relation) |
|
relation.options.limit |
|
end |
|
|
|
def relation_count(relation) |
|
# Mongo's `.count` doesn't apply limit or skip, which we need. So we have to load _everything_! |
|
relation.to_a.count |
|
end |
|
|
|
def null_relation(relation) |
|
relation.without_options.none |
|
end |
|
end |
|
end |
|
end |
|
|
|
``` |
|
|
|
### oss/graphql-ruby/lib/graphql/pagination/relation_connection.rb |
|
|
|
```ruby |
|
# frozen_string_literal: true |
|
require "graphql/pagination/connection" |
|
|
|
module GraphQL |
|
module Pagination |
|
# A generic class for working with database query objects. |
|
class RelationConnection < Pagination::Connection |
|
def nodes |
|
load_nodes |
|
@nodes |
|
end |
|
|
|
def has_previous_page |
|
if @has_previous_page.nil? |
|
@has_previous_page = if after_offset && after_offset > 0 |
|
true |
|
elsif last |
|
# See whether there are any nodes _before_ the current offset. |
|
# If there _is no_ current offset, then there can't be any nodes before it. |
|
# Assume that if the offset is positive, there are nodes before the offset. |
|
limited_nodes |
|
!(@paged_nodes_offset.nil? || @paged_nodes_offset == 0) |
|
else |
|
false |
|
end |
|
end |
|
@has_previous_page |
|
end |
|
|
|
def has_next_page |
|
if @has_next_page.nil? |
|
@has_next_page = if before_offset && before_offset > 0 |
|
true |
|
elsif first |
|
if @nodes && @nodes.count < first |
|
false |
|
else |
|
relation_larger_than(sliced_nodes, @sliced_nodes_offset, first) |
|
end |
|
else |
|
false |
|
end |
|
end |
|
@has_next_page |
|
end |
|
|
|
def cursor_for(item) |
|
load_nodes |
|
# index in nodes + existing offset + 1 (because it's offset, not index) |
|
offset = nodes.index(item) + 1 + (@paged_nodes_offset || 0) - (relation_offset(items) || 0) |
|
encode(offset.to_s) |
|
end |
|
|
|
private |
|
|
|
# @param relation [Object] A database query object |
|
# @param _initial_offset [Integer] The number of items already excluded from the relation |
|
# @param size [Integer] The value against which we check the relation size |
|
# @return [Boolean] True if the number of items in this relation is larger than `size` |
|
def relation_larger_than(relation, _initial_offset, size) |
|
relation_count(set_limit(relation, size + 1)) == size + 1 |
|
end |
|
|
|
# @param relation [Object] A database query object |