diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/Regional.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/Regional.scala index ee3e5336..352a1d2b 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/Regional.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/Regional.scala @@ -6,6 +6,32 @@ type Regional[A] object Regional { extension [F[a, b] <: (Case[a, b] | Field[a, b]), A, B, C](self: F[A, B] & Regional[C]) { + + /** + * Constrains a config option to a certain region (i.e. the option will apply to the transformations 'underneath' the selected field/case): + * + * {{{ + * case class Person(name: String, age: Int, info: Person.Info) + * object Person { + * case class Info(accountNo: String, email: String) + * } + * + * case class ReshuffledPerson(age: Int, name: String, extra: Option[String], info: ReshuffledPerson.Info) + * + * object ReshuffledPerson { + * case class Info(accountNo: String, email: String, extraInfo: Option[String]) + * } + * + * val person: Person = Person("Name", 26, Person.Info("123", "email@example.com")) + * person + * .into[ReshuffledPerson] + * .transform( + * Field.const(_.extra, Some("filled out with const since fallback won't be used here now")) + * Field.fallbackToNone.regional(_.info) + * ) + * // ReshuffledPerson(26, "Name", Some("filled out with const since fallback won't be used here now"), ReshuffledPerson.Info("123", "email@example.com", None)) + * }}} + */ @compileTimeOnly(".regional is only usable as field configuration for transformations") def regional[DestFieldTpe](selector: Selector ?=> C => DestFieldTpe): F[A, B] = ??? } @@ -15,6 +41,27 @@ type Local[A] object Local { extension [F[a, b] <: (Case[a, b] | Field[a, b]), A, B, C](self: F[A, B] & Local[C]) { + + /** + * Constrains a config option to a certain local region (i.e. to a case class/children of an enum 'underneath' the selected field/case): + * + * {{{ + * case class Source(int: Int, str: String, level1: SourceLevel1) + * case class SourceLevel1(INT: Int, STR: String, LEVEL2: SourceLevel2) + * case class SourceLevel2(int: Int, str: String) + * + * case class Dest(int: Int, str: String, level1: DestLevel1) + * case class DestLevel1(int: Int, str: String, level2: DestLevel2) + * case class DestLevel2(int: Int, str: String) + * + * val source = Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3"))) + * + * source + * .into[Dest] + * .transform(Field.modifyDestNames(_.toUpperCase).local(_.level1)) // <-- we use `.local` to only modify names under `Dest.level1` and not anywhere else + * // Dest(1, "1", DestLevel1(2, "2", DestLevel2(3, "3"))) + * }}} + */ @compileTimeOnly(".local is only usable as field configuration for transformations") def local[DestFieldTpe](selector: Selector ?=> C => DestFieldTpe): F[A, B] = ??? } @@ -24,6 +71,27 @@ type TypeSpecific object TypeSpecific { extension [F[a, b] <: (Case[a, b] | Field[a, b]), A, B](self: F[A, B] & TypeSpecific) { + + /** + * Constrains a config option to a subtypes of the selected type: + * + * {{{ + * case class Source(int: Int, str: String, level1: SourceLevel1) + * case class SourceLevel1(INT: Int, STR: String, LEVEL2: SourceLevel2) + * case class SourceLevel2(int: Int, str: String) + * + * case class Dest(int: Int, str: String, level1: DestLevel1) + * case class DestLevel1(int: Int, str: String, level2: DestLevel2) + * case class DestLevel2(int: Int, str: String) + * + * val source = Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3"))) + * + * source + * .into[Dest] + * .transform(Field.modifyDestNames(_.toUpperCase).typeSpecific[DestLevel1]) // <-- we use `.typeSpecifc` to only modify names for `DestLevel1` and not anywhere else + * // Dest(1, "1", DestLevel1(2, "2", DestLevel2(3, "3"))) + * }}} + */ @compileTimeOnly(".typeSpecific is only usable as field configuration for transformations") def typeSpecific[Tpe]: F[A, B] = ??? } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/Renamer.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/Renamer.scala index 20028a75..dd05d093 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/Renamer.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/Renamer.scala @@ -43,4 +43,19 @@ sealed trait Renamer { * Equivalent to the function `(str: String) => Pattern.compile(pattern).matcher(str).replaceAll(replacement)` */ def regexReplace(pattern: String, replacement: String): Renamer + + /** + * Equivalent to `String#stripPrefix(prefix)` + */ + def stripPrefix(prefix: String): Renamer + + /** + * Equivalent to `String#stripSuffix(suffix)` + */ + def stripSuffix(suffix: String): Renamer + + /** + * Equivalent to `String#capitalize` + */ + def capitalize: Renamer } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala index 0d93908c..3a518250 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/Transformer.scala @@ -4,6 +4,17 @@ import io.github.arainko.ducktape import io.github.arainko.ducktape.Transformer.Derived.FromFunction import io.github.arainko.ducktape.internal.{ FallibleTransformations, TotalTransformations } +/** + * A `Transformer[Source, Dest]` describes a total transformation from `Source` to `Dest`. + * + * `ducktape` uses `Transformers` as a user-controlled extension point of the library for transforming + * types that are not handled out of the box (or if the user wants to override the library-defined transformation). + * + * The recommended way of defining a `Transformer` is a SAM lambda: + * {{{ + * given Transformer[Int, String] = _.toString + * }}} + */ @FunctionalInterface trait Transformer[Source, Dest] extends Transformer.Derived[Source, Dest] @@ -17,6 +28,24 @@ object Transformer { def defineVia[Source]: DefinitionViaBuilder.PartiallyApplied[Source] = DefinitionViaBuilder.create[Source] + /** + * `Transformer.Derived[Source, Dest]` is a last resort mechanism for deriving transformations for types that are not known + * at definition site of the transformation, for example: + * {{{ + * final case class Source[A](field1: Int, field2: String, generic: A) + * final case class Dest[A](field1: Int, field2: String, generic: A) + * + * def transformSource[A, B](source: Source[A])(using Transformer.Derived[A, B]): Dest[B] = + * source.to[Dest[B]] + * + * transformSource[Int, Option[Int]](Source(1, "2", 3)) + * // Dest(1, "2", Some(3)) + * }}} + * + * Instances of `Transformer.Derived[Source, Dest]` are automatically derived but the derivation only kicks in as a last resort when requested by the user (like in the above example). + * + * Users should NOT provide their own `Transformer.Derived[Source, Dest]` instances - `Transformer[Source, Dest]` is fit for exactly that purpose. + */ sealed trait Derived[Source, Dest] { def transform(value: Source): Dest } @@ -27,10 +56,41 @@ object Transformer { } } + /** + * A `Transformer.Fallible[F, Source, Dest]` describes a fallible transformation from `Source` to `F[Dest]`. + * + * `ducktape` uses `Transformer.Fallible` as a user-controlled extension point of the library for transforming + * types that are not handled out of the box in fallible transformations (or if the user wants to override the library-defined transformation). + * + * The recommended way of defining a `Transformer.Fallible` is a SAM lambda: + * {{{ + * given Transformer.Fallible[[a] =>> Either[String, a], Int, String] = + * int => if int > 10 then Right(int.toString) else Left("lesser than 10 :(") + * }}} + */ @FunctionalInterface trait Fallible[F[+x], Source, Dest] extends Fallible.Derived[F, Source, Dest] object Fallible { + + /** + * `Transformer.Fallible.Derived[F, Source, Dest]` is a last resort mechanism for deriving transformations for types that are not known + * at definition site of the transformation, for example: + * {{{ + * final case class Source[A](field1: Int, field2: String, generic: A) + * final case class Dest[A](field1: Int, field2: String, generic: A) + * + * def transformSource[F[+x], A, B](source: Source[A])(using Mode[F], Transformer.Derived[F, A, B]): F[Dest[B]] = + * source.fallibleTo[Dest[B]] + * + * transformSource[[a] =>> Either[String, a], Int, Option[Int]](Source(1, "2", 3)) + * // Right(Dest(1, "2", Some(3))) + * }}} + * + * Instances of `Transformer.Fallible.Derived[Source, Dest]` are automatically derived but the derivation only kicks in as a last resort when requested by the user (like in the above example). + * + * Users should NOT provide their own `Transformer.Fallible.Derived[F, Source, Dest]` instances - `Transformer.Fallible[F, Source, Dest]` is fit for exactly that purpose. + */ sealed trait Derived[F[+x], Source, Dest] { def transform(source: Source): F[Dest] } diff --git a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ParseRenamer.scala b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ParseRenamer.scala index 756bc37d..5beecc9d 100644 --- a/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ParseRenamer.scala +++ b/ducktape/src/main/scala/io/github/arainko/ducktape/internal/ParseRenamer.scala @@ -3,12 +3,14 @@ package io.github.arainko.ducktape.internal import io.github.arainko.ducktape.Renamer import java.util.regex.Pattern +import scala.annotation.tailrec import scala.quoted.* private[ducktape] object ParseRenamer { def parse(expr: Expr[Renamer => Renamer])(using Quotes): String => String = { import quotes.reflect.* + @tailrec def recurse( current: Expr[Renamer => Renamer], accumulatedFunctions: List[String => String] @@ -36,6 +38,15 @@ private[ducktape] object ParseRenamer { } :: accumulatedFunctions ) + case '{ (arg: Renamer) => ($body(arg): Renamer).stripPrefix(${ Expr(prefix) }) } => + recurse(body, ((str: String) => str.stripPrefix(prefix)) :: accumulatedFunctions) + + case '{ (arg: Renamer) => ($body(arg): Renamer).stripSuffix(${ Expr(suffix) }) } => + recurse(body, ((str: String) => str.stripSuffix(suffix)) :: accumulatedFunctions) + + case '{ (arg: Renamer) => ($body(arg): Renamer).capitalize } => + recurse(body, ((str: String) => str.capitalize) :: accumulatedFunctions) + case _ => report.errorAndAbort( "Invalid renamer expression - make sure all of the renamer expressions can be read at compiletime", diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/total/FlagSuite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/total/FlagSuite.scala index 6d01298a..370179a9 100644 --- a/ducktape/src/test/scala/io/github/arainko/ducktape/total/FlagSuite.scala +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/total/FlagSuite.scala @@ -679,4 +679,28 @@ Field 'INT' (transformed to 'AMBIGOUS') in Dest maps to more than one field name ) } + test( + "Regional, local and type specific flags can work together in a single transformation according to their rules and the priority" + ) { + case class Source(int: Int, str: String, level1: SourceLevel1) + case class SourceLevel1(int: Int, str: String, level2: SourceLevel2) + case class SourceLevel2(int: Int, str: String, level3: SourceLevel3) + case class SourceLevel3(int: Int, str: String) + + case class Dest(INT: Int, STR: String, LEVEL1: DestLevel1) + case class DestLevel1(int2: Int, str2: String, level22: DestLevel2) + case class DestLevel2(_int: Int, _str: String, _level3: DestLevel3) + case class DestLevel3(INT: Int, STR: String) + + assertTransformsConfigured( + Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3", SourceLevel3(4, "4")))), + Dest(1, "1", DestLevel1(2, "2", DestLevel2(3, "3", DestLevel3(4, "4")))) + )( + Field.modifySourceNames(_.toUpperCase), + Field.modifySourceNames(_.rename("int", "int2").rename("str", "str2").rename("level2", "level22")).local(_.level1), + Field + .modifySourceNames(_.rename("int", "_int").rename("str", "_str").rename("level3", "_level3")) + .typeSpecific[SourceLevel2] + ) + } } diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/total/RegionalFlagSuite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/total/RegionalFlagSuite.scala index 22b3c33b..794baf31 100644 --- a/ducktape/src/test/scala/io/github/arainko/ducktape/total/RegionalFlagSuite.scala +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/total/RegionalFlagSuite.scala @@ -9,8 +9,8 @@ class RegionalFlagSuite extends DucktapeSuite { case class SourceLevel2(INT: Int, STR: String) case class Dest(int: Int, str: String, level1: DestLevel1) - case class DestLevel1(INT: Int, STR: String, LEVEL2: DestLevel2) - case class DestLevel2(INT: Int, STR: String) + case class DestLevel1(int: Int, str: String, level2: DestLevel2) + case class DestLevel2(int: Int, str: String) val source = Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3"))) val expected = Dest(1, "1", DestLevel1(2, "2", DestLevel2(3, "3"))) @@ -26,14 +26,14 @@ class RegionalFlagSuite extends DucktapeSuite { case class SourceLevel2(INT: Int, STR: String) case class Dest(int: Int, str: String, level1: DestLevel1) - case class DestLevel1(INT: Int, STR: String, LEVEL2: DestLevel2) - case class DestLevel2(INT: Int, STR: String) + case class DestLevel1(int: Int, str: String, level2: DestLevel2) + case class DestLevel2(int: Int, str: String) val source = Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3"))) val expected = Dest(1, "1", DestLevel1(2, "2", DestLevel2(3, "3"))) assertTransformsConfigured(source, expected)( - Field.modifySourceNames(_.toUpperCase).regional(_.level1) + Field.modifySourceNames(_.toLowerCase).regional(_.level1) ) } diff --git a/ducktape/src/test/scala/io/github/arainko/ducktape/total/RenamerSuite.scala b/ducktape/src/test/scala/io/github/arainko/ducktape/total/RenamerSuite.scala new file mode 100644 index 00000000..8c09644d --- /dev/null +++ b/ducktape/src/test/scala/io/github/arainko/ducktape/total/RenamerSuite.scala @@ -0,0 +1,116 @@ +package io.github.arainko.ducktape.total + +import io.github.arainko.ducktape.* + +class RenamerSuite extends DucktapeSuite { + + test("Renamer#toLowerCase works") { + case class Source(field: Int) + case class Dest(FIELD: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames(_.toLowerCase) + ) + } + + test("Renamer#toUpperCase works") { + case class Source(FIELD: Int) + case class Dest(field: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames(_.toUpperCase) + ) + } + + test("Renamer#rename works") { + case class Source(FIELD: Int) + case class Dest(field: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames(_.rename("field", "FIELD")) + ) + } + + test("Renamer#replace works") { + case class Source(f1i1e1l1d: Int) + case class Dest(f_i_e_l_d: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames(_.replace("_", "1")) + ) + } + + test("Renamer#regexReplace works") { + case class Source(f1i2e3l4d: Int) + case class Dest(f_1_i_2_e_3_l_4_d: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames(_.regexReplace("""_(\d)_""", "$1")) + ) + } + + test("Renamer#stripPrefix works") { + case class Source(field: Int) + case class Dest(PREFIX_field: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames(_.stripPrefix("PREFIX_")) + ) + } + + test("Renamer#stripSuffix works") { + case class Source(field: Int) + case class Dest(field_SUFFIX: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames(_.stripSuffix("_SUFFIX")) + ) + } + + test("Renamer#capitalize works") { + case class Source(field: Int) + case class Dest(Field: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifySourceNames(_.capitalize) + ) + } + + test("Renamer functions are sequenced in the right order") { + case class Source(field: Int) + case class Dest(FIELD_a_b_c: Int) + + assertTransformsConfigured( + Source(1), + Dest(1) + )( + Field.modifyDestNames( + _.toUpperCase.replace("_A", "").toLowerCase.replace("_b", "").toUpperCase.replace("_C", "").toLowerCase + ) + ) + } +}