Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
f83e7c5
structaccess: reach fields of a doubly-embedded struct
denik Aug 30, 2026
41b8a5b
structaccess: resolve embedded fields breadth-first, as encoding/json…
denik Aug 31, 2026
edd759a
structaccess: search embedded structs breadth-first across the whole …
denik Aug 31, 2026
a5ce512
structaccess: say what the doubly-embedded test is really asserting
denik Sep 1, 2026
8c0dd78
structaccess: treat a same-depth embed conflict as not found
denik Sep 1, 2026
78895e6
structaccess: silence vet on the deliberately ambiguous test fixture
denik Aug 31, 2026
918ed97
structaccess: do not walk a cyclic embedding twice
denik Aug 31, 2026
7849fbe
structaccess: notice a diamond embedding as ambiguous
denik Aug 31, 2026
9f5ec67
structaccess: ambiguity is a property of the type, not of the value
denik Aug 31, 2026
69312a8
structaccess: resolve a field on the type, then navigate the value by…
denik Sep 1, 2026
4ba56ac
structwalk: flatten an embed only when encoding/json flattens it
denik Sep 1, 2026
f5b7bd0
structdiff: report a tagged embed's changes under its own name
denik Sep 1, 2026
db3217f
structaccess: follow encoding/json's precedence rules for a field name
denik Sep 1, 2026
cd85f5b
structaccess: descend into a repeated embedded type once, as encoding…
denik Sep 1, 2026
e3f1886
structaccess: pin the repeated-embed matrix against encoding/json
denik Sep 1, 2026
9cf9506
structs: only the exact tag json:"-" omits a field
denik Sep 1, 2026
c700706
structstest: check the bundle's resource types against encoding/json
denik Sep 1, 2026
1511eea
dresources: check StateType and RemoteType against encoding/json
denik Sep 1, 2026
b2e0e2f
structstest: use slices.Sort
denik Sep 1, 2026
66cb2b5
libs/structs: check each package against encoding/json on a shared sh…
denik Sep 1, 2026
44288a7
structstest: drop an unused export, modernise the field loop
denik Sep 1, 2026
53fd68c
structstest: make the recorded divergences ratchets, not exemptions
denik Sep 1, 2026
1e12a26
jsonshapes: cover a tagged embed and an option-only one
denik Sep 1, 2026
0954c6f
jsonshapes: drop a redundant loop-variable copy
denik Sep 1, 2026
0715a9c
structstest: inventory containers, and ratchet the two tolerated cate…
denik Sep 1, 2026
4a2afc3
structstest: count structwalk's visits instead of collapsing them
denik Sep 1, 2026
a6dfd89
structstest: stop treating a dash-named field as skipped
denik Sep 1, 2026
842ed2d
structstest: compare wire types, and report the __embed__ rename
denik Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
149 changes: 149 additions & 0 deletions bundle/config/structstest/resources_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package structstest_test

