diff --git a/README.md b/README.md index 8694dbf..0146b67 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,9 @@ For each GraphQL operation (query or mutation), genqlient generates a Go functio It returns a pointer to a struct representing the query-result, and an `error`. The struct will always be initialized (never nil), even on error. The error may be a `github.com/vektah/gqlparser/v2/gqlerror.List`, if it was a GraphQL-level error (in this case the returned struct may still contain useful data, if the API returns data even on error), or may be another error if, for example, the whole HTTP request failed (in which case the struct is unlikely to contain useful data). If the GraphQL operation has a comment immediately above it, that comment text will be used as the GoDoc for the generated function. -TODO: document generated types further, especially when they become customizable. +The generated code may be customized using a directive-like syntax, `# @genqlient(...)`. For full documentation of options, see [`go doc github.com/Khan/genqlient/generate.GenqlientDirective`](https://pkg.go.dev/github.com/Khan/genqlient/generate#GenqlientDirective). + +TODO: consider inlining the direct stuff; and document generated types further. ## Development @@ -99,7 +101,6 @@ Fancy features: Generated code customization: - add flag(s) to make a field use a pointer (for optionality or perf; see DESIGN) -- omitempty-like thing for optional arguments - collapsing -- should be able to have `mutation { myMutation { error { code } } }` just return `(code string, err error)` - map a field to a particular Go type (if you want to use a named type for some string, say) - specify a particular name for a Go type, or for the helper function, or whatever else diff --git a/generate/comments.go b/generate/comments.go index ff2faeb..3601fd6 100644 --- a/generate/comments.go +++ b/generate/comments.go @@ -8,18 +8,123 @@ import ( "github.com/vektah/gqlparser/v2/parser" ) -func (g *generator) parsePrecedingComment(pos *ast.Position) (comment string, directives []*ast.Directive, err error) { +// GenqlientDirective represents the @genqlient quasi-directive, used to +// configure genqlient on a query-by-query basis. +// +// The syntax of the directive is just like a GraphQL directive, except it goes +// in a comment on the line immediately preceding the field. (This is because +// GraphQL expects directives in queries to be defined by the server, not by +// the client, so it would reject a real @genqlient directive as nonexistent.) +// +// Directives may be applied to fields, arguments, or the entire query. +// Directives on the line preceding the query apply to all relevant nodes in +// the query; other directives apply to all nodes on the following line. (In +// all cases it's fine for there to be other comments in between the directive +// and the node(s) to which it applies.) For example, in the following query: +// # @genqlient(n: "a") +// +// # @genqlient(n: "b") +// # +// # Comment describing the query +// # +// # @genqlient(n: "c") +// query MyQuery(arg1: String, +// # @genqlient(n: "d") +// arg2: String, arg3: String, +// arg4: String, +// ) { +// # @genqlient(n: "e") +// field1, field2 +// field3 +// } +// the directive "a" is ignored, "b" and "c" apply to all relevant nodes in the +// query, "d" applies to arg2 and arg3, and "e" applies to field1 and field2. +type GenqlientDirective struct { + // If set, this argument will be omitted if it's equal to its Go zero + // value. For example, given the following query: + // # @genqlient(omitempty: true) + // query MyQuery(arg: String) { ... } + // genqlient will generate a function + // MyQuery(ctx context.Context, client graphql.Client, arg string) ... + // which will pass {"arg": null} to GraphQL if arg is "", and the actual + // value otherwise. + // + // Only applicable to arguments of nullable types. + Omitempty bool +} + +func fromGraphQL(dir *ast.Directive) (*GenqlientDirective, error) { + if dir.Name != "genqlient" { + // Actually we just won't get here; we only get here if the line starts + // with "# @genqlient", unless there's some sort of bug. + return nil, fmt.Errorf("the only valid comment-directive is @genqlient, got %v", dir.Name) + } + + var retval GenqlientDirective + for _, arg := range dir.Arguments { + switch arg.Name { + case "omitempty": + retval.Omitempty = true + default: + return nil, fmt.Errorf("unknown argument %v for @genqlient", arg.Name) + } + } + return &retval, nil +} + +func (dir *GenqlientDirective) validate(node interface{}) error { + switch node := node.(type) { + case *ast.OperationDefinition: + // Anything is valid on the entire operation; it will just apply to + // whatever it is relevant to. + return nil + case *ast.VariableDefinition: + if dir.Omitempty && node.Type.NonNull { + return fmt.Errorf("omitempty may only be used on optional arguments") + } + return nil + case *ast.Field: + if dir.Omitempty { + return fmt.Errorf("omitempty is not appilcable to fields") + } + return nil + default: + return fmt.Errorf("invalid directive location: %T", node) + } +} + +func (dir *GenqlientDirective) merge(other *GenqlientDirective) *GenqlientDirective { + if dir == nil { + return other + } + var retval GenqlientDirective + retval.Omitempty = dir.Omitempty || other.Omitempty + return &retval +} + +func (g *generator) parsePrecedingComment( + node interface{}, + pos *ast.Position, +) (comment string, directive *GenqlientDirective, err error) { var commentLines []string sourceLines := strings.Split(pos.Src.Input, "\n") for i := pos.Line - 1; i > 0; i-- { line := strings.TrimSpace(sourceLines[i-1]) trimmed := strings.TrimSpace(strings.TrimPrefix(line, "#")) if strings.HasPrefix(line, "# @genqlient") { - directive, err := parseDirective(trimmed, pos) + graphQLDirective, err := parseDirective(trimmed, pos) if err != nil { return "", nil, err } - directives = append(directives, directive) + genqlientDirective, err := fromGraphQL(graphQLDirective) + if err != nil { + return "", nil, err + } + err = genqlientDirective.validate(node) + if err != nil { + return "", nil, err + } + directive = directive.merge(genqlientDirective) } else if strings.HasPrefix(line, "#") { commentLines = append(commentLines, trimmed) } else { @@ -29,7 +134,7 @@ func (g *generator) parsePrecedingComment(pos *ast.Position) (comment string, di reverse(commentLines) - return strings.TrimSpace(strings.Join(commentLines, "\n")), directives, nil + return strings.TrimSpace(strings.Join(commentLines, "\n")), directive, nil } func parseDirective(line string, pos *ast.Position) (*ast.Directive, error) { diff --git a/generate/generate.go b/generate/generate.go index 8203732..0d86abf 100644 --- a/generate/generate.go +++ b/generate/generate.go @@ -61,6 +61,7 @@ type argument struct { GoName string GoType string GraphQLName string + Omitempty bool } func newGenerator(config *Config, schema *ast.Schema) *generator { @@ -107,7 +108,21 @@ func (g *generator) Types() string { return strings.Join(defs, "\n\n") } -func (g *generator) getArgument(opName string, arg *ast.VariableDefinition) (argument, error) { +func (g *generator) getArgument( + opName string, + arg *ast.VariableDefinition, + operationDirective *GenqlientDirective, +) (argument, error) { + _, directive, err := g.parsePrecedingComment(arg, arg.Position) + if err != nil { + return argument{}, err + } + directive = operationDirective.merge(directive) + omitempty := false + if directive != nil { + omitempty = directive.Omitempty + } + graphQLName := arg.Variable goType, err := g.getTypeForInputType(opName, arg.Type) if err != nil { @@ -117,6 +132,7 @@ func (g *generator) getArgument(opName string, arg *ast.VariableDefinition) (arg GraphQLName: graphQLName, GoName: lowerFirst(graphQLName), GoType: goType, + Omitempty: omitempty, }, nil } @@ -133,10 +149,15 @@ func (g *generator) addOperation(op *ast.OperationDefinition) error { // TODO: handle fragments }) + commentLines, directive, err := g.parsePrecedingComment(op, op.Position) + if err != nil { + return err + } + args := make([]argument, len(op.VariableDefinitions)) for i, arg := range op.VariableDefinitions { var err error - args[i], err = g.getArgument(op.Name, arg) + args[i], err = g.getArgument(op.Name, arg, directive) if err != nil { return err } @@ -147,11 +168,6 @@ func (g *generator) addOperation(op *ast.OperationDefinition) error { return err } - commentLines, _, err := g.parsePrecedingComment(op.Position) - if err != nil { - return err - } - var docComment string if len(commentLines) > 0 { docComment = "// " + strings.ReplaceAll(commentLines, "\n", "\n// ") diff --git a/generate/operation.go.tmpl b/generate/operation.go.tmpl index 09f27ea..1ffb6ee 100644 --- a/generate/operation.go.tmpl +++ b/generate/operation.go.tmpl @@ -24,9 +24,19 @@ func {{.Name}}( {{- if .Args -}} variables := map[string]interface{}{ {{range .Args -}} - "{{.GraphQLName}}": {{.GoName}}, + "{{.GraphQLName}}": {{if .Omitempty}}nil{{else}}{{.GoName}}{{end}}, {{end}} } + {{range .Args -}} + {{if .Omitempty -}} + {{/* zero_{{.GoType}} would be a better name, but {{.GoType}} would require + munging since it might be, say, `time.Time`. */}} + var zero_{{.GoName}} {{.GoType}} + if {{.GoName}} != zero_{{.GoName}} { + variables["{{.GraphQLName}}"] = {{.GoName}} + } + {{end}} + {{end}} {{end}} var retval {{.ResponseName}} err := {{if $.Config.ClientGetter}}{{$.Config.ClientGetter}}{{else}}client{{end}}.MakeRequest( diff --git a/generate/testdata/queries/Omitempty.graphql b/generate/testdata/queries/Omitempty.graphql new file mode 100644 index 0000000..99ad62f --- /dev/null +++ b/generate/testdata/queries/Omitempty.graphql @@ -0,0 +1,11 @@ +# @genqlient(omitempty: true) +query OmitEmptyQuery( + $query: UserQueryInput, + $dt: DateTime, + $tz: String, +) { + user(query: $query) { + id + } + maybeConvert(dt: $dt, tz: $tz) +} diff --git a/generate/testdata/queries/Omitempty.graphql.go b/generate/testdata/queries/Omitempty.graphql.go new file mode 100644 index 0000000..dc4b537 --- /dev/null +++ b/generate/testdata/queries/Omitempty.graphql.go @@ -0,0 +1,79 @@ +package test + +// Code generated by github.com/Khan/genqlient, DO NOT EDIT. + +import ( + "time" + + "github.com/Khan/genqlient/graphql" + "github.com/me/mypkg" +) + +type OmitEmptyQueryResponse struct { + User OmitEmptyQueryUser `json:"user"` + MaybeConvert time.Time `json:"maybeConvert"` +} + +type OmitEmptyQueryUser struct { + Id mypkg.ID `json:"id"` +} + +type Role string + +const ( + RoleStudent Role = "STUDENT" + RoleTeacher Role = "TEACHER" +) + +type UserQueryInput struct { + Email string `json:"email"` + Name string `json:"name"` + Id mypkg.ID `json:"id"` + Role Role `json:"role"` + Names []string `json:"names"` +} + +func OmitEmptyQuery( + client graphql.Client, + query UserQueryInput, + dt time.Time, + tz string, +) (*OmitEmptyQueryResponse, error) { + variables := map[string]interface{}{ + "query": nil, + "dt": nil, + "tz": nil, + } + + var zero_query UserQueryInput + if query != zero_query { + variables["query"] = query + } + + var zero_dt time.Time + if dt != zero_dt { + variables["dt"] = dt + } + + var zero_tz string + if tz != zero_tz { + variables["tz"] = tz + } + + var retval OmitEmptyQueryResponse + err := client.MakeRequest( + nil, + "OmitEmptyQuery", + ` +query OmitEmptyQuery ($query: UserQueryInput, $dt: DateTime, $tz: String) { + user(query: $query) { + id + } + maybeConvert(dt: $dt, tz: $tz) +} +`, + &retval, + variables, + ) + return &retval, err +} diff --git a/generate/testdata/queries/Omitempty.graphql.json b/generate/testdata/queries/Omitempty.graphql.json new file mode 100644 index 0000000..5187151 --- /dev/null +++ b/generate/testdata/queries/Omitempty.graphql.json @@ -0,0 +1,9 @@ +{ + "operations": [ + { + "operationName": "OmitEmptyQuery", + "query": "\nquery OmitEmptyQuery ($query: UserQueryInput, $dt: DateTime, $tz: String) {\n\tuser(query: $query) {\n\t\tid\n\t}\n\tmaybeConvert(dt: $dt, tz: $tz)\n}\n", + "sourceLocation": "testdata/queries/Omitempty.graphql" + } + ] +} \ No newline at end of file diff --git a/generate/testdata/queries/schema.graphql b/generate/testdata/queries/schema.graphql index b3b0ba7..bb8e9c1 100644 --- a/generate/testdata/queries/schema.graphql +++ b/generate/testdata/queries/schema.graphql @@ -63,6 +63,7 @@ type Query { root: Topic! randomLeaf: LeafContent! convert(dt: DateTime!, tz: String): DateTime! + maybeConvert(dt: DateTime, tz: String): DateTime } type Mutation {