From 95a4ecd841312e27b542fb147295923832fdc7bc Mon Sep 17 00:00:00 2001 From: Richard Musiol Date: Mon, 20 Mar 2017 02:38:44 +0100 Subject: [PATCH] validation: fields --- internal/query/query.go | 30 ++-- internal/schema/schema.go | 8 ++ internal/tests/all_test.go | 2 +- internal/tests/testdata/tests.json | 214 +++++++++++++++++++++++++++++ internal/validation/suggestion.go | 71 ++++++++++ internal/validation/validation.go | 76 +++++++--- 6 files changed, 368 insertions(+), 33 deletions(-) create mode 100644 internal/validation/suggestion.go diff --git a/internal/query/query.go b/internal/query/query.go index 80d3ee207e5..89accc1a50c 100644 --- a/internal/query/query.go +++ b/internal/query/query.go @@ -54,6 +54,7 @@ type Field struct { Arguments common.ArgumentList Directives map[string]common.ArgumentList SelSet *SelectionSet + Location *errors.Location } type FragmentSpread struct { @@ -210,7 +211,9 @@ func parseSelection(l *lexer.Lexer) Selection { } func parseField(l *lexer.Lexer) *Field { - f := &Field{} + f := &Field{ + Location: l.Location(), + } f.Alias = l.ConsumeIdent() f.Name = f.Alias if l.Peek() == ':' { @@ -231,19 +234,20 @@ func parseSpread(l *lexer.Lexer) Selection { l.ConsumeToken('.') l.ConsumeToken('.') l.ConsumeToken('.') - ident := l.ConsumeIdent() - if ident == "on" { - f := &Fragment{} + f := &Fragment{} + if l.Peek() == scanner.Ident { + ident := l.ConsumeIdent() + if ident != "on" { + fs := &FragmentSpread{ + Name: ident, + } + fs.Directives = common.ParseDirectives(l) + return fs + } f.On = l.ConsumeIdent() - f.Directives = common.ParseDirectives(l) - f.SelSet = parseSelectionSet(l) - return f } - - fs := &FragmentSpread{ - Name: ident, - } - fs.Directives = common.ParseDirectives(l) - return fs + f.Directives = common.ParseDirectives(l) + f.SelSet = parseSelectionSet(l) + return f } diff --git a/internal/schema/schema.go b/internal/schema/schema.go index a9d52705a68..41d3a9eda74 100644 --- a/internal/schema/schema.go +++ b/internal/schema/schema.go @@ -89,6 +89,14 @@ func (l FieldList) Get(name string) *Field { return nil } +func (l FieldList) Names() []string { + names := make([]string, len(l)) + for i, f := range l { + names[i] = f.Name + } + return names +} + type Directive struct { Name string Desc string diff --git a/internal/tests/all_test.go b/internal/tests/all_test.go index 345b92396d6..0bb786f6ee9 100644 --- a/internal/tests/all_test.go +++ b/internal/tests/all_test.go @@ -45,7 +45,7 @@ func TestAll(t *testing.T) { t.Run(test.Name, func(t *testing.T) { d, err := query.Parse(test.Query) if err != nil { - t.Error(err) + t.Fatal(err) } got := validation.Validate(s, d) if got == nil { diff --git a/internal/tests/testdata/tests.json b/internal/tests/testdata/tests.json index 57d5a26c447..5bd1788a171 100644 --- a/internal/tests/testdata/tests.json +++ b/internal/tests/testdata/tests.json @@ -839,5 +839,219 @@ ] } ] + }, + { + "name": "Validate: Fields on correct type/Object field selection", + "query": "\n fragment objectFieldSelection on Dog {\n __typename\n name\n }\n ", + "errors": [] + }, + { + "name": "Validate: Fields on correct type/Aliased object field selection", + "query": "\n fragment aliasedObjectFieldSelection on Dog {\n tn : __typename\n otherName : name\n }\n ", + "errors": [] + }, + { + "name": "Validate: Fields on correct type/Interface field selection", + "query": "\n fragment interfaceFieldSelection on Pet {\n __typename\n name\n }\n ", + "errors": [] + }, + { + "name": "Validate: Fields on correct type/Aliased interface field selection", + "query": "\n fragment interfaceFieldSelection on Pet {\n otherName : name\n }\n ", + "errors": [] + }, + { + "name": "Validate: Fields on correct type/Lying alias selection", + "query": "\n fragment lyingAliasSelection on Dog {\n name : nickname\n }\n ", + "errors": [] + }, + { + "name": "Validate: Fields on correct type/Ignores fields on unknown type", + "query": "\n fragment unknownSelection on UnknownType {\n unknownField\n }\n ", + "errors": [] + }, + { + "name": "Validate: Fields on correct type/reports errors when type is known again", + "query": "\n fragment typeKnownAgain on Pet {\n unknown_pet_field {\n ... on Cat {\n unknown_cat_field\n }\n }\n }", + "errors": [ + { + "message": "Cannot query field \"unknown_pet_field\" on type \"Pet\".", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + }, + { + "message": "Cannot query field \"unknown_cat_field\" on type \"Cat\".", + "locations": [ + { + "line": 5, + "column": 13 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Field not defined on fragment", + "query": "\n fragment fieldNotDefined on Dog {\n meowVolume\n }", + "errors": [ + { + "message": "Cannot query field \"meowVolume\" on type \"Dog\". Did you mean \"barkVolume\"?", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Ignores deeply unknown field", + "query": "\n fragment deepFieldNotDefined on Dog {\n unknown_field {\n deeper_unknown_field\n }\n }", + "errors": [ + { + "message": "Cannot query field \"unknown_field\" on type \"Dog\".", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Sub-field not defined", + "query": "\n fragment subFieldNotDefined on Human {\n pets {\n unknown_field\n }\n }", + "errors": [ + { + "message": "Cannot query field \"unknown_field\" on type \"Pet\".", + "locations": [ + { + "line": 4, + "column": 11 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Field not defined on inline fragment", + "query": "\n fragment fieldNotDefined on Pet {\n ... on Dog {\n meowVolume\n }\n }", + "errors": [ + { + "message": "Cannot query field \"meowVolume\" on type \"Dog\". Did you mean \"barkVolume\"?", + "locations": [ + { + "line": 4, + "column": 11 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Aliased field target not defined", + "query": "\n fragment aliasedFieldTargetNotDefined on Dog {\n volume : mooVolume\n }", + "errors": [ + { + "message": "Cannot query field \"mooVolume\" on type \"Dog\". Did you mean \"barkVolume\"?", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Aliased lying field target not defined", + "query": "\n fragment aliasedLyingFieldTargetNotDefined on Dog {\n barkVolume : kawVolume\n }", + "errors": [ + { + "message": "Cannot query field \"kawVolume\" on type \"Dog\". Did you mean \"barkVolume\"?", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Not defined on interface", + "query": "\n fragment notDefinedOnInterface on Pet {\n tailLength\n }", + "errors": [ + { + "message": "Cannot query field \"tailLength\" on type \"Pet\".", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Defined on implementors but not on interface", + "query": "\n fragment definedOnImplementorsButNotInterface on Pet {\n nickname\n }", + "errors": [ + { + "message": "Cannot query field \"nickname\" on type \"Pet\".", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Meta field selection on union", + "query": "\n fragment directFieldSelectionOnUnion on CatOrDog {\n __typename\n }", + "errors": [] + }, + { + "name": "Validate: Fields on correct type/Direct field selection on union", + "query": "\n fragment directFieldSelectionOnUnion on CatOrDog {\n directField\n }", + "errors": [ + { + "message": "Cannot query field \"directField\" on type \"CatOrDog\".", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/Defined on implementors queried on union", + "query": "\n fragment definedOnImplementorsQueriedOnUnion on CatOrDog {\n name\n }", + "errors": [ + { + "message": "Cannot query field \"name\" on type \"CatOrDog\".", + "locations": [ + { + "line": 3, + "column": 9 + } + ] + } + ] + }, + { + "name": "Validate: Fields on correct type/valid field in inline fragment", + "query": "\n fragment objectFieldSelection on Pet {\n ... on Dog {\n name\n }\n ... {\n name\n }\n }\n ", + "errors": [] } ] \ No newline at end of file diff --git a/internal/validation/suggestion.go b/internal/validation/suggestion.go new file mode 100644 index 00000000000..9702b5f5294 --- /dev/null +++ b/internal/validation/suggestion.go @@ -0,0 +1,71 @@ +package validation + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +func makeSuggestion(prefix string, options []string, input string) string { + var selected []string + distances := make(map[string]int) + for _, opt := range options { + distance := levenshteinDistance(input, opt) + threshold := max(len(input)/2, max(len(opt)/2, 1)) + if distance < threshold { + selected = append(selected, opt) + distances[opt] = distance + } + } + + if len(selected) == 0 { + return "" + } + sort.Slice(selected, func(i, j int) bool { + return distances[selected[i]] < distances[selected[j]] + }) + + parts := make([]string, len(selected)) + for i, opt := range selected { + parts[i] = strconv.Quote(opt) + } + if len(parts) > 1 { + parts[len(parts)-1] = "or " + parts[len(parts)-1] + } + return fmt.Sprintf(" %s %s?", prefix, strings.Join(parts, ", ")) +} + +func levenshteinDistance(s1, s2 string) int { + column := make([]int, len(s1)+1) + for y := range s1 { + column[y+1] = y + 1 + } + for x, rx := range s2 { + column[0] = x + 1 + lastdiag := x + for y, ry := range s1 { + olddiag := column[y+1] + if rx != ry { + lastdiag++ + } + column[y+1] = min(column[y+1]+1, min(column[y]+1, lastdiag)) + lastdiag = olddiag + } + } + return column[len(s1)] +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +func max(a, b int) int { + if a > b { + return a + } + return b +} diff --git a/internal/validation/validation.go b/internal/validation/validation.go index b757cb90dc2..611cb5556ae 100644 --- a/internal/validation/validation.go +++ b/internal/validation/validation.go @@ -46,34 +46,42 @@ func Validate(s *schema.Schema, q *query.Document) (errs []*errors.QueryError) { } errs = append(errs, validateSelectionSet(s, op.SelSet, entryPoint)...) } + + for _, frag := range q.Fragments { + t, ok := s.Types[frag.On] + if !ok { + continue + } + errs = append(errs, validateSelectionSet(s, frag.SelSet, t)...) + } + return } func validateSelectionSet(s *schema.Schema, selSet *query.SelectionSet, t common.Type) []*errors.QueryError { var errs []*errors.QueryError - switch t := t.(type) { - case *schema.Object: - for _, sel := range selSet.Selections { - errs = append(errs, validateSelection(s, sel, t.Fields)...) - } - case *schema.Interface: - for _, sel := range selSet.Selections { - errs = append(errs, validateSelection(s, sel, t.Fields)...) - } + for _, sel := range selSet.Selections { + errs = append(errs, validateSelection(s, sel, t)...) } return errs } -func validateSelection(s *schema.Schema, sel query.Selection, fields schema.FieldList) (errs []*errors.QueryError) { +func validateSelection(s *schema.Schema, sel query.Selection, t common.Type) (errs []*errors.QueryError) { switch sel := sel.(type) { case *query.Field: errs = append(errs, validateDirectives(s, sel.Directives)...) - f := fields.Get(sel.Name) - if f == nil { - // TODO + if sel.Name == "__schema" || sel.Name == "__type" || sel.Name == "__typename" { return } - if len(f.Args) != 0 { // seems like a bug in graphql-js tests + + t = unwrapType(t) + f := fields(t).Get(sel.Name) + if f == nil && t != nil { + suggestion := makeSuggestion("Did you mean", fields(t).Names(), sel.Name) + errs = append(errs, errors.ErrorfWithLoc(sel.Location, "Cannot query field %q on type %q.%s", sel.Name, t, suggestion)) + } + + if f != nil && len(f.Args) != 0 { // seems like a bug in graphql-js tests for _, selArg := range sel.Arguments { arg := f.Args.Get(selArg.Name) value := selArg.Value @@ -82,15 +90,21 @@ func validateSelection(s *schema.Schema, sel query.Selection, fields schema.Fiel } } } + + var ft common.Type + if f != nil { + ft = f.Type + } if sel.SelSet != nil { - errs = append(errs, validateSelectionSet(s, sel.SelSet, f.Type)...) + errs = append(errs, validateSelectionSet(s, sel.SelSet, ft)...) } case *query.Fragment: - // errs = append(errs, validateDirectives(s, sel.Directives)...) - // for _, sel := range sel.SelSet.Selections { - // errs = append(errs, validateSelection(s, sel, fields)...) - // } + if sel.On != "" { + t = s.Types[sel.On] + } + errs = append(errs, validateDirectives(s, sel.Directives)...) + errs = append(errs, validateSelectionSet(s, sel.SelSet, t)...) default: panic("unreachable") @@ -98,6 +112,30 @@ func validateSelection(s *schema.Schema, sel query.Selection, fields schema.Fiel return } +func fields(t common.Type) schema.FieldList { + switch t := t.(type) { + case *schema.Object: + return t.Fields + case *schema.Interface: + return t.Fields + case *schema.Union, nil: + return nil + default: + panic("unreachable") + } +} + +func unwrapType(t common.Type) common.Type { + switch t := t.(type) { + case *common.List: + return unwrapType(t.OfType) + case *common.NonNull: + return unwrapType(t.OfType) + default: + return t + } +} + func validateDirectives(s *schema.Schema, directives map[string]common.ArgumentList) (errs []*errors.QueryError) { for name, args := range directives { d, ok := s.Directives[name]