Repository: Khan/genqlient
Pull request #103 (2021-09-17 01:18:28)

Refactor argument-handling to use a struct
## 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: https://github.com/Khan/genqlient/issues/38
Issue: https://github.com/Khan/genqlient/issues/43

## Test plan:
make check

benjaminjkraft: Hmm, I realized this actually makes some more breaking changes, because my implementation of `omitempty` had some inconsistencies with JSON around structs.  Let me think about this some more.
benjaminjkraft: Yeah, I think if we had more users I'd be more inclined to live with the existing behavior, but now's a good time to fix the inconsistency before anyone cares.  (FWIW, this appears to be something that `yaml` changed from v1 to v2!)  In practice it's not hard to search for potential problems (just find struct-typed fields with `# genqlient(omitempty: true)`), and webapp has none.

Patch:
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index f4c2e74..f9e6760 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -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
diff --git a/docs/genqlient_directive.graphql b/docs/genqlient_directive.graphql
index 5a845c1..dc56a2b 100644
--- a/docs/genqlient_directive.graphql
+++ b/docs/genqlient_directive.graphql
@@ -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)
diff --git a/example/generated.go b/example/generated.go
index 60086c4..82f320a 100644
--- a/example/generated.go
+++ b/example/generated.go
@@ -9,6 +9,11 @@ import (
 	"github.com/Khan/genqlient/graphql"
 )
 
