Add functionality to visit values in `dyn.Value` tree (#1142)

## Changes

This change adds the following functions:
* `dyn.Get(value, "foo.bar") -> (dyn.Value, error)`
* `dyn.Set(value, "foo.bar", newValue) -> (dyn.Value, error)`
* `dyn.Map(value, "foo.bar", func) -> (dyn.Value, error)`

And equivalent functions that take a previously constructed `dyn.Path`:
* `dyn.GetByPath(value, dyn.Path) -> (dyn.Value, error)`
* `dyn.SetByPath(value, dyn.Path, newValue) -> (dyn.Value, error)`
* `dyn.MapByPath(value, dyn.Path, func) -> (dyn.Value, error)`

Changes made by the "set" and "map" functions are never reflected in the
input argument; they return new `dyn.Value` instances for all nodes in
the path leading up to the changed value.

## Tests

New unit tests cover all critical paths.
This commit is contained in:
Pieter Noordhuis 2024-01-24 19:38:46 +01:00 committed by GitHub
parent cf2a1c38ba
commit ff6e0354b9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 666 additions and 0 deletions

View File

@ -10,6 +10,14 @@ type pathComponent struct {
index int index int
} }
func (c pathComponent) isKey() bool {
return c.key != ""
}
func (c pathComponent) isIndex() bool {
return c.key == ""
}
// Path represents a path to a value in a [Value] configuration tree. // Path represents a path to a value in a [Value] configuration tree.
type Path []pathComponent type Path []pathComponent

View File

