From 1747da8f51830472fd8cd07981ca25ac57772a86 Mon Sep 17 00:00:00 2001 From: Denis Bilenko Date: Tue, 6 Jan 2026 15:01:13 +0100 Subject: [PATCH] structaccess: support key-value syntax; notFoundError --- libs/structs/structaccess/get.go | 69 ++++++++++++++++++- libs/structs/structaccess/get_test.go | 92 +++++++++++++++++++++++--- libs/structs/structaccess/set.go | 4 ++ libs/structs/structaccess/set_test.go | 60 +++++++++++++---- libs/structs/structaccess/typecheck.go | 10 +++ 5 files changed, 213 insertions(+), 22 deletions(-) diff --git a/libs/structs/structaccess/get.go b/libs/structs/structaccess/get.go index aeef9f3c6b..b42c3977d6 100644 --- a/libs/structs/structaccess/get.go +++ b/libs/structs/structaccess/get.go @@ -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) { @@ -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") @@ -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 { @@ -142,7 +161,7 @@ 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: @@ -150,6 +169,52 @@ func accessKey(v reflect.Value, key string, path *structpath.PathNode) (reflect. } } +// 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) { diff --git a/libs/structs/structaccess/get_test.go b/libs/structs/structaccess/get_test.go index 18ab261623..593ae8a536 100644 --- a/libs/structs/structaccess/get_test.go +++ b/libs/structs/structaccess/get_test.go @@ -16,6 +16,7 @@ type testCase struct { want any wantSelf bool errFmt string + notFound string // if set, expect NotFoundError with this message typeHasPath bool } @@ -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", @@ -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", @@ -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, }, { @@ -225,7 +226,7 @@ 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, }, { @@ -233,20 +234,59 @@ func runCommonTests(t *testing.T, obj any) { 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, ¬Found) + return + } if tt.errFmt != "" { require.EqualError(t, err, tt.errFmt) + var notFound *NotFoundError + require.NotErrorAs(t, err, ¬Found, "non-NotFoundError should not match") return } require.NoError(t, err) @@ -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) +} diff --git a/libs/structs/structaccess/set.go b/libs/structs/structaccess/set.go index 104e8e4dbc..1f5857354f 100644 --- a/libs/structs/structaccess/set.go +++ b/libs/structs/structaccess/set.go @@ -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) } diff --git a/libs/structs/structaccess/set_test.go b/libs/structs/structaccess/set_test.go index 0d29bea822..2156327f71 100644 --- a/libs/structs/structaccess/set_test.go +++ b/libs/structs/structaccess/set_test.go @@ -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 @@ -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{ @@ -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 { diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index 3f851571be..c2f44d66c1 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -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 {