Skip to content

Commit

Permalink
[NU-1836] Add from string conversions as SpeL extension methods
Browse files Browse the repository at this point in the history
  • Loading branch information
Łukasz Bigorajski committed Nov 4, 2024
1 parent 25bb577 commit 1079acd
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 27 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ import pl.touk.nussknacker.test.PatientScalaFutures
import pl.touk.nussknacker.ui.api.ExpressionSuggesterTestData._
import pl.touk.nussknacker.ui.suggester.ExpressionSuggester

import java.time.{Duration, LocalDateTime}
import java.nio.charset.Charset
import java.time.chrono.{ChronoLocalDate, ChronoLocalDateTime}
import java.time.{Duration, LocalDate, LocalDateTime, LocalTime, ZoneId, ZoneOffset}
import java.util.{Currency, Locale, UUID}
import scala.collection.immutable.ListMap
import scala.concurrent.ExecutionContext
import scala.jdk.CollectionConverters._
Expand Down Expand Up @@ -801,6 +804,16 @@ class ExpressionSuggesterSpec
suggestion("String", Typed[java.lang.String]),
suggestion("List", Typed.genericTypeClass[java.util.List[_]](List(Unknown))),
suggestion("Map", Typed.genericTypeClass[java.util.Map[_, _]](List(Unknown, Unknown))),
suggestion("Charset", Typed[Charset]),
suggestion("ChronoLocalDate", Typed[ChronoLocalDate]),
suggestion("ChronoLocalDateTime", Typed[ChronoLocalDateTime[_]]),
suggestion("Currency", Typed[Currency]),
suggestion("LocalDate", Typed[LocalDate]),
suggestion("LocalTime", Typed[LocalTime]),
suggestion("Locale", Typed[Locale]),
suggestion("UUID", Typed[UUID]),
suggestion("ZoneId", Typed[ZoneId]),
suggestion("ZoneOffset", Typed[ZoneOffset]),
)
}

Expand All @@ -815,6 +828,17 @@ class ExpressionSuggesterSpec
suggestion("Integer", Typed[java.lang.Integer]),
suggestion("Short", Typed[java.lang.Short]),
suggestion("Byte", Typed[java.lang.Byte]),
suggestion("Charset", Typed[Charset]),
suggestion("ChronoLocalDate", Typed[ChronoLocalDate]),
suggestion("ChronoLocalDateTime", Typed[ChronoLocalDateTime[_]]),
suggestion("Currency", Typed[Currency]),
suggestion("LocalDate", Typed[LocalDate]),
suggestion("LocalDateTime", Typed[LocalDateTime]),
suggestion("LocalTime", Typed[LocalTime]),
suggestion("Locale", Typed[Locale]),
suggestion("UUID", Typed[UUID]),
suggestion("ZoneId", Typed[ZoneId]),
suggestion("ZoneOffset", Typed[ZoneOffset]),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package pl.touk.nussknacker.engine.extension

import cats.data.ValidatedNel
import cats.implicits.catsSyntaxValidatedId
import com.typesafe.scalalogging.LazyLogging
import org.apache.commons.lang3.LocaleUtils
import org.springframework.util.StringUtils
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}
Expand All @@ -11,18 +14,24 @@ import pl.touk.nussknacker.engine.util.Implicits.RichScalaMap
import pl.touk.nussknacker.engine.util.classes.Extensions.{ClassExtensions, ClassesExtensions}

import java.lang.{Boolean => JBoolean}
import java.nio.charset.Charset
import java.time.chrono.{ChronoLocalDate, ChronoLocalDateTime}
import java.time.{LocalDate, LocalDateTime, LocalTime, ZoneId, ZoneOffset}
import java.util.{Currency, UUID}
import scala.util.Try

