Add `dynvar` package for variable resolution with a `dyn.Value` tree (#1143)

## Changes

This is the `dyn` counterpart to the `bundle/config/interpolation`
package.

It relies on the paths in `${foo.bar}` being valid `dyn.Path` instances.
It leverages `dyn.Walk` to get a complete picture of all variable
references and uses `dyn.Get` to retrieve values pointed to by variable
references.

Depends on #1142.

## Tests

Unit test coverage. I tried to mirror the tests from
`bundle/config/interpolation` and added new ones where applicable (for
example to test type retention of referenced values).
This commit is contained in:
Pieter Noordhuis 2024-01-24 19:49:06 +01:00 committed by GitHub
parent ff6e0354b9
commit 14abcb3ad7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 553 additions and 0 deletions

21
libs/dyn/dynvar/lookup.go Normal file
View File

@ -0,0 +1,21 @@
package dynvar
import (
"errors"
"github.com/databricks/cli/libs/dyn"
)
// Lookup is the type of lookup functions that can be used with [Resolve].
type Lookup func(path dyn.Path) (dyn.Value, error)
// ErrSkipResolution is returned by a lookup function to indicate that the
// resolution of a variable reference should be skipped.
var ErrSkipResolution = errors.New("skip resolution")
// DefaultLookup is the default lookup function used by [Resolve].
func DefaultLookup(in dyn.Value) Lookup {
return func(path dyn.Path) (dyn.Value, error) {
return dyn.GetByPath(in, path)
}
}

View File

@ -0,0 +1,27 @@
package dynvar_test
import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/dynvar"
"github.com/stretchr/testify/assert"
)
func TestDefaultLookup(t *testing.T) {
lookup := dynvar.DefaultLookup(dyn.V(map[string]dyn.Value{
"a": dyn.V("a"),
"b": dyn.V("b"),
}))
v1, err := lookup(dyn.NewPath(dyn.Key("a")))
assert.NoError(t, err)
assert.Equal(t, dyn.V("a"), v1)
v2, err := lookup(dyn.NewPath(dyn.Key("b")))
assert.NoError(t, err)
assert.Equal(t, dyn.V("b"), v2)
_, err = lookup(dyn.NewPath(dyn.Key("c")))
assert.True(t, dyn.IsNoSuchKeyError(err))
}

69
libs/dyn/dynvar/ref.go Normal file
View File

@ -0,0 +1,69 @@
package dynvar
import (
"regexp"
"github.com/databricks/cli/libs/dyn"
)
var re = regexp.MustCompile(`\$\{([a-zA-Z]+([-_]?[a-zA-Z0-9]+)*(\.[a-zA-Z]+([-_]?[a-zA-Z0-9]+)*)*)\}`)
// ref represents a variable reference.
// It is a string [dyn.Value] contained in a larger [dyn.Value].
// Its path within the containing [dyn.Value] is also stored.
type ref struct {
// Original value.
value dyn.Value
// String value in the original [dyn.Value].
str string
// Matches of the variable reference in the string.
matches [][]string
}
// newRef returns a new ref if the given [dyn.Value] contains a string
// with one or more variable references. It returns false if the given
// [dyn.Value] does not contain variable references.
//
// Examples of a valid variable references:
// - "${a.b}"
// - "${a.b.c}"
// - "${a} ${b} ${c}"
func newRef(v dyn.Value) (ref, bool) {
s, ok := v.AsString()
if !ok {
return ref{}, false
}
// Check if the string contains any variable references.
m := re.FindAllStringSubmatch(s, -1)
if len(m) == 0 {
return ref{}, false
}
return ref{
value: v,
str: s,
matches: m,
}, true
}
// isPure returns true if the variable reference contains a single
// variable reference and nothing more. We need this so we can
// interpolate values of non-string types (i.e. it can be substituted).
func (v ref) isPure() bool {
// Need single match, equal to the incoming string.
if len(v.matches) == 0 || len(v.matches[0]) == 0 {
panic("invalid variable reference; expect at least one match")
}
return v.matches[0][0] == v.str
}
func (v ref) references() []string {
var out []string
for _, m := range v.matches {
out = append(out, m[1])
}
return out
}

