diff --git a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/typing.scala b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/typing.scala index 3b3dd862693..ca5cbde9597 100644 --- a/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/typing.scala +++ b/components-api/src/main/scala/pl/touk/nussknacker/engine/api/typed/typing.scala @@ -83,7 +83,7 @@ object typing { fields.map { case (fieldName, fieldType) => fieldName -> fieldType.withoutValue }, - runtimeObjType, + runtimeObjType.withoutValue, additionalInfo ) @@ -206,7 +206,7 @@ object typing { case class TypedClass private[typing] (klass: Class[_], params: List[TypingResult]) extends SingleTypingResult { override val valueOpt: None.type = None - override def withoutValue: TypedClass = this + override def withoutValue: TypedClass = TypedClass(klass, params.map(_.withoutValue)) override def display: String = { val className = if (klass.isArray) "List" else ReflectUtils.simpleNameWithoutSuffix(runtimeObjType.klass) @@ -353,8 +353,9 @@ object typing { supertypeOfElementTypes(javaList.asScala.toList).withoutValue, javaList ) + case set: java.util.Set[_] => + genericTypeClass(classOf[java.util.Set[_]], List(supertypeOfElementTypes(set.asScala.toList))) case typeFromInstance: TypedFromInstance => typeFromInstance.typingResult - // TODO: handle more types, for example Set case other => Typed(other.getClass) match { case typedClass: TypedClass => diff --git a/docs/Changelog.md b/docs/Changelog.md index 6d866ce0c72..470d4d775e6 100644 --- a/docs/Changelog.md +++ b/docs/Changelog.md @@ -76,6 +76,9 @@ * [#6958](https://github.com/TouK/nussknacker/pull/6958) Add message size limit in the "Kafka" exceptionHandler * [#6988](https://github.com/TouK/nussknacker/pull/6988) Remove unused API classes: `MultiMap`, `TimestampedEvictableStateFunction` * [#7000](https://github.com/TouK/nussknacker/pull/7000) Show all possible options for dictionary editor on open. +* [#7042](https://github.com/TouK/nussknacker/pull/7042) SpeL: added extension methods: + * toList/toListOrNull - a collection or unknown collection can be converted to a list. + * toMap/toMapOrNull - the list of key-value pairs or unknown map can be converted to a map. ## 1.17 diff --git a/docs/scenarios_authoring/Spel.md b/docs/scenarios_authoring/Spel.md index 350655b5059..8ebd2021052 100644 --- a/docs/scenarios_authoring/Spel.md +++ b/docs/scenarios_authoring/Spel.md @@ -199,12 +199,13 @@ person2 = name: "John"; age: 24 listOfPersons = {person1, person2} ``` -| Expression | Result | Type | -| ------------ | -------- | -------- | -| `{1,2,3,4}.![#this * 2]` | {2, 4, 6, 8} | List[Integer] | -| `#listOfPersons.![#this.name]` | {'Alex', 'John'} | List[String] | -| `#listOfPersons.![#this.age]` | {42, 24} | List[Integer] | -| `#listOfPersons.![7]` | {7, 7} | List[Integer] | +| Expression | Result | Type | +|-----------------------------------------------------------------|----------------------|----------------------| +| `{1,2,3,4}.![#this * 2]` | {2, 4, 6, 8} | List[Integer] | +| `#listOfPersons.![#this.name]` | {'Alex', 'John'} | List[String] | +| `#listOfPersons.![#this.age]` | {42, 24} | List[Integer] | +| `#listOfPersons.![7]` | {7, 7} | List[Integer] | +| `#listOfPersons.![{key: #this.name, value: #this.age}].toMap()` | {Alex: 42, John: 24} | Map[String, Integer] | For other operations on lists, please see the `#COLLECTION` [helper](#built-in-helpers). 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 9d98ae655e8..545bfa0d337 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 @@ -109,6 +109,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -3106,6 +3154,54 @@ ] } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -11573,6 +11669,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -11720,6 +11864,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -11956,6 +12148,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", diff --git a/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json b/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json index c12d863707c..16ea5b40103 100644 --- a/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json +++ b/engine/flink/tests/src/test/resources/extractedTypes/defaultModel.json @@ -109,6 +109,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -2693,6 +2741,54 @@ ] } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -11950,6 +12046,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -12097,6 +12241,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", @@ -12348,6 +12540,54 @@ } } ], + "toList": [ + { + "description": "Convert to a list or throw exception in case of failure", + "name": "toList", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toListOrNull": [ + { + "description": "Convert to a list or null in case of failure", + "name": "toListOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMap": [ + { + "description": "Convert to a map or throw exception in case of failure", + "name": "toMap", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], + "toMapOrNull": [ + { + "description": "Convert to a map or null in case of failure", + "name": "toMapOrNull", + "signatures": [ + { + "noVarArgs": [], + "result": {"type": "Unknown"} + } + ] + } + ], "toString": [ { "name": "toString", diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/CollectionConversionExt.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/CollectionConversionExt.scala new file mode 100644 index 00000000000..2b010f9a2f8 --- /dev/null +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/extension/CollectionConversionExt.scala @@ -0,0 +1,133 @@ +package pl.touk.nussknacker.engine.extension + +import cats.data.ValidatedNel +import cats.implicits.catsSyntaxValidatedId +import pl.touk.nussknacker.engine.api.generics.{GenericFunctionTypingError, MethodTypeInfo} +import pl.touk.nussknacker.engine.api.typed.typing.{Typed, TypedClass, TypedObjectTypingResult, TypingResult, Unknown} +import pl.touk.nussknacker.engine.definition.clazz.{ClassDefinitionSet, FunctionalMethodDefinition, MethodDefinition} +import pl.touk.nussknacker.engine.extension.CollectionConversionExt.{key, value} +import pl.touk.nussknacker.engine.util.classes.Extensions.ClassExtensions + +import java.util.{ArrayList => JArrayList, Collection => JCollection, HashMap => JHashMap, List => JList, Map => JMap} + +class CollectionConversionExt(target: Any) { + + def toList[T](): JList[T] = convertToList[T] match { + case Right(value) => value + case Left(ex) => throw ex + } + + def toMap[K, V](): JMap[K, V] = convertToMap[K, V] match { + case Right(value) => value + case Left(ex) => throw ex + } + + def toListOrNull[T](): JList[T] = convertToList[T] match { + case Right(value) => value + case Left(_) => null + } + + def toMapOrNull[K, V](): JMap[K, V] = convertToMap[K, V] match { + case Right(value) => value + case Left(_) => null + } + + private def convertToList[T]: Either[Throwable, JList[T]] = { + target match { + case l: JList[T @unchecked] => Right(l) + case c: JCollection[T @unchecked] => Right(new JArrayList[T](c)) + case x => Left(new IllegalArgumentException(s"Cannot convert $x to a List")) + } + } + + private def convertToMap[K, V]: Either[Throwable, JMap[K, V]] = { + target match { + case c: JCollection[JMap[String, Any] @unchecked] => + val map = new JHashMap[K, V]() + c.forEach(e => map.put(e.get(key).asInstanceOf[K], e.get(value).asInstanceOf[V])) + Right(map) + case m: JMap[K, V] @unchecked => Right(m) + case x => Left(new IllegalArgumentException(s"Cannot convert $x to a Map")) + } + } + +} + +object CollectionConversionExt extends ExtensionMethodsHandler { + + private val collectionClass = classOf[JCollection[_]] + private val unknownClass = classOf[Object] + + private val toMapDefinition = FunctionalMethodDefinition( + typeFunction = (invocationTarget, _) => toMapTypeFunction(invocationTarget), + signature = MethodTypeInfo( + noVarArgs = Nil, + varArg = None, + result = Unknown + ), + name = "toMap", + description = Option("Convert to a map or throw exception in case of failure") + ) + + private val toListDefinition = FunctionalMethodDefinition( + typeFunction = (invocationTarget, _) => toListTypeFunction(invocationTarget), + signature = MethodTypeInfo( + noVarArgs = Nil, + varArg = None, + result = Unknown + ), + name = "toList", + description = Option("Convert to a list or throw exception in case of failure") + ) + + private val definitions = List( + toMapDefinition, + toMapDefinition.copy(name = "toMapOrNull", description = Some("Convert to a map or null in case of failure")), + toListDefinition, + toListDefinition.copy(name = "toListOrNull", description = Some("Convert to a list or null in case of failure")), + ).groupBy(_.name) + + private val key = "key" + private val value = "value" + + override type ExtensionMethodInvocationTarget = CollectionConversionExt + override val invocationTargetClass: Class[CollectionConversionExt] = classOf[CollectionConversionExt] + + override def createConverter( + classLoader: ClassLoader, + set: ClassDefinitionSet + ): ToExtensionMethodInvocationTargetConverter[CollectionConversionExt] = + (target: Any) => new CollectionConversionExt(target) + + override def extractDefinitions(clazz: Class[_], set: ClassDefinitionSet): Map[String, List[MethodDefinition]] = + if (clazz.isAOrChildOf(collectionClass) || clazz == unknownClass || clazz.isArray) definitions + else Map.empty + + // Conversion extension should be available for every class in a runtime + override def applies(clazz: Class[_]): Boolean = + true + + private def toMapTypeFunction( + invocationTarget: TypingResult + ): ValidatedNel[GenericFunctionTypingError, TypingResult] = + invocationTarget.withoutValue match { + case TypedClass(_, List(TypedObjectTypingResult(fields, _, _))) + if fields.contains(key) && fields.contains(value) => + val params = List(fields.get(key), fields.get(value)).flatten + Typed.genericTypeClass[JMap[_, _]](params).validNel + case TypedClass(_, List(TypedObjectTypingResult(_, _, _))) => + GenericFunctionTypingError.OtherError("List element must contain 'key' and 'value' fields").invalidNel + case Unknown => Typed.genericTypeClass[JMap[_, _]](List(Unknown, Unknown)).validNel + case _ => GenericFunctionTypingError.ArgumentTypeError.invalidNel + } + + private def toListTypeFunction( + invocationTarget: TypingResult + ): ValidatedNel[GenericFunctionTypingError, TypingResult] = + invocationTarget.withoutValue match { + case TypedClass(_, params) => Typed.genericTypeClass[JList[_]](params).validNel + case Unknown => Typed.genericTypeClass[JList[_]](List(Unknown)).validNel + case _ => GenericFunctionTypingError.ArgumentTypeError.invalidNel + } + +} 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 index 750ccfbcc28..8ccecf80e97 100644 --- 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 @@ -41,6 +41,7 @@ object ExtensionMethods { val extensionMethodsHandlers: List[ExtensionMethodsHandler] = List( Cast, ArrayExt, + CollectionConversionExt, ) def enrichWithExtensionMethods(set: ClassDefinitionSet): ClassDefinitionSet = { diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala index acdc6ccdb08..00e90ac4e11 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/spel/Typer.scala @@ -434,7 +434,7 @@ private[spel] class Typer( case e: PropertyOrFieldReference => current.stackHead .map(extractProperty(e, _)) - .map(fallbackToCheckMethodsIfPropertyNotExists) + .map(mapErrorAndCheckMethodsIfPropertyNotExists) .getOrElse(invalid(NonReferenceError(e.toStringAST))) .map(toNodeResult) // TODO: what should be here? @@ -735,10 +735,13 @@ private[spel] class Typer( } } - private def fallbackToCheckMethodsIfPropertyNotExists(typing: TypingR[TypingResult]): TypingR[TypingResult] = + private def mapErrorAndCheckMethodsIfPropertyNotExists(typing: TypingR[TypingResult]): TypingR[TypingResult] = typing.mapWritten(_.map { case e: NoPropertyError => methodReferenceTyper.typeMethodReference(typer.MethodReference(e.typ, false, e.property, Nil)) match { + // Right is not mapped because of: pl.touk.nussknacker.engine.spel.Typer.propertyTypeBasedOnMethod and + // pl.touk.nussknacker.engine.spel.internal.propertyAccessors.NoParamMethodPropertyAccessor + // Methods without parameters can be treated as properties. case Left(me: ArgumentTypeError) => me case _ => e } diff --git a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/util/classes/Extensions.scala b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/util/classes/Extensions.scala index 94706b5b950..440fbe6a539 100644 --- a/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/util/classes/Extensions.scala +++ b/scenario-compiler/src/main/scala/pl/touk/nussknacker/engine/util/classes/Extensions.scala @@ -12,6 +12,9 @@ object Extensions { clazz != targetClazz && targetClazz.isAssignableFrom(clazz) + def isAOrChildOf(targetClazz: Class[_]): Boolean = + targetClazz.isAssignableFrom(clazz) + def isNotFromNuUtilPackage(): Boolean = { val name = clazz.getName !(name.contains("nussknacker") && name.contains("util")) 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 index 6bd07bfcf2b..b0fcce2a2b3 100644 --- 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 @@ -21,17 +21,29 @@ class ExtensionMethodsSpec extends AnyFunSuite with Matchers { val unknownDefinition = ClassDefinition( Unknown, Map( - "toString" -> List(StaticMethodDefinition(MethodTypeInfo(Nil, None, Typed[String]), "toString", None)) + "toString" -> List(StaticMethodDefinition(MethodTypeInfo(Nil, None, Typed[String]), "toStriqng", None)) ), Map.empty ) val definitionsSet = ClassDefinitionSet(Set(stringDefinition, unknownDefinition)) - ExtensionMethods.enrichWithExtensionMethods( - definitionsSet - ).classDefinitionsMap.map(e => e._1.getName -> e._2.methods.keys) shouldBe Map( + 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"), + "java.lang.Object" -> Set( + "toString", + "canCastTo", + "castTo", + "castToOrNull", + "toMap", + "toMapOrNull", + "toList", + "toListOrNull" + ), ) } 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 eb99bd47d53..6d721b73187 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 @@ -57,7 +57,7 @@ import java.nio.charset.Charset import java.time.chrono.ChronoLocalDate import java.time.{LocalDate, LocalDateTime} import java.util -import java.util.{Collections, Currency, Locale, Optional, UUID} +import java.util.{Collections, Currency, List => JList, Locale, Map => JMap, Optional, UUID} import scala.annotation.varargs import scala.jdk.CollectionConverters._ import scala.language.implicitConversions @@ -102,7 +102,8 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD "intArray" -> Array(1, 2, 3), "nestedArray" -> Array(Array(1, 2), Array(3, 4)), "arrayOfUnknown" -> Array("unknown".asInstanceOf[Any]), - "unknownString" -> ContainerOfUnknown("unknown") + "unknownString" -> ContainerOfUnknown("unknown"), + "setVal" -> Set("a").asJava ) ) @@ -1508,6 +1509,112 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD ) shouldBe List("unknown", "unknown", true).asJava } + test("should convert a Set to a List") { + val parsed = parse[Any](expr = "#setVal.toList()", context = ctx).validValue + parsed.returnType.withoutValue shouldBe Typed.genericTypeClass[JList[_]](List(Typed.typedClass[String])) + parsed.expression.evaluateSync[Any](ctx) shouldBe List("a").asJava + } + + test("should convert a List to a Map") { + val mapStringStringType = + Typed.genericTypeClass[JMap[_, _]](List(Typed.typedClass[String], Typed.typedClass[String])) + val mapStringUnknownType = + Typed.genericTypeClass[JMap[_, _]](List(Typed.typedClass[String], Unknown)) + val stringMap = Map("foo" -> "bar", "baz" -> "qux").asJava + val nullableMap = { + val result = new util.HashMap[String, String]() + result.put("foo", "bar") + result.put("baz", null) + result.put(null, "qux") + result + } + val mapWithDifferentValueTypes = Map("foo" -> "bar", "baz" -> 1).asJava + + forAll( + Table( + ("expression", "ctx", "expectedType", "expectedResult"), + ( + "#mapVal.![{key: #this.key + '_k', value: #this.value + '_v'}].toMap()", + ctx.withVariable("mapVal", stringMap), + mapStringStringType, + Map("foo_k" -> "bar_v", "baz_k" -> "qux_v").asJava + ), + ( + "#mapVal.![{key: #this.key, value: #this.value}].toMap()", + ctx.withVariable("mapVal", mapWithDifferentValueTypes), + mapStringUnknownType, + mapWithDifferentValueTypes + ), + ( + "#mapVal.![{key: #this.key, value: #this.value}].toMap()", + ctx.withVariable("mapVal", nullableMap), + mapStringStringType, + nullableMap + ) + ) + ) { (expression, ctx, expectedType, expectedResult) => + val parsed = parse[Any](expr = expression, context = ctx).validValue + parsed.returnType.withoutValue shouldBe expectedType + parsed.expression.evaluateSync[Any](ctx) shouldBe expectedResult + } + } + + test("should return error msg if record in map project does not contain required fields") { + parse[Any]("#mapValue.![{invalid_key: #this.key}].toMap()", ctx).invalidValue.toList should matchPattern { + case GenericFunctionError("List element must contain 'key' and 'value' fields") :: Nil => + } + } + + test("should convert unknown to a appropriate type") { + val map = Map("a" -> "b").asJava + val list = List("a").asJava + val mapTyping = Typed.genericTypeClass[JMap[_, _]](List(Unknown, Unknown)) + val listTyping = Typed.genericTypeClass[JList[_]](List(Unknown)) + val mapContext = ctx.withVariable("unknownMap", ContainerOfUnknown(map)) + val listContext = ctx.withVariable("unknownList", ContainerOfUnknown(list)) + forAll( + Table( + ("expression", "ctx", "expectedType", "expectedResult"), + ("#unknownMap.value.toMap()", mapContext, mapTyping, map), + ("#unknownMap.value.toMapOrNull()", mapContext, mapTyping, map), + ("#unknownList.value.toList()", listContext, listTyping, list), + ("#unknownList.value.toListOrNull()", listContext, listTyping, list), + ) + ) { (expression, ctx, expectedType, expectedResult) => + val parsed = parse[Any](expr = expression, context = ctx).validValue + parsed.returnType.withoutValue shouldBe expectedType + parsed.expression.evaluateSync[Any](ctx) shouldBe expectedResult + } + } + + test("should throw exception if an unknown could not be converted to appropriate type") { + forAll( + Table( + ("method", "errorMsg"), + ("toMap()", "Cannot convert unknown to a Map"), + ("toList()", "Cannot convert unknown to a List"), + ) + ) { (method, errorMsg) => + val caught = intercept[SpelExpressionEvaluationException] { + evaluate[Any](s"#unknownString.value.$method") + } + caught.getCause shouldBe a[IllegalArgumentException] + caught.getCause.getMessage shouldBe errorMsg + } + } + + test("should return null if an unknown could not be converted to appropriate type") { + forAll( + Table( + ("method"), + ("toMapOrNull()"), + ("toListOrNull()"), + ) + ) { (method) => + evaluate[Any](s"#unknownString.value.$method") == null shouldBe true + } + } + } case class SampleObject(list: java.util.List[SampleValue])