Skip to content

Commit 1ae217a

Browse files
authored
Combine nocase and strictcase tag options under a case option (#153)
WARNING: This commit contains breaking changes. Instead of specifying `nocase` or `strictcase` tag options, use a single `case` tag option, which is a key-value pair where the value can only be 'ignore' or 'strict'. This reads more naturally as `case:ignore` or `case:strict` and is more clear that `case:ignore` and `case:strict` are mutually exclusive tag options.
1 parent ee88191 commit 1ae217a

File tree

9 files changed

+112
-75
lines changed

9 files changed

+112
-75
lines changed

arshal_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -158,10 +158,10 @@ type (
158158
Quote string `json:"'\"'"`
159159
}
160160
structNoCase struct {
161-
Aaa string `json:",strictcase"`
161+
Aaa string `json:",case:strict"`
162162
AA_A string
163-
AaA string `json:",nocase"`
164-
AAa string `json:",nocase"`
163+
AaA string `json:",case:ignore"`
164+
AAa string `json:",case:ignore"`
165165
AAA string
166166
}
167167
structScalars struct {
@@ -476,17 +476,17 @@ type (
476476
B int `json:",omitzero"`
477477
}
478478
structNoCaseInlineTextValue struct {
479-
AAA string `json:",omitempty,strictcase"`
479+
AAA string `json:",omitempty,case:strict"`
480480
AA_b string `json:",omitempty"`
481-
AaA string `json:",omitempty,nocase"`
482-
AAa string `json:",omitempty,nocase"`
481+
AaA string `json:",omitempty,case:ignore"`
482+
AAa string `json:",omitempty,case:ignore"`
483483
Aaa string `json:",omitempty"`
484484
X jsontext.Value `json:",inline"`
485485
}
486486
structNoCaseInlineMapStringAny struct {
487487
AAA string `json:",omitempty"`
488-
AaA string `json:",omitempty,nocase"`
489-
AAa string `json:",omitempty,nocase"`
488+
AaA string `json:",omitempty,case:ignore"`
489+
AAa string `json:",omitempty,case:ignore"`
490490
Aaa string `json:",omitempty"`
491491
X jsonObject `json:",inline"`
492492
}

doc.go

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,15 @@
8888
// This extra level of encoding is often necessary since
8989
// many JSON parsers cannot precisely represent 64-bit integers.
9090
//
91-
// - nocase: When unmarshaling, the "nocase" option specifies that
92-
// if the JSON object name does not exactly match the JSON name
93-
// for any of the struct fields, then it attempts to match the struct field
94-
// using a case-insensitive match that also ignores dashes and underscores.
95-
// If multiple fields match,
91+
// - case: When unmarshaling, the "case" option specifies how
92+
// JSON object names are matched with the JSON name for Go struct fields.
93+
// The option is a key-value pair specified as "case:value" where
94+
// the value must either be 'ignore' or 'strict'.
95+
// The 'ignore' value specifies that matching is case-insensitive
96+
// where dashes and underscores are also ignored. If multiple fields match,
9697
// the first declared field in breadth-first order takes precedence.
97-
// This takes precedence even if [MatchCaseInsensitiveNames] is set to false.
98-
// This cannot be specified together with the "strictcase" option.
99-
//
100-
// - strictcase: When unmarshaling, the "strictcase" option specifies that the
101-
// JSON object name must exactly match the JSON name for the struct field.
102-
// This takes precedence even if [MatchCaseInsensitiveNames] is set to true.
103-
// This cannot be specified together with the "nocase" option.
98+
// The 'strict' value specifies that matching is case-sensitive.
99+
// This takes precedence over the [MatchCaseInsensitiveNames] option.
104100
//
105101
// - inline: The "inline" option specifies that
106102
// the JSON representable content of this field type is to be promoted

example_test.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ func Example_fieldNames() {
7474
// A JSON name is provided without any special characters.
7575
JSONName any `json:"jsonName"`
7676
// No JSON name is not provided, so the Go field name is used.
77-
Option any `json:",nocase"`
77+
Option any `json:",case:ignore"`
7878
// An empty JSON name specified using an single-quoted string literal.
7979
Empty any `json:"''"`
8080
// A dash JSON name specified using an single-quoted string literal.
@@ -108,8 +108,8 @@ func Example_fieldNames() {
108108

109109
// Unmarshal matches JSON object names with Go struct fields using
110110
// a case-sensitive match, but can be configured to use a case-insensitive
111-
// match with the "nocase" option. This permits unmarshaling from inputs that
112-
// use naming conventions such as camelCase, snake_case, or kebab-case.
111+
// match with the "case:ignore" option. This permits unmarshaling from inputs
112+
// that use naming conventions such as camelCase, snake_case, or kebab-case.
113113
func Example_caseSensitivity() {
114114
// JSON input using various naming conventions.
115115
const input = `[
@@ -124,24 +124,24 @@ func Example_caseSensitivity() {
124124
{"unknown": true}
125125
]`
126126

127-
// Without "nocase", Unmarshal looks for an exact match.
128-
var withcase []struct {
127+
// Without "case:ignore", Unmarshal looks for an exact match.
128+
var caseStrict []struct {
129129
X bool `json:"firstName"`
130130
}
131-
if err := json.Unmarshal([]byte(input), &withcase); err != nil {
131+
if err := json.Unmarshal([]byte(input), &caseStrict); err != nil {
132132
log.Fatal(err)
133133
}
134-
fmt.Println(withcase) // exactly 1 match found
134+
fmt.Println(caseStrict) // exactly 1 match found
135135

136-
// With "nocase", Unmarshal looks first for an exact match,
136+
// With "case:ignore", Unmarshal looks first for an exact match,
137137
// then for a case-insensitive match if none found.
138-
var nocase []struct {
139-
X bool `json:"firstName,nocase"`
138+
var caseIgnore []struct {
139+
X bool `json:"firstName,case:ignore"`
140140
}
141-
if err := json.Unmarshal([]byte(input), &nocase); err != nil {
141+
if err := json.Unmarshal([]byte(input), &caseIgnore); err != nil {
142142
log.Fatal(err)
143143
}
144-
fmt.Println(nocase) // 8 matches found
144+
fmt.Println(caseIgnore) // 8 matches found
145145

146146
// Output:
147147
// [{false} {true} {false} {false} {false} {false} {false} {false} {false}]

fields.go

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -331,7 +331,7 @@ func makeStructFields(root reflect.Type) (fs structFields, serr *SemanticError)
331331
}
332332
for foldedName, fields := range fs.byFoldedName {
333333
if len(fields) > 1 {
334-
// The precedence order for conflicting nocase names
334+
// The precedence order for conflicting ignoreCase names
335335
// is by breadth-first order, rather than depth-first order.
336336
slices.SortFunc(fields, func(x, y *structField) int {
337337
return cmp.Compare(x.id, y.id)
@@ -359,18 +359,18 @@ func indirectType(t reflect.Type) reflect.Type {
359359
// matchFoldedName matches a case-insensitive name depending on the options.
360360
// It assumes that foldName(f.name) == foldName(name).
361361
//
362-
// Case-insensitive matching is used if the `nocase` tag option is specified
362+
// Case-insensitive matching is used if the `case:ignore` tag option is specified
363363
// or the MatchCaseInsensitiveNames call option is specified
364-
// (and the `strictcase` tag option is not specified).
365-
// Functionally, the `nocase` and `strictcase` tag options take precedence.
364+
// (and the `case:strict` tag option is not specified).
365+
// Functionally, the `case:ignore` and `case:strict` tag options take precedence.
366366
//
367367
// The v1 definition of case-insensitivity operated under strings.EqualFold
368368
// and would strictly compare dashes and underscores,
369369
// while the v2 definition would ignore the presence of dashes and underscores.
370370
// Thus, if the MatchCaseSensitiveDelimiter call option is specified,
371371
// the match is further restricted to using strings.EqualFold.
372372
func (f *structField) matchFoldedName(name []byte, flags *jsonflags.Flags) bool {
373-
if f.casing == nocase || (flags.Get(jsonflags.MatchCaseInsensitiveNames) && f.casing != strictcase) {
373+
if f.casing == caseIgnore || (flags.Get(jsonflags.MatchCaseInsensitiveNames) && f.casing != caseStrict) {
374374
if !flags.Get(jsonflags.MatchCaseSensitiveDelimiter) || strings.EqualFold(string(name), f.name) {
375375
return true
376376
}
@@ -379,16 +379,16 @@ func (f *structField) matchFoldedName(name []byte, flags *jsonflags.Flags) bool
379379
}
380380

381381
const (
382-
nocase = 1
383-
strictcase = 2
382+
caseIgnore = 1
383+
caseStrict = 2
384384
)
385385

386386
type fieldOptions struct {
387387
name string
388388
quotedName string // quoted name per RFC 8785, section 3.2.2.2.
389389
hasName bool
390390
nameNeedEscape bool
391-
casing int8 // either 0, nocase, or strictcase
391+
casing int8 // either 0, caseIgnore, or caseStrict
392392
inline bool
393393
unknown bool
394394
omitzero bool
@@ -490,10 +490,30 @@ func parseFieldOptions(sf reflect.StructField) (out fieldOptions, ignored bool,
490490
err = cmp.Or(err, fmt.Errorf("Go struct field %s has unnecessarily quoted appearance of `%s` tag option; specify `%s` instead", sf.Name, rawOpt, opt))
491491
}
492492
switch opt {
493-
case "nocase":
494-
out.casing |= nocase
495-
case "strictcase":
496-
out.casing |= strictcase
493+
case "case":
494+
if !strings.HasPrefix(tag, ":") {
495+
err = cmp.Or(err, fmt.Errorf("Go struct field %s is missing value for `case` tag option; specify `case:ignore` or `case:strict` instead", sf.Name))
496+
break
497+
}
498+
tag = tag[len(":"):]
499+
opt, n, err2 := consumeTagOption(tag)
500+
if err2 != nil {
501+
err = cmp.Or(err, fmt.Errorf("Go struct field %s has malformed value for `case` tag option: %v", sf.Name, err2))
502+
break
503+
}
504+
rawOpt := tag[:n]
505+
tag = tag[n:]
506+
if strings.HasPrefix(rawOpt, "'") {
507+
err = cmp.Or(err, fmt.Errorf("Go struct field %s has unnecessarily quoted appearance of `case:%s` tag option; specify `case:%s` instead", sf.Name, rawOpt, opt))
508+
}
509+
switch opt {
510+
case "ignore":
511+
out.casing |= caseIgnore
512+
case "strict":
513+
out.casing |= caseStrict
514+
default:
515+
err = cmp.Or(err, fmt.Errorf("Go struct field %s has unknown `case:%s` tag value", sf.Name, rawOpt))
516+
}
497517
case "inline":
498518
out.inline = true
499519
case "unknown":
@@ -523,7 +543,7 @@ func parseFieldOptions(sf reflect.StructField) (out fieldOptions, ignored bool,
523543
// This catches invalid mutants such as "omitEmpty" or "omit_empty".
524544
normOpt := strings.ReplaceAll(strings.ToLower(opt), "_", "")
525545
switch normOpt {
526-
case "nocase", "strictcase", "inline", "unknown", "omitzero", "omitempty", "string", "format":
546+
case "case", "inline", "unknown", "omitzero", "omitempty", "string", "format":
527547
err = cmp.Or(err, fmt.Errorf("Go struct field %s has invalid appearance of `%s` tag option; specify `%s` instead", sf.Name, opt, normOpt))
528548
}
529549

@@ -534,8 +554,8 @@ func parseFieldOptions(sf reflect.StructField) (out fieldOptions, ignored bool,
534554

535555
// Reject duplicates.
536556
switch {
537-
case out.casing == nocase|strictcase:
538-
err = cmp.Or(err, fmt.Errorf("Go struct field %s cannot have both `nocase` and `strictcase` tag options", sf.Name))
557+
case out.casing == caseIgnore|caseStrict:
558+
err = cmp.Or(err, fmt.Errorf("Go struct field %s cannot have both `case:ignore` and `case:strict` tag options", sf.Name))
539559
case seenOpts[opt]:
540560
err = cmp.Or(err, fmt.Errorf("Go struct field %s has duplicate appearance of `%s` tag option", sf.Name, rawOpt))
541561
}

fields_test.go

Lines changed: 40 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ func TestMakeStructFields(t *testing.T) {
3838
F2 string `json:"-"`
3939
F3 string `json:"json_name"`
4040
f3 string
41-
F5 string `json:"json_name_nocase,nocase"`
41+
F5 string `json:"json_name_nocase,case:ignore"`
4242
}{},
4343
want: structFields{
4444
flattened: []structField{
4545
{id: 0, index: []int{0}, typ: stringType, fieldOptions: fieldOptions{name: "F1", quotedName: `"F1"`}},
4646
{id: 1, index: []int{2}, typ: stringType, fieldOptions: fieldOptions{name: "json_name", quotedName: `"json_name"`, hasName: true}},
47-
{id: 2, index: []int{4}, typ: stringType, fieldOptions: fieldOptions{name: "json_name_nocase", quotedName: `"json_name_nocase"`, hasName: true, casing: nocase}},
47+
{id: 2, index: []int{4}, typ: stringType, fieldOptions: fieldOptions{name: "json_name_nocase", quotedName: `"json_name_nocase"`, hasName: true, casing: caseIgnore}},
4848
},
4949
},
5050
}, {
@@ -615,24 +615,45 @@ func TestParseTagOptions(t *testing.T) {
615615
wantOpts: fieldOptions{name: "V", quotedName: `"V"`, inline: true, unknown: true},
616616
wantErr: errors.New("Go struct field V has malformed `json` tag: invalid character ',' at start of option (expecting Unicode letter or single quote)"),
617617
}, {
618-
name: jsontest.Name("NoCaseOption"),
618+
name: jsontest.Name("CaseAloneOption"),
619619
in: struct {
620-
FieldName int `json:",nocase"`
620+
FieldName int `json:",case"`
621621
}{},
622-
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: nocase},
622+
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
623+
wantErr: errors.New("Go struct field FieldName is missing value for `case` tag option; specify `case:ignore` or `case:strict` instead"),
624+
}, {
625+
name: jsontest.Name("CaseIgnoreOption"),
626+
in: struct {
627+
FieldName int `json:",case:ignore"`
628+
}{},
629+
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseIgnore},
630+
}, {
631+
name: jsontest.Name("CaseStrictOption"),
632+
in: struct {
633+
FieldName int `json:",case:strict"`
634+
}{},
635+
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseStrict},
636+
}, {
637+
name: jsontest.Name("CaseUnknownOption"),
638+
in: struct {
639+
FieldName int `json:",case:unknown"`
640+
}{},
641+
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
642+
wantErr: errors.New("Go struct field FieldName has unknown `case:unknown` tag value"),
623643
}, {
624-
name: jsontest.Name("StrictCaseOption"),
644+
name: jsontest.Name("CaseQuotedOption"),
625645
in: struct {
626-
FieldName int `json:",strictcase"`
646+
FieldName int `json:",case:'ignore'"`
627647
}{},
628-
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: strictcase},
648+
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseIgnore},
649+
wantErr: errors.New("Go struct field FieldName has unnecessarily quoted appearance of `case:'ignore'` tag option; specify `case:ignore` instead"),
629650
}, {
630651
name: jsontest.Name("BothCaseOptions"),
631652
in: struct {
632-
FieldName int `json:",nocase,strictcase"`
653+
FieldName int `json:",case:ignore,case:strict"`
633654
}{},
634-
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: nocase | strictcase},
635-
wantErr: errors.New("Go struct field FieldName cannot have both `nocase` and `strictcase` tag options"),
655+
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseIgnore | caseStrict},
656+
wantErr: errors.New("Go struct field FieldName cannot have both `case:ignore` and `case:strict` tag options"),
636657
}, {
637658
name: jsontest.Name("InlineOption"),
638659
in: struct {
@@ -699,12 +720,12 @@ func TestParseTagOptions(t *testing.T) {
699720
}, {
700721
name: jsontest.Name("AllOptions"),
701722
in: struct {
702-
FieldName int `json:",nocase,inline,unknown,omitzero,omitempty,string,format:format"`
723+
FieldName int `json:",case:ignore,inline,unknown,omitzero,omitempty,string,format:format"`
703724
}{},
704725
wantOpts: fieldOptions{
705726
name: "FieldName",
706727
quotedName: `"FieldName"`,
707-
casing: nocase,
728+
casing: caseIgnore,
708729
inline: true,
709730
unknown: true,
710731
omitzero: true,
@@ -715,31 +736,31 @@ func TestParseTagOptions(t *testing.T) {
715736
}, {
716737
name: jsontest.Name("AllOptionsQuoted"),
717738
in: struct {
718-
FieldName int `json:",'nocase','inline','unknown','omitzero','omitempty','string','format':'format'"`
739+
FieldName int `json:",'case':'ignore','inline','unknown','omitzero','omitempty','string','format':'format'"`
719740
}{},
720741
wantOpts: fieldOptions{
721742
name: "FieldName",
722743
quotedName: `"FieldName"`,
723-
casing: nocase,
744+
casing: caseIgnore,
724745
inline: true,
725746
unknown: true,
726747
omitzero: true,
727748
omitempty: true,
728749
string: true,
729750
format: "format",
730751
},
731-
wantErr: errors.New("Go struct field FieldName has unnecessarily quoted appearance of `'nocase'` tag option; specify `nocase` instead"),
752+
wantErr: errors.New("Go struct field FieldName has unnecessarily quoted appearance of `'case'` tag option; specify `case` instead"),
732753
}, {
733754
name: jsontest.Name("AllOptionsCaseSensitive"),
734755
in: struct {
735-
FieldName int `json:",NOCASE,INLINE,UNKNOWN,OMITZERO,OMITEMPTY,STRING,FORMAT:FORMAT"`
756+
FieldName int `json:",CASE:IGNORE,INLINE,UNKNOWN,OMITZERO,OMITEMPTY,STRING,FORMAT:FORMAT"`
736757
}{},
737758
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
738-
wantErr: errors.New("Go struct field FieldName has invalid appearance of `NOCASE` tag option; specify `nocase` instead"),
759+
wantErr: errors.New("Go struct field FieldName has invalid appearance of `CASE` tag option; specify `case` instead"),
739760
}, {
740761
name: jsontest.Name("AllOptionsSpaceSensitive"),
741762
in: struct {
742-
FieldName int `json:", nocase , inline , unknown , omitzero , omitempty , string , format:format "`
763+
FieldName int `json:", case:ignore , inline , unknown , omitzero , omitempty , string , format:format "`
743764
}{},
744765
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
745766
wantErr: errors.New("Go struct field FieldName has malformed `json` tag: invalid character ' ' at start of option (expecting Unicode letter or single quote)"),

fold_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,7 @@ func TestBenchmarkUnmarshalUnknown(t *testing.T) {
111111
fields = append(fields, reflect.StructField{
112112
Name: fmt.Sprintf("Name%d", i),
113113
Type: T[int](),
114-
Tag: `json:",nocase"`,
114+
Tag: `json:",case:ignore"`,
115115
})
116116
}
117117
out := reflect.New(reflect.StructOf(fields)).Interface()

options.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ func OmitZeroStructFields(v bool) Options {
185185

186186
// MatchCaseInsensitiveNames specifies that JSON object members are matched
187187
// against Go struct fields using a case-insensitive match of the name.
188-
// Go struct fields explicitly marked with `strictcase` or `nocase`
188+
// Go struct fields explicitly marked with `case:strict` or `case:ignore`
189189
// always use case-sensitive (or case-insensitive) name matching,
190190
// regardless of the value of this option.
191191
//

v1/diff_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ var jsonPackages = []struct {
3737
//
3838
// Case-insensitive matching is a surprising default and
3939
// incurs significant performance cost when unmarshaling unknown fields.
40-
// In v2, we can opt into v1-like behavior with the `nocase` tag option.
40+
// In v2, we can opt into v1-like behavior with the `case:ignore` tag option.
4141
// The case-insensitive matching performed by v2 is looser than that of v1
4242
// where it also ignores dashes and underscores.
4343
// This allows v2 to match fields regardless of whether the name is in
@@ -50,7 +50,7 @@ func TestCaseSensitivity(t *testing.T) {
5050
type Fields struct {
5151
FieldA bool
5252
FieldB bool `json:"fooBar"`
53-
FieldC bool `json:"fizzBuzz,nocase"` // `nocase` is used by v2 to explicitly enable case-insensitive matching
53+
FieldC bool `json:"fizzBuzz,case:ignore"` // `case:ignore` is used by v2 to explicitly enable case-insensitive matching
5454
}
5555

5656
for _, json := range jsonPackages {
@@ -82,8 +82,8 @@ func TestCaseSensitivity(t *testing.T) {
8282
},
8383
"FieldC": {
8484
"fizzBuzz": true, // exact match for explicitly specified JSON name
85-
"fizzbuzz": true, // v2 is case-insensitive due to `nocase` tag
86-
"FIZZBUZZ": true, // v2 is case-insensitive due to `nocase` tag
85+
"fizzbuzz": true, // v2 is case-insensitive due to `case:ignore` tag
86+
"FIZZBUZZ": true, // v2 is case-insensitive due to `case:ignore` tag
8787
"fizz_buzz": onlyV2, // case-insensitivity in v2 ignores dashes and underscores
8888
"fizz-buzz": onlyV2, // case-insensitivity in v2 ignores dashes and underscores
8989
"fooBar": false,

0 commit comments

Comments
 (0)