View File

@ -0,0 +1,46 @@
package dynvar
import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewRefNoString(t *testing.T) {
_, ok := newRef(dyn.V(1))
require.False(t, ok, "should not match non-string")
}
func TestNewRefValidPattern(t *testing.T) {
for in, refs := range map[string][]string{
"${hello_world.world_world}": {"hello_world.world_world"},
"${helloworld.world-world}": {"helloworld.world-world"},
"${hello-world.world-world}": {"hello-world.world-world"},
} {
ref, ok := newRef(dyn.V(in))
require.True(t, ok, "should match valid pattern: %s", in)
assert.Equal(t, refs, ref.references())
}
}
func TestNewRefInvalidPattern(t *testing.T) {
invalid := []string{
"${hello_world-.world_world}", // the first segment ending must not end with hyphen (-)
"${hello_world-_.world_world}", // the first segment ending must not end with underscore (_)
"${helloworld.world-world-}", // second segment must not end with hyphen (-)
"${helloworld-.world-world}", // first segment must not end with hyphen (-)
"${helloworld.-world-world}", // second segment must not start with hyphen (-)
"${-hello-world.-world-world-}", // must not start or end with hyphen (-)
"${_-_._-_.id}", // cannot use _- in sequence
"${0helloworld.world-world}", // interpolated first section shouldn't start with number
"${helloworld.9world-world}", // interpolated second section shouldn't start with number
"${a-a.a-_a-a.id}", // fails because of -_ in the second segment
"${a-a.a--a-a.id}", // fails because of -- in the second segment
}
for _, v := range invalid {
_, ok := newRef(dyn.V(v))
require.False(t, ok, "should not match invalid pattern: %s", v)
}
}

206
libs/dyn/dynvar/resolve.go Normal file
View File

