diff --git a/rune-integration-tests/src/test/java/com/regnosys/rosetta/generator/java/expression/ListOfListsTest.java b/rune-integration-tests/src/test/java/com/regnosys/rosetta/generator/java/expression/ListOfListsTest.java new file mode 100644 index 0000000000..2e09386dc8 --- /dev/null +++ b/rune-integration-tests/src/test/java/com/regnosys/rosetta/generator/java/expression/ListOfListsTest.java @@ -0,0 +1,107 @@ +package com.regnosys.rosetta.generator.java.expression; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import javax.inject.Inject; + +import org.eclipse.xtext.testing.InjectWith; +import org.eclipse.xtext.testing.extensions.InjectionExtension; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import com.regnosys.rosetta.generator.java.types.JavaTypeUtil; +import com.regnosys.rosetta.tests.RosettaTestInjectorProvider; +import com.regnosys.rosetta.tests.testmodel.JavaTestModel; +import com.regnosys.rosetta.tests.testmodel.RosettaTestModelService; + +/** + * Tests for expressions that produce a list of lists, in particular when such an expression + * appears in a branch of a conditional or of a switch. + */ +@ExtendWith(InjectionExtension.class) +@InjectWith(RosettaTestInjectorProvider.class) +public class ListOfListsTest { + @Inject + private RosettaTestModelService modelService; + @Inject + private JavaTypeUtil typeUtil; + + private static final String FOOS = """ + [Foo { xs: ["a", "b"] }, Foo { xs: ["c"] }] + """; + + @SuppressWarnings("unchecked") + private List evaluateStringList(JavaTestModel model, String expr) { + return (List) model.evaluateExpression(typeUtil.wrap(typeUtil.LIST, typeUtil.STRING), expr); + } + + @Test + void flattenConditionalContainingListOfLists() { + JavaTestModel model = modelService.toJavaTestModel(""" + type Foo: + xs string (0..*) + + func GetStrings: + inputs: + foos Foo (0..*) + test boolean (1..1) + output: + result string (0..*) + + add result: + (if test then foos extract item -> xs) flatten + """).compile(); + + assertEquals(List.of("a", "b", "c"), evaluateStringList(model, "GetStrings(" + FOOS + ", True)")); + assertEquals(List.of(), evaluateStringList(model, "GetStrings(" + FOOS + ", False)")); + } + + @Test + void thenFlattenAfterConditionalContainingListOfLists() { + JavaTestModel model = modelService.toJavaTestModel(""" + type Foo: + xs string (0..*) + + func GetStrings: + inputs: + foos Foo (0..*) + test boolean (1..1) + output: + result string (0..*) + + add result: + if test + then foos extract item -> xs + then flatten + """).compile(); + + assertEquals(List.of("a", "b", "c"), evaluateStringList(model, "GetStrings(" + FOOS + ", True)")); + assertEquals(List.of(), evaluateStringList(model, "GetStrings(" + FOOS + ", False)")); + } + + @Test + void flattenSwitchContainingListOfLists() { + JavaTestModel model = modelService.toJavaTestModel(""" + type Foo: + xs string (0..*) + + func GetStrings: + inputs: + foos Foo (0..*) + mode string (1..1) + output: + result string (0..*) + + add result: + (mode switch + "all" then foos extract item -> xs, + default empty) + flatten + """).compile(); + + assertEquals(List.of("a", "b", "c"), evaluateStringList(model, "GetStrings(" + FOOS + ", \"all\")")); + assertEquals(List.of(), evaluateStringList(model, "GetStrings(" + FOOS + ", \"none\")")); + } +} diff --git a/rune-integration-tests/src/test/java/com/regnosys/rosetta/validation/ExpressionValidatorTest.java b/rune-integration-tests/src/test/java/com/regnosys/rosetta/validation/ExpressionValidatorTest.java index 57797d747a..412a3d3f2d 100644 --- a/rune-integration-tests/src/test/java/com/regnosys/rosetta/validation/ExpressionValidatorTest.java +++ b/rune-integration-tests/src/test/java/com/regnosys/rosetta/validation/ExpressionValidatorTest.java @@ -17,7 +17,7 @@ @ExtendWith(InjectionExtension.class) @InjectWith(RosettaTestInjectorProvider.class) -public class ExpressionValidatorTest { +public class ExpressionValidatorTest extends AbstractValidatorTest { @Inject private RosettaValidationTestHelper validationTestHelper; @Inject @@ -417,4 +417,137 @@ void asOnUnsupportedTypeShouldError() { validationTestHelper.assertError(expr, AS_OPERATION, null, "Operator `as` is not supported for type `string`. Supported argument types are complex types and choice types"); } + + @Test + void listOfListsInConditionalBranchShouldError() { + assertIssues(""" + type Foo: + xs string (0..*) + + func GetStrings: + inputs: + foos Foo (0..*) + test boolean (1..1) + output: + result string (0..*) + + add result: + if test + then foos extract item -> xs + """, """ + ERROR (null) 'Assign expression contains a list of lists, use flatten to create a list' at 15:9, length 44, on Operation + """); + } + + @Test + void listOfListsInSwitchCaseShouldError() { + assertIssues(""" + type Foo: + xs string (0..*) + + func GetStrings: + inputs: + foos Foo (0..*) + mode string (1..1) + output: + result string (0..*) + + add result: + mode switch + "all" then foos extract item -> xs, + default empty + """, """ + ERROR (null) 'Assign expression contains a list of lists, use flatten to create a list' at 15:9, length 85, on Operation + """); + } + + @Test + void listOfListsInOnlyOneConditionalBranchShouldError() { + RosettaExpression expr = modelService.toTestModel(""" + type Foo: + xs string (0..*) + """).parseExpression(""" + (if test then foos extract item -> xs else foos -> xs) flatten + """, "foos Foo (0..*)", "test boolean (1..1)"); + + validationTestHelper.assertError(expr, ROSETTA_CONDITIONAL_EXPRESSION, null, + "Branch contains a list of lists, use flatten to create a list."); + } + + @Test + void listOfListsInOnlyOneSwitchCaseShouldError() { + RosettaExpression expr = modelService.toTestModel(""" + type Foo: + xs string (0..*) + """).parseExpression(""" + (mode switch + "all" then foos extract item -> xs, + default foos -> xs) + flatten + """, "foos Foo (0..*)", "mode string (1..1)"); + + validationTestHelper.assertError(expr, SWITCH_CASE_OR_DEFAULT, null, + "Branch contains a list of lists, use flatten to create a list."); + } + + @Test + void listOfListsAsListLiteralElementShouldError() { + RosettaExpression expr = modelService.toTestModel(""" + type Foo: + xs string (0..*) + """).parseExpression(""" + ["a", foos extract item -> xs] + """, "foos Foo (0..*)"); + + validationTestHelper.assertError(expr, LIST_LITERAL, null, + "List element contains a list of lists, use flatten to create a list."); + } + + @Test + void listOfListsAsConstructorValueShouldError() { + RosettaExpression expr = modelService.toTestModel(""" + type Foo: + xs string (0..*) + """).parseExpression(""" + Foo { xs: foos extract item -> xs } + """, "foos Foo (0..*)"); + + validationTestHelper.assertError(expr, CONSTRUCTOR_KEY_VALUE_PAIR, null, + "Attribute value contains a list of lists, use flatten to create a list."); + } + + @Test + void listOfListsAsOperandOfDefaultOperationShouldError() { + RosettaExpression expr = modelService.toTestModel(""" + type Foo: + xs string (0..*) + """).parseExpression(""" + (foos extract item -> xs) default empty + """, "foos Foo (0..*)"); + + validationTestHelper.assertError(expr, DEFAULT_OPERATION, null, + "Left operand contains a list of lists, use flatten to create a list."); + } + + @Test + void listOfListsAsFunctionArgumentShouldError() { + RosettaExpression expr = modelService.toTestModel(""" + type Foo: + xs string (0..*) + + func Identity: + inputs: + strings string (0..*) + output: + result string (0..*) + + add result: + strings + """).parseExpression(""" + Identity(foos extract item -> xs) + """, "foos Foo (0..*)"); + + validationTestHelper.assertError(expr, ROSETTA_SYMBOL_REFERENCE, null, + "Argument contains a list of lists, use flatten to create a list."); + } } diff --git a/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/ExpressionGenerator.xtend b/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/ExpressionGenerator.xtend index 5d8758d6e7..4c2b5d5ea0 100644 --- a/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/ExpressionGenerator.xtend +++ b/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/ExpressionGenerator.xtend @@ -1156,7 +1156,7 @@ class ExpressionGenerator extends RosettaExpressionSwitch errorClass) { val argumentJavaType = typeProvider.getRMetaAnnotatedType(expr.argument).RType.toJavaReferenceType diff --git a/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/TypeCoercionService.xtend b/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/TypeCoercionService.xtend index 0a6403f694..93d5de3e31 100644 --- a/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/TypeCoercionService.xtend +++ b/rune-lang/src/main/java/com/regnosys/rosetta/generator/java/expression/TypeCoercionService.xtend @@ -12,6 +12,7 @@ import com.regnosys.rosetta.generator.java.types.JavaTypeUtil import com.regnosys.rosetta.generator.java.types.RJavaWithMetaValue import com.rosetta.model.lib.expression.ComparisonResult import com.rosetta.model.lib.mapper.MapperC +import com.rosetta.model.lib.mapper.MapperListOfLists import com.rosetta.model.lib.mapper.MapperS import com.rosetta.util.types.JavaPrimitiveType import com.rosetta.util.types.JavaReferenceType @@ -382,6 +383,8 @@ class TypeCoercionService { JavaExpression.from('''«MapperS».<«itemType»>ofNull()''', MAPPER_S.wrap(itemType)) } else if (expected.isMapperC) { JavaExpression.from('''«MapperC».<«itemType»>ofNull()''', MAPPER_C.wrap(itemType)) + } else if (expected.isMapperListOfLists) { + JavaExpression.from('''«MapperListOfLists».<«itemType»>of(«Collections».emptyList())''', MAPPER_LIST_OF_LISTS.wrap(itemType)) } else if (expected.isComparisonResult) { JavaExpression.from('''«ComparisonResult».ofEmpty()''', COMPARISON_RESULT) } else if (expected == JavaPrimitiveType.BOOLEAN) { diff --git a/rune-lang/src/main/java/com/regnosys/rosetta/types/CardinalityProvider.java b/rune-lang/src/main/java/com/regnosys/rosetta/types/CardinalityProvider.java index 20138ae737..bdd986fc12 100644 --- a/rune-lang/src/main/java/com/regnosys/rosetta/types/CardinalityProvider.java +++ b/rune-lang/src/main/java/com/regnosys/rosetta/types/CardinalityProvider.java @@ -217,7 +217,9 @@ public boolean isOutputListOfLists(RosettaExpression expr) { return safeIsOutputListOfLists(expr, new HashMap<>()); } private boolean safeIsOutputListOfLists(RosettaExpression expr, Map cycleTracker) { - if (expr instanceof FlattenOperation) { + if (expr == null) { + return false; + } else if (expr instanceof FlattenOperation) { return false; } else if (expr instanceof MapOperation) { MapOperation mapOperation = (MapOperation) expr; @@ -250,6 +252,19 @@ private boolean safeIsOutputListOfLists(RosettaExpression expr, Map "A condition must be a boolean"); } + @Check + public void checkListOfListsInBinaryOperation(RosettaBinaryOperation op) { + isNotListOfListsCheck(op.getLeft(), op, ROSETTA_BINARY_OPERATION__LEFT, "Left operand"); + isNotListOfListsCheck(op.getRight(), op, ROSETTA_BINARY_OPERATION__RIGHT, "Right operand"); + } + + /** + * A conditional expression is a list of lists if one of its branches is. All of its other + * branches must then be a list of lists as well, or be empty. + */ + @Check + public void checkListOfListsInConditionalExpression(RosettaConditionalExpression expr) { + checkBranchesAgreeOnListOfLists(List.of( + new Branch(expr.getIfthen(), expr, ROSETTA_CONDITIONAL_EXPRESSION__IFTHEN), + new Branch(expr.getElsethen(), expr, ROSETTA_CONDITIONAL_EXPRESSION__ELSETHEN))); + } + + /** + * See {@link #checkListOfListsInConditionalExpression(RosettaConditionalExpression)}. + */ + @Check + public void checkListOfListsInSwitchOperation(SwitchOperation expr) { + checkBranchesAgreeOnListOfLists(expr.getCases().stream() + .map(c -> new Branch(c.getExpression(), c, SWITCH_CASE_OR_DEFAULT__EXPRESSION)) + .toList()); + } + + private record Branch(RosettaExpression expression, EObject source, EStructuralFeature feature) { + } + + private void checkBranchesAgreeOnListOfLists(List branches) { + Map> partition = branches.stream() + .collect(Collectors.partitioningBy(b -> b.expression() != null && cardinalityProvider.isOutputListOfLists(b.expression()))); + List listOfListsBranches = partition.get(true); + if (listOfListsBranches.isEmpty()) { + return; + } + boolean allOtherBranchesAreEmpty = partition.get(false).stream().allMatch(this::isEmptyExpression); + if (allOtherBranchesAreEmpty) { + return; + } + listOfListsBranches.forEach(b -> + error("Branch contains a list of lists, use flatten to create a list.", b.source(), b.feature())); + } + + private boolean isEmptyExpression(Branch branch) { + return branch.expression() == null + || builtins.NOTHING.equals(typeProvider.getRMetaAnnotatedType(branch.expression()).getRType()); + } + @Check public void checkFunctionOperation(Operation op) { RosettaExpression expr = op.getExpression(); @@ -286,6 +337,9 @@ public void checkConditionalExpression(RosettaConditionalExpression expr) { @Check public void checkListLiteral(ListLiteral expr) { commonTypeCheck(expr.getElements(), expr, LIST_LITERAL__ELEMENTS); + for (int i = 0; i < expr.getElements().size(); i++) { + isNotListOfListsCheck(expr.getElements().get(i), expr, LIST_LITERAL__ELEMENTS, i, "List element"); + } } @Check @@ -345,6 +399,13 @@ private void checkCallableReference(RosettaCallableReference expr, RosettaCallab if (ecoreUtil.isResolved(callable)) { int paramCount = callable.numberOfParameters(); int argCount = expr.getArgs().size(); + for (int i = 0; i < argCount; i++) { + if (expr.isExplicitArguments()) { + isNotListOfListsCheck(expr.getArgs().get(i), expr, ROSETTA_CALLABLE_REFERENCE__RAW_ARGS, i, "Argument"); + } else { + isNotListOfListsCheck(expr.getArgs().get(i), expr, null, "Argument"); + } + } if (paramCount != argCount) { error("Expected " + paramCount + " argument" + (paramCount == 1 ? "" : "s") + ", but got " + argCount + " instead", expr, null); }