Skip to content

Commit

Permalink
feat(config): add contains operator for template strings
Browse files Browse the repository at this point in the history
The `contains` operator can be used to see if an array contains a value,
an object contains a key, or if a string contains another string.

Examples:
* `${var.some-array contains "some-value"}` checks if the
  `var.some-array` array includes the string `"some-value"`.
* `${var.some-string contains "some"}` checks if the `var.some-string`
  string includes the string `"some"`.
* `${var.some-object contains "some-key"}` checks if the
  `var.some-object` object includes the key `"some-key"`.
  • Loading branch information
edvald committed Jul 30, 2020
1 parent 75af175 commit 33d8275
Show file tree
Hide file tree
Showing 3 changed files with 153 additions and 2 deletions.
7 changes: 7 additions & 0 deletions docs/using-garden/variables-and-templating.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ You can use a variety of operators in template string expressions:
* Equality: `==`, `!=`
* Logical: `&&`, `||`, ternary (`<test> ? <value if true> : <value if false>`)
* Unary: `!` (negation), `typeof` (returns the type of the following value as a string, e.g. `"boolean"` or `"number"`)
* Relational: `contains` (to see if an array contains a value, an object contains a key, or a string contains a substring)

The arithmetic and numeric comparison operators can only be used for numeric literals and keys that resolve to numbers. The equality and logical operators work with any term.

Expand Down Expand Up @@ -90,6 +91,12 @@ services:
...
```

The `contains` operator can be used in several ways:

* `${var.some-array contains "some-value"}` checks if the `var.some-array` array includes the string `"some-value"`.
* `${var.some-string contains "some"}` checks if the `var.some-string` string includes the substring `"some"`.
* `${var.some-object contains "some-key"}` checks if the `var.some-object` object includes the key `"some-key"`.

And the arithmetic operators can be handy when provisioning resources:

```yaml
Expand Down
47 changes: 45 additions & 2 deletions garden-service/src/template-string-parser.pegjs
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,52 @@ UnaryOperator
= $TypeofToken
/ "!"

ContainsExpression
= head:UnaryExpression __ ContainsOperator __ tail:UnaryExpression {
if (head && head._error) {
return head
}
if (tail && tail._error) {
return tail
}

head = options.getValue(head)
tail = options.getValue(tail)

if (!options.isPrimitive(tail)) {
return {
_error: new options.TemplateStringError(
`The right-hand side of a 'contains' operator must be a string, number, boolean or null (got ${typeof tail}).`
)
}
}

const headType = head === null ? "null" : typeof head

if (headType === "object") {
if (options.lodash.isArray(head)) {
return head.includes(tail)
} else {
return head.hasOwnProperty(tail)
}
} else if (headType === "string") {
return head.includes(tail.toString())
} else {
return {
_error: new options.TemplateStringError(
`The left-hand side of a 'contains' operator must be a string, array or object (got ${headType}).`
)
}
}
}
/ UnaryExpression

ContainsOperator
= "contains"

MultiplicativeExpression
= head:UnaryExpression
tail:(__ MultiplicativeOperator __ UnaryExpression)*
= head:ContainsExpression
tail:(__ MultiplicativeOperator __ ContainsExpression)*
{ return options.buildBinaryExpression(head, tail); }

MultiplicativeOperator
Expand Down
101 changes: 101 additions & 0 deletions garden-service/test/unit/src/template-string.ts
Original file line number Diff line number Diff line change
Expand Up @@ -670,6 +670,107 @@ describe("resolveTemplateString", async () => {
expect(res).to.equal("foo-null")
})
})

context("contains operator", () => {
it("should throw when right-hand side is not a primitive", () => {
const c = new TestContext({ a: [1, 2], b: [3, 4] })

expectError(
() => resolveTemplateString("${a contains b}", c),
(err) =>
expect(stripAnsi(err.message)).to.equal(
"Invalid template string ${a contains b}: The right-hand side of a 'contains' operator must be a string, number, boolean or null (got object)."
)
)
})

it("should throw when left-hand side is not a string, array or object", () => {
const c = new TestContext({ a: "foo", b: null })

expectError(
() => resolveTemplateString("${b contains a}", c),
(err) =>
expect(stripAnsi(err.message)).to.equal(
"Invalid template string ${b contains a}: The left-hand side of a 'contains' operator must be a string, array or object (got null)."
)
)
})

it("positive string literal contains string literal", () => {
const res = resolveTemplateString("${'foobar' contains 'foo'}", new TestContext({}))
expect(res).to.equal(true)
})

it("string literal contains string literal (negative)", () => {
const res = resolveTemplateString("${'blorg' contains 'blarg'}", new TestContext({}))
expect(res).to.equal(false)
})

it("string literal contains string reference", () => {
const res = resolveTemplateString("${a contains 'foo'}", new TestContext({ a: "foobar" }))
expect(res).to.equal(true)
})

it("string reference contains string literal (negative)", () => {
const res = resolveTemplateString("${a contains 'blarg'}", new TestContext({ a: "foobar" }))
expect(res).to.equal(false)
})

it("string contains number", () => {
const res = resolveTemplateString("${a contains 0}", new TestContext({ a: "hmm-0" }))
expect(res).to.equal(true)
})

it("object contains string literal", () => {
const res = resolveTemplateString("${a contains 'foo'}", new TestContext({ a: { foo: 123 } }))
expect(res).to.equal(true)
})

it("object contains string literal (negative)", () => {
const res = resolveTemplateString("${a contains 'bar'}", new TestContext({ a: { foo: 123 } }))
expect(res).to.equal(false)
})

it("object contains string reference", () => {
const res = resolveTemplateString("${a contains b}", new TestContext({ a: { foo: 123 }, b: "foo" }))
expect(res).to.equal(true)
})

it("object contains number reference", () => {
const res = resolveTemplateString("${a contains b}", new TestContext({ a: { 123: 456 }, b: 123 }))
expect(res).to.equal(true)
})

it("object contains number literal", () => {
const res = resolveTemplateString("${a contains 123}", new TestContext({ a: { 123: 456 } }))
expect(res).to.equal(true)
})

it("array contains string reference", () => {
const res = resolveTemplateString("${a contains b}", new TestContext({ a: ["foo"], b: "foo" }))
expect(res).to.equal(true)
})

it("array contains string reference (negative)", () => {
const res = resolveTemplateString("${a contains b}", new TestContext({ a: ["foo"], b: "bar" }))
expect(res).to.equal(false)
})

it("array contains string literal", () => {
const res = resolveTemplateString("${a contains 'foo'}", new TestContext({ a: ["foo"] }))
expect(res).to.equal(true)
})

it("array contains number", () => {
const res = resolveTemplateString("${a contains 1}", new TestContext({ a: [0, 1] }))
expect(res).to.equal(true)
})

it("array contains numeric index (negative)", () => {
const res = resolveTemplateString("${a contains 1}", new TestContext({ a: [0] }))
expect(res).to.equal(false)
})
})
})

describe("resolveTemplateStrings", () => {
Expand Down

0 comments on commit 33d8275

Please sign in to comment.