Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bundle/config/mutator/log_resource_references.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/dyn/dynvar"
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/structs/dynpath"
"github.com/databricks/cli/libs/structs/structaccess"
)

Expand Down Expand Up @@ -115,7 +116,7 @@ func truncate(s string, n int, suffix string) string {
}

func censorValue(ctx context.Context, v any, path dyn.Path) (string, error) {
v, err := structaccess.Get(v, path)
v, err := structaccess.Get(v, dynpath.ConvertDynPathToPathNode(path))
if err != nil {
log.Infof(ctx, "internal error: path=%s: %s", path, err)
return "err", err
Expand Down
9 changes: 3 additions & 6 deletions bundle/direct/dresources/all_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import (

"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/bundle/deployplan"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/structs/dynpath"
"github.com/databricks/cli/libs/structs/structaccess"
"github.com/databricks/cli/libs/structs/structpath"
"github.com/databricks/cli/libs/structs/structwalk"
Expand Down Expand Up @@ -156,10 +154,9 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W
require.NotNil(t, remappedState)

require.NoError(t, structwalk.Walk(newState, func(path *structpath.PathNode, val any, field *reflect.StructField) {
dynPath := dynpath.ConvertPathNodeToDynPath(path, reflect.TypeOf(newState))
remoteValue, err := structaccess.Get(remappedState, dyn.MustPathFromString(dynPath))
remoteValue, err := structaccess.Get(remappedState, path)
if err != nil {
t.Errorf("Failed to read %s from remapped remote state %#v", dynPath, remappedState)
t.Errorf("Failed to read %s from remapped remote state %#v", path.String(), remappedState)
}
if val == nil {
// t.Logf("Ignoring %s nil, remoteValue=%#v", path.String(), remoteValue)
Expand All @@ -174,7 +171,7 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W
// t.Logf("Testing %s v=%#v, remoteValue=%#v", path.String(), val, remoteValue)
// We expect fields set explicitly to be preserved by testserver, which is true for all resources as of today.
// If not true for your resource, add exception here:
assert.Equal(t, val, remoteValue, dynPath)
assert.Equal(t, val, remoteValue, path.String())
}))

err = adapter.DoDelete(ctx, createdID)
Expand Down
17 changes: 8 additions & 9 deletions bundle/direct/plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,12 @@ import (
"github.com/databricks/cli/bundle/deployplan"
"github.com/databricks/cli/bundle/direct/dresources"
"github.com/databricks/cli/bundle/direct/dstate"
"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/structs/structdiff"
"github.com/databricks/databricks-sdk-go"

"github.com/databricks/cli/libs/dyn/dynvar"
"github.com/databricks/cli/libs/log"
"github.com/databricks/cli/libs/structs/structaccess"
"github.com/databricks/cli/libs/structs/structpath"
)

func (d *DeploymentUnit) Plan(ctx context.Context, client *databricks.WorkspaceClient, db *dstate.DeploymentState, inputConfig any, localOnly, refresh bool) (deployplan.ActionType, error) {
Expand Down Expand Up @@ -68,12 +67,12 @@ func (d *DeploymentUnit) refreshRemoteState(ctx context.Context, id string) erro
var ErrDelayed = errors.New("must be resolved after apply")

func (d *DeploymentUnit) ResolveReferenceLocalOrRemote(ctx context.Context, db *dstate.DeploymentState, reference string, actionType deployplan.ActionType, config any) (any, error) {
path, ok := dynvar.PureReferenceToPath(reference)
if !ok || len(path) <= 3 || path[0:3].String() != d.ResourceKey {
path, ok := structpath.PureReferenceToPath(reference)
if !ok || path.Len() <= 3 || path.Prefix(3).String() != d.ResourceKey {
return nil, fmt.Errorf("internal error: expected reference to %q, got %q", d.ResourceKey, reference)
}

fieldPath := path[3:]
fieldPath := path.SkipPrefix(3)

if fieldPath.String() == "id" {
if actionType.KeepsID() {
Expand Down Expand Up @@ -136,12 +135,12 @@ func (d *DeploymentUnit) ResolveReferenceLocalOrRemote(ctx context.Context, db *
}

func (d *DeploymentUnit) ResolveReferenceRemote(ctx context.Context, db *dstate.DeploymentState, reference string) (any, error) {
path, ok := dynvar.PureReferenceToPath(reference)
if !ok || len(path) <= 3 || path[0:3].String() != d.ResourceKey {
path, ok := structpath.PureReferenceToPath(reference)
if !ok || path.Len() <= 3 || path.Prefix(3).String() != d.ResourceKey {
return nil, fmt.Errorf("internal error: expected reference to %q, got %q", d.ResourceKey, reference)
}

fieldPath := path[3:]
fieldPath := path.SkipPrefix(3)

// Handle "id" field separately - read from state, not remote state
if fieldPath.String() == "id" {
Expand All @@ -157,7 +156,7 @@ func (d *DeploymentUnit) ResolveReferenceRemote(ctx context.Context, db *dstate.
}

// ReadRemoteStateField reads a field from remote state with refresh if needed.
func (d *DeploymentUnit) ReadRemoteStateField(ctx context.Context, db *dstate.DeploymentState, fieldPath dyn.Path) (any, error) {
func (d *DeploymentUnit) ReadRemoteStateField(ctx context.Context, db *dstate.DeploymentState, fieldPath *structpath.PathNode) (any, error) {
// We have options:
// 1) Rely on the cached value; refresh if not cached.
// 2) Always refresh, read the value.
Expand Down
19 changes: 19 additions & 0 deletions libs/structs/dynpath/dynpath.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"strconv"
"strings"

"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/structs/structaccess"
"github.com/databricks/cli/libs/structs/structpath"
)
Expand Down Expand Up @@ -102,3 +103,21 @@ func ConvertPathNodeToDynPath(path *structpath.PathNode, rootType reflect.Type)

return result.String()
}

// ConvertDynPathToPathNode converts a dyn.Path to a *structpath.PathNode for compatibility.
// This is a bridge function for code that still uses dyn.Path.
func ConvertDynPathToPathNode(dynPath dyn.Path) *structpath.PathNode {
var result *structpath.PathNode

for _, component := range dynPath {
if component.Key() != "" {
// Key component - assume struct field for now
result = structpath.NewStructField(result, component.Key())
} else {
// Index component
result = structpath.NewIndex(result, component.Index())
}
}

return result
}
8 changes: 0 additions & 8 deletions libs/structs/structaccess/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,6 @@ func TestGet_ConfigRoot_JobTagsAccess(t *testing.T) {
require.Error(t, ValidateByString(reflect.TypeOf(root), "resources.jobs.my_job.tags.env.inner"))
require.Error(t, ValidateByString(reflect.TypeOf(root), "resources.jobs.my_job.tags1"))

// Leading dot is allowed
v, err = GetByString(root, ".resources.jobs.my_job.tags.team")
require.NoError(t, err)
require.Equal(t, "platform", v)
require.NoError(t, ValidateByString(reflect.TypeOf(root), ".resources.jobs.my_job.tags.team"))
require.Error(t, ValidateByString(reflect.TypeOf(root), ".resources.jobs.my_job.tags.team.inner"))
require.Error(t, ValidateByString(reflect.TypeOf(root), ".resources.jobs.my_job.tags1"))

// Array indexing test (1)
v, err = GetByString(root, "resources.jobs.my_job.tasks[0].task_key")
require.NoError(t, err)
Expand Down
86 changes: 47 additions & 39 deletions libs/structs/structaccess/get.go
Original file line number Diff line number Diff line change
@@ -1,86 +1,94 @@
package structaccess

import (
"errors"
"fmt"
"reflect"
"strconv"

"github.com/databricks/cli/libs/dyn"
"github.com/databricks/cli/libs/structs/structpath"
"github.com/databricks/cli/libs/structs/structtag"
)

// GetByString returns the value at the given path inside v.
// This is a convenience function that parses the path string and calls Get.
//
// Path grammar (compatible with dyn path):
// - Struct field names and map keys separated by '.' (e.g., connection.id)
// - (Note, this prevents maps keys that are not id-like from being referenced, but this general problem with references today.)
// - Numeric indices in brackets for arrays/slices (e.g., items[0].name)
// - Leading '.' is allowed (e.g., .connection.id)
//
// Behavior:
// - For structs: a key matches a field by its json tag name (if present and not "-").
// Embedded anonymous structs are searched.
// - For maps: a key indexes map[string]T (or string alias key types).
// - For slices/arrays: an index [N] selects the N-th element.
// - Wildcards ("*" or "[*]") are not supported and return an error.
func GetByString(v any, path string) (any, error) {
if path == "" {
return v, nil
}

dynPath, err := dyn.NewPathFromString(path)
pathNode, err := structpath.Parse(path)
if err != nil {
return nil, err
}

return Get(v, dynPath)
return Get(v, pathNode)
}

// Get returns the value at the given path inside v.
func Get(v any, path dyn.Path) (any, error) {
if len(path) == 0 {
// - For structs: supports both .field and ['field'] notation
// - For maps: supports both ['key'] and .key notation
// - For slices/arrays: an index [N] selects the N-th element.
// - Wildcards ("*" or "[*]") are not supported and return an error.
func Get(v any, path *structpath.PathNode) (any, error) {
if path.IsRoot() {
return v, nil
}

// Convert path to slice for easier iteration
pathSegments := path.AsSlice()

cur := reflect.ValueOf(v)
prefix := ""
for _, c := range path {
for _, node := range pathSegments {
var ok bool
cur, ok = deref(cur)
if !ok {
// cannot proceed further due to nil encountered at current location
return nil, fmt.Errorf("%s: cannot access nil value", prefix)
}

if c.Key() != "" {
// Key access: struct field (by json tag) or map key.
newPrefix := prefix
if newPrefix == "" {
newPrefix = c.Key()
} else {
newPrefix = newPrefix + "." + c.Key()
if idx, isIndex := node.Index(); isIndex {
newPrefix := prefix + "[" + strconv.Itoa(idx) + "]"
kind := cur.Kind()
if kind != reflect.Slice && kind != reflect.Array {
return nil, fmt.Errorf("%s: cannot index %s", newPrefix, kind)
}
nv, err := accessKey(cur, c.Key(), newPrefix)
if err != nil {
return nil, err
if idx < 0 || idx >= cur.Len() {
return nil, fmt.Errorf("%s: index out of range, length is %d", newPrefix, cur.Len())
}
cur = nv
cur = cur.Index(idx)
prefix = newPrefix
continue
}

// Index access: slice/array
idx := c.Index()
newPrefix := prefix + "[" + strconv.Itoa(idx) + "]"
kind := cur.Kind()
if kind != reflect.Slice && kind != reflect.Array {
return nil, fmt.Errorf("%s: cannot index %s", newPrefix, kind)
if node.DotStar() || node.BracketStar() {
return nil, fmt.Errorf("wildcards not supported: %s", path.String())
}

var key string
var newPrefix string

if field, isField := node.Field(); isField {
key = field
newPrefix = prefix
if newPrefix == "" {
newPrefix = key
} else {
newPrefix = newPrefix + "." + key
}
} else if mapKey, isMapKey := node.MapKey(); isMapKey {
key = mapKey
newPrefix = prefix + "[" + structpath.EncodeMapKey(key) + "]"
} else {
return nil, errors.New("unsupported path node type")
}
if idx < 0 || idx >= cur.Len() {
return nil, fmt.Errorf("%s: index out of range, length is %d", newPrefix, cur.Len())

nv, err := accessKey(cur, key, newPrefix)
if err != nil {
return nil, err
}
cur = cur.Index(idx)
cur = nv
prefix = newPrefix
}

Expand Down
61 changes: 55 additions & 6 deletions libs/structs/structaccess/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,11 +137,6 @@ func runCommonTests(t *testing.T, obj any) {
path: "alias_map.foo",
want: "bar",
},
{
name: "leading dot allowed",
path: ".connection.id",
want: "abc",
},

// Regular scalar fields - always return zero values
{
Expand Down Expand Up @@ -169,7 +164,7 @@ func runCommonTests(t *testing.T, obj any) {
{
name: "wildcard not supported",
path: "items[*].id",
errFmt: "invalid path: items[*].id",
errFmt: "wildcards not supported: items[*].id",
},
{
name: "missing field",
Expand Down Expand Up @@ -402,3 +397,57 @@ func TestGet_BundleTag_SkipsPromoted(t *testing.T) {
require.EqualError(t, err, "hidden: field \"hidden\" not found in structaccess.host")
require.EqualError(t, ValidateByString(reflect.TypeOf(host{}), "hidden"), "hidden: field \"hidden\" not found in structaccess.host")
}

func TestGet_FlexibleNotation(t *testing.T) {
type testStruct struct {
Field string `json:"field"`
Map map[string]string `json:"map"`
}

obj := testStruct{
Field: "value",
Map: map[string]string{"key": "mapvalue"},
}

tests := []struct {
name string
path string
want string
errFmt string
}{
// Struct field access - both notations should work
{
name: "struct field with dot notation",
path: "field",
want: "value",
},
{
name: "struct field with bracket notation",
path: "['field']",
want: "value",
},
// Map key access - both notations should work
{
name: "map key with bracket notation",
path: "map['key']",
want: "mapvalue",
},
{
name: "map key with dot notation",
path: "map.key",
want: "mapvalue",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := GetByString(obj, tt.path)
if tt.errFmt != "" {
require.EqualError(t, err, tt.errFmt)
return
}
require.NoError(t, err)
require.Equal(t, tt.want, got)
})
}
}
Loading
Loading