Skip to content

Commit cd0aafe

Browse files
authored
Rough renames docs (#258)
2 parents ad429d8 + c134746 commit cd0aafe

File tree

1 file changed

+285
-0
lines changed

1 file changed

+285
-0
lines changed

docs/total_transformations/configuring_transformations.md

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ What's worth noting is that any of the configuration options are purely a compil
201201
| `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) |
202202
| `Field.fallbackToDefault` | falls back to default field values but ONLY in case a transformation cannot be created |
203203
| `Field.fallbackToNone` | falls back to `None` for `Option` fields for which a transformation cannot be created |
204+
| `Field.modifySourceNames` | allows to transform names of fields on the source type side |
205+
| `Field.modifyDestNames` | allows to transform names of fields on the dest type side |
204206

205207
---
206208

@@ -479,6 +481,8 @@ Docs.printCode(
479481
|:-----------------:|:-------------------:|
480482
| `Case.const` | allows to supply a constant value for a given subtype of a coproduct |
481483
| `Case.computed` | allows to supply a function of the selected source type to the expected destination type |
484+
| `Case.modifySourceNames` | allows to transform names of enum/sealed trait children and case objects of the Source type |
485+
| `Case.modifyDestNames` | allows to transform names of enum/sealed trait children and case objects of the Dest type |
482486

483487
---
484488

@@ -533,6 +537,287 @@ Docs.printCode(
533537
```
534538
@:@
535539

540+
### Field/case name transformations
541+
542+
#### Changing field names
543+
544+
Let's establish a pair of case classes with a peculiar naming scheme first:
545+
```scala mdoc:nest:silent
546+
case class Source(int: Int, str: String)
547+
case class Dest(INT: Int, STR: String)
548+
549+
val source = Source(1, "1")
550+
```
551+
552+
Obviously, we wouldn't be able to map between those two case classes since their names do not match.
553+
To make this work we can make use of one of the following configuration options:
554+
555+
* `Field.modifySourceNames` - modifies source field names according to the provided `Renamer`:
556+
557+
@:select(underlying-code-13)
558+
@:choice(visible)
559+
```scala mdoc
560+
source
561+
.into[Dest]
562+
.transform(
563+
Field.modifySourceNames(_.toUpperCase)
564+
)
565+
```
566+
@:choice(generated)
567+
```scala mdoc:passthrough
568+
import io.github.arainko.ducktape.docs.*
569+
570+
Docs.printCode(
571+
source
572+
.into[Dest]
573+
.transform(
574+
Field.modifySourceNames(_.toUpperCase)
575+
)
576+
)
577+
```
578+
@:@
579+
580+
* `Field.modifyDestNames` - modifies destination field names according to the provided `Renamer`:
581+
582+
@:select(underlying-code-14)
583+
@:choice(visible)
584+
```scala mdoc
585+
source
586+
.into[Dest]
587+
.transform(
588+
Field.modifyDestNames(_.toLowerCase)
589+
)
590+
```
591+
@:choice(generated)
592+
```scala mdoc:passthrough
593+
import io.github.arainko.ducktape.docs.*
594+
595+
Docs.printCode(
596+
source
597+
.into[Dest]
598+
.transform(
599+
Field.modifyDestNames(_.toLowerCase)
600+
)
601+
)
602+
```
603+
@:@
604+
605+
#### Changing case names
606+
607+
Sealed traits', enums' and case objects' names can also be changed, let's look at an example:
608+
609+
```scala mdoc:nest:silent
610+
enum Source {
611+
case One, Two, Three
612+
}
613+
614+
enum Dest {
615+
case ONE, TWO, THREE
616+
}
617+
```
618+
619+
* `Case.modifyDestNames` - modifies destination case names according to the provided `Renamer`:
620+
621+
@:select(underlying-code-15)
622+
@:choice(visible)
623+
```scala mdoc
624+
Source.One
625+
.into[Dest]
626+
.transform(
627+
Case.modifyDestNames(_.toLowerCase.capitalize)
628+
)
629+
```
630+
@:choice(generated)
631+
```scala mdoc:passthrough
632+
import io.github.arainko.ducktape.docs.*
633+
634+
Docs.printCode(
635+
Source.One
636+
.into[Dest]
637+
.transform(
638+
Case.modifyDestNames(_.toLowerCase.capitalize)
639+
)
640+
)
641+
```
642+
@:@
643+
644+
* `Case.modifySourceNames` - modifies source case names according to the provided `Renamer`:
645+
646+
@:select(underlying-code-16)
647+
@:choice(visible)
648+
```scala mdoc
649+
Source.One
650+
.into[Dest]
651+
.transform(
652+
Case.modifySourceNames(_.toUpperCase)
653+
)
654+
```
655+
@:choice(generated)
656+
```scala mdoc:passthrough
657+
import io.github.arainko.ducktape.docs.*
658+
659+
Docs.printCode(
660+
Source.One
661+
.into[Dest]
662+
.transform(
663+
Case.modifySourceNames(_.toUpperCase)
664+
)
665+
)
666+
```
667+
@:@
668+
669+
#### Constraining the blast radius of name transformations
670+
671+
Let's take one of the config options that modifies names (`Field.modifyDestNames`) under closer inspection:
672+
```scala
673+
def modifyDestNames[Source, Dest](renamer: Renamer => Renamer): Field[Source, Dest] & Regional[Dest] & Local[Dest] & TypeSpecific
674+
```
675+
676+
We can see that, apart from being a `Field[Source, Dest]` (a type for describing field configs), it is also a `Regional[Dest] & Local[Dest] & TypeSpecific` - these are 'config modifiers' and each one of them introduces a method on the config option. Let's take a closer look at all of them:
677+
678+
* `.regional` - constrains a config option to a certain region (i.e. the option will apply to the transformations 'underneath' the selected field/case):
679+
680+
```scala mdoc:nest:silent
681+
case class Source(int: Int, str: String, level1: SourceLevel1)
682+
case class SourceLevel1(INT: Int, STR: String, LEVEL2: SourceLevel2)
683+
case class SourceLevel2(INT: Int, STR: String)
684+
685+
case class Dest(int: Int, str: String, level1: DestLevel1)
686+
case class DestLevel1(int: Int, str: String, level2: DestLevel2)
687+
case class DestLevel2(int: Int, str: String)
688+
689+
val source = Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3")))
690+
```
691+
692+
@:select(underlying-code-17)
693+
@:choice(visible)
694+
```scala mdoc
695+
source
696+
.into[Dest]
697+
.transform(Field.modifyDestNames(_.toUpperCase).regional(_.level1)) // <-- we use `.regional` to modify all fields BELOW `Dest.level1`
698+
```
699+
@:choice(generated)
700+
```scala mdoc:passthrough
701+
import io.github.arainko.ducktape.docs.*
702+
703+
Docs.printCode(
704+
source
705+
.into[Dest]
706+
.transform(Field.modifyDestNames(_.toUpperCase).regional(_.level1))
707+
)
708+
```
709+
@:@
710+
711+
If we were to use this modifier on a `Case` rename, we'd modify all case objects and the type names of all children of enums and sealed traits 'under' a type we choose.
712+
713+
* `.local` - constrains a config option to a certain local region (i.e. to a case class/children of an enum 'underneath' the selected field/case):
714+
715+
```scala mdoc:nest:silent
716+
case class Source(int: Int, str: String, level1: SourceLevel1)
717+
case class SourceLevel1(INT: Int, STR: String, LEVEL2: SourceLevel2)
718+
case class SourceLevel2(int: Int, str: String)
719+
720+
case class Dest(int: Int, str: String, level1: DestLevel1)
721+
case class DestLevel1(int: Int, str: String, level2: DestLevel2)
722+
case class DestLevel2(int: Int, str: String)
723+
724+
val source = Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3")))
725+
```
726+
727+
@:select(underlying-code-18)
728+
@:choice(visible)
729+
```scala mdoc
730+
source
731+
.into[Dest]
732+
.transform(Field.modifyDestNames(_.toUpperCase).local(_.level1)) // <-- we use `.local` to only modify names under `Dest.level1` and not anywhere else
733+
```
734+
@:choice(generated)
735+
```scala mdoc:passthrough
736+
import io.github.arainko.ducktape.docs.*
737+
738+
Docs.printCode(
739+
source
740+
.into[Dest]
741+
.transform(Field.modifyDestNames(_.toUpperCase).local(_.level1))
742+
)
743+
```
744+
@:@
745+
746+
If we were to use this modifier on a `Case` rename it'd bubble down (is that a phrase?) to all subtypes of an enum/sealed trait we choose, but only in that specific case.
747+
748+
* `.typeSpecific` - constrains a config option to subtypes of the selected type:
749+
750+
```scala mdoc:nest:silent
751+
case class Source(int: Int, str: String, level1: SourceLevel1)
752+
case class SourceLevel1(INT: Int, STR: String, LEVEL2: SourceLevel2)
753+
case class SourceLevel2(int: Int, str: String)
754+
755+
case class Dest(int: Int, str: String, level1: DestLevel1)
756+
case class DestLevel1(int: Int, str: String, level2: DestLevel2) // <-- fields of this type (and only this type) need to be uppercase
757+
case class DestLevel2(int: Int, str: String)
758+
759+
val source = Source(1, "1", SourceLevel1(2, "2", SourceLevel2(3, "3")))
760+
```
761+
762+
@:select(underlying-code-19)
763+
@:choice(visible)
764+
```scala mdoc
765+
source
766+
.into[Dest]
767+
.transform(Field.modifyDestNames(_.toUpperCase).typeSpecific[DestLevel1]) // <-- we use `.typeSpecifc` to only modify names for `DestLevel1` and not anywhere else
768+
```
769+
@:choice(generated)
770+
```scala mdoc:passthrough
771+
import io.github.arainko.ducktape.docs.*
772+
773+
Docs.printCode(
774+
source
775+
.into[Dest]
776+
.transform(Field.modifyDestNames(_.toUpperCase).typeSpecific[DestLevel1])
777+
)
778+
```
779+
@:@
780+
781+
#### Supported name transformations
782+
783+
Name transformations are designed to look like operations on your run of the mill `String`, this however is not the full story.
784+
One of the constrains of that design is that all of the operations (and their arguments) need to be known at compiletime so that the library can actually
785+
lift that into an actual `String => String` function to apply on the field/case names.
786+
787+
This leads us to `Renamer` - a small DSL that describes the supported subset of `String` methods, defined as such:
788+
```scala
789+
sealed trait Renamer {
790+
// ...small excerpt of the actual Renamer...
791+
792+
/**
793+
* Equivalent to `String#toLowerCase`
794+
*/
795+
def toLowerCase: Renamer
796+
797+
/**
798+
* Equivalent to the function `(str: String) => Pattern.compile(pattern).matcher(str).replaceAll(replacement)`
799+
*/
800+
def regexReplace(pattern: String, replacement: String): Renamer
801+
802+
/**
803+
* Equivalent to `String#replace(target, replacement)`
804+
*/
805+
def replace(target: String, replacement: String): Renamer
806+
807+
// ...small excerpt of the actual Renamer...
808+
}
809+
```
810+
811+
To reiterate, all of the calls on a `Renamer` (which always takes shape of a `Renamer => Renamer` in a config option), i.e. stuff like:
812+
```scala
813+
Field.modifyDestNames(_.toLowerCase.replace("some_string", "with_something_else").regexReplace("""_(\d)_""", "$1"))
814+
```
815+
...is legal, but the moment we introduce something that ISN'T immediately known at compiletime we'll get a compilation error, for example:
816+
```scala
817+
Field.modifyDestNames(_.toLowerCase.replace("some_string", Random.nextString(10)))
818+
// error: Invalid renamer expression - make sure all of the renamer expressions can be read at compiletime
819+
```
820+
536821
### Specifics and limitations
537822

538823
* Configs can override transformations

0 commit comments

Comments
 (0)