diff --git a/acceptance/bundle/validate/empty_tasks/databricks.yml b/acceptance/bundle/validate/empty_tasks/databricks.yml new file mode 100644 index 00000000000..50865a75a65 --- /dev/null +++ b/acceptance/bundle/validate/empty_tasks/databricks.yml @@ -0,0 +1,16 @@ +bundle: + name: empty_tasks + +resources: + jobs: + empty_tasks: + name: job with empty tasks + tasks: + valid: + name: valid job + tasks: + - task_key: t + sql_task: + warehouse_id: w123 + query: + query_id: abc diff --git a/acceptance/bundle/validate/empty_tasks/out.test.toml b/acceptance/bundle/validate/empty_tasks/out.test.toml new file mode 100644 index 00000000000..f784a183258 --- /dev/null +++ b/acceptance/bundle/validate/empty_tasks/out.test.toml @@ -0,0 +1,3 @@ +Local = true +Cloud = false +EnvMatrix.DATABRICKS_BUNDLE_ENGINE = ["terraform", "direct"] diff --git a/acceptance/bundle/validate/empty_tasks/output.txt b/acceptance/bundle/validate/empty_tasks/output.txt new file mode 100644 index 00000000000..36e6a7e63a8 --- /dev/null +++ b/acceptance/bundle/validate/empty_tasks/output.txt @@ -0,0 +1,42 @@ + +>>> [CLI] bundle validate -o json +{ + "empty_tasks": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/empty_tasks/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "job with empty tasks", + "queue": { + "enabled": true + }, + "tasks": null + }, + "valid": { + "deployment": { + "kind": "BUNDLE", + "metadata_file_path": "/Workspace/Users/[USERNAME]/.bundle/empty_tasks/default/state/metadata.json" + }, + "edit_mode": "UI_LOCKED", + "format": "MULTI_TASK", + "max_concurrent_runs": 1, + "name": "valid job", + "queue": { + "enabled": true + }, + "tasks": [ + { + "sql_task": { + "query": { + "query_id": "abc" + }, + "warehouse_id": "w123" + }, + "task_key": "t" + } + ] + } +} diff --git a/acceptance/bundle/validate/empty_tasks/script b/acceptance/bundle/validate/empty_tasks/script new file mode 100644 index 00000000000..350756f76d0 --- /dev/null +++ b/acceptance/bundle/validate/empty_tasks/script @@ -0,0 +1 @@ +trace $CLI bundle validate -o json | jq .resources.jobs diff --git a/libs/dyn/pattern.go b/libs/dyn/pattern.go index 06fd3f776f6..cc53a2b0480 100644 --- a/libs/dyn/pattern.go +++ b/libs/dyn/pattern.go @@ -1,6 +1,7 @@ package dyn import ( + "errors" "fmt" "slices" "strings" @@ -116,6 +117,20 @@ func (e expectedSequenceError) Error() string { return fmt.Sprintf("expected a sequence at %q, found %s", e.p, e.v.Kind()) } +// isNoMatchError reports whether err means the pattern suffix didn't match the +// visited value. Wildcard components skip such elements (e.g. a job with an +// empty "tasks:" block) instead of failing the visit for all valid siblings. +func isNoMatchError(err error) bool { + if IsNoSuchKeyError(err) || IsIndexOutOfBoundsError(err) || IsCannotTraverseNilError(err) { + return true + } + if _, ok := errors.AsType[expectedMapError](err); ok { + return true + } + _, ok := errors.AsType[expectedSequenceError](err) + return ok +} + // This function implements the patternComponent interface. func (c anyKeyComponent) visit(v Value, prefix Path, suffix Pattern, opts visitOptions) (Value, error) { m, ok := v.AsMap() @@ -132,7 +147,7 @@ func (c anyKeyComponent) visit(v Value, prefix Path, suffix Pattern, opts visitO nv, err := visit(pv, append(prefix, Key(pk.MustString())), suffix, opts) if err != nil { // Leave the value intact if the suffix pattern didn't match any value. - if IsNoSuchKeyError(err) || IsIndexOutOfBoundsError(err) { + if isNoMatchError(err) { continue } return InvalidValue, err @@ -164,7 +179,7 @@ func (c anyIndexComponent) visit(v Value, prefix Path, suffix Pattern, opts visi nv, err := visit(value, append(prefix, Index(i)), suffix, opts) if err != nil { // Leave the value intact if the suffix pattern didn't match any value. - if IsNoSuchKeyError(err) || IsIndexOutOfBoundsError(err) { + if isNoMatchError(err) { continue } return InvalidValue, err diff --git a/libs/dyn/visit_map_test.go b/libs/dyn/visit_map_test.go index a9ba12145c7..bff4b0bd3f9 100644 --- a/libs/dyn/visit_map_test.go +++ b/libs/dyn/visit_map_test.go @@ -371,6 +371,94 @@ func TestMapByPatternOnSequence(t *testing.T) { }, vout.AsAny()) } +func TestMapByPatternWildcardSkipsNonMatchingSiblings(t *testing.T) { + tests := []struct { + name string + vin dyn.Value + pattern dyn.Pattern + want any + visited []string + }{ + { + name: "nil sibling under any key", + vin: dyn.V(map[string]dyn.Value{ + "a": dyn.NilValue, + "b": dyn.V(map[string]dyn.Value{"x": dyn.V(1)}), + }), + pattern: dyn.NewPattern(dyn.AnyKey(), dyn.Key("x")), + want: map[string]any{ + "a": nil, + "b": map[string]any{"x": 42}, + }, + visited: []string{"b.x"}, + }, + { + name: "nil sequence sibling under any key", + vin: dyn.V(map[string]dyn.Value{ + "a": dyn.V(map[string]dyn.Value{"tasks": dyn.NilValue}), + "b": dyn.V(map[string]dyn.Value{"tasks": dyn.V([]dyn.Value{dyn.V(1)})}), + }), + pattern: dyn.NewPattern(dyn.AnyKey(), dyn.Key("tasks"), dyn.AnyIndex()), + want: map[string]any{ + "a": map[string]any{"tasks": nil}, + "b": map[string]any{"tasks": []any{42}}, + }, + visited: []string{"b.tasks[0]"}, + }, + { + name: "wrong kind sibling under any key", + vin: dyn.V(map[string]dyn.Value{ + "a": dyn.V(map[string]dyn.Value{"tasks": dyn.V("oops")}), + "b": dyn.V(map[string]dyn.Value{"tasks": dyn.V([]dyn.Value{dyn.V(1)})}), + }), + pattern: dyn.NewPattern(dyn.AnyKey(), dyn.Key("tasks"), dyn.AnyIndex()), + want: map[string]any{ + "a": map[string]any{"tasks": "oops"}, + "b": map[string]any{"tasks": []any{42}}, + }, + visited: []string{"b.tasks[0]"}, + }, + { + name: "nil element under any index", + vin: dyn.V([]dyn.Value{ + dyn.NilValue, + dyn.V(map[string]dyn.Value{"x": dyn.V(1)}), + }), + pattern: dyn.NewPattern(dyn.AnyIndex(), dyn.Key("x")), + want: []any{ + nil, + map[string]any{"x": 42}, + }, + visited: []string{"[1].x"}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + var visited []string + vout, err := dyn.MapByPattern(tc.vin, tc.pattern, func(p dyn.Path, v dyn.Value) (dyn.Value, error) { + visited = append(visited, p.String()) + return dyn.V(42), nil + }) + assert.NoError(t, err) + assert.Equal(t, tc.want, vout.AsAny()) + assert.Equal(t, tc.visited, visited) + }) + } +} + +func TestMapByPatternWildcardPropagatesMapFuncError(t *testing.T) { + vin := dyn.V(map[string]dyn.Value{ + "a": dyn.V(map[string]dyn.Value{"x": dyn.V(1)}), + }) + + ref := errors.New("error") + _, err := dyn.MapByPattern(vin, dyn.NewPattern(dyn.AnyKey(), dyn.Key("x")), func(_ dyn.Path, v dyn.Value) (dyn.Value, error) { + return dyn.InvalidValue, ref + }) + assert.ErrorIs(t, err, ref) +} + func TestMapByPatternOnSequenceWithoutMatch(t *testing.T) { vin := dyn.V([]dyn.Value{ dyn.V([]dyn.Value{