Code Monkey home page Code Monkey logo

ducktape's Introduction

ducktape-logo-32 ducktape 0.2.x

Maven Central

ducktape is a library for boilerplate-less and configurable transformations between case classes and enums/sealed traits for Scala 3. Directly inspired by chimney.

If this project interests you, please drop a šŸŒŸ - these things are worthless but give me a dopamine rush nonetheless.

Installation

libraryDependencies += "io.github.arainko" %% "ducktape" % "0.2.2"

// or if you're using Scala.js or Scala Native
libraryDependencies += "io.github.arainko" %%% "ducktape" % "0.2.2"

NOTE: the version scheme is set to early-semver

You're currently browsing the documentation for ducktape 0.2.x, if you're looking for the 0.1.x docs go here: https://github.com/arainko/ducktape/tree/series/0.1.x

Documentation

Head on over to the docs site!

Motivating example

ducktape is all about painlessly transforming between similiarly structured case classes/enums/sealed traits:

import java.time.Instant
import io.github.arainko.ducktape.*

// imagine this is a wire model of some kind - JSON, protobuf, avro, what have you...
object wire {
  final case class Person(
    firstName: String,
    lastName: String,
    paymentMethods: List[wire.PaymentMethod],
    status: wire.Status,
    updatedAt: Option[Instant],
  )

  enum Status:
    case Registered, PendingRegistration, Removed

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

object domain {
  final case class Person( // <-- fields reshuffled 
    lastName: String,
    firstName: String,
    status: Option[domain.Status], // <-- 'status' in the domain model is optional
    paymentMethods: Vector[domain.Payment], // <-- collection type changed from a List to a Vector
    updatedAt: Option[Instant],
  )

  enum Status:
    case Registered, PendingRegistration, Removed
    case PendingRemoval // <-- additional enum case

  enum Payment:
    case Card(name: String, digits: Long, expires: Instant)
    case PayPal(email: String)
    case Cash
}

val wirePerson: wire.Person = wire.Person(
  "John",
  "Doe",
  List(
    wire.PaymentMethod.Cash,
    wire.PaymentMethod.PayPal("[email protected]"),
    wire.PaymentMethod.Card("J. Doe", 12345, Instant.now)
  ),
  wire.Status.PendingRegistration,
  Some(Instant.ofEpochSecond(0))
)
val domainPerson = wirePerson.to[domain.Person]
// domainPerson: Person = Person(
//   lastName = "Doe",
//   firstName = "John",
//   status = Some(value = PendingRegistration),
//   paymentMethods = Vector(
//     Cash,
//     PayPal(email = "[email protected]"),
//     Card(
//       name = "J. Doe",
//       digits = 12345L,
//       expires = 2024-03-10T00:21:33.860394305Z
//     )
//   ),
//   updatedAt = Some(value = 1970-01-01T00:00:00Z)
// )
Click to see the generated code
  (({
    val paymentMethods$2: Vector[Payment] = MdocApp.this.wirePerson.paymentMethods
      .map[Payment]((src: PaymentMethod) =>
        if (src.isInstanceOf[Card])
          new Card(
            name = src.asInstanceOf[Card].name,
            digits = src.asInstanceOf[Card].digits,
            expires = src.asInstanceOf[Card].expires
          )
        else if (src.isInstanceOf[PayPal]) new PayPal(email = src.asInstanceOf[PayPal].email)
        else if (src.isInstanceOf[Cash.type]) MdocApp.this.domain.Payment.Cash
        else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
      )
      .to[Vector[Payment]](iterableFactory[Payment])
    val status$2: Some[Status] = Some.apply[Status](
      if (MdocApp.this.wirePerson.status.isInstanceOf[Registered.type]) MdocApp.this.domain.Status.Registered
      else if (MdocApp.this.wirePerson.status.isInstanceOf[PendingRegistration.type])
        MdocApp.this.domain.Status.PendingRegistration
      else if (MdocApp.this.wirePerson.status.isInstanceOf[Removed.type]) MdocApp.this.domain.Status.Removed
      else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
    )
    new Person(
      lastName = MdocApp.this.wirePerson.lastName,
      firstName = MdocApp.this.wirePerson.firstName,
      status = status$2,
      paymentMethods = paymentMethods$2,
      updatedAt = MdocApp.this.wirePerson.updatedAt
    )
  }: Person): Person)

But now imagine that your wire model differs ever so slightly from your domain model, maybe the wire model's PaymentMethod.Card doesn't have the name field for some inexplicable reason...

object wire {
  final case class Person(
    firstName: String,
    lastName: String,
    paymentMethods: List[wire.PaymentMethod],
    status: wire.Status,
    updatedAt: Option[Instant],
  )

  enum Status:
    case Registered, PendingRegistration, Removed

