Skip to content

Commit

Permalink
Add missing-key flag to manage behavior in case of non-existing key (#…
Browse files Browse the repository at this point in the history
…1949)

* Add missing-key flag to manage behavior in case of non-existing key

* Fix typo

* Added integration tests, added "default" to the allowed values for the missing-key flag

* Use the "error" value for the MissingKey if it passes as empty string

* Remove unnecessary writeFile from test

* Add docs for the missin key feature

* Add invalid to the allowed values of missing-key option

* Remove unnecesary code from tests

* Fix failed tests and linter errors

* Update docs/content/usage.md

Co-authored-by: Dave Henderson <[email protected]>

* Update feature description

* Add missing dot

---------

Co-authored-by: Aleksandr Paramonov <[email protected]>
Co-authored-by: Dave Henderson <[email protected]>
  • Loading branch information
3 people authored Jan 17, 2024
1 parent 886c3b2 commit f837061
Show file tree
Hide file tree
Showing 12 changed files with 172 additions and 5 deletions.
10 changes: 10 additions & 0 deletions docs/content/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,16 @@ Overrides the left template delimiter.
leftDelim: '%{'
```

## `missingKey`

See [`--missing-key`](../usage/#--missing-key).

Control the behavior during execution if a map is indexed with a key that is not present in the map

```yaml
missingKey: error
```

## `outputDir`

See [`--output-dir`](../usage/#input-dir-and-output-dir).
Expand Down
32 changes: 32 additions & 0 deletions docs/content/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,38 @@ $ gomplate -c .=http://xkcd.com/info.0.json -i '<a href="{{ .img }}">{{ .title }
<a href="https://imgs.xkcd.com/comics/diploma_legal_notes.png">Diploma Legal Notes</a>
```

### `--missing-key`

Control the behavior during execution if a map is indexed with a key that is not present in the map.

Available values:
- `error` (default): Execution stops immediately with an error.
- `default` or `invalid`: Do nothing and continue execution. If printed, the result is the string `"<no value>"`.
- `zero`: The operation returns the zero value for the element (which may be `nil`, in which case the string `"<no value>"` is printed).

Examples:

```console
$ gomplate --missing-key error -i 'Hi {{ .name }}'
Hi 14:06:57 ERR error="failed to render template <arg>: template: <arg>:1:6: executing \"<arg>\" at <.name>: map has no entry for key \"name\""
```

```console
$ gomplate --missing-key default -i 'Hi {{ .name }}'
Hi <no value>
```

```console
$ gomplate --missing-key zero -i 'Hi {{ .name | default "Alex" }}'
Hi Alex
```

```console
$ gomplate --missing-key zero -i 'Hi {{ .name | required }}'
Hi 14:12:04 ERR error="failed to render template <arg>: template: <arg>:1:11: executing \"<arg>\" at <required>: error calling required: can not render template: a required value was not set"
```


### Overriding the template delimiters

Sometimes it's necessary to override the default template delimiters (`{{`/`}}`).
Expand Down
20 changes: 20 additions & 0 deletions gomplate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,26 @@ func TestHasTemplate(t *testing.T) {
assert.Equal(t, "bar", testTemplate(t, g, tmpl))
}

func TestMissingKey(t *testing.T) {
tests := map[string]struct {
MissingKey string
ExpectedOut string
}{
"missing-key = zero": {MissingKey: "zero", ExpectedOut: "<no value>"},
"missing-key = invalid": {MissingKey: "invalid", ExpectedOut: "<no value>"},
"missing-key = default": {MissingKey: "default", ExpectedOut: "<no value>"},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
g := NewRenderer(Options{
MissingKey: tt.MissingKey,
})
tmpl := `{{ .name }}`
assert.Equal(t, tt.ExpectedOut, testTemplate(t, g, tmpl))
})
}
}

