All Downloads are FREE. Search and download functionalities are using the official Maven repository.

pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionExtractor.scala Maven / Gradle / Ivy

The newest version!
package pl.touk.nussknacker.engine.definition.clazz

import cats.data.Validated.{Invalid, Valid}
import cats.data.{NonEmptyList, StateT}
import cats.effect.IO
import cats.implicits.catsSyntaxSemigroup
import com.typesafe.scalalogging.LazyLogging
import org.apache.commons.lang3.{ClassUtils, StringUtils}
import pl.touk.nussknacker.engine.api.generics.{GenericType, MethodTypeInfo, Parameter, TypingFunction}
import pl.touk.nussknacker.engine.api.process.PropertyFromGetterExtractionStrategy.{
  AddPropertyNextToGetter,
  DoNothing,
  ReplaceGetterWithProperty
}
import pl.touk.nussknacker.engine.api.process.{
  ClassExtractionSettings,
  TypingFunctionForClassMember,
  VisibleMembersPredicate
}
import pl.touk.nussknacker.engine.api.typed.typing._
import pl.touk.nussknacker.engine.api.{Documentation, ParamName}
import pl.touk.nussknacker.engine.definition.clazz.ClassDefinitionExtractor.{
  extractClass,
  extractGenericReturnType,
  extractMethodReturnType,
  extractParameterType
}

import java.lang.annotation.Annotation
import java.lang.reflect._
import java.util.Optional

class ClassDefinitionExtractor(settings: ClassExtractionSettings) extends LazyLogging {

  import pl.touk.nussknacker.engine.util.Implicits._

  def extract(clazz: Class[_]): ClassDefinition =
    ClassDefinition(
      Typed(clazz),
      extractPublicMethodsAndFields(clazz, staticMethodsAndFields = false),
      extractPublicMethodsAndFields(clazz, staticMethodsAndFields = true)
    )

  def isHidden(clazz: Class[_]): Boolean =
    settings.isHidden(clazz)

  private def extractPublicMethodsAndFields(
      clazz: Class[_],
      staticMethodsAndFields: Boolean
  ): Map[String, List[MethodDefinition]] = {
    val membersPredicate = settings.visibleMembersPredicate(clazz)
    val methods          = extractPublicMethods(clazz, membersPredicate, staticMethodsAndFields)
    val fields           = extractPublicFields(clazz, membersPredicate, staticMethodsAndFields).mapValuesNow(List(_))
    filterHiddenParameterAndReturnType(methods ++ fields)
  }

  private def extractPublicMethods(
      clazz: Class[_],
      membersPredicate: VisibleMembersPredicate,
      staticMethodsAndFields: Boolean
  ): Map[String, List[MethodDefinition]] = {
    /* From getMethods javadoc: If this {@code Class} object represents an interface then the returned array
           does not contain any implicitly declared methods from {@code Object}.
           The same for primitives - we assume that languages like SpEL will be able to do boxing
           It could be significant only for toString, as we filter out other Object methods, but to be consistent...
     */
    val additionalMethods = if (clazz.isInterface) {
      classOf[Object].getMethods.toList
    } else if (clazz.isPrimitive) {
      ClassUtils.primitiveToWrapper(clazz).getMethods.toList
    } else {
      List.empty
    }
    val publicMethods = clazz.getMethods.toList ++ additionalMethods

    val methods =
      if (staticMethodsAndFields)
        publicMethods.filter(membersPredicate.shouldBeVisible).filter(m => Modifier.isStatic(m.getModifiers))
      else publicMethods.filter(membersPredicate.shouldBeVisible).filter(m => !Modifier.isStatic(m.getModifiers))

    // "varargs" annotation generates two methods - one with scala style varArgs
    // and one with java style varargs. We want only the second one so we have
    // to filter them.
    val filteredMethods = methods.filter(extractJavaVersionOfVarArgMethod(_).isEmpty)

    val methodNameAndInfoList = filteredMethods
      .flatMap(extractMethod(clazz, _))

    val staticMethodDefinitions =
      methodNameAndInfoList
        .filter(_._2.isInstanceOf[StaticMethodDefinition])
        .asInstanceOf[List[(String, StaticMethodDefinition)]]
    val functionalMethodDefinitions        = methodNameAndInfoList.filter(_._2.isInstanceOf[FunctionalMethodDefinition])
    val groupedFunctionalMethodDefinitions = functionalMethodDefinitions.groupBy(_._1).mapValuesNow(_.map(_._2))

    deduplicateMethodsWithGenericReturnType(staticMethodDefinitions)
      .asInstanceOf[Map[String, List[MethodDefinition]]]
      .combine(groupedFunctionalMethodDefinitions)
  }

