Repository: Khan/genqlient
Pull request #377 (2025-04-13 16:34:15)

Auto snake case
<!--
Thanks for your contribution! Check out the
[contributing docs](https://github.com/Khan/genqlient/blob/main/docs/CONTRIBUTING.md)
for more on contributing to genqlient.
-->

This builds on top of #376 by adding a configuration option to automatically convert all snake_case type and field names to CamelCase.

This PR is a draft until #376 is merged.

I have:
- [x] Written a clear PR title and description (above)
- [x] Signed the [Khan Academy CLA](https://www.khanacademy.org/r/cla)
- [x] Added tests covering my changes, if applicable
- [x] Included a link to the issue fixed, if applicable
- [x] Included documentation, for new features
- [x] Added an entry to the changelog


clinta: Take a look at this, I've tired to refactor to a more unified approach of applying casing algorithms. If this looks good, I can squash commits before you merge.
benjaminjkraft: This is great, thank you! I already squash when merging normally so it should be fine.

Patch:
diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md
index 38bfa85..9e665bf 100644
--- a/docs/CHANGELOG.md
+++ b/docs/CHANGELOG.md
@@ -25,6 +25,7 @@ When releasing a new version:
 ### New features:
 
 - Added `@genqlient(alias)` directive to customize field names without requiring GraphQL aliases (fixes #367)
+- Added `auto_camel_case` config option to automatically convert snake_case to camelCase in both field names and type names
 
 ### Bug fixes:
 
diff --git a/docs/genqlient.yaml b/docs/genqlient.yaml
index 33a49c0..ac345e8 100644
--- a/docs/genqlient.yaml
+++ b/docs/genqlient.yaml
@@ -241,8 +241,7 @@ bindings:
 package_bindings:
 - package: github.com/you/yourpkg/models
 
-# Configuration for genqlient's smart-casing.
-#
+
 # By default genqlient tries to convert GraphQL type names to Go style
 # automatically. Sometimes it doesn't do a great job; this suite of options
 # lets you configure its algorithm as makes sense for your schema.
@@ -254,7 +253,11 @@ package_bindings:
 # - raw: map the GraphQL type exactly; don't try to convert it to Go style.
 #   This is usually best for schemas with casing conflicts, e.g. enums with
 #   values which differ only in casing.
+# - auto_camel_case: automatically convert snake_case to camelCase names before
+#   standard processing. This applies to field names, type names and enum values.
 casing:
+  # The default casing algorithm to use for all GraphQL names (fields, types, etc.).
+  default: auto_camel_case
   # Use the given casing-style (see above) for all GraphQL enum values.
   all_enums: raw
   # Use the given casing-style (see above) for the enum values in the given
diff --git a/generate/config.go b/generate/config.go
index ff1a197..1cb28d6 100644
--- a/generate/config.go
+++ b/generate/config.go
@@ -71,13 +71,14 @@ type PackageBinding struct {
 type CasingAlgorithm string
 
 const (
-	CasingDefault CasingAlgorithm = "default"
-	CasingRaw     CasingAlgorithm = "raw"
+	CasingDefault       CasingAlgorithm = "default"
+	CasingRaw           CasingAlgorithm = "raw"
+	CasingAutoCamelCase CasingAlgorithm = "auto_camel_case"
 )
 
 func (algo CasingAlgorithm) validate() error {
 	switch algo {
-	case CasingDefault, CasingRaw:
+	case CasingDefault, CasingRaw, CasingAutoCamelCase:
 		return nil
 	default:
 		return errorf(nil, "unknown casing algorithm: %s", algo)
@@ -89,11 +90,17 @@ func (algo CasingAlgorithm) validate() error {
 //
 // [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
 type Casing struct {
+	Default  CasingAlgorithm            `yaml:"default"`
 	AllEnums CasingAlgorithm            `yaml:"all_enums"`
 	Enums    map[string]CasingAlgorithm `yaml:"enums"`
 }
 
 func (casing *Casing) validate() error {
+	if casing.Default != "" {
+		if err := casing.Default.validate(); err != nil {
+			return err
+		}
+	}
 	if casing.AllEnums != "" {
 		if err := casing.AllEnums.validate(); err != nil {
 			return err
@@ -107,6 +114,13 @@ func (casing *Casing) validate() error {
 	return nil
 }
 
+func (casing *Casing) getDefault() CasingAlgorithm {
+	if casing.Default != "" {
+		return casing.Default
+	}
+	return CasingDefault
+}
+
 func (casing *Casing) forEnum(graphQLTypeName string) CasingAlgorithm {
 	if specificConfig, ok := casing.Enums[graphQLTypeName]; ok {
 		return specificConfig
@@ -114,7 +128,7 @@ func (casing *Casing) forEnum(graphQLTypeName string) CasingAlgorithm {
 	if casing.AllEnums != "" {
 		return casing.AllEnums
 	}
-	return CasingDefault
+	return casing.getDefault()
 }
 
 // pathJoin is like filepath.Join but 1) it only takes two argsuments,
@@ -370,3 +384,8 @@ func findCfgInDir(dir string) string {
 	}
 	return ""
 }
+
+// GetDefaultCasingAlgorithm returns the default casing algorithm for the config.
+func (c *Config) GetDefaultCasingAlgorithm() CasingAlgorithm {
+	return c.Casing.getDefault()
+}
diff --git a/generate/convert.go b/generate/convert.go
index 36d4d52..6835906 100644
--- a/generate/convert.go
+++ b/generate/convert.go
@@ -171,7 +171,8 @@ func (g *generator) convertArguments(
 			return nil, err
 		}
 
-		goName := upperFirst(arg.Variable)
+		goName := arg.Variable
+		goName = ApplyCasing(goName, g.Config.GetDefaultCasingAlgorithm(), true)
 		// 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
@@ -345,7 +346,7 @@ func (g *generator) convertDefinition(
 			// name-prefix, append the type-name anyway.  This happens when you
 			// assign a type name to an interface type, and we are generating
 			// one of its implementations.
-			name = makeLongTypeName(namePrefix, def.Name)
+			name = makeLongTypeName(namePrefix, def.Name, g.Config.GetDefaultCasingAlgorithm())
 		}
 		// (But the prefix is shared.)
 		namePrefix = newPrefixList(options.TypeName)
@@ -354,11 +355,11 @@ func (g *generator) convertDefinition(
 		// ever possibly generate for this type, so we don't need any of the
 		// qualifiers.  This is especially helpful because the caller is very
 		// likely to need to reference these types in their code.
-		name = upperFirst(def.Name)
+		name = ApplyCasing(def.Name, g.Config.GetDefaultCasingAlgorithm(), true)
 		// (namePrefix is ignored in this case.)
 	} else {
 		// Else, construct a name using the usual algorithm (see names.go).
-		name = makeTypeName(namePrefix, def.Name)
+		name = makeTypeName(namePrefix, def.Name, g.Config.GetDefaultCasingAlgorithm())
 	}
 
 	// If we already generated the type, we can skip it as long as it matches
@@ -433,7 +434,8 @@ func (g *generator) convertDefinition(
 				return nil, err
 			}
 
-			goName := upperFirst(field.Name)
+			goName := field.Name
+			goName = ApplyCasing(goName, g.Config.GetDefaultCasingAlgorithm(), true)
 			// Several of the arguments don't really make sense here:
 			// (note field.Type is necessarily a scalar, input, or enum)
 			//  - namePrefix is ignored for input types and enums (see
@@ -917,12 +919,14 @@ func (g *generator) convertField(
 			field.Position, "undefined field %v", field.Alias)
 	}
 
-	goName := upperFirst(field.Alias)
+	goName := field.Alias
 	if fieldOptions.Alias != "" {
-		goName = upperFirst(fieldOptions.Alias)
+		goName = fieldOptions.Alias
 	}
 
-	namePrefix = nextPrefix(namePrefix, field)
+	goName = ApplyCasing(goName, g.Config.GetDefaultCasingAlgorithm(), true)
+
+	namePrefix = nextPrefix(namePrefix, field, g.Config.GetDefaultCasingAlgorithm())
 
 	fieldGoType, err := g.convertType(
 		namePrefix, field.Definition.Type, field.SelectionSet,
diff --git a/generate/names.go b/generate/names.go
index 0aecacc..aaa8793 100644
--- a/generate/names.go
+++ b/generate/names.go
@@ -139,10 +139,10 @@ func joinPrefixList(prefix *prefixList) string {
 // prefix-list, since it ends with a type, not a field (see top-of-file
 // comment), but it's used to construct both the type-names from the input and
 // the next prefix-list.
-func typeNameParts(prefix *prefixList, typeName string) *prefixList {
-	// GraphQL types are conventionally UpperCamelCase, but it's not required;
-	// our names will look best if they are.
-	typeName = upperFirst(typeName)
+func typeNameParts(prefix *prefixList, typeName string, algorithm CasingAlgorithm) *prefixList {
+	// Apply the specified casing algorithm with uppercase first letter
+	typeName = ApplyCasing(typeName, algorithm, true)
+
 	// If the prefix has just one part, that's the operation-name.  There's no
 	// need to add "Query" or "Mutation".  (Zero should never happen.)
 	if prefix == nil || prefix.tail == nil ||
@@ -156,18 +156,19 @@ func typeNameParts(prefix *prefixList, typeName string) *prefixList {
 
 // Given a prefix-list, and a field, compute the next prefix-list, which will
 // be used for that field's selections.
-func nextPrefix(prefix *prefixList, field *ast.Field) *prefixList {
+func nextPrefix(prefix *prefixList, field *ast.Field, algorithm CasingAlgorithm) *prefixList {
 	// Add the type.
-	prefix = typeNameParts(prefix, field.ObjectDefinition.Name)
+	prefix = typeNameParts(prefix, field.ObjectDefinition.Name, algorithm)
 	// Add the field (there's no shortening here, see top-of-file comment).
-	prefix = &prefixList{upperFirst(field.Alias), prefix}
+	fieldAlias := ApplyCasing(field.Alias, algorithm, true)
+	prefix = &prefixList{fieldAlias, prefix}
 	return prefix
 }
 
 // Given a prefix-list, and the GraphQL of the current type, compute the name
 // we should give it in Go.
-func makeTypeName(prefix *prefixList, typeName string) string {
-	return joinPrefixList(typeNameParts(prefix, typeName))
+func makeTypeName(prefix *prefixList, typeName string, algorithm CasingAlgorithm) string {
+	return joinPrefixList(typeNameParts(prefix, typeName, algorithm))
 }
 
 // Like makeTypeName, but append typeName unconditionally.
@@ -175,8 +176,8 @@ func makeTypeName(prefix *prefixList, typeName string) string {
 // This is used for when you specify a type-name for a field of interface
 // type; we use YourName for the interface, but need to do YourNameImplName for
 // the implementations.
-func makeLongTypeName(prefix *prefixList, typeName string) string {
-	typeName = upperFirst(typeName)
+func makeLongTypeName(prefix *prefixList, typeName string, algorithm CasingAlgorithm) string {
+	typeName = ApplyCasing(typeName, algorithm, true)
 	return joinPrefixList(&prefixList{typeName, prefix})
 }
 
@@ -186,6 +187,8 @@ func (casing *Casing) enumValueName(goTypeName string, enum *ast.Definition, val
 		return goTypeName + goConstName(val.Name)
 	case CasingRaw:
 		return goTypeName + "_" + val.Name
+	case CasingAutoCamelCase:
+		return goTypeName + ApplyCasing(val.Name, algo, true)
 	default:
 		// Should already be caught by validation.
 		panic(fmt.Sprintf("unknown casing algorithm %s", algo))
diff --git a/generate/util.go b/generate/util.go
index c424c7f..cd93364 100644
--- a/generate/util.go
+++ b/generate/util.go
@@ -28,6 +28,27 @@ func upperFirst(s string) string {
 	return changeFirst(strings.TrimLeft(s, "_"), unicode.ToUpper)
 }
 
+func snakeToCamel(s string) string {
+	var result strings.Builder
+	var nextUpper bool
+
+	for _, r := range s {
+		if r == '_' {
+			nextUpper = true
+			continue
+		}
+
+		if nextUpper {
+			result.WriteRune(unicode.ToUpper(r))
+			nextUpper = false
+		} else {
+			result.WriteRune(r)
+		}
+	}
+
+	return result.String()
+}
+
 func goConstName(s string) string {
 	if strings.TrimLeft(s, "_") == "" {
 		return s
@@ -47,6 +68,33 @@ func goConstName(s string) string {
 	}, s)
 }
 
+// ApplyCasing applies a specific casing algorithm to a string.
+// It handles the different casing transformations based on the algorithm.
+// The forceUpperFirst flag is used to make the first character uppercase regardless
+// of the algorithm.
+func ApplyCasing(s string, algo CasingAlgorithm, forceUpperFirst bool) string {
+	var result string
+
+	switch algo {
+	case CasingAutoCamelCase:
+		result = snakeToCamel(s)
+	case CasingRaw:
+		result = s
+	case CasingDefault:
+		// Default casing implementation
+		result = s
+	default:
+		// Unknown algorithm - treat as default
+		result = s
+	}
+
+	if forceUpperFirst {
+		result = upperFirst(result)
+	}
+
+	return result
+}
+
 // https://go.dev/ref/spec#Keywords
 var goKeywords = map[string]bool{
 	"break":       true,


Test patch:
diff --git a/generate/generate_test.go b/generate/generate_test.go
index d7f5309..fa38dfd 100644
--- a/generate/generate_test.go
+++ b/generate/generate_test.go
@@ -244,6 +244,13 @@ func TestGenerateWithConfig(t *testing.T) {
 				StructReferences: true,
 			},
 		},
+		{
+			"AutoCamelCase", "", []string{"SnakeCaseFields.graphql", "SnakeCaseType.graphql"}, &Config{
+				Casing: Casing{
+					Default: CasingAutoCamelCase,
+				},
+			},
+		},
 	}
 
 	sourceFilename := "SimpleQuery.graphql"
diff --git a/generate/names_test.go b/generate/names_test.go
index 1e3d2fc..a5fc8f5 100644
--- a/generate/names_test.go
+++ b/generate/names_test.go
@@ -60,9 +60,63 @@ func TestTypeNames(t *testing.T) {
 		t.Run(test.expectedTypeName, func(t *testing.T) {
 			prefix := newPrefixList("Operation")
 			for _, field := range test.fields {
-				prefix = nextPrefix(prefix, field)
+				prefix = nextPrefix(prefix, field, CasingDefault)
 			}
-			actualTypeName := makeTypeName(prefix, test.leafTypeName)
+			actualTypeName := makeTypeName(prefix, test.leafTypeName, CasingDefault)
+			if actualTypeName != test.expectedTypeName {
+				t.Errorf("name mismatch:\ngot:  %s\nwant: %s",
+					actualTypeName, test.expectedTypeName)
+			}
+		})
+	}
+}
+
+func TestSnakeToTypeNames(t *testing.T) {
+	// Test specifically for the snake_case conversion in type names
+	tests := []struct {
+		expectedTypeName string
+		fields           []*ast.Field
+		leafTypeName     string
+		autoCamelCase    bool
+	}{{
+		// Without auto_camel_case
+		"ServiceIPsIp_address_listSnake_case_type",
+		[]*ast.Field{fakeField("Query", "ip_address_list")},
+		"snake_case_type",
+		false,
+	}, {
+		// With auto_camel_case
+		"ServiceIPsIpAddressListSnakeCaseType",
+		[]*ast.Field{fakeField("Query", "ip_address_list")},
+		"snake_case_type",
+		true,
+	}, {
+		// With nested snake_case fields
+		"ServiceIPsObjectSnake_case_fieldSnake_case_type",
+		[]*ast.Field{fakeField("Query", "object"), fakeField("Object", "snake_case_field")},
+		"snake_case_type",
+		false,
+	}, {
+		// With nested snake_case fields and auto_camel_case enabled
+		"ServiceIPsObjectSnakeCaseFieldSnakeCaseType",
+		[]*ast.Field{fakeField("Query", "object"), fakeField("Object", "snake_case_field")},
+		"snake_case_type",
+		true,
+	}}
+
+	for _, test := range tests {
+		test := test
+		t.Run(test.expectedTypeName, func(t *testing.T) {
+			prefix := newPrefixList("ServiceIPs")
+			algorithm := CasingDefault
+			if test.autoCamelCase {
+				algorithm = CasingAutoCamelCase
+			}
+
+			for _, field := range test.fields {
+				prefix = nextPrefix(prefix, field, algorithm)
+			}
+			actualTypeName := makeTypeName(prefix, test.leafTypeName, algorithm)
 			if actualTypeName != test.expectedTypeName {
 				t.Errorf("name mismatch:\ngot:  %s\nwant: %s",
 					actualTypeName, test.expectedTypeName)
@@ -92,9 +146,9 @@ func TestTypeNameCollisions(t *testing.T) {
 	for i, test := range tests {
 		prefix := newPrefixList("Operation")
 		for _, field := range test.fields {
-			prefix = nextPrefix(prefix, field)
+			prefix = nextPrefix(prefix, field, CasingDefault)
 		}
-		actualTypeName := makeTypeName(prefix, test.leafTypeName)
+		actualTypeName := makeTypeName(prefix, test.leafTypeName, CasingDefault)
 
 		otherIndex, ok := seen[actualTypeName]
 		if ok {
diff --git a/generate/testdata/queries/SnakeCaseFields.graphql b/generate/testdata/queries/SnakeCaseFields.graphql
new file mode 100644
index 0000000..c12be1e
--- /dev/null
+++ b/generate/testdata/queries/SnakeCaseFields.graphql
@@ -0,0 +1,6 @@
+query SnakeCaseFields {
+  user {
+    user_id
+    display_name
+  }
+}
\ No newline at end of file
diff --git a/generate/testdata/queries/SnakeCaseNested.graphql b/generate/testdata/queries/SnakeCaseNested.graphql
new file mode 100644
index 0000000..30a75f7
--- /dev/null
+++ b/generate/testdata/queries/SnakeCaseNested.graphql
@@ -0,0 +1,8 @@
+query SnakeCaseNested {
+  object {
+    snake_case_field {
+      id
+      name
+    }
+  }
+}
\ No newline at end of file
diff --git a/generate/testdata/queries/SnakeCaseType.graphql b/generate/testdata/queries/SnakeCaseType.graphql
new file mode 100644
index 0000000..7335195
--- /dev/null
+++ b/generate/testdata/queries/SnakeCaseType.graphql
@@ -0,0 +1,6 @@
+query SnakeCaseType {
+  snake_case_type {
+    id
+    name
+  }
+}
\ No newline at end of file
diff --git a/generate/testdata/queries/schema.graphql b/generate/testdata/queries/schema.graphql
index e1fe9aa..aa70fcc 100644
--- a/generate/testdata/queries/schema.graphql
+++ b/generate/testdata/queries/schema.graphql
@@ -65,6 +65,8 @@ type User {
   emailsOrNull: [String!]
   emailsWithNulls: [String]!
   emailsWithNullsOrNull: [String]
+  user_id: String
+  display_name: String
   authMethods: [AuthMethod!]!
   pokemon: [Pokemon!]
   greeting: Clip
@@ -190,6 +192,8 @@ type Query {
   default(input: InputWithDefaults! = {field: "input omitted"}): Boolean
   omitempty(input: OmitemptyInput): Boolean
   useStructReferencesInput(input: UseStructReferencesInput!): Boolean
+  snake_case_type: snake_case_type
+  object: ObjectType!
 }
 
 type Mutation {
@@ -243,3 +247,13 @@ input UseStructReferencesInput {
   listOfNullable: [StructInput]!
   nullableList: [StructInput!]
 }
+
+type snake_case_type {
+  id: ID!
+  name: String
+}
+
+type ObjectType {
+  id: ID!
+  snake_case_field: snake_case_type
+}
diff --git a/generate/testdata/snapshots/TestGenerate-SnakeCaseFields.graphql-SnakeCaseFields.graphql.go b/generate/testdata/snapshots/TestGenerate-SnakeCaseFields.graphql-SnakeCaseFields.graphql.go
new file mode 100644
index 0000000..512db27
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-SnakeCaseFields.graphql-SnakeCaseFields.graphql.go
@@ -0,0 +1,65 @@
+// Code generated by github.com/Khan/genqlient, DO NOT EDIT.
+
+package test
+
+import (
+	"github.com/Khan/genqlient/graphql"
+)
+
+// SnakeCaseFieldsResponse is returned by SnakeCaseFields on success.
+type SnakeCaseFieldsResponse struct {
+	// user looks up a user by some stuff.
+	//
+	// See UserQueryInput for what stuff is supported.
+	// If query is null, returns the current user.
+	User SnakeCaseFieldsUser `json:"user"`
+}
+
+// GetUser returns SnakeCaseFieldsResponse.User, and is useful for accessing the field via an interface.
+func (v *SnakeCaseFieldsResponse) GetUser() SnakeCaseFieldsUser { return v.User }
+
+// SnakeCaseFieldsUser includes the requested fields of the GraphQL type User.
+// The GraphQL type's documentation follows.
+//
+// A User is a user!
+type SnakeCaseFieldsUser struct {
+	User_id      string `json:"user_id"`
+	Display_name string `json:"display_name"`
+}
+
+// GetUser_id returns SnakeCaseFieldsUser.User_id, and is useful for accessing the field via an interface.
+func (v *SnakeCaseFieldsUser) GetUser_id() string { return v.User_id }
+
+// GetDisplay_name returns SnakeCaseFieldsUser.Display_name, and is useful for accessing the field via an interface.
+func (v *SnakeCaseFieldsUser) GetDisplay_name() string { return v.Display_name }
+
+// The query executed by SnakeCaseFields.
+const SnakeCaseFields_Operation = `
+query SnakeCaseFields {
+	user {
+		user_id
+		display_name
+	}
+}
+`
+
+func SnakeCaseFields(
+	client_ graphql.Client,
+) (data_ *SnakeCaseFieldsResponse, err_ error) {
+	req_ := &graphql.Request{
+		OpName: "SnakeCaseFields",
+		Query:  SnakeCaseFields_Operation,
+	}
+
+	data_ = &SnakeCaseFieldsResponse{}
+	resp_ := &graphql.Response{Data: data_}
+
+	err_ = client_.MakeRequest(
+		nil,
+		req_,
+		resp_,
+	)
+
+	return data_, err_
+}
+
diff --git a/generate/testdata/snapshots/TestGenerate-SnakeCaseFields.graphql-SnakeCaseFields.graphql.json b/generate/testdata/snapshots/TestGenerate-SnakeCaseFields.graphql-SnakeCaseFields.graphql.json
new file mode 100644
index 0000000..3c5e106
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-SnakeCaseFields.graphql-SnakeCaseFields.graphql.json
@@ -0,0 +1,9 @@
+{
+  "operations": [
+    {
+      "operationName": "SnakeCaseFields",
+      "query": "\nquery SnakeCaseFields {\n\tuser {\n\t\tuser_id\n\t\tdisplay_name\n\t}\n}\n",
+      "sourceLocation": "testdata/queries/SnakeCaseFields.graphql"
+    }
+  ]
+}
diff --git a/generate/testdata/snapshots/TestGenerate-SnakeCaseNested.graphql-SnakeCaseNested.graphql.go b/generate/testdata/snapshots/TestGenerate-SnakeCaseNested.graphql-SnakeCaseNested.graphql.go
new file mode 100644
index 0000000..8819816
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-SnakeCaseNested.graphql-SnakeCaseNested.graphql.go
@@ -0,0 +1,75 @@
+// Code generated by github.com/Khan/genqlient, DO NOT EDIT.
+
+package test
+
+import (
+	"github.com/Khan/genqlient/graphql"
+	"github.com/Khan/genqlient/internal/testutil"
+)
+
+// SnakeCaseNestedObjectObjectType includes the requested fields of the GraphQL type ObjectType.
+type SnakeCaseNestedObjectObjectType struct {
+	Snake_case_field SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type `json:"snake_case_field"`
+}
+
+// GetSnake_case_field returns SnakeCaseNestedObjectObjectType.Snake_case_field, and is useful for accessing the field via an interface.
+func (v *SnakeCaseNestedObjectObjectType) GetSnake_case_field() SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type {
+	return v.Snake_case_field
+}
+
+// SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type includes the requested fields of the GraphQL type snake_case_type.
+type SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type struct {
+	Id   testutil.ID `json:"id"`
+	Name string      `json:"name"`
+}
+
+// GetId returns SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type.Id, and is useful for accessing the field via an interface.
+func (v *SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type) GetId() testutil.ID {
+	return v.Id
+}
+
+// GetName returns SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type.Name, and is useful for accessing the field via an interface.
+func (v *SnakeCaseNestedObjectObjectTypeSnake_case_fieldSnake_case_type) GetName() string {
+	return v.Name
+}
+
+// SnakeCaseNestedResponse is returned by SnakeCaseNested on success.
+type SnakeCaseNestedResponse struct {
+	Object SnakeCaseNestedObjectObjectType `json:"object"`
+}
+
+// GetObject returns SnakeCaseNestedResponse.Object, and is useful for accessing the field via an interface.
+func (v *SnakeCaseNestedResponse) GetObject() SnakeCaseNestedObjectObjectType { return v.Object }
+
+// The query executed by SnakeCaseNested.
+const SnakeCaseNested_Operation = `
+query SnakeCaseNested {
+	object {
+		snake_case_field {
+			id
+			name
+		}
+	}
+}
+`
+
+func SnakeCaseNested(
+	client_ graphql.Client,
+) (data_ *SnakeCaseNestedResponse, err_ error) {
+	req_ := &graphql.Request{
+		OpName: "SnakeCaseNested",
+		Query:  SnakeCaseNested_Operation,
+	}
+
+	data_ = &SnakeCaseNestedResponse{}
+	resp_ := &graphql.Response{Data: data_}
+
+	err_ = client_.MakeRequest(
+		nil,
+		req_,
+		resp_,
+	)
+
+	return data_, err_
+}
+
diff --git a/generate/testdata/snapshots/TestGenerate-SnakeCaseNested.graphql-SnakeCaseNested.graphql.json b/generate/testdata/snapshots/TestGenerate-SnakeCaseNested.graphql-SnakeCaseNested.graphql.json
new file mode 100644
index 0000000..ae32014
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-SnakeCaseNested.graphql-SnakeCaseNested.graphql.json
@@ -0,0 +1,9 @@
+{
+  "operations": [
+    {
+      "operationName": "SnakeCaseNested",
+      "query": "\nquery SnakeCaseNested {\n\tobject {\n\t\tsnake_case_field {\n\t\t\tid\n\t\t\tname\n\t\t}\n\t}\n}\n",
+      "sourceLocation": "testdata/queries/SnakeCaseNested.graphql"
+    }
+  ]
+}
diff --git a/generate/testdata/snapshots/TestGenerate-SnakeCaseType.graphql-SnakeCaseType.graphql.go b/generate/testdata/snapshots/TestGenerate-SnakeCaseType.graphql-SnakeCaseType.graphql.go
new file mode 100644
index 0000000..c264be4
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-SnakeCaseType.graphql-SnakeCaseType.graphql.go
@@ -0,0 +1,61 @@
+// Code generated by github.com/Khan/genqlient, DO NOT EDIT.
+
+package test
+
+import (
+	"github.com/Khan/genqlient/graphql"
+	"github.com/Khan/genqlient/internal/testutil"
+)
+
+// SnakeCaseTypeResponse is returned by SnakeCaseType on success.
+type SnakeCaseTypeResponse struct {
+	Snake_case_type SnakeCaseTypeSnake_case_type `json:"snake_case_type"`
+}
+
+// GetSnake_case_type returns SnakeCaseTypeResponse.Snake_case_type, and is useful for accessing the field via an interface.
+func (v *SnakeCaseTypeResponse) GetSnake_case_type() SnakeCaseTypeSnake_case_type {
+	return v.Snake_case_type
+}
+
+// SnakeCaseTypeSnake_case_type includes the requested fields of the GraphQL type snake_case_type.
+type SnakeCaseTypeSnake_case_type struct {
+	Id   testutil.ID `json:"id"`
+	Name string      `json:"name"`
+}
+
+// GetId returns SnakeCaseTypeSnake_case_type.Id, and is useful for accessing the field via an interface.
+func (v *SnakeCaseTypeSnake_case_type) GetId() testutil.ID { return v.Id }
+
+// GetName returns SnakeCaseTypeSnake_case_type.Name, and is useful for accessing the field via an interface.
+func (v *SnakeCaseTypeSnake_case_type) GetName() string { return v.Name }
+
+// The query executed by SnakeCaseType.
+const SnakeCaseType_Operation = `
+query SnakeCaseType {
+	snake_case_type {
+		id
+		name
+	}
+}
+`
+
+func SnakeCaseType(
+	client_ graphql.Client,
+) (data_ *SnakeCaseTypeResponse, err_ error) {
+	req_ := &graphql.Request{
+		OpName: "SnakeCaseType",
+		Query:  SnakeCaseType_Operation,
+	}
+
+	data_ = &SnakeCaseTypeResponse{}
+	resp_ := &graphql.Response{Data: data_}
+
+	err_ = client_.MakeRequest(
+		nil,
+		req_,
+		resp_,
+	)
+
+	return data_, err_
+}
+
diff --git a/generate/testdata/snapshots/TestGenerate-SnakeCaseType.graphql-SnakeCaseType.graphql.json b/generate/testdata/snapshots/TestGenerate-SnakeCaseType.graphql-SnakeCaseType.graphql.json
new file mode 100644
index 0000000..02803fd
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerate-SnakeCaseType.graphql-SnakeCaseType.graphql.json
@@ -0,0 +1,9 @@
+{
+  "operations": [
+    {
+      "operationName": "SnakeCaseType",
+      "query": "\nquery SnakeCaseType {\n\tsnake_case_type {\n\t\tid\n\t\tname\n\t}\n}\n",
+      "sourceLocation": "testdata/queries/SnakeCaseType.graphql"
+    }
+  ]
+}
diff --git a/generate/testdata/snapshots/TestGenerateWithConfig-AutoCamelCase-testdata-queries-generated.go b/generate/testdata/snapshots/TestGenerateWithConfig-AutoCamelCase-testdata-queries-generated.go
new file mode 100644
index 0000000..057ece5
--- /dev/null
+++ b/generate/testdata/snapshots/TestGenerateWithConfig-AutoCamelCase-testdata-queries-generated.go
@@ -0,0 +1,119 @@
+// Code generated by github.com/Khan/genqlient, DO NOT EDIT.
+
+package queries
+
+import (
+	"context"
+
+	"github.com/Khan/genqlient/graphql"
+)
+
+// SnakeCaseFieldsResponse is returned by SnakeCaseFields on success.
+type SnakeCaseFieldsResponse struct {
+	// user looks up a user by some stuff.
+	//
+	// See UserQueryInput for what stuff is supported.
+	// If query is null, returns the current user.
+	User SnakeCaseFieldsUser `json:"user"`
+}
+
+// GetUser returns SnakeCaseFieldsResponse.User, and is useful for accessing the field via an interface.
+func (v *SnakeCaseFieldsResponse) GetUser() SnakeCaseFieldsUser { return v.User }
+
+// SnakeCaseFieldsUser includes the requested fields of the GraphQL type User.
+// The GraphQL type's documentation follows.
+//
+// A User is a user!
+type SnakeCaseFieldsUser struct {
+	UserId      string `json:"user_id"`
+	DisplayName string `json:"display_name"`
+}
+
+// GetUserId returns SnakeCaseFieldsUser.UserId, and is useful for accessing the field via an interface.
+func (v *SnakeCaseFieldsUser) GetUserId() string { return v.UserId }
+
+// GetDisplayName returns SnakeCaseFieldsUser.DisplayName, and is useful for accessing the field via an interface.
+func (v *SnakeCaseFieldsUser) GetDisplayName() string { return v.DisplayName }
+
+// SnakeCaseTypeResponse is returned by SnakeCaseType on success.
+type SnakeCaseTypeResponse struct {
+	SnakeCaseType SnakeCaseTypeSnakeCaseType `json:"snake_case_type"`
+}
+
+// GetSnakeCaseType returns SnakeCaseTypeResponse.SnakeCaseType, and is useful for accessing the field via an interface.
+func (v *SnakeCaseTypeResponse) GetSnakeCaseType() SnakeCaseTypeSnakeCaseType { return v.SnakeCaseType }
+
+// SnakeCaseTypeSnakeCaseType includes the requested fields of the GraphQL type snake_case_type.
+type SnakeCaseTypeSnakeCaseType struct {
+	Id   string `json:"id"`
+	Name string `json:"name"`
+}
+
+// GetId returns SnakeCaseTypeSnakeCaseType.Id, and is useful for accessing the field via an interface.
+func (v *SnakeCaseTypeSnakeCaseType) GetId() string { return v.Id }
+
+// GetName returns SnakeCaseTypeSnakeCaseType.Name, and is useful for accessing the field via an interface.
+func (v *SnakeCaseTypeSnakeCaseType) GetName() string { return v.Name }
+
+// The query executed by SnakeCaseFields.
+const SnakeCaseFields_Operation = `
+query SnakeCaseFields {
+	user {
+		user_id
+		display_name
+	}
+}
+`
+
+func SnakeCaseFields(
+	ctx_ context.Context,
+	client_ graphql.Client,
+) (data_ *SnakeCaseFieldsResponse, err_ error) {
+	req_ := &graphql.Request{
+		OpName: "SnakeCaseFields",
+		Query:  SnakeCaseFields_Operation,
+	}
+
+	data_ = &SnakeCaseFieldsResponse{}
+	resp_ := &graphql.Response{Data: data_}
+
+	err_ = client_.MakeRequest(
+		ctx_,
+		req_,
+		resp_,
+	)
+
+	return data_, err_
+}
+
+// The query executed by SnakeCaseType.
+const SnakeCaseType_Operation = `
+query SnakeCaseType {
+	snake_case_type {
+		id
+		name
+	}
+}
+`
+
+func SnakeCaseType(
+	ctx_ context.Context,
+	client_ graphql.Client,
+) (data_ *SnakeCaseTypeResponse, err_ error) {
+	req_ := &graphql.Request{
+		OpName: "SnakeCaseType",
+		Query:  SnakeCaseType_Operation,
+	}
+
+	data_ = &SnakeCaseTypeResponse{}
+	resp_ := &graphql.Response{Data: data_}
+
+	err_ = client_.MakeRequest(
+		ctx_,
+		req_,
+		resp_,
+	)
+
+	return data_, err_
+}
+
diff --git a/generate/testdata/snapshots/TestValidConfigs-Empty.yml b/generate/testdata/snapshots/TestValidConfigs-Empty.yml
index 9ca8ea2..ee5e78c 100644
--- a/generate/testdata/snapshots/TestValidConfigs-Empty.yml
+++ b/generate/testdata/snapshots/TestValidConfigs-Empty.yml
@@ -9,6 +9,7 @@
   Bindings: (map[string]*generate.TypeBinding) <nil>,
   PackageBindings: ([]*generate.PackageBinding) <nil>,
   Casing: (generate.Casing) {
+    Default: (generate.CasingAlgorithm) "",
     AllEnums: (generate.CasingAlgorithm) "",
     Enums: (map[string]generate.CasingAlgorithm) <nil>
   },
diff --git a/generate/testdata/snapshots/TestValidConfigs-Lists.yaml b/generate/testdata/snapshots/TestValidConfigs-Lists.yaml
index b69452b..866448d 100644
--- a/generate/testdata/snapshots/TestValidConfigs-Lists.yaml
+++ b/generate/testdata/snapshots/TestValidConfigs-Lists.yaml
@@ -15,6 +15,7 @@
   Bindings: (map[string]*generate.TypeBinding) <nil>,
   PackageBindings: ([]*generate.PackageBinding) <nil>,
   Casing: (generate.Casing) {
+    Default: (generate.CasingAlgorithm) "",
     AllEnums: (generate.CasingAlgorithm) "",
     Enums: (map[string]generate.CasingAlgorithm) <nil>
   },
diff --git a/generate/testdata/snapshots/TestValidConfigs-Strings.yaml b/generate/testdata/snapshots/TestValidConfigs-Strings.yaml
index 3ad7738..30a578c 100644
--- a/generate/testdata/snapshots/TestValidConfigs-Strings.yaml
+++ b/generate/testdata/snapshots/TestValidConfigs-Strings.yaml
@@ -13,6 +13,7 @@
   Bindings: (map[string]*generate.TypeBinding) <nil>,
   PackageBindings: ([]*generate.PackageBinding) <nil>,
   Casing: (generate.Casing) {
+    Default: (generate.CasingAlgorithm) "",
     AllEnums: (generate.CasingAlgorithm) "",
     Enums: (map[string]generate.CasingAlgorithm) <nil>
   },