@ -0,0 +1,206 @@
package dynvar
import (
"errors"
"fmt"
"slices"
"sort"
"strings"
"github.com/databricks/cli/libs/dyn"
"golang.org/x/exp/maps"
)
// Resolve resolves variable references in the given input value using the provided lookup function.
// It returns the resolved output value and any error encountered during the resolution process.
//
// For example, given the input value:
//
// {
// "a": "a",
// "b": "${a}",
// "c": "${b}${b}",
// }
//
// The output value will be:
//
// {
// "a": "a",
// "b": "a",
// "c": "aa",
// }
//
// If the input value contains a variable reference that cannot be resolved, an error is returned.
// If a cycle is detected in the variable references, an error is returned.
// If for some path the resolution function returns [ErrSkipResolution], the variable reference is left in place.
// This is useful when some variable references are not yet ready to be interpolated.
func Resolve(in dyn.Value, fn Lookup) (out dyn.Value, err error) {
return resolver{in: in, fn: fn}.run()
}
type resolver struct {
in dyn.Value
fn Lookup
refs map[string]ref
resolved map[string]dyn.Value
}
func (r resolver) run() (out dyn.Value, err error) {
err = r.collectVariableReferences()
if err != nil {
return dyn.InvalidValue, err
}
err = r.resolveVariableReferences()
if err != nil {
return dyn.InvalidValue, err
}
out, err = r.replaceVariableReferences()
if err != nil {
return dyn.InvalidValue, err
}
return out, nil
}
func (r *resolver) collectVariableReferences() (err error) {
r.refs = make(map[string]ref)
// First walk the input to gather all values with a variable reference.
_, err = dyn.Walk(r.in, func(p dyn.Path, v dyn.Value) (dyn.Value, error) {
ref, ok := newRef(v)
if !ok {
// Skip values without variable references.
return v, nil
}
r.refs[p.String()] = ref
return v, nil
})
return err
}
func (r *resolver) resolveVariableReferences() (err error) {
// Initialize map for resolved variables.
// We use this for memoization.
r.resolved = make(map[string]dyn.Value)
// Resolve each variable reference (in order).
// We sort the keys here to ensure that we always resolve the same variable reference first.
// This is done such that the cycle detection error is deterministic. If we did not do this,
// we could enter the cycle at any point in the cycle and return varying errors.
keys := maps.Keys(r.refs)
sort.Strings(keys)
for _, key := range keys {
_, err := r.resolve(key, []string{key})
if err != nil {
return err
}
}
return nil
}
func (r *resolver) resolve(key string, seen []string) (dyn.Value, error) {
// Check if we have already resolved this variable reference.
if v, ok := r.resolved[key]; ok {
return v, nil
}
ref, ok := r.refs[key]
if !ok {
// Perform lookup in the input.
p, err := dyn.NewPathFromString(key)
if err != nil {
return dyn.InvalidValue, err
}
v, err := r.fn(p)
if err != nil && dyn.IsNoSuchKeyError(err) {
return dyn.InvalidValue, fmt.Errorf(
"reference does not exist: ${%s}",
key,
)
}
return v, err
}
// This is an unresolved variable reference.
deps := ref.references()
// Resolve each of the dependencies, then interpolate them in the ref.
resolved := make([]dyn.Value, len(deps))
complete := true
for j, dep := range deps {
// Cycle detection.
if slices.Contains(seen, dep) {
return dyn.InvalidValue, fmt.Errorf(
"cycle detected in field resolution: %s",
strings.Join(append(seen, dep), " -> "),
)
}
v, err := r.resolve(dep, append(seen, dep))
// If we should skip resolution of this key, index j will hold an invalid [dyn.Value].
if errors.Is(err, ErrSkipResolution) {
complete = false
continue
} else if err != nil {
// Otherwise, propagate the error.
return dyn.InvalidValue, err
}
resolved[j] = v
}
// Interpolate the resolved values.
if ref.isPure() && complete {
// If the variable reference is pure, we can substitute it.
// This is useful for interpolating values of non-string types.
r.resolved[key] = resolved[0]
return resolved[0], nil
}
// Not pure; perform string interpolation.
for j := range ref.matches {
// The value is invalid if resolution returned [ErrSkipResolution].
// We must skip those and leave the original variable reference in place.
if !resolved[j].IsValid() {
continue
}
// Try to turn the resolved value into a string.
s, ok := resolved[j].AsString()
if !ok {
return dyn.InvalidValue, fmt.Errorf(
"cannot interpolate non-string value: %s",
ref.matches[j][0],
)
}
ref.str = strings.Replace(ref.str, ref.matches[j][0], s, 1)
}
// Store the interpolated value.
v := dyn.NewValue(ref.str, ref.value.Location())
r.resolved[key] = v
return v, nil
}
func (r *resolver) replaceVariableReferences() (dyn.Value, error) {
// Walk the input and replace all variable references.
return dyn.Walk(r.in, func(p dyn.Path, v dyn.Value) (dyn.Value, error) {
nv, ok := r.resolved[p.String()]
if !ok {
// No variable reference; return the original value.
return v, nil
}
// We have a variable reference; return the resolved value.
return nv, nil
})
}

View File

