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
78 changes: 76 additions & 2 deletions binding.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,17 +387,91 @@ func convertValue(rawValue any, targetType reflect.Type) (any, error) {
return val, nil

case reflect.Slice:
// Handle []string
// Preserve the existing []string convenience behavior (comma-separated strings).
if targetType.Elem().Kind() == reflect.String {
return parseStringSlice(rawValue)
}
return nil, fmt.Errorf("unsupported slice type: %s", targetType)

rawValueRef := reflect.ValueOf(rawValue)
if rawValueRef.Kind() != reflect.Slice && rawValueRef.Kind() != reflect.Array {
return nil, fmt.Errorf("cannot convert %T to %s", rawValue, targetType)
}

result := reflect.MakeSlice(targetType, rawValueRef.Len(), rawValueRef.Len())
for i := 0; i < rawValueRef.Len(); i++ {
elem, err := convertCollectionElement(rawValueRef.Index(i).Interface(), targetType.Elem())
if err != nil {
return nil, fmt.Errorf("cannot convert slice element %d: %w", i, err)
}
result.Index(i).Set(reflect.ValueOf(elem))
}
return result.Interface(), nil

case reflect.Map:
if targetType.Key().Kind() != reflect.String {
return nil, fmt.Errorf("unsupported map key type: %s", targetType.Key())
}

rawValueRef := reflect.ValueOf(rawValue)
if rawValueRef.Kind() != reflect.Map {
return nil, fmt.Errorf("cannot convert %T to %s", rawValue, targetType)
}

result := reflect.MakeMapWithSize(targetType, rawValueRef.Len())
for _, rawKey := range rawValueRef.MapKeys() {
if rawKey.Kind() != reflect.String {
return nil, fmt.Errorf("cannot convert map key type %s to string", rawKey.Type())
}

elem, err := convertCollectionElement(rawValueRef.MapIndex(rawKey).Interface(), targetType.Elem())
if err != nil {
return nil, fmt.Errorf("cannot convert map value for key %q: %w", rawKey.String(), err)
}
result.SetMapIndex(reflect.ValueOf(rawKey.String()).Convert(targetType.Key()), reflect.ValueOf(elem))
}
return result.Interface(), nil

default:
return nil, fmt.Errorf("unsupported target type: %s", targetType)
}
}

func convertCollectionElement(rawValue any, targetType reflect.Type) (any, error) {
// Collection elements/values that are structs need real binding, not "return raw map as-is".
if rawValue != nil &&
targetType.Kind() == reflect.Struct &&
targetType != timeType &&
targetType != durationType &&
!isOptionalType(targetType) {
return convertStructValue(rawValue, targetType)
}

return convertValue(rawValue, targetType)
}

func convertStructValue(rawValue any, targetType reflect.Type) (any, error) {
rawValueRef := reflect.ValueOf(rawValue)
if !rawValueRef.IsValid() || rawValueRef.Kind() != reflect.Map {
return convertValue(rawValue, targetType)
}

nestedData := make(map[string]mergedEntry, rawValueRef.Len())
for _, rawKey := range rawValueRef.MapKeys() {
if rawKey.Kind() != reflect.String {
return nil, fmt.Errorf("cannot bind nested struct %s with non-string key type %s", targetType, rawKey.Type())
}
nestedData[strings.ToLower(rawKey.String())] = mergedEntry{value: rawValueRef.MapIndex(rawKey).Interface()}
}

nestedTarget := reflect.New(targetType).Elem()
fieldErrors := bindStruct(nestedTarget, nestedData, nil, "", "")
if len(fieldErrors) > 0 {
return nil, fmt.Errorf("cannot bind nested struct %s at %s: %s", targetType, fieldErrors[0].FieldPath, fieldErrors[0].Message)
}

return nestedTarget.Interface(), nil
}

// parseBool parses a boolean value from a string.
// Accepts: "true", "false", "1", "0", "yes", "no" (case-insensitive)
func parseBool(s string) (bool, error) {
Expand Down
87 changes: 87 additions & 0 deletions loader_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,93 @@ func TestLoad_NestedStruct(t *testing.T) {
}
}

func TestLoad_NestedCollections(t *testing.T) {
type ClickHouseConfig struct {
Host string
Port int
}

type Config struct {
ClickhouseList []ClickHouseConfig
ClickhouseMap map[string]ClickHouseConfig
}

t.Run("binds slice and direct map values", func(t *testing.T) {
source := &mockSource{
data: map[string]any{
"clickhouse_list": []any{
map[string]any{"host": "ch1", "port": 9000},
map[string]any{"host": "ch2", "port": 9001},
},
"clickhouse_map": map[string]any{
"primary": map[string]any{"host": "ch1", "port": 9000},
"replica": map[string]any{"host": "ch2", "port": 9001},
"analytics": map[string]any{"host": "ch3", "port": 9002},
},
},
}

cfg, err := NewLoader[Config]().WithSource(source).Load(context.Background())
if err != nil {
t.Fatalf("Load failed: %v", err)
}

wantList := []ClickHouseConfig{
{Host: "ch1", Port: 9000},
{Host: "ch2", Port: 9001},
}
if !reflect.DeepEqual(cfg.ClickhouseList, wantList) {
t.Errorf("ClickhouseList = %#v, want %#v", cfg.ClickhouseList, wantList)
}

wantMap := map[string]ClickHouseConfig{
"primary": {Host: "ch1", Port: 9000},
"replica": {Host: "ch2", Port: 9001},
"analytics": {Host: "ch3", Port: 9002},
}
if !reflect.DeepEqual(cfg.ClickhouseMap, wantMap) {
t.Errorf("ClickhouseMap = %#v, want %#v", cfg.ClickhouseMap, wantMap)
}
})

t.Run("invalid nested collection type reports invalid_type", func(t *testing.T) {
source := &mockSource{
data: map[string]any{
"clickhouse_list": []any{
map[string]any{"host": "ch1", "port": "not-an-int"},
},
},
}

cfg, err := NewLoader[Config]().WithSource(source).Load(context.Background())
if err == nil {
t.Fatal("expected invalid_type error")
}
if cfg != nil {
t.Fatal("expected nil cfg on invalid_type error")
}

valErr, ok := err.(*ValidationError)
if !ok {
t.Fatalf("expected ValidationError, got %T", err)
}
if len(valErr.FieldErrors) == 0 {
t.Fatal("expected at least one field error")
}

fieldErr := valErr.FieldErrors[0]
if fieldErr.Code != ErrCodeInvalidType {
t.Fatalf("expected code %q, got %q", ErrCodeInvalidType, fieldErr.Code)
}
if fieldErr.FieldPath != "ClickhouseList" {
t.Fatalf("expected FieldPath %q, got %q", "ClickhouseList", fieldErr.FieldPath)
}
if !strings.Contains(fieldErr.Message, "slice element 0") {
t.Fatalf("expected index context in error message, got %q", fieldErr.Message)
}
})
}

// TestLoad_SourceError verifies that source load errors are propagated.
func TestLoad_SourceError(t *testing.T) {
type Config struct {
Expand Down