diff --git a/src/jsutils/__tests__/suggestionList-test.js b/src/jsutils/__tests__/suggestionList-test.js new file mode 100644 index 0000000000..5e4c071be0 --- /dev/null +++ b/src/jsutils/__tests__/suggestionList-test.js @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { suggestionList } from '../suggestionList'; + +describe('suggestionList', () => { + + it('Returns results when input is empty', () => { + expect(suggestionList('', [ 'a' ])).to.deep.equal([ 'a' ]); + }); + + it('Returns empty array when there are no options', () => { + expect(suggestionList('input', [])).to.deep.equal([]); + }); + + it('Returns options sorted based on similarity', () => { + expect(suggestionList('abc', [ 'a', 'ab', 'abc' ])) + .to.deep.equal([ 'abc', 'ab' ]); + }); +}); diff --git a/src/jsutils/suggestionList.js b/src/jsutils/suggestionList.js new file mode 100644 index 0000000000..3f9962f87d --- /dev/null +++ b/src/jsutils/suggestionList.js @@ -0,0 +1,82 @@ +/* @flow */ +/** + * Copyright (c) 2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + */ + +/** + * 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. + */ +export function suggestionList( + input: string, + options: Array + ): Array { + let i; + const d = {}; + const oLength = options.length; + const inputThreshold = input.length / 2; + for (i = 0; i < oLength; i++) { + const distance = lexicalDistance(input, options[i]); + const threshold = Math.max(inputThreshold, options[i].length / 2, 1); + if (distance <= threshold) { + d[options[i]] = distance; + } + } + const result = Object.keys(d); + return result.sort((a , b) => d[a] - d[b]); +} + +/** + * Computes the lexical distance between strings A and B. + * + * The "distance" between two strings is given by counting the minimum number + * of edits needed to transform string A into string B. An edit can be an + * insertion, deletion, or substitution of a single character, or a swap of two + * adjacent characters. + * + * This distance can be useful for detecting typos in input or sorting + * + * @param {string} a + * @param {string} b + * @return {int} distance in number of edits + */ +function lexicalDistance(a, b) { + let i; + let j; + const d = []; + const aLength = a.length; + const bLength = b.length; + + for (i = 0; i <= aLength; i++) { + d[i] = [ i ]; + } + + for (j = 1; j <= bLength; j++) { + d[0][j] = j; + } + + for (i = 1; i <= aLength; i++) { + for (j = 1; j <= bLength; j++) { + const cost = a[i - 1] === b[j - 1] ? 0 : 1; + + d[i][j] = Math.min( + d[i - 1][j] + 1, + d[i][j - 1] + 1, + d[i - 1][j - 1] + cost + ); + + if (i > 1 && j > 1 && + a[i - 1] === b[j - 2] && + a[i - 2] === b[j - 1]) { + d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + cost); + } + } + } + + return d[aLength][bLength]; +} diff --git a/src/validation/__tests__/FieldsOnCorrectType-test.js b/src/validation/__tests__/FieldsOnCorrectType-test.js index 496eece221..b74133cd13 100644 --- a/src/validation/__tests__/FieldsOnCorrectType-test.js +++ b/src/validation/__tests__/FieldsOnCorrectType-test.js @@ -16,9 +16,21 @@ import { } from '../rules/FieldsOnCorrectType'; -function undefinedField(field, type, suggestions, line, column) { +function undefinedField( + field, + type, + suggestedTypes, + suggestedFields, + line, + column +) { return { - message: undefinedFieldMessage(field, type, suggestions), + message: undefinedFieldMessage( + field, + type, + suggestedTypes, + suggestedFields + ), locations: [ { line, column } ], }; } @@ -85,8 +97,16 @@ describe('Validate: Fields on correct type', () => { } } }`, - [ undefinedField('unknown_pet_field', 'Pet', [], 3, 9), - undefinedField('unknown_cat_field', 'Cat', [], 5, 13) ] + [ undefinedField('unknown_pet_field', 'Pet', [], [], 3, 9), + undefinedField( + 'unknown_cat_field', + 'Cat', + [], + [], + 5, + 13 + ) + ] ); }); @@ -95,7 +115,15 @@ describe('Validate: Fields on correct type', () => { fragment fieldNotDefined on Dog { meowVolume }`, - [ undefinedField('meowVolume', 'Dog', [], 3, 9) ] + [ undefinedField( + 'meowVolume', + 'Dog', + [], + [ 'barkVolume' ], + 3, + 9 + ) + ] ); }); @@ -106,7 +134,15 @@ describe('Validate: Fields on correct type', () => { deeper_unknown_field } }`, - [ undefinedField('unknown_field', 'Dog', [], 3, 9) ] + [ undefinedField( + 'unknown_field', + 'Dog', + [], + [], + 3, + 9 + ) + ] ); }); @@ -117,7 +153,7 @@ describe('Validate: Fields on correct type', () => { unknown_field } }`, - [ undefinedField('unknown_field', 'Pet', [], 4, 11) ] + [ undefinedField('unknown_field', 'Pet', [], [], 4, 11) ] ); }); @@ -128,7 +164,15 @@ describe('Validate: Fields on correct type', () => { meowVolume } }`, - [ undefinedField('meowVolume', 'Dog', [], 4, 11) ] + [ undefinedField( + 'meowVolume', + 'Dog', + [], + [ 'barkVolume' ], + 4, + 11 + ) + ] ); }); @@ -137,7 +181,15 @@ describe('Validate: Fields on correct type', () => { fragment aliasedFieldTargetNotDefined on Dog { volume : mooVolume }`, - [ undefinedField('mooVolume', 'Dog', [], 3, 9) ] + [ undefinedField( + 'mooVolume', + 'Dog', + [], + [ 'barkVolume' ], + 3, + 9 + ) + ] ); }); @@ -146,7 +198,15 @@ describe('Validate: Fields on correct type', () => { fragment aliasedLyingFieldTargetNotDefined on Dog { barkVolume : kawVolume }`, - [ undefinedField('kawVolume', 'Dog', [], 3, 9) ] + [ undefinedField( + 'kawVolume', + 'Dog', + [], + [ 'barkVolume' ], + 3, + 9 + ) + ] ); }); @@ -155,7 +215,7 @@ describe('Validate: Fields on correct type', () => { fragment notDefinedOnInterface on Pet { tailLength }`, - [ undefinedField('tailLength', 'Pet', [], 3, 9) ] + [ undefinedField('tailLength', 'Pet', [], [], 3, 9) ] ); }); @@ -164,7 +224,7 @@ describe('Validate: Fields on correct type', () => { fragment definedOnImplementorsButNotInterface on Pet { nickname }`, - [ undefinedField('nickname', 'Pet', [ 'Cat', 'Dog' ], 3, 9) ] + [ undefinedField('nickname', 'Pet', [ 'Cat', 'Dog' ], [ 'name' ], 3, 9) ] ); }); @@ -181,7 +241,7 @@ describe('Validate: Fields on correct type', () => { fragment directFieldSelectionOnUnion on CatOrDog { directField }`, - [ undefinedField('directField', 'CatOrDog', [], 3, 9) ] + [ undefinedField('directField', 'CatOrDog', [], [], 3, 9) ] ); }); @@ -195,6 +255,7 @@ describe('Validate: Fields on correct type', () => { 'name', 'CatOrDog', [ 'Being', 'Pet', 'Canine', 'Cat', 'Dog' ], + [], 3, 9 ) @@ -218,25 +279,33 @@ describe('Validate: Fields on correct type', () => { describe('Fields on correct type error message', () => { it('Works with no suggestions', () => { expect( - undefinedFieldMessage('T', 'f', []) - ).to.equal('Cannot query field "T" on type "f".'); + undefinedFieldMessage('f', 'T', [], []) + ).to.equal('Cannot query field "f" on type "T".'); }); it('Works with no small numbers of suggestions', () => { expect( - undefinedFieldMessage('T', 'f', [ 'A', 'B' ]) - ).to.equal('Cannot query field "T" on type "f". ' + + undefinedFieldMessage('f', 'T', [ 'A', 'B' ], [ 'z', 'y' ]) + ).to.equal('Cannot query field "f" on type "T". ' + 'However, this field exists on "A", "B". ' + - 'Perhaps you meant to use an inline fragment?'); + 'Perhaps you meant to use an inline fragment? ' + + 'Did you mean to query "z", "y"?'); }); it('Works with lots of suggestions', () => { expect( - undefinedFieldMessage('T', 'f', [ 'A', 'B', 'C', 'D', 'E', 'F' ]) - ).to.equal('Cannot query field "T" on type "f". ' + + undefinedFieldMessage( + 'f', + 'T', + [ 'A', 'B', 'C', 'D', 'E', 'F' ], + [ 'z', 'y', 'x', 'w', 'v', 'u' ] + ) + ).to.equal('Cannot query field "f" on type "T". ' + 'However, this field exists on "A", "B", "C", "D", "E", ' + 'and 1 other types. ' + - 'Perhaps you meant to use an inline fragment?'); + 'Perhaps you meant to use an inline fragment? ' + + 'Did you mean to query "z", "y", "x", "w", "v", or 1 other field?' + ); }); }); }); diff --git a/src/validation/__tests__/KnownArgumentNames-test.js b/src/validation/__tests__/KnownArgumentNames-test.js index bc9fec1ec9..eeae9cef1f 100644 --- a/src/validation/__tests__/KnownArgumentNames-test.js +++ b/src/validation/__tests__/KnownArgumentNames-test.js @@ -16,16 +16,22 @@ import { } from '../rules/KnownArgumentNames'; -function unknownArg(argName, fieldName, typeName, line, column) { +function unknownArg(argName, fieldName, typeName, suggestedArgs, line, column) { return { - message: unknownArgMessage(argName, fieldName, typeName), + message: unknownArgMessage(argName, fieldName, typeName, suggestedArgs), locations: [ { line, column } ], }; } -function unknownDirectiveArg(argName, directiveName, line, column) { +function unknownDirectiveArg( + argName, + directiveName, + suggestedArgs, + line, + column +) { return { - message: unknownDirectiveArgMessage(argName, directiveName), + message: unknownDirectiveArgMessage(argName, directiveName, suggestedArgs), locations: [ { line, column } ], }; } @@ -103,7 +109,7 @@ describe('Validate: Known argument names', () => { dog @skip(unless: true) } `, [ - unknownDirectiveArg('unless', 'skip', 3, 19), + unknownDirectiveArg('unless', 'skip', [], 3, 19), ]); }); @@ -113,7 +119,7 @@ describe('Validate: Known argument names', () => { doesKnowCommand(unknown: true) } `, [ - unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 25), + unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 3, 25), ]); }); @@ -123,8 +129,8 @@ describe('Validate: Known argument names', () => { doesKnowCommand(whoknows: 1, dogCommand: SIT, unknown: true) } `, [ - unknownArg('whoknows', 'doesKnowCommand', 'Dog', 3, 25), - unknownArg('unknown', 'doesKnowCommand', 'Dog', 3, 55), + unknownArg('whoknows', 'doesKnowCommand', 'Dog', [], 3, 25), + unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 3, 55), ]); }); @@ -143,8 +149,8 @@ describe('Validate: Known argument names', () => { } } `, [ - unknownArg('unknown', 'doesKnowCommand', 'Dog', 4, 27), - unknownArg('unknown', 'doesKnowCommand', 'Dog', 9, 31), + unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 4, 27), + unknownArg('unknown', 'doesKnowCommand', 'Dog', [], 9, 31), ]); }); diff --git a/src/validation/__tests__/KnownTypeNames-test.js b/src/validation/__tests__/KnownTypeNames-test.js index e158ad3acb..eca4a0424c 100644 --- a/src/validation/__tests__/KnownTypeNames-test.js +++ b/src/validation/__tests__/KnownTypeNames-test.js @@ -15,9 +15,9 @@ import { } from '../rules/KnownTypeNames'; -function unknownType(typeName, line, column) { +function unknownType(typeName, suggestedTypes, line, column) { return { - message: unknownTypeMessage(typeName), + message: unknownTypeMessage(typeName, suggestedTypes), locations: [ { line, column } ], }; } @@ -49,9 +49,24 @@ describe('Validate: Known type names', () => { name } `, [ - unknownType('JumbledUpLetters', 2, 23), - unknownType('Badger', 5, 25), - unknownType('Peettt', 8, 29) + unknownType( + 'JumbledUpLetters', + [], + 2, + 23 + ), + unknownType( + 'Badger', + [], + 5, + 25 + ), + unknownType( + 'Peettt', + [ 'Pet' ], + 8, + 29 + ) ]); }); @@ -73,7 +88,12 @@ describe('Validate: Known type names', () => { } } `, [ - unknownType('NotInTheSchema', 12, 23), + unknownType( + 'NotInTheSchema', + [], + 12, + 23 + ), ]); }); diff --git a/src/validation/rules/FieldsOnCorrectType.js b/src/validation/rules/FieldsOnCorrectType.js index f2962afa68..70a642d362 100644 --- a/src/validation/rules/FieldsOnCorrectType.js +++ b/src/validation/rules/FieldsOnCorrectType.js @@ -10,16 +10,23 @@ import type { ValidationContext } from '../index'; import { GraphQLError } from '../../error'; +import { suggestionList } from '../../jsutils/suggestionList'; import type { Field } from '../../language/ast'; import type { GraphQLSchema } from '../../type/schema'; import type { GraphQLAbstractType } from '../../type/definition'; -import { isAbstractType } from '../../type/definition'; +import { + isAbstractType, + GraphQLObjectType, + GraphQLInputObjectType, + GraphQLInterfaceType +} from '../../type/definition'; export function undefinedFieldMessage( fieldName: string, type: string, - suggestedTypes: Array + suggestedTypes: Array, + suggestedFields: Array ): string { let message = `Cannot query field "${fieldName}" on type "${type}".`; const MAX_LENGTH = 5; @@ -34,6 +41,16 @@ export function undefinedFieldMessage( message += ` However, this field exists on ${suggestions}.`; message += ' Perhaps you meant to use an inline fragment?'; } + if (suggestedFields.length !== 0) { + let suggestions = suggestedFields + .slice(0, MAX_LENGTH) + .map(t => `"${t}"`) + .join(', '); + if (suggestedFields.length > MAX_LENGTH) { + suggestions += `, or ${suggestedFields.length - MAX_LENGTH} other field`; + } + message += ` Did you mean to query ${suggestions}?`; + } return message; } @@ -50,10 +67,10 @@ export function FieldsOnCorrectType(context: ValidationContext): any { if (type) { const fieldDef = context.getFieldDef(); if (!fieldDef) { + const schema = context.getSchema(); // This isn't valid. Let's find suggestions, if any. let suggestedTypes = []; if (isAbstractType(type)) { - const schema = context.getSchema(); suggestedTypes = getSiblingInterfacesIncludingField( schema, type, @@ -63,8 +80,22 @@ export function FieldsOnCorrectType(context: ValidationContext): any { getImplementationsIncludingField(schema, type, node.name.value) ); } + let suggestedFields = []; + if (type instanceof GraphQLObjectType || + type instanceof GraphQLInterfaceType || + type instanceof GraphQLInputObjectType) { + suggestedFields = suggestionList( + node.name.value, + Object.keys(type.getFields()) + ); + } context.reportError(new GraphQLError( - undefinedFieldMessage(node.name.value, type.name, suggestedTypes), + undefinedFieldMessage( + node.name.value, + type.name, + suggestedTypes, + suggestedFields + ), [ node ] )); } @@ -113,4 +144,3 @@ function getSiblingInterfacesIncludingField( return Object.keys(suggestedInterfaces) .sort((a,b) => suggestedInterfaces[b] - suggestedInterfaces[a]); } - diff --git a/src/validation/rules/KnownArgumentNames.js b/src/validation/rules/KnownArgumentNames.js index e389d65371..9f9c3dc11c 100644 --- a/src/validation/rules/KnownArgumentNames.js +++ b/src/validation/rules/KnownArgumentNames.js @@ -12,6 +12,7 @@ import type { ValidationContext } from '../index'; import { GraphQLError } from '../../error'; import find from '../../jsutils/find'; import invariant from '../../jsutils/invariant'; +import { suggestionList } from '../../jsutils/suggestionList'; import { FIELD, DIRECTIVE @@ -22,17 +23,34 @@ import type { GraphQLType } from '../../type/definition'; export function unknownArgMessage( argName: string, fieldName: string, - type: GraphQLType + type: GraphQLType, + suggestedArgs: Array ): string { - return `Unknown argument "${argName}" on field "${fieldName}" of ` + + let message = `Unknown argument "${argName}" on field "${fieldName}" of ` + `type "${type}".`; + if (suggestedArgs.length) { + const suggestions = suggestedArgs + .map(t => `"${t}"`) + .join(', '); + message += ` Perhaps you meant ${suggestions}?`; + } + return message; } export function unknownDirectiveArgMessage( argName: string, - directiveName: string + directiveName: string, + suggestedArgs: Array ): string { - return `Unknown argument "${argName}" on directive "@${directiveName}".`; + let message = + `Unknown argument "${argName}" on directive "@${directiveName}".`; + if (suggestedArgs.length) { + const suggestions = suggestedArgs + .map(t => `"${t}"`) + .join(', '); + message += ` Perhaps you meant ${suggestions}?`; + } + return message; } /** @@ -59,7 +77,11 @@ export function KnownArgumentNames(context: ValidationContext): any { unknownArgMessage( node.name.value, fieldDef.name, - parentType.name + parentType.name, + suggestionList( + node.name.value, + fieldDef.args.map(arg => arg.name) + ) ), [ node ] )); @@ -74,7 +96,14 @@ export function KnownArgumentNames(context: ValidationContext): any { ); if (!directiveArgDef) { context.reportError(new GraphQLError( - unknownDirectiveArgMessage(node.name.value, directive.name), + unknownDirectiveArgMessage( + node.name.value, + directive.name, + suggestionList( + node.name.value, + directive.args.map(arg => arg.name) + ) + ), [ node ] )); } diff --git a/src/validation/rules/KnownTypeNames.js b/src/validation/rules/KnownTypeNames.js index 6ab367c3d9..c8858ba15f 100644 --- a/src/validation/rules/KnownTypeNames.js +++ b/src/validation/rules/KnownTypeNames.js @@ -10,11 +10,24 @@ import type { ValidationContext } from '../index'; import { GraphQLError } from '../../error'; +import { suggestionList } from '../../jsutils/suggestionList'; import type { GraphQLType } from '../../type/definition'; -export function unknownTypeMessage(type: GraphQLType): string { - return `Unknown type "${type}".`; +export function unknownTypeMessage( + type: GraphQLType, + suggestedTypes: Array +): string { + let message = `Unknown type "${type}".`; + const MAX_LENGTH = 5; + if (suggestedTypes.length) { + const suggestions = suggestedTypes + .slice(0, MAX_LENGTH) + .map(t => `"${t}"`) + .join(', '); + message += ` Perhaps you meant one of the following: ${suggestions}.`; + } + return message; } /** @@ -33,11 +46,18 @@ export function KnownTypeNames(context: ValidationContext): any { UnionTypeDefinition: () => false, InputObjectTypeDefinition: () => false, NamedType(node) { + const schema = context.getSchema(); const typeName = node.name.value; - const type = context.getSchema().getType(typeName); + const type = schema.getType(typeName); if (!type) { context.reportError( - new GraphQLError(unknownTypeMessage(typeName), [ node ]) + new GraphQLError( + unknownTypeMessage( + typeName, + suggestionList(typeName, Object.keys(schema.getTypeMap())) + ), + [ node ] + ) ); } }