+// __getUserInput is used internally by genqlient
+type __getUserInput struct {
+	Login string `json:"Login"`
+}
+
 // getUserResponse is returned by getUser on success.
 type getUserResponse struct {
 	// Lookup a user by login.
@@ -71,12 +76,11 @@ query getViewer {
 func getUser(
 	ctx context.Context,
 	client graphql.Client,
-	login string,
+	Login string,
 ) (*getUserResponse, error) {
-	variables := map[string]interface{}{
-		"Login": login,
+	__input := __getUserInput{
+		Login: Login,
 	}
-
 	var err error
 
 	var retval getUserResponse
@@ -92,7 +96,7 @@ query getUser ($Login: String!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/convert.go b/generate/convert.go
index 06271e8..2398166 100644
--- a/generate/convert.go
+++ b/generate/convert.go
@@ -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"
@@ -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
@@ -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.
@@ -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
diff --git a/generate/generate.go b/generate/generate.go
index 08f2ce5..5ca4ace 100644
--- a/generate/generate.go
+++ b/generate/generate.go
@@ -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.
@@ -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,
@@ -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 {
@@ -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)
@@ -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
diff --git a/generate/operation.go.tmpl b/generate/operation.go.tmpl
index e5cadf6..47a0c93 100644
--- a/generate/operation.go.tmpl
+++ b/generate/operation.go.tmpl
@@ -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
@@ -48,7 +39,7 @@ func {{.Name}}(
         "{{.Name}}",
         `{{.Body}}`,
         &retval,
-        {{if .Args}}variables{{else}}nil{{end}},
+        {{if .Input}}&__input{{else}}nil{{end}},
     )
     return &retval, err
 }
diff --git a/generate/types.go b/generate/types.go
index e29f32a..3b70d84 100644
--- a/generate/types.go
+++ b/generate/types.go
@@ -139,6 +139,7 @@ type goStructField struct {
 	GoType      goType
 	JSONName    string // i.e. the field's alias in this query
 	GraphQLName string // i.e. the field's name in its type-def
+	Omitempty   bool   // only used on input types
 	Description string
 }
 
@@ -162,19 +163,23 @@ func (typ *goStructType) WriteDefinition(w io.Writer, g *generator) error {
 	fmt.Fprintf(w, "type %s struct {\n", typ.GoName)
 	for _, field := range typ.Fields {
 		writeDescription(w, field.Description)
-		jsonName := field.JSONName
+		jsonTag := `"` + field.JSONName
+		if field.Omitempty {
+			jsonTag += ",omitempty"
+		}
+		jsonTag += `"`
 		if field.IsAbstract() {
 			// abstract types are handled in our UnmarshalJSON (see below)
 			needUnmarshaler = true
-			jsonName = "-"
+			jsonTag = `"-"`
 		}
 		if field.IsEmbedded() {
 			// embedded fields also need UnmarshalJSON handling (see below)
 			needUnmarshaler = true
 			fmt.Fprintf(w, "\t%s `json:\"-\"`\n", field.GoType.Unwrap().Reference())
 		} else {
-			fmt.Fprintf(w, "\t%s %s `json:\"%s\"`\n",
-				field.GoName, field.GoType.Reference(), jsonName)
+			fmt.Fprintf(w, "\t%s %s `json:%s`\n",
+				field.GoName, field.GoType.Reference(), jsonTag)
 		}
 	}
 	fmt.Fprintf(w, "}\n")
@@ -198,6 +203,9 @@ func (typ *goStructType) WriteDefinition(w io.Writer, g *generator) error {
 	// select the same field, or several fragments select the same field -- the
 	// JSON library will only fill one of those (the least-nested one); we want
 	// to fill them all.
+	//
+	// TODO(benkraft): If/when proposal #5901 is implemented (Go 1.18 at the
+	// earliest), we may be able to do some of this a simpler way.
 	if !needUnmarshaler {
 		return nil
 	}
diff --git a/graphql/client.go b/graphql/client.go
index ce28c8d..db7b826 100644
--- a/graphql/client.go
+++ b/graphql/client.go
@@ -24,9 +24,10 @@ type Client interface {
 	// context.Background().
 	//
 	// query is the literal string representing the GraphQL query, e.g.
-	// `query myQuery { myField }`.  variables contains the GraphQL variables
-	// to be sent along with the query, or may be nil if there are none.
-	// Typically, GraphQL APIs will accept a JSON payload of the form
+	// `query myQuery { myField }`.  variables contains a JSON-marshalable
+	// value containing the variables to be sent along with the query,
+	// or may be nil if there are none.  Typically, GraphQL APIs will
+	// accept a JSON payload of the form
 	//	{"query": "query myQuery { ... }", "variables": {...}}`
 	// but MakeRequest may use some other transport, handle extensions, or set
 	// other parameters, if it wishes.
@@ -41,8 +42,7 @@ type Client interface {
 		ctx context.Context,
 		opName string,
 		query string,
-		retval interface{},
-		variables map[string]interface{},
+		input, retval interface{},
 	) error
 }
 
@@ -69,8 +69,8 @@ func NewClient(endpoint string, httpClient *http.Client) Client {
 }
 
 type payload struct {
-	Query     string                 `json:"query"`
-	Variables map[string]interface{} `json:"variables,omitempty"`
+	Query     string      `json:"query"`
+	Variables interface{} `json:"variables,omitempty"`
 	// OpName is only required if there are multiple queries in the document,
 	// but we set it unconditionally, because that's easier.
 	OpName string `json:"operationName"`
@@ -81,7 +81,7 @@ type response struct {
 	Errors gqlerror.List `json:"errors"`
 }
 
-func (c *client) MakeRequest(ctx context.Context, opName string, query string, retval interface{}, variables map[string]interface{}) error {
+func (c *client) MakeRequest(ctx context.Context, opName string, query string, retval interface{}, variables interface{}) error {
 	body, err := json.Marshal(payload{
 		Query:     query,
 		Variables: variables,
diff --git a/internal/integration/generated.go b/internal/integration/generated.go
index 73dad47..c73aef5 100644
--- a/internal/integration/generated.go
+++ b/internal/integration/generated.go
@@ -266,6 +266,41 @@ func (v *UserFields) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
+// __queryWithFragmentsInput is used internally by genqlient
+type __queryWithFragmentsInput struct {
+	Ids []string `json:"ids"`
+}
+
+// __queryWithInterfaceListFieldInput is used internally by genqlient
+type __queryWithInterfaceListFieldInput struct {
+	Ids []string `json:"ids"`
+}
+
+// __queryWithInterfaceListPointerFieldInput is used internally by genqlient
+type __queryWithInterfaceListPointerFieldInput struct {
+	Ids []string `json:"ids"`
+}
+
+// __queryWithInterfaceNoFragmentsInput is used internally by genqlient
+type __queryWithInterfaceNoFragmentsInput struct {
+	Id string `json:"id"`
+}
+
+// __queryWithNamedFragmentsInput is used internally by genqlient
+type __queryWithNamedFragmentsInput struct {
+	Ids []string `json:"ids"`
+}
+
+// __queryWithOmitemptyInput is used internally by genqlient
+type __queryWithOmitemptyInput struct {
+	Id string `json:"id,omitempty"`
+}
+
+// __queryWithVariablesInput is used internally by genqlient
+type __queryWithVariablesInput struct {
+	Id string `json:"id"`
+}
+
 // failingQueryMeUser includes the requested fields of the GraphQL type User.
 type failingQueryMeUser struct {
 	Id string `json:"id"`
@@ -1036,6 +1071,18 @@ func (v *queryWithNamedFragmentsResponse) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
+// queryWithOmitemptyResponse is returned by queryWithOmitempty on success.
+type queryWithOmitemptyResponse struct {
+	User queryWithOmitemptyUser `json:"user"`
+}
+
+// queryWithOmitemptyUser includes the requested fields of the GraphQL type User.
+type queryWithOmitemptyUser struct {
+	Id          string `json:"id"`
+	Name        string `json:"name"`
+	LuckyNumber int    `json:"luckyNumber"`
+}
+
 // queryWithVariablesResponse is returned by queryWithVariables on success.
 type queryWithVariablesResponse struct {
 	User queryWithVariablesUser `json:"user"`
@@ -1114,10 +1161,9 @@ func queryWithVariables(
 	client graphql.Client,
 	id string,
 ) (*queryWithVariablesResponse, error) {
-	variables := map[string]interface{}{
-		"id": id,
+	__input := __queryWithVariablesInput{
+		Id: id,
 	}
-
 	var err error
 
 	var retval queryWithVariablesResponse
@@ -1134,7 +1180,36 @@ query queryWithVariables ($id: ID!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
+	)
+	return &retval, err
+}
+
+func queryWithOmitempty(
+	ctx context.Context,
+	client graphql.Client,
+	id string,
+) (*queryWithOmitemptyResponse, error) {
+	__input := __queryWithOmitemptyInput{
+		Id: id,
+	}
+	var err error
+
+	var retval queryWithOmitemptyResponse
+	err = client.MakeRequest(
+		ctx,
+		"queryWithOmitempty",
+		`
+query queryWithOmitempty ($id: ID) {
+	user(id: $id) {
+		id
+		name
+		luckyNumber
+	}
+}
+`,
+		&retval,
+		&__input,
 	)
 	return &retval, err
 }
@@ -1144,10 +1219,9 @@ func queryWithInterfaceNoFragments(
 	client graphql.Client,
 	id string,
 ) (*queryWithInterfaceNoFragmentsResponse, error) {
-	variables := map[string]interface{}{
-		"id": id,
+	__input := __queryWithInterfaceNoFragmentsInput{
+		Id: id,
 	}
-
 	var err error
 
 	var retval queryWithInterfaceNoFragmentsResponse
@@ -1168,7 +1242,7 @@ query queryWithInterfaceNoFragments ($id: ID!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
@@ -1178,10 +1252,9 @@ func queryWithInterfaceListField(
 	client graphql.Client,
 	ids []string,
 ) (*queryWithInterfaceListFieldResponse, error) {
-	variables := map[string]interface{}{
-		"ids": ids,
+	__input := __queryWithInterfaceListFieldInput{
+		Ids: ids,
 	}
-
 	var err error
 
 	var retval queryWithInterfaceListFieldResponse
@@ -1198,7 +1271,7 @@ query queryWithInterfaceListField ($ids: [ID!]!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
@@ -1208,10 +1281,9 @@ func queryWithInterfaceListPointerField(
 	client graphql.Client,
 	ids []string,
 ) (*queryWithInterfaceListPointerFieldResponse, error) {
-	variables := map[string]interface{}{
-		"ids": ids,
+	__input := __queryWithInterfaceListPointerFieldInput{
+		Ids: ids,
 	}
-
 	var err error
 
 	var retval queryWithInterfaceListPointerFieldResponse
@@ -1228,7 +1300,7 @@ query queryWithInterfaceListPointerField ($ids: [ID!]!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
@@ -1238,10 +1310,9 @@ func queryWithFragments(
 	client graphql.Client,
 	ids []string,
 ) (*queryWithFragmentsResponse, error) {
-	variables := map[string]interface{}{
-		"ids": ids,
+	__input := __queryWithFragmentsInput{
+		Ids: ids,
 	}
-
 	var err error
 
 	var retval queryWithFragmentsResponse
@@ -1286,7 +1357,7 @@ query queryWithFragments ($ids: [ID!]!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
@@ -1296,10 +1367,9 @@ func queryWithNamedFragments(
 	client graphql.Client,
 	ids []string,
 ) (*queryWithNamedFragmentsResponse, error) {
-	variables := map[string]interface{}{
-		"ids": ids,
+	__input := __queryWithNamedFragmentsInput{
+		Ids: ids,
 	}
-
 	var err error
 
 	var retval queryWithNamedFragmentsResponse
@@ -1344,7 +1414,7 @@ fragment MoreUserFields on User {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/internal/integration/schema.graphql b/internal/integration/schema.graphql
index 6ea7069..badf85d 100644
--- a/internal/integration/schema.graphql
+++ b/internal/integration/schema.graphql
@@ -1,6 +1,6 @@
 type Query {
   me: User
-  user(id: ID!): User
+  user(id: ID): User
   being(id: ID!): Being
   beings(ids: [ID!]!): [Being]!
   lotteryWinner(number: Int!): Lucky
diff --git a/internal/integration/server/gqlgen_exec.go b/internal/integration/server/gqlgen_exec.go
index 06635c6..98a2ba5 100644
--- a/internal/integration/server/gqlgen_exec.go
+++ b/internal/integration/server/gqlgen_exec.go
@@ -64,7 +64,7 @@ type ComplexityRoot struct {
 		Fail          func(childComplexity int) int
 		LotteryWinner func(childComplexity int, number int) int
 		Me            func(childComplexity int) int
-		User          func(childComplexity int, id string) int
+		User          func(childComplexity int, id *string) int
 	}
 
 	User struct {
@@ -77,7 +77,7 @@ type ComplexityRoot struct {
 
 type QueryResolver interface {
 	Me(ctx context.Context) (*User, error)
-	User(ctx context.Context, id string) (*User, error)
+	User(ctx context.Context, id *string) (*User, error)
 	Being(ctx context.Context, id string) (Being, error)
 	Beings(ctx context.Context, ids []string) ([]Being, error)
 	LotteryWinner(ctx context.Context, number int) (Lucky, error)
@@ -208,7 +208,7 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in
 			return 0, false
 		}
 
-		return e.complexity.Query.User(childComplexity, args["id"].(string)), true
+		return e.complexity.Query.User(childComplexity, args["id"].(*string)), true
 
 	case "User.hair":
 		if e.complexity.User.Hair == nil {
@@ -290,7 +290,7 @@ func (ec *executionContext) introspectType(name string) (*introspection.Type, er
 var sources = []*ast.Source{
 	{Name: "../schema.graphql", Input: `type Query {
   me: User
-  user(id: ID!): User
+  user(id: ID): User
   being(id: ID!): Being
   beings(ids: [ID!]!): [Being]!
   lotteryWinner(number: Int!): Lucky
@@ -400,10 +400,10 @@ func (ec *executionContext) field_Query_lotteryWinner_args(ctx context.Context,
 func (ec *executionContext) field_Query_user_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) {
 	var err error
 	args := map[string]interface{}{}
-	var arg0 string
+	var arg0 *string
 	if tmp, ok := rawArgs["id"]; ok {
 		ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("id"))
-		arg0, err = ec.unmarshalNID2string(ctx, tmp)
+		arg0, err = ec.unmarshalOID2ᚖstring(ctx, tmp)
 		if err != nil {
 			return nil, err
 		}
@@ -743,7 +743,7 @@ func (ec *executionContext) _Query_user(ctx context.Context, field graphql.Colle
 	fc.Args = args
 	resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) {
 		ctx = rctx // use context from middleware stack in children
-		return ec.resolvers.Query().User(rctx, args["id"].(string))
+		return ec.resolvers.Query().User(rctx, args["id"].(*string))
 	})
 	if err != nil {
 		ec.Error(ctx, err)
@@ -3131,6 +3131,21 @@ func (ec *executionContext) marshalOHair2ᚖgithubᚗcomᚋKhanᚋgenqlientᚋin
 	return ec._Hair(ctx, sel, v)
 }
 
+func (ec *executionContext) unmarshalOID2ᚖstring(ctx context.Context, v interface{}) (*string, error) {
+	if v == nil {
+		return nil, nil
+	}
+	res, err := graphql.UnmarshalID(v)
+	return &res, graphql.ErrorOnPath(ctx, err)
+}
+
+func (ec *executionContext) marshalOID2ᚖstring(ctx context.Context, sel ast.SelectionSet, v *string) graphql.Marshaler {
+	if v == nil {
+		return graphql.Null
+	}
+	return graphql.MarshalID(*v)
+}
+
 func (ec *executionContext) unmarshalOInt2ᚖint(ctx context.Context, v interface{}) (*int, error) {
 	if v == nil {
 		return nil, nil
diff --git a/internal/integration/server/server.go b/internal/integration/server/server.go
index 3cf362b..bbf0a29 100644
--- a/internal/integration/server/server.go
+++ b/internal/integration/server/server.go
@@ -58,8 +58,11 @@ func (r *queryResolver) Me(ctx context.Context) (*User, error) {
 	return userByID("1"), nil
 }
 
-func (r *queryResolver) User(ctx context.Context, id string) (*User, error) {
-	return userByID(id), nil
+func (r *queryResolver) User(ctx context.Context, id *string) (*User, error) {
+	if id == nil {
+		return userByID("1"), nil
+	}
+	return userByID(*id), nil
 }
 
 func (r *queryResolver) Being(ctx context.Context, id string) (Being, error) {


Test patch:
diff --git a/generate/generate_test.go b/generate/generate_test.go
index 94d925d..3612598 100644
--- a/generate/generate_test.go
+++ b/generate/generate_test.go
@@ -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])
diff --git a/generate/testdata/snapshots/TestGenerate-DateTime.graphql-DateTime.graphql.go b/generate/testdata/snapshots/TestGenerate-DateTime.graphql-DateTime.graphql.go
index 6673173..adfc839 100644
--- a/generate/testdata/snapshots/TestGenerate-DateTime.graphql-DateTime.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-DateTime.graphql-DateTime.graphql.go
@@ -8,6 +8,12 @@ import (
 	"github.com/Khan/genqlient/graphql"
 )
 
+// __convertTimezoneInput is used internally by genqlient
+type __convertTimezoneInput struct {
+	Dt time.Time `json:"dt"`
+	Tz string    `json:"tz"`
+}
+
 // convertTimezoneResponse is returned by convertTimezone on success.
 type convertTimezoneResponse struct {
 	Convert time.Time `json:"convert"`
@@ -18,11 +24,10 @@ func convertTimezone(
 	dt time.Time,
 	tz string,
 ) (*convertTimezoneResponse, error) {
-	variables := map[string]interface{}{
-		"dt": dt,
-		"tz": tz,
+	__input := __convertTimezoneInput{
+		Dt: dt,
+		Tz: tz,
 	}
-
 	var err error
 
 	var retval convertTimezoneResponse
@@ -35,7 +40,7 @@ query convertTimezone ($dt: DateTime!, $tz: String) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-InputEnum.graphql-InputEnum.graphql.go b/generate/testdata/snapshots/TestGenerate-InputEnum.graphql-InputEnum.graphql.go
index ba190bc..32c2af8 100644
--- a/generate/testdata/snapshots/TestGenerate-InputEnum.graphql-InputEnum.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-InputEnum.graphql-InputEnum.graphql.go
@@ -38,14 +38,18 @@ const (
 	RoleTeacher Role = "TEACHER"
 )
 
+// __InputEnumQueryInput is used internally by genqlient
+type __InputEnumQueryInput struct {
+	Role Role `json:"role"`
+}
+
 func InputEnumQuery(
 	client graphql.Client,
 	role Role,
 ) (*InputEnumQueryResponse, error) {
-	variables := map[string]interface{}{
-		"role": role,
+	__input := __InputEnumQueryInput{
+		Role: role,
 	}
-
 	var err error
 
 	var retval InputEnumQueryResponse
@@ -60,7 +64,7 @@ query InputEnumQuery ($role: Role!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go b/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go
index c64eed5..443070a 100644
--- a/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-InputObject.graphql-InputObject.graphql.go
@@ -56,14 +56,18 @@ type UserQueryInput struct {
 	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
+// __InputObjectQueryInput is used internally by genqlient
+type __InputObjectQueryInput struct {
+	Query UserQueryInput `json:"query"`
+}
+
 func InputObjectQuery(
 	client graphql.Client,
 	query UserQueryInput,
 ) (*InputObjectQueryResponse, error) {
-	variables := map[string]interface{}{
-		"query": query,
+	__input := __InputObjectQueryInput{
+		Query: query,
 	}
-
 	var err error
 
 	var retval InputObjectQueryResponse
@@ -78,7 +82,7 @@ query InputObjectQuery ($query: UserQueryInput) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-ListInput.graphql-ListInput.graphql.go b/generate/testdata/snapshots/TestGenerate-ListInput.graphql-ListInput.graphql.go
index 359293e..1f1c0cc 100644
--- a/generate/testdata/snapshots/TestGenerate-ListInput.graphql-ListInput.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-ListInput.graphql-ListInput.graphql.go
@@ -27,14 +27,18 @@ type ListInputQueryUser struct {
 	Id testutil.ID `json:"id"`
 }
 
+// __ListInputQueryInput is used internally by genqlient
+type __ListInputQueryInput struct {
+	Names []string `json:"names"`
+}
+
 func ListInputQuery(
 	client graphql.Client,
 	names []string,
 ) (*ListInputQueryResponse, error) {
-	variables := map[string]interface{}{
-		"names": names,
+	__input := __ListInputQueryInput{
+		Names: names,
 	}
-
 	var err error
 
 	var retval ListInputQueryResponse
@@ -49,7 +53,7 @@ query ListInputQuery ($names: [String]) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go b/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go
index 4a904f0..3b47ae3 100644
--- a/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-Omitempty.graphql-Omitempty.graphql.go
@@ -72,6 +72,15 @@ type UserQueryInput struct {
 	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
+// __OmitEmptyQueryInput is used internally by genqlient
+type __OmitEmptyQueryInput struct {
+	Query         UserQueryInput   `json:"query,omitempty"`
+	Queries       []UserQueryInput `json:"queries,omitempty"`
+	Dt            time.Time        `json:"dt,omitempty"`
+	Tz            string           `json:"tz,omitempty"`
+	TzNoOmitEmpty string           `json:"tzNoOmitEmpty"`
+}
+
 func OmitEmptyQuery(
 	client graphql.Client,
 	query UserQueryInput,
@@ -80,29 +89,13 @@ func OmitEmptyQuery(
 	tz string,
 	tzNoOmitEmpty string,
 ) (*OmitEmptyQueryResponse, error) {
-	variables := map[string]interface{}{
-		"tzNoOmitEmpty": tzNoOmitEmpty,
-	}
-
-	var zero_query UserQueryInput
-	if query != zero_query {
-		variables["query"] = query
-	}
-
-	if len(queries) > 0 {
-		variables["queries"] = queries
+	__input := __OmitEmptyQueryInput{
+		Query:         query,
+		Queries:       queries,
+		Dt:            dt,
+		Tz:            tz,
+		TzNoOmitEmpty: tzNoOmitEmpty,
 	}
-
-	var zero_dt time.Time
-	if dt != zero_dt {
-		variables["dt"] = dt
-	}
-
-	var zero_tz string
-	if tz != zero_tz {
-		variables["tz"] = tz
-	}
-
 	var err error
 
 	var retval OmitEmptyQueryResponse
@@ -122,7 +115,7 @@ query OmitEmptyQuery ($query: UserQueryInput, $queries: [UserQueryInput], $dt: D
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go b/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go
index b6bbe8a..96a2a2d 100644
--- a/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-Pointers.graphql-Pointers.graphql.go
@@ -79,18 +79,24 @@ type UserQueryInput struct {
 	HasPokemon *testutil.Pokemon `json:"hasPokemon"`
 }
 
+// __PointersQueryInput is used internally by genqlient
+type __PointersQueryInput struct {
+	Query *UserQueryInput `json:"query"`
+	Dt    time.Time       `json:"dt"`
+	Tz    *string         `json:"tz"`
+}
+
 func PointersQuery(
 	client graphql.Client,
-	query UserQueryInput,
+	query *UserQueryInput,
 	dt time.Time,
-	tz string,
+	tz *string,
 ) (*PointersQueryResponse, error) {
-	variables := map[string]interface{}{
-		"query": query,
-		"dt":    dt,
-		"tz":    tz,
+	__input := __PointersQueryInput{
+		Query: query,
+		Dt:    dt,
+		Tz:    tz,
 	}
-
 	var err error
 
 	var retval PointersQueryResponse
@@ -113,7 +119,7 @@ query PointersQuery ($query: UserQueryInput, $dt: DateTime, $tz: String) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go b/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go
index 67497e4..8ba2afa 100644
--- a/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-PointersInline.graphql-PointersInline.graphql.go
@@ -79,18 +79,24 @@ type UserQueryInput struct {
 	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
+// __PointersQueryInput is used internally by genqlient
+type __PointersQueryInput struct {
+	Query *UserQueryInput `json:"query"`
+	Dt    *time.Time      `json:"dt"`
+	Tz    string          `json:"tz"`
+}
+
 func PointersQuery(
 	client graphql.Client,
 	query *UserQueryInput,
 	dt *time.Time,
 	tz string,
 ) (*PointersQueryResponse, error) {
-	variables := map[string]interface{}{
-		"query": query,
-		"dt":    dt,
-		"tz":    tz,
+	__input := __PointersQueryInput{
+		Query: query,
+		Dt:    dt,
+		Tz:    tz,
 	}
-
 	var err error
 
 	var retval PointersQueryResponse
@@ -113,7 +119,7 @@ query PointersQuery ($query: UserQueryInput, $dt: DateTime, $tz: String) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.go b/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.go
index cc7e676..f5f655a 100644
--- a/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-Pokemon.graphql-Pokemon.graphql.go
@@ -37,14 +37,18 @@ type GetPokemonSiblingsUserGenqlientPokemon struct {
 	Level   int    `json:"level"`
 }
 
+// __GetPokemonSiblingsInput is used internally by genqlient
+type __GetPokemonSiblingsInput struct {
+	Input testutil.Pokemon `json:"input"`
+}
+
 func GetPokemonSiblings(
 	client graphql.Client,
 	input testutil.Pokemon,
 ) (*GetPokemonSiblingsResponse, error) {
-	variables := map[string]interface{}{
-		"input": input,
+	__input := __GetPokemonSiblingsInput{
+		Input: input,
 	}
-
 	var err error
 
 	var retval GetPokemonSiblingsResponse
@@ -69,7 +73,7 @@ query GetPokemonSiblings ($input: PokemonInput!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-Recursion.graphql-Recursion.graphql.go b/generate/testdata/snapshots/TestGenerate-Recursion.graphql-Recursion.graphql.go
index 02e2852..af22c29 100644
--- a/generate/testdata/snapshots/TestGenerate-Recursion.graphql-Recursion.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-Recursion.graphql-Recursion.graphql.go
@@ -36,14 +36,18 @@ type RecursiveInput struct {
 	Rec []RecursiveInput `json:"rec"`
 }
 
+// __RecursionInput is used internally by genqlient
+type __RecursionInput struct {
+	Input RecursiveInput `json:"input"`
+}
+
 func Recursion(
 	client graphql.Client,
 	input RecursiveInput,
 ) (*RecursionResponse, error) {
-	variables := map[string]interface{}{
-		"input": input,
+	__input := __RecursionInput{
+		Input: input,
 	}
-
 	var err error
 
 	var retval RecursionResponse
@@ -64,7 +68,7 @@ query Recursion ($input: RecursiveInput!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-SimpleInput.graphql-SimpleInput.graphql.go b/generate/testdata/snapshots/TestGenerate-SimpleInput.graphql-SimpleInput.graphql.go
index d9f3235..4b40094 100644
--- a/generate/testdata/snapshots/TestGenerate-SimpleInput.graphql-SimpleInput.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-SimpleInput.graphql-SimpleInput.graphql.go
@@ -27,14 +27,18 @@ type SimpleInputQueryUser struct {
 	Id testutil.ID `json:"id"`
 }
 
+// __SimpleInputQueryInput is used internally by genqlient
+type __SimpleInputQueryInput struct {
+	Name string `json:"name"`
+}
+
 func SimpleInputQuery(
 	client graphql.Client,
 	name string,
 ) (*SimpleInputQueryResponse, error) {
-	variables := map[string]interface{}{
-		"name": name,
+	__input := __SimpleInputQueryInput{
+		Name: name,
 	}
-
 	var err error
 
 	var retval SimpleInputQueryResponse
@@ -49,7 +53,7 @@ query SimpleInputQuery ($name: String!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-SimpleMutation.graphql-SimpleMutation.graphql.go b/generate/testdata/snapshots/TestGenerate-SimpleMutation.graphql-SimpleMutation.graphql.go
index b6cfc89..e7095e8 100644
--- a/generate/testdata/snapshots/TestGenerate-SimpleMutation.graphql-SimpleMutation.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-SimpleMutation.graphql-SimpleMutation.graphql.go
@@ -24,6 +24,11 @@ type SimpleMutationResponse struct {
 	CreateUser SimpleMutationCreateUser `json:"createUser"`
 }
 
+// __SimpleMutationInput is used internally by genqlient
+type __SimpleMutationInput struct {
+	Name string `json:"name"`
+}
+
 // SimpleMutation creates a user.
 //
 // It has a long doc-comment, to test that we handle that correctly.
@@ -32,10 +37,9 @@ func SimpleMutation(
 	client graphql.Client,
 	name string,
 ) (*SimpleMutationResponse, error) {
-	variables := map[string]interface{}{
-		"name": name,
+	__input := __SimpleMutationInput{
+		Name: name,
 	}
-
 	var err error
 
 	var retval SimpleMutationResponse
@@ -51,7 +55,7 @@ mutation SimpleMutation ($name: String!) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go b/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go
index 47c0e43..4994cbb 100644
--- a/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go
+++ b/generate/testdata/snapshots/TestGenerate-unexported.graphql-unexported.graphql.go
@@ -36,6 +36,11 @@ type UserQueryInput struct {
 	HasPokemon testutil.Pokemon `json:"hasPokemon"`
 }
 
+// __unexportedInput is used internally by genqlient
+type __unexportedInput struct {
+	Query UserQueryInput `json:"query"`
+}
+
 // unexportedResponse is returned by unexported on success.
 type unexportedResponse struct {
 	// user looks up a user by some stuff.
@@ -60,10 +65,9 @@ func unexported(
 	client graphql.Client,
 	query UserQueryInput,
 ) (*unexportedResponse, error) {
-	variables := map[string]interface{}{
-		"query": query,
+	__input := __unexportedInput{
+		Query: query,
 	}
-
 	var err error
 
 	var retval unexportedResponse
@@ -78,7 +82,7 @@ query unexported ($query: UserQueryInput) {
 }
 `,
 		&retval,
-		variables,
+		&__input,
 	)
 	return &retval, err
 }
diff --git a/internal/integration/integration_test.go b/internal/integration/integration_test.go
index bee2ef7..34ff6b1 100644
--- a/internal/integration/integration_test.go
+++ b/internal/integration/integration_test.go
@@ -89,6 +89,33 @@ func TestVariables(t *testing.T) {
 	assert.Zero(t, resp.User)
 }
 
+func TestOmitempty(t *testing.T) {
+	_ = `# @genqlient(omitempty: true)
+	query queryWithOmitempty($id: ID) {
+		user(id: $id) { id name luckyNumber }
+	}`
+
+	ctx := context.Background()
+	server := server.RunServer()
+	defer server.Close()
+	client := graphql.NewClient(server.URL, http.DefaultClient)
+
+	resp, err := queryWithOmitempty(ctx, client, "2")
+	require.NoError(t, err)
+
+	assert.Equal(t, "2", resp.User.Id)
+	assert.Equal(t, "Raven", resp.User.Name)
+	assert.Equal(t, -1, resp.User.LuckyNumber)
+
+	// should return default user, not the user with ID ""
+	resp, err = queryWithOmitempty(ctx, client, "")
+	require.NoError(t, err)
+
+	assert.Equal(t, "1", resp.User.Id)
+	assert.Equal(t, "Yours Truly", resp.User.Name)
+	assert.Equal(t, 17, resp.User.LuckyNumber)
+}
+
 func TestInterfaceNoFragments(t *testing.T) {
 	_ = `# @genqlient
 	query queryWithInterfaceNoFragments($id: ID!) {