8000 Nicer fallible transformers by arainko · Pull Request #48 · arainko/ducktape · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Nicer fallible transformers #48

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Apr 28, 2023
517 changes: 498 additions & 19 deletions README.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ lazy val previousArtifacts =
"io.github.arainko" %% "ducktape" % "0.1.2",
"io.github.arainko" %% "ducktape" % "0.1.3",
"io.github.arainko" %% "ducktape" % "0.1.4",
"io.github.arainko" %% "ducktape" % "0.1.5",
)

lazy val root =
Expand Down
316 changes: 315 additions & 1 deletion docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ libraryDependencies += "io.github.arainko" %% "ducktape" % "@VERSION@"
```
NOTE: the [version scheme](https://www.scala-lang.org/blog/2021/02/16/preventing-version-conflicts-with-versionscheme.html) is set to `early-semver`

### Examples
### Total transformations - examples

#### 1. *Case class to case class*

Expand Down Expand Up @@ -310,6 +310,320 @@ given recursive[A, B](using Transformer[A, B]): Transformer[Rec[A], Rec[B]] =
Rec("1", Some(Rec("2", Some(Rec("3", None))))).to[Rec[Option[String]]]
```

### Fallible transfomations - examples
Sometimes ordinary field mappings just do not cut it, more often than not our domain model's constructors are hidden behind a safe factory method, eg.:

```scala mdoc:reset
import io.github.arainko.ducktape.*

final case class ValidatedPerson private (name: String, age: Int)

object ValidatedPerson {
def create(name: String, age: Int): Either[String, ValidatedPerson] =
for {
validatedName <- Either.cond(!name.isBlank, name, "Name should not be blank")
validatedAge <- Either.cond(age > 0, age, "Age should be positive")
} yield ValidatedPerson(validatedName, validatedAge)
}
```

The `via` method expansion mechanism has us covered in the most straight-forward of use cases where there are no nested fallible transformations:

```scala mdoc

final case class UnvalidatedPerson(name: String, age: Int, socialSecurityNo: String)

val unvalidatedPerson = UnvalidatedPerson("ValidName", -1, "SSN")

val transformed = unvalidatedPerson.via(ValidatedPerson.create)
```

But this quickly falls apart when nested transformations are introduced and we're pretty much back to square one where we're on our own to write the boilerplate.

That's where `Fallible Transformers` and their modes come in:
* `Transformer.Mode.Accumulating` for error accumulation,
* `Transformer.Mode.FailFast` for the cases where we just want to bail at the very first sight of trouble.

Let's look at the definition of all of these:

#### Definition of `FallibleTransformer` aka `Transformer.Fallible` and `Transformer.Mode`

```scala
trait FallibleTransformer[F[+x], Source, Dest] {
def transform(value: Source): F[Dest]
}
```
So a `Fallible` transformer takes a `Source` and gives back a `Dest` wrapped in an `F` where `F` is the wrapper type for our transformations eg. if `F[+x]` = `Either[List[String], x]` then the `transform` method will return an `Either[List[String], Dest]`.

```scala
sealed trait Mode[F[+x]] {
def pure[A](value: A): F[A]
def map[A, B](fa: F[A], f: A => B): F[B]
def traverseCollection[A, B, AColl[x] <: Iterable[x], BColl[x] <: Iterable[x]](collection: AColl[A])(using
transformer: FallibleTransformer[F, A, B],
factory: Factory[B, BColl[B]]
): F[BColl[B]]
}
```

Moving on to `Transformer.Mode`, what exactly is it and why do we need it? So a `Mode[F]` is typeclass that gives us two bits of information:
* a hint for the derivation mechanism which transformation mode to use (hence the name!)
* some operations on the abstract `F` wrapper type, namely:
* `pure` is for wrapping arbitrary values into `F`, eg. if `F[+x] = Either[List[String], x]` then calling `pure` would involve just wrapping the value in a `Right.apply` call.
* `map` is for operating on the wrapped values, eg. if we find ourselves with a `F[Int]` in hand and we want to transform the value 'inside' to a `String` we can call `.map(_.toString)` to yield a `F[String]`
* `traverseCollection` is for the cases where we end up with eg. a `List[F[String]]` and we want to transform that into a `F[List[String]]` according to the rules of the `F` type wrapper and not blow up the stack in the process

