diff --git a/README.md b/README.md index 77655d0..fde408e 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ fmt.Println("you are", viewerResp.Viewer.MyName) For a complete working example, see `example/`. +TODO: document this a bit more, including different ways to specify queries, options once we have those, etc. + ## Documentation for generated code For each GraphQL operation (query or mutation), genqlient generates a Go function with the exact same name, which accepts: @@ -64,13 +66,11 @@ TODO: document generated types further, especially if they become customizable. ## Tests -`go test ./...` tests code generation. (This is run by GitHub Actions.) +`go test ./...` tests code generation. (This is run by GitHub Actions.) Most of the tests are snapshot-based; see `generate/generate_test.go`. -Most of the tests are snapshot-based; they use the schema, queries, and snapshots in `generate/testdata`. The schema is in `schema.graphql`; the queries are in `TestName.graphql`. The test by default asserts that the output of the generator matches the snapshot `TestName.graphql.go`. To update the snapshots, run with `UPDATE_SNAPSHOTS=1`; it will fail the tests and print the diffs, but regenerate the snapshots. Make sure to check that the output is sensible! +`make example` rebuilds the example, and tests that everything wires up to a real API correctly. This is not currently included in `go test`, since it requires a token. -`make example` rebuilds the example, and tests that everything wires up to a real API correctly. - -TODO(benkraft): Figure out how to get GitHub Actions to run the example -- it needs a token. +TODO(benkraft): Figure out how to get GitHub Actions a token to run the example. ## Design @@ -103,3 +103,4 @@ Other: - get a designer to fix my bad logo-thing - custom scalar types (or custom mappings for standard scalars, if you want a special ID type say) - allow mapping a custom type to a particular val (if you want to use a named type for some string, say) +- (optionally?) include full query in generated godoc diff --git a/generate/config.go b/generate/config.go index 4cc2423..3436963 100644 --- a/generate/config.go +++ b/generate/config.go @@ -27,6 +27,10 @@ type Config struct { // how to convert that to SDL). Schema string `yaml:"schema"` // Filenames or globs with the queries; defaults to queries.graphql. + // + // These may be .graphql files, containing the queries in SDL format, or + // Go files, in which case any string-literal starting with (optional + // whitespace and) the string "# @genqlient" will be extracted as a query. Queries []string `yaml:"queries"` // The filename to which to write the generated code; defaults to // generated.go diff --git a/generate/generate_test.go b/generate/generate_test.go index 60da5d2..fb3216e 100644 --- a/generate/generate_test.go +++ b/generate/generate_test.go @@ -11,7 +11,7 @@ import ( "testing" ) -const dataDir = "testdata" +const dataDir = "testdata/queries" func readFile(t *testing.T, filename string, allowNotExist bool) string { t.Helper() @@ -34,6 +34,18 @@ func gofmt(src string) (string, error) { return string(formatted), nil } +// TestGenerate is a snapshot-based test of code-generation. +// +// This file just has the test runner; the actual data is all in +// testdata/queries. Specifically, the schema used for all the queries is in +// schema.graphql; the queries themselves are in TestName.graphql. The test +// asserts that running genqlient on that query produces the generated code in +// the snapshot-file TestName.graphql.go. +// +// To update the snapshots (if the code-generator has changed), run the test +// with `UPDATE_SNAPSHOTS=1`; it will fail the tests and print any diffs, but +// update the snapshots. Make sure to check that the output is sensible; the +// snapshots don't even get compiled! func TestGenerate(t *testing.T) { update := (os.Getenv("UPDATE_SNAPSHOTS") == "1") @@ -56,8 +68,8 @@ func TestGenerate(t *testing.T) { } goCode, err := Generate(&Config{ - Schema: filepath.Join("testdata", "schema.graphql"), - Queries: []string{filepath.Join("testdata", graphqlFilename)}, + Schema: filepath.Join(dataDir, "schema.graphql"), + Queries: []string{filepath.Join(dataDir, graphqlFilename)}, Package: "test", }) if err != nil { diff --git a/generate/parse.go b/generate/parse.go index a795f92..793b196 100644 --- a/generate/parse.go +++ b/generate/parse.go @@ -2,8 +2,14 @@ package generate import ( "fmt" + goAst "go/ast" + goParser "go/parser" + "go/token" + goToken "go/token" "io/ioutil" "path/filepath" + "strconv" + "strings" "github.com/vektah/gqlparser/v2" "github.com/vektah/gqlparser/v2/ast" @@ -28,44 +34,76 @@ func getSchema(filename string) (*ast.Schema, error) { } func getAndValidateQueries(filenames []string, schema *ast.Schema) (*ast.QueryDocument, error) { + queryDoc, err := getQueries(filenames) + if err != nil { + return nil, err + } + + // Cf. gqlparser.LoadQuery + graphqlErrors := validator.Validate(schema, queryDoc) + if graphqlErrors != nil { + return nil, fmt.Errorf("query-spec does not match schema: %v", graphqlErrors) + } + + return queryDoc, nil +} + +func getQueries(filenames []string) (*ast.QueryDocument, error) { // We merge all the queries into a single query-document, since operations // in one might reference fragments in another. // // TODO(benkraft): It might be better to merge just within a filename, so // that fragment-names don't need to be unique across files. mergedQueryDoc := new(ast.QueryDocument) + addQueryDoc := func(queryDoc *ast.QueryDocument) { + mergedQueryDoc.Operations = append(mergedQueryDoc.Operations, queryDoc.Operations...) + mergedQueryDoc.Fragments = append(mergedQueryDoc.Fragments, queryDoc.Fragments...) + } + expandedFilenames := make([]string, 0, len(filenames)) for _, filename := range filenames { + matches, err := filepath.Glob(filename) + if err != nil { + return nil, fmt.Errorf("can't expand file-glob %v: %v", filename, err) + } + expandedFilenames = append(expandedFilenames, matches...) + } + + for _, filename := range expandedFilenames { + text, err := ioutil.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("unreadable query-spec file %v: %v", filename, err) + } + switch filepath.Ext(filename) { case ".graphql": - // Cf. gqlparser.LoadQuery - text, err := ioutil.ReadFile(filename) - if err != nil { - return nil, fmt.Errorf("unreadable query-spec file %v: %v", filename, err) - } - queryDoc, err := getQueriesFromString(string(text), filename) if err != nil { return nil, err } - mergedQueryDoc.Operations = append(mergedQueryDoc.Operations, queryDoc.Operations...) - mergedQueryDoc.Fragments = append(mergedQueryDoc.Fragments, queryDoc.Fragments...) + addQueryDoc(queryDoc) + + case ".go": + queryDocs, err := getQueriesFromGo(string(text), filename) + if err != nil { + return nil, err + } + + for _, queryDoc := range queryDocs { + addQueryDoc(queryDoc) + } default: return nil, fmt.Errorf("unknown file type: %v", filename) } } - graphqlErrors := validator.Validate(schema, mergedQueryDoc) - if graphqlErrors != nil { - return nil, fmt.Errorf("query-spec does not match schema: %v", graphqlErrors) - } - return mergedQueryDoc, nil } func getQueriesFromString(text string, filename string) (*ast.QueryDocument, error) { + // Cf. gqlparser.LoadQuery document, graphqlError := parser.ParseQuery( &ast.Source{Name: filename, Input: text}) if graphqlError != nil { // ParseQuery returns type *graphql.Error, yuck @@ -74,3 +112,45 @@ func getQueriesFromString(text string, filename string) (*ast.QueryDocument, err return document, nil } + +func getQueriesFromGo(text string, filename string) ([]*ast.QueryDocument, error) { + fset := goToken.NewFileSet() + f, err := goParser.ParseFile(fset, filename, text, 0) + if err != nil { + return nil, fmt.Errorf("invalid Go file %v: %v", filename, err) + } + + var retval []*ast.QueryDocument + goAst.Inspect(f, func(node goAst.Node) bool { + if err != nil { + return false // don't bother to recurse if something already failed + } + + basicLit, ok := node.(*goAst.BasicLit) + if !ok || basicLit.Kind != token.STRING { + return true // recurse + } + + var value string + value, err := strconv.Unquote(basicLit.Value) + if err != nil { + return false + } + + if !strings.HasPrefix(strings.TrimSpace(value), "# @genqlient") { + return true + } + + fakeFilename := fset.Position(basicLit.Pos()).String() + var query *ast.QueryDocument + query, err = getQueriesFromString(value, fakeFilename) + if err != nil { + return false + } + retval = append(retval, query) + + return true + }) + + return retval, err +} diff --git a/generate/parse_test.go b/generate/parse_test.go new file mode 100644 index 0000000..5434e2b --- /dev/null +++ b/generate/parse_test.go @@ -0,0 +1,59 @@ +package generate + +import ( + "path/filepath" + "sort" + "testing" + + "github.com/vektah/gqlparser/v2/ast" +) + +var parseDataDir = "testdata/parsing" + +func sortQueries(queryDoc *ast.QueryDocument) { + sort.Slice(queryDoc.Operations, func(i, j int) bool { + return queryDoc.Operations[i].Name < queryDoc.Operations[j].Name + }) + sort.Slice(queryDoc.Fragments, func(i, j int) bool { + return queryDoc.Fragments[i].Name < queryDoc.Fragments[j].Name + }) +} + +// TestParse tests that query-extraction from different language source files +// produces equivalent results. We do not test the results it produces (that's +// covered by TestGenerate), just that they are equivalent in different +// languages (since TestGenerate only uses .graphql as input). +func TestParse(t *testing.T) { + extensions := []string{"go"} + + graphqlQueries, err := getQueries([]string{filepath.Join(parseDataDir, "*.graphql")}) + if err != nil { + t.Fatal(err) + } + + // check it's at least non-empty + if len(graphqlQueries.Operations) == 0 || len(graphqlQueries.Fragments) == 0 { + t.Fatalf("Didn't find any queries in *.graphql files") + } + + sortQueries(graphqlQueries) + + for _, ext := range extensions { + t.Run(ext, func(t *testing.T) { + queries, err := getQueries([]string{filepath.Join(parseDataDir, "*."+ext)}) + if err != nil { + t.Fatal(err) + } + + // The different file-types may have the operations/fragments in a + // different order. + sortQueries(queries) + + got, want := ast.Dump(graphqlQueries), ast.Dump(queries) + if got != want { + // TODO: nice diffing + t.Errorf("got:\n%v\nwant:\n%v\n", got, want) + } + }) + } +} diff --git a/generate/testdata/parsing/fragments.go b/generate/testdata/parsing/fragments.go new file mode 100644 index 0000000..894d86a --- /dev/null +++ b/generate/testdata/parsing/fragments.go @@ -0,0 +1,46 @@ +package parsing + +const MyFragment = ` + # @genqlient + fragment MyFragment on MyType { + myFragmentField + ...NestedFragment + } +` + +var _ = ` + # @genqlient + fragment NestedFragment on MyType { + myOtherFragmentField + } +` + +const MyQuery = ` + # @genqlient + query MyQuery { + myField + myOtherField { + ...MyFragment + } + } +` + +func query(s string) {} + +func MyMutation() { + query(` + # @genqlient + mutation MyMutation { + myField + myOtherField { + ...MyFragment + } + } + `) +} + +const ( + NotAString = 1 + NotAQuery = `query + writing with GraphQL is fun!` +) diff --git a/generate/testdata/parsing/fragments.graphql b/generate/testdata/parsing/fragments.graphql new file mode 100644 index 0000000..2ec650b --- /dev/null +++ b/generate/testdata/parsing/fragments.graphql @@ -0,0 +1,19 @@ +query MyQuery { + myField + myOtherField { + ...MyFragment + } +} + +fragment MyFragment on MyType { + myFragmentField + ...NestedFragment +} + +fragment UnusedFragment on MyType { + myFragmentField +} + +fragment NestedFragment on MyType { + myOtherFragmentField +} diff --git a/generate/testdata/parsing/fragments2.go b/generate/testdata/parsing/fragments2.go new file mode 100644 index 0000000..7da30f8 --- /dev/null +++ b/generate/testdata/parsing/fragments2.go @@ -0,0 +1,8 @@ +package parsing + +const _ = ` + # @genqlient + fragment UnusedFragment on MyType { + myFragmentField + } +` diff --git a/generate/testdata/parsing/fragments2.graphql b/generate/testdata/parsing/fragments2.graphql new file mode 100644 index 0000000..a19a30f --- /dev/null +++ b/generate/testdata/parsing/fragments2.graphql @@ -0,0 +1,6 @@ +mutation MyMutation { + myField + myOtherField { + ...MyFragment + } +} diff --git a/generate/testdata/InputObject.graphql b/generate/testdata/queries/InputObject.graphql similarity index 100% rename from generate/testdata/InputObject.graphql rename to generate/testdata/queries/InputObject.graphql diff --git a/generate/testdata/InputObject.graphql.go b/generate/testdata/queries/InputObject.graphql.go similarity index 100% rename from generate/testdata/InputObject.graphql.go rename to generate/testdata/queries/InputObject.graphql.go diff --git a/generate/testdata/InterfaceNoFragments.graphql b/generate/testdata/queries/InterfaceNoFragments.graphql similarity index 100% rename from generate/testdata/InterfaceNoFragments.graphql rename to generate/testdata/queries/InterfaceNoFragments.graphql diff --git a/generate/testdata/InterfaceNoFragments.graphql.go b/generate/testdata/queries/InterfaceNoFragments.graphql.go similarity index 100% rename from generate/testdata/InterfaceNoFragments.graphql.go rename to generate/testdata/queries/InterfaceNoFragments.graphql.go diff --git a/generate/testdata/ListInput.graphql b/generate/testdata/queries/ListInput.graphql similarity index 100% rename from generate/testdata/ListInput.graphql rename to generate/testdata/queries/ListInput.graphql diff --git a/generate/testdata/ListInput.graphql.go b/generate/testdata/queries/ListInput.graphql.go similarity index 100% rename from generate/testdata/ListInput.graphql.go rename to generate/testdata/queries/ListInput.graphql.go diff --git a/generate/testdata/QueryWithAlias.graphql b/generate/testdata/queries/QueryWithAlias.graphql similarity index 100% rename from generate/testdata/QueryWithAlias.graphql rename to generate/testdata/queries/QueryWithAlias.graphql diff --git a/generate/testdata/QueryWithAlias.graphql.go b/generate/testdata/queries/QueryWithAlias.graphql.go similarity index 100% rename from generate/testdata/QueryWithAlias.graphql.go rename to generate/testdata/queries/QueryWithAlias.graphql.go diff --git a/generate/testdata/QueryWithDoubleAlias.graphql b/generate/testdata/queries/QueryWithDoubleAlias.graphql similarity index 100% rename from generate/testdata/QueryWithDoubleAlias.graphql rename to generate/testdata/queries/QueryWithDoubleAlias.graphql diff --git a/generate/testdata/QueryWithDoubleAlias.graphql.go b/generate/testdata/queries/QueryWithDoubleAlias.graphql.go similarity index 100% rename from generate/testdata/QueryWithDoubleAlias.graphql.go rename to generate/testdata/queries/QueryWithDoubleAlias.graphql.go diff --git a/generate/testdata/QueryWithEnums.graphql b/generate/testdata/queries/QueryWithEnums.graphql similarity index 100% rename from generate/testdata/QueryWithEnums.graphql rename to generate/testdata/queries/QueryWithEnums.graphql diff --git a/generate/testdata/QueryWithEnums.graphql.go b/generate/testdata/queries/QueryWithEnums.graphql.go similarity index 100% rename from generate/testdata/QueryWithEnums.graphql.go rename to generate/testdata/queries/QueryWithEnums.graphql.go diff --git a/generate/testdata/QueryWithSlices.graphql b/generate/testdata/queries/QueryWithSlices.graphql similarity index 100% rename from generate/testdata/QueryWithSlices.graphql rename to generate/testdata/queries/QueryWithSlices.graphql diff --git a/generate/testdata/QueryWithSlices.graphql.go b/generate/testdata/queries/QueryWithSlices.graphql.go similarity index 100% rename from generate/testdata/QueryWithSlices.graphql.go rename to generate/testdata/queries/QueryWithSlices.graphql.go diff --git a/generate/testdata/QueryWithStructs.graphql b/generate/testdata/queries/QueryWithStructs.graphql similarity index 100% rename from generate/testdata/QueryWithStructs.graphql rename to generate/testdata/queries/QueryWithStructs.graphql diff --git a/generate/testdata/QueryWithStructs.graphql.go b/generate/testdata/queries/QueryWithStructs.graphql.go similarity index 100% rename from generate/testdata/QueryWithStructs.graphql.go rename to generate/testdata/queries/QueryWithStructs.graphql.go diff --git a/generate/testdata/SimpleInput.graphql b/generate/testdata/queries/SimpleInput.graphql similarity index 100% rename from generate/testdata/SimpleInput.graphql rename to generate/testdata/queries/SimpleInput.graphql diff --git a/generate/testdata/SimpleInput.graphql.go b/generate/testdata/queries/SimpleInput.graphql.go similarity index 100% rename from generate/testdata/SimpleInput.graphql.go rename to generate/testdata/queries/SimpleInput.graphql.go diff --git a/generate/testdata/SimpleQuery.graphql b/generate/testdata/queries/SimpleQuery.graphql similarity index 100% rename from generate/testdata/SimpleQuery.graphql rename to generate/testdata/queries/SimpleQuery.graphql diff --git a/generate/testdata/SimpleQuery.graphql.go b/generate/testdata/queries/SimpleQuery.graphql.go similarity index 100% rename from generate/testdata/SimpleQuery.graphql.go rename to generate/testdata/queries/SimpleQuery.graphql.go diff --git a/generate/testdata/TypeName.graphql b/generate/testdata/queries/TypeName.graphql similarity index 100% rename from generate/testdata/TypeName.graphql rename to generate/testdata/queries/TypeName.graphql diff --git a/generate/testdata/TypeName.graphql.go b/generate/testdata/queries/TypeName.graphql.go similarity index 100% rename from generate/testdata/TypeName.graphql.go rename to generate/testdata/queries/TypeName.graphql.go diff --git a/generate/testdata/UnionNoFragments.graphql b/generate/testdata/queries/UnionNoFragments.graphql similarity index 100% rename from generate/testdata/UnionNoFragments.graphql rename to generate/testdata/queries/UnionNoFragments.graphql diff --git a/generate/testdata/UnionNoFragments.graphql.go b/generate/testdata/queries/UnionNoFragments.graphql.go similarity index 100% rename from generate/testdata/UnionNoFragments.graphql.go rename to generate/testdata/queries/UnionNoFragments.graphql.go diff --git a/generate/testdata/UsesEnumTwice.graphql b/generate/testdata/queries/UsesEnumTwice.graphql similarity index 100% rename from generate/testdata/UsesEnumTwice.graphql rename to generate/testdata/queries/UsesEnumTwice.graphql diff --git a/generate/testdata/UsesEnumTwice.graphql.go b/generate/testdata/queries/UsesEnumTwice.graphql.go similarity index 100% rename from generate/testdata/UsesEnumTwice.graphql.go rename to generate/testdata/queries/UsesEnumTwice.graphql.go diff --git a/generate/testdata/schema.graphql b/generate/testdata/queries/schema.graphql similarity index 100% rename from generate/testdata/schema.graphql rename to generate/testdata/queries/schema.graphql diff --git a/generate/testdata/unexported.graphql b/generate/testdata/queries/unexported.graphql similarity index 100% rename from generate/testdata/unexported.graphql rename to generate/testdata/queries/unexported.graphql diff --git a/generate/testdata/unexported.graphql.go b/generate/testdata/queries/unexported.graphql.go similarity index 100% rename from generate/testdata/unexported.graphql.go rename to generate/testdata/queries/unexported.graphql.go