Add suggestions for invalid values

For misspelled enums or field names, these suggestions can be helpful.

This also changes the suggestions algorithm to better detect case-sensitivity mistakes, which are common

ref: graphql/graphql-js#1153
This commit is contained in:
Daniel Tschinder 2018-02-16 16:19:25 +01:00
parent 48c5e64a08
commit d92a2dab21
7 changed files with 258 additions and 39 deletions

View File

@ -512,6 +512,9 @@ class Utils
* Given an invalid input string and a list of valid options, returns a filtered
* list of valid options sorted based on their similarity with the input.
*
* Includes a custom alteration from Damerau-Levenshtein to treat case changes
* as a single edit which helps identify mis-cased values with an edit distance
* of 1
* @param string $input
* @param array $options
* @return string[]
@ -521,7 +524,11 @@ class Utils
$optionsByDistance = [];
$inputThreshold = mb_strlen($input) / 2;
foreach ($options as $option) {
$distance = levenshtein($input, $option);
$distance = $input === $option
? 0
: (strtolower($input) === strtolower($option)
? 1
: levenshtein($input, $option));
$threshold = max($inputThreshold, mb_strlen($option) / 2, 1);
if ($distance <= $threshold) {
$optionsByDistance[$option] = $distance;

View File

@ -2,6 +2,7 @@
namespace GraphQL\Utils;
use GraphQL\Error\Error;
use GraphQL\Language\AST\Node;
use GraphQL\Type\Definition\EnumType;
use GraphQL\Type\Definition\InputObjectType;
use GraphQL\Type\Definition\InputType;
@ -26,7 +27,7 @@ class Value
if ($value === null) {
return self::ofErrors([
self::coercionError(
"Expected non-nullable type $type",
"Expected non-nullable type $type not to be null",
$blameNode,
$path
),
@ -55,11 +56,23 @@ class Value
return self::ofValue($parseResult);
} catch (\Exception $error) {
return self::ofErrors([
self::coercionError("Expected type {$type->name}", $blameNode, $path, $error),
self::coercionError(
"Expected type {$type->name}",
$blameNode,
$path,
$error->getMessage(),
$error
),
]);
} catch (\Throwable $error) {
return self::ofErrors([
self::coercionError("Expected type {$type->name}", $blameNode, $path, $error),
self::coercionError(
"Expected type {$type->name}",
$blameNode,
$path,
$error->getMessage(),
$error
),
]);
}
}
@ -72,8 +85,21 @@ class Value
}
}
$suggestions = Utils::suggestionList(
Utils::printSafe($value),
array_map(function($enumValue) { return $enumValue->name; }, $type->getValues())
);
$didYouMean = $suggestions
? "did you mean " . Utils::orList($suggestions) . "?"
: null;
return self::ofErrors([
self::coercionError("Expected type {$type->name}", $blameNode, $path),
self::coercionError(
"Expected type {$type->name}",
$blameNode,
$path,
$didYouMean
),
]);
}
@ -105,7 +131,11 @@ class Value
if ($type instanceof InputObjectType) {
if (!is_object($value) && !is_array($value) && !$value instanceof \Traversable) {
return self::ofErrors([
self::coercionError("Expected object type {$type->name}", $blameNode, $path),
self::coercionError(
"Expected type {$type->name} to be an object",
$blameNode,
$path
),
]);
}
@ -146,12 +176,20 @@ class Value
// Ensure every provided field is defined.
foreach ($value as $fieldName => $field) {
if (!array_key_exists($fieldName, $fields)) {
$suggestions = Utils::suggestionList(
$fieldName,
array_keys($fields)
);
$didYouMean = $suggestions
? "did you mean " . Utils::orList($suggestions) . "?"
: null;
$errors = self::add(
$errors,
self::coercionError(
"Field \"{$fieldName}\" is not defined by type {$type->name}",
$blameNode,
$path
$path,
$didYouMean
)
);
}
@ -183,18 +221,17 @@ class Value
* @param string $message
* @param Node $blameNode
* @param array|null $path
* @param string $subMessage
* @param \Exception|\Throwable|null $originalError
* @return Error
*/
private static function coercionError($message, $blameNode, array $path = null, $originalError = null) {
private static function coercionError($message, $blameNode, array $path = null, $subMessage = null, $originalError = null) {
$pathStr = self::printPath($path);
// Return a GraphQLError instance
return new Error(
$message .
($pathStr ? ' at ' . $pathStr : '') .
($originalError && $originalError->getMessage()
? '; ' . $originalError->getMessage()
: '.'),
($subMessage ? '; ' . $subMessage : '.'),
$blameNode,
null,
null,

View File

@ -45,9 +45,12 @@ class ValuesOfCorrectType extends AbstractValidationRule
"{$fieldTypeName} was not provided.";
}
static function unknownFieldMessage($typeName, $fieldName)
static function unknownFieldMessage($typeName, $fieldName, $message = null)
{
return "Field \"{$fieldName}\" is not defined by type {$typeName}.";
return (
"Field \"{$fieldName}\" is not defined by type {$typeName}" .
($message ? "; {$message}" : '.')
);
}
public function getVisitor(ValidationContext $context)
@ -103,10 +106,18 @@ class ValuesOfCorrectType extends AbstractValidationRule
NodeKind::OBJECT_FIELD => function(ObjectFieldNode $node) use ($context) {
$parentType = Type::getNamedType($context->getParentInputType());
$fieldType = $context->getInputType();
if (!$fieldType && $parentType) {
if (!$fieldType && $parentType instanceof InputObjectType) {
$suggestions = Utils::suggestionList(
$node->name->value,
array_keys($parentType->getFields())
);
$didYouMean = $suggestions
? "Did you mean " . Utils::orList($suggestions) . "?"
: null;
$context->reportError(
new Error(
self::unknownFieldMessage($parentType->name, $node->name->value),
self::unknownFieldMessage($parentType->name, $node->name->value, $didYouMean),
$node
)
);
@ -148,15 +159,12 @@ class ValuesOfCorrectType extends AbstractValidationRule
$type = Type::getNamedType($locationType);
if (!$type instanceof ScalarType) {
$suggestions = $type instanceof EnumType
? $this->enumTypeSuggestion($type, $node)
: null;
$context->reportError(
new Error(
self::badValueMessage(
(string) $locationType,
Printer::doPrint($node),
$suggestions
$this->enumTypeSuggestion($type, $node)
),
$node
)
@ -214,13 +222,17 @@ class ValuesOfCorrectType extends AbstractValidationRule
}
}
private function enumTypeSuggestion(EnumType $type, ValueNode $node)
private function enumTypeSuggestion($type, ValueNode $node)
{
$suggestions = Utils::suggestionList(
Printer::doPrint($node),
array_map(function (EnumValueDefinition $value) { return $value->name; }, $type->getValues())
);
if ($type instanceof EnumType) {
$suggestions = Utils::suggestionList(
Printer::doPrint($node),
array_map(function (EnumValueDefinition $value) {
return $value->name;
}, $type->getValues())
);
return $suggestions ? 'Did you mean the enum value: ' . Utils::orList($suggestions) . '?' : '';
return $suggestions ? 'Did you mean the enum value ' . Utils::orList($suggestions) . '?' : null;
}
}
}

View File

@ -160,7 +160,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase
'message' =>
'Variable "$input" got invalid value ' .
'{"a":"foo","b":"bar","c":null}; ' .
'Expected non-nullable type String! at value.c.',
'Expected non-nullable type String! not to be null at value.c.',
'locations' => [['line' => 2, 'column' => 17]],
'category' => 'graphql'
]
@ -177,7 +177,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase
[
'message' =>
'Variable "$input" got invalid value "foo bar"; ' .
'Expected object type TestInputObject.',
'Expected type TestInputObject to be an object.',
'locations' => [ [ 'line' => 2, 'column' => 17 ] ],
'category' => 'graphql',
]
@ -411,7 +411,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase
[
'message' =>
'Variable "$value" got invalid value null; ' .
'Expected non-nullable type String!.',
'Expected non-nullable type String! not to be null.',
'locations' => [['line' => 2, 'column' => 31]],
'category' => 'graphql',
]
@ -613,7 +613,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase
[
'message' =>
'Variable "$input" got invalid value null; ' .
'Expected non-nullable type [String]!.',
'Expected non-nullable type [String]! not to be null.',
'locations' => [['line' => 2, 'column' => 17]],
'category' => 'graphql',
]
@ -701,7 +701,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase
[
'message' =>
'Variable "$input" got invalid value ["A",null,"B"]; ' .
'Expected non-nullable type String! at value[1].',
'Expected non-nullable type String! not to be null at value[1].',
'locations' => [ ['line' => 2, 'column' => 17] ],
'category' => 'graphql',
]
@ -727,7 +727,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase
[
'message' =>
'Variable "$input" got invalid value null; ' .
'Expected non-nullable type [String!]!.',
'Expected non-nullable type [String!]! not to be null.',
'locations' => [ ['line' => 2, 'column' => 17] ],
'category' => 'graphql',
]
@ -768,7 +768,7 @@ class VariablesTest extends \PHPUnit_Framework_TestCase
[
'message' =>
'Variable "$input" got invalid value ["A",null,"B"]; ' .
'Expected non-nullable type String! at value[1].',
'Expected non-nullable type String! not to be null at value[1].',
'locations' => [ ['line' => 2, 'column' => 17] ],
'category' => 'graphql',
]

View File

@ -220,7 +220,7 @@ class EnumTypeTest extends \PHPUnit_Framework_TestCase
'{ colorEnum(fromEnum: "GREEN") }',
null,
[
'message' => "Expected type Color, found \"GREEN\"; Did you mean the enum value: GREEN?",
'message' => "Expected type Color, found \"GREEN\"; Did you mean the enum value GREEN?",
'locations' => [new SourceLocation(1, 23)]
]
);
@ -235,7 +235,22 @@ class EnumTypeTest extends \PHPUnit_Framework_TestCase
'{ colorEnum(fromEnum: GREENISH) }',
null,
[
'message' => "Expected type Color, found GREENISH; Did you mean the enum value: GREEN?",
'message' => "Expected type Color, found GREENISH; Did you mean the enum value GREEN?",
'locations' => [new SourceLocation(1, 23)]
]
);
}
/**
* @it does not accept values with incorrect casing
*/
public function testDoesNotAcceptValuesWithIncorrectCasing()
{
$this->expectFailure(
'{ colorEnum(fromEnum: green) }',
null,
[
'message' => "Expected type Color, found green; Did you mean the enum value GREEN?",
'locations' => [new SourceLocation(1, 23)]
]
);

View File

@ -1,12 +1,38 @@
<?php
namespace GraphQL\Tests\Utils;
use GraphQL\Error\Error;
use GraphQL\Executor\Values;
use GraphQL\Type\Definition\EnumType;
use GraphQL\Type\Definition\InputObjectType;
use GraphQL\Type\Definition\Type;
use GraphQL\Utils\Utils;
use GraphQL\Utils\Value;
class CoerceValueTest extends \PHPUnit_Framework_TestCase
{
private $testEnum;
private $testInputObject;
public function setUp()
{
$this->testEnum = new EnumType([
'name' => 'TestEnum',
'values' => [
'FOO' => 'InternalFoo',
'BAR' => 123456789,
],
]);
$this->testInputObject = new InputObjectType([
'name' => 'TestInputObject',
'fields' => [
'foo' => Type::nonNull(Type::int()),
'bar' => Type::int(),
],
]);
}
// Describe: coerceValue
/**
@ -186,16 +212,125 @@ class CoerceValueTest extends \PHPUnit_Framework_TestCase
);
}
// DESCRIBE: for GraphQLEnum
/**
* @it returns no error for a known enum name
*/
public function testReturnsNoErrorForAKnownEnumName()
{
$fooResult = Value::coerceValue('FOO', $this->testEnum);
$this->expectNoErrors($fooResult);
$this->assertEquals('InternalFoo', $fooResult['value']);
$barResult = Value::coerceValue('BAR', $this->testEnum);
$this->expectNoErrors($barResult);
$this->assertEquals(123456789, $barResult['value']);
}
/**
* @it results error for misspelled enum value
*/
public function testReturnsErrorForMisspelledEnumValue()
{
$result = Value::coerceValue('foo', $this->testEnum);
$this->expectError($result, 'Expected type TestEnum; did you mean FOO?');
}
/**
* @it results error for incorrect value type
*/
public function testReturnsErrorForIncorrectValueType()
{
$result1 = Value::coerceValue(123, $this->testEnum);
$this->expectError($result1, 'Expected type TestEnum.');
$result2 = Value::coerceValue(['field' => 'value'], $this->testEnum);
$this->expectError($result2, 'Expected type TestEnum.');
}
// DESCRIBE: for GraphQLInputObject
/**
* @it returns no error for a valid input
*/
public function testReturnsNoErrorForValidInput()
{
$result = Value::coerceValue(['foo' => 123], $this->testInputObject);
$this->expectNoErrors($result);
$this->assertEquals(['foo' => 123], $result['value']);
}
/**
* @it returns no error for a non-object type
*/
public function testReturnsErrorForNonObjectType()
{
$result = Value::coerceValue(123, $this->testInputObject);
$this->expectError($result, 'Expected type TestInputObject to be an object.');
}
/**
* @it returns no error for an invalid field
*/
public function testReturnErrorForAnInvalidField()
{
$result = Value::coerceValue(['foo' => 'abc'], $this->testInputObject);
$this->expectError($result, 'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc');
}
/**
* @it returns multiple errors for multiple invalid fields
*/
public function testReturnsMultipleErrorsForMultipleInvalidFields()
{
$result = Value::coerceValue(['foo' => 'abc', 'bar' => 'def'], $this->testInputObject);
$this->assertEquals([
'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc',
'Expected type Int at value.bar; Int cannot represent non 32-bit signed integer value: def',
], $result['errors']);
}
/**
* @it returns error for a missing required field
*/
public function testReturnsErrorForAMissingRequiredField()
{
$result = Value::coerceValue(['bar' => 123], $this->testInputObject);
$this->expectError($result, 'Field value.foo of required type Int! was not provided.');
}
/**
* @it returns error for an unknown field
*/
public function testReturnsErrorForAnUnknownField()
{
$result = Value::coerceValue(['foo' => 123, 'unknownField' => 123], $this->testInputObject);
$this->expectError($result, 'Field "unknownField" is not defined by type TestInputObject.');
}
/**
* @it returns error for a misspelled field
*/
public function testReturnsErrorForAMisspelledField()
{
$result = Value::coerceValue(['foo' => 123, 'bart' => 123], $this->testInputObject);
$this->expectError($result, 'Field "bart" is not defined by type TestInputObject; did you mean bar?');
}
private function expectNoErrors($result)
{
$this->assertInternalType('array', $result);
$this->assertNull($result['errors']);
$this->assertNotEquals(Utils::undefined(), $result['value']);
}
private function expectError($result, $expected) {
$this->assertInternalType('array', $result);
$this->assertInternalType('array', $result['errors']);
$this->assertCount(1, $result['errors']);
$this->assertEquals($expected, $result['errors'][0]->getMessage());
$this->assertEquals(Utils::undefined(), $result['value']);
}
}

View File

@ -30,11 +30,12 @@ class ValuesOfCorrectTypeTest extends TestCase
);
}
private function unknownField($typeName, $fieldName, $line, $column) {
private function unknownField($typeName, $fieldName, $line, $column, $message = null) {
return FormattedError::create(
ValuesOfCorrectType::unknownFieldMessage(
$typeName,
$fieldName
$fieldName,
$message
),
[new SourceLocation($line, $column)]
);
@ -581,7 +582,7 @@ class ValuesOfCorrectTypeTest extends TestCase
'"SIT"',
4,
41,
'Did you mean the enum value: SIT?'
'Did you mean the enum value SIT?'
)
]);
}
@ -630,7 +631,13 @@ class ValuesOfCorrectTypeTest extends TestCase
}
}
', [
$this->badValue('DogCommand', 'sit', 4, 41)
$this->badValue(
'DogCommand',
'sit',
4,
41,
'Did you mean the enum value SIT?'
)
]);
}
@ -1070,7 +1077,13 @@ class ValuesOfCorrectTypeTest extends TestCase
}
}
', [
$this->unknownField('ComplexInput', 'unknownField', 6, 15),
$this->unknownField(
'ComplexInput',
'unknownField',
6,
15,
'Did you mean intField or booleanField?'
),
]);
}