@ -0,0 +1,184 @@
package dynvar_test
import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/dynvar"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func getByPath(t *testing.T, v dyn.Value, path string) dyn.Value {
v, err := dyn.Get(v, path)
require.NoError(t, err)
return v
}
func TestResolve(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("a"),
"b": dyn.V("${a}"),
"c": dyn.V("${a}"),
})
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.NoError(t, err)
assert.Equal(t, "a", getByPath(t, out, "a").MustString())
assert.Equal(t, "a", getByPath(t, out, "b").MustString())
assert.Equal(t, "a", getByPath(t, out, "c").MustString())
}
func TestResolveNotFound(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"b": dyn.V("${a}"),
})
_, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.ErrorContains(t, err, `reference does not exist: ${a}`)
}
func TestResolveWithNesting(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("${f.a}"),
"f": dyn.V(map[string]dyn.Value{
"a": dyn.V("a"),
"b": dyn.V("${f.a}"),
}),
})
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.NoError(t, err)
assert.Equal(t, "a", getByPath(t, out, "a").MustString())
assert.Equal(t, "a", getByPath(t, out, "f.a").MustString())
assert.Equal(t, "a", getByPath(t, out, "f.b").MustString())
}
func TestResolveWithRecursion(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("a"),
"b": dyn.V("${a}"),
"c": dyn.V("${b}"),
})
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.NoError(t, err)
assert.Equal(t, "a", getByPath(t, out, "a").MustString())
assert.Equal(t, "a", getByPath(t, out, "b").MustString())
assert.Equal(t, "a", getByPath(t, out, "c").MustString())
}
func TestResolveWithRecursionLoop(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("a"),
"b": dyn.V("${c}"),
"c": dyn.V("${d}"),
"d": dyn.V("${b}"),
})
_, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
assert.ErrorContains(t, err, "cycle detected in field resolution: b -> c -> d -> b")
}
func TestResolveWithRecursionLoopSelf(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("${a}"),
})
_, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
assert.ErrorContains(t, err, "cycle detected in field resolution: a -> a")
}
func TestResolveWithStringConcatenation(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("a"),
"b": dyn.V("b"),
"c": dyn.V("${a}${b}${a}"),
})
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.NoError(t, err)
assert.Equal(t, "a", getByPath(t, out, "a").MustString())
assert.Equal(t, "b", getByPath(t, out, "b").MustString())
assert.Equal(t, "aba", getByPath(t, out, "c").MustString())
}
func TestResolveWithTypeRetentionFailure(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V(1),
"b": dyn.V(2),
"c": dyn.V("${a} ${b}"),
})
_, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.ErrorContains(t, err, "cannot interpolate non-string value: ${a}")
}
func TestResolveWithTypeRetention(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"int": dyn.V(1),
"int_var": dyn.V("${int}"),
"bool_true": dyn.V(true),
"bool_true_var": dyn.V("${bool_true}"),
"bool_false": dyn.V(false),
"bool_false_var": dyn.V("${bool_false}"),
"float": dyn.V(1.0),
"float_var": dyn.V("${float}"),
"string": dyn.V("a"),
"string_var": dyn.V("${string}"),
})
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
require.NoError(t, err)
assert.EqualValues(t, 1, getByPath(t, out, "int").MustInt())
assert.EqualValues(t, 1, getByPath(t, out, "int_var").MustInt())
assert.EqualValues(t, true, getByPath(t, out, "bool_true").MustBool())
assert.EqualValues(t, true, getByPath(t, out, "bool_true_var").MustBool())
assert.EqualValues(t, false, getByPath(t, out, "bool_false").MustBool())
assert.EqualValues(t, false, getByPath(t, out, "bool_false_var").MustBool())
assert.EqualValues(t, 1.0, getByPath(t, out, "float").MustFloat())
assert.EqualValues(t, 1.0, getByPath(t, out, "float_var").MustFloat())
assert.EqualValues(t, "a", getByPath(t, out, "string").MustString())
assert.EqualValues(t, "a", getByPath(t, out, "string_var").MustString())
}
func TestResolveWithSkip(t *testing.T) {
in := dyn.V(map[string]dyn.Value{
"a": dyn.V("a"),
"b": dyn.V("b"),
"c": dyn.V("${a}"),
"d": dyn.V("${b}"),
"e": dyn.V("${a} ${b}"),
"f": dyn.V("${b} ${a} ${a} ${b}"),
})
fallback := dynvar.DefaultLookup(in)
ignore := func(path dyn.Path) (dyn.Value, error) {
// If the variable reference to look up starts with "b", skip it.
if path.HasPrefix(dyn.NewPath(dyn.Key("b"))) {
return dyn.InvalidValue, dynvar.ErrSkipResolution
}
return fallback(path)
}
out, err := dynvar.Resolve(in, ignore)
require.NoError(t, err)
assert.Equal(t, "a", getByPath(t, out, "a").MustString())
assert.Equal(t, "b", getByPath(t, out, "b").MustString())
assert.Equal(t, "a", getByPath(t, out, "c").MustString())
// Check that the skipped variable references are not interpolated.
assert.Equal(t, "${b}", getByPath(t, out, "d").MustString())
assert.Equal(t, "a ${b}", getByPath(t, out, "e").MustString())
assert.Equal(t, "${b} a a ${b}", getByPath(t, out, "f").MustString())
}