Skip to content

Commit 6471aa6

Browse files
Merge pull request #149 from thespags/main
feature: Add map field name to convert structs dynamically instead of individually with a tag.
2 parents 87d541b + dbffaaa commit 6471aa6

File tree

2 files changed

+202
-1
lines changed

2 files changed

+202
-1
lines changed

mapstructure.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -311,11 +311,29 @@ type DecoderConfig struct {
311311
// MatchName is the function used to match the map key to the struct
312312
// field name or tag. Defaults to `strings.EqualFold`. This can be used
313313
// to implement case-sensitive tag values, support snake casing, etc.
314+
//
315+
// MatchName is used as a fallback comparison when the direct key lookup fails.
316+
// See also MapFieldName for transforming field names before lookup.
314317
MatchName func(mapKey, fieldName string) bool
315318

316319
// DecodeNil, if set to true, will cause the DecodeHook (if present) to run
317320
// even if the input is nil. This can be used to provide default values.
318321
DecodeNil bool
322+
323+
// MapFieldName is the function used to convert the struct field name to the map's key name.
324+
//
325+
// This is useful for automatically converting between naming conventions without
326+
// explicitly tagging each field. For example, to convert Go's PascalCase field names
327+
// to snake_case map keys:
328+
//
329+
// MapFieldName: func(s string) string {
330+
// return strcase.ToSnake(s)
331+
// }
332+
//
333+
// When decoding from a map to a struct, the transformed field name is used for
334+
// the initial lookup. If not found, MatchName is used as a fallback comparison.
335+
// Explicit struct tags always take precedence over MapFieldName.
336+
MapFieldName func(string) string
319337
}
320338

321339
// A Decoder takes a raw interface value and turns it into structured
@@ -452,6 +470,12 @@ func NewDecoder(config *DecoderConfig) (*Decoder, error) {
452470
config.MatchName = strings.EqualFold
453471
}
454472

473+
if config.MapFieldName == nil {
474+
config.MapFieldName = func(s string) string {
475+
return s
476+
}
477+
}
478+
455479
result := &Decoder{
456480
config: config,
457481
}
@@ -1068,7 +1092,7 @@ func (d *Decoder) decodeMapFromStruct(name string, dataVal reflect.Value, val re
10681092
}
10691093

10701094
tagValue := f.Tag.Get(d.config.TagName)
1071-
keyName := f.Name
1095+
keyName := d.config.MapFieldName(f.Name)
10721096