As mentioned earlier, `Modes` come in two flavors - one for error accumulating transformations (`Transformer.Mode.Accumulating[F]`) and one for fail fast transformations (`Transformer.Mode.FailFast[F]`):

```scala
object Mode {
trait Accumulating[F[+x]] extends Mode[F] {
def product[A, B](fa: F[A], fb: F[B]): F[(A, B)]
}

trait FailFast[F[+x]] extends Mode[F] {
def flatMap[A, B](fa: F[A], f: A => F[B]): F[B]
}
}
```

Each one of these exposes one operation that dictates its approach to errors, `flatMap` entails a dependency between fallible transformations so if we chain multiple `flatMaps` together our transformation will stop at the very first error, contrary to this `Transformer.Mode.Accumulating` exposes a `product` operation that given two independent transformations wrapped in `F` gives us back a tuple wrapped in an `F`, what that means is that each one of the transformations is independent from each other so we're able to accumulate all of the errors produced by these.

For accumulating transformations `ducktape` provides instances for `Either` with any subtype of `Iterable` on the left side, so that eg. `Transformer.Mode.Accumulating[[A] =>> Either[List[String], A]]` is available out of the box.

For fail fast transformations instances for `Option` and `Either` are avaiable out of the box.

#### Automatic fallible transformations

Now for the meat and potatoes of `Fallible Transformers`. To make use of the derivation mechanism that `ducktape` provides we should strive for our model to be modeled in a specific way - with a new nominal type per each validated field, which comes down to... Newtypes!

Let's define a minimalist newtype abstraction that will also do validation (this is a one-time effort that can easily be extracted to a library):

```scala mdoc
abstract class NewtypeValidated[A](pred: A => Boolean, errorMessage: String) {
opaque type Type = A

protected def unsafe(value: A): Type = value

def make(value: A): Either[String, Type] = Either.cond(pred(value), value, errorMessage)

def makeAccumulating(value: A): Either[List[String], Type] =
make(value).left.map(_ :: Nil)

extension (self: Type) {
def value: A = self
}

// these instances will be available in the implicit scope of `Type` (that is, our newtype)
given accumulatingWrappingTransformer: Transformer.Fallible[[a] =>> Either[List[String], a], A, Type] = makeAccumulating(_)

given failFastWrappingTransformer: Transformer.Fallible[[a] =>> Either[String, a], A, Type] = make(_)

given unwrappingTransformer: Transformer[Type, A] = _.value

}
```

Now let's get back to the definition of `ValidatedPerson` and tweak it a little:

```scala mdoc:nest
final case class ValidatedPerson(name: ValidatedPerson.Name, age: ValidatedPerson.Age, socialSecurityNo: ValidatedPerson.SSN)

object ValidatedPerson {
object Name extends NewtypeValidated[String](str => !str.isBlank, "Name should not be blank!")
export Name.Type as Name

object Age extends NewtypeValidated[Int](int => int > 0, "Age should be positive!")
export Age.Type as Age

object SSN extends NewtypeValidated[String](str => str.length > 5, "SSN should be longer than 5!")
export SSN.Type as SSN

}
```

We introduce a newtype for each field, this way we can keep our invariants at compiletime and also let `ducktape` do its thing.

```scala mdoc
// this should trip up our validation
val bad = UnvalidatedPerson(name = "", age = -1, socialSecurityNo = "SOCIALNO")

// this one should pass
val good = UnvalidatedPerson(name = "ValidName", age = 24, socialSecurityNo = "SOCIALNO")
```