import (
"reflect"
"slices"
"strings"
"testing"

"github.com/databricks/cli/bundle/config"
"github.com/databricks/cli/bundle/config/structstest"
"github.com/databricks/cli/libs/structs/structtag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// knownDivergences lists the disagreements the bundle's resource types have with
// encoding/json today. Each entry is a bug somewhere other than this test; the test
// enumerates them so that a *new* disagreement fails while these are worked through.
//
// Nothing in the CLI marshals a resource config type with encoding/json today -- bundle
// validate -o json marshals the dyn tree -- so none of these is user-visible yet. They
// are one json.Marshal away from being so.
var knownDivergences = map[string][]string{
// The resource type embeds another struct that declares MarshalJSON and does not
// declare its own, so the embedded marshaler takes over and every field the outer
// struct adds -- id, url, lifecycle, permissions -- never reaches the wire. Fixed by
// giving the resource type the marshaler pair resources.Job has.
"dashboards": baseResourceFields("file_path", "permissions"),
"genie_spaces": baseResourceFields("file_path", "permissions"),
"database_instances": baseResourceFields("permissions"),
"database_catalogs": baseResourceFields(),
"synced_database_tables": baseResourceFields(),
"postgres_projects": baseResourceFields("permissions"),
"postgres_branches": baseResourceFields(),
"postgres_endpoints": baseResourceFields(),
"postgres_catalogs": baseResourceFields(),
"postgres_databases": baseResourceFields(),
"postgres_roles": baseResourceFields(),
"postgres_synced_tables": baseResourceFields(),
}

// walkDuplicates lists the paths structwalk visits twice for a resource, because the resource
// embeds BaseResource alongside an SDK type that declares the same json name, or two structs
// that each carry a Lifecycle. encoding/json serializes the shallower one and nothing else, so
// the second visit is a field that cannot reach the wire under that name -- and structdiff
// reports a change at the path twice. Ratcheted by name: fixing structwalk to resolve a name
// the way encoding/json does empties these, and a new shadowed field has to be added here.
var walkDuplicates = map[string][]string{
"job_runs": {"lifecycle.prevent_destroy"},
"pipelines": {"id"},
"clusters": {"lifecycle.prevent_destroy"},
"apps": {"id", "url", "lifecycle.prevent_destroy"},
"alerts": {"id"},
"sql_warehouses": {"lifecycle.prevent_destroy"},
}

// freeFormFields lists the any-typed fields of each resource. Everything at or below one is
// invisible to the packages.
var freeFormFields = map[string][]string{
"dashboards": {"serialized_dashboard"},
"genie_spaces": {"serialized_space"},
"cluster_policies": {"definition", "policy_family_definition_overrides"},
}

// freeFormFieldNames reduces the reported paths to the distinct top-level field each sits under,
// so the expectation does not depend on the filler's choice of map key.
func freeFormFieldNames(paths []string) []string {
var out []string
for _, path := range paths {
name, _, _ := strings.Cut(strings.SplitN(path, ":", 2)[0], ".")
if !slices.Contains(out, name) {
out = append(out, name)
}
}
return out
}

// baseResourceFields returns the paths a resource gains from BaseResource, plus any extra
// fields the resource declares alongside it. They are lost together, by one cause.
func baseResourceFields(extra ...string) []string {
return append([]string{"id", "url", "modified_status", "lifecycle.prevent_destroy"}, extra...)
}

// TestResourceTypesAgreeWithJSON feeds every resource type in config.Resources through
// structstest.Check. Driving it off the struct by reflection means a newly added resource
// is covered without touching this test.
func TestResourceTypesAgreeWithJSON(t *testing.T) {
rt := reflect.TypeFor[config.Resources]()

var checked int
for field := range rt.Fields() {
if field.Type.Kind() != reflect.Map {
continue
}
elem := field.Type.Elem()
if elem.Kind() != reflect.Pointer || elem.Elem().Kind() != reflect.Struct {
continue
}
group := structtag.JSONTag(field.Tag.Get("json")).Name()

t.Run(group, func(t *testing.T) {
report, err := structstest.Check(elem)
require.NoError(t, err)

var known []string
known = append(known, knownDivergences[group]...)
report, stale := report.Filter(known)
require.Empty(t, stale,
"these recorded divergences no longer occur -- remove them from the list: %v", stale)
// A known limitation: structwalk does not traverse an interface and structaccess cannot
// validate a path through one, so a free-form field is opaque to both. Which resources
// have one is stable, so it is ratcheted by name: a new free-form field is a new blind
// spot and has to be added here deliberately.
// The EmbeddedSlice convention renames exactly one key: __embed__ carries the slice
// while the walkers put its elements at the parent path. Anything else renamed would
// be a change to the convention.
for _, path := range report.RenamedByConvention {
assert.Equal(t, "__embed__", path, "only __embed__ is renamed by convention")
}
report.RenamedByConvention = nil

assert.ElementsMatch(t, walkDuplicates[group], report.WalkDuplicated,
"paths structwalk visits twice changed for %s", group)
report.WalkDuplicated = nil

assert.ElementsMatch(t, freeFormFields[group], freeFormFieldNames(report.InsideFreeFormField),
"free-form any fields changed for %s", group)
report.InsideFreeFormField = nil
if len(report.SelfMarshalingScalars) > 0 {
// A known structwalk limitation. The ratchet is on the *types* that behave this
// way, not the paths: a new field of a type already known to hide itself tells us
// nothing, while a new such type is a finding.
assert.Subset(t, structstest.KnownSelfMarshalingTypes, report.SelfMarshalingTypes,
"a Go type that marshals itself as a scalar and so is invisible to structwalk")
t.Logf("%d self-marshaling scalar field(s): %v",
len(report.SelfMarshalingScalars), report.SelfMarshalingScalars)
report.SelfMarshalingScalars = nil
report.SelfMarshalingTypes = nil
}
require.True(t, report.Empty(),
"%s (%s) disagrees with encoding/json:%s", group, elem, report)
})
checked++
}

// A guard against the loop silently matching nothing, which would make the whole
// test vacuous.
require.Greater(t, checked, 20, "expected every resource group to be checked")
}
Loading
Loading