  // We have to filter here, not in ClassExtractionSettings, as we do e.g. boxed/unboxed mapping on TypedClass level...
  private def filterHiddenParameterAndReturnType(
      infos: Map[String, List[MethodDefinition]]
  ): Map[String, List[MethodDefinition]] = {
    def typeResultVisible(t: TypingResult): Boolean = t match {
      case str: SingleTypingResult =>
        !settings.isHidden(str.runtimeObjType.klass) && str.runtimeObjType.params.forall(typeResultVisible)
      case union: TypedUnion => union.possibleTypes.forall(typeResultVisible)
      case TypedNull         => true
      case Unknown           => true
    }
    def filterOneMethod(method: MethodDefinition): Boolean = {
      val noVarArgTypes = method.signatures.toList.flatMap(_.noVarArgs).map(_.refClazz)
      val varArgTypes   = method.signatures.toList.flatMap(_.varArg.toList).map(_.refClazz)
      val resultTypes   = method.signatures.toList.map(_.result)
      (noVarArgTypes ::: varArgTypes ::: resultTypes).forall(typeResultVisible)
    }
    infos.mapValuesNow(methodList => methodList.filter(filterOneMethod)).filter(_._2.nonEmpty)
  }

  /*
    This is tricky case. If we have generic class and concrete subclasses, e.g.
    - ChronoLocalDateTime
    - LocalDateTime extends ChronoLocalDateTime
    and method with generic return type from superclass: D toLocalDate()
    getMethods return two toLocalDate methods:
      ChronoLocalDate toLocalDate()
      LocalDate toLocalDate()
    In our case the second one is correct
   */
  private def deduplicateMethodsWithGenericReturnType(methodNameAndInfoList: List[(String, StaticMethodDefinition)]) = {
    val groupedByNameAndParameters =
      methodNameAndInfoList.groupBy(mi => (mi._1, mi._2.signature.noVarArgs, mi._2.signature.varArg))
    groupedByNameAndParameters.toList
      .map { case (_, methodsForParams) =>
        /*
          we want to find "most specific" class, however surprisingly it's not always possible, because we treat e.g. isLeft and left methods
          as equal (for javabean-like access) and e.g. in scala Either this is perfectly possible. In case we cannot find most specific
          class we pick arbitrary one (we sort to avoid randomness)
         */

        methodsForParams
          .find { case (_, method) =>
            methodsForParams.forall(mi => method.signature.result.canBeSubclassOf(mi._2.signature.result))
          }
          .getOrElse(methodsForParams.minBy(_._2.signature.result.display))
      }
      .toGroupedMap
      // we sort only to avoid randomness
      .mapValuesNow(_.sortBy(_.toString))
  }

  // SpEL is able to access getters using property name so you can write `obj.foo` instead of `obj.getFoo`
  private def collectMethodNames(method: Method): List[String] = {
    val isGetter = method.getName.matches("^(get|is).+") && method.getParameterCount == 0
    if (isGetter) {
      val propertyMethod = StringUtils.uncapitalize(method.getName.replaceAll("^get|^is", ""))
      settings.propertyExtractionStrategy match {
        case AddPropertyNextToGetter   => List(method.getName, propertyMethod)
        case ReplaceGetterWithProperty => List(propertyMethod)
        case DoNothing                 => List(method.getName)
      }
    } else {
      List(method.getName)
    }
  }

