Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix subtyping in dict service #7115

Open
wants to merge 13 commits into
base: staging
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package pl.touk.nussknacker.engine.api.typed

import cats.data.Validated.condNel
import cats.data.{NonEmptyList, Validated, ValidatedNel}
import cats.implicits.catsSyntaxValidatedId
import org.apache.commons.lang3.ClassUtils
import pl.touk.nussknacker.engine.api.typed.supertype.NumberTypesPromotionStrategy.AllNumbers
import pl.touk.nussknacker.engine.api.typed.typing.{
SingleTypingResult,
TypedClass,
TypedNull,
TypedObjectWithValue,
TypedUnion,
TypingResult,
Unknown
}

trait ConversionDeterminer {

def singleCanBeConvertedTo(
result: typing.SingleTypingResult,
result1: typing.SingleTypingResult
): ValidatedNel[String, Unit]

/**
* This method checks if `givenType` can by subclass of `superclassCandidate`
* It will return true if `givenType` is equals to `superclassCandidate` or `givenType` "extends" `superclassCandidate`
*/
def canBeConvertedTo(givenType: TypingResult, superclassCandidate: TypingResult): ValidatedNel[String, Unit] = {
(givenType, superclassCandidate) match {
case (_, Unknown) => ().validNel
case (Unknown, _) => ().validNel
case (TypedNull, other) => canNullBeConvertedTo(other)
case (_, TypedNull) => s"No type can be subclass of ${TypedNull.display}".invalidNel
case (given: SingleTypingResult, superclass: TypedUnion) =>
canBeConvertedTo(NonEmptyList.one(given), superclass.possibleTypes)
case (given: TypedUnion, superclass: SingleTypingResult) =>
canBeConvertedTo(given.possibleTypes, NonEmptyList.one(superclass))
case (given: SingleTypingResult, superclass: SingleTypingResult) => singleCanBeConvertedTo(given, superclass)
case (given: TypedUnion, superclass: TypedUnion) =>
canBeConvertedTo(given.possibleTypes, superclass.possibleTypes)
}
}

private def canNullBeConvertedTo(result: TypingResult): ValidatedNel[String, Unit] = result match {
// TODO: Null should not be subclass of typed map that has all values assigned.
case TypedObjectWithValue(_, _) => s"${TypedNull.display} cannot be subclass of type with value".invalidNel
case _ => ().validNel
}

def canBeConvertedTo(
givenTypes: NonEmptyList[SingleTypingResult],
superclassCandidates: NonEmptyList[SingleTypingResult]
): ValidatedNel[String, Unit] = {
// Would be more safety to do givenTypes.forAll(... superclassCandidates.exists ...) - we wil protect against
// e.g. (String | Int).canBeSubclassOf(String) which can fail in runtime for Int, but on the other hand we can't block user's intended action.
// He/she could be sure that in this type, only String will appear. He/she also can't easily downcast (String | Int) to String so leaving here
// "double exists" looks like a good tradeoff
condNel(
givenTypes.exists(given => superclassCandidates.exists(singleCanBeConvertedTo(given, _).isValid)),
(),
s"""None of the following types:
|${givenTypes.map(" - " + _.display).toList.mkString(",\n")}
|can be a subclass of any of:
|${superclassCandidates.map(" - " + _.display).toList.mkString(",\n")}""".stripMargin
)
}

def isStrictSubclass(givenClass: TypedClass, givenSuperclass: TypedClass): Validated[NonEmptyList[String], Unit] = {
condNel(
givenClass == givenSuperclass,
(),
f"${givenClass.display} and ${givenSuperclass.display} are not the same"
) orElse
condNel(
isAssignable(givenClass.klass, givenSuperclass.klass),
(),
s"${givenClass.klass} is not assignable from ${givenSuperclass.klass}"
)
}

def isAssignable(from: Class[_], to: Class[_]): Boolean
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
package pl.touk.nussknacker.engine.api.typed

import cats.data.Validated._
import cats.data.{NonEmptyList, ValidatedNel}
import cats.data.ValidatedNel
import cats.implicits.{catsSyntaxValidatedId, _}
import org.apache.commons.lang3.ClassUtils
import pl.touk.nussknacker.engine.api.typed.typing._
Expand All @@ -14,42 +14,17 @@ import pl.touk.nussknacker.engine.api.typed.typing._
* conversion for types not in the same jvm class hierarchy like boxed Integer to boxed Long and so on".
* WARNING: Evaluation of SpEL expressions fit into this spirit, for other language evaluation engines you need to provide such a compatibility.
*/
trait CanBeSubclassDeterminer {
object ImplicitConversionDeterminer extends ConversionDeterminer {

private val javaMapClass = classOf[java.util.Map[_, _]]
private val javaListClass = classOf[java.util.List[_]]
private val arrayOfAnyRefClass = classOf[Array[AnyRef]]

/**
* This method checks if `givenType` can by subclass of `superclassCandidate`
* It will return true if `givenType` is equals to `superclassCandidate` or `givenType` "extends" `superclassCandidate`
*/
def canBeSubclassOf(givenType: TypingResult, superclassCandidate: TypingResult): ValidatedNel[String, Unit] = {
(givenType, superclassCandidate) match {
case (_, Unknown) => ().validNel
case (Unknown, _) => ().validNel
case (TypedNull, other) => canNullBeSubclassOf(other)
case (_, TypedNull) => s"No type can be subclass of ${TypedNull.display}".invalidNel
case (given: SingleTypingResult, superclass: TypedUnion) =>
canBeSubclassOf(NonEmptyList.one(given), superclass.possibleTypes)
case (given: TypedUnion, superclass: SingleTypingResult) =>
canBeSubclassOf(given.possibleTypes, NonEmptyList.one(superclass))
case (given: SingleTypingResult, superclass: SingleTypingResult) => singleCanBeSubclassOf(given, superclass)
case (given: TypedUnion, superclass: TypedUnion) => canBeSubclassOf(given.possibleTypes, superclass.possibleTypes)
}
}

private def canNullBeSubclassOf(result: TypingResult): ValidatedNel[String, Unit] = result match {
// TODO: Null should not be subclass of typed map that has all values assigned.
case TypedObjectWithValue(_, _) => s"${TypedNull.display} cannot be subclass of type with value".invalidNel
case _ => ().validNel
}

protected def singleCanBeSubclassOf(
def singleCanBeConvertedTo(
givenType: SingleTypingResult,
superclassCandidate: SingleTypingResult
): ValidatedNel[String, Unit] = {
val objTypeRestriction = classCanBeSubclassOf(givenType, superclassCandidate.runtimeObjType)
val objTypeRestriction = classCanBeConvertedTo(givenType, superclassCandidate)
val typedObjectRestrictions = (_: Unit) =>
superclassCandidate match {
case superclass: TypedObjectTypingResult =>
Expand All @@ -65,7 +40,7 @@ trait CanBeSubclassDeterminer {
s"Field '$name' is lacking".invalidNel
case Some(givenFieldType) =>
condNel(
canBeSubclassOf(givenFieldType, typ).isValid,
canBeConvertedTo(givenFieldType, typ).isValid,
(),
s"Field '$name' is of the wrong type. Expected: ${givenFieldType.display}, actual: ${typ.display}"
)
Expand Down Expand Up @@ -123,29 +98,32 @@ trait CanBeSubclassDeterminer {
(typedObjectRestrictions combine dictRestriction combine taggedValueRestriction combine dataValueRestriction)
}

protected def classCanBeSubclassOf(
private def classCanBeConvertedTo(
givenType: SingleTypingResult,
superclassCandidate: TypedClass
superclassCandidate: SingleTypingResult
): ValidatedNel[String, Unit] = {
val givenClass = givenType.runtimeObjType
val givenClass = givenType.runtimeObjType
val givenSuperclass = superclassCandidate.runtimeObjType

val equalClassesOrCanAssign =
condNel(
givenClass == superclassCandidate,
(),
f"${givenClass.display} and ${superclassCandidate.display} are not the same"
) orElse
isAssignable(givenClass.klass, superclassCandidate.klass)
val equalClassesOrCanAssign = isStrictSubclass(givenClass, givenSuperclass)
val canBeSubclass = equalClassesOrCanAssign andThen (_ => typeParametersMatches(givenClass, givenSuperclass))
canBeSubclass orElse canBeConvertedTo(givenType, givenSuperclass)
}

val canBeSubclass = equalClassesOrCanAssign andThen (_ => typeParametersMatches(givenClass, superclassCandidate))
canBeSubclass orElse canBeConvertedTo(givenType, superclassCandidate)
// TODO: Conversions should be checked during typing, not during generic usage of TypingResult.canBeSubclassOf(...)
def canBeConvertedTo(
givenType: SingleTypingResult,
superclassCandidate: TypedClass
): ValidatedNel[String, Unit] = {
val errMsgPrefix = s"${givenType.runtimeObjType.display} cannot be converted to ${superclassCandidate.display}"
condNel(TypeConversionHandler.canBeConvertedTo(givenType, superclassCandidate), (), errMsgPrefix)
}

private def typeParametersMatches(givenClass: TypedClass, superclassCandidate: TypedClass) = {
def canBeSubOrSuperclass(givenClassParam: TypingResult, superclassParam: TypingResult) =
condNel(
canBeSubclassOf(givenClassParam, superclassParam).isValid ||
canBeSubclassOf(superclassParam, givenClassParam).isValid,
canBeConvertedTo(givenClassParam, superclassParam).isValid ||
canBeConvertedTo(superclassParam, givenClassParam).isValid,
(),
f"None of ${givenClassParam.display} and ${superclassParam.display} is a subclass of another"
)
Expand All @@ -154,18 +132,18 @@ trait CanBeSubclassDeterminer {
case (TypedClass(_, givenElementParam :: Nil), TypedClass(superclass, superclassParam :: Nil))
// Array are invariant but we have built-in conversion between array types - this check should be moved outside this class when we move away canBeConvertedTo as well
if javaListClass.isAssignableFrom(superclass) || arrayOfAnyRefClass.isAssignableFrom(superclass) =>
canBeSubclassOf(givenElementParam, superclassParam)
canBeConvertedTo(givenElementParam, superclassParam)
case (
TypedClass(_, givenKeyParam :: givenValueParam :: Nil),
TypedClass(superclass, superclassKeyParam :: superclassValueParam :: Nil)
) if javaMapClass.isAssignableFrom(superclass) =>
// Map's key generic param is invariant. We can't just check givenKeyParam == superclassKeyParam because of Unknown type which is a kind of wildcard
condNel(
canBeSubclassOf(givenKeyParam, superclassKeyParam).isValid &&
canBeSubclassOf(superclassKeyParam, givenKeyParam).isValid,
canBeConvertedTo(givenKeyParam, superclassKeyParam).isValid &&
canBeConvertedTo(superclassKeyParam, givenKeyParam).isValid,
(),
s"Key types of Maps ${givenKeyParam.display} and ${superclassKeyParam.display} are not equals"
) andThen (_ => canBeSubclassOf(givenValueParam, superclassValueParam))
) andThen (_ => canBeConvertedTo(givenValueParam, superclassValueParam))
case _ =>
// for unknown types we are lax - the generic type may be co- contra- or in-variant - and we don't want to
// return validation errors in this case. It's better to accept to much than too little
Expand All @@ -179,36 +157,8 @@ trait CanBeSubclassDeterminer {
}
}

private def canBeSubclassOf(
givenTypes: NonEmptyList[SingleTypingResult],
superclassCandidates: NonEmptyList[SingleTypingResult]
): ValidatedNel[String, Unit] = {
// Would be more safety to do givenTypes.forAll(... superclassCandidates.exists ...) - we wil protect against
// e.g. (String | Int).canBeSubclassOf(String) which can fail in runtime for Int, but on the other hand we can't block user's intended action.
// He/she could be sure that in this type, only String will appear. He/she also can't easily downcast (String | Int) to String so leaving here
// "double exists" looks like a good tradeoff
condNel(
givenTypes.exists(given => superclassCandidates.exists(singleCanBeSubclassOf(given, _).isValid)),
(),
s"""None of the following types:
|${givenTypes.map(" - " + _.display).toList.mkString(",\n")}
|can be a subclass of any of:
|${superclassCandidates.map(" - " + _.display).toList.mkString(",\n")}""".stripMargin
)
}

// TODO: Conversions should be checked during typing, not during generic usage of TypingResult.canBeSubclassOf(...)
private def canBeConvertedTo(
givenType: SingleTypingResult,
superclassCandidate: TypedClass
): ValidatedNel[String, Unit] = {
val errMsgPrefix = s"${givenType.runtimeObjType.display} cannot be converted to ${superclassCandidate.display}"
condNel(TypeConversionHandler.canBeConvertedTo(givenType, superclassCandidate), (), errMsgPrefix)
}

// we use explicit autoboxing = true flag, as ClassUtils in commons-lang3:3.3 (used in Flink) cannot handle JDK 11...
private def isAssignable(from: Class[_], to: Class[_]): ValidatedNel[String, Unit] =
condNel(ClassUtils.isAssignable(from, to, true), (), s"$to is not assignable from $from")
}
override def isAssignable(from: Class[_], to: Class[_]): Boolean =
ClassUtils.isAssignable(from, to, true)

object CanBeSubclassDeterminer extends CanBeSubclassDeterminer
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ object NumberTypeUtils {
else if (typ == Typed[java.lang.Double]) java.lang.Double.valueOf(0)
else if (typ == Typed[java.math.BigDecimal]) java.math.BigDecimal.ZERO
// in case of some unions
else if (typ.canBeSubclassOf(Typed[java.lang.Integer])) java.lang.Integer.valueOf(0)
else if (typ.canBeImplicitlyConvertedTo(Typed[java.lang.Integer])) java.lang.Integer.valueOf(0)
// double is quite safe - it can be converted to any Number
else if (typ.canBeSubclassOf(Typed[Number])) java.lang.Double.valueOf(0)
else if (typ.canBeImplicitlyConvertedTo(Typed[Number])) java.lang.Double.valueOf(0)
else throw new IllegalArgumentException(s"Not expected type: ${typ.display}, should be Number")
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package pl.touk.nussknacker.engine.api.typed

import cats.data.Validated.condNel
import cats.data.ValidatedNel
import org.apache.commons.lang3.ClassUtils
import pl.touk.nussknacker.engine.api.typed.supertype.NumberTypesPromotionStrategy.AllNumbers
import pl.touk.nussknacker.engine.api.typed.typing.{SingleTypingResult, TypedClass, TypingResult}

object SubclassDeterminer extends ConversionDeterminer {

def singleCanBeConvertedTo(
givenType: SingleTypingResult,
superclassCandidate: SingleTypingResult
): ValidatedNel[String, Unit] = {
val givenClass = givenType.runtimeObjType
val givenSuperclas = superclassCandidate.runtimeObjType

isStrictSubclass(givenClass, givenSuperclas)
}

def canBeStrictSubclassOf(givenType: TypingResult, superclassCandidate: TypingResult): ValidatedNel[String, Unit] = {
this.canBeConvertedTo(givenType, superclassCandidate)
}

override def isAssignable(from: Class[_], to: Class[_]): Boolean = {
(from, to) match {
case (f, t) if ClassUtils.isAssignable(f, t, true) => true
// Number double check by hand because lang3 can incorrectly throw false when dealing with java types
case (f, t) if AllNumbers.contains(f) && AllNumbers.contains(t) =>
AllNumbers.indexOf(f) >= AllNumbers.indexOf(t)
case _ => false
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ object TypeConversionHandler {

/**
* java.math.BigDecimal is quite often returned as a wrapper for all kind of numbers (floating and without floating point).
* Given to this we cannot to be sure if conversion is safe or not based on type (without scale knowledge).
* So we have two options: enforce user to convert to some type without floating point (e.g. BigInteger) or be loose in this point.
* Given to this we cannot be sure if conversion is safe or not based on type (without scale knowledge).
* So we have two options: force user to convert to some type without floating point (e.g. BigInteger) or be loose in this point.
* Be default we will be loose.
*/
// TODO: Add feature flag: strictBigDecimalChecking (default false?)
Expand Down Expand Up @@ -68,6 +68,11 @@ object TypeConversionHandler {
handleStringToValueClassConversions(givenType, superclassCandidate)
}

def canBeStrictlyConvertedTo(givenType: SingleTypingResult, superclassCandidate: TypedClass): Boolean = {
handleStrictNumberConversions(givenType.runtimeObjType, superclassCandidate) ||
handleStringToValueClassConversions(givenType, superclassCandidate)
}

// See org.springframework.core.convert.support.NumberToNumberConverterFactory
private def handleNumberConversions(givenClass: TypedClass, superclassCandidate: TypedClass): Boolean = {
val boxedGivenClass = ClassUtils.primitiveToWrapper(givenClass.klass)
Expand All @@ -84,6 +89,32 @@ object TypeConversionHandler {
}
}

// See org.springframework.core.convert.support.NumberToNumberConverterFactory
private def handleStrictNumberConversions(givenClass: TypedClass, superclassCandidate: TypedClass): Boolean = {
val boxedGivenClass = ClassUtils.primitiveToWrapper(givenClass.klass)
val boxedSuperclassCandidate = ClassUtils.primitiveToWrapper(superclassCandidate.klass)
// We can't check precision here so we need to be loose here
// TODO: Add feature flag: strictNumberPrecisionChecking (default false?)

def isFloating(candidate: Class[_]): Boolean = {
NumberTypesPromotionStrategy.isFloatingNumber(candidate) || candidate == classOf[java.math.BigDecimal]
}
def isDecimalNumber(candidate: Class[_]): Boolean = {
NumberTypesPromotionStrategy.isDecimalNumber(candidate)
}

boxedSuperclassCandidate match {
case candidate if isFloating(candidate) =>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Treating specially implicit conversion for floats is not a way to go, we should have option to check can be subclass without conversion in general

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify: we already treat floats specially, the condition here is not changed: if is replaced with pattern matching, the condition is extracted to separate def above.

The only change here is in if isDecimalNumber section:

ConversionFromClassesForDecimals.exists(ClassUtils.isAssignable(boxedGivenClass, _, true))

that checks boxedGivenClass with all possible supertypes (I guess...)
replaced with

CanBeSubclassDeterminer.isAssignable(boxedGivenClass, candidate)

that checks boxedGivenClass with exact given supertype. Aaaand that possibly collides with the java.math.BigDecimal is quite often returned as a wrapper for all kind of numbers.

Copy link
Contributor Author

@Diamekod0221 Diamekod0221 Nov 6, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, apart from this isAssignable(boxedGivenClass,...) the logic is exactly the same. To clarify, I think the current staging logic is buggy. It only checks whether a givenType CAN be converted, not if the conversion is to the givenSuperclass. Apart from that I've only included a fallback in case lang3 throws a false false, because the lib gives errors when it comes to decimal java types. In general the program execution flow is the same as intended before.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have moved this logic to a separate method and restored the previous logic in the handleNumberConversions method

ClassUtils.isAssignable(boxedGivenClass, classOf[Number], true)

case candidate if isDecimalNumber(candidate) =>
SubclassDeterminer.isAssignable(boxedGivenClass, candidate)

case _ => false
}

}

private def handleStringToValueClassConversions(
givenType: SingleTypingResult,
superclassCandidate: TypedClass
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,11 @@ object typing {
// TODO: Rename to Typed, maybe NuType?
sealed trait TypingResult {

// TODO: We should split this method into two or three methods:
// - Simple, strictly checking subclassing similar to isAssignable, where we don't do heuristics like
// Any can be subclass of Int, or for Union of Int and String can be subclass of Int
// - The one with heuristics considering limitations of our tool like poor support for generics, lack
// of casting allowing things described above
// - The one that allow things above + SPeL conversions like any Number to any Number conversion,
// String to LocalDate etc. This one should be accessible only for context where SPeL is used
final def canBeSubclassOf(typingResult: TypingResult): Boolean =
CanBeSubclassDeterminer.canBeSubclassOf(this, typingResult).isValid
final def canBeImplicitlyConvertedTo(typingResult: TypingResult): Boolean =
ImplicitConversionDeterminer.canBeConvertedTo(this, typingResult).isValid

def canBeStrictSubclassOf(typingResult: TypingResult): Boolean =
SubclassDeterminer.canBeStrictSubclassOf(this, typingResult).isValid

def valueOpt: Option[Any]

Expand Down Expand Up @@ -466,7 +462,9 @@ object typing {
case class CastTypedValue[T: TypeTag]() {

def unapply(typingResult: TypingResult): Option[TypingResultTypedValue[T]] = {
Option(typingResult).filter(_.canBeSubclassOf(Typed.fromDetailedType[T])).map(new TypingResultTypedValue(_))
Option(typingResult)
.filter(_.canBeImplicitlyConvertedTo(Typed.fromDetailedType[T]))
.map(new TypingResultTypedValue(_))
}

}
Expand Down
Loading
Loading