Skip to content
Merged
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
69 changes: 67 additions & 2 deletions libs/structs/structaccess/get.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@ import (
"github.com/databricks/cli/libs/structs/structtag"
)

// NotFoundError is returned when a map key, slice index, or key-value selector is not found.
type NotFoundError struct {
msg string
}

func (e *NotFoundError) Error() string {
return e.msg
}

// GetByString returns the value at the given path inside v.
// This is a convenience function that parses the path string and calls Get.
func GetByString(v any, path string) (any, error) {
Expand Down Expand Up @@ -53,12 +62,21 @@ func getValue(v any, path *structpath.PathNode) (reflect.Value, error) {
return reflect.Value{}, fmt.Errorf("%s: cannot index %s", node.String(), kind)
}
if idx < 0 || idx >= cur.Len() {
return reflect.Value{}, fmt.Errorf("%s: index out of range, length is %d", node.String(), cur.Len())
return reflect.Value{}, &NotFoundError{fmt.Sprintf("%s: index out of range, length is %d", node.String(), cur.Len())}
}
cur = cur.Index(idx)
continue
}

if key, value, ok := node.KeyValue(); ok {
nv, err := accessKeyValue(cur, key, value, node)
if err != nil {
return reflect.Value{}, err
}
cur = nv
continue
}

