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)
Field.const
- allows to supply a constant value for a given field
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
}
Field.computed
- allows to compute a value with a function the shape ofDest => FieldTpe
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
}
Field.default
- only works when a field's got a default value defined (defaults are not taken into consideration by default)
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
}
Field.computedDeep
- allows to compute a deeply nested field (for example going through multipleOptions
or collections)
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
}
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)
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
}
Field.fallbackToDefault
- falls back to default field values but ONLY in case a transformation cannot be created
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
}
Field.fallbackToNone
- falls back toNone
forOption
fields for which a transformation cannot be created
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")
Case.const
- allows to supply a constant value for a given subtype of a coproduct
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
}
Case.computed
- allows to supply a function of the selected source type to the expected destination type
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
- Configs can override transformations
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)
// )
- Configs can override each other
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
// )
// )
- Config on a field or a case 'above' overrides the configs 'below'
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
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^