  enum PaymentMethod:
    case Card(digits: Long, expires: Instant) // <-- poof, 'name' is gone
    case PayPal(email: String)
    case Cash
}

val wirePerson: wire.Person = wire.Person(
  "John",
  "Doe",
  List(
    wire.PaymentMethod.Cash,
    wire.PaymentMethod.PayPal("[email protected]"),
    wire.PaymentMethod.Card(12345, Instant.now)
  ),
  wire.Status.PendingRegistration,
  Some(Instant.ofEpochSecond(0))
)

...and when you try to transform between these two representations the compiler now yells at you.

val domainPerson = wirePerson.to[domain.Person]
// error:
// No field 'name' found in MdocApp0.this.wire.PaymentMethod.Card @ Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name
// given Transformer[Int, String] = int => int.toString
//                                 ^

Now onto dealing with that, let's first examine the error message:

No field 'name' found in MdocApp0.this.wire.PaymentMethod.Card @ Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name

especially the part after @:

Person.paymentMethods.element.at[MdocApp0.this.domain.Payment.Card].name

the thing above is basically a path to the field/subtype under which ducktape was not able to create a transformation, these are meant to be copy-pastable for when you're actually trying to fix the error, eg. by setting the name field to a constant value:

val domainPerson = 
  wirePerson
    .into[domain.Person]
    .transform(Field.const(_.paymentMethods.element.at[domain.Payment.Card].name, "CONST NAME"))
// domainPerson: Person = Person(
//   lastName = "Doe",
//   firstName = "John",
//   status = Some(value = PendingRegistration),
//   paymentMethods = Vector(
//     Cash,
//     PayPal(email = "[email protected]"),
//     Card(
//       name = "CONST NAME",
//       digits = 12345L,
//       expires = 2024-03-10T00:21:33.864184449Z
//     )
//   ),
//   updatedAt = Some(value = 1970-01-01T00:00:00Z)
// )
Click to see the generated code
  {
    val AppliedBuilder_this: AppliedBuilder[Person, Person] = into[Person](MdocApp2.this.wirePerson1)[MdocApp2.this.domain.Person]

    {
      val value$proxy3: Person = AppliedBuilder_this.inline$value

      {
        val paymentMethods$4: Vector[Payment] = value$proxy3.paymentMethods
          .map[Payment]((src: PaymentMethod) =>
            if (src.isInstanceOf[Card])
              new Card(name = "CONST NAME", digits = src.asInstanceOf[Card].digits, expires = src.asInstanceOf[Card].expires)
            else if (src.isInstanceOf[PayPal]) new PayPal(email = src.asInstanceOf[PayPal].email)
            else if (src.isInstanceOf[Cash.type]) MdocApp2.this.domain.Payment.Cash
            else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
          )
          .to[Vector[Payment]](iterableFactory[Payment])
        val status$4: Some[Status] = Some.apply[Status](
          if (value$proxy3.status.isInstanceOf[Registered.type]) MdocApp2.this.domain.Status.Registered
          else if (value$proxy3.status.isInstanceOf[PendingRegistration.type]) MdocApp2.this.domain.Status.PendingRegistration
          else if (value$proxy3.status.isInstanceOf[Removed.type]) MdocApp2.this.domain.Status.Removed
          else throw new RuntimeException("Unhandled case. This is most likely a bug in ducktape.")
        )
        new Person(
          lastName = value$proxy3.lastName,
          firstName = value$proxy3.firstName,
          status = status$4,
          paymentMethods = paymentMethods$4,
          updatedAt = value$proxy3.updatedAt
        )
      }: Person
    }: Person
  }

ducktape's People

Contributors

arainko avatar gregor-i avatar saeltz avatar scala-steward avatar wojciechmazur avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

ducktape's Issues

Support for non-singleton enums cases and sealed trait children

The following example fails to compile, for transformers are only derived for singleton enum cases and case object children of sealed traits

//> using scala "3.3.0-RC4"
//> using lib "io.github.arainko::ducktape:0.1.7"
import io.github.arainko.ducktape.*

sealed trait X
object X:
  case object A extends X
  case class B(b: String) extends X

sealed trait Y
object Y:
  object A extends Y
  case class B(b: String) extends Y

enum XE:
  case A
  case B(a: String)
enum YE:
  case A
  case B(a: String)

@main def main =
  implicitly[Transformer[X, Y]] // Cannot materialize singleton for B
  implicitly[Transformer[XE, YE]] // Cannot materialize singleton for B

Are there any technical limitations or design considerations that make support of these cases impossible or undesirable?

`Arg` selectors with IDE hints in Scala 3.2 thanks to refinements in `Selectable` being autocompleted now

With the arrival of Scala 3.2 a new and cool feature was added - suggesting the names of type refinements, eg.

val refined: Any { val int: Int; val string: String } = ???

if you type refined. you'll be given a list of 'fields' you can access due to that refinement. We can make this mechanism replace the current Arg selectors that use Dynamic and get a nice UX boost for this part of the codebase.

One of the quirks is a new bug introduced in 3.2 that doesn't allow for anonymous subtypes to refer to their fields with inline methods, which is tracked here but I don't think it is a showstopper for the upgrade.

Compile-time exception on 0.1.6 when using Scala 3.3.0-RC5 with `-Wunused:all`

Tried to upgrade from 0.1.4 to 0.1.6 and getting the following exception, presumably related to #48.

[error] ## Exception when compiling 102 sources to /Users/.../target/scala-3.3.0-RC5/classes
[error] dotty.tools.dotc.core.TypeError$$anon$1: Toplevel definition inline$Transformations$i1 is defined in
[error]   /Users/chuwy/Library/Caches/Coursier/v1/https/repo1.maven.org/maven2/io/github/arainko/ducktape_3/0.1.6/ducktape_3-0.1.6.jar(io/github/arainko/ducktape/fallibleSyntax$package.class)
[error] and also in
[error]   /Users/chuwy/Library/Caches/Coursier/v1/https/repo1.maven.org/maven2/io/github/arainko/ducktape_3/0.1.6/ducktape_3-0.1.6.jar(io/github/arainko/ducktape/syntax$package.class)
[error] One of these files should be removed from the classpath.

Apart from above lines I don't see any other useful info, just a bunch of my files listed.

Scalac 3.3.0-RC5, 0.1.5 compiles fine.

Optimize the number of `Transformer` instances during method expansions

Pretty much the same as #16 but for via and intoVia, the generated code is kind of different due to beta-reduction of the lambda.
Example:

import io.github.arainko.ducktape.*

final case class WrappedInt(value: Int) extends AnyVal

final case class WrappedString(value: String) extends AnyVal

final case class TransformedWithSubtransformations[A](
  int: WrappedInt,
  string: WrappedString,
  list: List[WrappedInt],
  option: Option[A]
)

def method[A](option: Option[A], list: List[WrappedInt], string: WrappedString, int: WrappedInt) =
      TransformedWithSubtransformations[A](int, string, list, option)

val value = Input(1, "a", List(1, 2, 3), Some(4))

val actual = 
    DebugMacros.code {
       value.via(method[WrappedInt])
    }

The output is:

(({
 val option$proxy3: Option[WrappedInt] = 
  given_Transformer_Option_Option[Int, WrappedInt]((((from: Int) => (new WrappedInt(from): WrappedInt)): ToAnyVal[Int, WrappedInt])).transform(value.option)
val list$proxy3: List[WrappedInt] = 
  given_Transformer_SourceCollection_DestCollection[Int, WrappedInt, List, List]((((`fromā‚‚`: Int) => (new WrappedInt(`fromā‚‚`): WrappedInt)): ToAnyVal[Int, WrappedInt]), iterableFactory[WrappedInt]).transform(value.list)
val string$proxy3: WrappedString = 
  (((`fromā‚ƒ`: String) => (new WrappedString(`fromā‚ƒ`): WrappedString)): ToAnyVal[String, WrappedString]).transform(value.string)
val int$proxy3: WrappedInt = 
  (((`fromā‚„`: Int) => (new WrappedInt(`fromā‚„`): WrappedInt)): ToAnyVal[Int, WrappedInt]).transform(value.int)

method[WrappedInt](option$proxy3, list$proxy3, string$proxy3, int$proxy3)
}: Return): Return)

We can get rid of the Transformer.ToAnyVal, Transformer.FromAnyVal and Transformer.ForProduct instances by 'inlining' their insides, eg. for this line:

val string$proxy3: WrappedString = 
  (((`fromā‚ƒ`: String) => (new WrappedString(`fromā‚ƒ`): WrappedString)): ToAnyVal[String, WrappedString]).transform(value.string)

The rewritten version should just look like this:

val string$proxy3: WrappedString = new WrappedString(value.string)

Type Mismatch Error for the same type

Hi @arainko,
First of all thank you for this awesome library! It is a godsend that is helping us migrate to Scala 3 in a more painless manner šŸ˜Œ

I've just started using it for some of our case class transformations and have encountered a curious issue. Seems to have to do with companion objects or objects in general.
Let's imagine I have these case classes:

case class A(anotherCaseClass: AnotherCaseClass)
object A:
  case class AnotherCaseClass(name: String)
  case class B(anotherCaseClass: AnotherCaseClass)

AnotherCaseClass can be simple or complex, it doesn't matter. If I then do:

val a = A(AnotherCaseClass("test"))
a.to[B]

I get the following compilation error:

[error] -- [E007] Type Mismatch Error: /some/file:95:11 
[error] 95 |    a.to[B]
[error]    |           ^
[error]    |           Found:    A.AnotherCaseClass | dest
[error]    |           Required: A.AnotherCaseClass
[error]    |----------------------------------------------------------------------------
[error]    |Inline stack trace
[error]    |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
[error]    |This location contains code that was inlined from LiftTransformationModule.scala:15
[error]    |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
[error]    |This location contains code that was inlined from LiftTransformationModule.scala:15
[error]    |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
[error]    |This location contains code that was inlined from LiftTransformationModule.scala:15

I'm using scala 3.2.1 and ducktape version 0.1.1.

Thanks again. If I get some time I'll try to research this problem deeper by looking into ducktape's codebase though metaprogramming is something I'm not very familiar with yet.

Mapping field names based on a constant function

Hi there šŸ‘‹ First, thank you very much for your work on this library, it's honestly amazing!

I'm not sure whether this is already supported or not, but I was wondering whether it's possible to map from one enum / case class to another applying a constant function to map the field names.

Example:

enum size:
  case small, large

enum Size:
  case Small, Large

In such case, one could transform size -> Size by mapping the field names using the .capitalize method.

Is something like this currently supported by the library? One usecase for this is that API models might use different notations (kebab-case vs snake-case, etc) than DAOs. It would be amazing if there was a way to do this transformation without having to explicitly map all the fields manually!

FallibleTransformer derivation for coproducts

Hello,

I am currently trying to migrate an application from chimney to ducktape.
The application uses TransformerF to validate an outer model towards an inner model. The validation mostly checks for mandatory fields.


To demonstrate the issue, I created a minimal example:

type ErrorsOrResult = [X] =>> Either[List[String], X]

given Transformer.Mode.Accumulating[ErrorsOrResult] =
  Transformer.Mode.Accumulating.either[String, List]

implicit def deriveMandatoryOptionTransformer[A, B](
  implicit transformer: FallibleTransformer[ErrorsOrResult, A, B],
): FallibleTransformer[ErrorsOrResult, Option[A], B] = {
  case Some(a) => transformer.transform(a)
  case None => Left(List("Missing required field"))
}

sealed trait Outer
object Outer {
  case object Empty extends Outer
  final case class Element(cmd: Option[Int]) extends Outer
}

sealed trait Inner
object Inner {
  case object Empty extends Inner
  final case class Element(cmd: Int) extends Inner
}

Deriving FallibleTransformer for the Element case works fine:

implicitly[FallibleTransformer[ErrorsOrResult, Outer.Element, Inner.Element]]

But not for the sum type (Inner vs. Outer) and the Empty case:

implicitly[FallibleTransformer[ErrorsOrResult, Outer, Inner]]
implicitly[FallibleTransformer[ErrorsOrResult, Outer.Empty.type, Inner.Empty.type]]

Should this transformation / validation be supported? Is there something wrong with the way I am using the fallable transformers?

[0.2.0] allow configuration of nested products/coproducts

So currently ducktape is only able to configure fields/cases of the top-level entity it operates on eg.

case class Person(name: String, address: Address)
case class Address(city: String)

so let's imagine that we want to transform Person into something else but the fields in the Address case class don't quite align. In ducktape 0.1.x to configure fields of the Address field we'd have to do a lot of gymnastics (a separate and explicit Transformer for Address -> DestTyle, an ugly transformation in Field.computed(_.address, _.address.into[DestType]...), generally it is not a good experience.

In ducktape 0.2.x we should be able to just do

Field.computed(_.address.city, _.doWhateverYouNeedToDoHere)

What's more we should also be able to configure fields inside coproduct cases with something like:

Field.computed(_.someCoproduct.at[SomeCoproduct.Case1].field1, _.doSomethingHere)

or even configure cases themselves with

Case.const(_.someCoproduct.at[SomeCoproduct.Case1], SomeCoproduct.Case1(12345))

that'd also mean 0.2.x should deprecate the old way of configuring Cases (but still keep them for sourcecompat until at least 0.3.x)

Setup `Scala.js` and `Scala Native` releases

This whole lib is just macros all the way down (and some plain classes and traits) so I guess targeting Scala.js and Scala Native should be pretty straight forward (?) but let's not jinx it

Creating generic transformers

Hello! I have one question regarding transformers. I have implemented Value Object pattern using opaque types so my validated data looks like this:

opaque type TeamName = String
object TeamName:
extension (value: TeamName) def value: String = value
def create(value: String): IO[InvalidTeamNameError, TeamName] =
ZIO.cond(value.inLengthRange(2 to 50), value, InvalidTeamNameError(value))

The problem that I have is that I canā€™t define generic transformers for these type of data because even if I extract ā€˜valueā€™ method to common interface that is implemented by all necessary structure after, the generic transformers donā€™t work.

Screenshot 2023-09-20 at 19 30 17 Screenshot 2023-09-20 at 19 30 08

Is it somehow possible to create a generic transformer for this type of structures? I don't want to create a custom transformer for every opaque type.

PS: I've managed to resolve it by using case class and AnyVal as it is said in documentation but it would like to have a similar solution without overhead.

betweenMaps?

It's great that ducktape has built-in support for collections, but it looks like this doesn't work for Map[K, V]. I needed to validate both the keys and the values of a Map using an accumulating transformer, so I implemented this as a special case, but I'm guessing that there might be a more generic way to do it by considering the Map as a collection of tuples. Is there any chance you might add support for this?

Allow merging case classes

I hope I didn't miss a way of doing this already, but it would be great if there was a possibility to merge case classes.

Example:

case class Person(name: String)
case class Address(street: String)
case class PersonWithAddress(name: String, street: String)

Person("John von Neumann")
  .into[PersonWithAddress]
  .transform(Fields.from(Address("1 Einstein Drive")))

On-fly transformations aka row polymorphism

I recently got an idea for library, but after I started implementing it - I see there's a lot of machinery in ducktape that looks very similar. Maybe ducktape itself can benefit from including this feature.

Basically, the idea is to implement row polymorphism via givens:

case class Person(name: String, age: Int)
case class Student(name: String, age: Int, university: String)

def checkin[P](p: P)(using Row[Person, P]): IO[P] =
  doTheStuff(p.name, p.age)

checkin(Student("Joe", 27, "Oxford"))        // ok
checkin(Person("Bob", 21))                   // ok

Let me know if you think it's out of scope of ducktape.

Support default parameters in `case class` to `case class`

Given for example the following code:

final case class TestClass(str: String, int: Int)
final case class TestClassWithAdditionalList(int: Int, str: String, additionalArg: List[String] = Nil)
val testClass = TestClass("str", 1)
testClass.to[TestClassWithAdditionalList]

It will fail with No field named 'additionalArg' found in TestClass.

That's correct but additionalArg has a default parameter that could be used to fill that field. I could of course specify that manually but that's not the point of using ducktape at all, is it?

By the way: Thanks for your amazing work on this!
If you happen to point me to where and also maybe guide me how to implement it, I'd give it a try myself.
I'm currently exploring using ducktape to automatically create readers/writers for model classes and ScalaPB generated case classes to decouple internal and external models. In Protobuf there's almost always a default parameter.

Package information in compilation error messages

Hi,
The compilation error messages are fantastic. We're spoilt compared to most macro errors just a few years ago. I have what is I hope a small suggestion for a tweak that I know would help me read the errors faster.

I've got a situation where I'm mapping to the same type name in multiple packages and at first glance I didn't spot that the compilation error message for No instance of Transformer[X, Y] was found. was about a type in a different package to the one I was working on. It would be really helpful if more package information was included in the compilation error messages.

There is actually some already. The first failure to find a Transformer is fully qualified. eg

[error]     |Neither an instance of Transformer[com.acme.wire.Event, com.acme.domain.Event] was found nor are 'Event' 'Event' 
[error]     |singletons with the same name.

However, I usually go straight to the last line of the error message as that is always the root cause. eg

[error]     |Compiler supplied explanation (may or may not be helpful):
[error]     |No field named 'make' found in Vehicle

If Vehicle in this case was fully qualified (eg com.acme.wire.Vehicle) as well this would be really helpful i think.

Fix implicit resolution issues with `FromAnyVal` and `ToAnyVal`

repro:

import io.github.arainko.ducktape.*

object Repro {
  final case class Rec[A](value: A, rec: Option[Rec[A]])

