Skip to content

Commit 52cb1a7

Browse files
authored
internal: structaccess: support key-value syntax; add NotFoundError (#4204)
## Changes - Support key-value syntax (`tasks[task_key='foo']`) in structaccess.Get() and Set() - Use separate type class for errors when structure is correct but item is not found. ## Why Completeness. Using this features in #4201 ## Tests Unit tests.
1 parent 2f558af commit 52cb1a7

File tree

5 files changed

+213
-22
lines changed

5 files changed

+213
-22
lines changed

libs/structs/structaccess/get.go

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,15 @@ import (
99
"github.com/databricks/cli/libs/structs/structtag"
1010
)
1111

12+
// NotFoundError is returned when a map key, slice index, or key-value selector is not found.
13+
type NotFoundError struct {
14+
msg string
15+
}
16+
17+
func (e *NotFoundError) Error() string {
18+
return e.msg
19+
}
20+
1221
// GetByString returns the value at the given path inside v.
1322
// This is a convenience function that parses the path string and calls Get.
1423
func GetByString(v any, path string) (any, error) {
@@ -53,12 +62,21 @@ func getValue(v any, path *structpath.PathNode) (reflect.Value, error) {
5362
return reflect.Value{}, fmt.Errorf("%s: cannot index %s", node.String(), kind)
5463
}
5564
if idx < 0 || idx >= cur.Len() {
56-
return reflect.Value{}, fmt.Errorf("%s: index out of range, length is %d", node.String(), cur.Len())
65+
return reflect.Value{}, &NotFoundError{fmt.Sprintf("%s: index out of range, length is %d", node.String(), cur.Len())}
5766
}
5867
cur = cur.Index(idx)
5968
continue
6069
}
6170

71+
if key, value, ok := node.KeyValue(); ok {
72+
nv, err := accessKeyValue(cur, key, value, node)
73+
if err != nil {
74+
return reflect.Value{}, err
75+
}
76+
cur = nv
77+
continue
78+
}
79+
6280
key, ok := node.StringKey()
6381
if !ok {
6482
return reflect.Value{}, errors.New("unsupported path node type")
@@ -76,6 +94,7 @@ func getValue(v any, path *structpath.PathNode) (reflect.Value, error) {
7694

7795
// Get returns the value at the given path inside v.
7896
// Wildcards ("*" or "[*]") are not supported and return an error.
97+
// Returns NotFoundError when a map key, slice index, or key-value selector is not found.
7998
func Get(v any, path *structpath.PathNode) (any, error) {
8099
cur, err := getValue(v, path)
81100
if err != nil {
@@ -142,14 +161,60 @@ func accessKey(v reflect.Value, key string, path *structpath.PathNode) (reflect.
142161
}
143162
mv := v.MapIndex(mk)
144163
if !mv.IsValid() {
145-
return reflect.Value{}, fmt.Errorf("%s: key %q not found in map", path.String(), key)
164+
return reflect.Value{}, &NotFoundError{fmt.Sprintf("%s: key %q not found in map", path.String(), key)}
146165
}
147166
return mv, nil
148167
default:
149168
return reflect.Value{}, fmt.Errorf("%s: cannot access key %q on %s", path.String(), key, v.Kind())
150169
}
151170
}
152171

172+
// accessKeyValue searches for an element in a slice/array where a field matching key has the given value.
173+
// v must be a slice or array. Returns the first matching element.
174+
func accessKeyValue(v reflect.Value, key, value string, path *structpath.PathNode) (reflect.Value, error) {
175+
kind := v.Kind()
176+
if kind != reflect.Slice && kind != reflect.Array {
177+
return reflect.Value{}, fmt.Errorf("%s: cannot use key-value syntax on %s", path.String(), kind)
178+
}
179+
180+
for i := range v.Len() {
181+
elem := v.Index(i)
182+
183+
// Dereference pointers/interfaces in the element
184+
elemDeref, ok := deref(elem)
185+
if !ok {
186+
continue // Skip nil elements
187+
}
188+
189+
// Element must be a struct to have fields
190+
if elemDeref.Kind() != reflect.Struct {
191+
return reflect.Value{}, fmt.Errorf("%s: key-value syntax requires slice elements to be structs, got %s", path.String(), elemDeref.Kind())
192+
}
193+
194+
// Try to get the field value
195+
fieldVal, err := accessKey(elemDeref, key, path)
196+
if err != nil {
197+
continue // Field not found in this element, try next
198+
}
199+
200+
// Check if the field value matches
201+
if !fieldVal.IsValid() {
202+
continue
203+
}
204+
205+
// Only string fields are supported for key-value matching
206+
if fieldVal.Kind() != reflect.String {
207+
continue
208+
}
209+
210+
if fieldVal.String() == value {
211+
return elem, nil
212+
}
213+
}
214+
215+
return reflect.Value{}, &NotFoundError{fmt.Sprintf("%s: no element found with %s=%q", path.String(), key, value)}
216+
}
217+
153218
// findFieldInStruct searches for a field by JSON key in a single struct (no embedding).
154219
// Returns: fieldValue, structField, found
155220
func findFieldInStruct(v reflect.Value, key string) (reflect.Value, reflect.StructField, bool) {

libs/structs/structaccess/get_test.go

Lines changed: 84 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type testCase struct {
1616
want any
1717
wantSelf bool
1818
errFmt string
19+
notFound string // if set, expect NotFoundError with this message
1920
typeHasPath bool
2021
}
2122

@@ -81,8 +82,8 @@ func makeOuterNoFSF() outerNoFSF {
8182
Name: "x",
8283
},
8384
Items: []inner{
84-
{ID: "i0"},
85-
{ID: "i1"},
85+
{ID: "i0", Name: "first"},
86+
{ID: "i1", Name: "second"},
8687
},
8788
Labels: map[string]string{
8889
"env": "dev",
@@ -101,8 +102,8 @@ func makeOuterWithFSF() outerWithFSF {
101102
Name: "x",
102103
},
103104
Items: []inner{
104-
{ID: "i0"},
105-
{ID: "i1"},
105+
{ID: "i0", Name: "first"},
106+
{ID: "i1", Name: "second"},
106107
},
107108
Labels: map[string]string{
108109
"env": "dev",
@@ -198,7 +199,7 @@ func runCommonTests(t *testing.T, obj any) {
198199
{
199200
name: "out of range index",
200201
path: "items[5]",
201-
errFmt: "items[5]: index out of range, length is 2",
202+
notFound: "items[5]: index out of range, length is 2",
202203
typeHasPath: true,
203204
},
204205
{
@@ -225,28 +226,67 @@ func runCommonTests(t *testing.T, obj any) {
225226
{
226227
name: "map missing key",
227228
path: "labels.missing",
228-
errFmt: "labels.missing: key \"missing\" not found in map",
229+
notFound: "labels.missing: key \"missing\" not found in map",
229230
typeHasPath: true,
230231
},
231232
{
232233
name: "json dash ignored",
233234
path: "ignored",
234235
errFmt: "ignored: field \"ignored\" not found in " + typeName,
235236
},
237+
238+
// Key-value selector tests
239+
{
240+
name: "key-value selector",
241+
path: "items[id='i1']",
242+
want: inner{ID: "i1", Name: "second"},
243+
},
244+
{
245+
name: "key-value selector then field",
246+
path: "items[id='i0'].name",
247+
want: "first",
248+
},
249+
{
250+
name: "key-value no match",
251+
path: "items[id='missing']",
252+
notFound: "items[id='missing']: no element found with id=\"missing\"",
253+
typeHasPath: true,
254+
},
255+
{
256+
name: "key-value on non-slice",
257+
path: "connection[id='abc']",
258+
errFmt: "connection[id='abc']: cannot use key-value syntax on struct",
259+
},
260+
{
261+
name: "key-value field not found",
262+
path: "items[missing='value']",
263+
notFound: "items[missing='value']: no element found with missing=\"value\"",
264+
typeHasPath: true,
265+
},
236266
}
237267

238268
for _, tt := range tests {
239269
t.Run(tt.name, func(t *testing.T) {
240270
hasPathError := ValidateByString(reflect.TypeOf(obj), tt.path)
241-
if tt.errFmt == "" || tt.typeHasPath {
271+
if tt.errFmt == "" && tt.notFound == "" || tt.typeHasPath {
242272
require.NoError(t, hasPathError)
243-
} else {
273+
} else if tt.errFmt != "" {
244274
require.EqualError(t, hasPathError, tt.errFmt)
275+
} else if tt.notFound != "" {
276+
require.EqualError(t, hasPathError, tt.notFound)
245277
}
246278

247279
got, err := GetByString(obj, tt.path)
280+
if tt.notFound != "" {
281+
require.EqualError(t, err, tt.notFound)
282+
var notFound *NotFoundError
283+
require.ErrorAs(t, err, &notFound)
284+
return
285+
}
248286
if tt.errFmt != "" {
249287
require.EqualError(t, err, tt.errFmt)
288+
var notFound *NotFoundError
289+
require.NotErrorAs(t, err, &notFound, "non-NotFoundError should not match")
250290
return
251291
}
252292
require.NoError(t, err)
@@ -700,3 +740,39 @@ func TestPipeline(t *testing.T) {
700740
require.Equal(t, "ingestion_definition: cannot access nil value", err.Error())
701741
require.Nil(t, v)
702742
}
743+
744+
func TestGetKeyValue_NestedMultiple(t *testing.T) {
745+
type Item struct {
746+
ID string `json:"id"`
747+
Name string `json:"name"`
748+
}
749+
type Group struct {
750+
GroupID string `json:"group_id"`
751+
Items []Item `json:"items"`
752+
}
753+
type Container struct {
754+
Groups []Group `json:"groups"`
755+
}
756+
757+
c := Container{
758+
Groups: []Group{
759+
{
760+
GroupID: "g1",
761+
Items: []Item{
762+
{ID: "i1", Name: "item1"},
763+
{ID: "i2", Name: "item2"},
764+
},
765+
},
766+
{
767+
GroupID: "g2",
768+
Items: []Item{
769+
{ID: "i3", Name: "item3"},
770+
},
771+
},
772+
},
773+
}
774+
775+
name, err := GetByString(&c, "groups[group_id='g2'].items[id='i3'].name")
776+
require.NoError(t, err)
777+
require.Equal(t, "item3", name)
778+
}

libs/structs/structaccess/set.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ func setValueAtNode(parentVal reflect.Value, node *structpath.PathNode, value an
8585
return errors.New("wildcards not supported")
8686
}
8787

88+
if key, matchValue, isKeyValue := node.KeyValue(); isKeyValue {
89+
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)
90+
}
91+
8892
if key, hasKey := node.StringKey(); hasKey {
8993
return setFieldOrMapValue(parentVal, key, valueVal)
9094
}

libs/structs/structaccess/set_test.go

Lines changed: 48 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -21,18 +21,24 @@ type NestedInfo struct {
2121
Build int `json:"build"`
2222
}
2323

24+
type NestedItem struct {
25+
ID string `json:"id"`
26+
Name string `json:"name"`
27+
}
28+
2429
type TestStruct struct {
25-
Name string `json:"name"`
26-
Age int `json:"age"`
27-
Score float64 `json:"score"`
28-
Active bool `json:"active"`
29-
Priority uint8 `json:"priority"`
30-
Tags map[string]string `json:"tags"`
31-
Items []string `json:"items"`
32-
Count *int `json:"count,omitempty"`
33-
Custom CustomString `json:"custom"`
34-
Info NestedInfo `json:"info"`
35-
Internal string `json:"-"`
30+
Name string `json:"name"`
31+
Age int `json:"age"`
32+
Score float64 `json:"score"`
33+
Active bool `json:"active"`
34+
Priority uint8 `json:"priority"`
35+
Tags map[string]string `json:"tags"`
36+
Items []string `json:"items"`
37+
NestedItems []NestedItem `json:"nested_items"`
38+
Count *int `json:"count,omitempty"`
39+
Custom CustomString `json:"custom"`
40+
Info NestedInfo `json:"info"`
41+
Internal string `json:"-"`
3642
}
3743

3844
// mustParsePath is a helper to parse path strings in tests
@@ -55,7 +61,11 @@ func newTestStruct() *TestStruct {
5561
Tags: map[string]string{
5662
"env": "old_env",
5763
},
58-
Items: []string{"old_a", "old_b", "old_c"},
64+
Items: []string{"old_a", "old_b", "old_c"},
65+
NestedItems: []NestedItem{
66+
{ID: "item1", Name: "first"},
67+
{ID: "item2", Name: "second"},
68+
},
5969
Count: nil,
6070
Custom: CustomString("old custom"),
6171
Info: NestedInfo{
@@ -439,6 +449,32 @@ func TestSet(t *testing.T) {
439449
},
440450
},
441451
},
452+
453+
// Key-value selector tests
454+
{
455+
name: "set field via key-value selector",
456+
path: "nested_items[id='item2'].name",
457+
value: "updated",
458+
expectedChanges: []structdiff.Change{
459+
{
460+
Path: mustParsePath("nested_items[1].name"),
461+
Old: "second",
462+
New: "updated",
463+
},
464+
},
465+
},
466+
{
467+
name: "cannot set key-value selector itself",
468+
path: "nested_items[id='item1']",
469+
value: "new value",
470+
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",
471+
},
472+
{
473+
name: "key-value no matching element",
474+
path: "nested_items[id='nonexistent'].name",
475+
value: "value",
476+
errorMsg: "failed to navigate to parent nested_items[id='nonexistent']: nested_items[id='nonexistent']: no element found with id=\"nonexistent\"",
477+
},
442478
}
443479

444480
for _, tt := range tests {

libs/structs/structaccess/typecheck.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,16 @@ func Validate(t reflect.Type, path *structpath.PathNode) error {
5757
return fmt.Errorf("wildcards not supported: %s", path.String())
5858
}
5959

60+
// Handle key-value selector: validates that we can index the slice/array
61+
if _, _, isKeyValue := node.KeyValue(); isKeyValue {
62+
kind := cur.Kind()
63+
if kind != reflect.Slice && kind != reflect.Array {
64+
return fmt.Errorf("%s: cannot use key-value syntax on %s", node.String(), kind)
65+
}
66+
cur = cur.Elem()
67+
continue
68+
}
69+
6070
key, ok := node.StringKey()
6171

6272
if !ok {

0 commit comments

Comments
 (0)