Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions ducktape/src/main/scala/io/github/arainko/ducktape/Regional.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 protected]"))
* 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 protected]", None))
* }}}
*/
@compileTimeOnly(".regional is only usable as field configuration for transformations")
def regional[DestFieldTpe](selector: Selector ?=> C => DestFieldTpe): F[A, B] = ???
}
Expand All @@ -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] = ???
}
Expand All @@ -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] = ???
}
Expand Down
15 changes: 15 additions & 0 deletions ducktape/src/main/scala/io/github/arainko/ducktape/Renamer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand All @@ -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
}
Expand All @@ -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]
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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")))
Expand All @@ -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)
)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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
)
)
}
}