Skip to content

Commit

Permalink
Combine nocase and strictcase tag options under a case option (#…
Browse files Browse the repository at this point in the history
…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.
  • Loading branch information
dsnet authored Feb 11, 2025
1 parent ee88191 commit 1ae217a
Show file tree
Hide file tree
Showing 9 changed files with 112 additions and 75 deletions.
16 changes: 8 additions & 8 deletions arshal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,10 @@ type (
Quote string `json:"'\"'"`
}
structNoCase struct {
Aaa string `json:",strictcase"`
Aaa string `json:",case:strict"`
AA_A string
AaA string `json:",nocase"`
AAa string `json:",nocase"`
AaA string `json:",case:ignore"`
AAa string `json:",case:ignore"`
AAA string
}
structScalars struct {
Expand Down Expand Up @@ -476,17 +476,17 @@ type (
B int `json:",omitzero"`
}
structNoCaseInlineTextValue struct {
AAA string `json:",omitempty,strictcase"`
AAA string `json:",omitempty,case:strict"`
AA_b string `json:",omitempty"`
AaA string `json:",omitempty,nocase"`
AAa string `json:",omitempty,nocase"`
AaA string `json:",omitempty,case:ignore"`
AAa string `json:",omitempty,case:ignore"`
Aaa string `json:",omitempty"`
X jsontext.Value `json:",inline"`
}
structNoCaseInlineMapStringAny struct {
AAA string `json:",omitempty"`
AaA string `json:",omitempty,nocase"`
AAa string `json:",omitempty,nocase"`
AaA string `json:",omitempty,case:ignore"`
AAa string `json:",omitempty,case:ignore"`
Aaa string `json:",omitempty"`
X jsonObject `json:",inline"`
}
Expand Down
20 changes: 8 additions & 12 deletions doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,15 @@
// This extra level of encoding is often necessary since
// many JSON parsers cannot precisely represent 64-bit integers.
//
// - nocase: When unmarshaling, the "nocase" option specifies that
// if the JSON object name does not exactly match the JSON name
// for any of the struct fields, then it attempts to match the struct field
// using a case-insensitive match that also ignores dashes and underscores.
// If multiple fields match,
// - case: When unmarshaling, the "case" option specifies how
// JSON object names are matched with the JSON name for Go struct fields.
// The option is a key-value pair specified as "case:value" where
// the value must either be 'ignore' or 'strict'.
// The 'ignore' value specifies that matching is case-insensitive
// where dashes and underscores are also ignored. If multiple fields match,
// the first declared field in breadth-first order takes precedence.
// This takes precedence even if [MatchCaseInsensitiveNames] is set to false.
// This cannot be specified together with the "strictcase" option.
//
// - strictcase: When unmarshaling, the "strictcase" option specifies that the
// JSON object name must exactly match the JSON name for the struct field.
// This takes precedence even if [MatchCaseInsensitiveNames] is set to true.
// This cannot be specified together with the "nocase" option.
// The 'strict' value specifies that matching is case-sensitive.
// This takes precedence over the [MatchCaseInsensitiveNames] option.
//
// - inline: The "inline" option specifies that
// the JSON representable content of this field type is to be promoted
Expand Down
24 changes: 12 additions & 12 deletions example_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ func Example_fieldNames() {
// A JSON name is provided without any special characters.
JSONName any `json:"jsonName"`
// No JSON name is not provided, so the Go field name is used.
Option any `json:",nocase"`
Option any `json:",case:ignore"`
// An empty JSON name specified using an single-quoted string literal.
Empty any `json:"''"`
// A dash JSON name specified using an single-quoted string literal.
Expand Down Expand Up @@ -108,8 +108,8 @@ func Example_fieldNames() {

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

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

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

// Output:
// [{false} {true} {false} {false} {false} {false} {false} {false} {false}]
Expand Down
50 changes: 35 additions & 15 deletions fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ func makeStructFields(root reflect.Type) (fs structFields, serr *SemanticError)
}
for foldedName, fields := range fs.byFoldedName {
if len(fields) > 1 {
// The precedence order for conflicting nocase names
// The precedence order for conflicting ignoreCase names
// is by breadth-first order, rather than depth-first order.
slices.SortFunc(fields, func(x, y *structField) int {
return cmp.Compare(x.id, y.id)
Expand Down Expand Up @@ -359,18 +359,18 @@ func indirectType(t reflect.Type) reflect.Type {
// matchFoldedName matches a case-insensitive name depending on the options.
// It assumes that foldName(f.name) == foldName(name).
//
// Case-insensitive matching is used if the `nocase` tag option is specified
// Case-insensitive matching is used if the `case:ignore` tag option is specified
// or the MatchCaseInsensitiveNames call option is specified
// (and the `strictcase` tag option is not specified).
// Functionally, the `nocase` and `strictcase` tag options take precedence.
// (and the `case:strict` tag option is not specified).
// Functionally, the `case:ignore` and `case:strict` tag options take precedence.
//
// The v1 definition of case-insensitivity operated under strings.EqualFold
// and would strictly compare dashes and underscores,
// while the v2 definition would ignore the presence of dashes and underscores.
// Thus, if the MatchCaseSensitiveDelimiter call option is specified,
// the match is further restricted to using strings.EqualFold.
func (f *structField) matchFoldedName(name []byte, flags *jsonflags.Flags) bool {
if f.casing == nocase || (flags.Get(jsonflags.MatchCaseInsensitiveNames) && f.casing != strictcase) {
if f.casing == caseIgnore || (flags.Get(jsonflags.MatchCaseInsensitiveNames) && f.casing != caseStrict) {
if !flags.Get(jsonflags.MatchCaseSensitiveDelimiter) || strings.EqualFold(string(name), f.name) {
return true
}
Expand All @@ -379,16 +379,16 @@ func (f *structField) matchFoldedName(name []byte, flags *jsonflags.Flags) bool
}

const (
nocase = 1
strictcase = 2
caseIgnore = 1
caseStrict = 2
)

type fieldOptions struct {
name string
quotedName string // quoted name per RFC 8785, section 3.2.2.2.
hasName bool
nameNeedEscape bool
casing int8 // either 0, nocase, or strictcase
casing int8 // either 0, caseIgnore, or caseStrict
inline bool
unknown bool
omitzero bool
Expand Down Expand Up @@ -490,10 +490,30 @@ func parseFieldOptions(sf reflect.StructField) (out fieldOptions, ignored bool,
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))
}
switch opt {
case "nocase":
out.casing |= nocase
case "strictcase":
out.casing |= strictcase
case "case":
if !strings.HasPrefix(tag, ":") {
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))
break
}
tag = tag[len(":"):]
opt, n, err2 := consumeTagOption(tag)
if err2 != nil {
err = cmp.Or(err, fmt.Errorf("Go struct field %s has malformed value for `case` tag option: %v", sf.Name, err2))
break
}
rawOpt := tag[:n]
tag = tag[n:]
if strings.HasPrefix(rawOpt, "'") {
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))
}
switch opt {
case "ignore":
out.casing |= caseIgnore
case "strict":
out.casing |= caseStrict
default:
err = cmp.Or(err, fmt.Errorf("Go struct field %s has unknown `case:%s` tag value", sf.Name, rawOpt))
}
case "inline":
out.inline = true
case "unknown":
Expand Down Expand Up @@ -523,7 +543,7 @@ func parseFieldOptions(sf reflect.StructField) (out fieldOptions, ignored bool,
// This catches invalid mutants such as "omitEmpty" or "omit_empty".
normOpt := strings.ReplaceAll(strings.ToLower(opt), "_", "")
switch normOpt {
case "nocase", "strictcase", "inline", "unknown", "omitzero", "omitempty", "string", "format":
case "case", "inline", "unknown", "omitzero", "omitempty", "string", "format":
err = cmp.Or(err, fmt.Errorf("Go struct field %s has invalid appearance of `%s` tag option; specify `%s` instead", sf.Name, opt, normOpt))
}

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

// Reject duplicates.
switch {
case out.casing == nocase|strictcase:
err = cmp.Or(err, fmt.Errorf("Go struct field %s cannot have both `nocase` and `strictcase` tag options", sf.Name))
case out.casing == caseIgnore|caseStrict:
err = cmp.Or(err, fmt.Errorf("Go struct field %s cannot have both `case:ignore` and `case:strict` tag options", sf.Name))
case seenOpts[opt]:
err = cmp.Or(err, fmt.Errorf("Go struct field %s has duplicate appearance of `%s` tag option", sf.Name, rawOpt))
}
Expand Down
59 changes: 40 additions & 19 deletions fields_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ func TestMakeStructFields(t *testing.T) {
F2 string `json:"-"`
F3 string `json:"json_name"`
f3 string
F5 string `json:"json_name_nocase,nocase"`
F5 string `json:"json_name_nocase,case:ignore"`
}{},
want: structFields{
flattened: []structField{
{id: 0, index: []int{0}, typ: stringType, fieldOptions: fieldOptions{name: "F1", quotedName: `"F1"`}},
{id: 1, index: []int{2}, typ: stringType, fieldOptions: fieldOptions{name: "json_name", quotedName: `"json_name"`, hasName: true}},
{id: 2, index: []int{4}, typ: stringType, fieldOptions: fieldOptions{name: "json_name_nocase", quotedName: `"json_name_nocase"`, hasName: true, casing: nocase}},
{id: 2, index: []int{4}, typ: stringType, fieldOptions: fieldOptions{name: "json_name_nocase", quotedName: `"json_name_nocase"`, hasName: true, casing: caseIgnore}},
},
},
}, {
Expand Down Expand Up @@ -615,24 +615,45 @@ func TestParseTagOptions(t *testing.T) {
wantOpts: fieldOptions{name: "V", quotedName: `"V"`, inline: true, unknown: true},
wantErr: errors.New("Go struct field V has malformed `json` tag: invalid character ',' at start of option (expecting Unicode letter or single quote)"),
}, {
name: jsontest.Name("NoCaseOption"),
name: jsontest.Name("CaseAloneOption"),
in: struct {
FieldName int `json:",nocase"`
FieldName int `json:",case"`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: nocase},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
wantErr: errors.New("Go struct field FieldName is missing value for `case` tag option; specify `case:ignore` or `case:strict` instead"),
}, {
name: jsontest.Name("CaseIgnoreOption"),
in: struct {
FieldName int `json:",case:ignore"`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseIgnore},
}, {
name: jsontest.Name("CaseStrictOption"),
in: struct {
FieldName int `json:",case:strict"`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseStrict},
}, {
name: jsontest.Name("CaseUnknownOption"),
in: struct {
FieldName int `json:",case:unknown"`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
wantErr: errors.New("Go struct field FieldName has unknown `case:unknown` tag value"),
}, {
name: jsontest.Name("StrictCaseOption"),
name: jsontest.Name("CaseQuotedOption"),
in: struct {
FieldName int `json:",strictcase"`
FieldName int `json:",case:'ignore'"`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: strictcase},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseIgnore},
wantErr: errors.New("Go struct field FieldName has unnecessarily quoted appearance of `case:'ignore'` tag option; specify `case:ignore` instead"),
}, {
name: jsontest.Name("BothCaseOptions"),
in: struct {
FieldName int `json:",nocase,strictcase"`
FieldName int `json:",case:ignore,case:strict"`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: nocase | strictcase},
wantErr: errors.New("Go struct field FieldName cannot have both `nocase` and `strictcase` tag options"),
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`, casing: caseIgnore | caseStrict},
wantErr: errors.New("Go struct field FieldName cannot have both `case:ignore` and `case:strict` tag options"),
}, {
name: jsontest.Name("InlineOption"),
in: struct {
Expand Down Expand Up @@ -699,12 +720,12 @@ func TestParseTagOptions(t *testing.T) {
}, {
name: jsontest.Name("AllOptions"),
in: struct {
FieldName int `json:",nocase,inline,unknown,omitzero,omitempty,string,format:format"`
FieldName int `json:",case:ignore,inline,unknown,omitzero,omitempty,string,format:format"`
}{},
wantOpts: fieldOptions{
name: "FieldName",
quotedName: `"FieldName"`,
casing: nocase,
casing: caseIgnore,
inline: true,
unknown: true,
omitzero: true,
Expand All @@ -715,31 +736,31 @@ func TestParseTagOptions(t *testing.T) {
}, {
name: jsontest.Name("AllOptionsQuoted"),
in: struct {
FieldName int `json:",'nocase','inline','unknown','omitzero','omitempty','string','format':'format'"`
FieldName int `json:",'case':'ignore','inline','unknown','omitzero','omitempty','string','format':'format'"`
}{},
wantOpts: fieldOptions{
name: "FieldName",
quotedName: `"FieldName"`,
casing: nocase,
casing: caseIgnore,
inline: true,
unknown: true,
omitzero: true,
omitempty: true,
string: true,
format: "format",
},
wantErr: errors.New("Go struct field FieldName has unnecessarily quoted appearance of `'nocase'` tag option; specify `nocase` instead"),
wantErr: errors.New("Go struct field FieldName has unnecessarily quoted appearance of `'case'` tag option; specify `case` instead"),
}, {
name: jsontest.Name("AllOptionsCaseSensitive"),
in: struct {
FieldName int `json:",NOCASE,INLINE,UNKNOWN,OMITZERO,OMITEMPTY,STRING,FORMAT:FORMAT"`
FieldName int `json:",CASE:IGNORE,INLINE,UNKNOWN,OMITZERO,OMITEMPTY,STRING,FORMAT:FORMAT"`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
wantErr: errors.New("Go struct field FieldName has invalid appearance of `NOCASE` tag option; specify `nocase` instead"),
wantErr: errors.New("Go struct field FieldName has invalid appearance of `CASE` tag option; specify `case` instead"),
}, {
name: jsontest.Name("AllOptionsSpaceSensitive"),
in: struct {
FieldName int `json:", nocase , inline , unknown , omitzero , omitempty , string , format:format "`
FieldName int `json:", case:ignore , inline , unknown , omitzero , omitempty , string , format:format "`
}{},
wantOpts: fieldOptions{name: "FieldName", quotedName: `"FieldName"`},
wantErr: errors.New("Go struct field FieldName has malformed `json` tag: invalid character ' ' at start of option (expecting Unicode letter or single quote)"),
Expand Down
2 changes: 1 addition & 1 deletion fold_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ func TestBenchmarkUnmarshalUnknown(t *testing.T) {
fields = append(fields, reflect.StructField{
Name: fmt.Sprintf("Name%d", i),
Type: T[int](),
Tag: `json:",nocase"`,
Tag: `json:",case:ignore"`,
})
}
out := reflect.New(reflect.StructOf(fields)).Interface()
Expand Down
2 changes: 1 addition & 1 deletion options.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ func OmitZeroStructFields(v bool) Options {

// MatchCaseInsensitiveNames specifies that JSON object members are matched
// against Go struct fields using a case-insensitive match of the name.
// Go struct fields explicitly marked with `strictcase` or `nocase`
// Go struct fields explicitly marked with `case:strict` or `case:ignore`
// always use case-sensitive (or case-insensitive) name matching,
// regardless of the value of this option.
//
Expand Down
8 changes: 4 additions & 4 deletions v1/diff_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ var jsonPackages = []struct {
//
// Case-insensitive matching is a surprising default and
// incurs significant performance cost when unmarshaling unknown fields.
// In v2, we can opt into v1-like behavior with the `nocase` tag option.
// In v2, we can opt into v1-like behavior with the `case:ignore` tag option.
// The case-insensitive matching performed by v2 is looser than that of v1
// where it also ignores dashes and underscores.
// This allows v2 to match fields regardless of whether the name is in
Expand All @@ -50,7 +50,7 @@ func TestCaseSensitivity(t *testing.T) {
type Fields struct {
FieldA bool
FieldB bool `json:"fooBar"`
FieldC bool `json:"fizzBuzz,nocase"` // `nocase` is used by v2 to explicitly enable case-insensitive matching
FieldC bool `json:"fizzBuzz,case:ignore"` // `case:ignore` is used by v2 to explicitly enable case-insensitive matching
}

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

0 comments on commit 1ae217a

Please sign in to comment.