@ -134,3 +134,28 @@ func (v Value) MarkAnchor() Value {
func (v Value) IsAnchor() bool { func (v Value) IsAnchor() bool {
return v.anchor return v.anchor
} }
// eq is an internal only method that compares two values.
// It is used to determine if a value has changed during a visit.
// We need a custom implementation because maps and slices
// cannot be compared with the regular == operator.
func (v Value) eq(w Value) bool {
if v.k != w.k || v.l != w.l {
return false
}
switch v.k {
case KindMap:
// Compare pointers to the underlying map.
// This is safe because we don't allow maps to be mutated.
return &v.v == &w.v
case KindSequence:
// Compare pointers to the underlying slice and slice length.
// This is safe because we don't allow slices to be mutated.
vs := v.v.([]Value)
ws := w.v.([]Value)
return &vs[0] == &ws[0] && len(vs) == len(ws)
default:
return v.v == w.v
}
}

139
libs/dyn/visit.go Normal file
View File

@ -0,0 +1,139 @@
package dyn
import (
"errors"
"fmt"
"maps"
"slices"
)
type noSuchKeyError struct {
p Path
}
func (e noSuchKeyError) Error() string {
return fmt.Sprintf("key not found at %q", e.p)
}
func IsNoSuchKeyError(err error) bool {
var target noSuchKeyError
return errors.As(err, &target)
}
type indexOutOfBoundsError struct {
p Path
}
func (e indexOutOfBoundsError) Error() string {
return fmt.Sprintf("index out of bounds at %q", e.p)
}
func IsIndexOutOfBoundsError(err error) bool {
var target indexOutOfBoundsError
return errors.As(err, &target)
}
type visitOptions struct {
// The function to apply to the value once found.
//
// If this function returns the same value as it receives as argument,
// the original visit function call returns the original value unmodified.
//
// If this function returns a new value, the original visit function call
// returns a value with all the intermediate values updated.
//
// If this function returns an error, the original visit function call
// returns this error and the value is left unmodified.
fn func(Value) (Value, error)
// If set, tolerate the absence of the last component in the path.
// This option is needed to set a key in a map that is not yet present.
allowMissingKeyInMap bool
}
func visit(v Value, prefix, suffix Path, opts visitOptions) (Value, error) {
if len(suffix) == 0 {
return opts.fn(v)
}
// Initialize prefix if it is empty.
// It is pre-allocated to its maximum size to avoid additional allocations.
if len(prefix) == 0 {
prefix = make(Path, 0, len(suffix))
}
component := suffix[0]
prefix = prefix.Append(component)
suffix = suffix[1:]
switch {
case component.isKey():
// Expect a map to be set if this is a key.
m, ok := v.AsMap()
if !ok {
return InvalidValue, fmt.Errorf("expected a map to index %q, found %s", prefix, v.Kind())
}
// Lookup current value in the map.
ev, ok := m[component.key]
if !ok && !opts.allowMissingKeyInMap {
return InvalidValue, noSuchKeyError{prefix}
}
// Recursively transform the value.
nv, err := visit(ev, prefix, suffix, opts)
if err != nil {
return InvalidValue, err
}
// Return the original value if the value hasn't changed.
if nv.eq(ev) {
return v, nil
}
// Return an updated map value.
m = maps.Clone(m)
m[component.key] = nv
return Value{
v: m,
k: KindMap,
l: v.l,
}, nil
case component.isIndex():
// Expect a sequence to be set if this is an index.
s, ok := v.AsSequence()
if !ok {
return InvalidValue, fmt.Errorf("expected a sequence to index %q, found %s", prefix, v.Kind())
}
// Lookup current value in the sequence.
if component.index < 0 || component.index >= len(s) {
return InvalidValue, indexOutOfBoundsError{prefix}
}
// Recursively transform the value.
ev := s[component.index]
nv, err := visit(ev, prefix, suffix, opts)
if err != nil {
return InvalidValue, err
}
// Return the original value if the value hasn't changed.
if nv.eq(ev) {
return v, nil
}
// Return an updated sequence value.
s = slices.Clone(s)
s[component.index] = nv
return Value{
v: s,
k: KindSequence,
l: v.l,
}, nil
default:
panic("invalid component")
}
}

25
libs/dyn/visit_get.go Normal file
View File

@ -0,0 +1,25 @@
package dyn
// Get returns the value inside the specified value at the specified path.
// It is identical to [GetByPath], except that it takes a string path instead of a [Path].
func Get(v Value, path string) (Value, error) {
p, err := NewPathFromString(path)
if err != nil {
return InvalidValue, err
}
return GetByPath(v, p)
}
// GetByPath returns the value inside the specified value at the specified path.
// If the path doesn't exist, it returns InvalidValue and an error.
func GetByPath(v Value, p Path) (Value, error) {
out := InvalidValue
_, err := visit(v, EmptyPath, p, visitOptions{
fn: func(ev Value) (Value, error) {
// Capture the value argument to return it.
out = ev
return ev, nil
},
})
return out, err
}

View File

@ -0,0 +1,76 @@
package dyn_test
import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
)
func TestGetWithEmptyPath(t *testing.T) {
// An empty path means to return the value itself.
vin := dyn.V(42)
vout, err := dyn.GetByPath(vin, dyn.NewPath())
assert.NoError(t, err)
assert.Equal(t, vin, vout)
}
func TestGetOnNilValue(t *testing.T) {
var err error
_, err = dyn.GetByPath(dyn.NilValue, dyn.NewPath(dyn.Key("foo")))
assert.ErrorContains(t, err, `expected a map to index "foo", found nil`)
_, err = dyn.GetByPath(dyn.NilValue, dyn.NewPath(dyn.Index(42)))
assert.ErrorContains(t, err, `expected a sequence to index "[42]", found nil`)
}
func TestGetOnMap(t *testing.T) {
vin := dyn.V(map[string]dyn.Value{
"foo": dyn.V(42),
"bar": dyn.V(43),
})
var err error
_, err = dyn.GetByPath(vin, dyn.NewPath(dyn.Index(42)))
assert.ErrorContains(t, err, `expected a sequence to index "[42]", found map`)
_, err = dyn.GetByPath(vin, dyn.NewPath(dyn.Key("baz")))
assert.True(t, dyn.IsNoSuchKeyError(err))
assert.ErrorContains(t, err, `key not found at "baz"`)
vfoo, err := dyn.GetByPath(vin, dyn.NewPath(dyn.Key("foo")))
assert.NoError(t, err)
assert.Equal(t, dyn.V(42), vfoo)
vbar, err := dyn.GetByPath(vin, dyn.NewPath(dyn.Key("bar")))
assert.NoError(t, err)
assert.Equal(t, dyn.V(43), vbar)
}
func TestGetOnSequence(t *testing.T) {
vin := dyn.V([]dyn.Value{
dyn.V(42),
dyn.V(43),
})
var err error
_, err = dyn.GetByPath(vin, dyn.NewPath(dyn.Key("foo")))
assert.ErrorContains(t, err, `expected a map to index "foo", found sequence`)
_, err = dyn.GetByPath(vin, dyn.NewPath(dyn.Index(-1)))
assert.True(t, dyn.IsIndexOutOfBoundsError(err))
assert.ErrorContains(t, err, `index out of bounds at "[-1]"`)
_, err = dyn.GetByPath(vin, dyn.NewPath(dyn.Index(2)))
assert.True(t, dyn.IsIndexOutOfBoundsError(err))
assert.ErrorContains(t, err, `index out of bounds at "[2]"`)
v0, err := dyn.GetByPath(vin, dyn.NewPath(dyn.Index(0)))
assert.NoError(t, err)
assert.Equal(t, dyn.V(42), v0)
v1, err := dyn.GetByPath(vin, dyn.NewPath(dyn.Index(1)))
assert.NoError(t, err)
assert.Equal(t, dyn.V(43), v1)
}

