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

pipez.internal.ProductCaseGeneration.scala Maven / Gradle / Ivy

The newest version!
package pipez.internal

import pipez.internal.Definitions.Context
import pipez.internal.ProductCaseGeneration.inputNameMatchesOutputName

import scala.annotation.nowarn
import scala.collection.immutable.ListMap
import scala.util.chaining.*

@nowarn("msg=The outer reference in this type test cannot be checked at run time.")
private[internal] trait ProductCaseGeneration[Pipe[_, _], In, Out] {
  self: Definitions[Pipe, In, Out] & Generators[Pipe, In, Out] =>

  /** True iff `A` is a tuple */
  def isTuple[A: Type]: Boolean

  /** True iff `A` is defined as `case class`, is NOT abstract and has a public constructor */
  def isCaseClass[A: Type]: Boolean

  /** True iff `A` is defined as `case object`, and is public.
    *
    * Exception's are Scala 3's cases without parameters - Scala 3 sees then as vals and Scala as non-case modules
    */
  def isCaseObject[A: Type]: Boolean

  /** True iff `A` has a (public) default constructor and at least one (public) method starting with `set`.
    *
    * The exceptions are Java Beans compiled with Scala 3 which expose `@BeanProperty var a: Int` as `var` as opposed to
    * pair of `getA` and `setA` - for that reason we need a special handling of Scala 3's beans
    */
  def isJavaBean[A: Type]: Boolean

  /** Whether `Out` type could be constructed as "product case" */
  final def isUsableAsProductOutput: Boolean =
    isCaseClass[Out] || isCaseObject[Out] || isJavaBean[Out]

  /** Should create `Out` expression from the constructor arguments grouped in parameter lists */
  type Constructor = List[List[Expr[Any]]] => Expr[Out]

  sealed trait FieldFallback[+OutField] extends Product with Serializable
  object FieldFallback {
    final case class Value[OutField, Value](tpe: Type[Value], expr: Expr[Value]) extends FieldFallback[OutField]
    final case class Default[OutField](expr: Expr[OutField]) extends FieldFallback[OutField]
  }

  /** Stores information how each attribute/getter could be extracted from `In` value */
  final case class ProductInData(getters: ListMap[String, ProductInData.Getter[?]]) {

    def findGetter(
      inParamName:           String,
      outParamName:          String,
      caseInsensitiveSearch: Boolean
    ): DerivationResult[ProductInData.Getter[?]] =
      DerivationResult.fromOption(
        getters.collectFirst {
          case (_, getter) if inputNameMatchesOutputName(getter.name, inParamName, caseInsensitiveSearch) => getter
        }
      )(DerivationError.MissingPublicSource(outParamName))

    def findIndex(index: Int, outParamName: String): DerivationResult[ProductInData.Getter[?]] =
      DerivationResult.fromOption(
        getters.toList.lift(index).map(_._2)
      )(DerivationError.MissingPublicSource(outParamName))
  }
  object ProductInData {

    final case class Getter[InField](
      name: String,
      tpe:  Type[InField],
      get:  Expr[In] => Expr[InField],
      path: Path
    ) {

      override def toString: String = s"Getter($name : ${previewType(tpe)})"
    }
  }

  /** Stores information how `Out` value could be constructed from values of constructor parameters/passed to setters */
  sealed trait ProductOutData extends Product with Serializable
  object ProductOutData {

    final case class ConstructorParam[OutField](
      name:     String,
      tpe:      Type[OutField],
      fallback: Vector[FieldFallback[OutField]]
    )
    final case class CaseClass(
      caller: Constructor,
      params: List[ListMap[String, ConstructorParam[?]]]
    ) extends ProductOutData {
      override def toString: String = s"CaseClass${params.map { list =>
          "(" + list.map { case (n, p) => s"$n : ${previewType(p.tpe)}" }.mkString(", ") + ")"
        }.mkString}"
    }

    final case class Setter[OutField](
      name:     String,
      tpe:      Type[OutField],
      set:      (Expr[Out], Expr[OutField]) => Expr[Unit],
      fallback: Vector[FieldFallback[OutField]]
    ) {

      override def toString: String = s"Setter($name : ${previewType(tpe)})"
    }
    final case class JavaBean(
      defaultConstructor: Expr[Out],
      setters:            ListMap[String, Setter[?]]
    ) extends ProductOutData {
      override def toString: String = s"JavaBean(${setters.map { case (n, p) => s"$n : $p" }.mkString(", ")})"
    }
  }

  /** Value generation strategy for a particular output parameter/setter */
  sealed trait OutFieldLogic[OutField] extends Product with Serializable
  object OutFieldLogic {

    final case class DefaultField[OutField]() extends OutFieldLogic[OutField]

    final case class FieldAdded[OutField](
      pipe: Expr[Pipe[In, OutField]]
    ) extends OutFieldLogic[OutField] {
      override def toString: String = s"FieldAdded(${previewCode(pipe)})"
    }

    final case class FieldRenamed[InField, OutField](
      inField:     String,
      inFieldType: Type[InField]
    ) extends OutFieldLogic[OutField] {
      override def toString: String = s"FieldRenamed($inField : ${previewType(inFieldType)})"
    }

    final case class PipeProvided[InField, OutField](
      inField:     String,
      inFieldType: Type[InField],
      pipe:        Expr[Pipe[InField, OutField]]
    ) extends OutFieldLogic[OutField] {
      override def toString: String = s"PipeProvided($inField : ${previewType(inFieldType)}, ${previewCode(pipe)})"
    }

    private def resolve[OutField: Type](
      settings:     Settings,
      outFieldName: String
    ): OutFieldLogic[OutField] = {
      import Path.*
      import ConfigEntry.*

      // outFieldName matches what we found as setter/constructor param
      // outFieldGetter in matter matching, what we got from config - user might have used JavaBeans' getter!
      settings.resolve[OutFieldLogic[OutField]](DefaultField()) {
        case AddField(Field(Root, outFieldGetter), outFieldType, pipe)
            if inputNameMatchesOutputName(outFieldGetter, outFieldName, settings.isFieldCaseInsensitive) =>
          // TODO: validate that Out <:< outFieldType is correct
          FieldAdded(pipe.asInstanceOf[Expr[Pipe[In, OutField]]])
        case RenameField(Field(Root, inName), in, Field(Root, outFieldGetter), out)
            if inputNameMatchesOutputName(outFieldGetter, outFieldName, settings.isFieldCaseInsensitive) =>
          // TODO: validate that Out <:< outFieldType is correct
          FieldRenamed(inName, In)
        case PlugInField(Field(Root, inName), in, Field(Root, outFieldGetter), out, pipe)
            if inputNameMatchesOutputName(outFieldGetter, outFieldName, settings.isFieldCaseInsensitive) =>
          // TODO: validate that Out <:< outFieldType is correct
          PipeProvided[Any, OutField](inName, in.asInstanceOf[Type[Any]], pipe.asInstanceOf[Expr[Pipe[Any, OutField]]])
      }
    }

    type InField
    def resolveField[OutField: Type](
      settings:     Settings,
      inData:       ProductInData,
      outParamName: String,
      indexOpt:     Option[Int],
      fallback:     Vector[FieldFallback[OutField]]
    ): DerivationResult[ProductGeneratorData.OutputValue] = resolve[OutField](settings, outParamName) match {
      case DefaultField() =>
        // if inField (same name as out - same index if tuple) not found then error
        // else if inField <:< outField then (in, ctx) => pure(in : OutField)
        // else (in, ctx) => unlift(summon[InField, OutField], in.outParamName, updateContext(ctx, path)) : Result[OutField]
        indexOpt
          .fold {
            inData.findGetter(outParamName, outParamName, settings.isFieldCaseInsensitive) // match by name
          } { index =>
            inData.findIndex(index, outParamName) // match by index
          }
          .map(_.asInstanceOf[ProductInData.Getter[InField]])
          .flatMap { getter =>
            implicit val tpe: Type[InField] = getter.tpe
            fromFieldConstructorParam[InField, OutField](getter, settings)
          }
          .orElse(fromFallbackValue(outParamName, fallback, settings))
          .log(
            s"Field $outParamName uses default resolution (${
                if (indexOpt.isEmpty) "matching input name" else "matching field position"
              }, summoning)"
          )
      case FieldAdded(pipe) =>
        // (in, ctx) => unlift(pipe, in, ctx) : Result[OutField]
        DerivationResult
          .pure(fieldAddedConstructorParam[OutField](pipe))
          .log(s"Field $outParamName considered added to output, uses provided pipe")
      case FieldRenamed(inFieldName, _) =>
        // if inField (name provided) not found then error
        // else if inField <:< outField then (in, ctx) => pure(in : OutField)
        // else (in, ctx) => unlift(summon[InField, OutField], in.inFieldName, updateContext(ctx, path)) : Result[OutField]
        inData
          .findGetter(inFieldName, outParamName, settings.isFieldCaseInsensitive)
          .map(_.asInstanceOf[ProductInData.Getter[InField]])
          .flatMap { getter =>
            implicit val tpe: Type[InField] = getter.tpe
            fromFieldConstructorParam[InField, OutField](getter, settings)
          }
          .log(s"Field $outParamName is considered renamed from $inFieldName, uses summoning if types differ")
      case PipeProvided(inFieldName, _, pipe) =>
        // if inField (name provided) not found then error
        // else (in, ctx) => unlift(summon[InField, OutField], in.used, updateContext(ctx, path)) : Result[OutField]
        inData
          .findGetter(inFieldName, outParamName, settings.isFieldCaseInsensitive)
          .map(_.asInstanceOf[ProductInData.Getter[InField]])
          .map { getter =>
            implicit val tpe: Type[InField] = getter.tpe
            pipeProvidedConstructorParam[InField, OutField](getter, pipe.asInstanceOf[Expr[Pipe[InField, OutField]]])
          }
          .log(s"Field $outParamName converted from $inFieldName using provided pipe")
    }
  }

  /** Final platform-independent result of matching inputs with outputs using resolved strategies */
  sealed trait ProductGeneratorData extends Product with Serializable
  object ProductGeneratorData {

    sealed trait OutputValue extends Product with Serializable
    object OutputValue {

      final case class Pure[A](
        tpe:    Type[A],
        caller: (Expr[In], Expr[Context]) => Expr[A]
      ) extends OutputValue {
        override def toString: String = s"Pure { (${previewType[In]}, Context) => ${previewType(tpe)} }"
      }

      final case class Result[A](
        tpe:    Type[A],
        caller: (Expr[In], Expr[Context]) => Expr[Definitions.Result[A]]
      ) extends OutputValue {
        override def toString: String = s"Result { (${previewType[In]}, Context) => ${previewType(tpe)} }"
      }
    }

    final case class CaseClass(
      constructor: Constructor,
      output:      List[List[OutputValue]]
    ) extends ProductGeneratorData {
      override def toString: String = s"CaseClass${output.map(list => "(" + list.mkString(", ") + ")").mkString}"
    }

    final case class JavaBean(
      defaultConstructor: Expr[Out],
      output:             List[(OutputValue, ProductOutData.Setter[?])]
    ) extends ProductGeneratorData {
      override def toString: String = s"JavaBean(${output.mkString(", ")})"
    }
  }

  object ProductTypeConversion {

    final def unapply(settings: Settings): Option[DerivationResult[Expr[Pipe[In, Out]]]] =
      if (isUsableAsProductOutput) Some(attemptProductRendering(settings)) else None
  }

  /** Platform-specific way of parsing `In` data
    *
    * Should:
    *   - obtain all methods which are Scala's getters (vals, nullary defs)
    *   - obtain all methods which are Java Bean getters (starting with is- or get-)
    *   - for each create an `InField` factory which takes `In` argument and returns `InField` expression
    *   - form obtained collection into `ProductInData`
    */
  def extractProductInData(settings: Settings): DerivationResult[ProductInData]

  /** Platform-specific way of parsing `Out` data
    *
    * Should:
    *   - verify whether output is a case class, a case object or a Java Bean
    *   - obtain respectively:
    *     - a constructor taking all arguments
    *     - expression containing case object value
    *     - a default constructor and collection of setters respectively
    *   - form obtained data into `ProductOutData`
    */
  def extractProductOutData(settings: Settings): DerivationResult[ProductOutData]

  /** Platform-specific way of generating code from resolved information
    *
    * For case class output should generate code like:
    *
    * {{{
    * pipeDerivation.lift { (in: In, ctx: pipeDerivation.Context) =>
    *   pipeDerivation.mergeResult(
    *      ctx,
    *     pipeDerivation.mergeResult(
    *        ctx,
    *       pipeDerivation.pure(Array[Any](2)),
    *       pipeDerivation.unlift(fooPipe, in.foo, pipeDerivation.updateContext(ctx, Path.root.field("foo"))),
    *       { (left, right) =>
    *         left(0) = right
    *         left
    *        }
    *     ),
    *     pipeDerivation.unlift(barPipe, in.bar, pipeDerivation.updateContext(ctx, Path.root.field("bar"))),
    *     { (left, right) =>
    *       left(1) = right
    *       new Out(
    *         foo = left(0).asInstanceOf[Foo2],
    *         bar = left(1).asInstanceOf[Bar2],
    *       )
    *     }
    *   )
    * }
    * }}}
    *
    * For case object output should generate code like:
    *
    * {{{
    * pipeDerivation.lift { (in: In, ctx: pipeDerivation.Context) =>
    *   pipeDerivation.pure(CaseObject)
    * }
    * }}}
    *
    * For Java Bean should generate code like:
    *
    * {{{
    * pipeDerivation.lift { (in: In, ctx: pipeDerivation.Context) =>
    *   pipeDerivation.mergeResult(
    *     ctx,
    *     pipeDerivation.mergeResult(
    *       ctx,
    *       pipeDerivation.pure {
    *         val result = new Out()
    *         result
    *       },
    *       pipeDerivation.unlift(fooPipe, in.foo, pipeDerivation.updateContext(ctx, Path.root.field("foo"))),
    *       { (left, right) =>
    *         left.setFoo(right)
    *         left
    *       }
    *     ),
    *     pipeDerivation.unlift(barPipe, in.bar, pipeDerivation.updateContext(ctx, Path.root.field("bar"))),
    *     { (left, right) =>
    *       left.setBar(right)
    *       left
    *     }
    *   )
    * }
    * }}}
    */
  def generateProductCode(generatorData: ProductGeneratorData): DerivationResult[Expr[Pipe[In, Out]]]

  private def attemptProductRendering(settings: Settings): DerivationResult[Expr[Pipe[In, Out]]] =
    for {
      data <- extractProductInData(settings) zip extractProductOutData(settings)
      (inData, outData) = data
      generatorData <-
        if (isTuple[In] || isTuple[Out]) matchFieldsByPosition(inData, outData, settings)
        else matchFieldsByName(inData, outData, settings)
      code <- generateProductCode(generatorData)
    } yield code

  // In the product derivation, the logic is driven by `Out` type:
  // - every field of Out should have an assigned value
  // - so we are iterating over the list of fields in Out and check the configuration for them
  // - additional fields in In can be safely ignored
  // - field by default are matched by their name
  private def matchFieldsByName(
    inData:   ProductInData,
    outData:  ProductOutData,
    settings: Settings
  ): DerivationResult[ProductGeneratorData] = outData match {
    case ProductOutData.CaseClass(caller, listOfParamsList) =>
      listOfParamsList
        .map(
          _.values
            .map { case ProductOutData.ConstructorParam(outParamName, outParamType, fallback) =>
              OutFieldLogic.resolveField(settings, inData, outParamName, None, fallback)(outParamType)
            }
            .toList
            .pipe(DerivationResult.sequence(_))
        )
        .pipe(DerivationResult.sequence(_))
        .map(ProductGeneratorData.CaseClass(caller, _))
        .logSuccess(gen => s"Case generation: $gen")

    case ProductOutData.JavaBean(defaultConstructor, setters) =>
      setters.values
        .map { case setter @ ProductOutData.Setter(outSetterName, outSetterType, _, fallback) =>
          OutFieldLogic.resolveField(settings, inData, outSetterName, None, fallback)(outSetterType).map(_ -> setter)
        }
        .toList
        .pipe(DerivationResult.sequence(_))
        .map(ProductGeneratorData.JavaBean(defaultConstructor, _))
        .logSuccess(gen => s"Case generation: $gen")
  }

  // In the product derivation, the logic is driven by `Out` type:
  // - every field of Out should have an assigned value
  // - so we are iterating over the list of fields in Out and check the configuration for them
  // - additional fields in In can be safely ignored
  // - field by default are matched by their position
  private def matchFieldsByPosition(
    inData:   ProductInData,
    outData:  ProductOutData,
    settings: Settings
  ): DerivationResult[ProductGeneratorData] = outData match {
    case ProductOutData.CaseClass(caller, listOfParamsList) =>
      listOfParamsList
        .map(
          _.values.zipWithIndex
            .map { case (ProductOutData.ConstructorParam(outParamName, outParamType, fallback), index) =>
              OutFieldLogic.resolveField(settings, inData, outParamName, Some(index), fallback)(outParamType)
            }
            .toList
            .pipe(DerivationResult.sequence(_))
        )
        .pipe(DerivationResult.sequence(_))
        .map(ProductGeneratorData.CaseClass(caller, _))
        .logSuccess(gen => s"Case generation: $gen")

    case ProductOutData.JavaBean(_, _) =>
      DerivationResult.fail(
        DerivationError.InvalidInput(
          "Conversion from tuple can only be performed into another tuple or case class"
        )
      )
  }

  // if inField <:< outField then (in, ctx) => pure(in : OutField)
  // else (in, ctx) => unlift(summon[InField, OutField], in.inField, updateContext(ctx, path)) : Result[OutField]
  private def fromFieldConstructorParam[InField: Type, OutField: Type](
    getter:   ProductInData.Getter[InField],
    settings: Settings
  ): DerivationResult[ProductGeneratorData.OutputValue] =
    if (isSubtype[InField, OutField]) {
      DerivationResult.pure(
        ProductGeneratorData.OutputValue.Pure(
          typeOf[InField],
          (in, _) => getter.get(in)
        )
      )
    } else {
      summonOrDerive[InField, OutField](settings, alwaysAllowDerivation = false).map {
        (pipe: Expr[Pipe[InField, OutField]]) =>
          ProductGeneratorData.OutputValue.Result(
            typeOf[OutField],
            (in, ctx) => unlift[InField, OutField](pipe, getter.get(in), updateContext(ctx, pathCode(getter.path)))
          )
      }
    }

  // (in, ctx) => unlift(pipe, in, ctx) : Result[OutField]
  private def fieldAddedConstructorParam[OutField: Type](
    pipe: Expr[Pipe[In, OutField]]
  ): ProductGeneratorData.OutputValue = ProductGeneratorData.OutputValue.Result(
    typeOf[OutField],
    (in, ctx) => unlift[In, OutField](pipe, in, ctx)
  )

  // (in, ctx) => unlift(summon[InField, OutField], in.used, updateContext(ctx, path)) : Result[OutField]
  private def pipeProvidedConstructorParam[InField: Type, OutField: Type](
    getter: ProductInData.Getter[InField],
    pipe:   Expr[Pipe[InField, OutField]]
  ): ProductGeneratorData.OutputValue =
    ProductGeneratorData.OutputValue.Result(
      typeOf[OutField],
      (in, ctx) => unlift[InField, OutField](pipe, getter.get(in), updateContext(ctx, pathCode(getter.path)))
    )

  type FallbackField
  private def fromFallbackValue[OutField: Type](
    outParamName: String,
    fallback:     Vector[FieldFallback[OutField]],
    settings:     Settings
  ): DerivationResult[ProductGeneratorData.OutputValue] = fallback.headOption match {
    case Some(value @ FieldFallback.Value(_, _)) =>
      implicit val fallbackType: Type[FallbackField] = value.tpe.asInstanceOf[Type[FallbackField]]
      val fallbackValue = value.expr.asInstanceOf[Expr[FallbackField]]
      if (isSubtype[FallbackField, OutField])
        // (in, out) => pure(providedValue.field)
        DerivationResult
          .pure(
            ProductGeneratorData.OutputValue
              .Pure(typeOf[OutField], (_, _) => fallbackValue.asInstanceOf[Expr[OutField]])
          )
          .log(s"Successful fallback of $outParamName to provided value ${previewCode(fallbackValue)}")
      else
        // (in, out) => unlift(summon[FallbackField, OutField], providedValue.field, ctx)
        summonOrDerive[FallbackField, OutField](settings, alwaysAllowDerivation = false)
          .map { pipe =>
            ProductGeneratorData.OutputValue.Result(typeOf[OutField], (_, ctx) => unlift(pipe, fallbackValue, ctx))
          }
          .log(s"Successful fallback of $outParamName to provided value ${previewCode(fallbackValue)} with conversion")
    case Some(FieldFallback.Default(code)) if settings.isFallbackToDefaultEnabled =>
      // (in, out) => pure(Out.apply$default$n)
      DerivationResult
        .pure(
          ProductGeneratorData.OutputValue.Pure(typeOf[OutField], (_, _) => code.asInstanceOf[Expr[OutField]])
        )
        .log(s"Successful fallback of $outParamName to default value ${previewCode(code)}")
    case Some(FieldFallback.Default(_)) =>
      DerivationResult.fail(DerivationError.InvalidInput(s"Couldn't fallback on default value for $outParamName"))
    case None if settings.isFallbackToDefaultEnabled =>
      DerivationResult.fail(
        DerivationError.InvalidInput(
          s"No value could be resolve for field $outParamName, although it has a default so you might try enableFallbackToDefaults option"
        )
      )
    case None =>
      DerivationResult.fail(DerivationError.InvalidInput(s"Couldn't fallback on default value for $outParamName"))
  }
}
object ProductCaseGeneration {

  private val getAccessor = raw"(?i)get(.)(.*)".r
  private val isAccessor  = raw"(?i)is(.)(.*)".r
  private val dropGetIs: String => String = {
    case getAccessor(head, tail) => head.toLowerCase + tail
    case isAccessor(head, tail)  => head.toLowerCase + tail
    case other                   => other
  }
  val isGetterName: String => Boolean = name => getAccessor.matches(name) || isAccessor.matches(name)

  private val setAccessor = raw"(?i)set(.)(.*)".r
  private val dropSet: String => String = {
    case setAccessor(head, tail) => head.toLowerCase + tail
    case other                   => other
  }
  val isSetterName: String => Boolean = name => setAccessor.matches(name)

  def inputNameMatchesOutputName(
    inFieldName:     String,
    outFieldName:    String,
    caseInsensitive: Boolean
  ): Boolean = {
    val in  = Set(inFieldName, dropGetIs(inFieldName))
    val out = Set(outFieldName, dropSet(outFieldName))
    if (caseInsensitive) in.exists(a => out.exists(b => a.equalsIgnoreCase(b)))
    else in.intersect(out).nonEmpty
  }
}




© 2015 - 2024 Weber Informatics LLC | Privacy Policy