diff --git a/bundle/config/mutator/log_resource_references.go b/bundle/config/mutator/log_resource_references.go index 546293ffde..aa7d4117a2 100644 --- a/bundle/config/mutator/log_resource_references.go +++ b/bundle/config/mutator/log_resource_references.go @@ -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" ) @@ -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 diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index 7fee2829b7..9ffd43329a 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -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" @@ -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) @@ -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) diff --git a/bundle/direct/plan.go b/bundle/direct/plan.go index bff9cf92d0..6b051068b7 100644 --- a/bundle/direct/plan.go +++ b/bundle/direct/plan.go @@ -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) { @@ -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() { @@ -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" { @@ -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. diff --git a/libs/structs/dynpath/dynpath.go b/libs/structs/dynpath/dynpath.go index d8901e657f..51a56216e8 100644 --- a/libs/structs/dynpath/dynpath.go +++ b/libs/structs/dynpath/dynpath.go @@ -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" ) @@ -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 +} diff --git a/libs/structs/structaccess/bundle_test.go b/libs/structs/structaccess/bundle_test.go index 27b868e9bd..a93ff2be95 100644 --- a/libs/structs/structaccess/bundle_test.go +++ b/libs/structs/structaccess/bundle_test.go @@ -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) diff --git a/libs/structs/structaccess/get.go b/libs/structs/structaccess/get.go index 4e298e24fe..f9d8f6f707 100644 --- a/libs/structs/structaccess/get.go +++ b/libs/structs/structaccess/get.go @@ -1,51 +1,46 @@ 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 { @@ -53,34 +48,47 @@ func Get(v any, path dyn.Path) (any, error) { 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 } diff --git a/libs/structs/structaccess/get_test.go b/libs/structs/structaccess/get_test.go index 70dd6ed803..2d525027c5 100644 --- a/libs/structs/structaccess/get_test.go +++ b/libs/structs/structaccess/get_test.go @@ -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 { @@ -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", @@ -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) + }) + } +} diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index 2582c963cd..43b5b70c29 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -1,11 +1,12 @@ 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" ) @@ -17,66 +18,85 @@ func ValidateByString(t reflect.Type, path string) error { return nil } - p, err := dyn.NewPathFromString(path) + pathNode, err := structpath.Parse(path) if err != nil { return err } - return Validate(t, p) + return Validate(t, pathNode) } // Validate reports whether the given path is valid for the provided type. // It returns nil if the path resolves fully, or an error indicating where resolution failed. -func Validate(t reflect.Type, path dyn.Path) error { - if len(path) == 0 { +func Validate(t reflect.Type, path *structpath.PathNode) error { + if path.IsRoot() { return nil } + // Convert path to slice for easier iteration + pathSegments := path.AsSlice() + cur := t prefix := "" - for _, c := range path { + for _, node := range pathSegments { // Always dereference pointers at the type level. for cur.Kind() == reflect.Pointer { cur = cur.Elem() } - 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() - } - - switch cur.Kind() { - case reflect.Struct: - sf, _, ok := FindStructFieldByKeyType(cur, c.Key()) - if !ok { - return fmt.Errorf("%s: field %q not found in %s", newPrefix, c.Key(), cur.String()) - } - cur = sf.Type - case reflect.Map: - kt := cur.Key() - if kt.Kind() != reflect.String { - return fmt.Errorf("%s: map key must be string, got %s", newPrefix, kt) - } - cur = cur.Elem() - default: - return fmt.Errorf("%s: cannot access key %q on %s", newPrefix, c.Key(), cur.Kind()) + // Handle different node types + if idx, isIndex := node.Index(); isIndex { + // Index access: slice/array + newPrefix := prefix + "[" + strconv.Itoa(idx) + "]" + kind := cur.Kind() + if kind != reflect.Slice && kind != reflect.Array { + return fmt.Errorf("%s: cannot index %s", newPrefix, kind) } + cur = cur.Elem() 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 fmt.Errorf("%s: cannot index %s", newPrefix, kind) + // Handle wildcards + if node.DotStar() || node.BracketStar() { + return fmt.Errorf("wildcards not supported: %s", path.String()) + } + + // Handle field or map key access + 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 + "['" + key + "']" + } else { + return errors.New("unsupported path node type") + } + + switch cur.Kind() { + case reflect.Struct: + sf, _, ok := FindStructFieldByKeyType(cur, key) + if !ok { + return fmt.Errorf("%s: field %q not found in %s", newPrefix, key, cur.String()) + } + cur = sf.Type + case reflect.Map: + kt := cur.Key() + if kt.Kind() != reflect.String { + return fmt.Errorf("%s: map key must be string, got %s", newPrefix, kt) + } + cur = cur.Elem() + default: + return fmt.Errorf("%s: cannot access key %q on %s", newPrefix, key, cur.Kind()) } - cur = cur.Elem() prefix = newPrefix } diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index 4c4ae2daef..c8c769442f 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -5,6 +5,9 @@ import ( "fmt" "strconv" "strings" + + "github.com/databricks/cli/libs/dyn" + "github.com/databricks/cli/libs/dyn/dynvar" ) const ( @@ -82,19 +85,14 @@ func (p *PathNode) Parent() *PathNode { // AsSlice returns the path as a slice of PathNodes from root to current. // Efficiently pre-allocates the exact length and fills in reverse order. func (p *PathNode) AsSlice() []*PathNode { - // First pass: count the length - length := 0 - current := p - for current != nil { - length++ - current = current.Parent() - } + // Use Len() to get the length efficiently + length := p.Len() // Allocate slice with exact capacity segments := make([]*PathNode, length) - // Second pass: fill in reverse order (from end to start) - current = p + // Fill in reverse order (from end to start) + current := p for i := length - 1; i >= 0; i-- { segments[i] = current current = current.Parent() @@ -188,8 +186,12 @@ func (p *PathNode) String() string { } // Format map key with single quotes, escaping single quotes by doubling them - escapedKey := strings.ReplaceAll(p.key, "'", "''") - return fmt.Sprintf("%s['%s']", p.prev.String(), escapedKey) + return fmt.Sprintf("%s[%s]", p.prev.String(), EncodeMapKey(p.key)) +} + +func EncodeMapKey(s string) string { + escaped := strings.ReplaceAll(s, "'", "''") + return "'" + escaped + "'" } // Parse parses a string representation of a path using a state machine. @@ -426,3 +428,99 @@ func isReservedFieldChar(ch byte) bool { return false } } + +// PureReferenceToPath returns a PathNode if s is a pure variable reference, otherwise false. +// This function is similar to dynvar.PureReferenceToPath but returns a *PathNode instead of dyn.Path. +func PureReferenceToPath(s string) (*PathNode, bool) { + ref, ok := dynvar.NewRef(dyn.V(s)) + if !ok { + return nil, false + } + + if !ref.IsPure() { + return nil, false + } + + pathNode, err := Parse(ref.References()[0]) + if err != nil { + return nil, false + } + + return pathNode, true +} + +// SkipPrefix returns a new PathNode that skips the first n components of the path. +// If n is greater than or equal to the path length, returns nil (root). +func (p *PathNode) SkipPrefix(n int) *PathNode { + if p.IsRoot() || n <= 0 { + return p + } + + length := p.Len() + if n >= length { + return nil // Return root + } + + startNode := p.Prefix(n) + + var result *PathNode + current := p + for current != startNode { + result = &PathNode{ + prev: result, + key: current.key, + index: current.index, + } + current = current.Parent() + } + + return result.Reverse() +} + +// Reverse returns a new PathNode with the order of components reversed. +func (p *PathNode) Reverse() *PathNode { + var result *PathNode + current := p + for current != nil { + next := current.prev + current.prev = result + result = current + current = next + } + return result +} + +// Len returns the number of components in the path. +func (p *PathNode) Len() int { + length := 0 + current := p + for current != nil { + length++ + current = current.Parent() + } + return length +} + +// Prefix returns the PathNode at the nth position (1-indexed from root). +// If n is greater than the path length, returns the entire path. +// If n <= 0, returns nil (root). +func (p *PathNode) Prefix(n int) *PathNode { + if p.IsRoot() || n <= 0 { + return nil // Return root + } + + // Find the path length first to handle edge cases + length := p.Len() + if n >= length { + return p // Return entire path + } + + // Traverse from root to find the nth node (1-indexed) + current := p + // Move to root first + for range length - n { + current = current.Parent() + } + + return current +} diff --git a/libs/structs/structpath/path_test.go b/libs/structs/structpath/path_test.go index a833e5611e..9d634468c3 100644 --- a/libs/structs/structpath/path_test.go +++ b/libs/structs/structpath/path_test.go @@ -463,3 +463,110 @@ func TestNewIndexPanic(t *testing.T) { NewIndex(nil, -1) // Should panic t.Error("Expected panic did not occur") } + +func TestPrefixAndSkipPrefix(t *testing.T) { + tests := []struct { + input string + n int + prefix string + skipPrefix string + }{ + { + input: "resources.jobs.my_job.tasks[0].name", + n: 0, + prefix: "", + skipPrefix: "resources.jobs.my_job.tasks[0].name", + }, + { + input: "resources.jobs.my_job.tasks[0].name", + n: 1, + prefix: "resources", + skipPrefix: "jobs.my_job.tasks[0].name", + }, + { + input: "resources.jobs.my_job.tasks[0].name", + n: 3, + prefix: "resources.jobs.my_job", + skipPrefix: "tasks[0].name", + }, + { + input: "resources.jobs.my_job.tasks[0].name", + n: 5, + prefix: "resources.jobs.my_job.tasks[0]", + skipPrefix: "name", + }, + { + input: "resources.jobs.my_job.tasks[0].name", + n: 6, + prefix: "resources.jobs.my_job.tasks[0].name", + skipPrefix: "", + }, + { + input: "resources.jobs.my_job.tasks[0].name", + n: 10, + prefix: "resources.jobs.my_job.tasks[0].name", + skipPrefix: "", + }, + { + input: "resources.jobs.my_job.tasks[0].name", + n: -1, + prefix: "", + skipPrefix: "resources.jobs.my_job.tasks[0].name", + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + path, err := Parse(tt.input) + assert.NoError(t, err) + + // Test Prefix + prefixResult := path.Prefix(tt.n) + if tt.prefix == "" { + assert.Nil(t, prefixResult) + } else { + assert.NotNil(t, prefixResult) + assert.Equal(t, tt.prefix, prefixResult.String()) + } + + // Test SkipPrefix + skipResult := path.SkipPrefix(tt.n) + if tt.skipPrefix == "" { + assert.Nil(t, skipResult) + } else { + assert.NotNil(t, skipResult) + assert.Equal(t, tt.skipPrefix, skipResult.String()) + } + }) + } +} + +func TestLen(t *testing.T) { + tests := []struct { + input string + expected int + }{ + { + input: "", + expected: 0, + }, + { + input: "field", + expected: 1, + }, + { + input: "resources.jobs['my_job'].tasks[0]", + expected: 5, + }, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + var path *PathNode + var err error + path, err = Parse(tt.input) + assert.NoError(t, err) + assert.Equal(t, tt.expected, path.Len()) + }) + } +}