Skip to content
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
64 changes: 54 additions & 10 deletions mapstructure.go
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,9 @@ type DecoderConfig struct {
Result any

// The tag name that mapstructure reads for field names. This
// defaults to "mapstructure"
// defaults to "mapstructure". Multiple tag names can be specified
// as a comma-separated list (e.g., "yaml,json"), and the first
// matching non-empty tag will be used.
TagName string

// The option of the value in the tag that indicates a field should
Expand Down Expand Up @@ -699,7 +701,7 @@ func (d *Decoder) decodeString(name string, data any, val reflect.Value) error {
case reflect.Uint8:
var uints []uint8
if dataKind == reflect.Array {
uints = make([]uint8, dataVal.Len(), dataVal.Len())
uints = make([]uint8, dataVal.Len())
for i := range uints {
uints[i] = dataVal.Index(i).Interface().(uint8)
}
Expand Down Expand Up @@ -1091,7 +1093,7 @@ func (d *Decoder) decodeMapFromStruct(name string, dataVal reflect.Value, val re
)
}

tagValue := f.Tag.Get(d.config.TagName)
tagValue, _ := getTagValue(f, d.config.TagName)
keyName := d.config.MapFieldName(f.Name)

if tagValue == "" && d.config.IgnoreUntaggedFields {
Expand All @@ -1112,12 +1114,12 @@ func (d *Decoder) decodeMapFromStruct(name string, dataVal reflect.Value, val re
continue
}
// If "omitempty" is specified in the tag, it ignores empty values.
if strings.Index(tagValue[index+1:], "omitempty") != -1 && isEmptyValue(v) {
if strings.Contains(tagValue[index+1:], "omitempty") && isEmptyValue(v) {
continue
}

// If "omitzero" is specified in the tag, it ignores zero values.
if strings.Index(tagValue[index+1:], "omitzero") != -1 && v.IsZero() {
if strings.Contains(tagValue[index+1:], "omitzero") && v.IsZero() {
continue
}

Expand All @@ -1137,7 +1139,7 @@ func (d *Decoder) decodeMapFromStruct(name string, dataVal reflect.Value, val re
)
}
} else {
if strings.Index(tagValue[index+1:], "remain") != -1 {
if strings.Contains(tagValue[index+1:], "remain") {
if v.Kind() != reflect.Map {
return newDecodeError(
name+"."+f.Name,
Expand All @@ -1153,7 +1155,7 @@ func (d *Decoder) decodeMapFromStruct(name string, dataVal reflect.Value, val re
}
}

deep = deep || strings.Index(tagValue[index+1:], "deep") != -1
deep = deep || strings.Contains(tagValue[index+1:], "deep")

if keyNameTagValue := tagValue[:index]; keyNameTagValue != "" {
keyName = keyNameTagValue
Expand Down Expand Up @@ -1543,7 +1545,10 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
remain := false

// We always parse the tags cause we're looking for other tags too
tagParts := strings.Split(fieldType.Tag.Get(d.config.TagName), ",")
tagParts := getTagParts(fieldType, d.config.TagName)
if len(tagParts) == 0 {
tagParts = []string{""}
}
for _, tag := range tagParts[1:] {
if tag == d.config.SquashTagOption {
squash = true
Expand Down Expand Up @@ -1600,7 +1605,7 @@ func (d *Decoder) decodeStructFromMap(name string, dataVal, val reflect.Value) e
field, fieldValue := f.field, f.val
fieldName := field.Name

tagValue := field.Tag.Get(d.config.TagName)
tagValue, _ := getTagValue(field, d.config.TagName)
if tagValue == "" && d.config.IgnoreUntaggedFields {
continue
}
Expand Down Expand Up @@ -1784,7 +1789,7 @@ func isStructTypeConvertibleToMap(typ reflect.Type, checkMapstructureTags bool,
if f.PkgPath == "" && !checkMapstructureTags { // check for unexported fields
return true
}
if checkMapstructureTags && f.Tag.Get(tagName) != "" { // check for mapstructure tags inside
if checkMapstructureTags && hasAnyTag(f, tagName) { // check for mapstructure tags inside
return true
}
}
Expand Down Expand Up @@ -1812,3 +1817,42 @@ func dereferencePtrToStructIfNeeded(v reflect.Value, tagName string) reflect.Val
return v
}
}

func hasAnyTag(field reflect.StructField, tagName string) bool {
_, ok := getTagValue(field, tagName)
return ok
}

func getTagParts(field reflect.StructField, tagName string) []string {
tagValue, ok := getTagValue(field, tagName)
if !ok {
return nil
}
return strings.Split(tagValue, ",")
}

func getTagValue(field reflect.StructField, tagName string) (string, bool) {
for _, name := range splitTagNames(tagName) {
if tag := field.Tag.Get(name); tag != "" {
return tag, true
}
}
return "", false
}

func splitTagNames(tagName string) []string {
if tagName == "" {
return []string{"mapstructure"}
}
parts := strings.Split(tagName, ",")
result := make([]string, 0, len(parts))

for _, name := range parts {
name = strings.TrimSpace(name)
if name != "" {
result = append(result, name)
}
}

return result
}
225 changes: 225 additions & 0 deletions mapstructure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3235,6 +3235,231 @@ func TestDecoder_IgnoreUntaggedFieldsWithStruct(t *testing.T) {
}
}

func TestDecoder_MultiTagInline(t *testing.T) {
type Inner struct {
A int `yaml:"a"`
}

type Wrap struct {
Inner `yaml:",inline"`
}

input := map[string]any{"a": 1}
var result Wrap

dec, err := NewDecoder(&DecoderConfig{
TagName: "config,yaml",
SquashTagOption: "inline",
WeaklyTypedInput: true,
Result: &result,
})
if err != nil {
t.Fatalf("NewDecoder error: %v", err)
}

if err := dec.Decode(input); err != nil {
t.Fatalf("Decode error: %v", err)
}

if result.Inner.A != 1 {
t.Fatalf("expected inline field A=1, got %d", result.Inner.A)
}
}

func TestDecoder_MultiTagRemain(t *testing.T) {
type Wrap struct {
Known string `yaml:"known"`
Extra map[string]any `yaml:",remain"`
}

input := map[string]any{
"known": "ok",
"extra1": "v1",
"extra2": 2,
}
var result Wrap

dec, err := NewDecoder(&DecoderConfig{
TagName: "config,yaml",
WeaklyTypedInput: true,
Result: &result,
})
if err != nil {
t.Fatalf("NewDecoder error: %v", err)
}

if err := dec.Decode(input); err != nil {
t.Fatalf("Decode error: %v", err)
}

if result.Known != "ok" {
t.Fatalf("expected Known=ok, got %q", result.Known)
}
if result.Extra == nil || len(result.Extra) != 2 {
t.Fatalf("expected Extra to contain 2 items, got %v", result.Extra)
}
if result.Extra["extra1"] != "v1" {
t.Fatalf("expected extra1=v1, got %v", result.Extra["extra1"])
}
}

func TestDecoder_MultiTagBasic(t *testing.T) {
type Person struct {
Name string `yaml:"name"`
Age int `json:"age"`
Email string `config:"email_address"`
}

input := map[string]any{
"name": "Alice",
"age": 30,
"email_address": "alice@example.com",
}
var result Person

dec, err := NewDecoder(&DecoderConfig{
TagName: "yaml,json,config",
Result: &result,
})
if err != nil {
t.Fatalf("NewDecoder error: %v", err)
}

if err := dec.Decode(input); err != nil {
t.Fatalf("Decode error: %v", err)
}

if result.Name != "Alice" {
t.Fatalf("expected Name=Alice, got %q", result.Name)
}
if result.Age != 30 {
t.Fatalf("expected Age=30, got %d", result.Age)
}
if result.Email != "alice@example.com" {
t.Fatalf("expected Email=alice@example.com, got %q", result.Email)
}
}

func TestDecoder_MultiTagPriority(t *testing.T) {
// When both tags exist, the first tag name in the list takes precedence
type Item struct {
Value string `yaml:"yaml_value" json:"json_value"`
}

input := map[string]any{
"yaml_value": "from_yaml",
"json_value": "from_json",
}

// Test yaml,json order - should use yaml tag
var result1 Item
dec1, err := NewDecoder(&DecoderConfig{
TagName: "yaml,json",
Result: &result1,
})
if err != nil {
t.Fatalf("NewDecoder error: %v", err)
}
if err := dec1.Decode(input); err != nil {
t.Fatalf("Decode error: %v", err)
}
if result1.Value != "from_yaml" {
t.Fatalf("with yaml,json expected Value=from_yaml, got %q", result1.Value)
}

// Test json,yaml order - should use json tag
var result2 Item
dec2, err := NewDecoder(&DecoderConfig{
TagName: "json,yaml",
Result: &result2,
})
if err != nil {
t.Fatalf("NewDecoder error: %v", err)
}
if err := dec2.Decode(input); err != nil {
t.Fatalf("Decode error: %v", err)
}
if result2.Value != "from_json" {
t.Fatalf("with json,yaml expected Value=from_json, got %q", result2.Value)
}
}

func TestDecoder_MultiTagWhitespace(t *testing.T) {
type Person struct {
Name string `yaml:"name"`
Age int `json:"age"`
}

input := map[string]any{
"name": "Bob",
"age": 25,
}
var result Person

// Test with whitespace around tag names
dec, err := NewDecoder(&DecoderConfig{
TagName: " yaml , json ",
Result: &result,
})
if err != nil {
t.Fatalf("NewDecoder error: %v", err)
}

if err := dec.Decode(input); err != nil {
t.Fatalf("Decode error: %v", err)
}

if result.Name != "Bob" {
t.Fatalf("expected Name=Bob, got %q", result.Name)
}
if result.Age != 25 {
t.Fatalf("expected Age=25, got %d", result.Age)
}
}

func TestDecoder_MultiTagEmptyNames(t *testing.T) {
type Person struct {
Name string `mapstructure:"name"`
}

input := map[string]any{
"name": "Charlie",
}

tests := []struct {
name string
tagName string
}{
{"leading comma", ",yaml"},
{"trailing comma", "yaml,"},
{"multiple commas", ",,yaml,,"},
{"only commas", ",,,"},
{"empty with spaces", " , , "},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
var result Person
dec, err := NewDecoder(&DecoderConfig{
TagName: tc.tagName,
Result: &result,
})
if err != nil {
t.Fatalf("NewDecoder error: %v", err)
}

if err := dec.Decode(input); err != nil {
t.Fatalf("Decode error: %v", err)
}

// With invalid/empty tag names, should fall back to mapstructure
if result.Name != "Charlie" {
t.Fatalf("expected Name=Charlie (fallback to mapstructure), got %q", result.Name)
}
})
}
}

func TestDecoder_DecodeNilOption(t *testing.T) {
t.Parallel()

Expand Down
Loading