Skip to content

Commit

Permalink
[Issue 199] Add Field.computedDeep and Field.fallibleComputedDeep (
Browse files Browse the repository at this point in the history
…#220)

Closes #199 

This adds two new config options that allow to computing deeply nested
fields (with the value that corresponds to the source value under the
config path).
Example:
```scala
 case class SourceToplevel1(level1: Option[SourceLevel1])
    case class SourceLevel1(level2: Option[SourceLevel2])
    case class SourceLevel2(level3: SourceLevel3)
    case class SourceLevel3(int: Int)

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

    val source = SourceToplevel1(Some(SourceLevel1(Some(SourceLevel2(SourceLevel3(1))))))
    val expected = DestToplevel1(Some(DestLevel1(Some(DestLevel2(Some(DestLevel3(11)))))))

    assertTransformsConfigured(source, expected)(
      Field.computedDeep(_.level1.element.level2.element.level3.element.int, (int: Int) => int.toLong + 10)
    )
```
  • Loading branch information
arainko authored Oct 30, 2024
2 parents 1d30f6a + f2d3e10 commit ccdeb45
Show file tree
Hide file tree
Showing 17 changed files with 923 additions and 86 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ val good = wire.Person(name = "ValidName", age = 24, socialSecurityNo = "SOCIALN
|:-----------------:|:-------------------:|
| `Field.fallibleConst` | a fallible variant of `Field.const` that allows for supplying values wrapped in an `F` |
| `Field.fallibleComputed` | a fallible variant of `Field.computed` that allows for supplying functions that return values wrapped in an `F` |
| `Field.fallibleComputedDeep` | a fallible variant of `Field.computedDeep` that allows for supplying functions that return values wrapped in an `F` |

---

Expand Down Expand Up @@ -150,6 +151,58 @@ Docs.printCode(
```
@:@

* `Field.fallibleComputedDeep` - a fallible variant of `Field.computedDeep` that allows for supplying functions that return values wrapped in an `F`

```scala mdoc:nest:silent
given Mode.Accumulating.Either[String, List]()

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: Positive)

val source = SourceToplevel1(Some(SourceLevel1(Some(SourceLevel2(1)))))
```

@:select(underlying-code-13)
@:choice(visible)

```scala mdoc
source
.into[DestToplevel1]
.fallible
.transform(
Field.fallibleComputedDeep(
_.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) => Positive.makeAccumulating(value + 10L))
)
```

@:choice(generated)
```scala mdoc:passthrough
import io.github.arainko.ducktape.docs.*

Docs.printCode(
source
.into[DestToplevel1]
.fallible
.transform(
Field.fallibleComputedDeep(
_.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) => Positive.makeAccumulating(value + 10L))
)
)
```

@:@

### Coproduct configurations

| **Name** | **Description** |
Expand Down
48 changes: 46 additions & 2 deletions docs/total_transformations/configuring_transformations.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,9 @@ What's worth noting is that any of the configuration options are purely a compil
| **Name** | **Description** |
|:-----------------:|:-------------------:|
| `Field.const` | allows to supply a constant value for a given field |
| `Field.computed` | allows to compute a value with a function the shape of `Dest => FieldTpe` |
| `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 |
Expand Down Expand Up @@ -279,9 +280,52 @@ Docs.printCode(
```
@:@

* `Field.computedDeep` - allows to compute a deeply nested field (for example going through multiple `Options` or collections)

```scala mdoc:nest:silent
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)))))
```

@:select(underlying-code-13)
@:choice(visible)

```scala mdoc
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
)
)
```

@:choice(generated)
```scala mdoc:passthrough
import io.github.arainko.ducktape.docs.*

Docs.printCode(
source
.into[DestToplevel1]
.transform(Field.computedDeep(_.level1.element.level2.element.int, (value: Int) => value + 10L))
)
```

@:@

* `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)

