Commit 7ec82fb1 authored by Imre Farkas's avatar Imre Farkas

Merge branch 'ajk-graphql-helpers' into 'master'

Improve GraphQL helpers

See merge request gitlab-org/gitlab!48341
parents ed2f764d ef1785c1
......@@ -13,4 +13,5 @@ FactoryBot.define do
sequence(:past_time) { |n| 4.hours.ago + (2 * n).seconds }
sequence(:iid)
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
sequence(:variable) { |n| "var#{n}" }
end
# frozen_string_literal: true
module Graphql
# Helper to pass variables around generated queries.
#
# e.g.:
# first = var('Int')
# after = var('String')
#
# query = with_signature(
# [first, after],
# query_graphql_path([
# [:project, { full_path: project.full_path }],
# [:issues, { after: after, first: first }]
# :nodes
# ], all_graphql_fields_for('Issue'))
# )
#
# post_graphql(query, variables: [first.with(2), after.with(some_cursor)])
#
class Var
attr_reader :name, :type
attr_accessor :value
def initialize(name, type)
@name = name
@type = type
end
def sig
"#{to_graphql_value}: #{type}"
end
def to_graphql_value
"$#{name}"
end
# We return a new object so that running the same query twice with
# different values does not risk re-using the value
#
# e.g.
#
# x = var('Int')
# expect { post_graphql(query, variables: x) }
# .to issue_same_number_of_queries_as { post_graphql(query, variables: x.with(1)) }
#
# Here we post the `x` variable once with the value set to 1, and once with
# the value set to `nil`.
def with(value)
copy = Var.new(name, type)
copy.value = value
copy
end
def to_h
{ name => value }
end
end
end
......@@ -4,6 +4,7 @@ module GraphqlHelpers
MutationDefinition = Struct.new(:query, :variables)
NoData = Class.new(StandardError)
UnauthorizedObject = Class.new(StandardError)
# makes an underscored string look like a fieldname
# "merge_request" => "mergeRequest"
......@@ -17,7 +18,10 @@ module GraphqlHelpers
# ready, then the early return is returned instead.
#
# Then the resolve method is called.
def resolve(resolver_class, args: {}, **resolver_args)
def resolve(resolver_class, args: {}, lookahead: :not_given, parent: :not_given, **resolver_args)
args = aliased_args(resolver_class, args)
args[:parent] = parent unless parent == :not_given
args[:lookahead] = lookahead unless lookahead == :not_given
resolver = resolver_instance(resolver_class, **resolver_args)
ready, early_return = sync_all { resolver.ready?(**args) }
......@@ -26,6 +30,15 @@ module GraphqlHelpers
resolver.resolve(**args)
end
# TODO: Remove this method entirely when GraphqlHelpers uses real resolve_field
def aliased_args(resolver, args)
definitions = resolver.arguments
args.transform_keys do |k|
definitions[GraphqlHelpers.fieldnamerize(k)]&.keyword || k
end
end
def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema)
if ctx.is_a?(Hash)
q = double('Query', schema: schema)
......@@ -111,24 +124,25 @@ module GraphqlHelpers
def variables_for_mutation(name, input)
graphql_input = prepare_input_for_mutation(input)
result = { input_variable_name_for_mutation(name) => graphql_input }
{ input_variable_name_for_mutation(name) => graphql_input }
end
# Avoid trying to serialize multipart data into JSON
if graphql_input.values.none? { |value| io_value?(value) }
result.to_json
else
result
end
def serialize_variables(variables)
return unless variables
return variables if variables.is_a?(String)
::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json
end
def resolve_field(name, object, args = {})
context = double("Context",
schema: GitlabSchema,
query: GraphQL::Query.new(GitlabSchema),
parent: nil)
field = described_class.fields[::GraphqlHelpers.fieldnamerize(name)]
def resolve_field(name, object, args = {}, current_user: nil)
q = GraphQL::Query.new(GitlabSchema)
context = GraphQL::Query::Context.new(query: q, object: object, values: { current_user: current_user })
allow(context).to receive(:parent).and_return(nil)
field = described_class.fields.fetch(GraphqlHelpers.fieldnamerize(name))
instance = described_class.authorized_new(object, context)
field.resolve_field(instance, {}, context)
raise UnauthorizedObject unless instance
field.resolve_field(instance, args, context)
end
# Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
......@@ -165,10 +179,32 @@ module GraphqlHelpers
end
def query_graphql_field(name, attributes = {}, fields = nil)
<<~QUERY
#{field_with_params(name, attributes)}
#{wrap_fields(fields || all_graphql_fields_for(name.to_s.classify))}
QUERY
attributes, fields = [nil, attributes] if fields.nil? && !attributes.is_a?(Hash)
field = field_with_params(name, attributes)
field + wrap_fields(fields || all_graphql_fields_for(name.to_s.classify)).to_s
end
def page_info_selection
"pageInfo { hasNextPage hasPreviousPage endCursor startCursor }"
end
def query_nodes(name, fields = nil, args: nil, of: name, include_pagination_info: false, max_depth: 1)
fields ||= all_graphql_fields_for(of.to_s.classify, max_depth: max_depth)
node_selection = include_pagination_info ? "#{page_info_selection} nodes" : :nodes
query_graphql_path([[name, args], node_selection], fields)
end
# e.g:
# query_graphql_path(%i[foo bar baz], all_graphql_fields_for('Baz'))
# => foo { bar { baz { x y z } } }
def query_graphql_path(segments, fields = nil)
# we really want foldr here...
segments.reverse.reduce(fields) do |tail, segment|
name, args = Array.wrap(segment)
query_graphql_field(name, args, tail)
end
end
def wrap_fields(fields)
......@@ -233,6 +269,14 @@ module GraphqlHelpers
end.join(", ")
end
def with_signature(variables, query)
%Q[query(#{variables.map(&:sig).join(', ')}) #{query}]
end
def var(type)
::Graphql::Var.new(generate(:variable), type)
end
# Fairly dumb Ruby => GraphQL rendering function. Only suitable for testing.
# Use symbol for Enum values
def as_graphql_literal(value)
......@@ -245,7 +289,12 @@ module GraphqlHelpers
when nil then 'null'
when true then 'true'
when false then 'false'
else raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
else
if value.respond_to?(:to_graphql_value)
value.to_graphql_value
else
raise ArgumentError, "Cannot represent #{value} as GraphQL literal"
end
end
end
......@@ -254,7 +303,7 @@ module GraphqlHelpers
end
def post_graphql(query, current_user: nil, variables: nil, headers: {})
params = { query: query, variables: variables&.to_json }
params = { query: query, variables: serialize_variables(variables) }
post api('/', current_user, version: 'graphql'), params: params, headers: headers
end
......@@ -332,13 +381,19 @@ module GraphqlHelpers
graphql_dig_at(graphql_data, *path)
end
# Slightly more powerful than just `dig`:
# - also supports implicit flat-mapping (.e.g. :foo :nodes :bar :nodes)
def graphql_dig_at(data, *path)
keys = path.map { |segment| segment.is_a?(Integer) ? segment : GraphqlHelpers.fieldnamerize(segment) }
# Allows for array indexing, like this
# ['project', 'boards', 'edges', 0, 'node', 'lists']
keys.reduce(data) do |memo, key|
memo.is_a?(Array) ? memo[key] : memo&.dig(key)
if memo.is_a?(Array)
key.is_a?(Integer) ? memo[key] : memo.flat_map { |e| Array.wrap(e[key]) }
else
memo&.dig(key)
end
end
end
......@@ -498,6 +553,20 @@ module GraphqlHelpers
variables: {}
)
end
# A lookahead that selects everything
def positive_lookahead
double(selects?: true).tap do |selection|
allow(selection).to receive(:selection).and_return(selection)
end
end
# A lookahead that selects nothing
def negative_lookahead
double(selects?: false).tap do |selection|
allow(selection).to receive(:selection).and_return(selection)
end
end
end
# This warms our schema, doing this as part of loading the helpers to avoid
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Graphql::Var do
subject(:var) { described_class.new('foo', 'Int') }
it 'associates a name with a type and an initially empty value' do
expect(var).to have_attributes(
name: 'foo',
type: 'Int',
value: be_nil
)
end
it 'has a correct signature' do
expect(var).to have_attributes(sig: '$foo: Int')
end
it 'implements to_graphql_value as $name' do
expect(var.to_graphql_value).to eq('$foo')
end
it 'can set a value using with, returning a new object' do
with_value = var.with(42)
expect(with_value).to have_attributes(name: 'foo', type: 'Int', value: 42)
expect(var).to have_attributes(value: be_nil)
end
it 'returns an object suitable for passing to post_graphql(variables:)' do
expect(var.with(17).to_h).to eq('foo' => 17)
end
end
......@@ -5,6 +5,223 @@ require 'spec_helper'
RSpec.describe GraphqlHelpers do
include GraphqlHelpers
# Normalize irrelevant whitespace to make comparison easier
def norm(query)
query.tr("\n", ' ').gsub(/\s+/, ' ').strip
end
describe 'graphql_dig_at' do
it 'transforms symbol keys to graphql field names' do
data = { 'camelCased' => 'names' }
expect(graphql_dig_at(data, :camel_cased)).to eq('names')
end
it 'supports integer indexing' do
data = { 'array' => [:boom, { 'id' => :hooray! }, :boom] }
expect(graphql_dig_at(data, :array, 1, :id)).to eq(:hooray!)
end
it 'gracefully degrades to nil' do
data = { 'project' => { 'mergeRequest' => nil } }
expect(graphql_dig_at(data, :project, :merge_request, :id)).to be_nil
end
it 'supports implicitly flat-mapping traversals' do
data = {
'foo' => {
'nodes' => [
{ 'bar' => { 'nodes' => [{ 'id' => 1 }, { 'id' => 2 }] } },
{ 'bar' => { 'nodes' => [{ 'id' => 3 }, { 'id' => 4 }] } },
{ 'bar' => nil }
]
},
'irrelevant' => 'the field is a red-herring'
}
expect(graphql_dig_at(data, :foo, :nodes, :bar, :nodes, :id)).to eq([1, 2, 3, 4])
end
end
describe 'var' do
it 'allocates a fresh name for each var' do
a = var('Int')
b = var('Int')
expect(a.name).not_to eq(b.name)
end
it 'can be used to construct correct signatures' do
a = var('Int')
b = var('String!')
q = with_signature([a, b], '{ foo bar }')
expect(q).to eq("query(#{a.to_graphql_value}: Int, #{b.to_graphql_value}: String!) { foo bar }")
end
it 'can be used to pass arguments to fields' do
a = var('ID!')
q = graphql_query_for(:project, { full_path: a }, :id)
expect(norm(q)).to eq("{ project(fullPath: #{a.to_graphql_value}){ id } }")
end
it 'can associate values with variables' do
a = var('Int')
expect(a.with(3).to_h).to eq(a.name => 3)
end
it 'does not mutate the variable when providing a value' do
a = var('Int')
three = a.with(3)
expect(three.value).to eq(3)
expect(a.value).to be_nil
end
it 'can associate many values with variables' do
a = var('Int').with(3)
b = var('String').with('foo')
expect(serialize_variables([a, b])).to eq({ a.name => 3, b.name => 'foo' }.to_json)
end
end
describe '.query_nodes' do
it 'can produce a basic connection selection' do
selection = query_nodes(:users)
expected = query_graphql_path([:users, :nodes], all_graphql_fields_for('User', max_depth: 1))
expect(selection).to eq(expected)
end
it 'allows greater depth' do
selection = query_nodes(:users, max_depth: 2)
expected = query_graphql_path([:users, :nodes], all_graphql_fields_for('User', max_depth: 2))
expect(selection).to eq(expected)
end
it 'accepts fields' do
selection = query_nodes(:users, :id)
expected = query_graphql_path([:users, :nodes], :id)
expect(selection).to eq(expected)
end
it 'accepts arguments' do
args = { username: 'foo' }
selection = query_nodes(:users, args: args)
expected = query_graphql_path([[:users, args], :nodes], all_graphql_fields_for('User', max_depth: 1))
expect(selection).to eq(expected)
end
it 'accepts arguments and fields' do
selection = query_nodes(:users, :id, args: { username: 'foo' })
expected = query_graphql_path([[:users, { username: 'foo' }], :nodes], :id)
expect(selection).to eq(expected)
end
it 'accepts explicit type name' do
selection = query_nodes(:members, of: 'User')
expected = query_graphql_path([:members, :nodes], all_graphql_fields_for('User', max_depth: 1))
expect(selection).to eq(expected)
end
it 'can optionally provide pagination info' do
selection = query_nodes(:users, include_pagination_info: true)
expected = query_graphql_path([:users, "#{page_info_selection} nodes"], all_graphql_fields_for('User', max_depth: 1))
expect(selection).to eq(expected)
end
end
describe '.query_graphql_path' do
it 'can build nested paths' do
selection = query_graphql_path(%i[foo bar wibble_wobble], :id)
expected = norm(<<-GQL)
foo{
bar{
wibbleWobble{
id
}
}
}
GQL
expect(norm(selection)).to eq(expected)
end
it 'can insert arguments at any point' do
selection = query_graphql_path(
[:foo, [:bar, { quux: true }], [:wibble_wobble, { eccentricity: :HIGH }]],
:id
)
expected = norm(<<-GQL)
foo{
bar(quux: true){
wibbleWobble(eccentricity: HIGH){
id
}
}
}
GQL
expect(norm(selection)).to eq(expected)
end
end
describe '.attributes_to_graphql' do
it 'can serialize hashes to literal arguments' do
x = var('Int')
args = {
an_array: [1, nil, "foo", true, [:foo, :bar]],
a_hash: {
nested: true,
value: "bar"
},
an_int: 42,
a_float: 0.1,
a_string: "wibble",
an_enum: :LOW,
null: nil,
a_bool: false,
a_var: x
}
literal = attributes_to_graphql(args)
expect(norm(literal)).to eq(norm(<<~EXP))
anArray: [1,null,"foo",true,[foo,bar]],
aHash: {nested: true, value: "bar"},
anInt: 42,
aFloat: 0.1,
aString: "wibble",
anEnum: LOW,
null: null,
aBool: false,
aVar: #{x.to_graphql_value}
EXP
end
end
describe '.graphql_mutation' do
shared_examples 'correct mutation definition' do
it 'returns correct mutation definition' do
......@@ -15,7 +232,7 @@ RSpec.describe GraphqlHelpers do
}
}
MUTATION
variables = %q({"updateAlertStatusInput":{"projectPath":"test/project"}})
variables = { "updateAlertStatusInput" => { "projectPath" => "test/project" } }
is_expected.to eq(GraphqlHelpers::MutationDefinition.new(query, variables))
end
......
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