diff --git a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ExpressionSuggesterSpec.scala b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ExpressionSuggesterSpec.scala index 9e398db4237..210a136a067 100644 --- a/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ExpressionSuggesterSpec.scala +++ b/designer/server/src/test/scala/pl/touk/nussknacker/ui/api/ExpressionSuggesterSpec.scala @@ -114,6 +114,11 @@ class ExpressionSuggesterSpec Map("empty" -> List(StaticMethodDefinition(MethodTypeInfo(Nil, None, Typed[Boolean]), "empty", None))), Map.empty ), + ClassDefinition( + Unknown, + Map("canCastTo" -> List(StaticMethodDefinition(MethodTypeInfo(Nil, None, Typed[Boolean]), "canCastTo", None))), + Map.empty + ), ) ) @@ -149,6 +154,7 @@ class ExpressionSuggesterSpec "listOfUnions" -> Typed.genericTypeClass[java.util.List[A]](List(Typed(Typed[A], Typed[B]))), "dictFoo" -> DictInstance("dictFoo", EmbeddedDictDefinition(Map.empty[String, String])).typingResult, "dictBar" -> DictInstance("dictBar", EmbeddedDictDefinition(Map.empty[String, String])).typingResult, + "unknown" -> Unknown ) private def spelSuggestionsFor(input: String, row: Int = 0, column: Int = -1): List[ExpressionSuggestion] = { @@ -202,6 +208,7 @@ class ExpressionSuggesterSpec "#other", "#union", "#unionOfLists", + "#unknown", "#util" ) } @@ -219,6 +226,7 @@ class ExpressionSuggesterSpec "#other", "#union", "#unionOfLists", + "#unknown", "#util" ) } @@ -765,6 +773,12 @@ class ExpressionSuggesterSpec ) } + test("should suggest methods for unknown") { + spelSuggestionsFor("#unknown.") shouldBe List( + suggestion("canCastTo", Typed[Boolean]), + ) + } + } object ExpressionSuggesterTestData { diff --git a/docs/Changelog.md b/docs/Changelog.md index 09d18de7599..117b5b6a355 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -62,6 +62,7 @@ * [#6925](https://github.com/TouK/nussknacker/pull/6925) Fix situation when preset labels were presented as `null` when node didn't pass the validation. * [#6935](https://github.com/TouK/nussknacker/pull/6935) Spel: Scenario labels added to meta variable - `#meta.scenarioLabels` * [#6952](https://github.com/TouK/nussknacker/pull/6952) Improvement: TypeInformation support for scala.Option +* [#6840](https://github.com/TouK/nussknacker/pull/6840) Introduce canCastTo, castTo and castToOrNull extension methods in SpeL. ## 1.17 diff --git a/docs/scenarios_authoring/Spel.md b/docs/scenarios_authoring/Spel.md index 8d6c400aead..d45e7632f4f 100644 --- a/docs/scenarios_authoring/Spel.md +++ b/docs/scenarios_authoring/Spel.md @@ -376,3 +376,16 @@ On the other hand, formatter created using `#DATE_FORMAT.formatter()` method wil - `#DATE_FORMAT.lenientFormatter('yyyy-MM-dd EEEE', 'PL')` - creates lenient version `DateTimeFormatter` using given pattern and locale For full list of available format options take a look at [DateTimeFormatter api docs](https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/time/format/DateTimeFormatter.html). + +## Casting. + +When a type cannot be determined by parser, the type is presented as `Unknown`. When we know what the type will be on +runtime, we can cast a given type, and then we can operate on the cast type. + +E.g. having a variable `obj` of a type: `List[Unknown]` and we know the elements are strings then we can cast elements +to String: `#obj.![#this.castToOrNull('java.lang.String')]`. + +Available methods: +- `canCastTo` - checks if a type can be cast to a given class. +- `castTo` - casts a type to a given class or throws exception if type cannot be cast. +- `castToOrNull` - casts a type to a given class or return null if type cannot be cast. diff --git a/engine/flink/management/dev-model/src/test/resources/extractedTypes/devCreator.json b/engine/flink/management/dev-model/src/test/resources/extractedTypes/devCreator.json index dd13e1d0f0c..74bd521a3c3 100644 --- a/engine/flink/management/dev-model/src/test/resources/extractedTypes/devCreator.json +++ b/engine/flink/management/dev-model/src/test/resources/extractedTypes/devCreator.json @@ -916,6 +916,48 @@ { "clazzName": {"refClazzName": "java.lang.CharSequence"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "charAt": [ { "name": "charAt", @@ -2823,6 +2865,48 @@ } } ], + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "doubleValue": [ { "name": "doubleValue", @@ -2883,6 +2967,48 @@ { "clazzName": {"type": "Unknown"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -9814,6 +9940,48 @@ { "clazzName": {"refClazzName": "java.time.ZoneId"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "getDisplayName": [ { "name": "getDisplayName", @@ -11137,6 +11305,48 @@ { "clazzName": {"refClazzName": "java.time.temporal.Temporal"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -11162,6 +11372,48 @@ "refClazzName": "java.util.Collection" }, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "contains": [ { "name": "contains", @@ -12994,25 +13246,6 @@ { "clazzName": {"refClazzName": "pl.touk.nussknacker.engine.variables.MetaVariables"}, "methods": { - "scenarioLabels": [ - { - "name": "scenarioLabels", - "signature": { - "noVarArgs": [], - "result": { - "refClazzName": "java.util.List", - "params" : [ - { - "display" : "String", - "params" : [], - "refClazzName" : "java.lang.String", - "type" : "TypedClass" - } - ] - } - } - } - ], "processName": [ { "name": "processName", @@ -13047,6 +13280,25 @@ } } ], + "scenarioLabels": [ + { + "name": "scenarioLabels", + "signature": { + "noVarArgs": [], + "result": { + "params": [ + { + "display": "String", + "params": [], + "refClazzName": "java.lang.String", + "type": "TypedClass" + } + ], + "refClazzName": "java.util.List" + } + } + } + ], "toString": [ { "name": "toString", @@ -13067,15 +13319,15 @@ { "name": "scenarioLabels", "refClazz": { - "refClazzName": "java.util.List", - "params" : [ + "params": [ { - "display" : "String", - "params" : [], - "refClazzName" : "java.lang.String", - "type" : "TypedClass" + "display": "String", + "params": [], + "refClazzName": "java.lang.String", + "type": "TypedClass" } - ] + ], + "refClazzName": "java.util.List" } }, { diff --git a/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json b/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json index e9538fc9bf7..f24de2fc4eb 100644 --- a/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json +++ b/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json @@ -436,6 +436,48 @@ { "clazzName": {"refClazzName": "java.lang.CharSequence"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "charAt": [ { "name": "charAt", @@ -517,6 +559,48 @@ "refClazzName": "java.lang.Comparable" }, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -2368,6 +2452,48 @@ } } ], + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "doubleValue": [ { "name": "doubleValue", @@ -2428,6 +2554,48 @@ { "clazzName": {"type": "Unknown"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -9374,6 +9542,48 @@ { "clazzName": {"refClazzName": "java.time.ZoneId"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "getDisplayName": [ { "name": "getDisplayName", @@ -11415,6 +11625,48 @@ { "clazzName": {"refClazzName": "java.time.temporal.Temporal"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -11430,6 +11682,48 @@ { "clazzName": {"refClazzName": "java.time.temporal.TemporalAccessor"}, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -11455,6 +11749,48 @@ "refClazzName": "java.util.Collection" }, "methods": { + "canCastTo": [ + { + "description": "Checks if a type can be cast to a given class", + "name": "canCastTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castTo": [ + { + "description": "Casts a type to a given class or throws exception if type cannot be cast.", + "name": "castTo", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], + "castToOrNull": [ + { + "description": "Casts a type to a given class or return null if type cannot be cast.", + "name": "castToOrNull", + "signatures": [ + { + "noVarArgs": [ + {"name": "className", "refClazz": {"refClazzName": "java.lang.String"}} + ], + "result": {"type": "Unknown"} + } + ] + } + ], "contains": [ { "name": "contains", @@ -15074,25 +15410,6 @@ { "clazzName": {"refClazzName": "pl.touk.nussknacker.engine.variables.MetaVariables"}, "methods": { - "scenarioLabels": [ - { - "name": "scenarioLabels", - "signature": { - "noVarArgs": [], - "result": { - "refClazzName": "java.util.List", - "params" : [ - { - "display" : "String", - "params" : [], - "refClazzName" : "java.lang.String", - "type" : "TypedClass" - } - ] - } - } - } - ], "processName": [ { "name": "processName", @@ -15127,6 +15444,25 @@ } } ], + "scenarioLabels": [ + { + "name": "scenarioLabels", + "signature": { + "noVarArgs": [], + "result": { + "params": [ + { + "display": "String", + "params": [], + "refClazzName": "java.lang.String", + "type": "TypedClass" + } + ], + "refClazzName": "java.util.List" + } + } + } + ], "toString": [ { "name": "toString", @@ -15147,15 +15483,15 @@ { "name": "scenarioLabels", "refClazz": { - "refClazzName": "java.util.List", - "params" : [ + "params": [ { - "display" : "String", - "params" : [], - "refClazzName" : "java.lang.String", - "type" : "TypedClass" + "display": "String", + "params": [], + "refClazzName": "java.lang.String", + "type": "TypedClass" } - ] + ], + "refClazzName": "java.util.List" } }, { diff --git a/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/NuReflectiveMethodExecutor.java b/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/NuReflectiveMethodExecutor.java index ef3b01f944c..55eafa11889 100644 --- a/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/NuReflectiveMethodExecutor.java +++ b/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/NuReflectiveMethodExecutor.java @@ -8,7 +8,7 @@ import org.springframework.expression.spel.support.ReflectionHelper; import org.springframework.expression.spel.support.ReflectiveMethodExecutor; import org.springframework.util.ReflectionUtils; -import pl.touk.nussknacker.engine.spel.internal.ArrayToListConversionHandler; +import pl.touk.nussknacker.engine.spel.internal.RuntimeConversionHandler; import java.lang.reflect.Method; import java.lang.reflect.Modifier; @@ -19,8 +19,8 @@ // As an additional feature we allow to invoke list methods on arrays and // in the point of the method invocation we convert an array to a list public class NuReflectiveMethodExecutor extends ReflectiveMethodExecutor { - private static final ArrayToListConversionHandler.ConversionAwareMethodInvoker methodInvoker = - new ArrayToListConversionHandler.ConversionAwareMethodInvoker(); + private static final RuntimeConversionHandler.ConversionAwareMethodInvoker methodInvoker = + new RuntimeConversionHandler.ConversionAwareMethodInvoker(); private final Method method; @@ -32,7 +32,9 @@ public class NuReflectiveMethodExecutor extends ReflectiveMethodExecutor { private boolean argumentConversionOccurred = false; - public NuReflectiveMethodExecutor(ReflectiveMethodExecutor original) { + private final ClassLoader classLoader; + + public NuReflectiveMethodExecutor(ReflectiveMethodExecutor original, ClassLoader classLoader) { super(original.getMethod()); this.method = original.getMethod(); if (method.isVarArgs()) { @@ -42,6 +44,7 @@ public NuReflectiveMethodExecutor(ReflectiveMethodExecutor original) { else { this.varargsPosition = null; } + this.classLoader = classLoader; } /** @@ -98,7 +101,7 @@ public TypedValue execute(EvaluationContext context, Object target, Object... ar } ReflectionUtils.makeAccessible(this.method); //Nussknacker: we use custom method invoker which is aware of array conversion - Object value = methodInvoker.invoke(this.method, target, arguments); + Object value = methodInvoker.invoke(this.method, target, arguments, this.classLoader); return new TypedValue(value, new TypeDescriptor(new MethodParameter(this.method, -1)).narrow(value)); } catch (Exception ex) { diff --git a/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/internal/ArrayToListConversionHandler.java b/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/internal/RuntimeConversionHandler.java similarity index 63% rename from scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/internal/ArrayToListConversionHandler.java rename to scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/internal/RuntimeConversionHandler.java index 76e1310d8a6..73114d00c3f 100644 --- a/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/internal/ArrayToListConversionHandler.java +++ b/scenario-compiler/src/main/java/pl/touk/nussknacker/engine/spel/internal/RuntimeConversionHandler.java @@ -4,6 +4,9 @@ import org.springframework.core.convert.TypeDescriptor; import org.springframework.core.convert.converter.ConditionalGenericConverter; import org.springframework.lang.Nullable; +import pl.touk.nussknacker.engine.extension.Cast; +import pl.touk.nussknacker.engine.extension.ExtensionMethods; +import scala.PartialFunction; import java.lang.reflect.Array; import java.lang.reflect.InvocationTargetException; @@ -13,23 +16,41 @@ import java.util.Collection; import java.util.List; import java.util.Set; +import java.util.stream.Stream; -public class ArrayToListConversionHandler { +public class RuntimeConversionHandler { public static final class ConversionAwareMethodsDiscovery { + private static final Method[] CAST_METHODS = Cast.class.getMethods(); + private static final Method[] LIST_AND_CAST_METHODS = concatArrays(List.class.getMethods(), CAST_METHODS); public Method[] discover(Class type) { if (type.isArray()) { - return List.class.getMethods(); + return LIST_AND_CAST_METHODS; } - return type.getMethods(); + return concatArrays(type.getMethods(), CAST_METHODS); + } + + private static Method[] concatArrays(Method[] a, Method[] b) { + return Stream + .concat(Arrays.stream(a), Arrays.stream(b)) + .toArray(Method[]::new); } } public static final class ConversionAwareMethodInvoker { - public Object invoke(Method method, Object target, Object[] arguments) throws IllegalAccessException, InvocationTargetException { - if (target != null && target.getClass().isArray() && method.getDeclaringClass().isAssignableFrom(List.class)) { - return method.invoke(ArrayToListConversionHandler.convert(target), arguments); + public Object invoke(Method method, + Object target, + Object[] arguments, + ClassLoader classLoader) throws IllegalAccessException, InvocationTargetException { + Class methodDeclaringClass = method.getDeclaringClass(); + if (target != null && target.getClass().isArray() && methodDeclaringClass.isAssignableFrom(List.class)) { + return method.invoke(RuntimeConversionHandler.convert(target), arguments); + } + PartialFunction, Object> extMethod = + ExtensionMethods.invoke(method, target, arguments, classLoader); + if (extMethod.isDefinedAt(methodDeclaringClass)) { + return extMethod.apply(methodDeclaringClass); } else { return method.invoke(target, arguments); } @@ -74,7 +95,7 @@ public Object convert( if (source == null) { return null; } - return ArrayToListConversionHandler.convert(source); + return RuntimeConversionHandler.convert(source); } } diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/ExpressionCompiler.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/ExpressionCompiler.scala index ce71c388b83..23c7426e4fb 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/ExpressionCompiler.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/compile/ExpressionCompiler.scala @@ -10,6 +10,7 @@ import pl.touk.nussknacker.engine.api.context.{PartSubGraphCompilationError, Pro import pl.touk.nussknacker.engine.api.definition._ import pl.touk.nussknacker.engine.api.dict.{DictRegistry, EngineDictRegistry} import pl.touk.nussknacker.engine.api.parameter.ParameterName +import pl.touk.nussknacker.engine.api.process.ClassExtractionSettings import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypingResult} import pl.touk.nussknacker.engine.compiledgraph.{CompiledParameter, TypedParameter} import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionSet diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionSet.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionSet.scala index d3a9892da77..3bbaaa60a44 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionSet.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionSet.scala @@ -9,6 +9,7 @@ object ClassDefinitionSet { } case class ClassDefinitionSet(classDefinitionsMap: Map[Class[_], ClassDefinition]) { + lazy val unknown = get(classOf[java.lang.Object]) def all: Set[ClassDefinition] = classDefinitionsMap.values.toSet diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/model/ModelDefinitionWithClasses.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/model/ModelDefinitionWithClasses.scala index cef12a7194b..b4384746811 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/model/ModelDefinitionWithClasses.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/definition/model/ModelDefinitionWithClasses.scala @@ -1,12 +1,12 @@ package pl.touk.nussknacker.engine.definition.model import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionSet -import pl.touk.nussknacker.engine.definition.component.ComponentDefinitionWithImplementation +import pl.touk.nussknacker.engine.extension.ExtensionMethods case class ModelDefinitionWithClasses(modelDefinition: ModelDefinition) { - @transient lazy val classDefinitions: ClassDefinitionSet = ClassDefinitionSet( - ModelClassDefinitionDiscovery.discoverClasses(modelDefinition) + @transient lazy val classDefinitions: ClassDefinitionSet = ExtensionMethods.enrichWithExtensionMethods( + ClassDefinitionSet(ModelClassDefinitionDiscovery.discoverClasses(modelDefinition)) ) } diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/Cast.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/Cast.scala new file mode 100644 index 00000000000..9614059af6a --- /dev/null +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/Cast.scala @@ -0,0 +1,112 @@ +package pl.touk.nussknacker.engine.extension + +import cats.data.ValidatedNel +import cats.implicits.catsSyntaxValidatedId +import pl.touk.nussknacker.engine.api.generics.{GenericFunctionTypingError, MethodTypeInfo, Parameter} +import pl.touk.nussknacker.engine.api.typed.typing +import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypedObjectWithValue, TypingResult, Unknown} +import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinitionSet, FunctionalMethodDefinition, MethodDefinition} +import pl.touk.nussknacker.engine.extension.CastMethodDefinitions._ +import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap + +import scala.util.Try + +sealed trait Cast { + def canCastTo(className: String): Boolean + def castTo[T](className: String): T + def castToOrNull[T >: Null](className: String): T +} + +class CastImpl(target: Any, classLoader: ClassLoader) extends Cast { + + override def canCastTo(className: String): Boolean = + classLoader.loadClass(className).isAssignableFrom(target.getClass) + + override def castTo[T](className: String): T = { + val clazz = classLoader.loadClass(className) + if (clazz.isInstance(target)) { + clazz.cast(target).asInstanceOf[T] + } else { + throw new ClassCastException(s"Cannot cast: ${target.getClass} to: $className") + } + } + + override def castToOrNull[T >: Null](className: String): T = Try { castTo[T](className) }.getOrElse(null) +} + +private[extension] object CastImpl extends ExtensionMethodsImplFactory { + override def create(target: Any, classLoader: ClassLoader): Any = + new CastImpl(target, classLoader) +} + +private[extension] class CastMethodDefinitions(private val classesWithTyping: Map[Class[_], TypingResult]) { + + def extractDefinitions(clazz: Class[_]): Map[String, List[MethodDefinition]] = { + val childTypes = classesWithTyping.filterKeysNow(targetClazz => + clazz != targetClazz && + clazz.isAssignableFrom(targetClazz) + ) + childTypes match { + case allowedClasses if allowedClasses.isEmpty => Map.empty + case allowedClasses => definitions(allowedClasses) + } + } + + private def definitions(allowedClasses: Map[Class[_], TypingResult]): Map[String, List[MethodDefinition]] = + List( + FunctionalMethodDefinition( + (_, x) => canCastToTyping(allowedClasses)(x), + methodTypeInfoWithStringParam, + "canCastTo", + Some("Checks if a type can be cast to a given class") + ), + FunctionalMethodDefinition( + (_, x) => castToTyping(allowedClasses)(x), + methodTypeInfoWithStringParam, + "castTo", + Some("Casts a type to a given class or throws exception if type cannot be cast.") + ), + FunctionalMethodDefinition( + (_, x) => castToTyping(allowedClasses)(x), + methodTypeInfoWithStringParam, + "castToOrNull", + Some("Casts a type to a given class or return null if type cannot be cast.") + ), + ).groupBy(_.name) + + private def castToTyping(allowedClasses: Map[Class[_], TypingResult])( + arguments: List[typing.TypingResult] + ): ValidatedNel[GenericFunctionTypingError, typing.TypingResult] = arguments match { + case TypedObjectWithValue(_, clazzName: String) :: Nil => + allowedClasses.find(_._1.getName == clazzName).map(_._2) match { + case Some(typing) => typing.validNel + case None => GenericFunctionTypingError.OtherError(s"Casting to '$clazzName' is not allowed").invalidNel + } + case _ => GenericFunctionTypingError.ArgumentTypeError.invalidNel + } + + private def canCastToTyping(allowedClasses: Map[Class[_], TypingResult])( + arguments: List[typing.TypingResult] + ): ValidatedNel[GenericFunctionTypingError, typing.TypingResult] = + castToTyping(allowedClasses)(arguments).map(_ => Typed.typedClass[Boolean]) + +} + +object CastMethodDefinitions { + private val stringClass = classOf[String] + + private val methodTypeInfoWithStringParam = MethodTypeInfo( + noVarArgs = List( + Parameter("className", Typed.genericTypeClass(stringClass, Nil)) + ), + varArg = None, + result = Unknown + ) + + def apply(set: ClassDefinitionSet): CastMethodDefinitions = + new CastMethodDefinitions( + set.classDefinitionsMap + .mapValuesNow(_.clazzName) + ) + +} diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/ExtensionMethods.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/ExtensionMethods.scala new file mode 100644 index 00000000000..f5d66a50512 --- /dev/null +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/ExtensionMethods.scala @@ -0,0 +1,44 @@ +package pl.touk.nussknacker.engine.extension + +import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionSet + +import java.lang.reflect.Method + +object ExtensionMethods { + + private val declarationsWithImplementations = Map[Class[_], ExtensionMethodsImplFactory]( + classOf[Cast] -> CastImpl, + ) + + private val registry: Set[Class[_]] = declarationsWithImplementations.keySet + + def enrichWithExtensionMethods(set: ClassDefinitionSet): ClassDefinitionSet = { + val castMethodDefinitions = CastMethodDefinitions(set) + new ClassDefinitionSet( + set.classDefinitionsMap.map { case (clazz, definition) => + clazz -> definition.copy(methods = definition.methods ++ castMethodDefinitions.extractDefinitions(clazz)) + }.toMap // .toMap is needed by scala 2.12 + ) + } + + def invoke( + method: Method, + target: Any, + arguments: Array[Object], + classLoader: ClassLoader + ): PartialFunction[Class[_], Any] = { + case clazz if registry.contains(clazz) => + declarationsWithImplementations + .get(method.getDeclaringClass) + .map(_.create(target, classLoader)) + .map(impl => method.invoke(impl, arguments: _*)) + .getOrElse { + throw new IllegalArgumentException(s"Extension method: ${method.getName} is not implemented") + } + } + +} + +trait ExtensionMethodsImplFactory { + def create(target: Any, classLoader: ClassLoader): Any +} diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSuggester.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSuggester.scala index fb8c8c60a36..102492c293b 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSuggester.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSuggester.scala @@ -1,5 +1,6 @@ package pl.touk.nussknacker.engine.spel +import cats.implicits._ import com.typesafe.scalalogging.LazyLogging import io.circe.generic.JsonCodec import org.springframework.expression.common.TemplateParserContext @@ -19,14 +20,10 @@ import pl.touk.nussknacker.engine.spel.Typer.TypingResultWithContext import pl.touk.nussknacker.engine.spel.ast.SpelAst.SpelNodeId import pl.touk.nussknacker.engine.spel.parser.NuTemplateAwareExpressionParser import pl.touk.nussknacker.engine.util.CaretPosition2d -import scala.collection.compat.immutable.LazyList -import scala.jdk.CollectionConverters._ - -import cats._ -import cats.implicits._ +import scala.collection.compat.immutable.LazyList import scala.concurrent.{ExecutionContext, Future} -import scala.util.{Failure, Success, Try} +import scala.util.{Failure, Try} class SpelExpressionSuggester( expressionConfig: ExpressionConfigDefinition, @@ -197,6 +194,12 @@ class SpelExpressionSuggester( .queryEntriesByLabel(td.dictId, if (shouldInsertDummyVariable) "" else p.getName) .map(_.map(list => list.map(e => ExpressionSuggestion(e.label, td, fromClass = false, None, Nil)))) .getOrElse(successfulNil) + case TypingResultWithContext(Unknown, staticContext) => + Future.successful( + clssDefinitions.unknown + .map(c => filterClassMethods(c, p.getName, staticContext)) + .getOrElse(Nil) + ) } .getOrElse(successfulNil) } diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/DefaultSpelConversionsProvider.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/DefaultSpelConversionsProvider.scala index 766b6b1e0c5..7f7d43206b4 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/DefaultSpelConversionsProvider.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/DefaultSpelConversionsProvider.scala @@ -31,7 +31,7 @@ class DefaultSpelConversionsProvider extends SpelConversionsProvider { service.addConverter(new ObjectToArrayConverter(service)) // For purpose of concise usage of numbers in spel templates service.addConverter(classOf[Number], classOf[String], (source: Number) => source.toString) - service.addConverter(new ArrayToListConversionHandler.ArrayToListConverter(service)) + service.addConverter(new RuntimeConversionHandler.ArrayToListConverter(service)) service } diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/OptimizedEvaluationContext.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/OptimizedEvaluationContext.scala index 77604ac856e..03523b71de2 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/OptimizedEvaluationContext.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/internal/OptimizedEvaluationContext.scala @@ -39,7 +39,7 @@ class EvaluationContextPreparer( private val optimizedMethodResolvers: java.util.List[MethodResolver] = { val mr = new ReflectiveMethodResolver { - private val conversionAwareMethodsDiscovery = new ArrayToListConversionHandler.ConversionAwareMethodsDiscovery() + private val conversionAwareMethodsDiscovery = new RuntimeConversionHandler.ConversionAwareMethodsDiscovery() override def resolve( context: EvaluationContext, @@ -53,7 +53,7 @@ class EvaluationContextPreparer( null } else { spelExpressionExcludeList.blockExcluded(targetObject, name) - new NuReflectiveMethodExecutor(methodExecutor) + new NuReflectiveMethodExecutor(methodExecutor, classLoader) } } diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/typer/MethodReferenceTyper.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/typer/MethodReferenceTyper.scala index 0ff2af36bba..167cd67200f 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/typer/MethodReferenceTyper.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/typer/MethodReferenceTyper.scala @@ -1,7 +1,7 @@ package pl.touk.nussknacker.engine.spel.typer -import cats.data.{NonEmptyList, ValidatedNel} import cats.data.Validated.{Invalid, Valid} +import cats.data.{NonEmptyList, ValidatedNel} import pl.touk.nussknacker.engine.api.generics.ExpressionParseError import pl.touk.nussknacker.engine.api.typed.typing._ import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinition, ClassDefinitionSet, MethodDefinition} @@ -21,7 +21,10 @@ class MethodReferenceTyper(classDefinitionSet: ClassDefinitionSet, methodExecuti case TypedNull => Left(IllegalInvocationError(TypedNull)) case Unknown => - if (methodExecutionForUnknownAllowed) Right(Unknown) else Left(IllegalInvocationError(Unknown)) + typeFromClazzDefinitions(classDefinitionSet.unknown.toList) match { + case Right(Unknown) if !methodExecutionForUnknownAllowed => Left(IllegalInvocationError(Unknown)) + case result @ _ => result + } } } diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionDiscoverySpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionDiscoverySpec.scala index fd43f63620e..3a7bbf7d014 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionDiscoverySpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionDiscoverySpec.scala @@ -18,7 +18,7 @@ import pl.touk.nussknacker.engine.api.typed.supertype.CommonSupertypeFinder import pl.touk.nussknacker.engine.api.typed.typing import pl.touk.nussknacker.engine.api.typed.typing.{Typed, _} import pl.touk.nussknacker.engine.api.{Context, Documentation, Hidden, HideToString, ParamName} -import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionTestUtils.DefaultExtractor +import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionTestUtils.{DefaultExtractor, createDiscovery} import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.ArgumentTypeError import pl.touk.nussknacker.engine.spel.SpelExpressionRepr import pl.touk.nussknacker.test.ValidatedValuesDetailedMessage @@ -145,26 +145,24 @@ class ClassDefinitionDiscoverySpec forAll(testTypes) { (clazz, clazzName) => forAll(testClassPatterns) { classPattern => - val infos = new ClassDefinitionDiscovery( - new ClassDefinitionExtractor( - ClassExtractionSettings.Default.copy(excludeClassMemberPredicates = - ClassExtractionSettings.DefaultExcludedMembers ++ Seq( - MemberNamePatternPredicate( - SuperClassPredicate(ClassPatternPredicate(Pattern.compile(classPattern))), - Pattern.compile("ba.*") - ), - MemberNamePatternPredicate( - SuperClassPredicate(ClassPatternPredicate(Pattern.compile(classPattern))), - Pattern.compile("get.*") - ), - MemberNamePatternPredicate( - SuperClassPredicate(ClassPatternPredicate(Pattern.compile(classPattern))), - Pattern.compile("is.*") - ), - ReturnMemberPredicate( - ExactClassPredicate[Context], - BasePackagePredicate("pl.touk.nussknacker.engine.definition.clazz") - ) + val infos = createDiscovery( + ClassExtractionSettings.Default.copy(excludeClassMemberPredicates = + ClassExtractionSettings.DefaultExcludedMembers ++ Seq( + MemberNamePatternPredicate( + SuperClassPredicate(ClassPatternPredicate(Pattern.compile(classPattern))), + Pattern.compile("ba.*") + ), + MemberNamePatternPredicate( + SuperClassPredicate(ClassPatternPredicate(Pattern.compile(classPattern))), + Pattern.compile("get.*") + ), + MemberNamePatternPredicate( + SuperClassPredicate(ClassPatternPredicate(Pattern.compile(classPattern))), + Pattern.compile("is.*") + ), + ReturnMemberPredicate( + ExactClassPredicate[Context], + BasePackagePredicate("pl.touk.nussknacker.engine.definition.clazz") ) ) ) @@ -229,19 +227,14 @@ class ClassDefinitionDiscoverySpec test("should skip toString method when HideToString implemented") { val hiddenToStringClasses = Table("class", classOf[JavaBannedToStringClass], classOf[BannedToStringClass]) forAll(hiddenToStringClasses) { - DefaultExtractor.extract(_).methods.keys shouldNot contain( - "toString" - ) + DefaultExtractor.extract(_).methods.keys shouldNot contain("toString") } } test("should break recursive discovery if hidden class found") { - - val extracted = new ClassDefinitionDiscovery( - new ClassDefinitionExtractor( - ClassExtractionSettings.Default.copy( - excludeClassPredicates = ClassExtractionSettings.DefaultExcludedClasses :+ ExactClassPredicate[Middle] - ) + val extracted = createDiscovery( + ClassExtractionSettings.Default.copy( + excludeClassPredicates = ClassExtractionSettings.DefaultExcludedClasses :+ ExactClassPredicate[Middle] ) ).discoverClassesFromTypes(List(Typed[Top])) extracted.find(_.clazzName == Typed[Top]) shouldBe Symbol("defined") @@ -561,16 +554,14 @@ class ClassDefinitionDiscoverySpec ): Option[ClassDefinition] = { val ref = Typed.fromDetailedType[T] // ClazzDefinition has clazzName with generic parameters but they are always empty so we need to compare name without them - new ClassDefinitionDiscovery(new ClassDefinitionExtractor(settings)) - .discoverClassesFromTypes(List(ref)) - .find(_.getClazz == ref.asInstanceOf[TypedClass].klass) + createDiscovery(settings).discoverClassesFromTypes(List(ref)).find(_.getClazz == ref.asInstanceOf[TypedClass].klass) } private def singleClassAndItsChildrenDefinition[T: TypeTag]( settings: ClassExtractionSettings = ClassExtractionSettings.Default ) = { val ref = Typed.fromDetailedType[T] - new ClassDefinitionDiscovery(new ClassDefinitionExtractor(settings)).discoverClassesFromTypes(List(ref)) + createDiscovery(settings).discoverClassesFromTypes(List(ref)) } } diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionTestUtils.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionTestUtils.scala index 8df7f61c2b1..ddd89cd9b49 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionTestUtils.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/definition/clazz/ClassDefinitionTestUtils.scala @@ -1,10 +1,13 @@ package pl.touk.nussknacker.engine.definition.clazz import pl.touk.nussknacker.engine.api.process.{ClassExtractionSettings, ExpressionConfig} +import pl.touk.nussknacker.engine.api.typed.typing.TypingResult +import pl.touk.nussknacker.engine.extension.ExtensionMethods object ClassDefinitionTestUtils { - val DefaultExtractor = new ClassDefinitionExtractor(ClassExtractionSettings.Default) - val DefaultDiscovery = new ClassDefinitionDiscovery(DefaultExtractor) + val DefaultSettings: ClassExtractionSettings = ClassExtractionSettings.Default + val DefaultExtractor: ClassDefinitionExtractor = new ClassDefinitionExtractor(DefaultSettings) + val DefaultDiscovery: ClassDefinitionDiscovery = createDiscovery() def createDefinitionForClasses(classes: Class[_]*): ClassDefinitionSet = ClassDefinitionSet( DefaultDiscovery.discoverClasses(classes ++ ExpressionConfig.defaultAdditionalClasses) @@ -14,4 +17,18 @@ object ClassDefinitionTestUtils { DefaultDiscovery.discoverClasses(ExpressionConfig.defaultAdditionalClasses) ) + def createDefaultDefinitionForTypes(types: Iterable[TypingResult]): ClassDefinitionSet = + ClassDefinitionSet(DefaultDiscovery.discoverClassesFromTypes(types)) + + def createDefinitionWithDefaultsAndExtensions: ClassDefinitionSet = + ExtensionMethods.enrichWithExtensionMethods(createDefinitionForDefaultAdditionalClasses) + + def createDefaultDefinitionForTypesWithExtensions(types: Iterable[TypingResult]): ClassDefinitionSet = + ExtensionMethods.enrichWithExtensionMethods(createDefaultDefinitionForTypes(types)) + + def createDefinitionForClassesWithExtensions(classes: Class[_]*): ClassDefinitionSet = + ExtensionMethods.enrichWithExtensionMethods(createDefinitionForClasses(classes: _*)) + + def createDiscovery(settings: ClassExtractionSettings = DefaultSettings): ClassDefinitionDiscovery = + new ClassDefinitionDiscovery(new ClassDefinitionExtractor(settings)) } diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/extension/ExtensionMethodsSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/extension/ExtensionMethodsSpec.scala new file mode 100644 index 00000000000..6bd07bfcf2b --- /dev/null +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/extension/ExtensionMethodsSpec.scala @@ -0,0 +1,38 @@ +package pl.touk.nussknacker.engine.extension + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers +import pl.touk.nussknacker.engine.api.generics.MethodTypeInfo +import pl.touk.nussknacker.engine.api.typed.typing.{Typed, Unknown} +import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinition, ClassDefinitionSet, StaticMethodDefinition} + +class ExtensionMethodsSpec extends AnyFunSuite with Matchers { + + test( + "should add extension methods to already existing definitions in ClassDefinitionSet" + ) { + val stringDefinition = ClassDefinition( + Typed.typedClass[String], + Map( + "toUpperCase" -> List(StaticMethodDefinition(MethodTypeInfo(Nil, None, Typed[String]), "toUpperCase", None)) + ), + Map.empty + ) + val unknownDefinition = ClassDefinition( + Unknown, + Map( + "toString" -> List(StaticMethodDefinition(MethodTypeInfo(Nil, None, Typed[String]), "toString", None)) + ), + Map.empty + ) + val definitionsSet = ClassDefinitionSet(Set(stringDefinition, unknownDefinition)) + + ExtensionMethods.enrichWithExtensionMethods( + definitionsSet + ).classDefinitionsMap.map(e => e._1.getName -> e._2.methods.keys) shouldBe Map( + "java.lang.String" -> Set("toUpperCase"), + "java.lang.Object" -> Set("toString", "canCastTo", "castTo", "castToOrNull"), + ) + } + +} diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/MethodReferenceTyperSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/MethodReferenceTyperSpec.scala index a911e22a19b..62fbc1f9ad6 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/MethodReferenceTyperSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/MethodReferenceTyperSpec.scala @@ -8,7 +8,7 @@ import org.scalatest.matchers.should.Matchers import pl.touk.nussknacker.engine.api.generics.GenericFunctionTypingError.OtherError import pl.touk.nussknacker.engine.api.generics._ import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypingResult} -import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinitionSet, ClassDefinitionTestUtils} +import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionTestUtils import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.ArgumentTypeError import pl.touk.nussknacker.engine.spel.typer.MethodReferenceTyper @@ -44,10 +44,10 @@ class MethodReferenceTyperSpec extends AnyFunSuite with Matchers { } private val methodReferenceTyper = { - val classDefinitionSet = ClassDefinitionSet( - ClassDefinitionTestUtils.DefaultDiscovery.discoverClassesFromTypes(List(Typed[Helper])) + new MethodReferenceTyper( + ClassDefinitionTestUtils.createDefaultDefinitionForTypesWithExtensions(List(Typed[Helper])), + methodExecutionForUnknownAllowed = false ) - new MethodReferenceTyper(classDefinitionSet, methodExecutionForUnknownAllowed = false) } private def extractMethod(name: String, args: List[TypingResult]): Either[ExpressionParseError, TypingResult] = { diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala index 77f5a87a880..0040221ee12 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/SpelExpressionSpec.scala @@ -30,6 +30,7 @@ import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinitionSet, ClassDef import pl.touk.nussknacker.engine.dict.SimpleDictRegistry import pl.touk.nussknacker.engine.expression.parse.{CompiledExpression, TypedExpression} import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.IllegalOperationError.{ + IllegalInvocationError, IllegalProjectionSelectionError, InvalidMethodReference, TypeReferenceError @@ -42,7 +43,11 @@ import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.MissingObjectErr } import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.OperatorError._ import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.UnsupportedOperationError.ArrayConstructorError -import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.{ArgumentTypeError, ExpressionTypeError} +import pl.touk.nussknacker.engine.spel.SpelExpressionParseError.{ + ArgumentTypeError, + ExpressionTypeError, + GenericFunctionError +} import pl.touk.nussknacker.engine.spel.SpelExpressionParser.{Flavour, Standard} import pl.touk.nussknacker.engine.testing.ModelDefinitionBuilder import pl.touk.nussknacker.test.ValidatedValuesDetailedMessage @@ -90,12 +95,14 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD private val ctx = Context("abc").withVariables( Map( - "obj" -> testValue, - "strVal" -> "", - "mapValue" -> Map("foo" -> "bar").asJava, - "array" -> Array("a", "b"), - "intArray" -> Array(1, 2, 3), - "nestedArray" -> Array(Array(1, 2), Array(3, 4)) + "obj" -> testValue, + "strVal" -> "", + "mapValue" -> Map("foo" -> "bar").asJava, + "array" -> Array("a", "b"), + "intArray" -> Array(1, 2, 3), + "nestedArray" -> Array(Array(1, 2), Array(3, 4)), + "arrayOfUnknown" -> Array("unknown".asInstanceOf[Any]), + "unknownString" -> ContainerOfUnknown("unknown") ) ) @@ -122,6 +129,8 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD bigValue: BigDecimal = BigDecimal.valueOf(0L) ) + case class ContainerOfUnknown(value: Any) + import pl.touk.nussknacker.engine.util.Implicits._ private def parse[T: TypeTag]( @@ -229,7 +238,7 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD classOf[SampleValue], Class.forName("pl.touk.nussknacker.engine.spel.SampleGlobalObject") ) - ClassDefinitionTestUtils.createDefinitionForClasses(typesFromGlobalVariables ++ customClasses: _*) + ClassDefinitionTestUtils.createDefinitionForClassesWithExtensions(typesFromGlobalVariables ++ customClasses: _*) } test("parsing first selection on array") { @@ -1353,6 +1362,154 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD } } + test("should check if a type can be casted to a given type") { + forAll( + Table( + ("expression", "expectedResult"), + ("#unknownString.value.canCastTo('java.lang.String')", true), + ("#unknownString.value.canCastTo('java.lang.Integer')", false), + ) + ) { (expression, expectedResult) => + parse[Any](expression, ctx).validExpression.evaluateSync[Any](ctx) shouldBe expectedResult + } + } + + test("should return unknownMethodError during invoke cast on simple types") { + forAll( + Table( + ("expression", "expectedMethod", "expectedType"), + ("11.canCastTo('java.lang.String')", "canCastTo", "Integer"), + ("true.canCastTo('java.lang.String')", "canCastTo", "Boolean"), + ("'true'.canCastTo('java.lang.String')", "canCastTo", "String"), + ("11.castTo('java.lang.String')", "castTo", "Integer"), + ("true.castTo('java.lang.String')", "castTo", "Boolean"), + ("'true'.castTo('java.lang.String')", "castTo", "String"), + ) + ) { (expression, expectedMethod, expectedType) => + parse[Any](expression, ctx).invalidValue.toList should matchPattern { + case UnknownMethodError(`expectedMethod`, `expectedType`) :: Nil => + } + } + } + + test("should compute correct result type based on parameter") { + val parsed = parse[Any]("#unknownString.value.castTo('java.lang.String')", ctx).validValue + parsed.returnType shouldBe Typed.typedClass[String] + parsed.expression.evaluateSync[Any](ctx) shouldBe a[java.lang.String] + } + + test("should return an error if the cast return type cannot be determined at parse time") { + parse[Any]("#unknownString.value.castTo('java.util.XYZ')", ctx).invalidValue.toList should matchPattern { + case GenericFunctionError("Casting to 'java.util.XYZ' is not allowed") :: Nil => + } + parse[Any]("#unknownString.value.castTo(#obj.id)", ctx).invalidValue.toList should matchPattern { + case ArgumentTypeError("castTo", _, _) :: Nil => + } + } + + test("should throw exception if cast fails") { + val caught = intercept[SpelExpressionEvaluationException] { + parse[Any]("#unknownString.value.castTo('java.lang.Integer')", ctx).validExpression.evaluateSync[Any](ctx) + } + caught.getCause shouldBe a[ClassCastException] + caught.getMessage should include("Cannot cast: class java.lang.String to: java.lang.Integer") + } + + test( + "should allow invoke discovered methods for unknown objects - not matter how methodExecutionForUnknownAllowed is set" + ) { + def typedArray(elementTypingResult: TypingResult): TypingResult = + Typed.genericTypeClass(classOf[Array[Object]], List(elementTypingResult)) + forAll( + Table( + ("expression", "expectedType", "expectedResult", "methodExecutionForUnknownAllowed"), + ("#arrayOfUnknown", typedArray(Unknown), Array("unknown"), false), + ( + "#arrayOfUnknown.![#this.castTo('java.lang.String')]", + typedArray(Typed.typedClass[String]), + Array("unknown"), + false + ), + ( + "#arrayOfUnknown.![#this.canCastTo('java.lang.String')]", + typedArray(Typed.typedClass[Boolean]), + Array(true), + false + ), + ("#arrayOfUnknown.![#this.toString()]", typedArray(Typed.typedClass[String]), Array("unknown"), false), + ( + "#arrayOfUnknown.![#this.castTo('java.lang.String')]", + typedArray(Typed.typedClass[String]), + Array("unknown"), + true + ), + ( + "#arrayOfUnknown.![#this.canCastTo('java.lang.String')]", + typedArray(Typed.typedClass[Boolean]), + Array(true), + true + ), + ("#arrayOfUnknown.![#this.toString()]", typedArray(Typed.typedClass[String]), Array("unknown"), true), + ) + ) { (expression, expectedType, expectedResult, methodExecutionForUnknownAllowed) => + val parsed = parse[Any]( + expr = expression, + context = ctx, + methodExecutionForUnknownAllowed = methodExecutionForUnknownAllowed + ).validValue + parsed.returnType shouldBe expectedType + parsed.expression.evaluateSync[Any](ctx) shouldBe expectedResult + } + } + + test( + "should allow invoke undiscovered methods for unknown objects when flag methodExecutionForUnknownAllowed is set to true" + ) { + parse[Any]( + expr = "#arrayOfUnknown.![#this.indexOf('n')]", + context = ctx, + methodExecutionForUnknownAllowed = true + ).validExpression.evaluateSync[Any](ctx) shouldBe Array(1) + } + + test( + "should not allow invoke undiscovered methods for unknown objects when flag methodExecutionForUnknownAllowed is set to false" + ) { + parse[Any]( + expr = "#arrayOfUnknown.![#this.indexOf('n')]", + context = ctx, + methodExecutionForUnknownAllowed = false + ).invalidValue.toList should matchPattern { case IllegalInvocationError(Unknown) :: Nil => + } + } + + test("should not allow cast to disallowed classes") { + parse[Any]( + "#hashMap.value.castTo('java.util.HashMap').remove('testKey')", + ctx.withVariable("hashMap", ContainerOfUnknown(new java.util.HashMap[String, Int](Map("testKey" -> 2).asJava))) + ).invalidValue.toList should matchPattern { + case GenericFunctionError("Casting to 'java.util.HashMap' is not allowed") :: IllegalInvocationError( + Unknown + ) :: Nil => + } + } + + test("should return null if castToOrNull fails") { + parse[Any]( + expr = "#unknownString.value.castToOrNull('java.lang.Integer')", + context = ctx, + methodExecutionForUnknownAllowed = true + ).validExpression.evaluateSync[Any](ctx) == null shouldBe true + } + + test("should castToOrNull succeed") { + parse[Any]( + expr = "#unknownString.value.castToOrNull('java.lang.String')", + context = ctx, + methodExecutionForUnknownAllowed = true + ).validExpression.evaluateSync[Any](ctx) shouldBe "unknown" + } + } case class SampleObject(list: java.util.List[SampleValue]) diff --git a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala index f90254a6d1b..bbae2477f81 100644 --- a/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala +++ b/scenario-compiler/src/test/scala/pl/touk/nussknacker/engine/spel/TyperSpec.scala @@ -172,7 +172,7 @@ class TyperSpec extends AnyFunSuite with Matchers with ValidatedValuesDetailedMe dictTyper = new KeysDictTyper(new SimpleDictRegistry(Map.empty)), strictMethodsChecking = false, staticMethodInvocationsChecking = false, - classDefinitionSet = ClassDefinitionTestUtils.createDefinitionForDefaultAdditionalClasses, + classDefinitionSet = ClassDefinitionTestUtils.createDefinitionWithDefaultsAndExtensions, evaluationContextPreparer = null, methodExecutionForUnknownAllowed = false, dynamicPropertyAccessAllowed = dynamicPropertyAccessAllowed