Configuring transformations

Introduction

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.

Case classes and enums/sealed traits

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.to[domain.Person]
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

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] then new Card(name = src.asInstanceOf[Card].name, digits = src.asInstanceOf[Card].digits)
          else if src.isInstanceOf[PayPal] then new PayPal(email = src.asInstanceOf[PayPal].email)
          else if src.isInstanceOf[Cash.type] then MdocApp.this.domain.PaymentMethod.Cash
          else if src.isInstanceOf[Transfer] then 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)

Tuples

Additionally, if we were to define a transformation that uses tuples and wanted to configure one of the tuple elements we can do this in two ways - either use .apply(N) or ._(N + 1) (like ._1 for the 0th element of the tuple) where N is the index of the tuple element:

// using `.apply` to select the element
(1, List(2, 2, 2), 3, 4).into[(Int, Vector[Int], Int)].transform(Field.const(_.apply(2), 10))
// res3: Tuple3[Int, Vector[Int], Int] = (1, Vector(2, 2, 2), 10)

// using the legacy accessors
(1, List(2, 2, 2), 3, 4).into[(Int, Vector[Int], Int)].transform(Field.const(_._3, 10))
// res4: Tuple3[Int, Vector[Int], Int] = (1, Vector(2, 2, 2), 10)
{
  val source: Tuple4[Int, List[Int], Int, Int] = Tuple4.apply[Int, List[Int], Int, Int](1, List.apply[Int](2, 2, 2), 3, 4)
  val AppliedBuilder_this: AppliedBuilder[Tuple4[Int, List[Int], Int, Int], Tuple3[Int, Vector[Int], Int]] =
    into[Tuple4[Int, List[Int], Int, Int]](source)[Tuple3[Int, Vector[Int], Int]]
  {
    val value$proxy6: Tuple4[Int, List[Int], Int, Int] = AppliedBuilder_this.inline$value
    Tuple3.apply[_1.type, Vector[Int], 10](
      value$proxy6._1,
      value$proxy6._2.map[Int]((src: Int) => src).to[Vector[Int]](iterableFactory[Int]),
      10
    ): Tuple3[Int, Vector[Int], Int]
  }: Tuple3[Int, Vector[Int], Int]
  val `AppliedBuilder_this₂`: AppliedBuilder[Tuple4[Int, List[Int], Int, Int], Tuple3[Int, Vector[Int], Int]] =
    into[Tuple4[Int, List[Int], Int, Int]](source)[Tuple3[Int, Vector[Int], Int]]
  {
    val value$proxy7: Tuple4[Int, List[Int], Int, Int] = `AppliedBuilder_this₂`.inline$value
    Tuple3.apply[_1.type, Vector[Int], 10](
      value$proxy7._1,
      value$proxy7._2.map[Int]((`src₂`: Int) => `src₂`).to[Vector[Int]](iterableFactory[Int]),
      10
    ): Tuple3[Int, Vector[Int], Int]
  }: Tuple3[Int, Vector[Int], Int]
}

For all intents and purposes these two ways of accessing tuple elements are equivalent with the exception of XXL tuples (i.e. tuples with more than 22 elements), these do not have legacy accessors and can only be configured with .apply.

TL;DR here's the cheat sheet of the configuration path DSL:

Input type Config accessor
Case class .fieldName
Tuple (plain) ._N / .apply(N)
Tuple (XXL) .apply(N)
Option/Collection .element
F-wrapped value .element
Enum/sealed trait .at[Subtype]

Explanation

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

  extension [Elem, F[+x]](using Mode[F])(self: F[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(or any wrapper type F[_] given that it has an instance of Mode[F]), 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

Name Description
Field.const allows to supply a constant value for a given field
Field.computed allows to compute a value with a function that has a shape of Dest => FieldTpe
Field.default only works when a field's got a default value defined (defaults are not taken into consideration by default)
Field.computedDeep allows to compute a deeply nested field (for example going through multiple Options or other collections)
Field.allMatching allow to supply a field source whose fields will replace all matching fields in the destination (given that the names and the types match up)
Field.fallbackToDefault falls back to default field values but ONLY in case a transformation cannot be created
Field.fallbackToNone falls back to None for Option fields for which a transformation cannot be created

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"))
// res6: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 213712345L,
//   color = "blue"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy10: Card = AppliedBuilder_this.inline$value
    new PaymentBand(name = value$proxy10.name, digits = value$proxy10.digits, color = "blue"): PaymentBand
  }: PaymentBand
}
card
  .into[PaymentBand]
  .transform(
    Field.computed(_.color, card => if card.digits % 2 == 0 then "green" else "yellow")
  )
// res8: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 213712345L,
//   color = "yellow"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy13: Card = AppliedBuilder_this.inline$value
    new PaymentBand(
      name = value$proxy13.name,
      digits = value$proxy13.digits,
      color = if value$proxy13.digits.%(2).==(0) then "green" else "yellow"
    ): PaymentBand
  }: PaymentBand
}
card
  .into[PaymentBand]
  .transform(Field.default(_.color))
// res10: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 213712345L,
//   color = "red"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy16: Card = AppliedBuilder_this.inline$value
    new PaymentBand(
      name = value$proxy16.name,
      digits = value$proxy16.digits,
      color = MdocApp.this.PaymentBand.$lessinit$greater$default$3
    ): PaymentBand
  }: PaymentBand
}
case class SourceToplevel1(level1: Option[SourceLevel1])
case class SourceLevel1(level2: Option[SourceLevel2])
case class SourceLevel2(int: Int)

case class DestToplevel1(level1: Option[DestLevel1])
case class DestLevel1(level2: Option[DestLevel2])
case class DestLevel2(int: Long)

