Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ bundle:
name: test-bundle

# Tests implicit dependency detection for vector search indexes:
# - name (format "catalog.schema.index") should resolve the catalog and schema parts
# - name (format "catalog.schema.index") resolves the catalog and schema parts (my_index)
# - a name that already contains a ${resources...} reference is left untouched; a mix of
# references and literals is not supported (mixed_ref_index, full_ref_index)
resources:
catalogs:
my_catalog:
Expand All @@ -19,6 +21,20 @@ resources:
index_type: DIRECT_ACCESS
direct_access_index_spec:
schema_json: '{"id":"integer","vector":"array<float>"}'
mixed_ref_index:
name: ${resources.catalogs.my_catalog.name}.myschema.myindex
endpoint_name: my-endpoint
primary_key: id
index_type: DIRECT_ACCESS
direct_access_index_spec:
schema_json: '{"id":"integer","vector":"array<float>"}'
full_ref_index:
name: ${resources.catalogs.my_catalog.name}.${resources.schemas.my_schema.name}.myindex
endpoint_name: my-endpoint
primary_key: id
index_type: DIRECT_ACCESS
direct_access_index_spec:
schema_json: '{"id":"integer","vector":"array<float>"}'

targets:
dev:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,24 @@
}
},
"vector_search_indexes": {
"full_ref_index": {
"direct_access_index_spec": {
"schema_json": "{\"id\":\"integer\",\"vector\":\"array<float>\"}"
},
"endpoint_name": "my-endpoint",
"index_type": "DIRECT_ACCESS",
"name": "${resources.catalogs.my_catalog.name}.${resources.schemas.my_schema.name}.myindex",
"primary_key": "id"
},
"mixed_ref_index": {
"direct_access_index_spec": {
"schema_json": "{\"id\":\"integer\",\"vector\":\"array<float>\"}"
},
"endpoint_name": "my-endpoint",
"index_type": "DIRECT_ACCESS",
"name": "${resources.catalogs.my_catalog.name}.myschema.myindex",
"primary_key": "id"
},
"my_index": {
"direct_access_index_spec": {
"schema_json": "{\"id\":\"integer\",\"vector\":\"array<float>\"}"
Expand Down
40 changes: 34 additions & 6 deletions bundle/config/mutator/resourcemutator/capture_uc_dependencies.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/databricks/cli/bundle"
"github.com/databricks/cli/bundle/config/resources"
"github.com/databricks/cli/libs/diag"
"github.com/databricks/cli/libs/dyn/dynvar"
)

type captureUCDependencies struct{}
Expand Down Expand Up @@ -88,6 +89,18 @@ func resolveCatalog(b *bundle.Bundle, catalogName string) string {
return catalogName
}

// splitUCName splits a compound UC identifier into exactly n dot-separated
// components, returning false if it has a different number of components. Callers
// skip names containing a ${...} reference beforehand, so this is a plain split:
// a reference would push the component count past n and be rejected here anyway.
func splitUCName(name string, n int) ([]string, bool) {
parts := strings.Split(name, ".")
if len(parts) != n {
return nil, false
}
return parts, true
}

// resolveParent rewrites a `schemas/{catalog}.{schema}` parent reference so that
// a catalog or schema defined in the same bundle becomes an explicit deploy-time
// dependency. AI Gateway securables address their parent schema with this
Expand All @@ -97,8 +110,8 @@ func resolveParent(b *bundle.Bundle, parent string) string {
if !ok {
return parent
}
parts := strings.SplitN(rest, ".", 2)
if len(parts) != 2 {
parts, ok := splitUCName(rest, 2)
if !ok {
return parent
}
catalogName, schemaName := parts[0], parts[1]
Expand Down Expand Up @@ -144,9 +157,14 @@ func (m *captureUCDependencies) Apply(ctx context.Context, b *bundle.Bundle) dia
if qm == nil || qm.OutputSchemaName == "" {
continue
}
// A name that already contains a reference is left as is: we only rewrite a
// fully literal name and do not support a mix of references and literals.
if dynvar.ContainsVariableReference(qm.OutputSchemaName) {
continue
}
// OutputSchemaName is a compound "catalog.schema" string.
parts := strings.SplitN(qm.OutputSchemaName, ".", 2)
if len(parts) != 2 {
parts, ok := splitUCName(qm.OutputSchemaName, 2)
if !ok {
continue
}
catalogName, schemaName := parts[0], parts[1]
Expand All @@ -159,9 +177,14 @@ func (m *captureUCDependencies) Apply(ctx context.Context, b *bundle.Bundle) dia
if idx == nil {
continue
}
// A name that already contains a reference is left as is; a mix of
// references and literals is not supported.
if dynvar.ContainsVariableReference(idx.Name) {
continue
}
// Name is a three-part "catalog.schema.index" UC identifier.
parts := strings.SplitN(idx.Name, ".", 3)
if len(parts) != 3 {
parts, ok := splitUCName(idx.Name, 3)
if !ok {
continue
}
catalogName, schemaName := parts[0], parts[1]
Expand All @@ -187,6 +210,11 @@ func (m *captureUCDependencies) Apply(ctx context.Context, b *bundle.Bundle) dia
if ms == nil {
continue
}
// A parent that already contains a reference is left as is; a mix of
// references and literals is not supported.
if dynvar.ContainsVariableReference(ms.Parent) {
continue
}
ms.Parent = resolveParent(b, ms.Parent)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -347,3 +347,88 @@ func TestCaptureUCDependenciesNilResources(t *testing.T) {
d := bundle.Apply(t.Context(), b, CaptureUCDependencies())
require.Nil(t, d)
}

func TestSplitUCName(t *testing.T) {
tests := []struct {
name string
input string
n int
expected []string
ok bool
}{
{"three_part", "catalog.schema.index", 3, []string{"catalog", "schema", "index"}, true},
{"two_part", "catalog.schema", 2, []string{"catalog", "schema"}, true},
{"too_many_components", "a.b.c.d", 3, nil, false},
{"too_few_components", "a.b", 3, nil, false},
{"single_component", "a", 3, nil, false},
{"empty", "", 3, nil, false},
{"reference_over_counts", "${resources.catalogs.c.name}.schema.index", 3, nil, false},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
parts, ok := splitUCName(tc.input, tc.n)
assert.Equal(t, tc.ok, ok)
assert.Equal(t, tc.expected, parts)
})
}
}

// A vector search index name that already contains a ${resources...} reference is left
// untouched: a mix of references and literals is not supported. Only a fully literal name
// with exactly three components has its catalog and schema captured.
func TestCaptureUCDependenciesVectorSearchIndexReferences(t *testing.T) {
newBundle := func(indexName string) *bundle.Bundle {
return &bundle.Bundle{
Config: config.Root{
Resources: config.Resources{
Catalogs: map[string]*resources.Catalog{
"dev_catalog": {CreateCatalog: catalog.CreateCatalog{Name: "catalog1"}},
},
Schemas: map[string]*resources.Schema{
"my_schema": {CreateSchema: catalog.CreateSchema{CatalogName: "catalog1", Name: "myschema"}},
},
VectorSearchIndexes: map[string]*resources.VectorSearchIndex{
"idx": {CreateVectorIndexRequest: vectorsearch.CreateVectorIndexRequest{Name: indexName}},
},
},
},
}
}

tests := []struct {
name string
input string
expected string
}{
{
"all_literal_captured",
"catalog1.myschema.myindex",
"${resources.catalogs.dev_catalog.name}.${resources.schemas.my_schema.name}.myindex",
},
{
"catalog_reference_with_literal_schema_unsupported",
"${resources.catalogs.dev_catalog.name}.myschema.myindex",
"${resources.catalogs.dev_catalog.name}.myschema.myindex",
},
{
"fully_referenced_unchanged",
"${resources.catalogs.dev_catalog.name}.${resources.schemas.my_schema.name}.myindex",
"${resources.catalogs.dev_catalog.name}.${resources.schemas.my_schema.name}.myindex",
},
{
"more_than_three_components_skipped",
"catalog1.myschema.my.index",
"catalog1.myschema.my.index",
},
}

for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
b := newBundle(tc.input)
d := bundle.Apply(t.Context(), b, CaptureUCDependencies())
require.Nil(t, d)
assert.Equal(t, tc.expected, b.Config.Resources.VectorSearchIndexes["idx"].Name)
})
}
}
Loading