Skip to content

Commit

Permalink
feat(template): support nested expressions, maps and numeric keys
Browse files Browse the repository at this point in the history
This greatly expands the power of our template strings. I'll copy the
added docs here for details:

### Nested lookups and maps

In addition to dot-notation for key lookups, we also support bracketed
lookups, e.g. `${some["key"]}` and `${some-array[0]}`.

This style offer nested template resolution, which is quite powerful,
because you can use the output of one expression to choose a key in a
parent expression.

For example, you can declare a mapping variable for your project, and
look up values by another variable such as the current environment name.
To illustrate, here's an excerpt from a project config with a mapping
variable:

```yaml
kind: Project
...
variables:
  - replicas:
      dev: 1
      prod: 3
  ...
```

And here that variable is used in a module:

```yaml
kind: Module
type: container
...
services:
  replicas: ${var.replicas["${environment.name}"]}
  ...
```

When the nested expression is a simple key lookup like above, you can
also just use the nested key directly, e.g.
`${var.replicas[environment.name]}`.

You can even use one variable to index another variable, e.g.
`${var.a[var.b]}`.
  • Loading branch information
edvald committed Jun 25, 2020
1 parent a783adc commit 578e555
Show file tree
Hide file tree
Showing 5 changed files with 159 additions and 13 deletions.
35 changes: 34 additions & 1 deletion docs/guides/variables-and-templating.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ This guide introduces the templating capabilities available in Garden configurat

String configuration values in `garden.yml` can be templated to inject variables, information about the user's environment, references to other modules/services and more.

The syntax for templated strings is `${some.key}`. The key is looked up from the context available when resolving the string. The context depends on which top-level key the configuration value belongs to (`project` or `module`).
The basic syntax for templated strings is `${some.key}`. The key is looked up from the context available when resolving the string. The context depends on which top-level key the configuration value belongs to (`project` or `module`).

For example, for one service you might want to reference something from another module and expose it as an environment variable:

Expand Down Expand Up @@ -96,6 +96,39 @@ services:
...
```

### Nested lookups and maps

In addition to dot-notation for key lookups, we also support bracketed lookups, e.g. `${some["key"]}` and `${some-array[0]}`.

This style offer nested template resolution, which is quite powerful, because you can use the output of one expression to choose a key in a parent expression.

For example, you can declare a mapping variable for your project, and look up values by another variable such as the current environment name. To illustrate, here's an excerpt from a project config with a mapping variable:

```yaml
kind: Project
...
variables:
- replicas:
dev: 1
prod: 3
...
```

And here that variable is used in a module:

```yaml
kind: Module
type: container
...
services:
replicas: ${var.replicas["${environment.name}"]}
...
```

When the nested expression is a simple key lookup like above, you can also just use the nested key directly, e.g. `${var.replicas[environment.name]}`.

You can even use one variable to index another variable, e.g. `${var.a[var.b]}`.

### Optional values

In some cases, you may want to provide configuration values only for certain cases, e.g. only for specific environments. By default, an error is thrown when a template string resolves to an undefined value, but you can explicitly allow that by adding a `?` after the template.
Expand Down
10 changes: 9 additions & 1 deletion garden-service/src/config/config-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { getProviderUrl, getModuleTypeUrl } from "../docs/common"
import { Module } from "../types/module"
import { ModuleConfig } from "./module"
import { ModuleVersion } from "../vcs/vcs"
import { isPrimitive } from "util"

export type ContextKey = string[]

Expand Down Expand Up @@ -109,8 +110,15 @@ export abstract class ConfigContext {
nestedNodePath = nodePath.concat(lookupPath)
const stackEntry = nestedNodePath.join(".")

if (nextKey.startsWith("_")) {
if (typeof nextKey === "string" && nextKey.startsWith("_")) {
value = undefined
} else if (isPrimitive(value)) {
throw new ConfigurationError(`Attempted to look up key ${JSON.stringify(nextKey)} on a ${typeof value}.`, {
value,
nodePath,
fullPath,
opts,
})
} else {
value = value instanceof Map ? value.get(nextKey) : value[nextKey]
}
Expand Down
43 changes: 33 additions & 10 deletions garden-service/src/template-string-parser.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,6 @@ FormatEnd
= __ "}?"
/ __ "}"

KeySeparator
= "."

Key
= head:Identifier tail:(KeySeparator Identifier)* {
return [["", head]].concat(tail).map(p => p[1])
}

Prefix
= !FormatStart (. ! FormatStart)* . { return text() }

Expand All @@ -58,6 +50,27 @@ Suffix

// ---- expressions -----
// Reduced and adapted from: https://github.com/pegjs/pegjs/blob/master/examples/javascript.pegjs
MemberExpression
= head:Identifier
tail:(
"[" __ e:PrimaryExpression __ "]" {
if (e.resolved && !options.isPrimitive(e.resolved)) {
const _error = new options.TemplateStringError(
`Expression in bracket must resolve to a primitive (got ${typeof e}).`,
{ text: e.resolved }
)
return { _error }
}
return e
}
/ "." e:Identifier {
return e
}
)*
{
return [head].concat(tail)
}

PrimaryExpression
= v:NonStringLiteral {
return v
Expand All @@ -66,7 +79,13 @@ PrimaryExpression
// Allow nested template strings in literals
return options.resolveNested(v)
}
/ key:Key {
/ key:MemberExpression {
for (const part of key) {
if (part._error) {
return part
}
}
key = key.map((part) => part.resolved || part)
return options.getKey(key, { allowUndefined: true })
}
/ "(" __ e:Expression __ ")" {
Expand Down Expand Up @@ -215,6 +234,7 @@ IdentifierName "identifier"
= head:IdentifierStart tail:IdentifierPart* {
return head + tail.join("")
}
/ Integer

IdentifierStart
= UnicodeLetter
Expand Down Expand Up @@ -312,7 +332,10 @@ ExponentIndicator
= "e"i

SignedInteger
= [+-]? DecimalDigit+
= [+-]? Integer

Integer
= DecimalDigit+

HexIntegerLiteral
= "0x"i digits:$HexDigit+ {
Expand Down
3 changes: 2 additions & 1 deletion garden-service/src/template-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { deepMap } from "./util/util"
import { GardenBaseError, ConfigurationError } from "./exceptions"
import { ConfigContext, ContextResolveOpts, ScanContext, ContextResolveOutput } from "./config/config-context"
import { difference, flatten, uniq, isPlainObject, isNumber } from "lodash"
import { Primitive, StringMap } from "./config/common"
import { Primitive, StringMap, isPrimitive } from "./config/common"
import { profile } from "./util/profiling"
import { dedent, deline } from "./util/string"

Expand Down Expand Up @@ -66,6 +66,7 @@ export function resolveTemplateString(string: string, context: ConfigContext, op
TemplateStringError,
allowUndefined: opts.allowUndefined,
optionalSuffix: "}?",
isPrimitive,
})

const outputs: ResolvedClause[] = parsed.map((p: any) => {
Expand Down
81 changes: 81 additions & 0 deletions garden-service/test/unit/src/template-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,77 @@ describe("resolveTemplateString", async () => {
expect(res).to.equal(21)
})

it("should handle member lookup with bracket notation", async () => {
const res = resolveTemplateString("${foo['bar']}", new TestContext({ foo: { bar: true } }))
expect(res).to.equal(true)
})

it("should handle numeric member lookup with bracket notation", async () => {
const res = resolveTemplateString("${foo[1]}", new TestContext({ foo: [false, true] }))
expect(res).to.equal(true)
})

it("should handle consecutive member lookups with bracket notation", async () => {
const res = resolveTemplateString("${foo['bar']['baz']}", new TestContext({ foo: { bar: { baz: true } } }))
expect(res).to.equal(true)
})

it("should handle dot member after bracket member", async () => {
const res = resolveTemplateString("${foo['bar'].baz}", new TestContext({ foo: { bar: { baz: true } } }))
expect(res).to.equal(true)
})

it("should handle template expression within brackets", async () => {
const res = resolveTemplateString(
"${foo['${bar}']}",
new TestContext({
foo: { baz: true },
bar: "baz",
})
)
expect(res).to.equal(true)
})

it("should handle identifiers within brackets", async () => {
const res = resolveTemplateString(
"${foo[bar]}",
new TestContext({
foo: { baz: true },
bar: "baz",
})
)
expect(res).to.equal(true)
})

it("should handle nested identifiers within brackets", async () => {
const res = resolveTemplateString(
"${foo[a.b]}",
new TestContext({
foo: { baz: true },
a: { b: "baz" },
})
)
expect(res).to.equal(true)
})

it("should throw if bracket expression resolves to a non-primitive", async () => {
return expectError(
() => resolveTemplateString("${foo[bar]}", new TestContext({ foo: {}, bar: {} })),
(err) =>
expect(err.message).to.equal(
"Invalid template string ${foo[bar]}: Expression in bracket must resolve to a primitive (got object)."
)
)
})

it("should throw if attempting to index a primitive with brackets", async () => {
return expectError(
() => resolveTemplateString("${foo[bar]}", new TestContext({ foo: 123, bar: "baz" })),
(err) =>
expect(err.message).to.equal('Invalid template string ${foo[bar]}: Attempted to look up key "baz" on a number.')
)
})

it("should throw when using >= on non-numeric terms", async () => {
return expectError(
() => resolveTemplateString("${a >= b}", new TestContext({ a: 123, b: "foo" })),
Expand Down Expand Up @@ -485,6 +556,16 @@ describe("resolveTemplateString", async () => {
expect(res).to.equal(true)
})

it("should handle numeric indices on arrays", () => {
const res = resolveTemplateString("${foo.1}", new TestContext({ foo: [false, true] }))
expect(res).to.equal(true)
})

it("should resolve keys on objects in arrays", () => {
const res = resolveTemplateString("${foo.1.bar}", new TestContext({ foo: [{}, { bar: true }] }))
expect(res).to.equal(true)
})

it("should correctly propagate errors from nested contexts", async () => {
await expectError(
() => resolveTemplateString("${nested.missing}", new TestContext({ nested: new TestContext({}) })),
Expand Down

0 comments on commit 578e555

Please sign in to comment.