Skip to content

Commit

Permalink
Implement the spread ... operator, other new functions, and indices…
Browse files Browse the repository at this point in the history
… on lambdas (#13658)

Adds the spread operator `...` as well as various new functions +
indexes on lambdas:
1. Spread operator - usage is as follows:
    * In an object:
        ```bicep
        var objA = { bar: 'bar' }
var objB = { foo: 'foo', ...objA } // equivalent to { foo: 'foo', bar:
'bar' }
        ```
    * In an array:
        ```bicep
        var arrA = [ 2, 3 ]
        var arrB = [ 1, ...arrA, 4 ] // equivalent to [ 1, 2, 3, 4 ]
        ```
1. New functions + usage:
    * `objectKeys`: Returns the keys of an object parameter:
        ```bicep
        var example = objectKeys({ a: 1, b: 2 }) // returns [ 'a', 'b' ]
        ```
* `mapValues`: Create an object from an input object, using a custom
lambda to map values:
        ```bicep
var example = mapValues({ foo: 'foo' }, val => toUpper(val)) // returns
{ foo: 'FOO' }
        ```
* `groupBy`: Create an object with array values from an array, using a
grouping condition:
        ```bicep
var example = groupBy(['foo', 'bar', 'baz'], x => substring(x, 0, 1)) //
returns { f: [ 'foo' ], b: [ 'bar', 'baz' ]
        ```
* `shallowMerge`: Perform a shallow merge of input object parameters:
        ```bicep
var example = shallowMerge([{ foo: 'foo' }, { bar: 'bar' }]) // returns
{ foo: 'foo', bar: 'bar' }
        ```
1. Optional indices on lambdas + usage:
    * `map`:
        ```bicep
var example = map(['a', 'b'], (x, i) => { index: i, val: x }) // returns
[ { index: 0, val: 'a' }, { index: 1 val: 'b' } ]
        ```
    * `reduce`:
        ```bicep
var example = reduce([ 2, 3, 7 ], (cur, next, i) => (i % 2 == 0) ? cur +
next : cur) // returns 9
        ```
    * `filter`:
        ```bicep
var example = filter([ 'foo', 'bar', 'baz' ], (val, i) => i < 2 &&
substring(val, 0, 1) == 'b') // returns [ 'bar' ]
        ```
Closes #13560
Closes #9244
Closes #1560
Addresses some of the issues described under the following: #2082,
#1853, #387

###### Microsoft Reviewers: [Open in
CodeFlow](https://microsoft.github.io/open-pr/?codeflow=https://github.com/Azure/bicep/pull/13658)
  • Loading branch information
anthony-c-martin committed Apr 23, 2024
1 parent a22caf3 commit 8be7869
Show file tree
Hide file tree
Showing 114 changed files with 7,430 additions and 515 deletions.
122 changes: 122 additions & 0 deletions src/Bicep.Core.IntegrationTests/EvaluationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1161,4 +1161,126 @@ func joinWithSpace(values string[]) string => join(values, ' ')
evaluated.Should().HaveValueAtPath("$.outputs['sayHiWithLambdas'].value", "Hi, Anthony Martin!");
}
}

[TestMethod]
public void New_functions_are_evaluated_correctly()
{
var bicepFile = @"
func isEven(i int) bool => i % 2 == 0
output sayHello string[] = map(
['Evie', 'Casper', 'Lady Lechuga'],
(dog, i) => '${isEven(i) ? 'Hi' : 'Ahoy'} ${dog}!')
output evenEntries string[] = filter(['a', 'b', 'c', 'd'], (item, i) => isEven(i))
output concatIfEven string = reduce(['abc', 'def', 'ghi'], '', (cur, next, i) => isEven(i) ? concat(cur, next) : cur)
output mapValuesTest object = mapValues({
a: 123
b: 456
}, val => val * 2)
output objectKeysTest string[] = objectKeys({
a: 123
b: 456
})
output shallowMergeTest object = shallowMerge([{
a: 123
}, {
b: 456
}])
output groupByTest object = groupBy([
{ type: 'a', value: 123 }
{ type: 'b', value: 456 }
{ type: 'a', value: 789 }
], arg => arg.type)
output groupByWithValMapTest object = groupBy([
{ type: 'a', value: 123 }
{ type: 'b', value: 456 }
{ type: 'a', value: 789 }
], arg => arg.type, arg => arg.value)
";

var (template, _, _) = CompilationHelper.Compile(bicepFile);

using (new AssertionScope())
{
var evaluated = TemplateEvaluator.Evaluate(template);

evaluated.Should().HaveJsonAtPath("$.outputs['sayHello'].value", """
[
"Hi Evie!",
"Ahoy Casper!",
"Hi Lady Lechuga!"
]
""");

evaluated.Should().HaveJsonAtPath("$.outputs['evenEntries'].value", """
[
"a",
"c"
]
""");

evaluated.Should().HaveValueAtPath("$.outputs['concatIfEven'].value", "abcghi");

evaluated.Should().HaveJsonAtPath("$.outputs['mapValuesTest'].value", """
{
"a": 246,
"b": 912
}
""");

evaluated.Should().HaveJsonAtPath("$.outputs['objectKeysTest'].value", """
[
"a",
"b"
]
""");

evaluated.Should().HaveJsonAtPath("$.outputs['shallowMergeTest'].value", """
{
"a": 123,
"b": 456
}
""");

evaluated.Should().HaveJsonAtPath("$.outputs['groupByTest'].value", """
{
"a": [
{
"type": "a",
"value": 123
},
{
"type": "a",
"value": 789
}
],
"b": [
{
"type": "b",
"value": 456
}
]
}
""");

evaluated.Should().HaveJsonAtPath("$.outputs['groupByWithValMapTest'].value", """
{
"a": [
123,
789
],
"b": [
456
]
}
""");
}
}
}
8 changes: 4 additions & 4 deletions src/Bicep.Core.IntegrationTests/LambdaTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ public void Lambdas_can_be_placed_inside_parentheses_and_nothing_else()

