Configuring transformations

Introduction and explanation

More often than not the models we work with daily do not map one-to-one with one another - let's define a wire/domain model pair that we'd like to transform.

object wire:
  case class Person(
    firstName: String,
    lastName: String,
    paymentMethods: List[wire.PaymentMethod]
  )

  enum PaymentMethod:
    case Card(name: String, digits: Long)
    case PayPal(email: String)
    case Cash
    case Transfer(accountNo: String) // <-- additional enum case, not present in the domain model
object domain:
  case class Person(
    firstName: String,
    lastName: String,
    age: Int, // <-- additional field, not present in the wire model
    paymentMethods: Vector[domain.PaymentMethod]
  )

  enum PaymentMethod:
    case Card(name: String, digits: Long)
    case PayPal(email: String)
    case Cash

...and an input value we want transformed:

val wirePerson = wire.Person(
  "John",
  "Doe",
  List(
    wire.PaymentMethod.Cash,
    wire.PaymentMethod.PayPal("john@doe.com"),
    wire.PaymentMethod.Card("J. Doe", 23232323),
    wire.PaymentMethod.Transfer("21371284583271927489486")
  )
)

If we were to just call .to[domain.Person] the compiler would yell at us with a (hopefully) helpful message that should lead us into being able to complete such transformation:

import io.github.arainko.ducktape.*

wirePerson.to[domain.Person]
// error:
// No child named 'Transfer' found in repl.MdocSession.MdocApp.domain.PaymentMethod @ Person.paymentMethods.element.at[repl.MdocSession.MdocApp.wire.PaymentMethod.Transfer]
// No field 'age' found in repl.MdocSession.MdocApp.wire.Person @ Person.age
// wirePerson
// ^

The newly added field (age) and enum case (PaymentMethod.Transfer) do not have a corresponding mapping, let's say we want to set the age field to a constant value of 24 and when a PaymentMethod.Transfer is encountered we map it to Cash instead.

import io.github.arainko.ducktape.*

wirePerson
  .into[domain.Person]
  .transform(
    Field.const(_.age, 24),
    Case.const(_.paymentMethods.element.at[wire.PaymentMethod.Transfer], domain.PaymentMethod.Cash)
  )
// res1: Person = Person(
//   firstName = "John",
//   lastName = "Doe",
//   age = 24,
//   paymentMethods = Vector(
//     Cash,
//     PayPal(email = "john@doe.com"),
//     Card(name = "J. Doe", digits = 23232323L),
//     Cash
//   )
// )
{
  val AppliedBuilder_this: AppliedBuilder[Person, Person] = into[Person](MdocApp.this.wirePerson)[MdocApp.this.domain.Person]
  {
    val value$proxy2: Person = AppliedBuilder_this.inline$value
    new Person(
      firstName = value$proxy2.firstName,
      lastName = value$proxy2.lastName,
      age = 24,
      paymentMethods = value$proxy2.paymentMethods
        .map[PaymentMethod]((src: PaymentMethod) =>
          if (src.isInstanceOf[Card]) new Card(name = src.asInstanceOf[Card].name, digits = src.asInstanceOf[Card].digits)
          else if (src.isInstanceOf[PayPal]) new PayPal(email = src.asInstanceOf[PayPal].email)
          else if (src.isInstanceOf[Cash.type]) MdocApp.this.domain.PaymentMethod.Cash
          else if (src.isInstanceOf[Transfer]) MdocApp.this.domain.PaymentMethod.Cash
          else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
        )
        .to[Vector[PaymentMethod]](iterableFactory[PaymentMethod])
    ): Person
  }: Person
}

Great! But let's take a step back and examine what we just did, starting with the first config example:

Field.const(_.age, 24)
            |      |
            |      the second argument is the constant itself, whatever value is passed here needs to be a subtype of the field type 
            the first argument is the path to the field we're configuring (all Field configs operate on the destination type)        

...and now for the second one:

Case.const(_.paymentMethods.element.at[wire.PaymentMethod.Transfer], domain.PaymentMethod.Cash)
              |             |       |
              |             |       '.at' is another special case used to pick a subtype of an enum/sealed trait
              |             '.element' is a special extension method that allows us to configure the type inside a collection or an Option
              path expressions are not limited to a single field, we can use these to dive as deep as we need for our config to be (paths inside Case configs operate on the source type)