  private def extractMethod(
      clazz: Class[_],
      method: Method
  ): List[(String, MethodDefinition)] =
    extractAnnotation(method, classOf[GenericType]) match {
      case None             => extractRegularMethod(clazz, method)
      case Some(annotation) => extractGenericMethod(method, annotation)
    }

  private def getTypeFunctionInstanceFromAnnotation(method: Method, genericType: GenericType): TypingFunction = {
    val typeFunctionClass = genericType.typingFunction()
    try {
      val typeFunctionConstructor = typeFunctionClass.getDeclaredConstructor()
      typeFunctionConstructor.newInstance()
    } catch {
      case e: InstantiationException =>
        throw new IllegalArgumentException(s"TypingFunction for ${method.getName} cannot be abstract class.", e)
      case e: InvocationTargetException =>
        throw new IllegalArgumentException(s"TypingFunction's constructor for ${method.getName} failed.", e)
      case e: NoSuchMethodException =>
        throw new IllegalArgumentException(
          s"Could not find parameterless constructor for method ${method.getName} or its TypingFunction was declared inside non-static class.",
          e
        )
      case e: Exception =>
        throw new IllegalArgumentException(s"Could not extract information about generic method ${method.getName}.", e)
    }
  }

  private def extractGenericMethod(method: Method, genericType: GenericType): List[(String, MethodDefinition)] = {
    val typeFunctionInstance = getTypeFunctionInstanceFromAnnotation(method, genericType)

    val methodTypeInfo = extractGenericParameters(typeFunctionInstance, method)

    collectMethodNames(method).map(methodName =>
      methodName -> FunctionalMethodDefinition(
        (_, x) => typeFunctionInstance.computeResultType(x),
        methodTypeInfo,
        methodName,
        extractNussknackerDocs(method)
      )
    )
  }

  private def extractRegularMethod(
      clazz: Class[_],
      method: Method
  ): List[(String, MethodDefinition)] = {
    collectMethodNames(method).map { methodName =>
      val reflectionBasedDefinition = extractMethodTypeInfo(method)
      val methodDefinition = extractMemberMethodDefinition(clazz, method, methodName, reflectionBasedDefinition)
      methodName -> methodDefinition
    }
  }

  private def extractPublicFields(
      clazz: Class[_],
      membersPredicate: VisibleMembersPredicate,
      staticMethodsAndFields: Boolean
  ): Map[String, MethodDefinition] = {
    val interestingFields = clazz.getFields.filter(membersPredicate.shouldBeVisible)
    val fields =
      if (staticMethodsAndFields) interestingFields.filter(m => Modifier.isStatic(m.getModifiers))
      else interestingFields.filter(m => !Modifier.isStatic(m.getModifiers))
    fields.map { field =>
      val reflectionBasedDefinition = MethodTypeInfo(Nil, None, extractFieldReturnType(field))
      val methodDefinition = extractMemberMethodDefinition(clazz, field, field.getName, reflectionBasedDefinition)
      field.getName -> methodDefinition
    }.toMap
  }

  private def extractMemberMethodDefinition(
      clazz: Class[_],
      member: Member with AccessibleObject,
      memberName: String,
      reflectionBasedDefinition: MethodTypeInfo
  ): MethodDefinition = {
    settings
      .typingFunction(clazz, member)
      .map(prepareFunctionMethodDefinition(clazz, member, memberName, reflectionBasedDefinition, _))
      .getOrElse {
        StaticMethodDefinition(
          reflectionBasedDefinition,
          memberName,
          extractNussknackerDocs(member)
        )
      }
  }