CompilationHelper.Compile("var asfsasdf = map([1], [i => i])")
.ExcludingLinterDiagnostics().Should().HaveDiagnostics(new[] {
("BCP070", DiagnosticLevel.Error, "Argument of type \"[any => any]\" is not assignable to parameter of type \"any => any\"."),
("BCP070", DiagnosticLevel.Error, "Argument of type \"[any => any]\" is not assignable to parameter of type \"(any[, int]) => any\"."),
("BCP242", DiagnosticLevel.Error, "Lambda functions may only be specified directly as function arguments."),
});
}
Expand Down Expand Up @@ -147,15 +147,15 @@ public void Map_function_works_with_unknowable_types()
public void Map_function_blocks_incorrect_args()
{
var (file, cursors) = ParserHelper.GetFileWithCursors(@"
var foo = map([123], (abc, def) => abc)
var foo = map([123], (abc, def, ghi) => abc)
var foo2 = map(['foo'], () => 'Hi!')
",
'|');

var result = CompilationHelper.Compile(file);
result.ExcludingLinterDiagnostics().Should().HaveDiagnostics(new[] {
("BCP070", DiagnosticLevel.Error, "Argument of type \"(123, any) => 123\" is not assignable to parameter of type \"any => any\"."),
("BCP070", DiagnosticLevel.Error, "Argument of type \"() => 'Hi!'\" is not assignable to parameter of type \"any => any\"."),
("BCP070", DiagnosticLevel.Error, """Argument of type "(123, int, any) => 123" is not assignable to parameter of type "(any[, int]) => any"."""),
("BCP070", DiagnosticLevel.Error, """Argument of type "() => 'Hi!'" is not assignable to parameter of type "(any[, int]) => any"."""),
});
}

Expand Down
232 changes: 232 additions & 0 deletions src/Bicep.Core.IntegrationTests/SpreadTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using Bicep.Core.UnitTests.Assertions;
using Bicep.Core.UnitTests.Utils;
using Microsoft.VisualStudio.TestTools.UnitTesting;

namespace Bicep.Core.IntegrationTests;