77
libs/dyn/visit_map.go Normal file
View File

@ -0,0 +1,77 @@
package dyn
import (
"fmt"
"maps"
"slices"
)
// MapFunc is a function that maps a value to another value.
type MapFunc func(Value) (Value, error)
// Foreach returns a [MapFunc] that applies the specified [MapFunc] to each
// value in a map or sequence and returns the new map or sequence.
func Foreach(fn MapFunc) MapFunc {
return func(v Value) (Value, error) {
switch v.Kind() {
case KindMap:
m := maps.Clone(v.MustMap())
for key, value := range m {
var err error
m[key], err = fn(value)
if err != nil {
return InvalidValue, err
}
}
return NewValue(m, v.Location()), nil
case KindSequence:
s := slices.Clone(v.MustSequence())
for i, value := range s {
var err error
s[i], err = fn(value)
if err != nil {
return InvalidValue, err
}
}
return NewValue(s, v.Location()), nil
default:
return InvalidValue, fmt.Errorf("expected a map or sequence, found %s", v.Kind())
}
}
}
// Map applies the given function to the value at the specified path in the specified value.
// It is identical to [MapByPath], except that it takes a string path instead of a [Path].
func Map(v Value, path string, fn MapFunc) (Value, error) {
p, err := NewPathFromString(path)
if err != nil {
return InvalidValue, err
}
return MapByPath(v, p, fn)
}
// Map applies the given function to the value at the specified path in the specified value.
// If successful, it returns the new value with all intermediate values copied and updated.
//
// If the path contains a key that doesn't exist, or an index that is out of bounds,
// it returns the original value and no error. This is because setting a value at a path
// that doesn't exist is a no-op.
//
// If the path is invalid for the given value, it returns InvalidValue and an error.
func MapByPath(v Value, p Path, fn MapFunc) (Value, error) {
nv, err := visit(v, EmptyPath, p, visitOptions{
fn: fn,
})
// Check for success.
if err == nil {
return nv, nil
}
// Return original value if a key or index is missing.
if IsNoSuchKeyError(err) || IsIndexOutOfBoundsError(err) {
return v, nil
}
return nv, err
}

202
libs/dyn/visit_map_test.go Normal file
View File

