Skip to content

Commit

Permalink
Refactor argument-handling to use a struct (#103)
Browse files Browse the repository at this point in the history
## Summary:
In this commit I refactor the argument-generation logic to move most of
the code out of the template and into the type-generator.  This logic
predates #51, and I didn't think to update it there, but I think it
benefits from similar treatment, for similar reasons.

Specifically, the main change is to treat variables as another struct
type we can generate, rather than handling them inline as a
`map[string]interface{}`.  Users still pass them the same way, but
instead of putting them into a `map[string]interface{}` and JSONifying
that, we generate a struct and put them there.

This turns out to simplify things quite a lot, because we already have a
lot of code to generate types.  Notably, the omitempty code goes from a
dozen lines to basically two, and fixes a bug (#43) in the process,
because now that we have a struct, `json.Marshal` will do our work for
us! (And, once we have syntax for it (#14), we'll be able to handle
field-level omitempty basically for free.)  More importantly, it will
simplify custom marshalers (#38, forthcoming) significantly, since we do
all that logic at the containing-struct level, but will need to apply it
to arguments.

It does require two breaking changes:

1. For folks implementing the `graphql.Client` API (rather than just
   calling `NewClient`): we now pass them variables as an `interface{}`
   rather than a `map[string]interface{}`.  For most callers, including
   Khan/webapp, this is basically a one-line change to the signature of
   their `MakeRequest`, and it should be a lot more future-proof.
2. genqlient's handling of the `omitempty` option has changed to match
   that of `encoding/json`, in particular it now never considers structs
   "empty".  The difference was never intentional (I just didn't realize
   that behavior of `encoding/json`); arguably our behavior was more
   useful but I think that's outweighed by the value of consistency with
   `encoding/json` as well as the simpler and more correct
   implementation (fixing #43 is actually quite nontrivial otherwise).
   Once we have custom unmarshaler support (#38), users will be able to
   map a zero value to JSON null if they wish, which is mostly if not
   entirely equivalent for GraphQL's purposes.

Issue: #38
Issue: #43

## Test plan:
make check

Author: benjaminjkraft

Reviewers: StevenACoffman, dnerdy, aberkan, jvoll, mahtabsabet, MiguelCastillo

Required Reviewers: 

Approved By: StevenACoffman, dnerdy

Checks: ✅ Test (1.17), ✅ Test (1.16), ✅ Test (1.15), ✅ Test (1.14), ✅ Lint, ✅ Lint, ✅ Test (1.17), ✅ Test (1.16), ✅ Test (1.15), ✅ Test (1.14)

Pull Request URL: #103
  • Loading branch information
benjaminjkraft authored Sep 23, 2021
1 parent ab1aaed commit 5995653
Show file tree
Hide file tree
Showing 26 changed files with 395 additions and 206 deletions.
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ When releasing a new version:

### Breaking changes:

- The [`graphql.Client`](https://pkg.go.dev/github.com/Khan/genqlient/graphql#Client) interface now accepts `variables interface{}` (containing a JSON-marshalable value) rather than `variables map[string]interface{}`. Clients implementing the interface themselves will need to change the signature; clients who simply call `graphql.NewClient` are unaffected.
- genqlient's handling of the `omitempty` option has changed to match that of `encoding/json`, from which it had inadvertently differed. In particular, this means struct-typed arguments with `# @genqlient(omitempty: true)` will no longer be omitted if they are the zero value. (Struct-pointers are still omitted if nil, so adding `pointer: true` will typically work fine.)

### New features:

### Bug fixes:

- The `omitempty` option now works correctly for struct- and map-typed variables, matching `encoding/json`, which is to say it never omits structs, and omits empty maps. (#43)
- Generated type-names now abbreviate across multiple components; for example if the path to a type is `(MyOperation, Outer, Outer, Inner, OuterInner)`, it will again be called `MyOperationOuterInner`. (This regressed in a pre-v0.1.0 refactor.) (#109)

## v0.1.0
Expand Down
5 changes: 3 additions & 2 deletions docs/genqlient_directive.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,9 @@
# query, "d" applies to arg2 and arg3, and "e" applies to field1 and field2.
directive genqlient(

# If set, this argument will be omitted if it's equal to its Go zero
# value, or is an empty slice.
# If set, this argument will be omitted if it has an empty value, defined
# (the same as in encoding/json) as false, 0, a nil pointer, a nil interface
# value, and any empty array, slice, map, or string.
#
# For example, given the following query:
# # @genqlient(omitempty: true)
Expand Down
14 changes: 9 additions & 5 deletions example/generated.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

84 changes: 71 additions & 13 deletions generate/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ package generate
// into code, in types.go.
//
// The entrypoints are convertOperation, which builds the response-type for a
// query, and convertInputType, which builds the argument-types.
// query, and convertArguments, which builds the argument-types.

import (
"fmt"
Expand Down Expand Up @@ -139,15 +139,66 @@ var builtinTypes = map[string]string{
"ID": "string",
}

// convertInputType decides the Go type we will generate corresponding to an
// argument to a GraphQL operation.
func (g *generator) convertInputType(
typ *ast.Type,
options, queryOptions *genqlientDirective,
) (goType, error) {
// note prefix is ignored here (see generator.typeName), as is selectionSet
// (for input types we use the whole thing).
return g.convertType(nil, typ, nil, options, queryOptions)
// convertArguments builds the type of the GraphQL arguments to the given
// operation.
//
// This type is not exposed to the user; it's just used internally in the
// unmarshaler; and it's used as a container
func (g *generator) convertArguments(
operation *ast.OperationDefinition,
queryOptions *genqlientDirective,
) (*goStructType, error) {
if len(operation.VariableDefinitions) == 0 {
return nil, nil
}
name := "__" + operation.Name + "Input"
fields := make([]*goStructField, len(operation.VariableDefinitions))
for i, arg := range operation.VariableDefinitions {
_, directive, err := g.parsePrecedingComment(arg, arg.Position)
if err != nil {
return nil, err
}
options := queryOptions.merge(directive)

goName := upperFirst(arg.Variable)
// Some of the arguments don't apply here, namely the name-prefix (see
// names.go) and the selection-set (we use all the input type's fields,
// and so on recursively). See also the `case ast.InputObject` in
// convertDefinition, below.
goTyp, err := g.convertType(nil, arg.Type, nil, options, queryOptions)
if err != nil {
return nil, err
}

fields[i] = &goStructField{
GoName: goName,
GoType: goTyp,
JSONName: arg.Variable,
GraphQLName: arg.Variable,
Omitempty: options.GetOmitempty(),
}
}
goTyp := &goStructType{
GoName: name,
Fields: fields,
Selection: nil,
IsInput: true,
descriptionInfo: descriptionInfo{
CommentOverride: fmt.Sprintf("%s is used internally by genqlient", name),
// fake name, used by addType
GraphQLName: name,
},
}
goTypAgain, err := g.addType(goTyp, goTyp.GoName, operation.Position)
if err != nil {
return nil, err
}
goTyp, ok := goTypAgain.(*goStructType)
if !ok {
return nil, errorf(
operation.Position, "internal error: input type was %T", goTypAgain)
}
return goTyp, nil
}

// convertType decides the Go type we will generate corresponding to a
Expand Down Expand Up @@ -305,11 +356,16 @@ func (g *generator) convertDefinition(

for i, field := range def.Fields {
goName := upperFirst(field.Name)
// Several of the arguments don't really make sense here:
// Several of the arguments don't really make sense here
// (note field.Type is necessarily a scalar, input, or enum)
// - no field-specific options can apply, because this is
// a field in the type, not in the query (see also #14).
// - namePrefix is ignored for input types; see note in
// generator.typeName.
// - namePrefix is ignored for input types and enums (see
// names.go) and for scalars (they use client-specified
// names)
// - selectionSet is ignored for input types, because we
// just use all fields of the type; and it's nonexistent
// for scalars and enums, our only other possible types,
// TODO(benkraft): Can we refactor to avoid passing the values that
// will be ignored? We know field.Type is a scalar, enum, or input
// type. But plumbing that is a bit tricky in practice.
Expand All @@ -325,6 +381,8 @@ func (g *generator) convertDefinition(
JSONName: field.Name,
GraphQLName: field.Name,
Description: field.Description,
// TODO(benkraft): set Omitempty once we have a way for the
// user to specify it.
}
}
return goType, nil
Expand Down
49 changes: 9 additions & 40 deletions generate/generate.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,11 @@ type operation struct {
Doc string `json:"-"`
// The body of the operation to send.
Body string `json:"query"`
// The arguments to the operation.
Args []argument `json:"-"`
// The type of the argument to the operation, which we use both internally
// and to construct the arguments. We do it this way so we can use the
// machinery we have for handling (and, specifically, json-marshaling)
// types.
Input *goStructType `json:"-"`
// The type-name for the operation's response type.
ResponseName string `json:"-"`
// The original filename from which we got this query.
Expand All @@ -69,14 +72,6 @@ type exportedOperations struct {
Operations []*operation `json:"operations"`
}

type argument struct {
GoName string
GoType string
GraphQLName string
IsSlice bool
Options *genqlientDirective
}

func newGenerator(
config *Config,
schema *ast.Schema,
Expand Down Expand Up @@ -125,29 +120,6 @@ func (g *generator) WriteTypes(w io.Writer) error {
return nil
}

func (g *generator) getArgument(
arg *ast.VariableDefinition,
operationDirective *genqlientDirective,
) (argument, error) {
_, directive, err := g.parsePrecedingComment(arg, arg.Position)
if err != nil {
return argument{}, err
}

graphQLName := arg.Variable
goTyp, err := g.convertInputType(arg.Type, directive, operationDirective)
if err != nil {
return argument{}, err
}
return argument{
GraphQLName: graphQLName,
GoName: lowerFirst(graphQLName),
GoType: goTyp.Reference(),
IsSlice: arg.Type.Elem != nil,
Options: operationDirective.merge(directive),
}, nil
}

// usedFragmentNames returns the named-fragments used by (i.e. spread into)
// this operation.
func (g *generator) usedFragments(op *ast.OperationDefinition) ast.FragmentDefinitionList {
Expand Down Expand Up @@ -277,12 +249,9 @@ func (g *generator) addOperation(op *ast.OperationDefinition) error {
return err
}

args := make([]argument, len(op.VariableDefinitions))
for i, arg := range op.VariableDefinitions {
args[i], err = g.getArgument(arg, directive)
if err != nil {
return err
}
inputType, err := g.convertArguments(op, directive)
if err != nil {
return err
}

responseType, err := g.convertOperation(op, directive)
Expand Down Expand Up @@ -313,7 +282,7 @@ func (g *generator) addOperation(op *ast.OperationDefinition) error {
// rather than in the template so exported operations will match
// *exactly* what we send to the server.
Body: "\n" + builder.String(),
Args: args,
Input: inputType,
ResponseName: responseType.Reference(),
SourceFilename: sourceFilename,
Config: g.Config, // for the convenience of the template
Expand Down
3 changes: 0 additions & 3 deletions generate/generate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,6 @@ func TestGenerate(t *testing.T) {
t.Run("Build", func(t *testing.T) {
if testing.Short() {
t.Skip("skipping build due to -short")
} else if sourceFilename == "Omitempty.graphql" {
t.Skip("TODO: enable after fixing " +
"https://github.com/Khan/genqlient/issues/43")
}

err := buildGoFile(sourceFilename, generated[goFilename])
Expand Down
37 changes: 14 additions & 23 deletions generate/operation.go.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,23 @@ func {{.Name}}(
{{- if not .Config.ClientGetter -}}
client {{ref "github.com/Khan/genqlient/graphql.Client"}},
{{end}}
{{- range .Args -}}
{{.GoName}} {{.GoType}},
{{- if .Input -}}
{{- range .Input.Fields -}}
{{/* the GraphQL name here is the user-specified variable-name */ -}}
{{.GraphQLName}} {{.GoType.Reference}},
{{end -}}
{{end -}}
) (*{{.ResponseName}}, error) {
{{- if .Args -}}
variables := map[string]interface{}{
{{range .Args -}}
{{if not .Options.GetOmitempty -}}
"{{.GraphQLName}}": {{.GoName}},
{{- if .Input -}}
{{/* We need to avoid conflicting with any of the function's argument names
which are derived from the GraphQL argument names; notably `input` is
a common one. So we use a name that's not legal in GraphQL, namely
one starting with a double-underscore. */ -}}
__input := {{.Input.GoName}}{
{{range .Input.Fields -}}
{{.GoName}}: {{.GraphQLName}},
{{end -}}
{{end}}
}
{{range .Args -}}
{{if .Options.GetOmitempty -}}
{{if .IsSlice -}}
if len({{.GoName}}) > 0 {
{{else -}}
{{/* 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}} {
{{end -}}
variables["{{.GraphQLName}}"] = {{.GoName}}
}
{{end}}
{{end}}
{{end -}}

var err error
Expand All @@ -48,7 +39,7 @@ func {{.Name}}(
"{{.Name}}",
`{{.Body}}`,
&retval,
{{if .Args}}variables{{else}}nil{{end}},
{{if .Input}}&__input{{else}}nil{{end}},
)
return &retval, err
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 5995653

Please sign in to comment.