key, ok := node.StringKey()
if !ok {
return reflect.Value{}, errors.New("unsupported path node type")
Expand All @@ -76,6 +94,7 @@ func getValue(v any, path *structpath.PathNode) (reflect.Value, error) {

// Get returns the value at the given path inside v.
// Wildcards ("*" or "[*]") are not supported and return an error.
// Returns NotFoundError when a map key, slice index, or key-value selector is not found.
func Get(v any, path *structpath.PathNode) (any, error) {
cur, err := getValue(v, path)
if err != nil {
Expand Down Expand Up @@ -142,14 +161,60 @@ func accessKey(v reflect.Value, key string, path *structpath.PathNode) (reflect.
}
mv := v.MapIndex(mk)
if !mv.IsValid() {
return reflect.Value{}, fmt.Errorf("%s: key %q not found in map", path.String(), key)
return reflect.Value{}, &NotFoundError{fmt.Sprintf("%s: key %q not found in map", path.String(), key)}
}
return mv, nil
default:
return reflect.Value{}, fmt.Errorf("%s: cannot access key %q on %s", path.String(), key, v.Kind())
}
}

// accessKeyValue searches for an element in a slice/array where a field matching key has the given value.
// v must be a slice or array. Returns the first matching element.
func accessKeyValue(v reflect.Value, key, value string, path *structpath.PathNode) (reflect.Value, error) {
kind := v.Kind()
if kind != reflect.Slice && kind != reflect.Array {
return reflect.Value{}, fmt.Errorf("%s: cannot use key-value syntax on %s", path.String(), kind)
}

for i := range v.Len() {
elem := v.Index(i)

// Dereference pointers/interfaces in the element
elemDeref, ok := deref(elem)
if !ok {
continue // Skip nil elements
}

// Element must be a struct to have fields
if elemDeref.Kind() != reflect.Struct {
return reflect.Value{}, fmt.Errorf("%s: key-value syntax requires slice elements to be structs, got %s", path.String(), elemDeref.Kind())
}

// Try to get the field value
fieldVal, err := accessKey(elemDeref, key, path)
if err != nil {
continue // Field not found in this element, try next
}

// Check if the field value matches
if !fieldVal.IsValid() {
continue
}

// Only string fields are supported for key-value matching
if fieldVal.Kind() != reflect.String {
continue
}

if fieldVal.String() == value {
return elem, nil
}
}

return reflect.Value{}, &NotFoundError{fmt.Sprintf("%s: no element found with %s=%q", path.String(), key, value)}
}

// findFieldInStruct searches for a field by JSON key in a single struct (no embedding).
// Returns: fieldValue, structField, found
func findFieldInStruct(v reflect.Value, key string) (reflect.Value, reflect.StructField, bool) {
Expand Down
92 changes: 84 additions & 8 deletions libs/structs/structaccess/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type testCase struct {
want any
wantSelf bool
errFmt string
notFound string // if set, expect NotFoundError with this message
typeHasPath bool
}

Expand Down Expand Up @@ -81,8 +82,8 @@ func makeOuterNoFSF() outerNoFSF {
Name: "x",
},
Items: []inner{
{ID: "i0"},
{ID: "i1"},
{ID: "i0", Name: "first"},
{ID: "i1", Name: "second"},
},
Labels: map[string]string{
"env": "dev",
Expand All @@ -101,8 +102,8 @@ func makeOuterWithFSF() outerWithFSF {
Name: "x",
},
Items: []inner{
{ID: "i0"},
{ID: "i1"},
{ID: "i0", Name: "first"},
{ID: "i1", Name: "second"},
},
Labels: map[string]string{
"env": "dev",
Expand Down Expand Up @@ -198,7 +199,7 @@ func runCommonTests(t *testing.T, obj any) {
{
name: "out of range index",
path: "items[5]",
errFmt: "items[5]: index out of range, length is 2",
notFound: "items[5]: index out of range, length is 2",
typeHasPath: true,
},
{
Expand All @@ -225,28 +226,67 @@ func runCommonTests(t *testing.T, obj any) {
{
name: "map missing key",
path: "labels.missing",
errFmt: "labels.missing: key \"missing\" not found in map",
notFound: "labels.missing: key \"missing\" not found in map",
typeHasPath: true,
},
{
name: "json dash ignored",
path: "ignored",
errFmt: "ignored: field \"ignored\" not found in " + typeName,
},

// Key-value selector tests
{
name: "key-value selector",
path: "items[id='i1']",
want: inner{ID: "i1", Name: "second"},
},
{
name: "key-value selector then field",
path: "items[id='i0'].name",
want: "first",
},
{
name: "key-value no match",
path: "items[id='missing']",
notFound: "items[id='missing']: no element found with id=\"missing\"",
typeHasPath: true,
},
{
name: "key-value on non-slice",
path: "connection[id='abc']",
errFmt: "connection[id='abc']: cannot use key-value syntax on struct",
},
{
name: "key-value field not found",
path: "items[missing='value']",
notFound: "items[missing='value']: no element found with missing=\"value\"",
typeHasPath: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hasPathError := ValidateByString(reflect.TypeOf(obj), tt.path)
if tt.errFmt == "" || tt.typeHasPath {
if tt.errFmt == "" && tt.notFound == "" || tt.typeHasPath {
require.NoError(t, hasPathError)
} else {
} else if tt.errFmt != "" {
require.EqualError(t, hasPathError, tt.errFmt)
} else if tt.notFound != "" {
require.EqualError(t, hasPathError, tt.notFound)
}

got, err := GetByString(obj, tt.path)
if tt.notFound != "" {
require.EqualError(t, err, tt.notFound)
var notFound *NotFoundError
require.ErrorAs(t, err, &notFound)
return
}
if tt.errFmt != "" {
require.EqualError(t, err, tt.errFmt)
var notFound *NotFoundError
require.NotErrorAs(t, err, &notFound, "non-NotFoundError should not match")
return
}
require.NoError(t, err)
Expand Down Expand Up @@ -700,3 +740,39 @@ func TestPipeline(t *testing.T) {
require.Equal(t, "ingestion_definition: cannot access nil value", err.Error())
require.Nil(t, v)
}

func TestGetKeyValue_NestedMultiple(t *testing.T) {
type Item struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Group struct {
GroupID string `json:"group_id"`
Items []Item `json:"items"`
}
type Container struct {
Groups []Group `json:"groups"`
}

c := Container{
Groups: []Group{
{
GroupID: "g1",
Items: []Item{
{ID: "i1", Name: "item1"},
{ID: "i2", Name: "item2"},
},
},
{
GroupID: "g2",
Items: []Item{
{ID: "i3", Name: "item3"},
},
},
},
}

name, err := GetByString(&c, "groups[group_id='g2'].items[id='i3'].name")
require.NoError(t, err)
require.Equal(t, "item3", name)
}
4 changes: 4 additions & 0 deletions libs/structs/structaccess/set.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,10 @@ func setValueAtNode(parentVal reflect.Value, node *structpath.PathNode, value an
return errors.New("wildcards not supported")
}

if key, matchValue, isKeyValue := node.KeyValue(); isKeyValue {
return fmt.Errorf("cannot set value at key-value selector [%s='%s'] - key-value syntax can only be used for path traversal, not as a final target", key, matchValue)
}

if key, hasKey := node.StringKey(); hasKey {
return setFieldOrMapValue(parentVal, key, valueVal)
}
Expand Down
60 changes: 48 additions & 12 deletions libs/structs/structaccess/set_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,24 @@ type NestedInfo struct {
Build int `json:"build"`
}

type NestedItem struct {
ID string `json:"id"`
Name string `json:"name"`
}

type TestStruct struct {
Name string `json:"name"`
Age int `json:"age"`
Score float64 `json:"score"`
Active bool `json:"active"`
Priority uint8 `json:"priority"`
Tags map[string]string `json:"tags"`
Items []string `json:"items"`
Count *int `json:"count,omitempty"`
Custom CustomString `json:"custom"`
Info NestedInfo `json:"info"`
Internal string `json:"-"`
Name string `json:"name"`
Age int `json:"age"`
Score float64 `json:"score"`
Active bool `json:"active"`
Priority uint8 `json:"priority"`
Tags map[string]string `json:"tags"`
Items []string `json:"items"`
NestedItems []NestedItem `json:"nested_items"`
Count *int `json:"count,omitempty"`
Custom CustomString `json:"custom"`
Info NestedInfo `json:"info"`
Internal string `json:"-"`
}

// mustParsePath is a helper to parse path strings in tests
Expand All @@ -55,7 +61,11 @@ func newTestStruct() *TestStruct {
Tags: map[string]string{
"env": "old_env",
},
Items: []string{"old_a", "old_b", "old_c"},
Items: []string{"old_a", "old_b", "old_c"},
NestedItems: []NestedItem{
{ID: "item1", Name: "first"},
{ID: "item2", Name: "second"},
},
Count: nil,
Custom: CustomString("old custom"),
Info: NestedInfo{
Expand Down Expand Up @@ -439,6 +449,32 @@ func TestSet(t *testing.T) {
},
},
},

// Key-value selector tests
{
name: "set field via key-value selector",
path: "nested_items[id='item2'].name",
value: "updated",
expectedChanges: []structdiff.Change{
{
Path: mustParsePath("nested_items[1].name"),
Old: "second",
New: "updated",
},
},
},
{
name: "cannot set key-value selector itself",
path: "nested_items[id='item1']",
value: "new value",
errorMsg: "cannot set value at key-value selector [id='item1'] - key-value syntax can only be used for path traversal, not as a final target",
},
{
name: "key-value no matching element",
path: "nested_items[id='nonexistent'].name",
value: "value",
errorMsg: "failed to navigate to parent nested_items[id='nonexistent']: nested_items[id='nonexistent']: no element found with id=\"nonexistent\"",
},
}

for _, tt := range tests {
Expand Down
10 changes: 10 additions & 0 deletions libs/structs/structaccess/typecheck.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,16 @@ func Validate(t reflect.Type, path *structpath.PathNode) error {
return fmt.Errorf("wildcards not supported: %s", path.String())
}

// Handle key-value selector: validates that we can index the slice/array
if _, _, isKeyValue := node.KeyValue(); isKeyValue {
kind := cur.Kind()
if kind != reflect.Slice && kind != reflect.Array {
return fmt.Errorf("%s: cannot use key-value syntax on %s", node.String(), kind)
}
cur = cur.Elem()
continue
}

key, ok := node.StringKey()

if !ok {
Expand Down