@ -0,0 +1,202 @@
package dyn_test
import (
"fmt"
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMapWithEmptyPath(t *testing.T) {
// An empty path means to return the value itself.
vin := dyn.V(42)
vout, err := dyn.MapByPath(dyn.InvalidValue, dyn.EmptyPath, func(v dyn.Value) (dyn.Value, error) {
return vin, nil
})
assert.NoError(t, err)
assert.Equal(t, vin, vout)
}
func TestMapOnNilValue(t *testing.T) {
var err error
_, err = dyn.MapByPath(dyn.NilValue, dyn.NewPath(dyn.Key("foo")), nil)
assert.ErrorContains(t, err, `expected a map to index "foo", found nil`)
_, err = dyn.MapByPath(dyn.NilValue, dyn.NewPath(dyn.Index(42)), nil)
assert.ErrorContains(t, err, `expected a sequence to index "[42]", found nil`)
}
func TestMapFuncOnMap(t *testing.T) {
vin := dyn.V(map[string]dyn.Value{
"foo": dyn.V(42),
"bar": dyn.V(43),
})
var err error
_, err = dyn.MapByPath(vin, dyn.NewPath(dyn.Index(42)), nil)
assert.ErrorContains(t, err, `expected a sequence to index "[42]", found map`)
// A key that does not exist is not an error.
vout, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Key("baz")), nil)
assert.NoError(t, err)
assert.Equal(t, vin, vout)
// Note: in the test cases below we implicitly test that the original
// value is not modified as we repeatedly set values on it.
vfoo, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Key("foo")), func(v dyn.Value) (dyn.Value, error) {
assert.Equal(t, dyn.V(42), v)
return dyn.V(44), nil
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"foo": 44,
"bar": 43,
}, vfoo.AsAny())
vbar, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Key("bar")), func(v dyn.Value) (dyn.Value, error) {
assert.Equal(t, dyn.V(43), v)
return dyn.V(45), nil
})
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"foo": 42,
"bar": 45,
}, vbar.AsAny())
// Return error from map function.
var ref = fmt.Errorf("error")
verr, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Key("foo")), func(v dyn.Value) (dyn.Value, error) {
return dyn.InvalidValue, ref
})
assert.Equal(t, dyn.InvalidValue, verr)
assert.ErrorIs(t, err, ref)
}
func TestMapFuncOnSequence(t *testing.T) {
vin := dyn.V([]dyn.Value{
dyn.V(42),
dyn.V(43),
})
var err error
_, err = dyn.MapByPath(vin, dyn.NewPath(dyn.Key("foo")), nil)
assert.ErrorContains(t, err, `expected a map to index "foo", found sequence`)
// An index that does not exist is not an error.
vout, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Index(2)), nil)
assert.NoError(t, err)
assert.Equal(t, vin, vout)
// Note: in the test cases below we implicitly test that the original
// value is not modified as we repeatedly set values on it.
v0, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Index(0)), func(v dyn.Value) (dyn.Value, error) {
assert.Equal(t, dyn.V(42), v)
return dyn.V(44), nil
})
assert.NoError(t, err)
assert.Equal(t, []any{44, 43}, v0.AsAny())
v1, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Index(1)), func(v dyn.Value) (dyn.Value, error) {
assert.Equal(t, dyn.V(43), v)
return dyn.V(45), nil
})
assert.NoError(t, err)
assert.Equal(t, []any{42, 45}, v1.AsAny())
// Return error from map function.
var ref = fmt.Errorf("error")
verr, err := dyn.MapByPath(vin, dyn.NewPath(dyn.Index(0)), func(v dyn.Value) (dyn.Value, error) {
return dyn.InvalidValue, ref
})
assert.Equal(t, dyn.InvalidValue, verr)
assert.ErrorIs(t, err, ref)
}
func TestMapForeachOnMap(t *testing.T) {
vin := dyn.V(map[string]dyn.Value{
"foo": dyn.V(42),
"bar": dyn.V(43),
})
var err error
// Run foreach, adding 1 to each of the elements.
vout, err := dyn.Map(vin, ".", dyn.Foreach(func(v dyn.Value) (dyn.Value, error) {
i, ok := v.AsInt()
require.True(t, ok, "expected an integer")
return dyn.V(int(i) + 1), nil
}))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"foo": 43,
"bar": 44,
}, vout.AsAny())
// Check that the original has not been modified.
assert.Equal(t, map[string]any{
"foo": 42,
"bar": 43,
}, vin.AsAny())
}
func TestMapForeachOnMapError(t *testing.T) {
vin := dyn.V(map[string]dyn.Value{
"foo": dyn.V(42),
"bar": dyn.V(43),
})
// Check that an error from the map function propagates.
var ref = fmt.Errorf("error")
_, err := dyn.Map(vin, ".", dyn.Foreach(func(v dyn.Value) (dyn.Value, error) {
return dyn.InvalidValue, ref
}))
assert.ErrorIs(t, err, ref)
}
func TestMapForeachOnSequence(t *testing.T) {
vin := dyn.V([]dyn.Value{
dyn.V(42),
dyn.V(43),
})
var err error
// Run foreach, adding 1 to each of the elements.
vout, err := dyn.Map(vin, ".", dyn.Foreach(func(v dyn.Value) (dyn.Value, error) {
i, ok := v.AsInt()
require.True(t, ok, "expected an integer")
return dyn.V(int(i) + 1), nil
}))
assert.NoError(t, err)
assert.Equal(t, []any{43, 44}, vout.AsAny())
// Check that the original has not been modified.
assert.Equal(t, []any{42, 43}, vin.AsAny())
}
func TestMapForeachOnSequenceError(t *testing.T) {
vin := dyn.V([]dyn.Value{
dyn.V(42),
dyn.V(43),
})
// Check that an error from the map function propagates.
var ref = fmt.Errorf("error")
_, err := dyn.Map(vin, ".", dyn.Foreach(func(v dyn.Value) (dyn.Value, error) {
return dyn.InvalidValue, ref
}))
assert.ErrorIs(t, err, ref)
}
func TestMapForeachOnOtherError(t *testing.T) {
vin := dyn.V(42)
// Check that if foreach is applied to something other than a map or a sequence, it returns an error.
_, err := dyn.Map(vin, ".", dyn.Foreach(func(v dyn.Value) (dyn.Value, error) {
return dyn.InvalidValue, nil
}))
assert.ErrorContains(t, err, "expected a map or sequence, found int")
}

24
libs/dyn/visit_set.go Normal file
View File

@ -0,0 +1,24 @@
package dyn
// Set assigns a new value at the specified path in the specified value.
// It is identical to [SetByPath], except that it takes a string path instead of a [Path].
func Set(v Value, path string, nv Value) (Value, error) {
p, err := NewPathFromString(path)
if err != nil {
return InvalidValue, err
}
return SetByPath(v, p, nv)
}
// SetByPath assigns the given value at the specified path in the specified value.
// If successful, it returns the new value with all intermediate values copied and updated.
// If the path doesn't exist, it returns InvalidValue and an error.
func SetByPath(v Value, p Path, nv Value) (Value, error) {
return visit(v, EmptyPath, p, visitOptions{
fn: func(_ Value) (Value, error) {
// Return the incoming value to set it.
return nv, nil
},
allowMissingKeyInMap: true,
})
}

View File

@ -0,0 +1,90 @@
package dyn_test
import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
)
func TestSetWithEmptyPath(t *testing.T) {
// An empty path means to return the value itself.
vin := dyn.V(42)
vout, err := dyn.SetByPath(dyn.InvalidValue, dyn.EmptyPath, vin)
assert.NoError(t, err)
assert.Equal(t, vin, vout)
}
func TestSetOnNilValue(t *testing.T) {
var err error
_, err = dyn.SetByPath(dyn.NilValue, dyn.NewPath(dyn.Key("foo")), dyn.V(42))
assert.ErrorContains(t, err, `expected a map to index "foo", found nil`)
_, err = dyn.SetByPath(dyn.NilValue, dyn.NewPath(dyn.Index(42)), dyn.V(42))
assert.ErrorContains(t, err, `expected a sequence to index "[42]", found nil`)
}
func TestSetOnMap(t *testing.T) {
vin := dyn.V(map[string]dyn.Value{
"foo": dyn.V(42),
"bar": dyn.V(43),
})
var err error
_, err = dyn.SetByPath(vin, dyn.NewPath(dyn.Index(42)), dyn.V(42))
assert.ErrorContains(t, err, `expected a sequence to index "[42]", found map`)
// Note: in the test cases below we implicitly test that the original
// value is not modified as we repeatedly set values on it.
vfoo, err := dyn.SetByPath(vin, dyn.NewPath(dyn.Key("foo")), dyn.V(44))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"foo": 44,
"bar": 43,
}, vfoo.AsAny())
vbar, err := dyn.SetByPath(vin, dyn.NewPath(dyn.Key("bar")), dyn.V(45))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"foo": 42,
"bar": 45,
}, vbar.AsAny())
vbaz, err := dyn.SetByPath(vin, dyn.NewPath(dyn.Key("baz")), dyn.V(46))
assert.NoError(t, err)
assert.Equal(t, map[string]any{
"foo": 42,
"bar": 43,
"baz": 46,
}, vbaz.AsAny())
}
func TestSetOnSequence(t *testing.T) {
vin := dyn.V([]dyn.Value{
dyn.V(42),
dyn.V(43),
})
var err error
_, err = dyn.SetByPath(vin, dyn.NewPath(dyn.Key("foo")), dyn.V(42))
assert.ErrorContains(t, err, `expected a map to index "foo", found sequence`)
// It is not allowed to set a value at an index that is out of bounds.
_, err = dyn.SetByPath(vin, dyn.NewPath(dyn.Index(-1)), dyn.V(42))
assert.True(t, dyn.IsIndexOutOfBoundsError(err))
_, err = dyn.SetByPath(vin, dyn.NewPath(dyn.Index(2)), dyn.V(42))
assert.True(t, dyn.IsIndexOutOfBoundsError(err))
// Note: in the test cases below we implicitly test that the original
// value is not modified as we repeatedly set values on it.
v0, err := dyn.SetByPath(vin, dyn.NewPath(dyn.Index(0)), dyn.V(44))
assert.NoError(t, err)
assert.Equal(t, []any{44, 43}, v0.AsAny())
v1, err := dyn.SetByPath(vin, dyn.NewPath(dyn.Index(1)), dyn.V(45))
assert.NoError(t, err)
assert.Equal(t, []any{42, 45}, v1.AsAny())
}