So, is .at and .element another one of those extensions that will always pollute the namespace? Thankfully, no - let's look at how Field.const and Case.const are actually defined in the code:

opaque type Field[Source, Dest] = Unit

object Field {
  @compileTimeOnly("Field.const is only useable as a field configuration for transformations")
  def const[Source, Dest, DestFieldTpe, ConstTpe](path: Selector ?=> Dest => DestFieldTpe, value: ConstTpe): Field[Source, Dest] = ???
}

opaque type Case[A, B] = Unit

object Case {
  @compileTimeOnly("Case.const is only useable as a case configuration for transformations")
  def const[Source, Dest, SourceTpe, ConstTpe](path: Selector ?=> Source => SourceTpe, value: ConstTpe): Case[Source, Dest] = ???
}

the things that interest us the most are the path paramenters of both of these methods, defined as a context function of Selector to a function that allows us to 'pick' which part of the transformation we want to customize.

So what is a Selector anyway? It is defined as such:

sealed trait Selector {
  extension [A](self: A) def at[B <: A]: B

  extension [Elem](self: Iterable[Elem] | Option[Elem]) def element: Elem
}

Which means that for a context function such as Selector ?=> Dest => DestFieldTpe the Selector brings in the neccessary extensions that allow us to pick and configure subtypes and elements under a collection or an Option, but only in the scope of that context function and not anywhere outside which means we do not pollute the outside world's namespace with these.

What's worth noting is that any of the configuration options are purely a compiletime construct and are completely erased from the runtime representation (i.e. it's not possible to implement an instance of a Selector in a sane way since such an implementation would throw exceptions left and right but using it as a sort of a DSL for picking and choosing is completely fair game since it doesn't exist at runtime).

Product configurations

Let's introduce another payment method (not part of any of the previous payment method ADTs, just a standalone case class).

case class PaymentBand(name: String, digits: Long, color: String = "red")

val card: wire.PaymentMethod.Card =
  wire.PaymentMethod.Card(name = "J. Doe", digits = 213712345)
card
  .into[PaymentBand]
  .transform(Field.const(_.color, "blue"))
// res3: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 213712345L,
//   color = "blue"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy5: Card = AppliedBuilder_this.inline$value
    new PaymentBand(name = value$proxy5.name, digits = value$proxy5.digits, color = "blue"): PaymentBand
  }: PaymentBand
}
card
  .into[PaymentBand]
  .transform(
    Field.computed(_.color, card => if (card.digits % 2 == 0) "green" else "yellow")
  )
// res5: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 213712345L,
//   color = "yellow"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy8: Card = AppliedBuilder_this.inline$value
    new PaymentBand(
      name = value$proxy8.name,
      digits = value$proxy8.digits,
      color = if (value$proxy8.digits.%(2).==(0)) "green" else "yellow"
    ): PaymentBand
  }: PaymentBand
}
card
  .into[PaymentBand]
  .transform(Field.default(_.color))
// res7: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 213712345L,
//   color = "red"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy11: Card = AppliedBuilder_this.inline$value
    new PaymentBand(
      name = value$proxy11.name,
      digits = value$proxy11.digits,
      color = MdocApp.this.PaymentBand.$lessinit$greater$default$3
    ): PaymentBand
  }: PaymentBand
}
case class FieldSource(color: String, digits: Long, extra: Int)
val source = FieldSource("magenta", 123445678, 23)
card
  .into[PaymentBand]
  .transform(Field.allMatching(paymentBand => paymentBand, source))
// res9: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 123445678L,
//   color = "magenta"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy14: Card = AppliedBuilder_this.inline$value
    new PaymentBand(
      name = value$proxy14.name,
      digits = MdocApp.this.source.digits,
      color = MdocApp.this.source.color
    ): PaymentBand
  }: PaymentBand
}
case class SourceToplevel(level1: SourceLevel1, transformableButWithDefault: Int)
case class SourceLevel1(str: String)

case class DestToplevel(level1: DestLevel1, extra: Int = 111, transformableButWithDefault: Int = 3000)
case class DestLevel1(extra: String = "level1", str: String)

val source = SourceToplevel(SourceLevel1("str"), 400)
source
  .into[DestToplevel]
  .transform(Field.fallbackToDefault)