[TestClass]
public class SpreadTests
{
[TestMethod]
public void Spread_operator_results_in_correct_codegen()
{
var result = CompilationHelper.Compile("""
var other = {
bar: [1, ...[2, 3], 4]
}

output test object = {
foo: 'foo'
...other
baz: 'baz'
}
""");

result.ExcludingLinterDiagnostics().Should().NotHaveAnyDiagnostics();
result.Template.Should().HaveValueAtPath("$.variables['other']['bar']", "[flatten(createArray(createArray(1), createArray(2, 3), createArray(4)))]");
result.Template.Should().HaveValueAtPath("$.outputs['test'].value", "[shallowMerge(createArray(createObject('foo', 'foo'), variables('other'), createObject('baz', 'baz')))]");

var evaluated = TemplateEvaluator.Evaluate(result.Template);
evaluated.Should().HaveJsonAtPath("$.outputs['test'].value", """
{
"foo": "foo",
"bar": [
1,
2,
3,
4
],
"baz": "baz"
}
""");
}

[TestMethod]
public void Spread_operator_works_on_single_line()
{
var result = CompilationHelper.Compile("""
param other object

var test = { foo: 'foo', ...other, baz: 'baz' }
""");

result.ExcludingLinterDiagnostics().Should().NotHaveAnyDiagnostics();
}

[TestMethod]
public void Spread_types_are_calculated_correctly_for_objects()
{
// positive case
var result = CompilationHelper.Compile("""
param foo { foo: string }
param bar { bar: string }

output baz { foo: string, bar: string } = { ...foo, ...bar }
output qux { foo: string, bar: string } = { foo: 'foo', ...bar }
""");

result.ExcludingLinterDiagnostics().Should().NotHaveAnyDiagnostics();

// negative case
result = CompilationHelper.Compile("""
param foo { foo: string }
param bar { bar: string }

output baz { foo: string, baz: string } = { ...foo, ...bar }
output qux { foo: string, baz: string } = { foo: 'foo', ...bar }
""");

result.ExcludingLinterDiagnostics().Should().HaveDiagnostics([
("BCP089", Diagnostics.DiagnosticLevel.Warning, """The property "bar" is not allowed on objects of type "{ foo: string, baz: string }". Did you mean "baz"?"""),
("BCP089", Diagnostics.DiagnosticLevel.Warning, """The property "bar" is not allowed on objects of type "{ foo: string, baz: string }". Did you mean "baz"?"""),
]);
}

[TestMethod]
public void Spread_types_are_calculated_correctly_for_arrays()
{
// positive case
var result = CompilationHelper.Compile("""
param foo string[]
param bar string[]

output baz string[] = [ ...foo, ...bar ]
output qux string[] = [ 'foo', ...bar ]
""");

result.ExcludingLinterDiagnostics().Should().NotHaveAnyDiagnostics();

// negative case
result = CompilationHelper.Compile("""
param foo string[]
param bar string[]

output baz int[] = [ ...foo, ...bar ]
output qux int[] = [ 123, ...bar ]
""");

result.ExcludingLinterDiagnostics().Should().HaveDiagnostics([
("BCP403", Diagnostics.DiagnosticLevel.Error, """The enclosing array expects elements of type "int", but the array being spread contains elements of incompatible type "string"."""),
("BCP403", Diagnostics.DiagnosticLevel.Error, """The enclosing array expects elements of type "int", but the array being spread contains elements of incompatible type "string"."""),
("BCP403", Diagnostics.DiagnosticLevel.Error, """The enclosing array expects elements of type "int", but the array being spread contains elements of incompatible type "string"."""),
]);
}

[TestMethod]
public void Array_spread_cannot_be_used_inside_object()
{
var result = CompilationHelper.Compile("""
var other = ['bar']

var test = {
foo: 'foo'
...other
baz: 'baz'
}
""");

result.ExcludingLinterDiagnostics().Should().ContainDiagnostic(
"BCP402", Diagnostics.DiagnosticLevel.Error, """The spread operator "..." can only be used in this context for an expression assignable to type "object".""");
}

[TestMethod]
public void Object_spread_cannot_be_used_inside_array()
{
var result = CompilationHelper.Compile("""
var other = {
bar: 'bar'
}

var test = [
'foo'
...other
'baz'
]
""");

result.ExcludingLinterDiagnostics().Should().ContainDiagnostic(
"BCP402", Diagnostics.DiagnosticLevel.Error, """The spread operator "..." can only be used in this context for an expression assignable to type "array".""");
}

[TestMethod]
public void Spread_works_with_any()
{
var result = CompilationHelper.Compile("""
var badObj = {
...any(['foo'])
}

var badArray = [
...any({ foo: 'foo' })
]
""");

result.ExcludingLinterDiagnostics().Should().NotHaveAnyDiagnostics();

// this will result in a runtime failure, but at least the codegen is correct.
result.Template.Should().HaveValueAtPath("$.variables['badObj']", "[shallowMerge(createArray(createArray('foo')))]");
result.Template.Should().HaveValueAtPath("$.variables['badArray']", "[flatten(createArray(createObject('foo', 'foo')))]");
}

[TestMethod]
public void Spread_is_blocked_in_resource_body()
{
var result = CompilationHelper.Compile("""
var other = { location: 'westus' }

resource foo 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: 'foo'
...other
}
""");

result.ExcludingLinterDiagnostics().Should().ContainDiagnostic(
"BCP401", Diagnostics.DiagnosticLevel.Error, """The spread operator "..." is not permitted in this location.""");
}

[TestMethod]
public void Object_spread_edge_cases()
{
var result = CompilationHelper.Compile("""
output test1 object = {
a: 0
...{ a: 1, b: 0 }
c: 0
}

output test2 object = {
'ABC': 0
...{ 'aBC': 1 }
}

output test3 object = {
foo: 'bar'
...{ foo: null }
}
""");

result.ExcludingLinterDiagnostics().Should().NotHaveAnyDiagnostics();

var evaluated = TemplateEvaluator.Evaluate(result.Template);
evaluated.Should().HaveJsonAtPath("$.outputs['test1'].value", """
{
"a": 1,
"b": 0,
"c": 0
}
""");
evaluated.Should().HaveJsonAtPath("$.outputs['test2'].value", """
{
"ABC": 1
}
""");
evaluated.Should().HaveJsonAtPath("$.outputs['test3'].value", """
{
"foo": null
}
""");
}
}
Loading

0 comments on commit 8be7869

Please sign in to comment.