diff --git a/bundle/config/mutator/log_resource_references.go b/bundle/config/mutator/log_resource_references.go index 14c6beed49..a18b3aaa24 100644 --- a/bundle/config/mutator/log_resource_references.go +++ b/bundle/config/mutator/log_resource_references.go @@ -117,7 +117,7 @@ func truncate(s string, n int, suffix string) string { func censorValue(ctx context.Context, v any, path dyn.Path) (string, error) { pathString := path.String() - pathNode, err := structpath.Parse(pathString) + pathNode, err := structpath.ParsePath(pathString) if err != nil { log.Warnf(ctx, "internal error: parsing %q: %s", pathString, err) return "err", err diff --git a/bundle/configsync/diff.go b/bundle/configsync/diff.go index a5d089d503..842be23fa6 100644 --- a/bundle/configsync/diff.go +++ b/bundle/configsync/diff.go @@ -49,7 +49,7 @@ func normalizeValue(v any) (any, error) { } func isEntityPath(path string) bool { - pathNode, err := structpath.Parse(path) + pathNode, err := structpath.ParsePath(path) if err != nil { return false } diff --git a/bundle/configsync/patch.go b/bundle/configsync/patch.go index 701894b451..3a1ae110f8 100644 --- a/bundle/configsync/patch.go +++ b/bundle/configsync/patch.go @@ -229,8 +229,9 @@ func buildNestedMaps(targetPath, missingPath yamlpatch.Path, leafValue any) any // strPathToJSONPointer converts a structpath string to JSON Pointer format. // Example: "resources.jobs.test[0].name" -> "/resources/jobs/test/0/name" +// The path may contain [*] which is converted to "-" (JSON Pointer append syntax). func strPathToJSONPointer(pathStr string) (string, error) { - node, err := structpath.Parse(pathStr) + node, err := structpath.ParsePattern(pathStr) if err != nil { return "", fmt.Errorf("failed to parse path %q: %w", pathStr, err) } diff --git a/bundle/configsync/resolve.go b/bundle/configsync/resolve.go index 2b366f8058..23314045a2 100644 --- a/bundle/configsync/resolve.go +++ b/bundle/configsync/resolve.go @@ -2,7 +2,6 @@ package configsync import ( "context" - "errors" "fmt" "sort" @@ -20,19 +19,20 @@ type FieldChange struct { // resolveSelectors converts key-value selectors to numeric indices. // Example: "resources.jobs.foo.tasks[task_key='main'].name" -> "resources.jobs.foo.tasks[1].name" -func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) (*structpath.PathNode, error) { - node, err := structpath.Parse(pathStr) +// Returns a PatternNode because for Add operations, [*] may be used as a placeholder for new elements. +func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) (*structpath.PatternNode, error) { + node, err := structpath.ParsePath(pathStr) if err != nil { return nil, fmt.Errorf("failed to parse path %s: %w", pathStr, err) } nodes := node.AsSlice() - var result *structpath.PathNode + var result *structpath.PatternNode currentValue := b.Config.Value() for _, n := range nodes { if key, ok := n.StringKey(); ok { - result = structpath.NewStringKey(result, key) + result = structpath.NewPatternStringKey(result, key) if currentValue.IsValid() { currentValue, _ = dyn.GetByPath(currentValue, dyn.Path{dyn.Key(key)}) } @@ -40,7 +40,7 @@ func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) } if idx, ok := n.Index(); ok { - result = structpath.NewIndex(result, idx) + result = structpath.NewPatternIndex(result, idx) if currentValue.IsValid() { currentValue, _ = dyn.GetByPath(currentValue, dyn.Path{dyn.Index(idx)}) } @@ -70,7 +70,7 @@ func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) if foundIndex == -1 { if operation == OperationAdd { - result = structpath.NewBracketStar(result) + result = structpath.NewPatternBracketStar(result) // Can't navigate further into non-existent element currentValue = dyn.Value{} continue @@ -78,35 +78,31 @@ func resolveSelectors(pathStr string, b *bundle.Bundle, operation OperationType) return nil, fmt.Errorf("no array element found with %s='%s' in path %s", key, value, pathStr) } - result = structpath.NewIndex(result, foundIndex) + result = structpath.NewPatternIndex(result, foundIndex) currentValue = seq[foundIndex] continue } - - if n.DotStar() || n.BracketStar() { - return nil, errors.New("wildcard patterns are not supported in field paths") - } } return result, nil } func pathDepth(pathStr string) int { - node, err := structpath.Parse(pathStr) + node, err := structpath.ParsePath(pathStr) if err != nil { return 0 } return len(node.AsSlice()) } -// adjustArrayIndex adjusts the index in a PathNode based on previous operations. +// adjustArrayIndex adjusts the index in a PatternNode based on previous operations. // When operations are applied sequentially, removals and additions shift array indices. // This function adjusts the index to account for those shifts. -func adjustArrayIndex(path *structpath.PathNode, operations map[string][]struct { +func adjustArrayIndex(path *structpath.PatternNode, operations map[string][]struct { index int operation OperationType }, -) *structpath.PathNode { +) *structpath.PatternNode { originalIndex, ok := path.Index() if !ok { return path @@ -134,7 +130,7 @@ func adjustArrayIndex(path *structpath.PathNode, operations map[string][]struct adjustedIndex = 0 } - return structpath.NewIndex(parentPath, adjustedIndex) + return structpath.NewPatternIndex(parentPath, adjustedIndex) } // ResolveChanges resolves selectors and computes field path candidates for each change. @@ -213,7 +209,7 @@ func ResolveChanges(ctx context.Context, b *bundle.Bundle, configChanges Changes if ok && len(indices) > 0 { index := indices[0] indicesToReplaceMap[parentPath] = indices[1:] - resolvedPath = structpath.NewIndex(resolvedPath.Parent(), index) + resolvedPath = structpath.NewPatternIndex(resolvedPath.Parent(), index) } } diff --git a/bundle/configsync/resolve_test.go b/bundle/configsync/resolve_test.go index 0b27b9277b..dcabf0132a 100644 --- a/bundle/configsync/resolve_test.go +++ b/bundle/configsync/resolve_test.go @@ -202,5 +202,5 @@ func TestResolveSelectors_WildcardNotSupported(t *testing.T) { _, err = resolveSelectors("resources.jobs.test_job.tasks.*.task_key", b, OperationReplace) require.Error(t, err) - assert.Contains(t, err.Error(), "wildcard patterns are not supported") + assert.Contains(t, err.Error(), "wildcards not allowed in path") } diff --git a/bundle/deploy/terraform/tfdyn/spec_fields.go b/bundle/deploy/terraform/tfdyn/spec_fields.go index 684b41d367..189e82f4b7 100644 --- a/bundle/deploy/terraform/tfdyn/spec_fields.go +++ b/bundle/deploy/terraform/tfdyn/spec_fields.go @@ -14,7 +14,7 @@ import ( // included without manual updates to the converters. func specFieldNames(v any) []string { var names []string - _ = structwalk.WalkType(reflect.TypeOf(v), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + _ = structwalk.WalkType(reflect.TypeOf(v), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { // Skip root node (path is nil) if path == nil { return true diff --git a/bundle/deployplan/plan.go b/bundle/deployplan/plan.go index fcb3695501..a4c70c7f96 100644 --- a/bundle/deployplan/plan.go +++ b/bundle/deployplan/plan.go @@ -110,26 +110,19 @@ const ( // HasChange checks if there are any changes for fields with the given prefix. // This function is path-aware and correctly handles path component boundaries. // For example: -// - HasChange("a") matches "a" and "a.b" but not "aa" -// - HasChange("config") matches "config" and "config.name" but not "configuration" -// -// Note: This function does not support wildcard patterns. -func (c *Changes) HasChange(fieldPath string) bool { +// - HasChange for path "a" matches "a" and "a.b" but not "aa" +// - HasChange for path "config" matches "config" and "config.name" but not "configuration" +func (c *Changes) HasChange(fieldPath *structpath.PathNode) bool { if c == nil { return false } - fieldPathNode, err := structpath.Parse(fieldPath) - if err != nil { - return false - } - for field := range *c { - fieldNode, err := structpath.Parse(field) + fieldNode, err := structpath.ParsePath(field) if err != nil { continue } - if fieldNode.HasPrefix(fieldPathNode) { + if fieldNode.HasPrefix(fieldPath) { return true } } diff --git a/bundle/direct/bundle_plan.go b/bundle/direct/bundle_plan.go index d5fddb77d6..e77197921d 100644 --- a/bundle/direct/bundle_plan.go +++ b/bundle/direct/bundle_plan.go @@ -363,7 +363,7 @@ func addPerFieldActions(ctx context.Context, adapter *dresources.Adapter, change generatedCfg := adapter.GeneratedResourceConfig() for pathString, ch := range changes { - path, err := structpath.Parse(pathString) + path, err := structpath.ParsePath(pathString) if err != nil { return err } @@ -584,8 +584,8 @@ func (b *DeploymentBundle) LookupReferencePreDeploy(ctx context.Context, path *s return nil, fmt.Errorf("internal error: %s: unknown resource type %q", targetResourceKey, targetGroup) } - configValidErr := structaccess.Validate(reflect.TypeOf(localConfig), fieldPath) - remoteValidErr := structaccess.Validate(adapter.RemoteType(), fieldPath) + configValidErr := structaccess.ValidatePath(reflect.TypeOf(localConfig), fieldPath) + remoteValidErr := structaccess.ValidatePath(adapter.RemoteType(), fieldPath) // Note: using adapter.RemoteType() over reflect.TypeOf(remoteState) because remoteState might be untyped nil if configValidErr != nil && remoteValidErr != nil { @@ -655,7 +655,7 @@ func (b *DeploymentBundle) resolveReferences(ctx context.Context, resourceKey st for _, pathString := range refs.References() { ref := "${" + pathString + "}" - targetPath, err := structpath.Parse(pathString) + targetPath, err := structpath.ParsePath(pathString) if err != nil { logdiag.LogError(ctx, fmt.Errorf("%s: cannot parse reference %q: %w", errorPrefix, ref, err)) return false diff --git a/bundle/direct/dresources/all_test.go b/bundle/direct/dresources/all_test.go index bcbecdf590..5233ecff47 100644 --- a/bundle/direct/dresources/all_test.go +++ b/bundle/direct/dresources/all_test.go @@ -785,7 +785,7 @@ func testCRUD(t *testing.T, group string, adapter *Adapter, client *databricks.W err = adapter.DoDelete(ctx, createdID) require.NoError(t, err) - p, err := structpath.Parse("name") + p, err := structpath.ParsePath("name") require.NoError(t, err) if adapter.HasOverrideChangeDesc() { @@ -842,13 +842,13 @@ func TestGeneratedResourceConfig(t *testing.T) { func validateResourceConfig(t *testing.T, stateType reflect.Type, cfg *ResourceLifecycleConfig) { for _, p := range cfg.RecreateOnChanges { - assert.NoError(t, structaccess.Validate(stateType, p.Field), "RecreateOnChanges: %s", p.Field) + assert.NoError(t, structaccess.ValidatePath(stateType, p.Field), "RecreateOnChanges: %s", p.Field) } for _, p := range cfg.UpdateIDOnChanges { - assert.NoError(t, structaccess.Validate(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) + assert.NoError(t, structaccess.ValidatePath(stateType, p.Field), "UpdateIDOnChanges: %s", p.Field) } for _, p := range cfg.IgnoreRemoteChanges { - assert.NoError(t, structaccess.Validate(stateType, p.Field), "IgnoreRemoteChanges: %s", p.Field) + assert.NoError(t, structaccess.ValidatePath(stateType, p.Field), "IgnoreRemoteChanges: %s", p.Field) } } diff --git a/bundle/direct/dresources/model_serving_endpoint.go b/bundle/direct/dresources/model_serving_endpoint.go index 27c98abaaa..26a10cf739 100644 --- a/bundle/direct/dresources/model_serving_endpoint.go +++ b/bundle/direct/dresources/model_serving_endpoint.go @@ -6,11 +6,20 @@ import ( "time" "github.com/databricks/cli/bundle/config/resources" + "github.com/databricks/cli/libs/structs/structpath" "github.com/databricks/cli/libs/utils" "github.com/databricks/databricks-sdk-go" "github.com/databricks/databricks-sdk-go/service/serving" ) +// Precalculated paths for HasChange checks +var ( + pathTags = structpath.MustParsePath("tags") + pathAiGateway = structpath.MustParsePath("ai_gateway") + pathConfig = structpath.MustParsePath("config") + pathEmailNotifications = structpath.MustParsePath("email_notifications") +) + type ResourceModelServingEndpoint struct { client *databricks.WorkspaceClient } @@ -281,28 +290,28 @@ func (r *ResourceModelServingEndpoint) DoUpdate(ctx context.Context, id string, // Terraform makes these API calls sequentially. We do the same here. // It's an unknown as of 1st Dec 2025 if these APIs are safe to make in parallel. (we did not check) // https://github.com/databricks/terraform-provider-databricks/blob/c61a32300445f84efb2bb6827dee35e6e523f4ff/serving/resource_model_serving.go#L373 - if changes.HasChange("tags") { + if changes.HasChange(pathTags) { err = r.updateTags(ctx, id, config.Tags) if err != nil { return nil, err } } - if changes.HasChange("ai_gateway") { + if changes.HasChange(pathAiGateway) { err = r.updateAiGateway(ctx, id, config.AiGateway) if err != nil { return nil, err } } - if changes.HasChange("config") { + if changes.HasChange(pathConfig) { err = r.updateConfig(ctx, id, config.Config) if err != nil { return nil, err } } - if changes.HasChange("email_notifications") { + if changes.HasChange(pathEmailNotifications) { err = r.updateNotifications(ctx, id, config.EmailNotifications) if err != nil { return nil, err diff --git a/bundle/direct/dresources/type_test.go b/bundle/direct/dresources/type_test.go index 39ef5547cd..140d711f95 100644 --- a/bundle/direct/dresources/type_test.go +++ b/bundle/direct/dresources/type_test.go @@ -173,7 +173,7 @@ func TestInputSubset(t *testing.T) { // Validate that all fields in InputType exist in StateType var missingFields []string - err := structwalk.WalkType(inputType, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(inputType, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { if path.IsRoot() { return true } @@ -184,7 +184,7 @@ func TestInputSubset(t *testing.T) { return false // don't recurse into internal/readonly fields } } - if structaccess.Validate(stateType, path) != nil { + if structaccess.ValidatePattern(stateType, path) != nil { missingFields = append(missingFields, path.String()) return false // don't recurse into missing field } @@ -237,11 +237,11 @@ func TestRemoteSuperset(t *testing.T) { // Validate that all fields in StateType exist in RemoteType var missingFields []string - err := structwalk.WalkType(stateType, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(stateType, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { if path.IsRoot() { return true } - if structaccess.Validate(remoteType, path) != nil { + if structaccess.ValidatePattern(remoteType, path) != nil { missingFields = append(missingFields, path.String()) return false // don't recurse into missing field } diff --git a/bundle/internal/validation/enum.go b/bundle/internal/validation/enum.go index 3612830fc8..d0c201e909 100644 --- a/bundle/internal/validation/enum.go +++ b/bundle/internal/validation/enum.go @@ -111,7 +111,7 @@ func getEnumValues(typ reflect.Type) ([]string, error) { func extractEnumFields(typ reflect.Type) ([]EnumPatternInfo, error) { fieldsByPattern := make(map[string][]string) - err := structwalk.WalkType(typ, func(path *structpath.PathNode, fieldType reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(typ, func(path *structpath.PatternNode, fieldType reflect.Type, field *reflect.StructField) bool { if path == nil { return true } diff --git a/bundle/internal/validation/required.go b/bundle/internal/validation/required.go index 76465f52d0..b08e0f4065 100644 --- a/bundle/internal/validation/required.go +++ b/bundle/internal/validation/required.go @@ -47,7 +47,7 @@ func formatSliceToString(values []string) string { func extractRequiredFields(typ reflect.Type) ([]RequiredPatternInfo, error) { fieldsByPattern := make(map[string][]string) - err := structwalk.WalkType(typ, func(path *structpath.PathNode, _ reflect.Type, field *reflect.StructField) bool { + err := structwalk.WalkType(typ, func(path *structpath.PatternNode, _ reflect.Type, field *reflect.StructField) bool { if path == nil || field == nil { return true } diff --git a/cmd/bundle/debug/refschema.go b/cmd/bundle/debug/refschema.go index c2ffc17a0c..932b8d62ab 100644 --- a/cmd/bundle/debug/refschema.go +++ b/cmd/bundle/debug/refschema.go @@ -63,7 +63,7 @@ func dumpRemoteSchemas(out io.Writer) error { pathTypes := make(map[string]map[string]map[string]struct{}) collect := func(root reflect.Type, source string) error { - return structwalk.WalkType(root, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) bool { + return structwalk.WalkType(root, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) bool { if path == nil { return true } diff --git a/libs/structs/structaccess/get.go b/libs/structs/structaccess/get.go index 0c35cf520d..f9b6b70baf 100644 --- a/libs/structs/structaccess/get.go +++ b/libs/structs/structaccess/get.go @@ -25,7 +25,7 @@ func GetByString(v any, path string) (any, error) { return v, nil } - pathNode, err := structpath.Parse(path) + pathNode, err := structpath.ParsePath(path) if err != nil { return nil, err } @@ -45,9 +45,7 @@ func getValue(v any, path *structpath.PathNode) (reflect.Value, error) { cur := reflect.ValueOf(v) for _, node := range pathSegments { - if node.DotStar() || node.BracketStar() { - return reflect.Value{}, fmt.Errorf("wildcards not supported: %s", path.String()) - } + // Note: wildcards cannot appear in PathNode (Parse rejects them) var ok bool cur, ok = deref(cur) diff --git a/libs/structs/structaccess/get_test.go b/libs/structs/structaccess/get_test.go index c583f9baff..cfc3365b75 100644 --- a/libs/structs/structaccess/get_test.go +++ b/libs/structs/structaccess/get_test.go @@ -184,7 +184,7 @@ func runCommonTests(t *testing.T, obj any) { { name: "wildcard not supported for Get", path: "items[*].id", - getOnlyErr: "wildcards not supported: items[*].id", + getOnlyErr: "wildcards not allowed in path", }, { name: "missing field", diff --git a/libs/structs/structaccess/set.go b/libs/structs/structaccess/set.go index 1f5857354f..7f12a77751 100644 --- a/libs/structs/structaccess/set.go +++ b/libs/structs/structaccess/set.go @@ -57,7 +57,7 @@ func SetByString(target any, path string, value any) error { return errors.New("cannot set empty path") } - pathNode, err := structpath.Parse(path) + pathNode, err := structpath.ParsePath(path) if err != nil { return err } @@ -81,9 +81,7 @@ func setValueAtNode(parentVal reflect.Value, node *structpath.PathNode, value an return setArrayElement(parentVal, idx, valueVal) } - if node.DotStar() || node.BracketStar() { - return errors.New("wildcards not supported") - } + // Note: wildcards cannot appear in PathNode (Parse rejects them) 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) diff --git a/libs/structs/structaccess/set_test.go b/libs/structs/structaccess/set_test.go index 2156327f71..25674c1211 100644 --- a/libs/structs/structaccess/set_test.go +++ b/libs/structs/structaccess/set_test.go @@ -41,15 +41,6 @@ type TestStruct struct { Internal string `json:"-"` } -// mustParsePath is a helper to parse path strings in tests -func mustParsePath(path string) *structpath.PathNode { - p, err := structpath.Parse(path) - if err != nil { - panic(err) - } - return p -} - // newTestStruct creates a fresh TestStruct instance for testing func newTestStruct() *TestStruct { return &TestStruct{ @@ -89,7 +80,7 @@ func TestSet(t *testing.T) { value: "NewName", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "NewName", }, @@ -101,7 +92,7 @@ func TestSet(t *testing.T) { value: "BracketName", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "BracketName", }, @@ -113,7 +104,7 @@ func TestSet(t *testing.T) { value: 30, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: 30, }, @@ -125,7 +116,7 @@ func TestSet(t *testing.T) { value: "new_version", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("info.version"), + Path: structpath.MustParsePath("info.version"), Old: "old_version", New: "new_version", }, @@ -137,7 +128,7 @@ func TestSet(t *testing.T) { value: 200, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("info.build"), + Path: structpath.MustParsePath("info.build"), Old: 100, New: 200, }, @@ -149,7 +140,7 @@ func TestSet(t *testing.T) { value: "new_map_value", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("tags['version']"), + Path: structpath.MustParsePath("tags['version']"), Old: nil, // new key New: "new_map_value", }, @@ -161,7 +152,7 @@ func TestSet(t *testing.T) { value: "dot_map_value", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("tags['version']"), + Path: structpath.MustParsePath("tags['version']"), Old: nil, // new key New: "dot_map_value", }, @@ -173,7 +164,7 @@ func TestSet(t *testing.T) { value: "new_item", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("items[1]"), + Path: structpath.MustParsePath("items[1]"), Old: "old_b", New: "new_item", }, @@ -185,7 +176,7 @@ func TestSet(t *testing.T) { value: 42, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("count"), + Path: structpath.MustParsePath("count"), Old: nil, // structdiff reports this as interface{}(nil) New: intPtr(42), }, @@ -197,7 +188,7 @@ func TestSet(t *testing.T) { value: "new custom", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("custom"), + Path: structpath.MustParsePath("custom"), Old: CustomString("old custom"), New: CustomString("new custom"), }, @@ -209,7 +200,7 @@ func TestSet(t *testing.T) { value: CustomString("typed custom"), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("custom"), + Path: structpath.MustParsePath("custom"), Old: CustomString("old custom"), New: CustomString("typed custom"), }, @@ -237,7 +228,7 @@ func TestSet(t *testing.T) { name: "error on wildcard", path: "items[*]", value: "value", - errorMsg: "wildcards not supported", + errorMsg: "wildcards not allowed in path", }, { name: "custom string to string field", @@ -245,7 +236,7 @@ func TestSet(t *testing.T) { value: CustomString("custom to regular"), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "custom to regular", }, @@ -257,7 +248,7 @@ func TestSet(t *testing.T) { value: CustomInt(35), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: 35, }, @@ -281,7 +272,7 @@ func TestSet(t *testing.T) { value: "42", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: 42, }, @@ -299,7 +290,7 @@ func TestSet(t *testing.T) { value: "false", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("active"), + Path: structpath.MustParsePath("active"), Old: true, New: false, }, @@ -317,7 +308,7 @@ func TestSet(t *testing.T) { value: "3.14", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("score"), + Path: structpath.MustParsePath("score"), Old: 85.5, New: 3.14, }, @@ -329,7 +320,7 @@ func TestSet(t *testing.T) { value: "0", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("score"), + Path: structpath.MustParsePath("score"), Old: 85.5, New: 0.0, }, @@ -347,7 +338,7 @@ func TestSet(t *testing.T) { value: "200", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("priority"), + Path: structpath.MustParsePath("priority"), Old: uint8(10), New: uint8(200), }, @@ -371,7 +362,7 @@ func TestSet(t *testing.T) { value: 42, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "42", }, @@ -383,7 +374,7 @@ func TestSet(t *testing.T) { value: true, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "true", }, @@ -395,7 +386,7 @@ func TestSet(t *testing.T) { value: false, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "false", }, @@ -407,7 +398,7 @@ func TestSet(t *testing.T) { value: 3.14, expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "3.14", }, @@ -419,7 +410,7 @@ func TestSet(t *testing.T) { value: uint8(200), expectedChanges: []structdiff.Change{ { - Path: mustParsePath("name"), + Path: structpath.MustParsePath("name"), Old: "OldName", New: "200", }, @@ -431,7 +422,7 @@ func TestSet(t *testing.T) { value: "-10", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("age"), + Path: structpath.MustParsePath("age"), Old: 25, New: -10, }, @@ -443,7 +434,7 @@ func TestSet(t *testing.T) { value: "0", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("priority"), + Path: structpath.MustParsePath("priority"), Old: uint8(10), New: uint8(0), }, @@ -457,7 +448,7 @@ func TestSet(t *testing.T) { value: "updated", expectedChanges: []structdiff.Change{ { - Path: mustParsePath("nested_items[1].name"), + Path: structpath.MustParsePath("nested_items[1].name"), Old: "second", New: "updated", }, diff --git a/libs/structs/structaccess/typecheck.go b/libs/structs/structaccess/typecheck.go index 24b2dba04d..d2f8ed2581 100644 --- a/libs/structs/structaccess/typecheck.go +++ b/libs/structs/structaccess/typecheck.go @@ -11,39 +11,50 @@ import ( // ValidateByString reports whether the given path string is valid for the provided type. // It returns nil if the path resolves fully, or an error indicating where resolution failed. -// This is a convenience function that parses the path string and calls Validate. +// This is a convenience function that parses the path string and calls ValidatePattern. +// Wildcards are allowed in the path. func ValidateByString(t reflect.Type, path string) error { if path == "" { return nil } - pathNode, err := structpath.Parse(path) + patternNode, err := structpath.ParsePattern(path) if err != nil { return err } - return Validate(t, pathNode) + return ValidatePattern(t, patternNode) } -// Validate reports whether the given path is valid for the provided type. +// ValidatePath 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 *structpath.PathNode) error { +// Paths cannot contain wildcards. +func ValidatePath(t reflect.Type, path *structpath.PathNode) error { + // PathNode is type definition of PatternNode, so we can cast directly + return ValidatePattern(t, (*structpath.PatternNode)(path)) +} + +// ValidatePattern reports whether the given pattern path is valid for the provided type. +// It returns nil if the path resolves fully, or an error indicating where resolution failed. +// Patterns may include wildcards ([*] and .*). +func ValidatePattern(t reflect.Type, path *structpath.PatternNode) error { if path.IsRoot() { return nil } + return validateNodeSlice(t, path.AsSlice()) +} - // Convert path to slice for easier iteration - pathSegments := path.AsSlice() - +// validateNodeSlice is the implementation for ValidatePattern. +func validateNodeSlice(t reflect.Type, nodes []*structpath.PatternNode) error { cur := t - for _, node := range pathSegments { + for _, node := range nodes { // Always dereference pointers at the type level. for cur.Kind() == reflect.Pointer { cur = cur.Elem() } + // Index access: slice/array if _, isIndex := node.Index(); isIndex { - // Index access: slice/array kind := cur.Kind() if kind != reflect.Slice && kind != reflect.Array { return fmt.Errorf("%s: cannot index %s", node.String(), kind) diff --git a/libs/structs/structpath/path.go b/libs/structs/structpath/path.go index 0fa9ab1bbf..0760d477b6 100644 --- a/libs/structs/structpath/path.go +++ b/libs/structs/structpath/path.go @@ -39,6 +39,10 @@ type PathNode struct { value string // Used for tagKeyValue: stores the value part of [key='value'] } +// PatternNode is a PathNode that can also contain wildcards. +// Use type conversion to access PathNode methods: (*PathNode)(patternNode).Method() +type PatternNode PathNode + func (p *PathNode) IsRoot() bool { return p == nil } @@ -53,20 +57,6 @@ func (p *PathNode) Index() (int, bool) { return -1, false } -func (p *PathNode) DotStar() bool { - if p == nil { - return false - } - return p.index == tagDotStar -} - -func (p *PathNode) BracketStar() bool { - if p == nil { - return false - } - return p.index == tagBracketStar -} - func (p *PathNode) KeyValue() (key, value string, ok bool) { if p == nil { return "", "", false @@ -173,20 +163,6 @@ func NewStringKey(prev *PathNode, fieldName string) *PathNode { return NewBracketString(prev, fieldName) } -func NewDotStar(prev *PathNode) *PathNode { - return &PathNode{ - prev: prev, - index: tagDotStar, - } -} - -func NewBracketStar(prev *PathNode) *PathNode { - return &PathNode{ - prev: prev, - index: tagBracketStar, - } -} - func NewKeyValue(prev *PathNode, key, value string) *PathNode { return &PathNode{ prev: prev, @@ -256,7 +232,109 @@ func EncodeMapKey(s string) string { return "'" + escaped + "'" } -// Parse parses a string representation of a path using a state machine. +// PatternNode methods - delegate to PathNode via casting + +func (p *PatternNode) IsRoot() bool { + return (*PathNode)(p).IsRoot() +} + +func (p *PatternNode) Index() (int, bool) { + return (*PathNode)(p).Index() +} + +func (p *PatternNode) DotStar() bool { + if p == nil { + return false + } + return p.index == tagDotStar +} + +func (p *PatternNode) BracketStar() bool { + if p == nil { + return false + } + return p.index == tagBracketStar +} + +func (p *PatternNode) KeyValue() (key, value string, ok bool) { + return (*PathNode)(p).KeyValue() +} + +func (p *PatternNode) StringKey() (string, bool) { + return (*PathNode)(p).StringKey() +} + +func (p *PatternNode) Parent() *PatternNode { + return (*PatternNode)((*PathNode)(p).Parent()) +} + +func (p *PatternNode) Len() int { + return (*PathNode)(p).Len() +} + +func (p *PatternNode) String() string { + return (*PathNode)(p).String() +} + +// AsSlice returns the pattern as a slice of PatternNodes from root to current. +func (p *PatternNode) AsSlice() []*PatternNode { + length := p.Len() + segments := make([]*PatternNode, length) + + // Fill in reverse order + current := p + for i := length - 1; i >= 0; i-- { + segments[i] = current + current = current.Parent() + } + + return segments +} + +// PatternNode constructors + +// NewPatternIndex creates a new PatternNode for an array/slice index. +func NewPatternIndex(prev *PatternNode, index int) *PatternNode { + return (*PatternNode)(NewIndex((*PathNode)(prev), index)) +} + +// NewPatternDotString creates a PatternNode for dot notation (.field). +func NewPatternDotString(prev *PatternNode, fieldName string) *PatternNode { + return (*PatternNode)(NewDotString((*PathNode)(prev), fieldName)) +} + +// NewPatternBracketString creates a PatternNode for bracket notation (["field"]). +func NewPatternBracketString(prev *PatternNode, fieldName string) *PatternNode { + return (*PatternNode)(NewBracketString((*PathNode)(prev), fieldName)) +} + +// NewPatternStringKey creates a PatternNode, choosing dot notation if the fieldName is a valid field name, +// otherwise bracket notation. +func NewPatternStringKey(prev *PatternNode, fieldName string) *PatternNode { + return (*PatternNode)(NewStringKey((*PathNode)(prev), fieldName)) +} + +func NewPatternDotStar(prev *PatternNode) *PatternNode { + return (*PatternNode)(&PathNode{ + prev: (*PathNode)(prev), + index: tagDotStar, + }) +} + +func NewPatternBracketStar(prev *PatternNode) *PatternNode { + return (*PatternNode)(&PathNode{ + prev: (*PathNode)(prev), + index: tagBracketStar, + }) +} + +func NewPatternKeyValue(prev *PatternNode, key, value string) *PatternNode { + return (*PatternNode)(NewKeyValue((*PathNode)(prev), key, value)) +} + +// parse parses a string representation of a path or pattern using a state machine. +// Returns *PatternNode on success. If wildcardAllowed is false and wildcards are +// encountered, returns an error. // // State Machine for Path Parsing: // @@ -292,7 +370,7 @@ func EncodeMapKey(s string) string { // - KEYVALUE_VALUE: (any except quote) -> KEYVALUE_VALUE, quote -> KEYVALUE_VALUE_QUOTE // - KEYVALUE_VALUE_QUOTE: quote -> KEYVALUE_VALUE (escape), "]" -> EXPECT_DOT_OR_END // - EXPECT_DOT_OR_END: "." -> FIELD_START, "[" -> BRACKET_OPEN, EOF -> END -func Parse(s string) (*PathNode, error) { +func parse(s string, wildcardAllowed bool) (*PatternNode, error) { if s == "" { return nil, nil } @@ -317,7 +395,7 @@ func Parse(s string) (*PathNode, error) { ) state := stateStart - var result *PathNode + var result *PatternNode var currentToken strings.Builder var keyValueKey string // Stores the key part of [key='value'] pos := 0 @@ -330,6 +408,9 @@ func Parse(s string) (*PathNode, error) { if ch == '[' { state = stateBracketOpen } else if ch == '*' { + if !wildcardAllowed { + return nil, errors.New("wildcards not allowed in path") + } state = stateDotStar } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) @@ -340,6 +421,9 @@ func Parse(s string) (*PathNode, error) { case stateFieldStart: if ch == '*' { + if !wildcardAllowed { + return nil, errors.New("wildcards not allowed in path") + } state = stateDotStar } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) @@ -350,11 +434,11 @@ func Parse(s string) (*PathNode, error) { case stateField: if ch == '.' { - result = NewDotString(result, currentToken.String()) + result = NewPatternDotString(result, currentToken.String()) currentToken.Reset() state = stateFieldStart } else if ch == '[' { - result = NewDotString(result, currentToken.String()) + result = NewPatternDotString(result, currentToken.String()) currentToken.Reset() state = stateBracketOpen } else if !isReservedFieldChar(ch) { @@ -366,10 +450,10 @@ func Parse(s string) (*PathNode, error) { case stateDotStar: switch ch { case '.': - result = NewDotStar(result) + result = NewPatternDotStar(result) state = stateFieldStart case '[': - result = NewDotStar(result) + result = NewPatternDotStar(result) state = stateBracketOpen default: return nil, fmt.Errorf("unexpected character '%c' after '.*' at position %d", ch, pos) @@ -382,6 +466,9 @@ func Parse(s string) (*PathNode, error) { } else if ch == '\'' { state = stateMapKey } else if ch == '*' { + if !wildcardAllowed { + return nil, errors.New("wildcards not allowed in path") + } state = stateWildcard } else if !isReservedFieldChar(ch) { currentToken.WriteByte(ch) @@ -398,7 +485,7 @@ func Parse(s string) (*PathNode, error) { if err != nil { return nil, fmt.Errorf("invalid index '%s' at position %d", currentToken.String(), pos-len(currentToken.String())) } - result = NewIndex(result, index) + result = NewPatternIndex(result, index) currentToken.Reset() state = stateExpectDotOrEnd } else { @@ -421,7 +508,7 @@ func Parse(s string) (*PathNode, error) { state = stateMapKey case ']': // End of map key - result = NewBracketString(result, currentToken.String()) + result = NewPatternBracketString(result, currentToken.String()) currentToken.Reset() state = stateExpectDotOrEnd default: @@ -430,7 +517,7 @@ func Parse(s string) (*PathNode, error) { case stateWildcard: if ch == ']' { - result = NewBracketStar(result) + result = NewPatternBracketStar(result) state = stateExpectDotOrEnd } else { return nil, fmt.Errorf("unexpected character '%c' after '*' at position %d", ch, pos) @@ -469,7 +556,7 @@ func Parse(s string) (*PathNode, error) { state = stateKeyValueValue case ']': // End of key-value - result = NewKeyValue(result, keyValueKey, currentToken.String()) + result = NewPatternKeyValue(result, keyValueKey, currentToken.String()) currentToken.Reset() keyValueKey = "" state = stateExpectDotOrEnd @@ -488,7 +575,7 @@ func Parse(s string) (*PathNode, error) { } case stateEnd: - return result, nil + break default: return nil, fmt.Errorf("parser error at position %d", pos) @@ -500,15 +587,13 @@ func Parse(s string) (*PathNode, error) { // Handle end-of-input based on final state switch state { case stateStart: - return result, nil // Empty path, result is nil + // Empty path case stateField: - result = NewDotString(result, currentToken.String()) - return result, nil + result = NewPatternDotString(result, currentToken.String()) case stateDotStar: - result = NewDotStar(result) - return result, nil + result = NewPatternDotStar(result) case stateExpectDotOrEnd: - return result, nil + // Already complete case stateFieldStart: return nil, errors.New("unexpected end of input after '.'") case stateBracketOpen: @@ -530,10 +615,41 @@ func Parse(s string) (*PathNode, error) { case stateKeyValueValueQuote: return nil, errors.New("unexpected end of input after quote in key-value value") case stateEnd: - return result, nil + // Already complete default: return nil, fmt.Errorf("parser error at position %d", pos) } + + return result, nil +} + +// ParsePath parses a path string. Wildcards are not allowed. +func ParsePath(s string) (*PathNode, error) { + pattern, err := parse(s, false) + return (*PathNode)(pattern), err +} + +// ParsePattern parses a pattern string. Wildcards are allowed. +func ParsePattern(s string) (*PatternNode, error) { + return parse(s, true) +} + +// MustParsePath parses a path string and panics on error. Wildcards are not allowed. +func MustParsePath(s string) *PathNode { + path, err := ParsePath(s) + if err != nil { + panic(err) + } + return path +} + +// MustParsePattern parses a pattern string and panics on error. Wildcards are allowed. +func MustParsePattern(s string) *PatternNode { + pattern, err := ParsePattern(s) + if err != nil { + panic(err) + } + return pattern } // isReservedFieldChar checks if character is reserved and cannot be used in field names @@ -585,12 +701,12 @@ func PureReferenceToPath(s string) (*PathNode, bool) { return nil, false } - pathNode, err := Parse(ref.References()[0]) + pattern, err := parse(ref.References()[0], false) if err != nil { return nil, false } - return pathNode, true + return (*PathNode)(pattern), true } // SkipPrefix returns a new PathNode that skips the first n components of the path. @@ -614,6 +730,7 @@ func (p *PathNode) SkipPrefix(n int) *PathNode { prev: result, key: current.key, index: current.index, + value: current.value, } current = current.Parent() } @@ -733,18 +850,19 @@ func (p *PathNode) MarshalYAML() (any, error) { } // UnmarshalYAML implements yaml.Unmarshaler for PathNode. +// Note: wildcards are not allowed in PathNode; use PatternNode for paths with wildcards. func (p *PathNode) UnmarshalYAML(unmarshal func(any) error) error { var s string if err := unmarshal(&s); err != nil { return err } - parsed, err := Parse(s) + parsed, err := parse(s, false) if err != nil { return err } if parsed == nil { return nil } - *p = *parsed + *p = *(*PathNode)(parsed) return nil } diff --git a/libs/structs/structpath/path_test.go b/libs/structs/structpath/path_test.go index 3f87476404..1dbdbdbda6 100644 --- a/libs/structs/structpath/path_test.go +++ b/libs/structs/structpath/path_test.go @@ -8,10 +8,11 @@ import ( "go.yaml.in/yaml/v3" ) -func TestPathNode(t *testing.T) { +func TestPathAndPatternNode(t *testing.T) { tests := []struct { name string - node *PathNode + pathNode *PathNode // nil for wildcard-only patterns + patternNode *PatternNode // always set String string Index any StringKey any @@ -19,117 +20,107 @@ func TestPathNode(t *testing.T) { Root any DotStar bool BracketStar bool + PathError string // expected error when parsing as path (for wildcards) }{ // Single node tests { - name: "nil path", - node: nil, - String: "", - Root: true, + name: "nil path", + pathNode: nil, + patternNode: nil, + String: "", + Root: true, }, { - name: "array index", - node: NewIndex(nil, 5), - String: "[5]", - Index: 5, + name: "array index", + pathNode: NewIndex(nil, 5), + patternNode: NewPatternIndex(nil, 5), + String: "[5]", + Index: 5, }, { - name: "map key", - node: NewDotString(nil, "mykey"), - String: `mykey`, - StringKey: "mykey", + name: "map key", + pathNode: NewDotString(nil, "mykey"), + patternNode: NewPatternStringKey(nil, "mykey"), + String: `mykey`, + StringKey: "mykey", }, { - name: "dot star", - node: NewDotStar(nil), - String: "*", - DotStar: true, - }, - { - name: "bracket star", - node: NewBracketStar(nil), - String: "[*]", - BracketStar: true, - }, - { - name: "key value", - node: NewKeyValue(nil, "name", "foo"), - String: "[name='foo']", - KeyValue: []string{"name", "foo"}, + name: "key value", + pathNode: NewKeyValue(nil, "name", "foo"), + patternNode: NewPatternKeyValue(nil, "name", "foo"), + String: "[name='foo']", + KeyValue: []string{"name", "foo"}, }, // Two node tests { - name: "struct field -> array index", - node: NewIndex(NewDotString(nil, "items"), 3), - String: "items[3]", - Index: 3, + name: "struct field -> array index", + pathNode: NewIndex(NewDotString(nil, "items"), 3), + patternNode: NewPatternIndex(NewPatternStringKey(nil, "items"), 3), + String: "items[3]", + Index: 3, }, { - name: "struct field -> map key", - node: NewBracketString(NewDotString(nil, "config"), "database.name"), - String: `config['database.name']`, - StringKey: "database.name", + name: "struct field -> map key", + pathNode: NewBracketString(NewDotString(nil, "config"), "database.name"), + patternNode: NewPatternBracketString(NewPatternStringKey(nil, "config"), "database.name"), + String: `config['database.name']`, + StringKey: "database.name", }, { - name: "struct field -> struct field", - node: NewDotString(NewDotString(nil, "user"), "name"), - String: "user.name", - StringKey: "name", + name: "struct field -> struct field", + pathNode: NewDotString(NewDotString(nil, "user"), "name"), + patternNode: NewPatternDotString(NewPatternStringKey(nil, "user"), "name"), + String: "user.name", + StringKey: "name", }, { - name: "map key -> array index", - node: NewIndex(NewBracketString(nil, "servers list"), 0), - String: `['servers list'][0]`, - Index: 0, + name: "map key -> array index", + pathNode: NewIndex(NewBracketString(nil, "servers list"), 0), + patternNode: NewPatternIndex(NewPatternBracketString(nil, "servers list"), 0), + String: `['servers list'][0]`, + Index: 0, }, { - name: "array index -> struct field", - node: NewDotString(NewIndex(nil, 2), "id"), - String: "[2].id", - StringKey: "id", + name: "array index -> struct field", + pathNode: NewDotString(NewIndex(nil, 2), "id"), + patternNode: NewPatternDotString(NewPatternIndex(nil, 2), "id"), + String: "[2].id", + StringKey: "id", }, { - name: "array index -> map key", - node: NewStringKey(NewIndex(nil, 1), "status{}"), - String: `[1]['status{}']`, - StringKey: "status{}", - }, - { - name: "dot star with parent", - node: NewDotStar(NewStringKey(nil, "Parent")), - String: "Parent.*", - DotStar: true, - }, - { - name: "bracket star with parent", - node: NewBracketStar(NewStringKey(nil, "Parent")), - String: "Parent[*]", - BracketStar: true, + name: "array index -> map key", + pathNode: NewStringKey(NewIndex(nil, 1), "status{}"), + patternNode: NewPatternStringKey(NewPatternIndex(nil, 1), "status{}"), + String: `[1]['status{}']`, + StringKey: "status{}", }, // Edge cases with special characters in map keys { - name: "map key with single quote", - node: NewStringKey(nil, "key's"), - String: `['key''s']`, - StringKey: "key's", + name: "map key with single quote", + pathNode: NewStringKey(nil, "key's"), + patternNode: NewPatternStringKey(nil, "key's"), + String: `['key''s']`, + StringKey: "key's", }, { - name: "map key with multiple single quotes", - node: NewStringKey(nil, "''"), - String: `['''''']`, - StringKey: "''", + name: "map key with multiple single quotes", + pathNode: NewStringKey(nil, "''"), + patternNode: NewPatternStringKey(nil, "''"), + String: `['''''']`, + StringKey: "''", }, { - name: "empty map key", - node: NewStringKey(nil, ""), - String: `['']`, - StringKey: "", + name: "empty map key", + pathNode: NewStringKey(nil, ""), + patternNode: NewPatternStringKey(nil, ""), + String: `['']`, + StringKey: "", }, { name: "complex path", - node: NewStringKey( + pathNode: NewStringKey( NewIndex( NewStringKey( NewStringKey( @@ -138,144 +129,206 @@ func TestPathNode(t *testing.T) { "theme.list"), 0), "color"), + patternNode: NewPatternStringKey( + NewPatternIndex( + NewPatternStringKey( + NewPatternStringKey( + NewPatternStringKey(nil, "user"), + "settings"), + "theme.list"), + 0), + "color"), String: "user.settings['theme.list'][0].color", StringKey: "color", }, { - name: "field with special characters", - node: NewStringKey(nil, "field@name:with#symbols!"), - String: "field@name:with#symbols!", - StringKey: "field@name:with#symbols!", + name: "field with special characters", + pathNode: NewStringKey(nil, "field@name:with#symbols!"), + patternNode: NewPatternStringKey(nil, "field@name:with#symbols!"), + String: "field@name:with#symbols!", + StringKey: "field@name:with#symbols!", }, { - name: "field with spaces", - node: NewStringKey(nil, "field with spaces"), - String: "['field with spaces']", - StringKey: "field with spaces", + name: "field with spaces", + pathNode: NewStringKey(nil, "field with spaces"), + patternNode: NewPatternStringKey(nil, "field with spaces"), + String: "['field with spaces']", + StringKey: "field with spaces", }, { - name: "field starting with digit", - node: NewStringKey(nil, "123field"), - String: "123field", - StringKey: "123field", + name: "field starting with digit", + pathNode: NewStringKey(nil, "123field"), + patternNode: NewPatternStringKey(nil, "123field"), + String: "123field", + StringKey: "123field", }, { - name: "field with unicode", - node: NewStringKey(nil, "名前🙂"), - String: "名前🙂", - StringKey: "名前🙂", + name: "field with unicode", + pathNode: NewStringKey(nil, "名前🙂"), + patternNode: NewPatternStringKey(nil, "名前🙂"), + String: "名前🙂", + StringKey: "名前🙂", }, { - name: "map key with reserved characters", - node: NewStringKey(nil, "key\x00[],`"), - String: "['key\x00[],`']", - StringKey: "key\x00[],`", + name: "map key with reserved characters", + pathNode: NewStringKey(nil, "key\x00[],`"), + patternNode: NewPatternStringKey(nil, "key\x00[],`"), + String: "['key\x00[],`']", + StringKey: "key\x00[],`", }, + // Key-value tests { - name: "field dot star bracket index", - node: NewIndex(NewDotStar(NewStringKey(nil, "bla")), 0), - String: "bla.*[0]", - Index: 0, + name: "key value with parent", + pathNode: NewKeyValue(NewStringKey(nil, "tasks"), "task_key", "my_task"), + patternNode: NewPatternKeyValue(NewPatternStringKey(nil, "tasks"), "task_key", "my_task"), + String: "tasks[task_key='my_task']", + KeyValue: []string{"task_key", "my_task"}, }, { - name: "field dot star bracket star", - node: NewBracketStar(NewDotStar(NewStringKey(nil, "bla"))), - String: "bla.*[*]", - BracketStar: true, + name: "key value then field", + pathNode: NewStringKey(NewKeyValue(nil, "name", "foo"), "id"), + patternNode: NewPatternStringKey(NewPatternKeyValue(nil, "name", "foo"), "id"), + String: "[name='foo'].id", + StringKey: "id", + }, + { + name: "key value with quote in value", + pathNode: NewKeyValue(nil, "name", "it's"), + patternNode: NewPatternKeyValue(nil, "name", "it's"), + String: "[name='it''s']", + KeyValue: []string{"name", "it's"}, + }, + { + name: "key value with empty value", + pathNode: NewKeyValue(nil, "key", ""), + patternNode: NewPatternKeyValue(nil, "key", ""), + String: "[key='']", + KeyValue: []string{"key", ""}, + }, + { + name: "complex path with key value", + pathNode: NewStringKey(NewKeyValue(NewStringKey(NewStringKey(nil, "resources"), "jobs"), "task_key", "my_task"), "notebook_task"), + patternNode: NewPatternStringKey(NewPatternKeyValue(NewPatternStringKey(NewPatternStringKey(nil, "resources"), "jobs"), "task_key", "my_task"), "notebook_task"), + String: "resources.jobs[task_key='my_task'].notebook_task", + StringKey: "notebook_task", }, - // Key-value tests + // Wildcard patterns (cannot be parsed as PathNode) { - name: "key value with parent", - node: NewKeyValue(NewStringKey(nil, "tasks"), "task_key", "my_task"), - String: "tasks[task_key='my_task']", - KeyValue: []string{"task_key", "my_task"}, + name: "dot star", + patternNode: NewPatternDotStar(nil), + String: "*", + DotStar: true, + PathError: "wildcards not allowed in path", }, { - name: "key value then field", - node: NewStringKey(NewKeyValue(nil, "name", "foo"), "id"), - String: "[name='foo'].id", - StringKey: "id", + name: "bracket star", + patternNode: NewPatternBracketStar(nil), + String: "[*]", + BracketStar: true, + PathError: "wildcards not allowed in path", }, { - name: "key value with quote in value", - node: NewKeyValue(nil, "name", "it's"), - String: "[name='it''s']", - KeyValue: []string{"name", "it's"}, + name: "dot star with parent", + patternNode: NewPatternDotStar(NewPatternStringKey(nil, "Parent")), + String: "Parent.*", + DotStar: true, + PathError: "wildcards not allowed in path", }, { - name: "key value with empty value", - node: NewKeyValue(nil, "key", ""), - String: "[key='']", - KeyValue: []string{"key", ""}, + name: "bracket star with parent", + patternNode: NewPatternBracketStar(NewPatternStringKey(nil, "Parent")), + String: "Parent[*]", + BracketStar: true, + PathError: "wildcards not allowed in path", }, { - name: "complex path with key value", - node: NewStringKey(NewKeyValue(NewStringKey(NewStringKey(nil, "resources"), "jobs"), "task_key", "my_task"), "notebook_task"), - String: "resources.jobs[task_key='my_task'].notebook_task", - StringKey: "notebook_task", + name: "field dot star bracket index", + patternNode: NewPatternIndex(NewPatternDotStar(NewPatternStringKey(nil, "bla")), 0), + String: "bla.*[0]", + PathError: "wildcards not allowed in path", + }, + { + name: "field dot star bracket star", + patternNode: NewPatternBracketStar(NewPatternDotStar(NewPatternStringKey(nil, "bla"))), + String: "bla.*[*]", + BracketStar: true, + PathError: "wildcards not allowed in path", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Test String() method - result := tt.node.String() - assert.Equal(t, tt.String, result, "String() method") - - // Test roundtrip conversion: String() -> Parse() -> String() - parsed, err := Parse(tt.String) - if assert.NoError(t, err, "Parse() should not error") { - assert.Equal(t, tt.node, parsed) - roundtripResult := parsed.String() - assert.Equal(t, tt.String, roundtripResult, "Roundtrip conversion should be identical") + // Test pattern parsing and roundtrip + parsedPattern, err := ParsePattern(tt.String) + if assert.NoError(t, err, "ParsePattern() should not error") { + assert.Equal(t, tt.patternNode, parsedPattern) + assert.Equal(t, tt.String, parsedPattern.String(), "Pattern roundtrip") } - // Index - gotIndex, isIndex := tt.node.Index() - if tt.Index == nil { - assert.Equal(t, -1, gotIndex) - assert.False(t, isIndex) - } else { - expectedIndex := tt.Index.(int) - assert.Equal(t, expectedIndex, gotIndex) - assert.True(t, isIndex) + // Test DotStar and BracketStar on pattern + if tt.patternNode != nil { + assert.Equal(t, tt.DotStar, tt.patternNode.DotStar()) + assert.Equal(t, tt.BracketStar, tt.patternNode.BracketStar()) } - gotStringKey, isStringKey := tt.node.StringKey() - if tt.StringKey == nil { - assert.Equal(t, "", gotStringKey) - assert.False(t, isStringKey) + // Test path parsing + if tt.PathError != "" { + // Wildcard pattern - should fail to parse as path + _, err := ParsePath(tt.String) + if assert.Error(t, err) { + assert.Contains(t, err.Error(), tt.PathError) + } } else { - expected := tt.StringKey.(string) - assert.Equal(t, expected, gotStringKey) - assert.True(t, isStringKey) - } + // Concrete path - should parse successfully as both path and pattern + parsedPath, err := ParsePath(tt.String) + if assert.NoError(t, err, "ParsePath() should not error") { + assert.Equal(t, tt.pathNode, parsedPath) + assert.Equal(t, tt.String, parsedPath.String(), "Path roundtrip") + } - // KeyValue - gotKey, gotValue, isKeyValue := tt.node.KeyValue() - if tt.KeyValue == nil { - assert.Equal(t, "", gotKey) - assert.Equal(t, "", gotValue) - assert.False(t, isKeyValue) - } else { - assert.Equal(t, tt.KeyValue[0], gotKey) - assert.Equal(t, tt.KeyValue[1], gotValue) - assert.True(t, isKeyValue) - } + // Test PathNode-specific methods + gotIndex, isIndex := tt.pathNode.Index() + if tt.Index == nil { + assert.Equal(t, -1, gotIndex) + assert.False(t, isIndex) + } else { + expectedIndex := tt.Index.(int) + assert.Equal(t, expectedIndex, gotIndex) + assert.True(t, isIndex) + } - // IsRoot - isRoot := tt.node.IsRoot() - if tt.Root == nil { - assert.False(t, isRoot) - } else { - assert.True(t, isRoot) - } + gotStringKey, isStringKey := tt.pathNode.StringKey() + if tt.StringKey == nil { + assert.Equal(t, "", gotStringKey) + assert.False(t, isStringKey) + } else { + expected := tt.StringKey.(string) + assert.Equal(t, expected, gotStringKey) + assert.True(t, isStringKey) + } + + // KeyValue + gotKey, gotValue, isKeyValue := tt.pathNode.KeyValue() + if tt.KeyValue == nil { + assert.Equal(t, "", gotKey) + assert.Equal(t, "", gotValue) + assert.False(t, isKeyValue) + } else { + assert.Equal(t, tt.KeyValue[0], gotKey) + assert.Equal(t, tt.KeyValue[1], gotValue) + assert.True(t, isKeyValue) + } - // DotStar and BracketStar - assert.Equal(t, tt.DotStar, tt.node.DotStar()) - assert.Equal(t, tt.BracketStar, tt.node.BracketStar()) + // IsRoot + isRoot := tt.pathNode.IsRoot() + if tt.Root == nil { + assert.False(t, isRoot) + } else { + assert.True(t, isRoot) + } + } }) } } @@ -484,7 +537,7 @@ func TestParseErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - _, err := Parse(tt.input) + _, err := ParsePattern(tt.input) // Allow wildcards in error tests if assert.Error(t, err) { assert.Equal(t, tt.error, err.Error()) } @@ -555,7 +608,7 @@ func TestPrefixAndSkipPrefix(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - path, err := Parse(tt.input) + path, err := ParsePath(tt.input) assert.NoError(t, err) // Test Prefix @@ -600,9 +653,7 @@ func TestLen(t *testing.T) { for _, tt := range tests { t.Run(tt.input, func(t *testing.T) { - var path *PathNode - var err error - path, err = Parse(tt.input) + path, err := ParsePath(tt.input) assert.NoError(t, err) assert.Equal(t, tt.expected, path.Len()) }) @@ -731,20 +782,6 @@ func TestHasPrefix(t *testing.T) { expected: false, }, - // wildcard patterns are NOT supported - treated as literals - { - name: "regex pattern not respected - star quantifier", - s: "aaa", - prefix: "a*", - expected: false, - }, - { - name: "regex pattern not respected - bracket class", - s: "a[1]", - prefix: "a[*]", - expected: false, - }, - // Exact component matching - array indices, bracket keys, and key-value notation { name: "prefix longer than path", @@ -780,10 +817,10 @@ func TestHasPrefix(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - path, err := Parse(tt.s) + path, err := ParsePath(tt.s) require.NoError(t, err) - prefix, err := Parse(tt.prefix) + prefix, err := ParsePath(tt.prefix) require.NoError(t, err) result := path.HasPrefix(prefix) @@ -855,11 +892,6 @@ func TestPathNodeYAMLUnmarshal(t *testing.T) { input: "items[0].name", expected: "items[0].name", }, - { - name: "path with wildcard", - input: "tasks[*].name", - expected: "tasks[*].name", - }, { name: "path with key-value", input: "tags[key='server']", @@ -920,6 +952,11 @@ func TestPathNodeYAMLUnmarshalErrors(t *testing.T) { input: "field['key", error: "unexpected end of input while parsing map key", }, + { + name: "wildcard not allowed in PathNode", + input: "tasks[*].name", + error: "wildcards not allowed", + }, } for _, tt := range tests { @@ -938,7 +975,6 @@ func TestPathNodeYAMLRoundtrip(t *testing.T) { "name", "config.database", "items[0].name", - "tasks[*].settings", "tags[key='env'].value", "resources.jobs['my-job'].tasks[0]", } @@ -946,7 +982,7 @@ func TestPathNodeYAMLRoundtrip(t *testing.T) { for _, path := range paths { t.Run(path, func(t *testing.T) { // Parse -> Marshal -> Unmarshal -> compare - original, err := Parse(path) + original, err := ParsePath(path) require.NoError(t, err) data, err := yaml.Marshal(original) diff --git a/libs/structs/structvar/structvar.go b/libs/structs/structvar/structvar.go index 68c372a528..b60f8c15ee 100644 --- a/libs/structs/structvar/structvar.go +++ b/libs/structs/structvar/structvar.go @@ -82,7 +82,7 @@ func (sv *StructVar) ResolveRef(reference string, value any) error { foundAny = true // Parse the path - pathNode, err := structpath.Parse(pathKey) + pathNode, err := structpath.ParsePath(pathKey) if err != nil { return fmt.Errorf("invalid path %q: %w", pathKey, err) } diff --git a/libs/structs/structwalk/walk_test.go b/libs/structs/structwalk/walk_test.go index 302679eaff..d754329b6d 100644 --- a/libs/structs/structwalk/walk_test.go +++ b/libs/structs/structwalk/walk_test.go @@ -18,7 +18,7 @@ func flatten(t *testing.T, value any) map[string]any { results[s] = value // Test path parsing round trip - newPath, err := structpath.Parse(s) + newPath, err := structpath.ParsePath(s) if assert.NoError(t, err, s) { newS := newPath.String() assert.Equal(t, path, newPath, "s=%q newS=%q", s, newS) diff --git a/libs/structs/structwalk/walktype.go b/libs/structs/structwalk/walktype.go index 56b60a92f2..604e3e5c4a 100644 --- a/libs/structs/structwalk/walktype.go +++ b/libs/structs/structwalk/walktype.go @@ -11,29 +11,31 @@ import ( // VisitTypeFunc is invoked for fields encountered while walking typ. This includes both leaf nodes as well as any // intermediate nodes encountered while walking the struct tree. // -// path PathNode representing the JSON-style path to the field. -// typ the field's type – if the field is a pointer to a scalar the pointer type is preserved; -// the callback receives the actual type (e.g., *string, *int, etc.). -// field the struct field if this node represents a struct field, nil otherwise. +// path PatternNode representing the JSON-style path to the field (may include wildcards). +// typ the field's type – if the field is a pointer to a scalar the pointer type is preserved; +// the callback receives the actual type (e.g., *string, *int, etc.). +// field the struct field if this node represents a struct field, nil otherwise. // // The function returns a boolean: -// continueWalk: if true, the WalkType function will continue recursively walking the current field. -// if false, the WalkType function will skip walking the current field and all its children. +// +// continueWalk: if true, the WalkType function will continue recursively walking the current field. +// if false, the WalkType function will skip walking the current field and all its children. // // NOTE: Fields lacking a json tag or tagged as "-" are ignored entirely. -// Dynamic types like func, chan, interface, etc. are *not* visited. -// Only maps with string keys are traversed so that paths stay JSON-like. +// +// Dynamic types like func, chan, interface, etc. are *not* visited. +// Only maps with string keys are traversed so that paths stay JSON-like. // // The walk is depth-first and deterministic (map keys are sorted lexicographically). // // Example: -// err := structwalk.WalkType(reflect.TypeOf(cfg), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) { -// fmt.Printf("%s = %v\n", path.String(), typ) -// }) +// +// err := structwalk.WalkType(reflect.TypeOf(cfg), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) { +// fmt.Printf("%s = %v\n", path.String(), typ) +// }) // // ****************************************************************************************************** - -type VisitTypeFunc func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) +type VisitTypeFunc func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) // WalkType validates that t is a struct or pointer to one and starts the recursive traversal. func WalkType(t reflect.Type, visit VisitTypeFunc) error { @@ -48,7 +50,7 @@ func WalkType(t reflect.Type, visit VisitTypeFunc) error { return nil } -func walkTypeValue(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { +func walkTypeValue(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { if typ == nil { return } @@ -84,14 +86,14 @@ func walkTypeValue(path *structpath.PathNode, typ reflect.Type, field *reflect.S walkTypeStruct(path, typ, visit, visitedCount) case reflect.Slice, reflect.Array: - walkTypeValue(structpath.NewBracketStar(path), typ.Elem(), nil, visit, visitedCount) + walkTypeValue(structpath.NewPatternBracketStar(path), typ.Elem(), nil, visit, visitedCount) case reflect.Map: if typ.Key().Kind() != reflect.String { return // unsupported map key type } // For maps, we walk the value type directly at the current path - walkTypeValue(structpath.NewDotStar(path), typ.Elem(), nil, visit, visitedCount) + walkTypeValue(structpath.NewPatternDotStar(path), typ.Elem(), nil, visit, visitedCount) default: // func, chan, interface, invalid, etc. -> ignore @@ -100,7 +102,7 @@ func walkTypeValue(path *structpath.PathNode, typ reflect.Type, field *reflect.S visitedCount[typ]-- } -func walkTypeStruct(path *structpath.PathNode, st reflect.Type, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { +func walkTypeStruct(path *structpath.PatternNode, st reflect.Type, visit VisitTypeFunc, visitedCount map[reflect.Type]int) { for i := range st.NumField() { sf := st.Field(i) if sf.PkgPath != "" { @@ -127,7 +129,7 @@ func walkTypeStruct(path *structpath.PathNode, st reflect.Type, visit VisitTypeF if fieldName == "" { fieldName = sf.Name } - node := structpath.NewDotString(path, fieldName) + node := structpath.NewPatternDotString(path, fieldName) walkTypeValue(node, sf.Type, &sf, visit, visitedCount) } } diff --git a/libs/structs/structwalk/walktype_bench_test.go b/libs/structs/structwalk/walktype_bench_test.go index aea1cedec5..e0c8a8c344 100644 --- a/libs/structs/structwalk/walktype_bench_test.go +++ b/libs/structs/structwalk/walktype_bench_test.go @@ -11,7 +11,7 @@ import ( func countFields(typ reflect.Type) (int, error) { fieldCount := 0 - err := WalkType(typ, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(typ, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { fieldCount++ return true }) diff --git a/libs/structs/structwalk/walktype_test.go b/libs/structs/structwalk/walktype_test.go index 0ad985cf71..ba24d0f6cc 100644 --- a/libs/structs/structwalk/walktype_test.go +++ b/libs/structs/structwalk/walktype_test.go @@ -14,7 +14,7 @@ import ( func getScalarFields(t *testing.T, typ reflect.Type) map[string]any { results := make(map[string]any) - err := WalkType(typ, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(typ, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { for typ.Kind() == reflect.Pointer { typ = typ.Elem() } @@ -26,8 +26,8 @@ func getScalarFields(t *testing.T, typ reflect.Type) map[string]any { } // Test structpath round trip as well - pathNew, err := structpath.Parse(s) - if assert.NoError(t, err, "Parse(path.String()) failed for %q: %s", s, err) { + pathNew, err := structpath.ParsePattern(s) + if assert.NoError(t, err, "ParsePattern(path.String()) failed for %q: %s", s, err) { newS := pathNew.String() assert.Equal(t, path, pathNew, "Parse(path.String()) returned different path;\npath=%#v %q\npathNew=%#v %q", path, s, pathNew, newS) assert.Equal(t, s, newS, "Parse(path.String()).String() is different from path.String()\npath.String()=%q\npathNew.String()=%q", path, pathNew) @@ -167,7 +167,7 @@ func TestTypeRoot(t *testing.T) { func getReadonlyFields(t *testing.T, rootType reflect.Type) []string { var results []string - err := WalkType(rootType, func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(rootType, func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil || field == nil { return true } @@ -206,7 +206,7 @@ func TestTypeBundleTag(t *testing.T) { } var readonly, internal []string - err := WalkType(reflect.TypeOf(Foo{}), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(reflect.TypeOf(Foo{}), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil || field == nil { return true } @@ -241,7 +241,7 @@ func TestWalkTypeVisited(t *testing.T) { } var visited []string - err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil { return true } @@ -280,7 +280,7 @@ func TestWalkSkip(t *testing.T) { } var seen []string - err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PathNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { + err := WalkType(reflect.TypeOf(Outer{}), func(path *structpath.PatternNode, typ reflect.Type, field *reflect.StructField) (continueWalk bool) { if path == nil { return true }