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 ...@@ -13,4 +13,5 @@ FactoryBot.define do
sequence(:past_time) { |n| 4.hours.ago + (2 * n).seconds } sequence(:past_time) { |n| 4.hours.ago + (2 * n).seconds }
sequence(:iid) sequence(:iid)
sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") } sequence(:sha) { |n| Digest::SHA1.hexdigest("commit-like-#{n}") }
sequence(:variable) { |n| "var#{n}" }
end 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 ...@@ -4,6 +4,7 @@ module GraphqlHelpers
MutationDefinition = Struct.new(:query, :variables) MutationDefinition = Struct.new(:query, :variables)
NoData = Class.new(StandardError) NoData = Class.new(StandardError)
UnauthorizedObject = Class.new(StandardError)
# makes an underscored string look like a fieldname # makes an underscored string look like a fieldname
# "merge_request" => "mergeRequest" # "merge_request" => "mergeRequest"
...@@ -17,7 +18,10 @@ module GraphqlHelpers ...@@ -17,7 +18,10 @@ module GraphqlHelpers
# ready, then the early return is returned instead. # ready, then the early return is returned instead.
# #
# Then the resolve method is called. # 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) resolver = resolver_instance(resolver_class, **resolver_args)
ready, early_return = sync_all { resolver.ready?(**args) } ready, early_return = sync_all { resolver.ready?(**args) }
...@@ -26,6 +30,15 @@ module GraphqlHelpers ...@@ -26,6 +30,15 @@ module GraphqlHelpers
resolver.resolve(**args) resolver.resolve(**args)
end 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) def resolver_instance(resolver_class, obj: nil, ctx: {}, field: nil, schema: GitlabSchema)
if ctx.is_a?(Hash) if ctx.is_a?(Hash)
q = double('Query', schema: schema) q = double('Query', schema: schema)
...@@ -111,24 +124,25 @@ module GraphqlHelpers ...@@ -111,24 +124,25 @@ module GraphqlHelpers
def variables_for_mutation(name, input) def variables_for_mutation(name, input)
graphql_input = prepare_input_for_mutation(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 def serialize_variables(variables)
if graphql_input.values.none? { |value| io_value?(value) } return unless variables
result.to_json return variables if variables.is_a?(String)
else
result ::Gitlab::Utils::MergeHash.merge(Array.wrap(variables).map(&:to_h)).to_json
end
end end
def resolve_field(name, object, args = {}) def resolve_field(name, object, args = {}, current_user: nil)
context = double("Context", q = GraphQL::Query.new(GitlabSchema)
schema: GitlabSchema, context = GraphQL::Query::Context.new(query: q, object: object, values: { current_user: current_user })
query: GraphQL::Query.new(GitlabSchema), allow(context).to receive(:parent).and_return(nil)
parent: nil) field = described_class.fields.fetch(GraphqlHelpers.fieldnamerize(name))
field = described_class.fields[::GraphqlHelpers.fieldnamerize(name)]
instance = described_class.authorized_new(object, context) instance = described_class.authorized_new(object, context)
field.resolve_field(instance, {}, context) raise UnauthorizedObject unless instance
field.resolve_field(instance, args, context)
end end
# Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys # Recursively convert a Hash with Ruby-style keys to GraphQL fieldname-style keys
...@@ -165,10 +179,32 @@ module GraphqlHelpers ...@@ -165,10 +179,32 @@ module GraphqlHelpers
end end
def query_graphql_field(name, attributes = {}, fields = nil) def query_graphql_field(name, attributes = {}, fields = nil)
<<~QUERY attributes, fields = [nil, attributes] if fields.nil? && !attributes.is_a?(Hash)
#{field_with_params(name, attributes)}
#{wrap_fields(fields || all_graphql_fields_for(name.to_s.classify))} field = field_with_params(name, attributes)
QUERY
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 end
def wrap_fields(fields) def wrap_fields(fields)
...@@ -233,6 +269,14 @@ module GraphqlHelpers ...@@ -233,6 +269,14 @@ module GraphqlHelpers
end.join(", ") end.join(", ")
end 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. # Fairly dumb Ruby => GraphQL rendering function. Only suitable for testing.
# Use symbol for Enum values # Use symbol for Enum values
def as_graphql_literal(value) def as_graphql_literal(value)
...@@ -245,7 +289,12 @@ module GraphqlHelpers ...@@ -245,7 +289,12 @@ module GraphqlHelpers
when nil then 'null' when nil then 'null'
when true then 'true' when true then 'true'
when false then 'false' 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
end end
...@@ -254,7 +303,7 @@ module GraphqlHelpers ...@@ -254,7 +303,7 @@ module GraphqlHelpers
end end
def post_graphql(query, current_user: nil, variables: nil, headers: {}) 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 post api('/', current_user, version: 'graphql'), params: params, headers: headers
end end
...@@ -332,13 +381,19 @@ module GraphqlHelpers ...@@ -332,13 +381,19 @@ module GraphqlHelpers
graphql_dig_at(graphql_data, *path) graphql_dig_at(graphql_data, *path)
end end
# Slightly more powerful than just `dig`:
# - also supports implicit flat-mapping (.e.g. :foo :nodes :bar :nodes)
def graphql_dig_at(data, *path) def graphql_dig_at(data, *path)
keys = path.map { |segment| segment.is_a?(Integer) ? segment : GraphqlHelpers.fieldnamerize(segment) } keys = path.map { |segment| segment.is_a?(Integer) ? segment : GraphqlHelpers.fieldnamerize(segment) }
# Allows for array indexing, like this # Allows for array indexing, like this
# ['project', 'boards', 'edges', 0, 'node', 'lists'] # ['project', 'boards', 'edges', 0, 'node', 'lists']
keys.reduce(data) do |memo, key| 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
end end
...@@ -498,6 +553,20 @@ module GraphqlHelpers ...@@ -498,6 +553,20 @@ module GraphqlHelpers
variables: {} variables: {}
) )
end 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 end
# This warms our schema, doing this as part of loading the helpers to avoid # 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' ...@@ -5,6 +5,223 @@ require 'spec_helper'
RSpec.describe GraphqlHelpers do RSpec.describe GraphqlHelpers do
include GraphqlHelpers 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 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
...@@ -15,7 +232,7 @@ RSpec.describe GraphqlHelpers do ...@@ -15,7 +232,7 @@ RSpec.describe GraphqlHelpers do
} }
} }
MUTATION MUTATION
variables = %q({"updateAlertStatusInput":{"projectPath":"test/project"}}) variables = { "updateAlertStatusInput" => { "projectPath" => "test/project" } }
is_expected.to eq(GraphqlHelpers::MutationDefinition.new(query, variables)) is_expected.to eq(GraphqlHelpers::MutationDefinition.new(query, variables))
end 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