// res11: DestToplevel = DestToplevel(
//   level1 = DestLevel1(extra = "level1", str = "str"),
//   extra = 111,
//   transformableButWithDefault = 400
// )
{
  val AppliedBuilder_this: AppliedBuilder[SourceToplevel, DestToplevel] = into[SourceToplevel](source)[DestToplevel]
  {
    val value$proxy17: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = DestLevel1.$lessinit$greater$default$1, str = value$proxy17.level1.str),
      extra = DestToplevel.$lessinit$greater$default$2,
      transformableButWithDefault = value$proxy17.transformableButWithDefault
    ): DestToplevel
  }: DestToplevel
}

Field.fallbackToDefault is a regional config, which means that you can control the scope where it applies:

source
  .into[DestToplevel]
  .transform(
    Field.fallbackToDefault.regional(
      _.level1
    ), // <-- we're applying the config starting on the `.level1` field and below, it'll be also applied to other transformations nested inside
    Field.const(_.extra, 123) // <-- note that this field now needs to be configured manually
  )
// res13: DestToplevel = DestToplevel(
//   level1 = DestLevel1(extra = "level1", str = "str"),
//   extra = 123,
//   transformableButWithDefault = 400
// )
{
  val AppliedBuilder_this: AppliedBuilder[SourceToplevel, DestToplevel] = into[SourceToplevel](source)[DestToplevel]
  {
    val value$proxy20: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = DestLevel1.$lessinit$greater$default$1, str = value$proxy20.level1.str),
      extra = 123,
      transformableButWithDefault = value$proxy20.transformableButWithDefault
    ): DestToplevel
  }: DestToplevel
}
case class SourceToplevel(level1: SourceLevel1, transformable: Option[Int])
case class SourceLevel1(str: String)

case class DestToplevel(level1: DestLevel1, extra: Option[Int], transformable: Option[Int])
case class DestLevel1(extra: Option[String], str: String)

val source = SourceToplevel(SourceLevel1("str"), Some(400))
source
  .into[DestToplevel]
  .transform(Field.fallbackToNone)
// res15: DestToplevel = DestToplevel(
//   level1 = DestLevel1(extra = None, str = "str"),
//   extra = None,
//   transformable = Some(value = 400)
// )
{
  val AppliedBuilder_this: AppliedBuilder[SourceToplevel, DestToplevel] = into[SourceToplevel](source)[DestToplevel]
  {
    val value$proxy23: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = None, str = value$proxy23.level1.str),
      extra = None,
      transformable = value$proxy23.transformable
    ): DestToplevel
  }: DestToplevel
}

Field.fallbackToNone is a regional config, which means that you can control the scope where it applies:

source
  .into[DestToplevel]
  .transform(
    Field.fallbackToNone.regional(
      _.level1
    ), // <-- we're applying the config starting on the `.level1` field and below, it'll be also applied to other transformations nested inside
    Field.const(_.extra, Some(123)) // <-- note that this field now needs to be configured manually
  )
// res17: DestToplevel = DestToplevel(
//   level1 = DestLevel1(extra = None, str = "str"),
//   extra = Some(value = 123),
//   transformable = Some(value = 400)
// )
{
  val AppliedBuilder_this: AppliedBuilder[SourceToplevel, DestToplevel] = into[SourceToplevel](source)[DestToplevel]
  {
    val value$proxy26: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = None, str = value$proxy26.level1.str),
      extra = Some.apply[Int](123),
      transformable = value$proxy26.transformable
    ): DestToplevel
  }: DestToplevel
}

Coproduct configurations

val transfer = wire.PaymentMethod.Transfer("2764262")
// transfer: PaymentMethod = Transfer(accountNo = "2764262")
transfer
  .into[domain.PaymentMethod]
  .transform(Case.const(_.at[wire.PaymentMethod.Transfer], domain.PaymentMethod.Cash))
// res19: PaymentMethod = Cash
{
  val AppliedBuilder_this: AppliedBuilder[PaymentMethod, PaymentMethod] =
    into[PaymentMethod](transfer)[MdocApp.this.domain.PaymentMethod]
  {
    val value$proxy29: PaymentMethod = AppliedBuilder_this.inline$value
    if (value$proxy29.isInstanceOf[Card])
      new Card(name = value$proxy29.asInstanceOf[Card].name, digits = value$proxy29.asInstanceOf[Card].digits)
    else if (value$proxy29.isInstanceOf[PayPal]) new PayPal(email = value$proxy29.asInstanceOf[PayPal].email)
    else if (value$proxy29.isInstanceOf[Cash.type]) MdocApp.this.domain.PaymentMethod.Cash
    else if (value$proxy29.isInstanceOf[Transfer]) MdocApp.this.domain.PaymentMethod.Cash
    else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape."): PaymentMethod
  }: PaymentMethod
}
transfer
  .into[domain.PaymentMethod]
  .transform(
    Case.computed(_.at[wire.PaymentMethod.Transfer], transfer => domain.PaymentMethod.Card("J. Doe", transfer.accountNo.toLong))
  )
