Define `dyn.Mapping` to represent maps (#1301)

## Changes

Before this change maps were stored as a regular Go map with string
keys. This didn't let us capture metadata (location information) for map
keys.

To address this, this change replaces the use of the regular Go map with
a dedicated type for a dynamic map. This type stores the `dyn.Value` for
both the key and the value. It uses a map to still allow O(1) lookups
and redirects those into a slice.

## Tests

* All existing unit tests pass (some with minor modifications due to
interface change).
* Equality assertions with `assert.Equal` no longer worked because the
new `dyn.Mapping` persists the order in which keys are set and is
therefore susceptible to map ordering issues. To fix this, I added a
`dynassert` package that forwards all assertions to `testify/assert` but
intercepts equality for `dyn.Value` arguments.
This commit is contained in:
Pieter Noordhuis 2024-03-25 12:01:09 +01:00 committed by GitHub
parent 1efebabbf9
commit 26094f01a0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 680 additions and 127 deletions

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)

View File

@ -71,17 +71,28 @@ func fromTypedStruct(src reflect.Value, ref dyn.Value) (dyn.Value, error) {
return dyn.InvalidValue, fmt.Errorf("unhandled type: %s", ref.Kind())
}
out := make(map[string]dyn.Value)
refm, _ := ref.AsMap()
out := dyn.NewMapping()
info := getStructInfo(src.Type())
for k, v := range info.FieldValues(src) {
pair, ok := refm.GetPairByString(k)
refk := pair.Key
refv := pair.Value
// Use nil reference if there is no reference for this key
if !ok {
refk = dyn.V(k)
refv = dyn.NilValue
}
// Convert the field taking into account the reference value (may be equal to config.NilValue).
nv, err := fromTyped(v.Interface(), ref.Get(k))
nv, err := fromTyped(v.Interface(), refv)
if err != nil {
return dyn.InvalidValue, err
}
if nv != dyn.NilValue {
out[k] = nv
out.Set(refk, nv)
}
}
@ -101,21 +112,31 @@ func fromTypedMap(src reflect.Value, ref dyn.Value) (dyn.Value, error) {
return dyn.NilValue, nil
}
out := make(map[string]dyn.Value)
refm, _ := ref.AsMap()
out := dyn.NewMapping()
iter := src.MapRange()
for iter.Next() {
k := iter.Key().String()
v := iter.Value()
pair, ok := refm.GetPairByString(k)
refk := pair.Key
refv := pair.Value
// Use nil reference if there is no reference for this key
if !ok {
refk = dyn.V(k)
refv = dyn.NilValue
}
// Convert entry taking into account the reference value (may be equal to dyn.NilValue).
nv, err := fromTyped(v.Interface(), ref.Get(k), includeZeroValues)
nv, err := fromTyped(v.Interface(), refv, includeZeroValues)
if err != nil {
return dyn.InvalidValue, err
}
// Every entry is represented, even if it is a nil.
// Otherwise, a map with zero-valued structs would yield a nil as well.
out[k] = nv
out.Set(refk, nv)
}
return dyn.NewValue(out, ref.Location()), nil

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)

View File

