Skip to content

Commit b283d9b

Browse files
authored
Add suggestions for invalid values (#1153)
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
1 parent f236898 commit b283d9b

File tree

7 files changed

+232
-40
lines changed

7 files changed

+232
-40
lines changed

src/execution/__tests__/variables-test.js

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -296,7 +296,7 @@ describe('Execute: Handles inputs', () => {
296296
message:
297297
'Variable "$input" got invalid value ' +
298298
'{"a":"foo","b":"bar","c":null}; ' +
299-
'Expected non-nullable type String! at value.c.',
299+
'Expected non-nullable type String! not to be null at value.c.',
300300
locations: [{ line: 2, column: 17 }],
301301
path: undefined,
302302
},
@@ -313,7 +313,7 @@ describe('Execute: Handles inputs', () => {
313313
{
314314
message:
315315
'Variable "$input" got invalid value "foo bar"; ' +
316-
'Expected object type TestInputObject.',
316+
'Expected type TestInputObject to be an object.',
317317
locations: [{ line: 2, column: 17 }],
318318
path: undefined,
319319
},
@@ -535,7 +535,7 @@ describe('Execute: Handles inputs', () => {
535535
{
536536
message:
537537
'Variable "$value" got invalid value null; ' +
538-
'Expected non-nullable type String!.',
538+
'Expected non-nullable type String! not to be null.',
539539
locations: [{ line: 2, column: 31 }],
540540
path: undefined,
541541
},
@@ -734,7 +734,7 @@ describe('Execute: Handles inputs', () => {
734734
{
735735
message:
736736
'Variable "$input" got invalid value null; ' +
737-
'Expected non-nullable type [String]!.',
737+
'Expected non-nullable type [String]! not to be null.',
738738
locations: [{ line: 2, column: 17 }],
739739
path: undefined,
740740
},
@@ -825,7 +825,7 @@ describe('Execute: Handles inputs', () => {
825825
{
826826
message:
827827
'Variable "$input" got invalid value ["A",null,"B"]; ' +
828-
'Expected non-nullable type String! at value[1].',
828+
'Expected non-nullable type String! not to be null at value[1].',
829829
locations: [{ line: 2, column: 17 }],
830830
path: undefined,
831831
},
@@ -847,7 +847,7 @@ describe('Execute: Handles inputs', () => {
847847
{
848848
message:
849849
'Variable "$input" got invalid value null; ' +
850-
'Expected non-nullable type [String!]!.',
850+
'Expected non-nullable type [String!]! not to be null.',
851851
locations: [{ line: 2, column: 17 }],
852852
path: undefined,
853853
},
@@ -887,7 +887,7 @@ describe('Execute: Handles inputs', () => {
887887
{
888888
message:
889889
'Variable "$input" got invalid value ["A",null,"B"]; ' +
890-
'Expected non-nullable type String! at value[1].',
890+
'Expected non-nullable type String! not to be null at value[1].',
891891
locations: [{ line: 2, column: 17 }],
892892
path: undefined,
893893
},

src/jsutils/suggestionList.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,19 +38,34 @@ export default function suggestionList(
3838
* insertion, deletion, or substitution of a single character, or a swap of two
3939
* adjacent characters.
4040
*
41+
* Includes a custom alteration from Damerau-Levenshtein to treat case changes
42+
* as a single edit which helps identify mis-cased values with an edit distance
43+
* of 1.
44+
*
4145
* This distance can be useful for detecting typos in input or sorting
4246
*
4347
* @param {string} a
4448
* @param {string} b
4549
* @return {int} distance in number of edits
4650
*/
47-
function lexicalDistance(a, b) {
51+
function lexicalDistance(aStr, bStr) {
52+
if (aStr === bStr) {
53+
return 0;
54+
}
55+
4856
let i;
4957
let j;
5058
const d = [];
59+
const a = aStr.toLowerCase();
60+
const b = bStr.toLowerCase();
5161
const aLength = a.length;
5262
const bLength = b.length;
5363

64+
// Any case change counts as a single edit
65+
if (a === b) {
66+
return 1;
67+
}
68+
5469
for (i = 0; i <= aLength; i++) {
5570
d[i] = [i];
5671
}

src/type/__tests__/enumType-test.js

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ describe('Type System: Enum Values', () => {
161161
errors: [
162162
{
163163
message:
164-
'Expected type Color, found "GREEN"; Did you mean the enum value: GREEN?',
164+
'Expected type Color, found "GREEN"; Did you mean the enum value GREEN?',
165165
locations: [{ line: 1, column: 23 }],
166166
},
167167
],
@@ -175,7 +175,21 @@ describe('Type System: Enum Values', () => {
175175
errors: [
176176
{
177177
message:
178-
'Expected type Color, found GREENISH; Did you mean the enum value: GREEN?',
178+
'Expected type Color, found GREENISH; Did you mean the enum value GREEN?',
179+
locations: [{ line: 1, column: 23 }],
180+
},
181+
],
182+
});
183+
});
184+
185+
it('does not accept values with incorrect casing', async () => {
186+
expect(
187+
await graphql(schema, '{ colorEnum(fromEnum: green) }'),
188+
).to.jsonEqual({
189+
errors: [
190+
{
191+
message:
192+
'Expected type Color, found green; Did you mean the enum value GREEN?',
179193
locations: [{ line: 1, column: 23 }],
180194
},
181195
],

src/utilities/__tests__/coerceValue-test.js

Lines changed: 109 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,15 +8,24 @@
88
import { describe, it } from 'mocha';
99
import { expect } from 'chai';
1010
import { coerceValue } from '../coerceValue';
11-
import { GraphQLInt, GraphQLFloat, GraphQLString } from '../../type';
11+
import {
12+
GraphQLInt,
13+
GraphQLFloat,
14+
GraphQLString,
15+
GraphQLEnumType,
16+
GraphQLInputObjectType,
17+
GraphQLNonNull,
18+
} from '../../type';
1219

1320
function expectNoErrors(result) {
1421
expect(result.errors).to.equal(undefined);
22+
expect(result.value).not.to.equal(undefined);
1523
}
1624

1725
function expectError(result, expected) {
1826
const messages = result.errors && result.errors.map(error => error.message);
1927
expect(messages).to.deep.equal([expected]);
28+
expect(result.value).to.equal(undefined);
2029
}
2130

2231
describe('coerceValue', () => {
@@ -130,4 +139,103 @@ describe('coerceValue', () => {
130139
);
131140
});
132141
});
142+
143+
describe('for GraphQLEnum', () => {
144+
const TestEnum = new GraphQLEnumType({
145+
name: 'TestEnum',
146+
values: {
147+
FOO: { value: 'InternalFoo' },
148+
BAR: { value: 123456789 },
149+
},
150+
});
151+
152+
it('returns no error for a known enum name', () => {
153+
const fooResult = coerceValue('FOO', TestEnum);
154+
expectNoErrors(fooResult);
155+
expect(fooResult.value).to.equal('InternalFoo');
156+
157+
const barResult = coerceValue('BAR', TestEnum);
158+
expectNoErrors(barResult);
159+
expect(barResult.value).to.equal(123456789);
160+
});
161+
162+
it('results error for misspelled enum value', () => {
163+
const result = coerceValue('foo', TestEnum);
164+
expectError(result, 'Expected type TestEnum; did you mean FOO?');
165+
});
166+
167+
it('results error for incorrect value type', () => {
168+
const result1 = coerceValue(123, TestEnum);
169+
expectError(result1, 'Expected type TestEnum.');
170+
171+
const result2 = coerceValue({ field: 'value' }, TestEnum);
172+
expectError(result2, 'Expected type TestEnum.');
173+
});
174+
});
175+
176+
describe('for GraphQLInputObject', () => {
177+
const TestInputObject = new GraphQLInputObjectType({
178+
name: 'TestInputObject',
179+
fields: {
180+
foo: { type: GraphQLNonNull(GraphQLInt) },
181+
bar: { type: GraphQLInt },
182+
},
183+
});
184+
185+
it('returns no error for a valid input', () => {
186+
const result = coerceValue({ foo: 123 }, TestInputObject);
187+
expectNoErrors(result);
188+
expect(result.value).to.deep.equal({ foo: 123 });
189+
});
190+
191+
it('returns no error for a non-object type', () => {
192+
const result = coerceValue(123, TestInputObject);
193+
expectError(result, 'Expected type TestInputObject to be an object.');
194+
});
195+
196+
it('returns no error for an invalid field', () => {
197+
const result = coerceValue({ foo: 'abc' }, TestInputObject);
198+
expectError(
199+
result,
200+
'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc',
201+
);
202+
});
203+
204+
it('returns multiple errors for multiple invalid fields', () => {
205+
const result = coerceValue({ foo: 'abc', bar: 'def' }, TestInputObject);
206+
expect(
207+
result.errors && result.errors.map(error => error.message),
208+
).to.deep.equal([
209+
'Expected type Int at value.foo; Int cannot represent non 32-bit signed integer value: abc',
210+
'Expected type Int at value.bar; Int cannot represent non 32-bit signed integer value: def',
211+
]);
212+
});
213+
214+
it('returns error for a missing required field', () => {
215+
const result = coerceValue({ bar: 123 }, TestInputObject);
216+
expectError(
217+
result,
218+
'Field value.foo of required type Int! was not provided.',
219+
);
220+
});
221+
222+
it('returns error for an unknown field', () => {
223+
const result = coerceValue(
224+
{ foo: 123, unknownField: 123 },
225+
TestInputObject,
226+
);
227+
expectError(
228+
result,
229+
'Field "unknownField" is not defined by type TestInputObject.',
230+
);
231+
});
232+
233+
it('returns error for a misspelled field', () => {
234+
const result = coerceValue({ foo: 123, bart: 123 }, TestInputObject);
235+
expectError(
236+
result,
237+
'Field "bart" is not defined by type TestInputObject; did you mean bar?',
238+
);
239+
});
240+
});
133241
});

src/utilities/coerceValue.js

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
import { forEach, isCollection } from 'iterall';
1111
import isInvalid from '../jsutils/isInvalid';
1212
import isNullish from '../jsutils/isNullish';
13+
import orList from '../jsutils/orList';
14+
import suggestionList from '../jsutils/suggestionList';
1315
import { GraphQLError } from '../error';
1416
import type { ASTNode } from '../language/ast';
1517
import {
@@ -46,7 +48,7 @@ export function coerceValue(
4648
if (isNullish(value)) {
4749
return ofErrors([
4850
coercionError(
49-
`Expected non-nullable type ${String(type)}`,
51+
`Expected non-nullable type ${String(type)} not to be null`,
5052
blameNode,
5153
path,
5254
),
@@ -74,7 +76,13 @@ export function coerceValue(
7476
return ofValue(parseResult);
7577
} catch (error) {
7678
return ofErrors([
77-
coercionError(`Expected type ${type.name}`, blameNode, path, error),
79+
coercionError(
80+
`Expected type ${type.name}`,
81+
blameNode,
82+
path,
83+
error.message,
84+
error,
85+
),
7886
]);
7987
}
8088
}
@@ -86,8 +94,16 @@ export function coerceValue(
8694
return ofValue(enumValue.value);
8795
}
8896
}
97+
const suggestions = suggestionList(
98+
String(value),
99+
type.getValues().map(enumValue => enumValue.name),
100+
);
101+
const didYouMean =
102+
suggestions.length !== 0
103+
? `did you mean ${orList(suggestions)}?`
104+
: undefined;
89105
return ofErrors([
90-
coercionError(`Expected type ${type.name}`, blameNode, path),
106+
coercionError(`Expected type ${type.name}`, blameNode, path, didYouMean),
91107
]);
92108
}
93109

@@ -119,7 +135,11 @@ export function coerceValue(
119135
if (isInputObjectType(type)) {
120136
if (typeof value !== 'object') {
121137
return ofErrors([
122-
coercionError(`Expected object type ${type.name}`, blameNode, path),
138+
coercionError(
139+
`Expected type ${type.name} to be an object`,
140+
blameNode,
141+
path,
142+
),
123143
]);
124144
}
125145
let errors;
@@ -164,12 +184,18 @@ export function coerceValue(
164184
for (const fieldName in value) {
165185
if (hasOwnProperty.call(value, fieldName)) {
166186
if (!fields[fieldName]) {
187+
const suggestions = suggestionList(fieldName, Object.keys(fields));
188+
const didYouMean =
189+
suggestions.length !== 0
190+
? `did you mean ${orList(suggestions)}?`
191+
: undefined;
167192
errors = add(
168193
errors,
169194
coercionError(
170195
`Field "${fieldName}" is not defined by type ${type.name}`,
171196
blameNode,
172197
path,
198+
didYouMean,
173199
),
174200
);
175201
}
@@ -199,15 +225,13 @@ function atPath(prev, key) {
199225
return { prev, key };
200226
}
201227

202-
function coercionError(message, blameNode, path, originalError) {
228+
function coercionError(message, blameNode, path, subMessage, originalError) {
203229
const pathStr = printPath(path);
204230
// Return a GraphQLError instance
205231
return new GraphQLError(
206232
message +
207233
(pathStr ? ' at ' + pathStr : '') +
208-
(originalError && originalError.message
209-
? '; ' + originalError.message
210-
: '.'),
234+
(subMessage ? '; ' + subMessage : '.'),
211235
blameNode,
212236
undefined,
213237
undefined,

0 commit comments

Comments
 (0)