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
78d141e
structaccess: reach fields of a doubly-embedded struct
denik Aug 30, 2026
8938122
structaccess: resolve embedded fields breadth-first, as encoding/json…
denik Aug 31, 2026
5fd9c3e
structaccess: search embedded structs breadth-first across the whole …
denik Aug 31, 2026
30485c8
structaccess: say what the doubly-embedded test is really asserting
denik Sep 1, 2026
a963248
structaccess: treat a same-depth embed conflict as not found
denik Sep 1, 2026
2fc009d
structaccess: silence vet on the deliberately ambiguous test fixture
denik Aug 31, 2026
81b652a
structaccess: do not walk a cyclic embedding twice
denik Aug 31, 2026
c6cf240
structaccess: notice a diamond embedding as ambiguous
denik Aug 31, 2026
3d43956
structaccess: ambiguity is a property of the type, not of the value
denik Aug 31, 2026
ee322e6
structaccess: resolve a field on the type, then navigate the value by…
denik Sep 1, 2026
74fb436
structwalk: flatten an embed only when encoding/json flattens it
denik Sep 1, 2026
1445158
structdiff: report a tagged embed's changes under its own name
denik Sep 1, 2026
41c2599
structaccess: follow encoding/json's precedence rules for a field name
denik Sep 1, 2026
0299bab
structaccess: descend into a repeated embedded type once, as encoding…
denik Sep 1, 2026
2deeb7c
structaccess: pin the repeated-embed matrix against encoding/json
denik Sep 1, 2026
54ee0f7
structs: only the exact tag json:"-" omits a field
denik Sep 1, 2026
cfbd5a5
structstest: check the bundle's resource types against encoding/json
denik Sep 1, 2026
de29a41
dresources: check StateType and RemoteType against encoding/json
denik Sep 1, 2026
48e620f
structstest: use slices.Sort
denik Sep 1, 2026
2772ed3
libs/structs: check each package against encoding/json on a shared sh…
denik Sep 1, 2026
413c4a1
structstest: drop an unused export, modernise the field loop
denik Sep 1, 2026
91b0156
structstest: make the recorded divergences ratchets, not exemptions
denik Sep 1, 2026
eb4445b
jsonshapes: cover a tagged embed and an option-only one
denik Sep 1, 2026
53be9cb
jsonshapes: drop a redundant loop-variable copy
denik Sep 1, 2026
b176c22
structstest: inventory containers, and ratchet the two tolerated cate…
denik Sep 1, 2026
38c5cdb
structstest: count structwalk's visits instead of collapsing them
denik Sep 1, 2026
069c0af
structstest: stop treating a dash-named field as skipped
denik Sep 1, 2026
5625399
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