func TestCustomDelim(t *testing.T) {
g := NewRenderer(Options{
LDelim: "[",
Expand Down
5 changes: 5 additions & 0 deletions internal/cmd/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ func cobraConfig(cmd *cobra.Command, args []string) (cfg *config.Config, err err
return nil, err
}

cfg.MissingKey, err = getString(cmd, "missing-key")
if err != nil {
return nil, err
}

ds, err := getStringSlice(cmd, "datasource")
if err != nil {
return nil, err
Expand Down
2 changes: 2 additions & 0 deletions internal/cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ func InitFlags(command *cobra.Command) {
command.Flags().String("left-delim", ldDefault, "override the default left-`delimiter` [$GOMPLATE_LEFT_DELIM]")
command.Flags().String("right-delim", rdDefault, "override the default right-`delimiter` [$GOMPLATE_RIGHT_DELIM]")

command.Flags().String("missing-key", "error", "Control the behavior during execution if a map is indexed with a key that is not present in the map. error (default) - return an error, zero - fallback to zero value, default/invalid - print <no value>")

command.Flags().Bool("experimental", false, "enable experimental features [$GOMPLATE_EXPERIMENTAL]")

command.Flags().BoolP("verbose", "V", false, "output extra information about what gomplate is doing")
Expand Down
14 changes: 14 additions & 0 deletions internal/config/configfile.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"strings"
"time"

"golang.org/x/exp/slices"

"github.com/hairyhenderson/gomplate/v4/internal/datafs"
"github.com/hairyhenderson/gomplate/v4/internal/iohelpers"
"github.com/hairyhenderson/yaml"
Expand Down Expand Up @@ -60,6 +62,8 @@ type Config struct {
LDelim string `yaml:"leftDelim,omitempty"`
RDelim string `yaml:"rightDelim,omitempty"`

MissingKey string `yaml:"missingKey,omitempty"`

PostExec []string `yaml:"postExec,omitempty,flow"`

PluginTimeout time.Duration `yaml:"pluginTimeout,omitempty"`
Expand Down Expand Up @@ -465,6 +469,13 @@ func (c Config) Validate() (err error) {
}
}

if err == nil {
missingKeyValues := []string{"", "error", "zero", "default", "invalid"}
if !slices.Contains(missingKeyValues, c.MissingKey) {
err = fmt.Errorf("not allowed value for the 'missing-key' flag: %s. Allowed values: %s", c.MissingKey, strings.Join(missingKeyValues, ","))
}
}

return err
}

Expand Down Expand Up @@ -533,6 +544,9 @@ func (c *Config) ApplyDefaults() {
if c.RDelim == "" {
c.RDelim = "}}"
}
if c.MissingKey == "" {
c.MissingKey = "error"
}

if c.ExecPipe {
pipe := &bytes.Buffer{}
Expand Down
1 change: 1 addition & 0 deletions internal/config/configfile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -539,6 +539,7 @@ inputFiles: ['-']
outputFiles: ['-']
leftDelim: '{{'
rightDelim: '}}'
missingKey: error
pluginTimeout: 5s
`
assert.Equal(t, expected, c.String())
Expand Down
20 changes: 20 additions & 0 deletions internal/tests/integration/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,3 +275,23 @@ templates:
o, e, err := cmd(t).withDir(tmpDir.Path()).run()
assertSuccess(t, o, e, err, "12345")
}

func TestConfig_MissingKeyDefault(t *testing.T) {
tmpDir := setupConfigTest(t)
writeConfig(t, tmpDir, `inputFiles: [in]
missingKey: default
`)
writeFile(t, tmpDir, "in", `{{ .name }}`)

o, e, err := cmd(t).withDir(tmpDir.Path()).run()
assertSuccess(t, o, e, err, `<no value>`)
}

func TestConfig_MissingKeyNotDefined(t *testing.T) {
tmpDir := setupConfigTest(t)
writeConfig(t, tmpDir, `inputFiles: [in]`)
writeFile(t, tmpDir, "in", `{{ .name }}`)

o, e, err := cmd(t).withDir(tmpDir.Path()).run()
assertFailed(t, o, e, err, `map has no entry for key \"name\"`)
}
21 changes: 19 additions & 2 deletions internal/tests/integration/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,22 @@ import (
const isWindows = runtime.GOOS == "windows"

// a convenience...
func inOutTest(t *testing.T, i, o string) {
func inOutTest(t *testing.T, i, o string, args ...string) {
t.Helper()

stdout, stderr, err := cmd(t, "-i", i).run()
args = append(args, "-i", i)
stdout, stderr, err := cmd(t, args...).run()
assertSuccess(t, stdout, stderr, err, o)
}

func inOutContainsError(t *testing.T, i, e string, args ...string) {
t.Helper()

args = append(args, "-i", i)
stdout, stderr, err := cmd(t, args...).run()
assertFailed(t, stdout, stderr, err, e)
}

func inOutTestExperimental(t *testing.T, i, o string) {
t.Helper()

Expand All @@ -56,6 +65,14 @@ func assertSuccess(t *testing.T, o, e string, err error, expected string) {
require.NoError(t, err)
}

func assertFailed(t *testing.T, o, e string, err error, expected string) {
t.Helper()

assert.Contains(t, e, expected)
assert.Equal(t, "", o)
require.Error(t, err)
}

// mirrorHandler - reflects back the HTTP headers from the request
func mirrorHandler(w http.ResponseWriter, r *http.Request) {
type Req struct {
Expand Down
25 changes: 25 additions & 0 deletions internal/tests/integration/missing_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package integration

import (
"testing"
)

func TestMissingKey_Default(t *testing.T) {
inOutTest(t, `{{ .name }}`, "<no value>", "--missing-key", "default")
}

func TestMissingKey_Zero(t *testing.T) {
inOutTest(t, `{{ .name }}`, "<no value>", "--missing-key", "zero")
}

func TestMissingKey_Fallback(t *testing.T) {
inOutTest(t, `{{ .name | default "Alex" }}`, "Alex", "--missing-key", "default")
}

func TestMissingKey_NotSpecified(t *testing.T) {
inOutContainsError(t, `{{ .name | default "Alex" }}`, `map has no entry for key \"name\"`)
}

func TestMissingKey_Error(t *testing.T) {
inOutContainsError(t, `{{ .name | default "Alex" }}`, `map has no entry for key \"name\"`, "--missing-key", "error")
}
13 changes: 12 additions & 1 deletion render.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ type Options struct {
// templates to the specified string. Defaults to "{{"
RDelim string

// MissingKey controls the behavior during execution if a map is indexed with a key that is not present in the map
MissingKey string

// Experimental - enable experimental features
Experimental bool
}
Expand Down Expand Up @@ -78,6 +81,7 @@ func optionsFromConfig(cfg *config.Config) Options {
ExtraHeaders: cfg.ExtraHeaders,
LDelim: cfg.LDelim,
RDelim: cfg.RDelim,
MissingKey: cfg.MissingKey,
Experimental: cfg.Experimental,
}

Expand All @@ -103,6 +107,7 @@ type Renderer struct {
funcs template.FuncMap
lDelim string
rDelim string
missingKey string
tctxAliases []string
}

Expand Down Expand Up @@ -161,13 +166,19 @@ func NewRenderer(opts Options) *Renderer {
opts.Funcs = template.FuncMap{}
}

missingKey := opts.MissingKey
if missingKey == "" {
missingKey = "error"
}

return &Renderer{
nested: nested,
data: d,
funcs: opts.Funcs,
tctxAliases: tctxAliases,
lDelim: opts.LDelim,
rDelim: opts.RDelim,
missingKey: missingKey,
}
}

Expand Down Expand Up @@ -236,7 +247,7 @@ func (t *Renderer) renderTemplate(ctx context.Context, template Template, f temp

tstart := time.Now()
tmpl, err := parseTemplate(ctx, template.Name, template.Text,
f, tmplctx, t.nested, t.lDelim, t.rDelim)
f, tmplctx, t.nested, t.lDelim, t.rDelim, t.missingKey)
if err != nil {
return err
}
Expand Down
14 changes: 12 additions & 2 deletions template.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"text/template"

Expand Down Expand Up @@ -48,9 +49,18 @@ func copyFuncMap(funcMap template.FuncMap) template.FuncMap {
}

// parseTemplate - parses text as a Go template with the given name and options
func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string) (tmpl *template.Template, err error) {
func parseTemplate(ctx context.Context, name, text string, funcs template.FuncMap, tmplctx interface{}, nested config.Templates, leftDelim, rightDelim string, missingKey string) (tmpl *template.Template, err error) {
tmpl = template.New(name)
tmpl.Option("missingkey=error")
if missingKey == "" {
missingKey = "error"
}

missingKeyValues := []string{"error", "zero", "default", "invalid"}
if !slices.Contains(missingKeyValues, missingKey) {
return nil, fmt.Errorf("not allowed value for the 'missing-key' flag: %s. Allowed values: %s", missingKey, strings.Join(missingKeyValues, ","))
}

tmpl.Option("missingkey=" + missingKey)

funcMap := copyFuncMap(funcs)

Expand Down

0 comments on commit f837061

Please sign in to comment.