Instances of `Transformer.Fallible` wrapped in some type `F` are derived automatically for case classes given that a `Transformer.Mode.Accumulating` instance exists for `F` and all of the fields of the source type have a corresponding counterpart in the destination type and each one of them has an instance of either `Transformer.Fallible` or a total `Transformer` in scope.

```scala mdoc
given Transformer.Mode.Accumulating[[A] =>> Either[List[String], A]] =
Transformer.Mode.Accumulating.either[String, List]

bad.fallibleTo[ValidatedPerson]
good.fallibleTo[ValidatedPerson]
```

and the generated code looks like this:

```scala mdoc:passthrough
import io.github.arainko.ducktape.docs.*

Docs.printCode(bad.fallibleTo[ValidatedPerson])
```

Same goes for instances that do fail fast transformations (you need `Transformer.Mode.FailFast[F]` in scope in this case)

```scala mdoc:nest
given Transformer.Mode.FailFast[[A] =>> Either[String, A]] =
Transformer.Mode.FailFast.either[String]

bad.fallibleTo[ValidatedPerson]
good.fallibleTo[ValidatedPerson]
```

and the generated code looks like this:
```scala mdoc:passthrough
Docs.printCode(bad.fallibleTo[ValidatedPerson])
```

#### Configured fallible transformations
Fallible transformations support a superset of total transformations' configuration options.

##### `Field` config
All of these work the very same way they do in total transformations:
* `Field.const`
* `Field.computed`
* `Field.renamed`
* `Field.allMatching`
* `Field.default`

plus two fallible-specific config options:
* `Field.fallibleConst`
* `Field.fallibleComputed`

which work like so for `Accumulating` transformations:
```scala mdoc:nest
given Transformer.Mode.Accumulating[[A] =>> Either[List[String], A]] =
Transformer.Mode.Accumulating.either[String, List]

bad
.into[ValidatedPerson]
.fallible
.transform(
Field.fallibleConst(_.name, ValidatedPerson.Name.makeAccumulating("ConstValidName")),
Field.fallibleComputed(_.age, unvPerson => ValidatedPerson.Age.makeAccumulating(unvPerson.age + 100))
)
```

and for `FailFast` transformations:
```scala mdoc:nest
given Transformer.Mode.FailFast[[A] =>> Either[String, A]] =
Transformer.Mode.FailFast.either[String]

bad
.into[ValidatedPerson]
.fallible
.transform(
Field.fallibleConst(_.name, ValidatedPerson.Name.make("ConstValidName")),
Field.fallibleComputed(_.age, unvPerson => ValidatedPerson.Age.make(unvPerson.age + 100))
)
```

##### `Arg` config
All of these work the very same way they do in total transformations:
* `Arg.const`
* `Arg.computed`
* `Arg.renamed`

plus two fallible-specific config options:
* `Arg.fallibleConst`
* `Arg.fallibleComputed`

which work like so for `Accumulating` transformations:
```scala mdoc:nest
given Transformer.Mode.Accumulating[[A] =>> Either[List[String], A]] =
Transformer.Mode.Accumulating.either[String, List]

bad
.intoVia(ValidatedPerson.apply)
.fallible
.transform(
Arg.fallibleConst(_.name, ValidatedPerson.Name.makeAccumulating("ConstValidName")),
Arg.fallibleComputed(_.age, unvPerson => ValidatedPerson.Age.makeAccumulating(unvPerson.age + 100))
)
```

and for `FailFast` transformations:
```scala mdoc:nest
given Transformer.Mode.FailFast[[A] =>> Either[String, A]] =
Transformer.Mode.FailFast.either[String]

bad
.intoVia(ValidatedPerson.apply)
.fallible
.transform(
Arg.fallibleConst(_.name, ValidatedPerson.Name.make("ConstValidName")),
Arg.fallibleComputed(_.age, unvPerson => ValidatedPerson.Age.make(unvPerson.age + 100))
)
```