// res21: PaymentMethod = Card(name = "J. Doe", digits = 2764262L)
{
  val AppliedBuilder_this: AppliedBuilder[PaymentMethod, PaymentMethod] =
    into[PaymentMethod](transfer)[MdocApp.this.domain.PaymentMethod]
  {
    val value$proxy32: PaymentMethod = AppliedBuilder_this.inline$value
    if (value$proxy32.isInstanceOf[Card])
      new Card(name = value$proxy32.asInstanceOf[Card].name, digits = value$proxy32.asInstanceOf[Card].digits)
    else if (value$proxy32.isInstanceOf[PayPal]) new PayPal(email = value$proxy32.asInstanceOf[PayPal].email)
    else if (value$proxy32.isInstanceOf[Cash.type]) MdocApp.this.domain.PaymentMethod.Cash
    else if (value$proxy32.isInstanceOf[Transfer]) {
      val `transferâ‚‚` : Transfer = value$proxy32.asInstanceOf[Transfer]
      MdocApp.this.domain.PaymentMethod.Card.apply("J. Doe", augmentString(`transferâ‚‚`.accountNo).toLong): PaymentMethod
    } else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape."): PaymentMethod
  }: PaymentMethod
}

Specifics and limitations

wirePerson
  .into[domain.Person]
  .transform(
    Field.const(_.age, 24),
    Case.const(_.paymentMethods.element.at[wire.PaymentMethod.Transfer], domain.PaymentMethod.Cash),
    Field.const(_.paymentMethods.element, domain.PaymentMethod.Cash) // <-- override all payment methods to `Cash`
  )
// res23: Person = Person(
//   firstName = "John",
//   lastName = "Doe",
//   age = 24,
//   paymentMethods = Vector(Cash, Cash, Cash, Cash)
// )
wirePerson
  .into[domain.Person]
  .transform(
    Case.const(_.paymentMethods.element.at[wire.PaymentMethod.Transfer], domain.PaymentMethod.Cash),
    Field.const(_.age, 24),
    Field.const(_.age, 50) // <-- override the previously configured 'age' field`
  )
// res24: Person = Person(
//   firstName = "John",
//   lastName = "Doe",
//   age = 50,
//   paymentMethods = Vector(
//     Cash,
//     PayPal(email = "john@doe.com"),
//     Card(name = "J. Doe", digits = 23232323L),
//     Cash
//   )
// )
wirePerson
  .into[domain.Person]
  .transform(
    Field.const(_.age, 24),
    Case.const(_.paymentMethods.element.at[wire.PaymentMethod.Transfer], domain.PaymentMethod.Cash),
    Field.const(_.paymentMethods.element, domain.PaymentMethod.Cash), // <-- override all payment methods to `Cash`,
    Field.const(
      _.paymentMethods,
      Vector.empty[domain.PaymentMethod]
    ) // <-- also override the 'parent' of '_.paymentMethods.element' so now payment methods are just empty
  )
// res25: Person = Person(
//   firstName = "John",
//   lastName = "Doe",
//   age = 24,
//   paymentMethods = Vector()
// )

However, first configuring the field a level above and then the field a level below is not supported:

wirePerson
  .into[domain.Person]
  .transform(
    Field.const(_.age, 24),
    Case.const(_.paymentMethods.element.at[wire.PaymentMethod.Transfer], domain.PaymentMethod.Cash),
    Field.const(_.paymentMethods, Vector.empty[domain.PaymentMethod]), // <-- configure the field a level above first
    Field.const(_.paymentMethods.element, domain.PaymentMethod.Cash) // <-- then the field below it
  )
// error:
// The path segment 'element' is not valid @ Person.paymentMethods
//     Field.const(_.paymentMethods.element, domain.PaymentMethod.Cash) // <-- then the field below it
//     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^