  given rec[A,B](using Transformer[A, B]): Transformer[Rec[A], Rec[B]] = Transformer.define[Rec[A], Rec[B]].build()

  rec[Int, Option[Int]] // Failed to fetch the wrapped field name of scala.Int
}

More info about this issue here: scala/scala3#16793 (comment)
Derived transformers should probably have lower priority than non derived ones in general.

Ambiguous given instances for Option coproduct

Hi,

I'm unable to transform from case class Option to another case class Option (for example Option[ClassA] -> Option[ClassB]). Compilation fails with error:

[error] 20 |  val b = a.to[Option[Yyy]]
[error]    |                           ^
[error]    |Ambiguous given instances: both given instance betweenOptions in object Transformer and given instance betweenCoproducts in object Transformer match type io.github.arainko.ducktape.Transformer[Option[web.Main.Xxx], 
[error]    |  Option[web.Main.Yyy]
[error]    |] of parameter x$2 of method to in package io.github.arainko.ducktape

Example code:
https://scastie.scala-lang.org/9z8Pyt5fSNGZdNRkY2Dv3g

Replacement for `chimney's` partial transformations

I was originally not going to tackle this but I've found a really compelling use case in conjunction with newtypes that need to be validated.

It works perfectly because we can create an instance of a PartialTransformer (TransformerF from chimney) in the companion object of such a newtype for free which will make composing case classes made up of such newtypes a one liner instead of eg. (value1, value2, value3, ...).mapN(CaseClass.apply) given that a case class/method we use to transform to has the correct fields - this is especially helpful when working with generated code (eg. guardrail, smithy4s etc.)

Optimize the number of `Transformer` instances during product transformations

example:

import io.github.arainko.ducktape.*
import io.github.arainko.ducktape.Transformer.ForProduct

case class Person(int: Int, str: String, inside: Inside)
case class Person2(int: Int, str: String, inside: Inside2)

case class Inside(str: String, int: Int, inside: EvenMoreInside)
case class Inside2(int: Int, str: String, inside: EvenMoreInside2)

case class EvenMoreInside(str: String, int: Int)
case class EvenMoreInside2(str: String, int: Int)

val transformed =  Person(1, "2", Inside("2", 1, EvenMoreInside("asd", 3))).to[Person2]

In the current version the generated code is kind of allocation heavy in regards to transformers, let's take a look at what is generated (Output of DebugMacros.code):

to[Person](Person.apply(1, "2", Inside.apply("2", 1, EvenMoreInside.apply("asd", 3))))[Person2](
    (((from: Person) => 
      (new Person2(
        int = from.int,
        str = from.str,
        inside = (
          ((`fromā‚‚`: Inside) => 
            (new Inside2(
              int = `fromā‚‚`.int,
              str = `fromā‚‚`.str, 
              inside = (
                ((`fromā‚ƒ`: EvenMoreInside) => 
                  (new EvenMoreInside2(
                    str = `fromā‚ƒ`.str,
                    int = `fromā‚ƒ`.int
                  ): EvenMoreInside2)): ForProduct[EvenMoreInside, EvenMoreInside2]
              ).transform(`fromā‚‚`.inside)
            ): Inside2)): ForProduct[Inside, Inside2]).transform(from.inside)): Person2)): ForProduct[Person, Person2])
    )

We can see that for each 'sub-transformation' we allocate a new transformer to then just call transform and get the result, we can simplify it by extracting the inside of the Transformer lambda and calling it directly, so after optimizations this code should look like this:

to[Person](Person.apply(1, "2", Inside.apply("2", 1, EvenMoreInside.apply("asd", 3))))[Person2]((((from: Person) =>
    (new Person2(
      int = from.int,
      str = from.str,
      inside = new Inside2(
        int = from.inside.int,
        str = from.inside.str,
        inside = new EvenMoreInside2(
          str = from.inside.inside.str,
          int = from.inside.inside.int
        )
      )
    ): Person2)
  ): ForProduct[Person, Person2]))

So pretty much something we'd write by hand, this optimization can also be done on ToAnyVal and FromAnyVal transformers.

When a `case class` has a companion object with at least a single `val` inside `Transformer` optimizations for nested transformations don't kick in

Let's say we have a set of case classes and we summon a Transformer for the topelevel ones:

final case class Id(value: String, in: Inside)
final case class Inside(str: String)

final case class Id2(value: String)
final case class Inside2(str: String, in: Inside2)

// exactly what one would expect, no nested transformers are created and the transformation for `Inside2` is 'inlined'
// (inline$make$i1[Id, Id2](ForProduct)((((source: Id) => new Id2(value = source.value, in = new Inside2(str = source.in.str))): Transformer[Id, Id2])): ForProduct[Id, Id2])
Transformer.Debug.showCode(summon[Transformer[Id, Id2]])

now let's tweak the definitions a tiny bit:

final case class Id(value: String, in: Inside)

final case class Inside(str: String)
object Inside {
  val a = 1
}

final case class Id2(value: String, in: Inside2)
final case class Inside2(str: String)
Transformer.Debug.showCode(summon[Transformer[Id, Id2]])
/*
(inline$make$i1[Id, Id2](ForProduct)((((source: Id) => new Id2(value = source.value, in = {
  val x$1$proxy2: Product {
    type MirroredMonoType >: Inside <: Inside
    type MirroredType >: Inside <: Inside
    type MirroredLabel >: "Inside" <: "Inside"
    type MirroredElemTypes >: *:[String, EmptyTuple] <: *:[String, EmptyTuple]
    type MirroredElemLabels >: *:["str", EmptyTuple] <: *:["str", EmptyTuple]
  } = Inside.$asInstanceOf$[Product {
    type MirroredMonoType >: Inside <: Inside
    type MirroredType >: Inside <: Inside
    type MirroredLabel >: "Inside" <: "Inside"
    type MirroredElemTypes >: *:[String, EmptyTuple] <: *:[String, EmptyTuple]
    type MirroredElemLabels >: *:["str", EmptyTuple] <: *:["str", EmptyTuple]
  }]

  (inline$make$i1[Inside, Inside2](ForProduct)((((`sourceā‚‚`: Inside) => new Inside2(str = `sourceā‚‚`.str)): Transformer[Inside, Inside2])): ForProduct[Inside, Inside2])
}.transform(source.in))): Transformer[Id, Id2])): ForProduct[Id, Id2])
*/

After adding an arbitrary val to Inside's companion Inside's Mirror now appears in the inlined code (even tho it's not spliced into the AST anywhere!) and introduces another Inlined node which throws off LiftedTransformation's optimizations.

The resulting code should get rid of the excessive instance of Transformer.ForProduct for Inside and inline the transformation. The mirror needs to stay there for better or for worse as we can't be sure about it not being used inside the AST (maybe erased definitions will save us here once they land?)

traverseCollection is not working for Map after the migration to 0.2

When trying to update the library version to 0.2 I have an error

    Exception in thread "zio-fiber-89" java.lang.ClassCastException: class scala.collection.immutable.$colon$colon cannot be cast to class scala.collection.immutable.Map (scala.collection.immutable.$colon$colon and scala.collection.immutable.Map are in unnamed module of loader 'app')
        at ... .ZioTransformer.zio.$anon.traverseCollection(ZioTransformer.scala:19)

Now I have traverseCollection implemented as:

def traverseCollection[A, B, AColl <: Iterable[A], BColl <: Iterable[B]](
          collection: AColl,
          transformation: A => IO[E, B]
      )(using factory: Factory[B, BColl]): IO[E, BColl] = ZIO.foreach(collection)(transformation.apply).asInstanceOf

Before the migration it was implemented in this way:

def traverseCollection[A, B, AColl[x] <: Iterable[x], BColl[x] <: Iterable[x]](
          collection: AColl[A]
      )(using
          transformer: FallibleTransformer[[A] =>> IO[E, A], A, B],
          factory: Factory[B, BColl[B]]
      ): IO[E, BColl[B]] = ZIO.foreach(collection)(transformer.transform).asInstanceOf

Any suggestion what could be the problem?
I can also pass A and B objects structure if it can help.

Bad owners reported under `-Xcheck-macros` for Accumulating transformations of products with collections with other products containing fallible transformations

Under -Xcheck-macros this:

case class Positive(value: Int)

object Positive {
  given transformer: Transformer.Fallible[[a] =>> Either[List[String], a], Int, Positive] = a => Right(Positive(a))
}

final case class SourceToplevel1(level1: List[SourceLevel1])
final case class SourceLevel1(int: Int)

final case class DestToplevel1(level1: List[DestLevel1])
final case class DestLevel1(int: Positive)

val source: SourceToplevel1 = ???

given F: Mode.Accumulating.Either[String, List] with {}


object Playground {
  source.fallibleTo[DestToplevel1]
}

fails with:

[info] compiling 49 Scala sources to /home/aleksander/Repos/ducktape/ducktape/.jvm/target/scala-3.3.2/classes ...
Warning: mocking up superclass for module class internal
[error] -- Error: /home/aleksander/Repos/ducktape/ducktape/src/main/scala/io/github/arainko/ducktape/Playground.scala:21:19 
[error] 21 |  source.fallibleTo[DestToplevel1]
[error]    |  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[error]    |Malformed tree was found while expanding macro with -Xcheck-macros.
[error]    |               |The tree does not conform to the compiler's tree invariants.
[error]    |               |
[error]    |               |Macro was:
[error]    |               |scala.quoted.runtime.Expr.splice[scala.util.Either[scala.collection.immutable.List[scala.Predef.String], io.github.arainko.ducktape.DestToplevel1]](((contextual$1: scala.quoted.Quotes) ?=> io.github.arainko.ducktape.internal.FallibleTransformations.inline$createTransformationBetween[[A >: scala.Nothing <: scala.Any] => scala.util.Either[scala.collection.immutable.List[scala.Predef.String], A], io.github.arainko.ducktape.SourceToplevel1, io.github.arainko.ducktape.DestToplevel1](scala.quoted.runtime.Expr.quote[io.github.arainko.ducktape.SourceToplevel1](io.github.arainko.ducktape.Playground$package.source).apply(using contextual$1), scala.quoted.runtime.Expr.quote[io.github.arainko.ducktape.Mode[[A >: scala.Nothing <: scala.Any] => scala.util.Either[scala.collection.immutable.List[scala.Predef.String], A]]](io.github.arainko.ducktape.F).apply(using contextual$1), scala.quoted.runtime.Expr.quote["transformation" | "definition"]("transformation").apply(using contextual$1), scala.quoted.runtime.Expr.quote[scala.collection.immutable.Seq[io.github.arainko.ducktape.Field$package.Field.Fallible[[A >: scala.Nothing <: scala.Any] => scala.util.Either[scala.collection.immutable.List[scala.Predef.String], A], io.github.arainko.ducktape.SourceToplevel1, io.github.arainko.ducktape.DestToplevel1] | io.github.arainko.ducktape.Case$package.Case.Fallible[[A >: scala.Nothing <: scala.Any] => scala.util.Either[scala.collection.immutable.List[scala.Predef.String], A], io.github.arainko.ducktape.SourceToplevel1, io.github.arainko.ducktape.DestToplevel1]]]().apply(using contextual$1))(scala.quoted.Type.of[[A >: scala.Nothing <: scala.Any] => scala.util.Either[scala.collection.immutable.List[scala.Predef.String], A]](contextual$1), scala.quoted.Type.of[io.github.arainko.ducktape.SourceToplevel1](contextual$1), scala.quoted.Type.of[io.github.arainko.ducktape.DestToplevel1](contextual$1), contextual$1)))
[error]    |               |
[error]    |               |The macro returned:
[error]    |               |io.github.arainko.ducktape.F.map[scala.collection.immutable.List[io.github.arainko.ducktape.DestLevel1], io.github.arainko.ducktape.DestToplevel1](io.github.arainko.ducktape.F.traverseCollection[io.github.arainko.ducktape.SourceLevel1, io.github.arainko.ducktape.DestLevel1, [A >: scala.Nothing <: scala.Any] => scala.collection.Iterable[A][io.github.arainko.ducktape.SourceLevel1], scala.collection.immutable.List[io.github.arainko.ducktape.DestLevel1]](io.github.arainko.ducktape.Playground$package.source.level1, ((a: io.github.arainko.ducktape.SourceLevel1) => io.github.arainko.ducktape.F.map[io.github.arainko.ducktape.Positive, io.github.arainko.ducktape.DestLevel1](io.github.arainko.ducktape.Positive.transformer.transform(a.int), ((value: io.github.arainko.ducktape.Positive) => value match {
[error]    |  case int =>
[error]    |    new io.github.arainko.ducktape.DestLevel1(int = int)
[error]    |}))))(scala.collection.immutable.List.iterableFactory[io.github.arainko.ducktape.DestLevel1]), ((`valueā‚‚`: scala.collection.immutable.List[io.github.arainko.ducktape.DestLevel1]) => `valueā‚‚` match {
[error]    |  case level1 =>
[error]    |    new io.github.arainko.ducktape.DestToplevel1(level1 = level1)
[error]    |}))
[error]    |               |
[error]    |               |Error:
[error]    |               |assertion failed: bad owner; method $anonfun has owner method $anonfun, expected was value <local Playground$>
[error]    |owner chain = method $anonfun, method $anonfun, value macro, value <local Playground$>, object Playground, package io.github.arainko.ducktape, package io.github.arainko, package io.github, package io, package <root>, ctxOwners = value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, value <local Playground$>, object Playground, package io.github.arainko.ducktape, package <root>, package <root>, package <root>, package <root>, package <root>, package <root>,  <none>,  <none>,  <none>,  <none>,  <none>
[error]    |               |
[error]    |stacktrace available when compiling with `-Ydebug`
[error]    |               |
[error]    |----------------------------------------------------------------------------
[error]    |Inline stack trace
[error]    |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
[error]    |This location contains code that was inlined from FallibleTransformations.scala:13
[error] 13 |  ) = ${ createTransformationBetween[F, A, B]('source, 'F, 'transformationSite, 'configs) }
[error]    |      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[error]    |- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
[error]    |This location contains code that was inlined from FallibleTransformations.scala:13
[error] 18 |  inline def fallibleTo[Dest]: F[Dest] = FallibleTransformations.between[F, Source, Dest](value, F, "transformation")
[error]    |                                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[error]     ----------------------------------------------------------------------------
[error] one error found
[error] (Compile / compileIncremental) Compilation failed
[error] Total time: 6 s, completed Feb 26, 2024, 9:03:58 PM
sbt:ducktape>

The AST from the error but pretty printed:

io.github.arainko.ducktape.Playground.F
    .map[scala.collection.immutable.List[io.github.arainko.ducktape.DestLevel1], io.github.arainko.ducktape.DestToplevel1](
      io.github.arainko.ducktape.Playground.F.traverseCollection[
        io.github.arainko.ducktape.SourceLevel1,
        io.github.arainko.ducktape.DestLevel1,
        [A >: scala.Nothing <: scala.Any] =>> scala.collection.Iterable[A][io.github.arainko.ducktape.SourceLevel1],
        scala.collection.immutable.List[io.github.arainko.ducktape.DestLevel1]
      ](
        io.github.arainko.ducktape.Playground$package.source.level1,
        (a: io.github.arainko.ducktape.SourceLevel1) =>
          io.github.arainko.ducktape.Playground.F.map[io.github.arainko.ducktape.Positive, io.github.arainko.ducktape.DestLevel1](
            io.github.arainko.ducktape.Positive.transformer.transform(a.int),
            (value: io.github.arainko.ducktape.Positive) =>
              value match {
                case int =>
                  new io.github.arainko.ducktape.DestLevel1(int = int)
              }
          )
      )(scala.collection.immutable.List.iterableFactory[io.github.arainko.ducktape.DestLevel1]),
      (`valueā‚‚`: scala.collection.immutable.List[io.github.arainko.ducktape.DestLevel1]) =>
        `valueā‚‚` match {
          case level1 =>
            new io.github.arainko.ducktape.DestToplevel1(level1 = level1)
        }
    )

More context:

  • only seems to fail for Collection transformations with products containing other fallible transformations inside, i.e. this works:
final case class SourceToplevel1(level1: List[Int])

final case class DestToplevel1(level1: List[Positive])

val source: SourceToplevel1 = ???
given F: Mode.Accumulating.Either[String, List] with {}


object Playground {
  source.fallibleTo[DestToplevel1]
}

Support tuple to product (and back) transformations

Usage examples:

case class Numbers(int1: Int, int2: Int, int3: Int)
val toTuple = (???: Numbers).to[(Int, Int, Int)]
val fromTuple = (1, 2. 3).to[Numbers]
case class NumbersWithLessFields(int1: Int, int2: Int)
(1, 2, 3, 4, 5, 6).to[NumbersWithLessFields] // should equal NumbersWithLessFields(1, 2)

Move away from the module pattern for macros

Stashing instances of Quotes in a class field can be problematic due to how quoted code works, we can end up with a quote that has a wrong owners which will make the compiler yell at us (this has already happened before), moving away from modules will also allow for more testability of macros.

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    šŸ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. šŸ“ŠšŸ“ˆšŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ā¤ļø Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.