```scala mdoc:silent
```scala mdoc:nest:silent
case class FieldSource(color: String, digits: Long, extra: Int)
val source = FieldSource("magenta", 123445678, 23)
```
Expand Down
12 changes: 12 additions & 0 deletions ducktape/src/main/scala/io/github/arainko/ducktape/Field.scala
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ object Field {
function: A => F[DestFieldTpe]
): Field.Fallible[F, A, B] = ???

@compileTimeOnly("Field.fallibleComputedDeep is only useable as a field configuration for transformations")
def fallibleComputedDeep[F[+x], A, B, DestFieldTpe, SourceFieldTpe](
selector: Selector ?=> B => DestFieldTpe,
function: SourceFieldTpe => F[DestFieldTpe]
): Field.Fallible[F, A, B] = ???

@compileTimeOnly("Field.const is only useable as a field configuration for transformations")
def const[A, B, DestFieldTpe, ConstTpe](selector: Selector ?=> B => DestFieldTpe, value: ConstTpe): Field[A, B] = ???

Expand All @@ -31,6 +37,12 @@ object Field {
function: A => ComputedTpe
): Field[A, B] = ???

@compileTimeOnly("Field.computedDeep is only useable as a field configuration for transformations")
def computedDeep[A, B, DestFieldTpe, SourceFieldTpe, ComputedTpe](
selector: Selector ?=> B => DestFieldTpe,
function: SourceFieldTpe => ComputedTpe
): Field[A, B] = ???

@compileTimeOnly("Field.renamed is only useable as a field configuration for transformations")
def renamed[A, B, DestFieldTpe, SourceFieldTpe](
destSelector: Selector ?=> B => DestFieldTpe,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ private[ducktape] object ConfigInstructionRefiner {
instruction match
case inst @ Instruction.Static(_, _, config, _) =>
config match
case cfg: (Const | CaseComputed | FieldComputed | FieldReplacement) => inst.copy(config = cfg)
case fallible: (FallibleConst | FallibleFieldComputed | FallibleCaseComputed) => None
case cfg: (Const | CaseComputed | FieldComputed | FieldComputedDeep | FieldReplacement) => inst.copy(config = cfg)
case fallible: (FallibleConst | FallibleFieldComputed | FallibleFieldComputedDeep | FallibleCaseComputed) => None
case inst: (Instruction.Dynamic | Instruction.Bulk | Instruction.Regional | Instruction.Failed) => inst

}
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,9 @@ private[ducktape] object ConfigParser {

def fallible[F[+x]: Type] = NonEmptyList(Total, PossiblyFallible[F])

def combine[F <: Fallible](parsers: NonEmptyList[ConfigParser[F]])(using
Quotes,
Context
): PartialFunction[quotes.reflect.Term, Instruction[F]] =
def combine[F <: Fallible](
parsers: NonEmptyList[ConfigParser[F]]
)(using Quotes, Context): PartialFunction[quotes.reflect.Term, Instruction[F]] =
parsers.map(_.apply).reduceLeft(_ orElse _)

object Total extends ConfigParser[Nothing] {
Expand Down Expand Up @@ -74,6 +73,24 @@ private[ducktape] object ConfigParser {
Span.fromPosition(cfg.pos)
)

case cfg @ Apply(
TypeApply(
Select(IdentOfType('[Field.type]), "computedDeep"),
a :: b :: destFieldTpe :: sourceFieldTpe :: computedFieldTpe :: Nil
),
PathSelector(path) :: function :: Nil
) =>
Configuration.Instruction.Static(
path,
Side.Dest,
Configuration.FieldComputedDeep(
computedFieldTpe.tpe.asType,
sourceFieldTpe.tpe.asType,
function.asExpr.asInstanceOf[Expr[Any => Any]]
),
Span.fromPosition(cfg.pos)
)

case cfg @ Apply(
TypeApply(Select(IdentOfType('[Field.type]), "allMatching"), a :: b :: destFieldTpe :: fieldSourceTpe :: Nil),
PathSelector(path) :: fieldSource :: Nil
Expand Down Expand Up @@ -173,6 +190,21 @@ private[ducktape] object ConfigParser {
Span.fromPosition(cfg.pos)
)

case cfg @ Apply(
TypeApply(
Select(IdentOfType('[Field.type]), "fallibleComputedDeep"),
f :: a :: b :: destFieldTpe :: sourceFieldTpe :: Nil
),
PathSelector(path) :: AsExpr('{ $function: (a => F[computed]) }) :: Nil
) =>
Configuration.Instruction.Static(
path,
Side.Dest,
Configuration
.FallibleFieldComputedDeep(Type.of[computed], sourceFieldTpe.tpe.asType, function.asInstanceOf[Expr[Any => Any]]),
Span.fromPosition(cfg.pos)
)

case cfg @ Apply(
TypeApply(Select(IdentOfType('[Case.type]), "fallibleConst"), f :: a :: b :: sourceTpe :: constTpe :: Nil),
PathSelector(path) :: AsExpr('{ $value: F[const] }) :: Nil
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,28 @@ import io.github.arainko.ducktape.*
import scala.quoted.*

private[ducktape] enum Configuration[+F <: Fallible] {
def tpe: Type[?]

case Const(value: Expr[Any], tpe: Type[?]) extends Configuration[Nothing]
case CaseComputed(tpe: Type[?], function: Expr[Any => Any]) extends Configuration[Nothing]
case FieldComputed(tpe: Type[?], function: Expr[Any => Any]) extends Configuration[Nothing]
case FieldReplacement(source: Expr[Any], name: String, tpe: Type[?]) extends Configuration[Nothing]
case FallibleConst(value: Expr[Any], tpe: Type[?]) extends Configuration[Fallible]
case FallibleFieldComputed(tpe: Type[?], function: Expr[Any => Any]) extends Configuration[Fallible]
case FallibleCaseComputed(tpe: Type[?], function: Expr[Any => Any]) extends Configuration[Fallible]
def destTpe: Type[?]
def sourceTpe: Type[?] | None.type = None

case Const(value: Expr[Any], destTpe: Type[?]) extends Configuration[Nothing]

case CaseComputed(destTpe: Type[?], function: Expr[Any => Any]) extends Configuration[Nothing]

case FieldComputed(destTpe: Type[?], function: Expr[Any => Any]) extends Configuration[Nothing]

case FieldComputedDeep(destTpe: Type[?], override val sourceTpe: Type[?], function: Expr[Any => Any])
extends Configuration[Nothing]

case FieldReplacement(source: Expr[Any], name: String, destTpe: Type[?]) extends Configuration[Nothing]

case FallibleConst(value: Expr[Any], destTpe: Type[?]) extends Configuration[Fallible]

case FallibleFieldComputed(destTpe: Type[?], function: Expr[Any => Any]) extends Configuration[Fallible]

case FallibleFieldComputedDeep(destTpe: Type[?], override val sourceTpe: Type[?], function: Expr[Any => Any])
extends Configuration[Fallible]

case FallibleCaseComputed(destTpe: Type[?], function: Expr[Any => Any]) extends Configuration[Fallible]
}

private[ducktape] object Configuration {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ private[ducktape] object ErrorMessage {
val side = Side.Source
}

final case class InvalidConfiguration(configTpe: Type[?], expectedTpe: Type[?], side: Side, span: Span) extends ErrorMessage {
final case class InvalidConfigurationDestType(configTpe: Type[?], expectedTpe: Type[?], side: Side, span: Span)
extends ErrorMessage {

def render(using Quotes): String = {
val renderedConfigTpe = configTpe.repr.show
Expand All @@ -52,6 +53,16 @@ private[ducktape] object ErrorMessage {
}
}

final case class InvalidConfigurationSourceType(configTpe: Type[?], expectedTpe: Type[?], side: Side, span: Span)
extends ErrorMessage {

def render(using Quotes): String = {
val renderedConfigTpe = configTpe.repr.show
val renderedExpectedTpe = expectedTpe.repr.show
s"Configuration is not valid since the provided source type (${renderedConfigTpe}) is not a supertype of ${renderedExpectedTpe}"
}
}

final case class CouldntBuildTransformation(source: Type[?], dest: Type[?]) extends ErrorMessage {
def render(using Quotes): String = s"Couldn't build a transformation plan between ${source.repr.show} and ${dest.repr.show}"
def span = None
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,13 +30,15 @@ private[ducktape] object FallibilityRefiner {

case Configured(source, dest, config, _) =>
config match
case Configuration.Const(value, tpe) => ()
case Configuration.CaseComputed(tpe, function) => ()
case Configuration.FieldComputed(tpe, function) => ()
case Configuration.FieldReplacement(source, name, tpe) => ()
case Configuration.FallibleConst(value, tpe) => boundary.break(None)
case Configuration.FallibleFieldComputed(tpe, function) => boundary.break(None)
case Configuration.FallibleCaseComputed(tpe, function) => boundary.break(None)
case Configuration.Const(value, tpe) => ()
case Configuration.CaseComputed(tpe, function) => ()
case Configuration.FieldComputed(tpe, function) => ()
case Configuration.FieldComputedDeep(tpe, srcTpe, function) => ()
case Configuration.FieldReplacement(source, name, tpe) => ()
case Configuration.FallibleConst(value, tpe) => boundary.break(None)
case Configuration.FallibleFieldComputed(tpe, function) => boundary.break(None)
case Configuration.FallibleFieldComputedDeep(tpe, srcTpe, function) => boundary.break(None)
case Configuration.FallibleCaseComputed(tpe, function) => boundary.break(None)

case BetweenProductFunction(source, dest, argPlans) =>
evaluate(argPlans.values)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ private[ducktape] object FalliblePlanInterpreter {
Value.Unwrapped(PlanInterpreter.evaluateConfig(cfg, value))
case cfg @ Configuration.FieldComputed(tpe, function) =>
Value.Unwrapped(PlanInterpreter.evaluateConfig(cfg, value))
case cfg @ Configuration.FieldComputedDeep(tpe, srcTpe, function) =>
Value.Unwrapped(PlanInterpreter.evaluateConfig(cfg, value))
case cfg @ Configuration.FieldReplacement(source, name, tpe) =>
Value.Unwrapped(PlanInterpreter.evaluateConfig(cfg, value))
case Configuration.FallibleConst(value, tpe) =>
Expand All @@ -51,6 +53,12 @@ private[ducktape] object FalliblePlanInterpreter {
Value.Wrapped('{ $function($toplevelValue) }.asExprOf[F[tpe]])
}

case Configuration.FallibleFieldComputedDeep(tpe, srcTpe, function) =>
tpe match {
case '[tpe] =>
Value.Wrapped('{ $function($value) }.asExprOf[F[tpe]])
}

case Configuration.FallibleCaseComputed(tpe, function) =>
tpe match {
case '[tpe] =>
Expand Down Expand Up @@ -95,12 +103,12 @@ private[ducktape] object FalliblePlanInterpreter {
dest.tpe match {
case '[destSupertype] =>
val branches = casePlans.map { plan =>
(plan.source.tpe -> plan.dest.tpe) match {
case '[src] -> '[dest] =>
plan.source.tpe match {
case '[src] =>
val sourceValue = '{ $value.asInstanceOf[src] }
IfExpression.Branch(
IsInstanceOf(value, plan.source.tpe),
recurse(plan, sourceValue, F).wrapped(F, Type.of[dest])
recurse(plan, sourceValue, F).wrapped(F, Type.of[destSupertype])
)
}
}.toList
Expand Down Expand Up @@ -232,11 +240,11 @@ private[ducktape] object FalliblePlanInterpreter {

val (unwrapped, wrapped) =
plans.zipWithIndex.partitionMap {
case (p: Plan.Configured[Fallible]) -> index =>
recurse(p, value, F).asFieldValue(index, p.dest.tpe)
case plan -> index =>
case plan -> index if sourceStruct.elements.isDefinedAt(index) =>
val fieldValue = value.accesFieldByIndex(index, sourceStruct)
recurse(plan, fieldValue, F).asFieldValue(index, plan.dest.tpe)
case plan -> index =>
recurse(plan, value, F).asFieldValue(index, plan.dest.tpe)
}

plan.dest.tpe match {
Expand Down Expand Up @@ -275,22 +283,22 @@ private[ducktape] object FalliblePlanInterpreter {

def handleVectorMap(fieldPlans: VectorMap[String, Plan[Nothing, Fallible]])(using Quotes) =
fieldPlans.zipWithIndex.partitionMap {
case (fieldName, p: Plan.Configured[Fallible]) -> index =>
recurse(p, value, F).asFieldValue(index, p.dest.tpe)
case (fieldName, plan) -> index =>
case (fieldName, plan) -> index if source.fields.contains(fieldName) =>
val fieldValue = value.accessFieldByName(fieldName).asExpr
recurse(plan, fieldValue, F).asFieldValue(index, plan.dest.tpe)
case (fieldName, plan) -> index =>
recurse(plan, value, F).asFieldValue(index, plan.dest.tpe)
}

def handleVector(fieldPlans: Vector[Plan[Nothing, Fallible]])(using Quotes) = {
val sourceFields = source.fields.keys
fieldPlans.zipWithIndex.partitionMap {
case (p: Plan.Configured[Fallible]) -> index =>
recurse(p, value, F).asFieldValue(index, p.dest.tpe)
case plan -> index =>
case plan -> index if sourceFields.isDefinedAt(index) =>
val fieldName = sourceFields(index)
val fieldValue = value.accessFieldByName(fieldName).asExpr
recurse(plan, fieldValue, F).asFieldValue(index, plan.dest.tpe)
case plan -> index =>
recurse(plan, value, F).asFieldValue(index, plan.dest.tpe)
}
}

Expand Down
Loading

0 comments on commit ccdeb45

Please sign in to comment.