// todo: lbg - add casting methods to UTIL
class CastOrConversionExt(target: Any, classesBySimpleName: Map[String, Class[_]]) {
class CastOrConversionExt(target: Any, classesBySimpleName: Map[String, Class[_]]) extends LazyLogging {

def is(className: String): Boolean =
getClass(className).exists(clazz => clazz.isAssignableFrom(target.getClass)) ||
getConversion(className).exists(_.canConvert(target))

def to(className: String): Any =
orElse(tryCast(className), tryConvert(className))
.getOrElse(throw new IllegalStateException(s"Cannot cast or convert value: $target to: '$className'"))
orElse(tryCast(className), tryConvert(className)) match {
case Right(value) => value
case Left(ex) => throw new IllegalStateException(s"Cannot cast or convert value: $target to: '$className'", ex)
}

def toOrNull(className: String): Any =
orElse(tryCast(className), tryConvert(className))
Expand All @@ -37,8 +46,12 @@ class CastOrConversionExt(target: Any, classesBySimpleName: Map[String, Class[_]
classesBySimpleName.get(className.toLowerCase())

private def tryConvert(className: String): Either[Throwable, Any] =
getConversion(className)
.flatMap(_.convertEither(target))
getConversion(className).flatMap(_.convertEither(target)) match {
case r @ Right(_) => r
case l @ Left(ex) =>
logger.debug(s"Conversion from value: $target to '$className' failed", ex)
l
}

// scala 2.12 does not support either.orElse
private def orElse(e1: Either[Throwable, Any], e2: => Either[Throwable, Any]): Either[Throwable, Any] =
Expand Down Expand Up @@ -69,6 +82,23 @@ object CastOrConversionExt extends ExtensionMethodsHandler {
ToIntegerConversion,
ToFloatConversion,
ToBigIntegerConversion,
FromStringConversion(ZoneOffset.of),
FromStringConversion(ZoneId.of),
FromStringConversion((source: String) => {
val locale = StringUtils.parseLocale(source)
assert(LocaleUtils.isAvailableLocale(locale)) // without this check even "qwerty" is considered a Locale
locale
}),
FromStringConversion(Charset.forName),
FromStringConversion(Currency.getInstance),
FromStringConversion[UUID]((source: String) =>
if (StringUtils.hasLength(source)) UUID.fromString(source.trim) else null
),
FromStringConversion(LocalTime.parse),
FromStringConversion(LocalDate.parse),
FromStringConversion(LocalDateTime.parse),
FromStringConversion[ChronoLocalDate](LocalDate.parse),
FromStringConversion[ChronoLocalDateTime[_]](LocalDateTime.parse)
)

private val conversionsByType: Map[String, Conversion] = conversionsRegistry
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,14 @@ import java.lang.{
}
import java.math.{BigDecimal => JBigDecimal, BigInteger => JBigInteger}
import java.util.{Collection => JCollection}
import scala.reflect.{ClassTag, classTag}
import scala.util.{Success, Try}

trait Conversion {
private[extension] val numberClass = classOf[JNumber]
private[extension] val stringClass = classOf[String]
private[extension] val unknownClass = classOf[Object]

type ResultType >: Null <: AnyRef
val resultTypeClass: Class[ResultType]

Expand Down Expand Up @@ -121,18 +126,13 @@ object ToStringConversion extends Conversion {
}

trait ToCollectionConversion extends Conversion {
private val unknownClass = classOf[Object]
private val collectionClass = classOf[JCollection[_]]

override def appliesToConversion(clazz: Class[_]): Boolean =
clazz != resultTypeClass && (clazz.isAOrChildOf(collectionClass) || clazz == unknownClass || clazz.isArray)
}

trait ToNumericConversion extends Conversion {
private val numberClass = classOf[JNumber]
private val stringClass = classOf[String]
private val unknownClass = classOf[Object]

override def appliesToConversion(clazz: Class[_]): Boolean =
clazz != resultTypeClass && (clazz.isAOrChildOf(numberClass) || clazz == stringClass || clazz == unknownClass)
}
Expand Down Expand Up @@ -202,3 +202,13 @@ object ToBigIntegerConversion extends ToNumericConversion {
}

}

final case class FromStringConversion[T >: Null <: AnyRef: ClassTag](fromStringConversion: String => T)
extends Conversion {

override type ResultType = T
override val resultTypeClass: Class[T] = classTag[T].runtimeClass.asInstanceOf[Class[T]]
override def convertEither(value: Any): Either[Throwable, T] =
Try(fromStringConversion(value.asInstanceOf[String])).toEither
override def appliesToConversion(clazz: Class[_]): Boolean = clazz == stringClass || clazz == unknownClass
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import org.scalatest.funsuite.AnyFunSuite
import org.scalatest.matchers.should.Matchers
import org.scalatest.prop.TableDrivenPropertyChecks._
import org.scalatestplus.scalacheck.ScalaCheckDrivenPropertyChecks
import org.springframework.util.NumberUtils
import org.springframework.util.{NumberUtils, StringUtils}
import pl.touk.nussknacker.engine.api.context.ValidationContext
import pl.touk.nussknacker.engine.api.dict.embedded.EmbeddedDictDefinition
import pl.touk.nussknacker.engine.api.dict.{DictDefinition, DictInstance}
Expand Down Expand Up @@ -63,9 +63,9 @@ import java.lang.{
Short => JShort
}
import java.math.{BigDecimal => JBigDecimal, BigInteger => JBigInteger}
import java.nio.charset.Charset
import java.time.chrono.ChronoLocalDate
import java.time.{LocalDate, LocalDateTime}
import java.nio.charset.{Charset, StandardCharsets}
import java.time.chrono.{ChronoLocalDate, ChronoLocalDateTime}
import java.time.{LocalDate, LocalDateTime, LocalTime, ZoneId, ZoneOffset}
import java.util
import java.util.{Collections, Currency, List => JList, Locale, Map => JMap, Optional, UUID}
import scala.annotation.varargs
Expand Down Expand Up @@ -1654,6 +1654,15 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD
val emptyList = List().asJava
val emptyTuplesList = List(Map().asJava).asJava
val convertedDoubleToBigDecimal = NumberUtils.convertNumberToTargetClass(1.1, classOf[JBigDecimal])
val zoneOffset = ZoneOffset.of("+01:00")
val zoneId = ZoneId.of("Europe/Warsaw")
val locale = StringUtils.parseLocale("pl_PL")
val charset = StandardCharsets.UTF_8
val currency = Currency.getInstance("USD")
val uuid = UUID.fromString("7447e433-83dd-47d0-a115-769a03236bca")
val localTime = LocalTime.parse("10:15:30")
val localDate = LocalDate.parse("2024-11-04")
val localDateTime = LocalDateTime.parse("2024-11-04T10:15:30")
val customCtx = ctx
.withVariable("unknownInteger", ContainerOfUnknown(1))
.withVariable("unknownBoolean", ContainerOfUnknown(false))
Expand All @@ -1669,18 +1678,29 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD
.withVariable("unknownListOfTuples", ContainerOfUnknown(listOfTuples))
.withVariable("unknownEmptyList", ContainerOfUnknown(emptyList))
.withVariable("unknownEmptyTuplesList", ContainerOfUnknown(emptyTuplesList))
val byteTyping = Typed.typedClass[JByte]
val shortTyping = Typed.typedClass[JShort]
val integerTyping = Typed.typedClass[JInteger]
val longTyping = Typed.typedClass[JLong]
val floatTyping = Typed.typedClass[JFloat]
val doubleTyping = Typed.typedClass[JDouble]
val bigDecimalTyping = Typed.typedClass[JBigDecimal]
val bigIntegerTyping = Typed.typedClass[JBigInteger]
val booleanTyping = Typed.typedClass[JBoolean]
val stringTyping = Typed.typedClass[String]
val mapTyping = Typed.genericTypeClass[JMap[_, _]](List(Unknown, Unknown))
val listTyping = Typed.genericTypeClass[JList[_]](List(Unknown))
val byteTyping = Typed.typedClass[JByte]
val shortTyping = Typed.typedClass[JShort]
val integerTyping = Typed.typedClass[JInteger]
val longTyping = Typed.typedClass[JLong]
val floatTyping = Typed.typedClass[JFloat]
val doubleTyping = Typed.typedClass[JDouble]
val bigDecimalTyping = Typed.typedClass[JBigDecimal]
val bigIntegerTyping = Typed.typedClass[JBigInteger]
val booleanTyping = Typed.typedClass[JBoolean]
val stringTyping = Typed.typedClass[String]
val mapTyping = Typed.genericTypeClass[JMap[_, _]](List(Unknown, Unknown))
val listTyping = Typed.genericTypeClass[JList[_]](List(Unknown))
val zoneOffsetTyping = Typed.typedClass[ZoneOffset]
val zoneIdTyping = Typed.typedClass[ZoneId]
val localeTyping = Typed.typedClass[Locale]
val charsetTyping = Typed.typedClass[Charset]
val currencyTyping = Typed.typedClass[Currency]
val uuidTyping = Typed.typedClass[UUID]
val localTimeTyping = Typed.typedClass[LocalTime]
val localDateTyping = Typed.typedClass[LocalDate]
val localDateTimeTyping = Typed.typedClass[LocalDateTime]
val chronoLocalDateTyping = Typed.typedClass[ChronoLocalDate]
val chronoLocalDateTimeTyping = Typed.typedClass[ChronoLocalDateTime[_]]
forAll(
Table(
("expression", "expectedType", "expectedResult"),
Expand Down Expand Up @@ -1747,6 +1767,17 @@ class SpelExpressionSpec extends AnyFunSuite with Matchers with ValidatedValuesD
("'1'.to('BigInteger')", bigIntegerTyping, JBigInteger.ONE),
("1.toOrNull('BigInteger')", bigIntegerTyping, JBigInteger.ONE),
("'1'.toOrNull('BigInteger')", bigIntegerTyping, JBigInteger.ONE),
("'+01:00'.to('ZoneOffset')", zoneOffsetTyping, zoneOffset),
("'Europe/Warsaw'.to('ZoneId')", zoneIdTyping, zoneId),
("'pl_PL'.to('Locale')", localeTyping, locale),
("'UTF-8'.to('Charset')", charsetTyping, charset),
("'USD'.to('Currency')", currencyTyping, currency),
("'7447e433-83dd-47d0-a115-769a03236bca'.to('UUID')", uuidTyping, uuid),
("'10:15:30'.to('LocalTime')", localTimeTyping, localTime),
("'2024-11-04'.to('LocalDate')", localDateTyping, localDate),
("'2024-11-04T10:15:30'.to('LocalDateTime')", localDateTimeTyping, localDateTime),
("'2024-11-04'.to('ChronoLocalDate')", chronoLocalDateTyping, localDate),
("'2024-11-04T10:15:30'.to('ChronoLocalDateTime')", chronoLocalDateTimeTyping, localDateTime),
("#unknownString.value.to('String')", stringTyping, "unknown"),
("#unknownMap.value.to('Map')", mapTyping, map),
("#unknownMap.value.toOrNull('Map')", mapTyping, map),
Expand Down

0 comments on commit 1079acd

Please sign in to comment.