10731097
if tagValue == "" && d.config.IgnoreUntaggedFields {
10741098
continue
@@ -1583,6 +1607,8 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
15831607
tagValue = strings.SplitN(tagValue, ",", 2)[0]
15841608
if tagValue != "" {
15851609
fieldName = tagValue
1610+
} else {
1611+
fieldName = d.config.MapFieldName(fieldName)
15861612
}
15871613

15881614
rawMapKey := reflect.ValueOf(fieldName)

mapstructure_test.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3681,3 +3681,178 @@ func uintPtr(v uint) *uint { return &v }
36813681
func boolPtr(v bool) *bool { return &v }
36823682
func floatPtr(v float64) *float64 { return &v }
36833683
func interfacePtr(v any) *any { return &v }
3684+
3685+
type TestMapFieldName struct {
3686+
HostName string
3687+
Username string
3688+
}
3689+
3690+
func TestDecoder_MapFieldNameMapFromStruct(t *testing.T) {
3691+
t.Parallel()
3692+
3693+
var structKeys map[string]any
3694+
3695+
decoder, err := NewDecoder(&DecoderConfig{
3696+
ErrorUnused: true, // Enable error on unused keys
3697+
Result: &structKeys,
3698+
MapFieldName: func(s string) string {
3699+
if s == "HostName" {
3700+
return "host_name"
3701+
}
3702+
return s
3703+
},
3704+
})
3705+
if err != nil {
3706+
t.Fatalf("err: %s", err)
3707+
}
3708+
3709+
var input TestMapFieldName
3710+
3711+
err = decoder.Decode(&input)
3712+
if err != nil {
3713+
t.Fatalf("err: %s", err)
3714+
}
3715+
3716+
_, ok := structKeys["host_name"]
3717+
if !ok {
3718+
t.Fatal("expected host_name to exist")
3719+
}
3720+
3721+
_, ok = structKeys["Username"]
3722+
if !ok {
3723+
t.Fatal("expected Username to exist")
3724+
}
3725+
}
3726+
3727+
func TestDecoder_MapFieldNameStructFromMap(t *testing.T) {
3728+
t.Parallel()
3729+
3730+
foo := TestMapFieldName{}
3731+
3732+
decoder, err := NewDecoder(&DecoderConfig{
3733+
Result: &foo,
3734+
MapFieldName: func(s string) string {
3735+
if s == "HostName" {
3736+
return "host_name"
3737+
}
3738+
if s == "Username" {
3739+
return "user_name"
3740+
}
3741+
return s
3742+
},
3743+
})
3744+
if err != nil {
3745+
t.Fatalf("err: %s", err)
3746+
}
3747+
3748+
structKeys := map[string]any{
3749+
"host_name": "foo",
3750+
"user_name": "bar",
3751+
}
3752+
3753+
err = decoder.Decode(&structKeys)
3754+
if err != nil {
3755+
t.Fatalf("err: %s", err)
3756+
}
3757+
3758+
if foo.HostName != "foo" {
3759+
t.Fatal("expected HostName to be foo")
3760+
}
3761+
3762+
if foo.Username != "bar" {
3763+
t.Fatal("expected Username to be bar")
3764+
}
3765+
}
3766+
3767+
func TestDecoder_MapFieldNameWithMatchName(t *testing.T) {
3768+
t.Parallel()
3769+
3770+
type Target struct {
3771+
HostName string
3772+
Username string
3773+
}
3774+
3775+
input := map[string]any{
3776+
"HOST_NAME": "server1",
3777+
"user_name": "admin",
3778+
}
3779+
3780+
var result Target
3781+
decoder, err := NewDecoder(&DecoderConfig{
3782+
Result: &result,
3783+
MapFieldName: func(s string) string {
3784+
// Convert HostName -> host_name, Username -> user_name
3785+
if s == "HostName" {
3786+
return "host_name"
3787+
}
3788+
if s == "Username" {
3789+
return "user_name"
3790+
}
3791+
return s
3792+
},
3793+
MatchName: strings.EqualFold, // Case-insensitive fallback
3794+
})
3795+
if err != nil {
3796+
t.Fatalf("err: %s", err)
3797+
}
3798+
3799+
err = decoder.Decode(input)
3800+
if err != nil {
3801+
t.Fatalf("err: %s", err)
3802+
}
3803+
3804+
// HOST_NAME should match host_name via case-insensitive MatchName
3805+
if result.HostName != "server1" {
3806+
t.Fatalf("expected HostName to be 'server1', got '%s'", result.HostName)
3807+
}
3808+
3809+
// user_name matches exactly
3810+
if result.Username != "admin" {
3811+
t.Fatalf("expected Username to be 'admin', got '%s'", result.Username)
3812+
}
3813+
}
3814+
3815+
func TestDecoder_MapFieldNameWithIgnoreUntaggedFields(t *testing.T) {
3816+
t.Parallel()
3817+
3818+
type Target struct {
3819+
TaggedField string `mapstructure:"tagged_field"`
3820+
UntaggedField string
3821+
}
3822+
3823+
input := map[string]any{
3824+
"tagged_field": "tagged_value",
3825+
"untagged_field": "untagged_value",
3826+
}
3827+
3828+
var result Target
3829+
decoder, err := NewDecoder(&DecoderConfig{
3830+
Result: &result,
3831+
IgnoreUntaggedFields: true,
3832+
MapFieldName: func(s string) string {
3833+
// This would convert UntaggedField -> untagged_field,
3834+
// but IgnoreUntaggedFields takes precedence
3835+
if s == "UntaggedField" {
3836+
return "untagged_field"
3837+
}
3838+
return s
3839+
},
3840+
})
3841+
if err != nil {
3842+
t.Fatalf("err: %s", err)
3843+
}
3844+
3845+
err = decoder.Decode(input)
3846+
if err != nil {
3847+
t.Fatalf("err: %s", err)
3848+
}
3849+
3850+
if result.TaggedField != "tagged_value" {
3851+
t.Fatalf("expected TaggedField to be 'tagged_value', got '%s'", result.TaggedField)
3852+
}
3853+
3854+
// UntaggedField should remain empty because IgnoreUntaggedFields is true
3855+
if result.UntaggedField != "" {
3856+
t.Fatalf("expected UntaggedField to be empty due to IgnoreUntaggedFields, got '%s'", result.UntaggedField)
3857+
}
3858+
}

0 commit comments

Comments
 (0)