diff --git a/docs/using-garden/variables-and-templating.md b/docs/using-garden/variables-and-templating.md index c2405f71d0..6ec2865f08 100644 --- a/docs/using-garden/variables-and-templating.md +++ b/docs/using-garden/variables-and-templating.md @@ -45,6 +45,7 @@ You can use a variety of operators in template string expressions: * Equality: `==`, `!=` * Logical: `&&`, `||`, ternary (` ? : `) * 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. @@ -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 diff --git a/garden-service/src/template-string-parser.pegjs b/garden-service/src/template-string-parser.pegjs index a20ad98dfd..3a9cd38228 100644 --- a/garden-service/src/template-string-parser.pegjs +++ b/garden-service/src/template-string-parser.pegjs @@ -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 diff --git a/garden-service/test/unit/src/template-string.ts b/garden-service/test/unit/src/template-string.ts index 7078649701..ca58ddfe2d 100644 --- a/garden-service/test/unit/src/template-string.ts +++ b/garden-service/test/unit/src/template-string.ts @@ -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", () => {