#### Building custom instances of fallible transformers
Life is not always lolipops and crisps and sometimes you need to write a typeclass instance by hand. Worry not though, just like in the case of total transformers, we can easily define custom instances with the help of the configuration DSL (which, let's write it down once again, is a superset of total transformers' DSL).

By all means go wild with the configuration options, I'm too lazy to write them all out here again.
```scala mdoc:nest
given Transformer.Mode.Accumulating[[A] =>> Either[List[String], A]] =
Transformer.Mode.Accumulating.either[String, List]

val customAccumulating =
Transformer
.define[UnvalidatedPerson, ValidatedPerson]
.fallible
.build(
Field.fallibleConst(_.name, ValidatedPerson.Name.makeAccumulating("IAmAlwaysValidNow!"))
)
```

```scala mdoc:nest
given Transformer.Mode.FailFast[[A] =>> Either[String, A]] =
Transformer.Mode.FailFast.either[String]

val customFailFast =
Transformer
.define[UnvalidatedPerson, ValidatedPerson]
.fallible
.build(
Field.fallibleComputed(_.age, uvp => ValidatedPerson.Age.make(uvp.age + 30))
)
```

And for the ones that are not keen on writing out method arguments:
```scala mdoc:nest
given Transformer.Mode.Accumulating[[A] =>> Either[List[String], A]] =
Transformer.Mode.Accumulating.either[String, List]

val customAccumulatingVia =
Transformer
.defineVia[UnvalidatedPerson](ValidatedPerson.apply)
.fallible
.build(
Arg.fallibleConst(_.name, ValidatedPerson.Name.makeAccumulating("IAmAlwaysValidNow!"))
)
```

```scala mdoc:nest
given Transformer.Mode.FailFast[[A] =>> Either[String, A]] =
Transformer.Mode.FailFast.either[String]

val customFailFastVia =
Transformer
.defineVia[UnvalidatedPerson](ValidatedPerson.apply)
.fallible
.build(
Arg.fallibleComputed(_.age, uvp => ValidatedPerson.Age.make(uvp.age + 30))
)
```


### A look at the generated code

To inspect the code that is generated you can use `Transformer.Debug.showCode`, this method will print
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import scala.deriving.Mirror

opaque type ArgBuilderConfig[Source, Dest, ArgSelector <: FunctionArguments] = Unit

opaque type FallibleArgBuilderConfig[F[+x], Source, Dest, ArgSelector <: FunctionArguments] = Unit

object Arg {

@compileTimeOnly("'Arg.const' needs to be erased from the AST with a macro.")
Expand Down Expand Up @@ -40,4 +42,23 @@ object Arg {
ev2: FieldType <:< ArgType
): ArgBuilderConfig[Source, Dest, ArgSelector] = throw NotQuotedException("Arg.renamed")

@compileTimeOnly("'Arg.fallibleConst' needs to be erased from the AST with a macro.")
def fallibleConst[F[+x], Source, Dest, ArgType, ActualType, ArgSelector <: FunctionArguments](
selector: ArgSelector => ArgType,
const: F[ActualType]
)(using
@implicitNotFound("Arg.fallibleConst is only supported for product types but ${Source} is not a product type.")
ev1: Mirror.ProductOf[Source],
ev2: ActualType <:< ArgType
): FallibleArgBuilderConfig[F, Source, Dest, ArgSelector] = throw NotQuotedException("Arg.fallibleConst")

@compileTimeOnly("'Arg.fallibleComputed' needs to be erased from the AST with a macro.")
def fallibleComputed[F[+x], Source, Dest, ArgType, ActualType, ArgSelector <: FunctionArguments](
selector: ArgSelector => ArgType,
f: Source => F[ActualType]
)(using
@implicitNotFound("Arg.fallibleComputed is only supported for product types but ${Source} is not a product type.")
ev1: Mirror.ProductOf[Source],
ev2: ActualType <:< ArgType
): FallibleArgBuilderConfig[F, Source, Dest, ArgSelector] = throw NotQuotedException("Arg.computed")
}
Loading
0