allow globbing, and extract queries from .go files

This commit is contained in:
Ben Kraft
2021-04-01 12:04:26 -07:00
parent ec6681e715
commit eae68e43c9
38 changed files with 256 additions and 21 deletions
+6 -5
View File
@@ -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
+4
View File
@@ -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
+15 -3
View File
@@ -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 {
+93 -13
View File
@@ -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
}
+59
View File
@@ -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)
}
})
}
}
+46
View File
@@ -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!`
)
+19
View File
@@ -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
}
+8
View File
@@ -0,0 +1,8 @@
package parsing
const _ = `
# @genqlient
fragment UnusedFragment on MyType {
myFragmentField
}
`
+6
View File
@@ -0,0 +1,6 @@
mutation MyMutation {
myField
myOtherField {
...MyFragment
}
}