Skip to content

Commit ea33af9

Browse files
Support nested variable references like ${var.foo_${var.tail}}
The parser now treats outer ${...} prefixes as literal text when a nested ${ is encountered, allowing inner references to be resolved first. Multi-round resolution progressively resolves from inside out. Co-authored-by: Isaac
1 parent 8fabf56 commit ea33af9

File tree

10 files changed

+121
-21
lines changed

10 files changed

+121
-21
lines changed

NEXT_CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
* Deduplicate grant entries with duplicate principals or privileges during initialization ([#4801](https://github.com/databricks/cli/pull/4801))
1313
* engine/direct: Fix unwanted recreation of secret scopes when scope_backend_type is not set ([#4834](https://github.com/databricks/cli/pull/4834))
1414

15-
* **Breaking**: Nested variable references like `${var.foo_${var.tail}}` are now rejected with a warning and left unresolved. Previously the regex-based parser matched only the innermost `${var.tail}` by coincidence, which silently produced incorrect results. If you rely on dynamic variable name construction, use separate variables or target overrides instead ([#4747](https://github.com/databricks/cli/pull/4747)).
15+
* Replace regex-based variable interpolation with a character scanner. Escape sequences `\$` and `\\` are now supported ([#4747](https://github.com/databricks/cli/pull/4747)).
1616

1717
### Dependency updates
1818

acceptance/bundle/variables/var_in_var/databricks.yml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ variables:
99
tail:
1010
default: x
1111
final:
12-
# This works but it's not officially supported, please do not rely on it.
13-
# We might start to reject this config in the future.
1412
default: ${var.foo_${var.tail}}
1513

1614
targets:

acceptance/bundle/variables/var_in_var/output.txt

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,9 @@
11

22
>>> [CLI] bundle validate -o json -t target_x
3-
Warning: nested variable references are not supported
4-
at variables.final.default
5-
in databricks.yml:14:14
6-
73
{
84
"final": {
9-
"default": "${var.foo_${var.tail}}",
10-
"value": "${var.foo_${var.tail}}"
5+
"default": "hello from foo x",
6+
"value": "hello from foo x"
117
},
128
"foo_x": {
139
"default": "hello from foo x",
@@ -24,14 +20,10 @@ Warning: nested variable references are not supported
2420
}
2521

2622
>>> [CLI] bundle validate -o json -t target_y
27-
Warning: nested variable references are not supported
28-
at variables.final.default
29-
in databricks.yml:14:14
30-
3123
{
3224
"final": {
33-
"default": "${var.foo_${var.tail}}",
34-
"value": "${var.foo_${var.tail}}"
25+
"default": "hi from foo y",
26+
"value": "hi from foo y"
3527
},
3628
"foo_x": {
3729
"default": "hello from foo x",
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
bundle:
2+
name: test-bundle
3+
4+
variables:
5+
env:
6+
default: prod
7+
region_prod:
8+
default: us
9+
endpoint_us:
10+
default: https://us.example.com
11+
result:
12+
default: ${var.endpoint_${var.region_${var.env}}}

acceptance/bundle/variables/var_in_var_3level/out.test.toml

Lines changed: 5 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
2+
>>> [CLI] bundle validate -o json
3+
{
4+
"endpoint_us": {
5+
"default": "https://us.example.com",
6+
"value": "https://us.example.com"
7+
},
8+
"env": {
9+
"default": "prod",
10+
"value": "prod"
11+
},
12+
"region_prod": {
13+
"default": "us",
14+
"value": "us"
15+
},
16+
"result": {
17+
"default": "https://us.example.com",
18+
"value": "https://us.example.com"
19+
}
20+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
trace $CLI bundle validate -o json | jq .variables

libs/dyn/dynvar/resolve_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,35 @@ func TestResolveSequenceVariable(t *testing.T) {
394394
assert.Equal(t, "value2", seq[1].MustString())
395395
}
396396

397+
func TestResolveNestedVariableReference(t *testing.T) {
398+
in := dyn.V(map[string]dyn.Value{
399+
"tail": dyn.V("x"),
400+
"foo_x": dyn.V("hello"),
401+
"final": dyn.V("${foo_${tail}}"),
402+
})
403+
404+
// First pass resolves ${tail} -> "x", producing "${foo_x}" for final.
405+
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
406+
require.NoError(t, err)
407+
408+
// After one pass, the inner ref is resolved but the outer is not yet.
409+
assert.Equal(t, "${foo_x}", getByPath(t, out, "final").MustString())
410+
}
411+
412+
func TestResolveThreeLevelNestedVariableReference(t *testing.T) {
413+
in := dyn.V(map[string]dyn.Value{
414+
"c": dyn.V("z"),
415+
"b_z": dyn.V("y"),
416+
"a_y": dyn.V("hello"),
417+
"final": dyn.V("${a_${b_${c}}}"),
418+
})
419+
420+
// First pass resolves ${c} -> "z", producing "${a_${b_z}}" for final.
421+
out, err := dynvar.Resolve(in, dynvar.DefaultLookup(in))
422+
require.NoError(t, err)
423+
assert.Equal(t, "${a_${b_z}}", getByPath(t, out, "final").MustString())
424+
}
425+
397426
func TestResolveEscapedRef(t *testing.T) {
398427
in := dyn.V(map[string]dyn.Value{
399428
"a": dyn.V("a"),

libs/interpolation/parse.go

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,16 @@ var indexPattern = regexp.MustCompile(`^(\[[0-9]+\])+$`)
5757
// - "\$" produces a literal "$"
5858
// - "\\" produces a literal "\"
5959
//
60+
// Nested references like "${a.${b}}" are supported by treating the outer
61+
// "${a." as literal text so that inner references are resolved first.
62+
// After resolution the resulting string (e.g. "${a.x}") is re-parsed.
63+
//
6064
// Examples:
6165
// - "hello" -> [Literal("hello")]
6266
// - "${a.b}" -> [Ref("a.b")]
6367
// - "pre ${a.b} post" -> [Literal("pre "), Ref("a.b"), Literal(" post")]
6468
// - "\${a.b}" -> [Literal("${a.b}")]
69+
// - "${a.${b}}" -> [Literal("${a."), Ref("b"), Literal("}")]
6570
func Parse(s string) ([]Token, error) {
6671
if len(s) == 0 {
6772
return nil, nil
@@ -136,18 +141,30 @@ func Parse(s string) ([]Token, error) {
136141
refStart := i
137142
j := i + 2 // skip "${"
138143

139-
// Scan until closing '}', rejecting nested '${'.
144+
// Scan until closing '}', handling nested '${'.
140145
pathStart := j
146+
nested := false
141147
for j < len(s) && s[j] != closeBrace {
142148
if s[j] == dollarChar && j+1 < len(s) && s[j+1] == openBrace {
143-
return nil, &ParseError{
144-
Msg: "nested variable references are not supported",
145-
Pos: refStart,
146-
}
149+
// Nested '${' found. Treat the outer "${..." prefix as
150+
// literal so inner references get resolved first.
151+
// E.g. "${a.${b}}" produces:
152+
// [Literal("${a."), Ref("b"), Literal("}")]
153+
nested = true
154+
break
147155
}
148156
j++
149157
}
150158

159+
if nested {
160+
if buf.Len() == 0 {
161+
litStart = refStart
162+
}
163+
buf.WriteString(s[refStart:j])
164+
i = j
165+
continue
166+
}
167+
151168
if j >= len(s) {
152169
return nil, &ParseError{
153170
Msg: "unterminated variable reference",

libs/interpolation/parse_test.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,33 @@ func TestParse(t *testing.T) {
178178
{Kind: TokenRef, Value: "b", Start: 6, End: 10},
179179
},
180180
},
181+
{
182+
"nested_ref",
183+
"${var.foo_${var.tail}}",
184+
[]Token{
185+
{Kind: TokenLiteral, Value: "${var.foo_", Start: 0, End: 10},
186+
{Kind: TokenRef, Value: "var.tail", Start: 10, End: 21},
187+
{Kind: TokenLiteral, Value: "}", Start: 21, End: 22},
188+
},
189+
},
190+
{
191+
"three_level_nested_ref",
192+
"${a_${b_${c}}}",
193+
[]Token{
194+
{Kind: TokenLiteral, Value: "${a_${b_", Start: 0, End: 8},
195+
{Kind: TokenRef, Value: "c", Start: 8, End: 12},
196+
{Kind: TokenLiteral, Value: "}}", Start: 12, End: 14},
197+
},
198+
},
199+
{
200+
"nested_ref_mid_path",
201+
"${a.${b.c}.d}",
202+
[]Token{
203+
{Kind: TokenLiteral, Value: "${a.", Start: 0, End: 4},
204+
{Kind: TokenRef, Value: "b.c", Start: 4, End: 10},
205+
{Kind: TokenLiteral, Value: ".d}", Start: 10, End: 13},
206+
},
207+
},
181208
}
182209

183210
for _, tt := range tests {
@@ -195,7 +222,6 @@ func TestParseErrors(t *testing.T) {
195222
input string
196223
errContains string
197224
}{
198-
{"nested_reference", "${var.foo_${var.tail}}", "nested variable references are not supported"},
199225
{"unterminated_ref", "${a.b", "unterminated"},
200226
{"empty_ref", "${}", "empty"},
201227
{"trailing_hyphen", "${foo.bar-}", "invalid"},

0 commit comments

Comments
 (0)