val source = SourceToplevel1(Some(SourceLevel1(Some(SourceLevel2(1)))))
source
  .into[DestToplevel1]
  .transform(
    Field.computedDeep(
      _.level1.element.level2.element.int,
      // the type here cannot be inferred automatically and needs to be provided by the user,
      // a nice compiletime error message is shown (with a suggestion on what the proper type to use is) otherwise
      (value: Int) => value + 10L
    )
  )
// res12: DestToplevel1 = DestToplevel1(
//   level1 = Some(
//     value = DestLevel1(level2 = Some(value = DestLevel2(int = 11L)))
//   )
// )
{
  val AppliedBuilder_this: AppliedBuilder[SourceToplevel1, DestToplevel1] = into[SourceToplevel1](source)[DestToplevel1]
  {
    val value$proxy19: SourceToplevel1 = AppliedBuilder_this.inline$value
    new DestToplevel1(level1 =
      value$proxy19.level1.map[DestLevel1]((src: SourceLevel1) =>
        new DestLevel1(level2 =
          src.level2.map[DestLevel2]((`src₂`: SourceLevel2) =>
            new DestLevel2(int = {
              val value: Int = `src₂`.int
              value.+(10L)
            })
          )
        )
      )
    ): DestToplevel1
  }: DestToplevel1
}
case class FieldSource(color: String, digits: Long, extra: Int)
val source = FieldSource("magenta", 123445678, 23)
card
  .into[PaymentBand]
  .transform(Field.allMatching(paymentBand => paymentBand, source))
// res14: PaymentBand = PaymentBand(
//   name = "J. Doe",
//   digits = 123445678L,
//   color = "magenta"
// )
{
  val AppliedBuilder_this: AppliedBuilder[Card, PaymentBand] = into[Card](MdocApp.this.card)[PaymentBand]
  {
    val value$proxy22: Card = AppliedBuilder_this.inline$value
    new PaymentBand(name = value$proxy22.name, digits = source.digits, color = 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)
// res16: DestToplevel = DestToplevel(
//   level1 = DestLevel1(extra = "level1", str = "str"),
//   extra = 111,
//   transformableButWithDefault = 400
// )
{
  val AppliedBuilder_this: AppliedBuilder[SourceToplevel, DestToplevel] = into[SourceToplevel](source)[DestToplevel]
  {
    val value$proxy25: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = DestLevel1.$lessinit$greater$default$1, str = value$proxy25.level1.str),
      extra = DestToplevel.$lessinit$greater$default$2,
      transformableButWithDefault = value$proxy25.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
  )
// res18: DestToplevel = DestToplevel(
//   level1 = DestLevel1(extra = "level1", str = "str"),
//   extra = 123,
//   transformableButWithDefault = 400
// )
{
  val AppliedBuilder_this: AppliedBuilder[SourceToplevel, DestToplevel] = into[SourceToplevel](source)[DestToplevel]
  {
    val value$proxy28: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = DestLevel1.$lessinit$greater$default$1, str = value$proxy28.level1.str),
      extra = 123,
      transformableButWithDefault = value$proxy28.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)
// res20: 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$proxy31: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = None, str = value$proxy31.level1.str),
      extra = None,
      transformable = value$proxy31.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
  )
// res22: 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$proxy34: SourceToplevel = AppliedBuilder_this.inline$value
    new DestToplevel(
      level1 = new DestLevel1(extra = None, str = value$proxy34.level1.str),
      extra = Some.apply[Int](123),
      transformable = value$proxy34.transformable
    ): DestToplevel
  }: DestToplevel
}

Coproduct configurations

Name Description
Case.const allows to supply a constant value for a given subtype of a coproduct
Case.computed allows to supply a function of the selected source type to the expected destination type

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))
// res24: PaymentMethod = Cash
{
  val AppliedBuilder_this: AppliedBuilder[PaymentMethod, PaymentMethod] =
    into[PaymentMethod](transfer)[MdocApp.this.domain.PaymentMethod]
  {
    val value$proxy37: PaymentMethod = AppliedBuilder_this.inline$value
    if value$proxy37.isInstanceOf[Card] then
      new Card(name = value$proxy37.asInstanceOf[Card].name, digits = value$proxy37.asInstanceOf[Card].digits)
    else if value$proxy37.isInstanceOf[PayPal] then new PayPal(email = value$proxy37.asInstanceOf[PayPal].email)
    else if value$proxy37.isInstanceOf[Cash.type] then MdocApp.this.domain.PaymentMethod.Cash
    else if value$proxy37.isInstanceOf[Transfer] then 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))
  )
// res26: PaymentMethod = Card(name = "J. Doe", digits = 2764262L)
{
  val AppliedBuilder_this: AppliedBuilder[PaymentMethod, PaymentMethod] =
    into[PaymentMethod](transfer)[MdocApp.this.domain.PaymentMethod]
  {
    val value$proxy40: PaymentMethod = AppliedBuilder_this.inline$value
    if value$proxy40.isInstanceOf[Card] then
      new Card(name = value$proxy40.asInstanceOf[Card].name, digits = value$proxy40.asInstanceOf[Card].digits)
    else if value$proxy40.isInstanceOf[PayPal] then new PayPal(email = value$proxy40.asInstanceOf[PayPal].email)
    else if value$proxy40.isInstanceOf[Cash.type] then MdocApp.this.domain.PaymentMethod.Cash
    else if value$proxy40.isInstanceOf[Transfer] then {
      val `transfer₂`: Transfer = value$proxy40.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`
  )
// res28: 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`
  )
// res29: 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
  )
// res30: 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
//     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^