@ -74,30 +74,32 @@ func (n normalizeOptions) normalizeStruct(typ reflect.Type, src dyn.Value, seen
switch src.Kind() {
case dyn.KindMap:
out := make(map[string]dyn.Value)
out := dyn.NewMapping()
info := getStructInfo(typ)
for k, v := range src.MustMap() {
index, ok := info.Fields[k]
for _, pair := range src.MustMap().Pairs() {
pk := pair.Key
pv := pair.Value
index, ok := info.Fields[pk.MustString()]
if !ok {
diags = diags.Append(diag.Diagnostic{
Severity: diag.Warning,
Summary: fmt.Sprintf("unknown field: %s", k),
Location: src.Location(),
Summary: fmt.Sprintf("unknown field: %s", pk.MustString()),
Location: pk.Location(),
})
continue
}
// Normalize the value according to the field type.
v, err := n.normalizeType(typ.FieldByIndex(index).Type, v, seen)
nv, err := n.normalizeType(typ.FieldByIndex(index).Type, pv, seen)
if err != nil {
diags = diags.Extend(err)
// Skip the element if it cannot be normalized.
if !v.IsValid() {
if !nv.IsValid() {
continue
}
}
out[k] = v
out.Set(pk, nv)
}
// Return the normalized value if missing fields are not included.
@ -107,7 +109,7 @@ func (n normalizeOptions) normalizeStruct(typ reflect.Type, src dyn.Value, seen
// Populate missing fields with their zero values.
for k, index := range info.Fields {
if _, ok := out[k]; ok {
if _, ok := out.GetByString(k); ok {
continue
}
@ -143,7 +145,7 @@ func (n normalizeOptions) normalizeStruct(typ reflect.Type, src dyn.Value, seen
continue
}
if v.IsValid() {
out[k] = v
out.Set(dyn.V(k), v)
}
}
@ -160,19 +162,22 @@ func (n normalizeOptions) normalizeMap(typ reflect.Type, src dyn.Value, seen []r
switch src.Kind() {
case dyn.KindMap:
out := make(map[string]dyn.Value)
for k, v := range src.MustMap() {
out := dyn.NewMapping()
for _, pair := range src.MustMap().Pairs() {
pk := pair.Key
pv := pair.Value
// Normalize the value according to the map element type.
v, err := n.normalizeType(typ.Elem(), v, seen)
nv, err := n.normalizeType(typ.Elem(), pv, seen)
if err != nil {
diags = diags.Extend(err)
// Skip the element if it cannot be normalized.
if !v.IsValid() {
if !nv.IsValid() {
continue
}
}
out[k] = v
out.Set(pk, nv)
}
return dyn.NewValue(out, src.Location()), diags

View File

@ -5,7 +5,7 @@ import (
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestNormalizeStruct(t *testing.T) {

View File

@ -5,7 +5,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestStructInfoPlain(t *testing.T) {

View File

@ -59,8 +59,11 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error {
dst.SetZero()
info := getStructInfo(dst.Type())
for k, v := range src.MustMap() {
index, ok := info.Fields[k]
for _, pair := range src.MustMap().Pairs() {
pk := pair.Key
pv := pair.Value
index, ok := info.Fields[pk.MustString()]
if !ok {
// Ignore unknown fields.
// A warning will be printed later. See PR #904.
@ -82,7 +85,7 @@ func toTypedStruct(dst reflect.Value, src dyn.Value) error {
f = f.Field(x)
}
err := ToTyped(f.Addr().Interface(), v)
err := ToTyped(f.Addr().Interface(), pv)
if err != nil {
return err
}
@ -112,12 +115,14 @@ func toTypedMap(dst reflect.Value, src dyn.Value) error {
m := src.MustMap()
// Always overwrite.
dst.Set(reflect.MakeMapWithSize(dst.Type(), len(m)))
for k, v := range m {
kv := reflect.ValueOf(k)
dst.Set(reflect.MakeMapWithSize(dst.Type(), m.Len()))
for _, pair := range m.Pairs() {
pk := pair.Key
pv := pair.Value
kv := reflect.ValueOf(pk.MustString())
kt := dst.Type().Key()
vv := reflect.New(dst.Type().Elem())
err := ToTyped(vv.Interface(), v)
err := ToTyped(vv.Interface(), pv)
if err != nil {
return err
}

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)

View File

@ -0,0 +1,113 @@
package dynassert
import (
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
)
func Equal(t assert.TestingT, expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool {
ev, eok := expected.(dyn.Value)
av, aok := actual.(dyn.Value)
if eok && aok && ev.IsValid() && av.IsValid() {
if !assert.Equal(t, ev.AsAny(), av.AsAny(), msgAndArgs...) {
return false
}
// The values are equal on contents. Now compare the locations.
if !assert.Equal(t, ev.Location(), av.Location(), msgAndArgs...) {
return false
}
// Walk ev and av and compare the locations of each element.
_, err := dyn.Walk(ev, func(p dyn.Path, evv dyn.Value) (dyn.Value, error) {
avv, err := dyn.GetByPath(av, p)
if assert.NoError(t, err, "unable to get value from actual value at path %v", p.String()) {
assert.Equal(t, evv.Location(), avv.Location())
}
return evv, nil
})
return assert.NoError(t, err)
}
return assert.Equal(t, expected, actual, msgAndArgs...)
}
func EqualValues(t assert.TestingT, expected, actual interface{}, msgAndArgs ...interface{}) bool {
return assert.EqualValues(t, expected, actual, msgAndArgs...)
}
func NotEqual(t assert.TestingT, expected interface{}, actual interface{}, msgAndArgs ...interface{}) bool {
return assert.NotEqual(t, expected, actual, msgAndArgs...)
}
func Len(t assert.TestingT, object interface{}, length int, msgAndArgs ...interface{}) bool {
return assert.Len(t, object, length, msgAndArgs...)
}
func Empty(t assert.TestingT, object interface{}, msgAndArgs ...interface{}) bool {
return assert.Empty(t, object, msgAndArgs...)
}
func Nil(t assert.TestingT, object interface{}, msgAndArgs ...interface{}) bool {
return assert.Nil(t, object, msgAndArgs...)
}
func NotNil(t assert.TestingT, object interface{}, msgAndArgs ...interface{}) bool {
return assert.NotNil(t, object, msgAndArgs...)
}
func NoError(t assert.TestingT, err error, msgAndArgs ...interface{}) bool {
return assert.NoError(t, err, msgAndArgs...)
}
func Error(t assert.TestingT, err error, msgAndArgs ...interface{}) bool {
return assert.Error(t, err, msgAndArgs...)
}
func EqualError(t assert.TestingT, theError error, errString string, msgAndArgs ...interface{}) bool {
return assert.EqualError(t, theError, errString, msgAndArgs...)
}
func ErrorContains(t assert.TestingT, theError error, contains string, msgAndArgs ...interface{}) bool {
return assert.ErrorContains(t, theError, contains, msgAndArgs...)
}
func ErrorIs(t assert.TestingT, theError, target error, msgAndArgs ...interface{}) bool {
return assert.ErrorIs(t, theError, target, msgAndArgs...)
}
func True(t assert.TestingT, value bool, msgAndArgs ...interface{}) bool {
return assert.True(t, value, msgAndArgs...)
}
func False(t assert.TestingT, value bool, msgAndArgs ...interface{}) bool {
return assert.False(t, value, msgAndArgs...)
}
func Contains(t assert.TestingT, list interface{}, element interface{}, msgAndArgs ...interface{}) bool {
return assert.Contains(t, list, element, msgAndArgs...)
}
func NotContains(t assert.TestingT, list interface{}, element interface{}, msgAndArgs ...interface{}) bool {
return assert.NotContains(t, list, element, msgAndArgs...)
}
func ElementsMatch(t assert.TestingT, listA, listB interface{}, msgAndArgs ...interface{}) bool {
return assert.ElementsMatch(t, listA, listB, msgAndArgs...)
}
func Panics(t assert.TestingT, f func(), msgAndArgs ...interface{}) bool {
return assert.Panics(t, f, msgAndArgs...)
}
func PanicsWithValue(t assert.TestingT, expected interface{}, f func(), msgAndArgs ...interface{}) bool {
return assert.PanicsWithValue(t, expected, f, msgAndArgs...)
}
func PanicsWithError(t assert.TestingT, errString string, f func(), msgAndArgs ...interface{}) bool {
return assert.PanicsWithError(t, errString, f, msgAndArgs...)
}
func NotPanics(t assert.TestingT, f func(), msgAndArgs ...interface{}) bool {
return assert.NotPanics(t, f, msgAndArgs...)
}

View File

@ -0,0 +1,45 @@
package dynassert
import (
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestThatThisTestPackageIsUsed(t *testing.T) {
var base = ".."
var files []string
err := fs.WalkDir(os.DirFS(base), ".", func(path string, d fs.DirEntry, err error) error {
if d.IsDir() {
// Filter this directory.
if filepath.Base(path) == "dynassert" {
return fs.SkipDir
}
}
if ok, _ := filepath.Match("*_test.go", d.Name()); ok {
files = append(files, filepath.Join(base, path))
}
return nil
})
require.NoError(t, err)
// Confirm that none of the test files under `libs/dyn` import the
// `testify/assert` package and instead import this package for asserts.
fset := token.NewFileSet()
for _, file := range files {
f, err := parser.ParseFile(fset, file, nil, parser.ParseComments)
require.NoError(t, err)
for _, imp := range f.Imports {
if strings.Contains(imp.Path.Value, `github.com/stretchr/testify/assert`) {
t.Errorf("File %s should not import github.com/stretchr/testify/assert", file)
}
}
}
}

View File

@ -4,8 +4,8 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/databricks/cli/libs/dyn/dynvar"
"github.com/stretchr/testify/assert"
)
func TestDefaultLookup(t *testing.T) {

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)

View File

@ -4,8 +4,8 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/databricks/cli/libs/dyn/dynvar"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

View File

@ -22,7 +22,7 @@ const (
func kindOf(v any) Kind {
switch v.(type) {
case map[string]Value:
case Mapping:
return KindMap
case []Value:
return KindSequence

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestKindZeroValue(t *testing.T) {

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestLocation(t *testing.T) {

148
libs/dyn/mapping.go Normal file
View File

@ -0,0 +1,148 @@
package dyn
import (
"fmt"
"maps"
"slices"
)
// Pair represents a single key-value pair in a Mapping.
type Pair struct {
Key Value
Value Value
}
// Mapping represents a key-value map of dynamic values.
// It exists because plain Go maps cannot use dynamic values for keys.
// We need to use dynamic values for keys because it lets us associate metadata
// with keys (i.e. their definition location). Keys must be strings.
type Mapping struct {
pairs []Pair
index map[string]int
}
// NewMapping creates a new empty Mapping.
func NewMapping() Mapping {
return Mapping{
pairs: make([]Pair, 0),
index: make(map[string]int),
}
}
// newMappingWithSize creates a new Mapping preallocated to the specified size.
func newMappingWithSize(size int) Mapping {
return Mapping{
pairs: make([]Pair, 0, size),
index: make(map[string]int, size),
}
}
// newMappingFromGoMap creates a new Mapping from a Go map of string keys and dynamic values.
func newMappingFromGoMap(vin map[string]Value) Mapping {
m := newMappingWithSize(len(vin))
for k, v := range vin {
m.Set(V(k), v)
}
return m
}
// Pairs returns all the key-value pairs in the Mapping.
func (m Mapping) Pairs() []Pair {
return m.pairs
}
// Len returns the number of key-value pairs in the Mapping.
func (m Mapping) Len() int {
return len(m.pairs)
}
// GetPair returns the key-value pair with the specified key.
// It also returns a boolean indicating whether the pair was found.
func (m Mapping) GetPair(key Value) (Pair, bool) {
skey, ok := key.AsString()
if !ok {
return Pair{}, false
}
return m.GetPairByString(skey)
}
// GetPairByString returns the key-value pair with the specified string key.
// It also returns a boolean indicating whether the pair was found.
func (m Mapping) GetPairByString(skey string) (Pair, bool) {
if i, ok := m.index[skey]; ok {
return m.pairs[i], true
}
return Pair{}, false
}
// Get returns the value associated with the specified key.
// It also returns a boolean indicating whether the value was found.
func (m Mapping) Get(key Value) (Value, bool) {
p, ok := m.GetPair(key)
return p.Value, ok
}
// GetByString returns the value associated with the specified string key.
// It also returns a boolean indicating whether the value was found.
func (m *Mapping) GetByString(skey string) (Value, bool) {
p, ok := m.GetPairByString(skey)
return p.Value, ok
}
// Set sets the value for the given key in the mapping.
// If the key already exists, the value is updated.
// If the key does not exist, a new key-value pair is added.
// The key must be a string, otherwise an error is returned.
func (m *Mapping) Set(key Value, value Value) error {
skey, ok := key.AsString()
if !ok {
return fmt.Errorf("key must be a string, got %s", key.Kind())
}
// If the key already exists, update the value.
if i, ok := m.index[skey]; ok {
m.pairs[i].Value = value
return nil
}
// Otherwise, add a new pair.
m.pairs = append(m.pairs, Pair{key, value})
if m.index == nil {
m.index = make(map[string]int)
}
m.index[skey] = len(m.pairs) - 1
return nil
}
// Keys returns all the keys in the Mapping.
func (m Mapping) Keys() []Value {
keys := make([]Value, 0, len(m.pairs))
for _, p := range m.pairs {
keys = append(keys, p.Key)
}
return keys
}
// Values returns all the values in the Mapping.
func (m Mapping) Values() []Value {
values := make([]Value, 0, len(m.pairs))
for _, p := range m.pairs {
values = append(values, p.Value)
}
return values
}
// Clone creates a shallow copy of the Mapping.
func (m Mapping) Clone() Mapping {
return Mapping{
pairs: slices.Clone(m.pairs),
index: maps.Clone(m.index),
}
}
// Merge merges the key-value pairs from another Mapping into the current Mapping.
func (m *Mapping) Merge(n Mapping) {
for _, p := range n.pairs {
m.Set(p.Key, p.Value)
}
}

204
libs/dyn/mapping_test.go Normal file
View File

@ -0,0 +1,204 @@
package dyn_test
import (
"fmt"
"testing"
"github.com/databricks/cli/libs/dyn"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)
func TestNewMapping(t *testing.T) {
m := dyn.NewMapping()
assert.Equal(t, 0, m.Len())
}
func TestMappingZeroValue(t *testing.T) {
var m dyn.Mapping
assert.Equal(t, 0, m.Len())
value, ok := m.Get(dyn.V("key"))
assert.Equal(t, dyn.InvalidValue, value)
assert.False(t, ok)
assert.Len(t, m.Keys(), 0)
assert.Len(t, m.Values(), 0)
}
func TestMappingGet(t *testing.T) {
var m dyn.Mapping
err := m.Set(dyn.V("key"), dyn.V("value"))
assert.NoError(t, err)
assert.Equal(t, 1, m.Len())
// Call GetPair
p, ok := m.GetPair(dyn.V("key"))
assert.True(t, ok)
assert.Equal(t, dyn.V("key"), p.Key)
assert.Equal(t, dyn.V("value"), p.Value)
// Modify the value to make sure we're not getting a reference
p.Value = dyn.V("newvalue")
// Call GetPair with invalid key
p, ok = m.GetPair(dyn.V(1234))
assert.False(t, ok)
assert.Equal(t, dyn.InvalidValue, p.Key)
assert.Equal(t, dyn.InvalidValue, p.Value)
// Call GetPair with non-existent key
p, ok = m.GetPair(dyn.V("enoexist"))
assert.False(t, ok)
assert.Equal(t, dyn.InvalidValue, p.Key)
assert.Equal(t, dyn.InvalidValue, p.Value)
// Call GetPairByString
p, ok = m.GetPairByString("key")
assert.True(t, ok)
assert.Equal(t, dyn.V("key"), p.Key)
assert.Equal(t, dyn.V("value"), p.Value)
// Modify the value to make sure we're not getting a reference
p.Value = dyn.V("newvalue")
// Call GetPairByString with with non-existent key
p, ok = m.GetPairByString("enoexist")
assert.False(t, ok)
assert.Equal(t, dyn.InvalidValue, p.Key)
assert.Equal(t, dyn.InvalidValue, p.Value)
// Call Get
value, ok := m.Get(dyn.V("key"))
assert.True(t, ok)
assert.Equal(t, dyn.V("value"), value)
// Call Get with invalid key
value, ok = m.Get(dyn.V(1234))
assert.False(t, ok)
assert.Equal(t, dyn.InvalidValue, value)
// Call Get with non-existent key
value, ok = m.Get(dyn.V("enoexist"))
assert.False(t, ok)
assert.Equal(t, dyn.InvalidValue, value)
// Call GetByString
value, ok = m.GetByString("key")
assert.True(t, ok)
assert.Equal(t, dyn.V("value"), value)
// Call GetByString with non-existent key
value, ok = m.GetByString("enoexist")
assert.False(t, ok)
assert.Equal(t, dyn.InvalidValue, value)
}
func TestMappingSet(t *testing.T) {
var err error
var m dyn.Mapping
// Set a value
err = m.Set(dyn.V("key1"), dyn.V("foo"))
assert.NoError(t, err)
assert.Equal(t, 1, m.Len())
// Confirm the value
value, ok := m.GetByString("key1")
assert.True(t, ok)
assert.Equal(t, dyn.V("foo"), value)
// Set another value
err = m.Set(dyn.V("key2"), dyn.V("bar"))
assert.NoError(t, err)
assert.Equal(t, 2, m.Len())
// Confirm the value
value, ok = m.Get(dyn.V("key2"))
assert.True(t, ok)
assert.Equal(t, dyn.V("bar"), value)
// Overwrite first value
err = m.Set(dyn.V("key1"), dyn.V("qux"))
assert.NoError(t, err)
assert.Equal(t, 2, m.Len())
// Confirm the value
value, ok = m.Get(dyn.V("key1"))
assert.True(t, ok)
assert.Equal(t, dyn.V("qux"), value)
// Try to set non-string key
err = m.Set(dyn.V(1), dyn.V("qux"))
assert.Error(t, err)
assert.Equal(t, 2, m.Len())
}
func TestMappingKeysValues(t *testing.T) {
var err error
// Configure mapping
var m dyn.Mapping
err = m.Set(dyn.V("key1"), dyn.V("foo"))
assert.NoError(t, err)
err = m.Set(dyn.V("key2"), dyn.V("bar"))
assert.NoError(t, err)
// Confirm keys
keys := m.Keys()
assert.Len(t, keys, 2)
assert.Contains(t, keys, dyn.V("key1"))
assert.Contains(t, keys, dyn.V("key2"))
// Confirm values
values := m.Values()
assert.Len(t, values, 2)
assert.Contains(t, values, dyn.V("foo"))
assert.Contains(t, values, dyn.V("bar"))
}
func TestMappingClone(t *testing.T) {
var err error
// Configure mapping
var m1 dyn.Mapping
err = m1.Set(dyn.V("key1"), dyn.V("foo"))
assert.NoError(t, err)
err = m1.Set(dyn.V("key2"), dyn.V("bar"))
assert.NoError(t, err)
// Clone mapping
m2 := m1.Clone()
assert.Equal(t, m1.Len(), m2.Len())
// Modify original mapping
err = m1.Set(dyn.V("key1"), dyn.V("qux"))
assert.NoError(t, err)
// Confirm values
value, ok := m1.Get(dyn.V("key1"))
assert.True(t, ok)
assert.Equal(t, dyn.V("qux"), value)
value, ok = m2.Get(dyn.V("key1"))
assert.True(t, ok)
assert.Equal(t, dyn.V("foo"), value)
}
func TestMappingMerge(t *testing.T) {
var m1 dyn.Mapping
for i := 0; i < 10; i++ {
err := m1.Set(dyn.V(fmt.Sprintf("%d", i)), dyn.V(i))
require.NoError(t, err)
}
var m2 dyn.Mapping
for i := 5; i < 15; i++ {
err := m2.Set(dyn.V(fmt.Sprintf("%d", i)), dyn.V(i))
require.NoError(t, err)
}
var out dyn.Mapping
out.Merge(m1)
assert.Equal(t, 10, out.Len())
out.Merge(m2)
assert.Equal(t, 15, out.Len())
}

View File

@ -5,7 +5,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)

View File

@ -51,27 +51,27 @@ func merge(a, b dyn.Value) (dyn.Value, error) {
}
func mergeMap(a, b dyn.Value) (dyn.Value, error) {
out := make(map[string]dyn.Value)
out := dyn.NewMapping()
am := a.MustMap()
bm := b.MustMap()
// Add the values from a into the output map.
for k, v := range am {
out[k] = v
}
out.Merge(am)
// Merge the values from b into the output map.
for k, v := range bm {
if _, ok := out[k]; ok {
for _, pair := range bm.Pairs() {
pk := pair.Key
pv := pair.Value
if ov, ok := out.Get(pk); ok {
// If the key already exists, merge the values.
merged, err := merge(out[k], v)
merged, err := merge(ov, pv)
if err != nil {
return dyn.NilValue, err
}
out[k] = merged
out.Set(pk, merged)
} else {
// Otherwise, just set the value.
out[k] = v
out.Set(pk, pv)
}
}

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestMergeMaps(t *testing.T) {

View File

@ -5,7 +5,7 @@ import (
"testing"
. "github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestNewPathFromString(t *testing.T) {

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestPathAppend(t *testing.T) {

View File

@ -2,7 +2,6 @@ package dyn
import (
"fmt"
"maps"
"slices"
)
@ -55,10 +54,13 @@ func (c anyKeyComponent) visit(v Value, prefix Path, suffix Pattern, opts visitO
return InvalidValue, fmt.Errorf("expected a map at %q, found %s", prefix, v.Kind())
}
m = maps.Clone(m)
for key, value := range m {
m = m.Clone()
for _, pair := range m.Pairs() {
pk := pair.Key
pv := pair.Value
var err error
nv, err := visit(value, append(prefix, Key(key)), suffix, opts)
nv, err := visit(pv, append(prefix, Key(pk.MustString())), suffix, opts)
if err != nil {
// Leave the value intact if the suffix pattern didn't match any value.
if IsNoSuchKeyError(err) || IsIndexOutOfBoundsError(err) {
@ -66,7 +68,8 @@ func (c anyKeyComponent) visit(v Value, prefix Path, suffix Pattern, opts visitO
}
return InvalidValue, err
}
m[key] = nv
m.Set(pk, nv)
}
return NewValue(m, v.Location()), nil

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestNewPattern(t *testing.T) {

View File

@ -27,14 +27,16 @@ var NilValue = Value{
// V constructs a new Value with the given value.
func V(v any) Value {
return Value{
v: v,
k: kindOf(v),
}
return NewValue(v, Location{})
}
// NewValue constructs a new Value with the given value and location.
func NewValue(v any, loc Location) Value {
switch vin := v.(type) {
case map[string]Value:
v = newMappingFromGoMap(vin)
}
return Value{
v: v,
k: kindOf(v),
@ -72,12 +74,14 @@ func (v Value) AsAny() any {
case KindInvalid:
panic("invoked AsAny on invalid value")
case KindMap:
vv := v.v.(map[string]Value)
m := make(map[string]any, len(vv))
for k, v := range vv {
m[k] = v.AsAny()
m := v.v.(Mapping)
out := make(map[string]any, m.Len())
for _, pair := range m.pairs {
pk := pair.Key
pv := pair.Value
out[pk.MustString()] = pv.AsAny()
}
return m
return out
case KindSequence:
vv := v.v.([]Value)
a := make([]any, len(vv))
@ -109,7 +113,7 @@ func (v Value) Get(key string) Value {
return NilValue
}
vv, ok := m[key]
vv, ok := m.GetByString(key)
if !ok {
return NilValue
}

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestInvalidValue(t *testing.T) {
@ -22,14 +22,12 @@ func TestValueIsAnchor(t *testing.T) {
func TestValueAsMap(t *testing.T) {
var zeroValue dyn.Value
m, ok := zeroValue.AsMap()
_, ok := zeroValue.AsMap()
assert.False(t, ok)
assert.Nil(t, m)
var intValue = dyn.NewValue(1, dyn.Location{})
m, ok = intValue.AsMap()
_, ok = intValue.AsMap()
assert.False(t, ok)
assert.Nil(t, m)
var mapValue = dyn.NewValue(
map[string]dyn.Value{
@ -37,9 +35,9 @@ func TestValueAsMap(t *testing.T) {
},
dyn.Location{File: "file", Line: 1, Column: 2},
)
m, ok = mapValue.AsMap()
m, ok := mapValue.AsMap()
assert.True(t, ok)
assert.Len(t, m, 1)
assert.Equal(t, 1, m.Len())
}
func TestValueIsValid(t *testing.T) {

View File

@ -5,16 +5,16 @@ import (
"time"
)
// AsMap returns the underlying map if this value is a map,
// AsMap returns the underlying mapping if this value is a map,
// the zero value and false otherwise.
func (v Value) AsMap() (map[string]Value, bool) {
vv, ok := v.v.(map[string]Value)
func (v Value) AsMap() (Mapping, bool) {
vv, ok := v.v.(Mapping)
return vv, ok
}
// MustMap returns the underlying map if this value is a map,
// MustMap returns the underlying mapping if this value is a map,
// panics otherwise.
func (v Value) MustMap() map[string]Value {
func (v Value) MustMap() Mapping {
vv, ok := v.AsMap()
if !ok || v.k != KindMap {
panic(fmt.Sprintf("expected kind %s, got %s", KindMap, v.k))

View File

@ -5,7 +5,7 @@ import (
"time"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestValueUnderlyingMap(t *testing.T) {

View File

@ -3,7 +3,6 @@ package dyn
import (
"errors"
"fmt"
"maps"
"slices"
)
@ -77,7 +76,7 @@ func (component pathComponent) visit(v Value, prefix Path, suffix Pattern, opts
}
// Lookup current value in the map.
ev, ok := m[component.key]
ev, ok := m.GetByString(component.key)
if !ok {
return InvalidValue, noSuchKeyError{path}
}
@ -94,8 +93,8 @@ func (component pathComponent) visit(v Value, prefix Path, suffix Pattern, opts
}
// Return an updated map value.
m = maps.Clone(m)
m[component.key] = nv
m = m.Clone()
m.Set(V(component.key), nv)
return Value{
v: m,
k: KindMap,

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestGetWithEmptyPath(t *testing.T) {

View File

@ -2,7 +2,6 @@ package dyn
import (
"fmt"
"maps"
"slices"
)
@ -15,13 +14,15 @@ func Foreach(fn MapFunc) MapFunc {
return func(p Path, 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(append(p, Key(key)), value)
m := v.MustMap().Clone()
for _, pair := range m.Pairs() {
pk := pair.Key
pv := pair.Value
nv, err := fn(append(p, Key(pk.MustString())), pv)
if err != nil {
return InvalidValue, err
}
m.Set(pk, nv)
}
return NewValue(m, v.Location()), nil
case KindSequence:

View File

@ -5,7 +5,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)

View File

@ -2,7 +2,6 @@ package dyn
import (
"fmt"
"maps"
"slices"
)
@ -41,8 +40,8 @@ func SetByPath(v Value, p Path, nv Value) (Value, error) {
}
// Return an updated map value.
m = maps.Clone(m)
m[component.key] = nv
m = m.Clone()
m.Set(V(component.key), nv)
return Value{
v: m,
k: KindMap,

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestSetWithEmptyPath(t *testing.T) {

View File

@ -34,16 +34,18 @@ func walk(v Value, p Path, fn func(p Path, v Value) (Value, error)) (Value, erro
switch v.Kind() {
case KindMap:
m := v.MustMap()
out := make(map[string]Value, len(m))
for k := range m {
nv, err := walk(m[k], append(p, Key(k)), fn)
out := newMappingWithSize(m.Len())
for _, pair := range m.Pairs() {
pk := pair.Key
pv := pair.Value
nv, err := walk(pv, append(p, Key(pk.MustString())), fn)
if err == ErrDrop {
continue
}
if err != nil {
return NilValue, err
}
out[k] = nv
out.Set(pk, nv)
}
v.v = out
case KindSequence:

View File

@ -5,7 +5,7 @@ import (
"testing"
. "github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/stretchr/testify/require"
)

View File

@ -92,7 +92,7 @@ func (d *loader) loadSequence(node *yaml.Node, loc dyn.Location) (dyn.Value, err
func (d *loader) loadMapping(node *yaml.Node, loc dyn.Location) (dyn.Value, error) {
var merge *yaml.Node
acc := make(map[string]dyn.Value)
acc := dyn.NewMapping()
for i := 0; i < len(node.Content); i += 2 {
key := node.Content[i]
val := node.Content[i+1]
@ -116,12 +116,17 @@ func (d *loader) loadMapping(node *yaml.Node, loc dyn.Location) (dyn.Value, erro
return dyn.NilValue, errorf(loc, "invalid key tag: %v", st)
}
k, err := d.load(key)
if err != nil {
return dyn.NilValue, err
}
v, err := d.load(val)
if err != nil {
return dyn.NilValue, err
}
acc[key.Value] = v
acc.Set(k, v)
}
if merge == nil {
@ -146,7 +151,7 @@ func (d *loader) loadMapping(node *yaml.Node, loc dyn.Location) (dyn.Value, erro
// Build a sequence of values to merge.
// The entries that we already accumulated have precedence.
var seq []map[string]dyn.Value
var seq []dyn.Mapping
for _, n := range mnodes {
v, err := d.load(n)
if err != nil {
@ -161,11 +166,9 @@ func (d *loader) loadMapping(node *yaml.Node, loc dyn.Location) (dyn.Value, erro
// Append the accumulated entries to the sequence.
seq = append(seq, acc)
out := make(map[string]dyn.Value)
out := dyn.NewMapping()
for _, m := range seq {
for k, v := range m {
out[k] = v
}
out.Merge(m)
}
return dyn.NewValue(out, loc), nil

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestYAMLAnchor01(t *testing.T) {

View File

@ -5,8 +5,8 @@ import (
"os"
"testing"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/databricks/cli/libs/dyn/yamlloader"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestYAMLMix01(t *testing.T) {

View File

@ -6,8 +6,8 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"github.com/databricks/cli/libs/dyn/yamlloader"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gopkg.in/yaml.v3"
)

View File

@ -3,7 +3,7 @@ package yamlsaver
import (
"testing"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestOrderReturnsIncreasingIndex(t *testing.T) {

View File

@ -9,7 +9,6 @@ import (
"strconv"
"github.com/databricks/cli/libs/dyn"
"golang.org/x/exp/maps"
"gopkg.in/yaml.v3"
)
@ -75,25 +74,27 @@ func (s *saver) toYamlNodeWithStyle(v dyn.Value, style yaml.Style) (*yaml.Node,
switch v.Kind() {
case dyn.KindMap:
m, _ := v.AsMap()
keys := maps.Keys(m)
// We're using location lines to define the order of keys in YAML.
// The location is set when we convert API response struct to config.Value representation
// See convert.convertMap for details
sort.SliceStable(keys, func(i, j int) bool {
return m[keys[i]].Location().Line < m[keys[j]].Location().Line
pairs := m.Pairs()
sort.SliceStable(pairs, func(i, j int) bool {
return pairs[i].Value.Location().Line < pairs[j].Value.Location().Line
})
content := make([]*yaml.Node, 0)
for _, k := range keys {
item := m[k]
node := yaml.Node{Kind: yaml.ScalarNode, Value: k, Style: style}
for _, pair := range pairs {
pk := pair.Key
pv := pair.Value
node := yaml.Node{Kind: yaml.ScalarNode, Value: pk.MustString(), Style: style}
var nestedNodeStyle yaml.Style
if customStyle, ok := s.hasStyle(k); ok {
if customStyle, ok := s.hasStyle(pk.MustString()); ok {
nestedNodeStyle = customStyle
} else {
nestedNodeStyle = style
}
c, err := s.toYamlNodeWithStyle(item, nestedNodeStyle)
c, err := s.toYamlNodeWithStyle(pv, nestedNodeStyle)
if err != nil {
return nil, err
}

View File

@ -5,7 +5,7 @@ import (
"time"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
"gopkg.in/yaml.v3"
)

View File

@ -26,7 +26,9 @@ func ConvertToMapValue(strct any, order *Order, skipFields []string, dst map[str
}
func skipAndOrder(mv dyn.Value, order *Order, skipFields []string, dst map[string]dyn.Value) (dyn.Value, error) {
for k, v := range mv.MustMap() {
for _, pair := range mv.MustMap().Pairs() {
k := pair.Key.MustString()
v := pair.Value
if v.Kind() == dyn.KindNil {
continue
}

View File

@ -4,7 +4,7 @@ import (
"testing"
"github.com/databricks/cli/libs/dyn"
"github.com/stretchr/testify/assert"
assert "github.com/databricks/cli/libs/dyn/dynassert"
)
func TestConvertToMapValueWithOrder(t *testing.T) {
@ -32,7 +32,7 @@ func TestConvertToMapValueWithOrder(t *testing.T) {
result, err := ConvertToMapValue(v, NewOrder([]string{"list", "name", "map"}), []string{"format"}, map[string]dyn.Value{})
assert.NoError(t, err)
assert.Equal(t, map[string]dyn.Value{
assert.Equal(t, dyn.V(map[string]dyn.Value{
"list": dyn.NewValue([]dyn.Value{
dyn.V("a"),
dyn.V("b"),
@ -44,5 +44,5 @@ func TestConvertToMapValueWithOrder(t *testing.T) {
"key2": dyn.V("value2"),
}, dyn.Location{Line: -1}),
"long_name_field": dyn.NewValue("long name goes here", dyn.Location{Line: 1}),
}, result.MustMap())
}), result)
}