From bf2a330c6b81e86d8cffb559fe8e3d34b1978341 Mon Sep 17 00:00:00 2001 From: godeh Date: Wed, 25 Mar 2026 00:42:19 -0400 Subject: [PATCH] Support pointer slices in struct loading --- README.md | 7 +++ examples/pointer_slices/main.go | 75 +++++++++++++++++++++++++++++++ loader.go | 49 +++++++++++--------- loader_test.go | 80 +++++++++++++++++++++++++++++++++ 4 files changed, 189 insertions(+), 22 deletions(-) create mode 100644 examples/pointer_slices/main.go diff --git a/README.md b/README.md index d746089..8db97e4 100644 --- a/README.md +++ b/README.md @@ -172,6 +172,10 @@ colorB := dotenvgo.WithLoader[Color](loaderB, "THEME").Get() // Red | `bool` | `true`, `false`, `1`, `0`, `yes`, `no`, `on`, `off` | | `time.Duration` | `30s`, `1h30m`, `500ms` | | `*time.Location` | `America/New_York`, `UTC` | +| `*string`, `*int`, `*uint`, `*float64`, `*bool`, `*time.Duration` | same values as their non-pointer equivalents | +| `*[]string`, `*[]int`, `*[]uint`, `*[]float64`, `*[]bool` | comma-separated values, or custom separators with `sep` | +| `[]*string`, `[]*int`, `[]*uint`, `[]*float64`, `[]*bool` | comma-separated values, each item loaded as a pointer | +| `*Struct` | nested config loaded from prefixed child fields such as `DB_URL` | | `[]string` | `a,b,c` | | `[]int`, `[]int8-64` | `1,2,3` | | `[]uint`, `[]uint8-64` | `1,2,3` | @@ -188,6 +192,8 @@ colorB := dotenvgo.WithLoader[Color](loaderB, "THEME").Get() // Red | `required` | Fail if missing | `required:"true"` | | `sep` | Slice separator | `sep:";"` | +Pointer fields are supported during struct loading. For scalar pointer fields such as `*string` or `*int`, the loader allocates the pointer when a value or default exists. Pointer-to-slice fields such as `*[]string` work the same way. Slice-of-pointer fields such as `[]*string` and `[]*int` are also supported for leaf types. For nested pointer structs such as `*Database`, the loader allocates the struct when at least one nested field is loaded, for example from `DB_URL` or nested defaults. + ## `.env` File Format ```bash @@ -239,6 +245,7 @@ See [examples/](./examples) for complete working code: | [struct](./examples/struct) | Struct-based config | | [nested_prefix](./examples/nested_prefix) | Nested structs with env tag prefixes | | [empty_values](./examples/empty_values) | Missing vs empty value semantics | +| [pointer_slices](./examples/pointer_slices) | Pointer to slice and slice of pointers | | [file](./examples/file) | Loading `.env` files | | [expansion](./examples/expansion) | Variable expansion | | [isolated_loader](./examples/isolated_loader) | Isolated loader demo | diff --git a/examples/pointer_slices/main.go b/examples/pointer_slices/main.go new file mode 100644 index 0000000..1cbb0a0 --- /dev/null +++ b/examples/pointer_slices/main.go @@ -0,0 +1,75 @@ +// Example: Pointer to slice and slice of pointers +package main + +import ( + "fmt" + "os" + + "github.com/godeh/dotenvgo" +) + +type Config struct { + Hosts *[]string `env:"HOSTS"` + DefaultIDs *[]int `env:"DEFAULT_IDS" default:"10,20,30"` + Workers []*string `env:"WORKERS"` + Ports []*int `env:"PORTS" sep:";"` +} + +func main() { + os.Setenv("HOSTS", "api,worker") + os.Setenv("WORKERS", "alpha,beta") + os.Setenv("PORTS", "8080; 9090;10010;57770") + + var cfg Config + if err := dotenvgo.Load(&cfg); err != nil { + fmt.Printf("Failed to load config: %v\n", err) + os.Exit(1) + } + + fmt.Println("=== Pointer Slice Configuration ===") + hosts := derefStringSlice(cfg.Hosts) + defaultIDs := derefIntSlice(cfg.DefaultIDs) + workers := derefStringPointers(cfg.Workers) + ports := derefIntPointers(cfg.Ports) + + fmt.Printf("Hosts: %v - %v\n", len(hosts), hosts) + fmt.Printf("DefaultIDs: %v - %v\n", len(defaultIDs), defaultIDs) + fmt.Printf("Workers: %v - %v\n", len(workers), workers) + fmt.Printf("Ports: %v - %v\n", len(ports), ports) +} + +func derefStringSlice(values *[]string) []string { + if values == nil { + return nil + } + return *values +} + +func derefIntSlice(values *[]int) []int { + if values == nil { + return nil + } + return *values +} + +func derefStringPointers(values []*string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if value == nil { + continue + } + result = append(result, *value) + } + return result +} + +func derefIntPointers(values []*int) []int { + result := make([]int, 0, len(values)) + for _, value := range values { + if value == nil { + continue + } + result = append(result, *value) + } + return result +} diff --git a/loader.go b/loader.go index 77883d2..1fd5736 100644 --- a/loader.go +++ b/loader.go @@ -235,29 +235,13 @@ func (l *Loader) setField(field reflect.Value, tag reflect.StructTag, value stri // 0. Handle custom separator for slices if field.Kind() == reflect.Slice { sep := tag.Get("sep") - if sep != "" { - parts := strings.Split(value, sep) - slice := reflect.MakeSlice(field.Type(), 0, len(parts)) - elemType := field.Type().Elem() - - // Find parser for element type - parser, ok := l.getParser(elemType) - if !ok { - // Fallback to TextUnmarshaler for element? - // For now error if no parser for element - return fmt.Errorf("dotenvgo: no parser registered for slice element type %v", elemType) + if sep != "" || field.Type().Elem().Kind() == reflect.Pointer { + if sep == "" { + sep = "," } - - for _, p := range parts { - p = strings.TrimSpace(p) - if p == "" { - continue - } - parsed, err := parser(p) - if err != nil { - return err - } - slice = reflect.Append(slice, reflect.ValueOf(parsed)) + slice, err := l.parseSlice(field.Type(), tag, value, sep) + if err != nil { + return err } field.Set(slice) return nil @@ -287,3 +271,24 @@ func (l *Loader) setField(field reflect.Value, tag reflect.StructTag, value stri return fmt.Errorf("dotenvgo: no parser registered for type %v", field.Type()) } + +func (l *Loader) parseSlice(sliceType reflect.Type, tag reflect.StructTag, value, sep string) (reflect.Value, error) { + parts := strings.Split(value, sep) + slice := reflect.MakeSlice(sliceType, 0, len(parts)) + elemType := sliceType.Elem() + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + elem := reflect.New(elemType).Elem() + if err := l.setField(elem, tag, part); err != nil { + return reflect.Value{}, fmt.Errorf("dotenvgo: no parser registered for slice element type %v: %w", elemType, err) + } + slice = reflect.Append(slice, elem) + } + + return slice, nil +} diff --git a/loader_test.go b/loader_test.go index 0ab1d5e..6ccd1f9 100644 --- a/loader_test.go +++ b/loader_test.go @@ -195,6 +195,86 @@ func TestPointerLeafTypes(t *testing.T) { } } +func TestPointerSlices(t *testing.T) { + t.Run("Pointer To Slice", func(t *testing.T) { + type PointerSliceConfig struct { + Hosts *[]string `env:"HOSTS"` + Ports *[]int `env:"PORTS" default:"8080,9090"` + } + + setEnv(t, "HOSTS", "api,worker") + + var cfg PointerSliceConfig + if err := Load(&cfg); err != nil { + t.Fatalf("Load failed: %v", err) + } + + if cfg.Hosts == nil { + t.Fatal("Expected Hosts to be set") + } + if len(*cfg.Hosts) != 2 || (*cfg.Hosts)[0] != "api" || (*cfg.Hosts)[1] != "worker" { + t.Errorf("Expected Hosts [api worker], got %v", *cfg.Hosts) + } + if cfg.Ports == nil { + t.Fatal("Expected Ports default to be set") + } + if len(*cfg.Ports) != 2 || (*cfg.Ports)[0] != 8080 || (*cfg.Ports)[1] != 9090 { + t.Errorf("Expected Ports [8080 9090], got %v", *cfg.Ports) + } + }) + + t.Run("Slice Of Pointers", func(t *testing.T) { + type SlicePointerConfig struct { + Hosts []*string `env:"HOSTS"` + IDs []*int `env:"IDS" sep:";"` + } + + setEnv(t, "HOSTS", "api,worker") + setEnv(t, "IDS", "1; 2;3") + + var cfg SlicePointerConfig + if err := Load(&cfg); err != nil { + t.Fatalf("Load failed: %v", err) + } + + if len(cfg.Hosts) != 2 { + t.Fatalf("Expected 2 Hosts, got %d", len(cfg.Hosts)) + } + if cfg.Hosts[0] == nil || cfg.Hosts[1] == nil { + t.Fatal("Expected all Host pointers to be non-nil") + } + if *cfg.Hosts[0] != "api" || *cfg.Hosts[1] != "worker" { + t.Errorf("Expected Hosts [api worker], got [%q %q]", *cfg.Hosts[0], *cfg.Hosts[1]) + } + + if len(cfg.IDs) != 3 { + t.Fatalf("Expected 3 IDs, got %d", len(cfg.IDs)) + } + for i, expected := range []int{1, 2, 3} { + if cfg.IDs[i] == nil { + t.Fatalf("Expected ID pointer at index %d to be non-nil", i) + } + if *cfg.IDs[i] != expected { + t.Errorf("Expected ID %d at index %d, got %d", expected, i, *cfg.IDs[i]) + } + } + }) + + t.Run("Pointer To Slice Remains Nil When Missing", func(t *testing.T) { + type PointerSliceConfig struct { + Hosts *[]string `env:"HOSTS"` + } + + var cfg PointerSliceConfig + if err := Load(&cfg); err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.Hosts != nil { + t.Errorf("Expected Hosts to remain nil, got %v", *cfg.Hosts) + } + }) +} + func TestLoadWithPrefix(t *testing.T) { setEnv(t, "APP_REQUIRED_VAR", "req") setEnv(t, "APP_HOST", "app-host")