Skip to content

Combine nocase and strictcase tag options under a case option #153

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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":
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can refactor this in the future to share more code with the format:value logic below, but good enough for now.

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
Loading