Skip to content

Commit 9986fc8

Browse files
committed
Make coalesce variadic
This commit adds support for variadic functions in the rules engine and makes coalesce variadic. This makes things like phi functions a shallow list of expressions rather than a massive list of nested binary expressions. This also makes it easier to optimize phi nodes without requiring peephole checks in compilers for optimization.
1 parent 4a8aa35 commit 9986fc8

File tree

13 files changed

+368
-154
lines changed

13 files changed

+368
-154
lines changed

docs/source-2.0/additional-specs/rules-engine/standard-library.rst

Lines changed: 21 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -44,24 +44,28 @@ parameter is equal to the value ``false``:
4444
=====================
4545

4646
Summary
47-
Evaluates the first argument and returns the result if it is present, otherwise evaluates and returns the result
48-
of the second argument.
47+
Evaluates arguments in order and returns the first non-empty result, otherwise returns the result of the last
48+
argument.
4949
Argument types
50-
* value1: ``T`` or ``option<T>``
51-
* value2: ``T`` or ``option<T>``
50+
* This function is variadic and requires two or more arguments, each of type ``T`` or ``option<T>``
51+
* All arguments must have the same inner type ``T``
5252
Return type
53-
* ``coalesce(T, T)`` → ``T``
54-
* ``coalesce(option<T>, T)`` → ``T``
55-
* ``coalesce(T, option<T>)`` → ``T``
56-
* ``coalesce(option<T>, option<T>)`` → ``option<T>``
53+
* ``coalesce(T, T, ...)`` → ``T``
54+
* ``coalesce(option<T>, T, ...)`` → ``T`` (if any argument is non-optional)
55+
* ``coalesce(T, option<T>, ...)`` → ``T`` (if any argument is non-optional)
56+
* ``coalesce(option<T>, option<T>, ...)`` → ``option<T>`` (if all arguments are optional)
5757
Since
5858
1.1
5959

60-
The ``coalesce`` function provides null-safe chaining by returning the result of the first argument if it returns a
61-
value, otherwise returns the result of the second argument. This is particularly useful for providing default values
62-
for optional parameters, chaining multiple optional values together, and related optimizations.
60+
The ``coalesce`` function provides null-safe chaining by evaluating arguments in order and returning the first
61+
non-empty result. If all arguments evaluate to empty, it returns the result of the last argument. This is
62+
particularly useful for providing default values for optional parameters, chaining multiple optional values
63+
together, and related optimizations.
6364

64-
The following example demonstrates chaining multiple ``coalesce`` calls to try several optional values
65+
The function accepts two or more arguments, all of which must have the same inner type after unwrapping any
66+
optionals. The return type is ``option<T>`` only if all arguments are ``option<T>``; otherwise it returns ``T``.
67+
68+
The following example demonstrates using ``coalesce`` with multiple arguments to try several optional values
6569
in sequence:
6670

6771
.. code-block:: json
@@ -70,20 +74,15 @@ in sequence:
7074
"fn": "coalesce",
7175
"argv": [
7276
{"ref": "customEndpoint"},
73-
{
74-
"fn": "coalesce",
75-
"argv": [
76-
{"ref": "regionalEndpoint"},
77-
{"ref": "defaultEndpoint"}
78-
]
79-
}
77+
{"ref": "regionalEndpoint"},
78+
{"ref": "defaultEndpoint"}
8079
]
8180
}
8281
8382
.. important::
84-
Both arguments must be of the same type after unwrapping any optionals (types are known at compile time and do not
85-
need to be validated at runtime). Note that the first result is returned even if it's ``false`` (coalesce is
86-
looking for a *non-empty* value).
83+
All arguments must have the same type after unwrapping any optionals (types are known at compile time and do not
84+
need to be validated at runtime). Note that the first non-empty result is returned even if it's ``false``
85+
(coalesce is looking for a *non-empty* value, not a truthy value).
8786

8887

8988
.. _rules-engine-standard-library-getAttr:

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/evaluation/RuleEvaluator.java

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -184,9 +184,15 @@ public Value visitIsSet(Expression fn) {
184184
}
185185

186186
@Override
187-
public Value visitCoalesce(Expression left, Expression right) {
188-
Value leftValue = left.accept(this);
189-
return leftValue.isEmpty() ? right.accept(this) : leftValue;
187+
public Value visitCoalesce(List<Expression> expressions) {
188+
Value result = Value.emptyValue();
189+
for (Expression exp : expressions) {
190+
result = exp.accept(this);
191+
if (!result.isEmpty()) {
192+
return result;
193+
}
194+
}
195+
return result;
190196
}
191197

192198
@Override

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/syntax/expressions/ExpressionVisitor.java

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.FunctionDefinition;
1010
import software.amazon.smithy.rulesengine.language.syntax.expressions.functions.GetAttr;
1111
import software.amazon.smithy.rulesengine.language.syntax.expressions.literal.Literal;
12-
import software.amazon.smithy.utils.ListUtils;
1312
import software.amazon.smithy.utils.SmithyUnstableApi;
1413

1514
/**
@@ -54,12 +53,11 @@ public interface ExpressionVisitor<R> {
5453
/**
5554
* Visits a coalesce function.
5655
*
57-
* @param left the first value to check.
58-
* @param right the second value to check.
56+
* @param expressions The coalesce expressions to check.
5957
* @return the value from the visitor.
6058
*/
61-
default R visitCoalesce(Expression left, Expression right) {
62-
return visitLibraryFunction(Coalesce.getDefinition(), ListUtils.of(left, right));
59+
default R visitCoalesce(List<Expression> expressions) {
60+
return visitLibraryFunction(Coalesce.getDefinition(), expressions);
6361
}
6462

6563
/**
@@ -121,7 +119,7 @@ public R visitIsSet(Expression fn) {
121119
}
122120

123121
@Override
124-
public R visitCoalesce(Expression left, Expression right) {
122+
public R visitCoalesce(List<Expression> expressions) {
125123
return getDefault();
126124
}
127125

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/syntax/expressions/functions/Coalesce.java

Lines changed: 51 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,9 @@
44
*/
55
package software.amazon.smithy.rulesengine.language.syntax.expressions.functions;
66

7-
import java.util.Arrays;
7+
import java.util.Collections;
88
import java.util.List;
9+
import java.util.Optional;
910
import software.amazon.smithy.rulesengine.language.RulesVersion;
1011
import software.amazon.smithy.rulesengine.language.evaluation.Scope;
1112
import software.amazon.smithy.rulesengine.language.evaluation.type.OptionalType;
@@ -18,19 +19,17 @@
1819

1920
/**
2021
* A coalesce function that returns the first non-empty value.
21-
* At runtime, returns the left value unless it's EmptyValue, in which case returns the right value.
22+
*
23+
* <p>This variadic function requires two or more arguments. At runtime, returns the first arguments that returns a
24+
* non-EmptyValue, otherwise returns the result of the last argument.
2225
*
2326
* <p>Type checking rules:
2427
* <ul>
25-
* <li>{@code coalesce(T, T) => T} (same types)</li>
26-
* <li>{@code coalesce(Optional<T>, T) => T} (unwraps optional)</li>
27-
* <li>{@code coalesce(T, Optional<T>) => T} (unwraps optional)</li>
28-
* <li>{@code coalesce(Optional<T>, Optional<T>) => Optional<T>}</li>
28+
* <li>{@code coalesce(T, T, T) => T} (same types)</li>
29+
* <li>{@code coalesce(Optional<T>, T, T) => T} (any non-optional makes result non-optional)</li>
30+
* <li>{@code coalesce(Optional<T>, Optional<T>, Optional<T>) => Optional<T>} (all optional)</li>
2931
* </ul>
3032
*
31-
* <p>Supports chaining:
32-
* {@code coalesce(opt1, coalesce(opt2, coalesce(opt3, default)))}
33-
*
3433
* <p>Available since: rules engine 1.1.
3534
*/
3635
@SmithyUnstableApi
@@ -52,14 +51,23 @@ public static Definition getDefinition() {
5251
}
5352

5453
/**
55-
* Creates a {@link Coalesce} function from the given expressions.
54+
* Creates a {@link Coalesce} function from variadic expressions.
55+
*
56+
* @param args the expressions to coalesce
57+
* @return The resulting {@link Coalesce} function.
58+
*/
59+
public static Coalesce ofExpressions(ToExpression... args) {
60+
return DEFINITION.createFunction(FunctionNode.ofExpressions(ID, args));
61+
}
62+
63+
/**
64+
* Creates a {@link Coalesce} function from a list of expressions.
5665
*
57-
* @param arg1 the first expression, typically optional.
58-
* @param arg2 the second expression, used as fallback.
66+
* @param args the expressions to coalesce
5967
* @return The resulting {@link Coalesce} function.
6068
*/
61-
public static Coalesce ofExpressions(ToExpression arg1, ToExpression arg2) {
62-
return DEFINITION.createFunction(FunctionNode.ofExpressions(ID, arg1, arg2));
69+
public static Coalesce ofExpressions(List<? extends ToExpression> args) {
70+
return ofExpressions(args.toArray(new ToExpression[0]));
6371
}
6472

6573
@Override
@@ -69,37 +77,38 @@ public RulesVersion availableSince() {
6977

7078
@Override
7179
public <R> R accept(ExpressionVisitor<R> visitor) {
72-
List<Expression> args = getArguments();
73-
return visitor.visitCoalesce(args.get(0), args.get(1));
80+
return visitor.visitCoalesce(getArguments());
7481
}
7582

7683
@Override
7784
public Type typeCheck(Scope<Type> scope) {
7885
List<Expression> args = getArguments();
79-
80-
if (args.size() != 2) {
81-
throw new IllegalArgumentException("Coalesce requires exactly 2 arguments, got " + args.size());
82-
}
83-
84-
Type leftType = args.get(0).typeCheck(scope);
85-
Type rightType = args.get(1).typeCheck(scope);
86-
Type leftInner = getInnerType(leftType);
87-
Type rightInner = getInnerType(rightType);
88-
89-
// Both must be the same type (after unwrapping optionals)
90-
if (!leftInner.equals(rightInner)) {
91-
throw new IllegalArgumentException(String.format(
92-
"Type mismatch in coalesce: %s and %s must be the same type",
93-
leftType,
94-
rightType));
86+
if (args.size() < 2) {
87+
throw new IllegalArgumentException("Coalesce requires at least 2 arguments, got " + args.size());
9588
}
9689

97-
// Only return Optional if both sides are optional
98-
if (leftType instanceof OptionalType && rightType instanceof OptionalType) {
99-
return Type.optionalType(leftInner);
90+
// Get the first argument's type as the baseline
91+
Type firstType = args.get(0).typeCheck(scope);
92+
Type baseInnerType = getInnerType(firstType);
93+
boolean hasNonOptional = !(firstType instanceof OptionalType);
94+
95+
// Check all other arguments match the base type
96+
for (int i = 1; i < args.size(); i++) {
97+
Type argType = args.get(i).typeCheck(scope);
98+
Type innerType = getInnerType(argType);
99+
100+
if (!innerType.equals(baseInnerType)) {
101+
throw new IllegalArgumentException(String.format(
102+
"Type mismatch in coalesce at argument %d: expected %s but got %s",
103+
i + 1,
104+
baseInnerType,
105+
innerType));
106+
}
107+
108+
hasNonOptional = hasNonOptional || !(argType instanceof OptionalType);
100109
}
101110

102-
return leftInner;
111+
return hasNonOptional ? baseInnerType : Type.optionalType(baseInnerType);
103112
}
104113

105114
private static Type getInnerType(Type t) {
@@ -119,7 +128,12 @@ public String getId() {
119128

120129
@Override
121130
public List<Type> getArguments() {
122-
return Arrays.asList(Type.anyType(), Type.anyType());
131+
return Collections.emptyList();
132+
}
133+
134+
@Override
135+
public Optional<Type> getVariadicArguments() {
136+
return Optional.of(Type.anyType());
123137
}
124138

125139
@Override

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/syntax/expressions/functions/FunctionDefinition.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
package software.amazon.smithy.rulesengine.language.syntax.expressions.functions;
66

77
import java.util.List;
8+
import java.util.Optional;
89
import software.amazon.smithy.rulesengine.language.evaluation.type.Type;
910
import software.amazon.smithy.rulesengine.language.evaluation.value.Value;
1011
import software.amazon.smithy.utils.SmithyUnstableApi;
@@ -26,6 +27,18 @@ public interface FunctionDefinition {
2627
*/
2728
List<Type> getArguments();
2829

30+
/**
31+
* Gets the type of variadic arguments if this function accepts them.
32+
*
33+
* <p>When present, the function accepts any number of additional arguments of this type after the fixed arguments
34+
* from getArguments().
35+
*
36+
* @return the variadic argument type, or empty if not variadic
37+
*/
38+
default Optional<Type> getVariadicArguments() {
39+
return Optional.empty();
40+
}
41+
2942
/**
3043
* The return type of this function definition.
3144
* @return The function return type

smithy-rules-engine/src/main/java/software/amazon/smithy/rulesengine/language/syntax/expressions/functions/LibraryFunction.java

Lines changed: 57 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import java.util.LinkedHashSet;
99
import java.util.List;
1010
import java.util.Objects;
11+
import java.util.Optional;
1112
import java.util.Set;
1213
import software.amazon.smithy.model.SourceException;
1314
import software.amazon.smithy.model.SourceLocation;
@@ -105,45 +106,72 @@ public SourceLocation getSourceLocation() {
105106
protected Type typeCheckLocal(Scope<Type> scope) {
106107
RuleError.context(String.format("while typechecking the invocation of %s", definition.getId()), this, () -> {
107108
try {
108-
checkTypeSignature(definition.getArguments(), functionNode.getArguments(), scope);
109+
checkTypeSignature(scope);
109110
} catch (InnerParseError e) {
110111
throw new RuntimeException(e.getMessage());
111112
}
112113
});
113114
return definition.getReturnType();
114115
}
115116

116-
private void checkTypeSignature(List<Type> expectedArgs, List<Expression> actualArguments, Scope<Type> scope)
117+
private void checkTypeSignature(Scope<Type> scope) throws InnerParseError {
118+
List<Type> expectedArgs = definition.getArguments();
119+
Optional<Type> variadicType = definition.getVariadicArguments();
120+
List<Expression> actualArguments = functionNode.getArguments();
121+
122+
if (variadicType.isPresent()) {
123+
// check we have at least the fixed arguments
124+
if (actualArguments.size() < expectedArgs.size()) {
125+
throw new InnerParseError(String.format("Expected at least %s arguments but found %s",
126+
expectedArgs.size(),
127+
actualArguments.size()));
128+
}
129+
// check fixed arguments
130+
for (int i = 0; i < expectedArgs.size(); i++) {
131+
checkArgument(i, expectedArgs.get(i), actualArguments.get(i), scope);
132+
}
133+
// check variadic arguments
134+
Type varType = variadicType.get();
135+
for (int i = expectedArgs.size(); i < actualArguments.size(); i++) {
136+
checkArgument(i, varType, actualArguments.get(i), scope);
137+
}
138+
} else {
139+
// Non-variadic, so exact count required
140+
if (expectedArgs.size() != actualArguments.size()) {
141+
throw new InnerParseError(String.format("Expected %s arguments but found %s",
142+
expectedArgs.size(),
143+
actualArguments.size()));
144+
}
145+
// check all positional arguments
146+
for (int i = 0; i < expectedArgs.size(); i++) {
147+
checkArgument(i, expectedArgs.get(i), actualArguments.get(i), scope);
148+
}
149+
}
150+
}
151+
152+
private void checkArgument(int index, Type expected, Expression actual, Scope<Type> scope)
117153
throws InnerParseError {
118-
if (expectedArgs.size() != actualArguments.size()) {
119-
throw new InnerParseError(
120-
String.format(
121-
"Expected %s arguments but found %s",
122-
expectedArgs.size(),
123-
actualArguments));
154+
Type actualType = actual.typeCheck(scope);
155+
if (expected.isA(actualType)) {
156+
return;
124157
}
125-
for (int i = 0; i < expectedArgs.size(); i++) {
126-
Type expected = expectedArgs.get(i);
127-
Type actual = actualArguments.get(i).typeCheck(scope);
128-
if (!expected.isA(actual)) {
129-
Type optAny = Type.optionalType(Type.anyType());
130-
String hint = "";
131-
if (actual.isA(optAny) && !expected.isA(optAny)
132-
&& actual.expectOptionalType().inner().equals(expected)) {
133-
hint = String.format(
134-
"hint: use `assign` in a condition or `isSet(%s)` to prove that this value is non-null",
135-
actualArguments.get(i));
136-
hint = StringUtils.indent(hint, 2);
137-
}
138-
throw new InnerParseError(
139-
String.format(
140-
"Unexpected type in the %s argument: Expected %s but found %s%n%s",
141-
ordinal(i + 1),
142-
expected,
143-
actual,
144-
hint));
145-
}
158+
159+
Type optAny = Type.optionalType(Type.anyType());
160+
String hint = "";
161+
if (actualType.isA(optAny)
162+
&& !expected.isA(optAny)
163+
&& actualType.expectOptionalType().inner().equals(expected)) {
164+
hint = String.format(
165+
"hint: use `assign` in a condition or `isSet(%s)` to prove that this value is non-null",
166+
actual);
167+
hint = StringUtils.indent(hint, 2);
146168
}
169+
170+
throw new InnerParseError(String.format("Unexpected type in the %s argument: Expected %s but found %s%n%s",
171+
ordinal(index + 1),
172+
expected,
173+
actualType,
174+
hint));
147175
}
148176

149177
private static String ordinal(int arg) {

0 commit comments

Comments
 (0)