  private def prepareFunctionMethodDefinition(
      clazz: Class[_],
      member: Member with AccessibleObject,
      memberName: String,
      reflectionBasedDefinition: MethodTypeInfo,
      typingFun: TypingFunctionForClassMember
  ) = {
    FunctionalMethodDefinition(
      (invocationTarget, _) => {
        Valid(invocationTarget.withoutValue match {
          case single: SingleTypingResult =>
            val returnedResultType = typingFun.resultType(single).valueOr { message =>
              logger.warn(
                s"ClassExtractionSettings defined typing function for class: ${clazz.getName}, member: ${member.getName} " +
                  s"which returned error during result type computation: $message. " +
                  s"Will be used fallback result type: ${reflectionBasedDefinition.result}"
              )
              reflectionBasedDefinition.result
            }
            if (returnedResultType.canBeSubclassOf(returnedResultType)) {
              returnedResultType
            } else {
              logger.warn(
                s"ClassExtractionSettings defined typing function for class: ${clazz.getName}, member: ${member.getName} " +
                  s"which returned result type $returnedResultType that can't be a subclass of type ${reflectionBasedDefinition.result} " +
                  s"computed using reflection. Will be used type computed using reflection"
              )
              reflectionBasedDefinition.result
            }
          case _ => reflectionBasedDefinition.result
        })
      },
      reflectionBasedDefinition,
      memberName,
      extractNussknackerDocs(member)
    )
  }

  private def extractNussknackerDocs(accessibleObject: AccessibleObject): Option[String] = {
    extractAnnotation(accessibleObject, classOf[Documentation]).map(_.description())
  }

  private def extractGenericParameters(typingFunction: TypingFunction, method: Method): NonEmptyList[MethodTypeInfo] = {
    val autoExtractedParameters = extractMethodTypeInfo(method)
    val definedParametersOption = typingFunction.signatures.toList.flatMap(_.toList)

    definedParametersOption
      .map(MethodTypeInfoSubclassChecker.check(_, autoExtractedParameters))
      .collect { case Invalid(e) => e }
      .foreach { x =>
        val errorString = x.map(_.message).toList.mkString("; ")
        throw new IllegalArgumentException(
          s"Generic function ${method.getName} has declared parameters that are incompatible with methods signature: $errorString"
        )
      }

    NonEmptyList.fromList(definedParametersOption).getOrElse(NonEmptyList.one(autoExtractedParameters))
  }

  private def extractMethodTypeInfo(method: Method): MethodTypeInfo = {
    MethodTypeInfo.fromList(
      for {
        param <- method.getParameters.toList
        annotationOption = extractAnnotation(param, classOf[ParamName])
        name             = annotationOption.map(_.value).getOrElse(param.getName)
        paramType        = extractParameterType(param)
      } yield Parameter(name, paramType),
      method.isVarArgs,
      extractMethodReturnType(method)
    )
  }

  private def extractFieldReturnType(field: Field): TypingResult = {
    extractGenericReturnType(field.getGenericType)
      .orElse(extractClass(field.getGenericType))
      .getOrElse(Typed(field.getType))
  }

  private def extractScalaVersionOfVarArgMethod(method: Method): Option[Method] = {
    val obj  = method.getDeclaringClass
    val name = method.getName
    val args = method.getParameterTypes.toList
    args match {
      case noVarArgs :+ varArg if method.isVarArgs && varArg.isArray =>
        try {
          Some(obj.getMethod(name, noVarArgs :+ classOf[Seq[_]]: _*))
        } catch {
          case _: NoSuchMethodException => None
        }
      case _ => None
    }
  }

  private def extractJavaVersionOfVarArgMethod(method: Method): Option[Method] = {
    method.getDeclaringClass.getMethods.find(m =>
      m.isVarArgs && (m.getParameterTypes.toList match {
        case noVarArgs :+ varArgArr if varArgArr.isArray =>
          method.getParameterTypes.toList == noVarArgs :+ classOf[Seq[_]]
        case _ => false
      })
    )
  }

