Skip to content

Commit

Permalink
feat: mapping slice of complex struct (#312)
Browse files Browse the repository at this point in the history
* feat: mapping slice of complex struct #298

* feat: support predefined values and pre initialized structs

* refactor: some improvement

* refactor: some improvement

* test: support normal features for nested fields

* test: trying to fix `gofumpt` lint issues

* chore: add sample for complex struct in readme

---------

Co-authored-by: Hamid Reza Ranjbar <hamidreza.ranjbar@snapp.cab>
  • Loading branch information
Rancbar and Hamid Reza Ranjbar authored Jul 19, 2024
1 parent 0de9383 commit 68793c0
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 0 deletions.
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,61 @@ func main() {
}
```

### Complex objects inside array (slice)

You can set sub-struct field values inside a slice by naming the environment variables with sequential numbers starting from 0 (without omitting numbers in between) and an underscore.
It is possible to use prefix tag too.

Here's an example with and without prefix tag:

```go
package main

import (
"fmt"
"log"

"github.com/caarlos0/env/v11"
)

type Test struct {
Str string `env:"STR"`
Num int `env:"NUM"`
}
type ComplexConfig struct {
Baz []Test `env:",init"`
Bar []Test `envPrefix:"BAR"`
Foo *[]Test `envPrefix:"FOO_"`
}

func main() {
cfg := &ComplexConfig{}
opts := env.Options{
Environment: map[string]string{
"0_STR": "bt",
"1_NUM": "10",

"FOO_0_STR": "b0t",
"FOO_1_STR": "b1t",
"FOO_1_NUM": "212",

"BAR_0_STR": "f0t",
"BAR_0_NUM": "101",
"BAR_1_STR": "f1t",
"BAR_1_NUM": "111",
},
}

// Load env vars.
if err := env.ParseWithOptions(cfg, opts); err != nil {
log.Fatal(err)
}

// Print the loaded data.
fmt.Printf("%+v\n", cfg)
}
```

### On set hooks

You might want to listen to value sets and, for example, log something or do
Expand Down
111 changes: 111 additions & 0 deletions env.go
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,19 @@ func customOptions(opt Options) Options {
return opt
}

func optionsWithSliceEnvPrefix(opts Options, index int) Options {
return Options{
Environment: opts.Environment,
TagName: opts.TagName,
RequiredIfNoDef: opts.RequiredIfNoDef,
OnSet: opts.OnSet,
Prefix: fmt.Sprintf("%s%d_", opts.Prefix, index),
UseFieldNameByDefault: opts.UseFieldNameByDefault,
FuncMap: opts.FuncMap,
rawEnvVars: opts.rawEnvVars,
}
}

func optionsWithEnvPrefix(field reflect.StructField, opts Options) Options {
return Options{
Environment: opts.Environment,
Expand Down Expand Up @@ -313,6 +326,104 @@ func doParseField(refField reflect.Value, refTypeField reflect.StructField, proc
return doParse(refField, processField, optionsWithEnvPrefix(refTypeField, opts))
}

if isSliceOfStructs(refTypeField, opts) {
return doParseSlice(refField, processField, optionsWithEnvPrefix(refTypeField, opts))
}

return nil
}

func isSliceOfStructs(refTypeField reflect.StructField, opts Options) bool {
field := refTypeField.Type
if reflect.Ptr == field.Kind() {
field = field.Elem()
}

if reflect.Slice != field.Kind() {
return false
}

field = field.Elem()

if reflect.Ptr == field.Kind() {
field = field.Elem()
}

_, ignore := defaultBuiltInParsers[field.Kind()]

if !ignore {
_, ignore = opts.FuncMap[field]
}

if !ignore {
_, ignore = reflect.New(field).Interface().(encoding.TextUnmarshaler)
}

if !ignore {
ignore = reflect.Struct != field.Kind()
}
return !ignore
}

func doParseSlice(ref reflect.Value, processField processFieldFn, opts Options) error {
if opts.Prefix != "" && !strings.HasSuffix(opts.Prefix, string(underscore)) {
opts.Prefix += string(underscore)
}

var environments []string
for environment := range opts.Environment {
if strings.HasPrefix(environment, opts.Prefix) {
environments = append(environments, environment)
}
}

if len(environments) > 0 {
counter := 0
for finished := false; !finished; {
finished = true
prefix := fmt.Sprintf("%s%d%c", opts.Prefix, counter, underscore)
for _, variable := range environments {
if strings.HasPrefix(variable, prefix) {
counter++
finished = false
break
}
}
}

sliceType := ref.Type()
var initialized int
if reflect.Ptr == ref.Kind() {
sliceType = sliceType.Elem()
// Due to the rest of code the pre-initialized slice has no chance for this situation
initialized = 0
} else {
initialized = ref.Len()
}

var capacity int
if capacity = initialized; counter > initialized {
capacity = counter
}
result := reflect.MakeSlice(sliceType, capacity, capacity)
for i := 0; i < capacity; i++ {
item := result.Index(i)
if i < initialized {
item.Set(ref.Index(i))
}
if err := doParse(item, processField, optionsWithSliceEnvPrefix(opts, i)); err != nil {
return err
}
}

if reflect.Ptr == ref.Kind() {
resultPtr := reflect.New(sliceType)
resultPtr.Elem().Set(result)
result = resultPtr
}
ref.Set(result)
}

return nil
}

Expand Down
64 changes: 64 additions & 0 deletions env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2151,3 +2151,67 @@ func TestMultipleTagOptions(t *testing.T) {
isEqual(t, "", os.Getenv("URL"))
})
}

func TestIssue298(t *testing.T) {
type Test struct {
Str string `env:"STR"`
Num int `env:"NUM"`
}
type ComplexConfig struct {
Foo *[]Test `envPrefix:"FOO_"`
Bar []Test `envPrefix:"BAR"`
Baz []Test `env:",init"`
}

t.Setenv("FOO_0_STR", "f0t")
t.Setenv("FOO_0_NUM", "101")
t.Setenv("FOO_1_STR", "f1t")
t.Setenv("FOO_1_NUM", "111")

t.Setenv("BAR_0_STR", "b0t")
// t.Setenv("BAR_0_NUM", "202") // Not overridden
t.Setenv("BAR_1_STR", "b1t")
t.Setenv("BAR_1_NUM", "212")

t.Setenv("0_STR", "bt")
t.Setenv("1_NUM", "10")

sample := make([]Test, 1)
sample[0].Str = "overridden text"
sample[0].Num = 99999999
cfg := ComplexConfig{Bar: sample}

isNoErr(t, Parse(&cfg))

isEqual(t, "f0t", (*cfg.Foo)[0].Str)
isEqual(t, 101, (*cfg.Foo)[0].Num)
isEqual(t, "f1t", (*cfg.Foo)[1].Str)
isEqual(t, 111, (*cfg.Foo)[1].Num)

isEqual(t, "b0t", cfg.Bar[0].Str)
isEqual(t, 99999999, cfg.Bar[0].Num)
isEqual(t, "b1t", cfg.Bar[1].Str)
isEqual(t, 212, cfg.Bar[1].Num)

isEqual(t, "bt", cfg.Baz[0].Str)
isEqual(t, 0, cfg.Baz[0].Num)
isEqual(t, "", cfg.Baz[1].Str)
isEqual(t, 10, cfg.Baz[1].Num)
}

func TestIssue298ErrorNestedFieldRequiredNotSet(t *testing.T) {
type Test struct {
Str string `env:"STR,required"`
Num int `env:"NUM"`
}
type ComplexConfig struct {
Foo *[]Test `envPrefix:"FOO"`
}

t.Setenv("FOO_0_NUM", "101")

cfg := ComplexConfig{}
err := Parse(&cfg)
isErrorWithMessage(t, err, `env: required environment variable "FOO_0_STR" is not set`)
isTrue(t, errors.Is(err, EnvVarIsNotSetError{}))
}

0 comments on commit 68793c0

Please sign in to comment.