Skip to content

Commit e2e0ef0

Browse files
authored
Auto snake case (#377)
This builds on top of #376 by adding a configuration option to automatically convert all snake_case type and field names to CamelCase.
1 parent bc17161 commit e2e0ef0

22 files changed

+552
-29
lines changed

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ When releasing a new version:
2525
### New features:
2626

2727
- Added `@genqlient(alias)` directive to customize field names without requiring GraphQL aliases (fixes #367)
28+
- Added `auto_camel_case` config option to automatically convert snake_case to camelCase in both field names and type names
2829

2930
### Bug fixes:
3031

docs/genqlient.yaml

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,8 +241,7 @@ bindings:
241241
package_bindings:
242242
- package: github.com/you/yourpkg/models
243243

244-
# Configuration for genqlient's smart-casing.
245-
#
244+
246245
# By default genqlient tries to convert GraphQL type names to Go style
247246
# automatically. Sometimes it doesn't do a great job; this suite of options
248247
# lets you configure its algorithm as makes sense for your schema.
@@ -254,7 +253,11 @@ package_bindings:
254253
# - raw: map the GraphQL type exactly; don't try to convert it to Go style.
255254
# This is usually best for schemas with casing conflicts, e.g. enums with
256255
# values which differ only in casing.
256+
# - auto_camel_case: automatically convert snake_case to camelCase names before
257+
# standard processing. This applies to field names, type names and enum values.
257258
casing:
259+
# The default casing algorithm to use for all GraphQL names (fields, types, etc.).
260+
default: auto_camel_case
258261
# Use the given casing-style (see above) for all GraphQL enum values.
259262
all_enums: raw
260263
# Use the given casing-style (see above) for the enum values in the given

generate/config.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,14 @@ type PackageBinding struct {
7171
type CasingAlgorithm string
7272

7373
const (
74-
CasingDefault CasingAlgorithm = "default"
75-
CasingRaw CasingAlgorithm = "raw"
74+
CasingDefault CasingAlgorithm = "default"
75+
CasingRaw CasingAlgorithm = "raw"
76+
CasingAutoCamelCase CasingAlgorithm = "auto_camel_case"
7677
)
7778

7879
func (algo CasingAlgorithm) validate() error {
7980
switch algo {
80-
case CasingDefault, CasingRaw:
81+
case CasingDefault, CasingRaw, CasingAutoCamelCase:
8182
return nil
8283
default:
8384
return errorf(nil, "unknown casing algorithm: %s", algo)
@@ -89,11 +90,17 @@ func (algo CasingAlgorithm) validate() error {
8990
//
9091
// [genqlient.yaml docs]: https://github.com/Khan/genqlient/blob/main/docs/genqlient.yaml
9192
type Casing struct {
93+
Default CasingAlgorithm `yaml:"default"`
9294
AllEnums CasingAlgorithm `yaml:"all_enums"`
9395
Enums map[string]CasingAlgorithm `yaml:"enums"`
9496
}
9597

9698
func (casing *Casing) validate() error {
99+
if casing.Default != "" {
100+
if err := casing.Default.validate(); err != nil {
101+
return err
102+
}
103+
}
97104
if casing.AllEnums != "" {
98105
if err := casing.AllEnums.validate(); err != nil {
99106
return err
@@ -107,14 +114,21 @@ func (casing *Casing) validate() error {
107114
return nil
108115
}
109116

117+
func (casing *Casing) getDefault() CasingAlgorithm {
118+
if casing.Default != "" {
119+
return casing.Default
120+
}
121+
return CasingDefault
122+
}
123+
110124
func (casing *Casing) forEnum(graphQLTypeName string) CasingAlgorithm {
111125
if specificConfig, ok := casing.Enums[graphQLTypeName]; ok {
112126
return specificConfig
113127
}
114128
if casing.AllEnums != "" {
115129
return casing.AllEnums
116130
}
117-
return CasingDefault
131+
return casing.getDefault()
118132
}
119133

120134
// pathJoin is like filepath.Join but 1) it only takes two argsuments,
@@ -370,3 +384,8 @@ func findCfgInDir(dir string) string {
370384
}
371385
return ""
372386
}
387+
388+
// GetDefaultCasingAlgorithm returns the default casing algorithm for the config.
389+
func (c *Config) GetDefaultCasingAlgorithm() CasingAlgorithm {
390+
return c.Casing.getDefault()
391+
}

generate/convert.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,8 @@ func (g *generator) convertArguments(
171171
return nil, err
172172
}
173173

174-
goName := upperFirst(arg.Variable)
174+
goName := arg.Variable
175+
goName = ApplyCasing(goName, g.Config.GetDefaultCasingAlgorithm(), true)
175176
// Some of the arguments don't apply here, namely the name-prefix (see
176177
// names.go) and the selection-set (we use all the input type's fields,
177178
// and so on recursively). See also the `case ast.InputObject` in
@@ -345,7 +346,7 @@ func (g *generator) convertDefinition(
345346
// name-prefix, append the type-name anyway. This happens when you
346347
// assign a type name to an interface type, and we are generating
347348
// one of its implementations.
348-
name = makeLongTypeName(namePrefix, def.Name)
349+
name = makeLongTypeName(namePrefix, def.Name, g.Config.GetDefaultCasingAlgorithm())
349350
}
350351
// (But the prefix is shared.)
351352
namePrefix = newPrefixList(options.TypeName)
@@ -354,11 +355,11 @@ func (g *generator) convertDefinition(
354355
// ever possibly generate for this type, so we don't need any of the
355356
// qualifiers. This is especially helpful because the caller is very
356357
// likely to need to reference these types in their code.
357-
name = upperFirst(def.Name)
358+
name = ApplyCasing(def.Name, g.Config.GetDefaultCasingAlgorithm(), true)
358359
// (namePrefix is ignored in this case.)
359360
} else {
360361
// Else, construct a name using the usual algorithm (see names.go).
361-
name = makeTypeName(namePrefix, def.Name)
362+
name = makeTypeName(namePrefix, def.Name, g.Config.GetDefaultCasingAlgorithm())
362363
}
363364

364365
// If we already generated the type, we can skip it as long as it matches
@@ -433,7 +434,8 @@ func (g *generator) convertDefinition(
433434
return nil, err
434435
}
435436

436-
goName := upperFirst(field.Name)
437+
goName := field.Name
438+
goName = ApplyCasing(goName, g.Config.GetDefaultCasingAlgorithm(), true)
437439
// Several of the arguments don't really make sense here:
438440
// (note field.Type is necessarily a scalar, input, or enum)
439441
// - namePrefix is ignored for input types and enums (see
@@ -917,12 +919,14 @@ func (g *generator) convertField(
917919
field.Position, "undefined field %v", field.Alias)
918920
}
919921

920-
goName := upperFirst(field.Alias)
922+
goName := field.Alias
921923
if fieldOptions.Alias != "" {
922-
goName = upperFirst(fieldOptions.Alias)
924+
goName = fieldOptions.Alias
923925
}
924926

925-
namePrefix = nextPrefix(namePrefix, field)
927+
goName = ApplyCasing(goName, g.Config.GetDefaultCasingAlgorithm(), true)
928+
929+
namePrefix = nextPrefix(namePrefix, field, g.Config.GetDefaultCasingAlgorithm())
926930

927931
fieldGoType, err := g.convertType(
928932
namePrefix, field.Definition.Type, field.SelectionSet,

generate/generate_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,13 @@ func TestGenerateWithConfig(t *testing.T) {
244244
StructReferences: true,
245245
},
246246
},
247+
{
248+
"AutoCamelCase", "", []string{"SnakeCaseFields.graphql", "SnakeCaseType.graphql"}, &Config{
249+
Casing: Casing{
250+
Default: CasingAutoCamelCase,
251+
},
252+
},
253+
},
247254
}
248255

249256
sourceFilename := "SimpleQuery.graphql"

generate/names.go

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -139,10 +139,10 @@ func joinPrefixList(prefix *prefixList) string {
139139
// prefix-list, since it ends with a type, not a field (see top-of-file
140140
// comment), but it's used to construct both the type-names from the input and
141141
// the next prefix-list.
142-
func typeNameParts(prefix *prefixList, typeName string) *prefixList {
143-
// GraphQL types are conventionally UpperCamelCase, but it's not required;
144-
// our names will look best if they are.
145-
typeName = upperFirst(typeName)
142+
func typeNameParts(prefix *prefixList, typeName string, algorithm CasingAlgorithm) *prefixList {
143+
// Apply the specified casing algorithm with uppercase first letter
144+
typeName = ApplyCasing(typeName, algorithm, true)
145+
146146
// If the prefix has just one part, that's the operation-name. There's no
147147
// need to add "Query" or "Mutation". (Zero should never happen.)
148148
if prefix == nil || prefix.tail == nil ||
@@ -156,27 +156,28 @@ func typeNameParts(prefix *prefixList, typeName string) *prefixList {
156156

157157
// Given a prefix-list, and a field, compute the next prefix-list, which will
158158
// be used for that field's selections.
159-
func nextPrefix(prefix *prefixList, field *ast.Field) *prefixList {
159+
func nextPrefix(prefix *prefixList, field *ast.Field, algorithm CasingAlgorithm) *prefixList {
160160
// Add the type.
161-
prefix = typeNameParts(prefix, field.ObjectDefinition.Name)
161+
prefix = typeNameParts(prefix, field.ObjectDefinition.Name, algorithm)
162162
// Add the field (there's no shortening here, see top-of-file comment).
163-
prefix = &prefixList{upperFirst(field.Alias), prefix}
163+
fieldAlias := ApplyCasing(field.Alias, algorithm, true)
164+
prefix = &prefixList{fieldAlias, prefix}
164165
return prefix
165166
}
166167

167168
// Given a prefix-list, and the GraphQL of the current type, compute the name
168169
// we should give it in Go.
169-
func makeTypeName(prefix *prefixList, typeName string) string {
170-
return joinPrefixList(typeNameParts(prefix, typeName))
170+
func makeTypeName(prefix *prefixList, typeName string, algorithm CasingAlgorithm) string {
171+
return joinPrefixList(typeNameParts(prefix, typeName, algorithm))
171172
}
172173

173174
// Like makeTypeName, but append typeName unconditionally.
174175
//
175176
// This is used for when you specify a type-name for a field of interface
176177
// type; we use YourName for the interface, but need to do YourNameImplName for
177178
// the implementations.
178-
func makeLongTypeName(prefix *prefixList, typeName string) string {
179-
typeName = upperFirst(typeName)
179+
func makeLongTypeName(prefix *prefixList, typeName string, algorithm CasingAlgorithm) string {
180+
typeName = ApplyCasing(typeName, algorithm, true)
180181
return joinPrefixList(&prefixList{typeName, prefix})
181182
}
182183

@@ -186,6 +187,8 @@ func (casing *Casing) enumValueName(goTypeName string, enum *ast.Definition, val
186187
return goTypeName + goConstName(val.Name)
187188
case CasingRaw:
188189
return goTypeName + "_" + val.Name
190+
case CasingAutoCamelCase:
191+
return goTypeName + ApplyCasing(val.Name, algo, true)
189192
default:
190193
// Should already be caught by validation.
191194
panic(fmt.Sprintf("unknown casing algorithm %s", algo))

generate/names_test.go

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -60,9 +60,63 @@ func TestTypeNames(t *testing.T) {
6060
t.Run(test.expectedTypeName, func(t *testing.T) {
6161
prefix := newPrefixList("Operation")
6262
for _, field := range test.fields {
63-
prefix = nextPrefix(prefix, field)
63+
prefix = nextPrefix(prefix, field, CasingDefault)
6464
}
65-
actualTypeName := makeTypeName(prefix, test.leafTypeName)
65+
actualTypeName := makeTypeName(prefix, test.leafTypeName, CasingDefault)
66+
if actualTypeName != test.expectedTypeName {
67+
t.Errorf("name mismatch:\ngot: %s\nwant: %s",
68+
actualTypeName, test.expectedTypeName)
69+
}
70+
})
71+
}
72+
}
73+
74+
func TestSnakeToTypeNames(t *testing.T) {
75+
// Test specifically for the snake_case conversion in type names
76+
tests := []struct {
77+
expectedTypeName string
78+
fields []*ast.Field
79+
leafTypeName string
80+
autoCamelCase bool
81+
}{{
82+
// Without auto_camel_case
83+
"ServiceIPsIp_address_listSnake_case_type",
84+
[]*ast.Field{fakeField("Query", "ip_address_list")},
85+
"snake_case_type",
86+
false,
87+
}, {
88+
// With auto_camel_case
89+
"ServiceIPsIpAddressListSnakeCaseType",
90+
[]*ast.Field{fakeField("Query", "ip_address_list")},
91+
"snake_case_type",
92+
true,
93+
}, {
94+
// With nested snake_case fields
95+
"ServiceIPsObjectSnake_case_fieldSnake_case_type",
96+
[]*ast.Field{fakeField("Query", "object"), fakeField("Object", "snake_case_field")},
97+
"snake_case_type",
98+
false,
99+
}, {
100+
// With nested snake_case fields and auto_camel_case enabled
101+
"ServiceIPsObjectSnakeCaseFieldSnakeCaseType",
102+
[]*ast.Field{fakeField("Query", "object"), fakeField("Object", "snake_case_field")},
103+
"snake_case_type",
104+
true,
105+
}}
106+
107+
for _, test := range tests {
108+
test := test
109+
t.Run(test.expectedTypeName, func(t *testing.T) {
110+
prefix := newPrefixList("ServiceIPs")
111+
algorithm := CasingDefault
112+
if test.autoCamelCase {
113+
algorithm = CasingAutoCamelCase
114+
}
115+
116+
for _, field := range test.fields {
117+
prefix = nextPrefix(prefix, field, algorithm)
118+
}
119+
actualTypeName := makeTypeName(prefix, test.leafTypeName, algorithm)
66120
if actualTypeName != test.expectedTypeName {
67121
t.Errorf("name mismatch:\ngot: %s\nwant: %s",
68122
actualTypeName, test.expectedTypeName)
@@ -92,9 +146,9 @@ func TestTypeNameCollisions(t *testing.T) {
92146
for i, test := range tests {
93147
prefix := newPrefixList("Operation")
94148
for _, field := range test.fields {
95-
prefix = nextPrefix(prefix, field)
149+
prefix = nextPrefix(prefix, field, CasingDefault)
96150
}
97-
actualTypeName := makeTypeName(prefix, test.leafTypeName)
151+
actualTypeName := makeTypeName(prefix, test.leafTypeName, CasingDefault)
98152

99153
otherIndex, ok := seen[actualTypeName]
100154
if ok {
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
query SnakeCaseFields {
2+
user {
3+
user_id
4+
display_name
5+
}
6+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
query SnakeCaseNested {
2+
object {
3+
snake_case_field {
4+
id
5+
name
6+
}
7+
}
8+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
query SnakeCaseType {
2+
snake_case_type {
3+
id
4+
name
5+
}
6+
}

0 commit comments

Comments
 (0)