  // "varargs" annotation creates new function that has java style varArgs
  // but it disregards annotations, so we have to look for original function
  // to extract them.
  private def extractAnnotation[T <: Annotation](obj: AnnotatedElement, annotationType: Class[T]): Option[T] =
    Option(obj.getAnnotation(annotationType)).orElse(obj match {
      case method: Method => extractScalaVersionOfVarArgMethod(method).flatMap(extractAnnotation(_, annotationType))
      // TODO: Add new case for parameters.
      case _ => None
    })

}

object ClassDefinitionExtractor {

  def extractMethodReturnType(method: Method): TypingResult = {
    extractGenericReturnType(method.getGenericReturnType)
      .orElse(extractClass(method.getGenericReturnType))
      .getOrElse(Typed(method.getReturnType))
  }

  def extractParameterType(javaParam: java.lang.reflect.Parameter): TypingResult = {
    extractClass(javaParam.getParameterizedType).getOrElse(Typed(javaParam.getType))
  }

  def companionObject[T](klazz: Class[T]): T = {
    klazz.getField("MODULE$").get(null).asInstanceOf[T]
  }

  private def extractGenericReturnType(typ: Type): Option[TypingResult] = {
    typ match {
      case t: ParameterizedType if t.getRawType.isInstanceOf[Class[_]] =>
        extractGenericMonadReturnType(t, t.getRawType.asInstanceOf[Class[_]])
      case _ => None
    }
  }

  // This method should be used only for method's and field's return type - for method's parameters such unwrapping has no sense
  //
  // Arguments of generic types that are Scala's primitive types are always erased by Scala compiler to java.lang.Object:
  // * issue: https://github.com/scala/bug/issues/4214 (and discussion at https://groups.google.com/g/scala-internals/c/K2dELqajQbg/m/gV0tbjRHJ4UJ)
  // * commit: https://github.com/scala/scala/commit/e42733e9fe1f3af591976fbb48b66035253d85b9
  private def extractGenericMonadReturnType(
      genericReturnType: ParameterizedType,
      genericReturnRawType: Class[_]
  ): Option[TypingResult] = {
    // see ScalaLazyPropertyAccessor
    if (classOf[StateT[IO, _, _]].isAssignableFrom(genericReturnRawType)) {
      val returnType = genericReturnType.getActualTypeArguments.apply(
        3
      ) // it's IndexedStateT[IO, ContextWithLazyValuesProvider, ContextWithLazyValuesProvider, A]
      extractClass(returnType)
    }
    // see ScalaOptionOrNullPropertyAccessor
    else if (classOf[Option[_]].isAssignableFrom(genericReturnRawType)) {
      val optionGenericType = genericReturnType.getActualTypeArguments.apply(0)
      extractClass(optionGenericType)
    }
    // see JavaOptionalOrNullPropertyAccessor
    else if (classOf[Optional[_]].isAssignableFrom(genericReturnRawType)) {
      val optionalGenericType = genericReturnType.getActualTypeArguments.apply(0)
      extractClass(optionalGenericType)
    } else None
  }

  // TODO this is not correct for primitives and complicated hierarchies, but should work in most cases
  // http://docs.oracle.com/javase/8/docs/api/java/lang/reflect/ParameterizedType.html#getActualTypeArguments--
  private def extractClass(typ: Type): Option[TypingResult] = {
    typ match {
      case t: Class[_] => Some(Typed(t))
      case t: ParameterizedType if t.getRawType.isInstanceOf[Class[_]] =>
        Some(extractGenericParams(t, t.getRawType.asInstanceOf[Class[_]]))
      case _ => None
    }
  }

  private def extractGenericParams(paramsType: ParameterizedType, paramsRawType: Class[_]): TypingResult = {
    Typed.genericTypeClass(
      paramsRawType,
      paramsType.getActualTypeArguments.toList.map(p => extractClass(p).getOrElse(Unknown))
    )
  }

}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy