diff --git a/libs/dyn/path.go b/libs/dyn/path.go index bfd93dad..34285de1 100644 --- a/libs/dyn/path.go +++ b/libs/dyn/path.go @@ -10,6 +10,14 @@ type pathComponent struct { 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. type Path []pathComponent diff --git a/libs/dyn/value.go b/libs/dyn/value.go index bbb8ad3e..a487e13e 100644 --- a/libs/dyn/value.go +++ b/libs/dyn/value.go @@ -134,3 +134,28 @@ func (v Value) MarkAnchor() Value { func (v Value) IsAnchor() bool { 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 + } +} diff --git a/libs/dyn/visit.go b/libs/dyn/visit.go new file mode 100644 index 00000000..077fd51c --- /dev/null +++ b/libs/dyn/visit.go @@ -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") + } +} diff --git a/libs/dyn/visit_get.go b/libs/dyn/visit_get.go new file mode 100644 index 00000000..a0f848cd --- /dev/null +++ b/libs/dyn/visit_get.go @@ -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 +} diff --git a/libs/dyn/visit_get_test.go b/libs/dyn/visit_get_test.go new file mode 100644 index 00000000..22dce085 --- /dev/null +++ b/libs/dyn/visit_get_test.go @@ -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) +} diff --git a/libs/dyn/visit_map.go b/libs/dyn/visit_map.go new file mode 100644 index 00000000..ed89baa4 --- /dev/null +++ b/libs/dyn/visit_map.go @@ -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 +} diff --git a/libs/dyn/visit_map_test.go b/libs/dyn/visit_map_test.go new file mode 100644 index 00000000..a5af3411 --- /dev/null +++ b/libs/dyn/visit_map_test.go @@ -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") +} diff --git a/libs/dyn/visit_set.go b/libs/dyn/visit_set.go new file mode 100644 index 00000000..fdbf41c2 --- /dev/null +++ b/libs/dyn/visit_set.go @@ -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, + }) +} diff --git a/libs/dyn/visit_set_test.go b/libs/dyn/visit_set_test.go new file mode 100644 index 00000000..b3847158 --- /dev/null +++ b/libs/dyn/visit_set_test.go @@ -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()) +}