Commit c0d65002 authored by Alex Kalderimis's avatar Alex Kalderimis

Create testable abstractions for arguments and fields

This moves our GraphQL helpers away from string-orientated operations to
the use of abstractions for arguments and field selections that are more
testable, composable and debugging friendly.
parent f20bdc4f
# frozen_string_literal: true
module Graphql
class Arguments
delegate :blank?, :empty?, to: :to_h
def initialize(values)
@values = values.compact
end
def to_h
@values
end
def ==(other)
to_h == other&.to_h
end
alias_method :eql, :==
def to_s
return '' if empty?
@values.map do |name, value|
value_str = as_graphql_literal(value)
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}"
end.join(", ")
end
def as_graphql_literal(value)
self.class.as_graphql_literal(value)
end
# Transform values to GraphQL literal arguments.
# Use symbol for Enum values
def self.as_graphql_literal(value)
case value
when ::Graphql::Arguments then "{#{value}}"
when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]"
when Hash then "{#{new(value)}}"
when Integer, Float, Symbol then value.to_s
when String then "\"#{value.gsub(/"/, '\\"')}\""
when nil then 'null'
when true then 'true'
when false then 'false'
else
value.to_graphql_value
end
rescue NoMethodError
raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
end
def merge(other)
self.class.new(@values.merge(other.to_h))
end
def +(other)
if blank?
other
elsif other.blank?
self
elsif other.is_a?(String)
[to_s, other].compact.join(', ')
else
merge(other)
end
end
end
end
# frozen_string_literal: true
module Graphql
class FieldInspection
def initialize(field)
@field = field
end
def nested_fields?
!scalar? && !enum?
end
def scalar?
type.kind.scalar?
end
def enum?
type.kind.enum?
end
def type
@type ||= begin
field_type = @field.type.respond_to?(:to_graphql) ? @field.type.to_graphql : @field.type
# The type could be nested. For example `[GraphQL::STRING_TYPE]`:
# - List
# - String!
# - String
field_type = field_type.of_type while field_type.respond_to?(:of_type)
field_type
end
end
end
end
# frozen_string_literal: true
module Graphql
class FieldSelection
delegate :empty?, :blank?, :to_h, to: :selection
delegate :size, to: :paths
attr_reader :selection
def initialize(selection = {})
@selection = selection.to_h
end
def to_s
serialize_field_selection(selection)
end
def paths
selection.flat_map do |field, subselection|
paths_in([field], subselection)
end
end
private
def paths_in(path, leaves)
return [path] if leaves.nil?
leaves.to_a.flat_map do |k, v|
paths_in([k], v).map { |tail| path + tail }
end
end
def serialize_field_selection(hash, level = 0)
indent = ' ' * level
hash.map do |field, subselection|
if subselection.nil?
"#{indent}#{field}"
else
subfields = serialize_field_selection(subselection, level + 1)
"#{indent}#{field} {\n#{subfields}\n#{indent}}"
end
end.join("\n")
end
NO_SKIP = ->(_name, _field) { false }
def self.select_fields(type, skip = NO_SKIP, parent_types = Set.new, max_depth = 3)
return new if max_depth <= 0
new(type.fields.flat_map do |name, field|
next [] if skip[name, field]
inspected = ::Graphql::FieldInspection.new(field)
singular_field_type = inspected.type
# If field type is the same as parent type, then we're hitting into
# mutual dependency. Break it from infinite recursion
next [] if parent_types.include?(singular_field_type)
if inspected.nested_fields?
subselection = select_fields(singular_field_type, skip, parent_types | [type], max_depth - 1)
next [] if subselection.empty?
[[name, subselection.to_h]]
else
[[name, nil]]
end
end)
end
end
end
...@@ -6,6 +6,10 @@ module GraphqlHelpers ...@@ -6,6 +6,10 @@ module GraphqlHelpers
NoData = Class.new(StandardError) NoData = Class.new(StandardError)
UnauthorizedObject = Class.new(StandardError) UnauthorizedObject = Class.new(StandardError)
def graphql_args(**values)
::Graphql::Arguments.new(values)
end
# makes an underscored string look like a fieldname # makes an underscored string look like a fieldname
# "merge_request" => "mergeRequest" # "merge_request" => "mergeRequest"
def self.fieldnamerize(underscored_field_name) def self.fieldnamerize(underscored_field_name)
...@@ -240,34 +244,10 @@ module GraphqlHelpers ...@@ -240,34 +244,10 @@ module GraphqlHelpers
type = GitlabSchema.types[class_name.to_s] type = GitlabSchema.types[class_name.to_s]
return "" unless type return "" unless type
type.fields.map do |name, field| # We can't guess arguments, so skip fields that require them
# We can't guess arguments, so skip fields that require them skip = ->(name, field) { excluded.include?(name) || required_arguments?(field) }
next if required_arguments?(field)
next if excluded.include?(name)
singular_field_type = field_type(field)
# If field type is the same as parent type, then we're hitting into
# mutual dependency. Break it from infinite recursion
next if parent_types.include?(singular_field_type)
if nested_fields?(field)
fields =
all_graphql_fields_for(singular_field_type, parent_types | [type], max_depth: max_depth - 1)
"#{name} { #{fields} }" unless fields.blank?
else
name
end
end.compact.join("\n")
end
def attributes_to_graphql(attributes)
attributes.map do |name, value|
value_str = as_graphql_literal(value)
"#{GraphqlHelpers.fieldnamerize(name.to_s)}: #{value_str}" ::Graphql::FieldSelection.select_fields(type, skip, parent_types, max_depth)
end.join(", ")
end end
def with_signature(variables, query) def with_signature(variables, query)
...@@ -278,25 +258,8 @@ module GraphqlHelpers ...@@ -278,25 +258,8 @@ module GraphqlHelpers
::Graphql::Var.new(generate(:variable), type) ::Graphql::Var.new(generate(:variable), type)
end end
# Fairly dumb Ruby => GraphQL rendering function. Only suitable for testing. def attributes_to_graphql(arguments)
# Use symbol for Enum values ::Graphql::Arguments.new(arguments).to_s
def as_graphql_literal(value)
case value
when Array then "[#{value.map { |v| as_graphql_literal(v) }.join(',')}]"
when Hash then "{#{attributes_to_graphql(value)}}"
when Integer, Float then value.to_s
when String then "\"#{value.gsub(/"/, '\\"')}\""
when Symbol then value
when nil then 'null'
when true then 'true'
when false then 'false'
else
if value.respond_to?(:to_graphql_value)
value.to_graphql_value
else
raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
end
end
end end
def post_multiplex(queries, current_user: nil, headers: {}) def post_multiplex(queries, current_user: nil, headers: {})
...@@ -370,12 +333,16 @@ module GraphqlHelpers ...@@ -370,12 +333,16 @@ module GraphqlHelpers
{ operations: operations.to_json, map: map.to_json }.merge(extracted_files) { operations: operations.to_json, map: map.to_json }.merge(extracted_files)
end end
def fresh_response_data
Gitlab::Json.parse(response.body)
end
# Raises an error if no data is found # Raises an error if no data is found
def graphql_data def graphql_data(body = json_response)
# Note that `json_response` is defined as `let(:json_response)` and # Note that `json_response` is defined as `let(:json_response)` and
# therefore, in a spec with multiple queries, will only contain data # therefore, in a spec with multiple queries, will only contain data
# from the _first_ query, not subsequent ones # from the _first_ query, not subsequent ones
json_response['data'] || (raise NoData, graphql_errors) body['data'] || (raise NoData, graphql_errors(body))
end end
def graphql_data_at(*path) def graphql_data_at(*path)
...@@ -398,14 +365,15 @@ module GraphqlHelpers ...@@ -398,14 +365,15 @@ module GraphqlHelpers
end end
end end
def graphql_errors # See note at graphql_data about memoization and multiple requests
case json_response def graphql_errors(body = json_response)
case body
when Hash # regular query when Hash # regular query
json_response['errors'] body['errors']
when Array # multiplexed queries when Array # multiplexed queries
json_response.map { |response| response['errors'] } body.map { |response| response['errors'] }
else else
raise "Unknown GraphQL response type #{json_response.class}" raise "Unknown GraphQL response type #{body.class}"
end end
end end
...@@ -448,15 +416,15 @@ module GraphqlHelpers ...@@ -448,15 +416,15 @@ module GraphqlHelpers
end end
def nested_fields?(field) def nested_fields?(field)
!scalar?(field) && !enum?(field) ::Graphql::FieldInspection.new(field).nested_fields?
end end
def scalar?(field) def scalar?(field)
field_type(field).kind.scalar? ::Graphql::FieldInspection.new(field).scalar?
end end
def enum?(field) def enum?(field)
field_type(field).kind.enum? ::Graphql::FieldInspection.new(field).enum?
end end
# There are a few non BaseField fields in our schema (pageInfo for one). # There are a few non BaseField fields in our schema (pageInfo for one).
...@@ -478,15 +446,7 @@ module GraphqlHelpers ...@@ -478,15 +446,7 @@ module GraphqlHelpers
end end
def field_type(field) def field_type(field)
field_type = field.type.respond_to?(:to_graphql) ? field.type.to_graphql : field.type ::Graphql::FieldInspection.new(field).type
# The type could be nested. For example `[GraphQL::STRING_TYPE]`:
# - List
# - String!
# - String
field_type = field_type.of_type while field_type.respond_to?(:of_type)
field_type
end end
# for most tests, we want to allow unlimited complexity # for most tests, we want to allow unlimited complexity
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Graphql::Arguments do
it 'returns a blank string if the arguments are blank' do
args = described_class.new({})
expect("#{args}").to be_blank
end
it 'returns a serialized arguments if the arguments are not blank' do
units = described_class.new({ temp: :CELSIUS, time: :MINUTES })
args = described_class.new({ temp: 180, time: 45, units: units })
expect("#{args}").to eq('temp: 180, time: 45, units: {temp: CELSIUS, time: MINUTES}')
end
it 'supports merge with +' do
lhs = described_class.new({ a: 1, b: 2 })
rhs = described_class.new({ b: 3, c: 4 })
expect(lhs + rhs).to eq({ a: 1, b: 3, c: 4 })
end
it 'supports merge with + and a string' do
lhs = described_class.new({ a: 1, b: 2 })
rhs = 'x: no'
expect(lhs + rhs).to eq('a: 1, b: 2, x: no')
end
it 'supports merge with + and a string when empty' do
lhs = described_class.new({})
rhs = 'x: no'
expect(lhs + rhs).to eq('x: no')
end
it 'supports merge with + and an empty string' do
lhs = described_class.new({ a: 1 })
rhs = ''
expect(lhs + rhs).to eq({ a: 1 })
end
it 'serializes all values correctly' do
args = described_class.new({
array: [1, 2.5, "foo", nil, true, false, :BAR, { power: :on }],
hash: { a: 1, b: 2, c: 3 },
int: 42,
float: 2.7,
string: %q[he said "no"],
enum: :OFF,
null: nil, # we expect this to be omitted - absence is the same as explicit nullness
bool_true: true,
bool_false: false,
var: ::Graphql::Var.new('x', 'Int')
})
expect(args.to_s).to eq([
%q(array: [1,2.5,"foo",null,true,false,BAR,{power: on}]),
%q(hash: {a: 1, b: 2, c: 3}),
'int: 42, float: 2.7',
%q(string: "he said \\"no\\""),
'enum: OFF',
'boolTrue: true, boolFalse: false',
'var: $x'
].join(', '))
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Graphql::FieldSelection do
it 'can report on the paths that are selected' do
selection = described_class.new({
'foo' => nil,
'bar' => nil,
'quux' => {
'a' => nil,
'b' => { 'x' => nil, 'y' => nil }
},
'qoox' => {
'q' => nil,
'r' => { 's' => { 't' => nil } }
}
})
expect(selection.paths).to include(
%w[foo],
%w[quux a],
%w[quux b x],
%w[qoox r s t]
)
end
it 'can serialize a field selection nicely' do
selection = described_class.new({
'foo' => nil,
'bar' => nil,
'quux' => {
'a' => nil,
'b' => { 'x' => nil, 'y' => nil }
},
'qoox' => {
'q' => nil,
'r' => { 's' => { 't' => nil } }
}
})
expect(selection.to_s).to eq(<<~FRAG.strip)
foo
bar
quux {
a
b {
x
y
}
}
qoox {
q
r {
s {
t
}
}
}
FRAG
end
end
...@@ -215,13 +215,68 @@ RSpec.describe GraphqlHelpers do ...@@ -215,13 +215,68 @@ RSpec.describe GraphqlHelpers do
aFloat: 0.1, aFloat: 0.1,
aString: "wibble", aString: "wibble",
anEnum: LOW, anEnum: LOW,
null: null,
aBool: false, aBool: false,
aVar: #{x.to_graphql_value} aVar: #{x.to_graphql_value}
EXP EXP
end end
end end
describe '.all_graphql_fields_for' do
it 'returns a FieldSelection' do
selection = all_graphql_fields_for('User', max_depth: 1)
expect(selection).to be_a(::Graphql::FieldSelection)
end
it 'returns nil if the depth is too shallow' do
selection = all_graphql_fields_for('User', max_depth: 0)
expect(selection).to be_nil
end
it 'can select just the scalar fields' do
selection = all_graphql_fields_for('User', max_depth: 1)
paths = selection.paths.map(&:join)
# A sample, tested using include to save churn as fields are added
expect(paths)
.to include(*%w[avatarUrl email groupCount id location name state username webPath webUrl])
expect(selection.paths).to all(have_attributes(size: 1))
end
it 'selects only as far as 3 levels by default' do
selection = all_graphql_fields_for('User')
expect(selection.paths).to all(have_attributes(size: (be <= 3)))
# Representative sample
expect(selection.paths).to include(
%w[userPermissions createSnippet],
%w[todos nodes id],
%w[starredProjects nodes name],
%w[authoredMergeRequests count],
%w[assignedMergeRequests pageInfo startCursor]
)
end
it 'selects only as far as requested' do
selection = all_graphql_fields_for('User', max_depth: 2)
expect(selection.paths).to all(have_attributes(size: (be <= 2)))
end
it 'omits fields that have required arguments' do
selection = all_graphql_fields_for('DesignCollection', max_depth: 3)
expect(selection.paths).not_to be_empty
expect(selection.paths).not_to include(
%w[designAtVersion id]
)
end
end
describe '.graphql_mutation' do describe '.graphql_mutation' do
shared_examples 'correct mutation definition' do shared_examples 'correct mutation definition' do
it 'returns correct mutation definition' do it 'returns correct mutation definition' do
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment