Skip to content

Commit ca63484

Browse files
Add TransformedNamesComparison.CamelSnakeCaseEquality (closes scalalandio#831)
Adds a built-in snake_case <-> camelCase name-matching strategy so users no longer have to hand-roll one, plus tests and documentation. Also fixes assorted typos in cookbook.md/supported-transformations.md and corrects the indentation of the `!!! example` code fences in the Cats section (3 -> 4 spaces). The cookbook.md changes no longer de-indent the `Scala 2`/`Scala 3` labels inside the first `!!! example` admonition, which would have broken its rendering. Co-authored-by: Mateusz Kubuszok <mateusz.kubuszok@gmail.com>
1 parent 8e173ed commit ca63484

5 files changed

Lines changed: 90 additions & 10 deletions

File tree

chimney/src/main/scala/io/scalaland/chimney/dsl/TransformedNamesComparison.scala

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package io.scalaland.chimney.dsl
22

3-
/** Provides a way of customizing how fields/subtypes shoud get matched betwen source value and target value.
3+
/** Provides a way of customizing how fields/subtypes should get matched between source value and target value.
44
*
55
* @see
66
* [[https://chimney.readthedocs.io/supported-transformations/#defining-custom-name-matching-predicate]] for more
@@ -49,6 +49,33 @@ object TransformedNamesComparison {
4949
def namesMatch(fromName: String, toName: String): Boolean = fromName.equalsIgnoreCase(toName)
5050
}
5151

52+
case object CamelSnakeCaseEquality extends TransformedNamesComparison {
53+
54+
private def snakeToCamel(snake: String): (String, Boolean) =
55+
snake.split('_') match {
56+
case Array(part) => (part, false)
57+
case parts =>
58+
val camel = parts
59+
.filter(_.nonEmpty) // Remove empty parts from consecutive underscores
60+
.zipWithIndex
61+
.map { case (part, idx) =>
62+
if (idx == 0) part.toLowerCase
63+
else part.toLowerCase.capitalize
64+
}
65+
.mkString
66+
(camel, true)
67+
}
68+
69+
def namesMatch(fromName: String, toName: String): Boolean = {
70+
val (from, fromWasCamelCase) = snakeToCamel(fromName)
71+
val (to, toWasCamelCase) = snakeToCamel(toName)
72+
if (fromWasCamelCase == toWasCamelCase)
73+
fromName == toName
74+
else
75+
from == to
76+
}
77+
}
78+
5279
type FieldDefault = BeanAware.type
5380
val FieldDefault: FieldDefault = BeanAware
5481

chimney/src/test/scala/io/scalaland/chimney/IssuesSpec.scala

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -912,4 +912,24 @@ class IssuesSpec extends ChimneySpec {
912912
Some(None)
913913
}
914914
}
915+
916+
test("fix issue #831") {
917+
case class UserDAO(userId: Int, createdAt: String)
918+
case class UserDTO(user_id: Int, created_at: String)
919+
val userId = 123
920+
val createdAt = "2024-12-03T12:00:00"
921+
UserDAO(userId, createdAt)
922+
.into[UserDTO]
923+
.enableCustomFieldNameComparison(
924+
TransformedNamesComparison.CamelSnakeCaseEquality
925+
)
926+
.transform ==> UserDTO(userId, createdAt)
927+
928+
UserDTO(userId, createdAt)
929+
.into[UserDAO]
930+
.enableCustomFieldNameComparison(
931+
TransformedNamesComparison.CamelSnakeCaseEquality
932+
)
933+
.transform ==> UserDAO(userId, createdAt)
934+
}
915935
}

chimney/src/test/scala/io/scalaland/chimney/TransformedNamesComparisonSpec.scala

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,4 +79,35 @@ class TransformedNamesComparisonSpec extends ChimneySpec {
7979
TransformedNamesComparison.CaseInsensitiveEquality.namesMatch("SOME_FIELD", "someField") ==> false
8080
}
8181
}
82+
83+
group("TransformedNamesComparison.CamelSnakeCaseEquality") {
84+
85+
def namesMatches(from: String, to: String): Boolean =
86+
TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch(
87+
from,
88+
to
89+
) && TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch(to, from)
90+
91+
test("should match identical names") {
92+
TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch("_", "_") ==> true
93+
TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch("someField", "someField") ==> true
94+
}
95+
96+
test("should match conversion") {
97+
namesMatches("some_Field", "someField") ==> true
98+
namesMatches("some_field", "someField") ==> true
99+
namesMatches("some_field_123", "someField123") ==> true
100+
namesMatches("some__field", "someField") ==> true
101+
namesMatches("_some__field", "someField") ==> true
102+
namesMatches("SOME_FIELD", "someField") ==> true
103+
}
104+
105+
test("should not match conversion") {
106+
TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch("_", "__") ==> false
107+
TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch("somefield", "some_field") ==> false
108+
TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch("some_field", "some_Field") ==> false
109+
TransformedNamesComparison.CamelSnakeCaseEquality.namesMatch("someField", "somefield") ==> false
110+
}
111+
112+
}
82113
}

docs/docs/cookbook.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -233,9 +233,9 @@ Similarly `Patcher` has `.withPatchedValueFlag(pathFromPatchedValue)`:
233233

234234
## Checking for unused source fields/unmatched target subtypes
235235

236-
While most of the time Chimney is picked for generating mapping between 2 data types wit as little hassle as possible,
236+
While most of the time Chimney is picked for generating mapping between 2 data types with as little hassle as possible,
237237
some people use type mapping tools to express mapping as a declarative description of the transformation. As a Part of
238-
that requirement is making it explicit, that some field in the source value was dropped, or that matching between 2
238+
that requirement is making it explicit that some field in the source value was dropped, or that matching between 2
239239
`sealed` hierarchies didn't use one target subtype.
240240

241241
These can be enabled with `UnusedFieldPolicy`:
@@ -302,7 +302,7 @@ These can be enabled with `UnusedFieldPolicy`:
302302

303303
and `UnmatchedSubtypePolicy`:
304304

305-
!!! example "Subptype has to be explicitly ignored to compile"
305+
!!! example "Subtype has to be explicitly ignored to compile"
306306

307307
```scala
308308
//> using dep io.scalaland::chimney::{{ chimney_version() }}
@@ -500,7 +500,7 @@ Since Chimney 1.6.0 we are able to [scope the flag to a particular field](#const
500500

501501
If the particular flag we want to use in limited scope is `.enableDefaultValues`, we might also consider
502502
[`.enableDefaultValueOfType[A]`](supported-transformations.md#allowing-fallback-to-the-constructors-default-values)
503-
available since Chimney 1.2.0 (but scoped flag would work as well!).
503+
available since Chimney 1.2.0 (but a scoped flag would work as well!).
504504

505505
!!! example "Enabling default values only for 1 type"
506506

@@ -664,7 +664,7 @@ Many users are not aware that Chimney can transform one Scala collection into an
664664
// List(Bar(a = 10))
665665
```
666666

667-
even though transforming all values of a collection (and even the type of a collection!) was supported since Chimney 0.2.0:
667+
even though transforming all values of a collection (and even the type of collection!) was supported since Chimney 0.2.0:
668668

669669
!!! example
670670

@@ -1327,7 +1327,7 @@ collection), then you can:
13271327

13281328
!!! example "Converting from Cats collections"
13291329

1330-
```scala
1330+
```scala
13311331
//> using dep org.typelevel::cats-core::{{ libraries.cats }}
13321332
//> using dep io.scalaland::chimney-cats::{{ chimney_version() }}
13331333
//> using dep com.lihaoyi::pprint::{{ libraries.pprint }}
@@ -1403,7 +1403,7 @@ collection), then you can:
14031403

14041404
!!! example "Converting between Cats collections of the same type"
14051405

1406-
```scala
1406+
```scala
14071407
//> using dep org.typelevel::cats-core::{{ libraries.cats }}
14081408
//> using dep io.scalaland::chimney-cats::{{ chimney_version() }}
14091409
//> using dep com.lihaoyi::pprint::{{ libraries.pprint }}
@@ -1435,7 +1435,7 @@ collection), then you can:
14351435

14361436
!!! example "Converting using implicit ~> (FunctionK)"
14371437

1438-
```scala
1438+
```scala
14391439
//> using dep org.typelevel::cats-core::{{ libraries.cats }}
14401440
//> using dep io.scalaland::chimney-cats::{{ chimney_version() }}
14411441
//> using dep com.lihaoyi::pprint::{{ libraries.pprint }}

docs/docs/supported-transformations.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6225,8 +6225,10 @@ Arguments taken by both `.enableCustomFieldNameComparison` and `.enableCustomSub
62256225

62266226
- `TransformedNamesComparison.CaseInsensitiveEquality` - 2 names are considered equal if `equalsIgnoreCase` returns
62276227
`true`
6228+
- `TransformedNamesComparison.CamelSnakeCaseEquality` - 2 names are considered equal if they are identical `String`s OR if they are
6229+
identical after you convert them from snake to camel case (e.g. `snake_case` -> `snakeCase`, `camelCase` -> `camelCase`)
62286230

6229-
However, these 3 do not exhaust all possible comparisons and you might need to provide one yourself.
6231+
However, these 4 do not exhaust all possible comparisons, and you might need to provide one yourself.
62306232

62316233
!!! warning
62326234

0 commit comments

Comments
 (0)