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

Closed
wants to merge 25 commits into from
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d7893c6
remove unnecessary nesting in DictApiEndopoints
Oct 28, 2024
d5796f3
Fixed improper subtyping in CanBeSubclassDeterminer
Oct 30, 2024
7f14f49
regenerated openapi
Oct 31, 2024
1d5ce0c
Changed type in UIProcess
Oct 31, 2024
9bd74d5
extract isAssignable
Oct 31, 2024
28ff753
Added CanBeSubclassDeterminerSpec
Nov 6, 2024
a881083
comment fixup
Nov 6, 2024
108a345
Fix decimal typing in SubclassDeterminerSpec
Nov 6, 2024
d073fd1
Fix numerical typing in SubclassDeterminerSpec
Nov 6, 2024
2e50536
CanBeSubclassDeterminerSpec fixes
Nov 6, 2024
50c59b5
Extracted ConversionDeterminer and SubclassDeterminer
Nov 7, 2024
81d10a0
Cleaned up tests and naming
Nov 7, 2024
43706dc
TypingResultSpec fix
Nov 7, 2024
6385259
feat: Add StrictConversionDeterminer for type conversion validation
Nov 18, 2024
209ab0c
Determiner remakes for PR
Nov 18, 2024
e4724a2
rename for pr
Nov 18, 2024
c7296a0
Update components-api/src/test/scala/pl/touk/nussknacker/engine/api/t…
Diamekod0221 Nov 20, 2024
07af990
pr changes - delte test cases
Nov 21, 2024
6716c23
simplify conversion check (#7220)
mslabek Nov 25, 2024
6f7187c
tests for AssignabilityDeterminerSpec
Nov 25, 2024
1118186
Pr changes to AssignabilityDeterminerSpec and AssignabilityDeterminer…
Nov 26, 2024
06ceea9
Update designer/server/src/test/scala/pl/touk/nussknacker/ui/api/Dict…
Diamekod0221 Nov 26, 2024
ce5448a
Update components-api/src/main/scala/pl/touk/nussknacker/engine/api/t…
Diamekod0221 Nov 26, 2024
155a359
Changes to MigrationGuide.md and comment in AssignabilityDeterminer
Nov 26, 2024
4204af0
Merge remote-tracking branch 'origin/fix_subtyping_in_DictService' in…
Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.{NonEmptyList, Validated, ValidatedNel}
import cats.implicits.{catsSyntaxValidatedId, _}
import org.apache.commons.lang3.ClassUtils
import pl.touk.nussknacker.engine.api.typed.typing._
Expand All @@ -14,42 +14,61 @@ 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 {

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] = {
* 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) => canNullBeSubclassOf(other)
case (TypedNull, other) => canNullBeConvertedTo(other)
case (_, TypedNull) => s"No type can be subclass of ${TypedNull.display}".invalidNel
case (given: SingleTypingResult, superclass: TypedUnion) =>
canBeSubclassOf(NonEmptyList.one(given), superclass.possibleTypes)
canBeConvertedTo(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)
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 canNullBeSubclassOf(result: TypingResult): ValidatedNel[String, Unit] = result match {
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
)
}

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
}

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 +84,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 +142,45 @@ trait CanBeSubclassDeterminer {
(typedObjectRestrictions combine dictRestriction combine taggedValueRestriction combine dataValueRestriction)
}

protected def classCanBeSubclassOf(
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}"
)
}

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 +189,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 +214,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")
}
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.canBeConvertedTo(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.canBeConvertedTo(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,85 @@
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._

object StrictConversionDeterminer {

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)
}
}

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 singleCanBeConvertedTo(
givenType: SingleTypingResult,
superclassCandidate: SingleTypingResult
): ValidatedNel[String, Unit] = {
val givenClass = givenType.runtimeObjType
val givenSuperclas = superclassCandidate.runtimeObjType

isStrictSubclass(givenClass, givenSuperclas)
}

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 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 = {
(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) =>
ClassUtils.isAssignable(boxedGivenClass, classOf[Number], true)

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

case _ => false
}

}

private def handleStringToValueClassConversions(
givenType: SingleTypingResult,
superclassCandidate: TypedClass
Expand Down
Loading
Loading