Commit ac1b09af authored by Alex Kalderimis's avatar Alex Kalderimis

Merge branch 'cablett-329617-graphql-nullable-args' into 'master'

Add optional `nullable` declaration to GraphQL arguments

See merge request gitlab-org/gitlab!66618
parents 00a17431 f93038ec
...@@ -31,6 +31,12 @@ module Mutations ...@@ -31,6 +31,12 @@ module Mutations
def ready?(**args) def ready?(**args)
raise_resource_not_available_error! ERROR_MESSAGE if Gitlab::Database.read_only? raise_resource_not_available_error! ERROR_MESSAGE if Gitlab::Database.read_only?
missing_args = self.class.arguments.values
.reject { |arg| arg.accepts?(args.fetch(arg.keyword, :not_given)) }
.map(&:graphql_name)
raise ArgumentError, "Arguments must be provided: #{missing_args.join(", ")}" if missing_args.any?
true true
end end
......
...@@ -7,17 +7,8 @@ module Mutations ...@@ -7,17 +7,8 @@ module Mutations
argument :due_date, argument :due_date,
Types::TimeType, Types::TimeType,
required: false, required: :nullable,
description: 'The desired due date for the issue, ' \ description: 'The desired due date for the issue. Due date is removed if null.'
'due date will be removed if absent or set to null'
def ready?(**args)
unless args.key?(:due_date)
raise Gitlab::Graphql::Errors::ArgumentError, 'Argument dueDate must be provided (`null` accepted)'
end
super
end
def resolve(project_path:, iid:, due_date:) def resolve(project_path:, iid:, due_date:)
issue = authorized_find!(project_path: project_path, iid: iid) issue = authorized_find!(project_path: project_path, iid: iid)
......
...@@ -10,7 +10,29 @@ module Types ...@@ -10,7 +10,29 @@ module Types
@deprecation = gitlab_deprecation(kwargs) @deprecation = gitlab_deprecation(kwargs)
@doc_reference = kwargs.delete(:see) @doc_reference = kwargs.delete(:see)
# our custom addition `nullable` which allows us to declare
# an argument that must be provided, even if its value is null.
# When `required: true` then required arguments must not be null.
@gl_required = !!kwargs[:required]
@gl_nullable = kwargs[:required] == :nullable
# Only valid if an argument is also required.
if @gl_nullable
# Since the framework asserts that "required" means "cannot be null"
# we have to switch off "required" but still do the check in `ready?` behind the scenes
kwargs[:required] = false
end
super(*args, **kwargs, &block) super(*args, **kwargs, &block)
end end
def accepts?(value)
# if the argument is declared as required, it must be included
return false if @gl_required && value == :not_given
# if the argument is declared as required, the value can only be null IF it is also nullable.
return false if @gl_required && value.nil? && !@gl_nullable
true
end
end end
end end
...@@ -2533,7 +2533,7 @@ Input type: `IssueSetDueDateInput` ...@@ -2533,7 +2533,7 @@ Input type: `IssueSetDueDateInput`
| Name | Type | Description | | Name | Type | Description |
| ---- | ---- | ----------- | | ---- | ---- | ----------- |
| <a id="mutationissuesetduedateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | <a id="mutationissuesetduedateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationissuesetduedateduedate"></a>`dueDate` | [`Time`](#time) | The desired due date for the issue, due date will be removed if absent or set to null. | | <a id="mutationissuesetduedateduedate"></a>`dueDate` | [`Time`](#time) | The desired due date for the issue. Due date is removed if null. |
| <a id="mutationissuesetduedateiid"></a>`iid` | [`String!`](#string) | The IID of the issue to mutate. | | <a id="mutationissuesetduedateiid"></a>`iid` | [`String!`](#string) | The IID of the issue to mutate. |
| <a id="mutationissuesetduedateprojectpath"></a>`projectPath` | [`ID!`](#id) | The project the issue to mutate is in. | | <a id="mutationissuesetduedateprojectpath"></a>`projectPath` | [`ID!`](#id) | The project the issue to mutate is in. |
......
...@@ -1366,6 +1366,31 @@ argument :my_arg, GraphQL::Types::String, ...@@ -1366,6 +1366,31 @@ argument :my_arg, GraphQL::Types::String,
description: "A description of the argument." description: "A description of the argument."
``` ```
#### Nullability
Arguments can be marked as `required: true` which means the value must be present and not `null`.
If a required argument's value can be `null`, use the `required: :nullable` declaration.
Example:
```ruby
argument :due_date,
Types::TimeType,
required: :nullable,
description: 'The desired due date for the issue. Due date is removed if null.'
```
In the above example, the `due_date` argument must be given, but unlike the GraphQL spec, the value can be `null`.
This allows 'unsetting' the due date in a single mutation rather than creating a new mutation for removing the due date.
```ruby
{ due_date: null } # => OK
{ due_date: "2025-01-10" } # => OK
{ } # => invalid (not given)
```
#### Keywords
Each GraphQL `argument` defined is passed to the `#resolve` method Each GraphQL `argument` defined is passed to the `#resolve` method
of a mutation as keyword arguments. of a mutation as keyword arguments.
...@@ -1377,6 +1402,8 @@ def resolve(my_arg:) ...@@ -1377,6 +1402,8 @@ def resolve(my_arg:)
end end
``` ```
#### Input Types
`graphql-ruby` wraps up arguments into an `graphql-ruby` wraps up arguments into an
[input type](https://graphql.org/learn/schema/#input-types). [input type](https://graphql.org/learn/schema/#input-types).
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Mutations::BaseMutation do
include GraphqlHelpers
describe 'argument nullability' do
let_it_be(:user) { create(:user) }
let_it_be(:context) { { current_user: user } }
subject(:mutation) { mutation_class.new(object: nil, context: context, field: nil) }
describe 'when using a mutation with correct argument declarations' do
context 'when argument is nullable and required' do
let(:mutation_class) do
Class.new(described_class) do
argument :foo, GraphQL::STRING_TYPE, required: :nullable
end
end
specify do
expect { subject.ready? }.to raise_error(ArgumentError, /must be provided: foo/)
end
specify do
expect { subject.ready?(foo: nil) }.not_to raise_error
end
specify do
expect { subject.ready?(foo: "bar") }.not_to raise_error
end
end
context 'when argument is required and NOT nullable' do
let(:mutation_class) do
Class.new(described_class) do
argument :foo, GraphQL::STRING_TYPE, required: true
end
end
specify do
expect { subject.ready? }.to raise_error(ArgumentError, /must be provided/)
end
specify do
expect { subject.ready?(foo: nil) }.to raise_error(ArgumentError, /must be provided/)
end
specify do
expect { subject.ready?(foo: "bar") }.not_to raise_error
end
end
end
end
end
...@@ -41,7 +41,7 @@ RSpec.describe Mutations::Ci::Runner::Delete do ...@@ -41,7 +41,7 @@ RSpec.describe Mutations::Ci::Runner::Delete do
let(:mutation_params) { {} } let(:mutation_params) { {} }
it 'raises an error' do it 'raises an error' do
expect { subject }.to raise_error(ArgumentError, "missing keyword: :id") expect { subject }.to raise_error(ArgumentError, "Arguments must be provided: id")
end end
end end
......
...@@ -43,7 +43,7 @@ RSpec.describe Mutations::Ci::Runner::Update do ...@@ -43,7 +43,7 @@ RSpec.describe Mutations::Ci::Runner::Update do
let(:mutation_params) { {} } let(:mutation_params) { {} }
it 'raises an error' do it 'raises an error' do
expect { subject }.to raise_error(ArgumentError, "missing keyword: :id") expect { subject }.to raise_error(ArgumentError, "Arguments must be provided: id")
end end
end end
......
...@@ -3,7 +3,6 @@ ...@@ -3,7 +3,6 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Types::BaseArgument do RSpec.describe Types::BaseArgument do
include_examples 'Gitlab-style deprecations' do
let_it_be(:field) do let_it_be(:field) do
Types::BaseField.new(name: 'field', type: String, null: true) Types::BaseField.new(name: 'field', type: String, null: true)
end end
...@@ -13,5 +12,32 @@ RSpec.describe Types::BaseArgument do ...@@ -13,5 +12,32 @@ RSpec.describe Types::BaseArgument do
def subject(args = {}) def subject(args = {})
described_class.new(**base_args.merge(args)) described_class.new(**base_args.merge(args))
end end
include_examples 'Gitlab-style deprecations'
describe 'required argument declarations' do
it 'accepts nullable, required arguments' do
arguments = base_args.merge({ required: :nullable })
expect { subject(arguments) }.not_to raise_error
end
it 'accepts required, non-nullable arguments' do
arguments = base_args.merge({ required: true })
expect { subject(arguments) }.not_to raise_error
end
it 'accepts non-required arguments' do
arguments = base_args.merge({ required: false })
expect { subject(arguments) }.not_to raise_error
end
it 'accepts no required argument declaration' do
arguments = base_args
expect { subject(arguments) }.not_to raise_error
end
end end
end end
...@@ -68,7 +68,7 @@ RSpec.describe 'Setting Due Date of an issue' do ...@@ -68,7 +68,7 @@ RSpec.describe 'Setting Due Date of an issue' do
it 'returns an error' do it 'returns an error' do
post_graphql_mutation(mutation, current_user: current_user) post_graphql_mutation(mutation, current_user: current_user)
expect(graphql_errors).to include(a_hash_including('message' => /Argument dueDate must be provided/)) expect(graphql_errors).to include(a_hash_including('message' => /Arguments must be provided: dueDate/))
end end
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