Skip to content

Commit 35f30e3

Browse files
committed
#45: Add CsvEmbed annotation
Allows to map nested case class structures from and to flat CSV. Fixes #45.
1 parent 365e9c7 commit 35f30e3

File tree

10 files changed

+250
-73
lines changed

10 files changed

+250
-73
lines changed
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package fs2.data.csv.generic
2+
3+
import scala.annotation.Annotation
4+
5+
/** Mark a field of a case class to be embedded (= to be parsed from the same row, but inlined as value)
6+
*/
7+
case class CsvEmbed() extends Annotation

csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowDecoder.scala

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -24,14 +24,14 @@ trait DerivedCsvRowDecoder[T] extends CsvRowDecoder[T, String]
2424

2525
object DerivedCsvRowDecoder {
2626

27-
final implicit def productReader[T, Repr <: HList, DefaultRepr <: HList, AnnoRepr <: HList](implicit
27+
final implicit def productReader[T, Repr <: HList, DefaultRepr <: HList, NamesAnno <: HList, EmbedsAnno <: HList](
28+
implicit
2829
gen: LabelledGeneric.Aux[T, Repr],
2930
defaults: Default.AsOptions.Aux[T, DefaultRepr],
30-
annotations: Annotations.Aux[CsvName, T, AnnoRepr],
31-
cc: Lazy[MapShapedCsvRowDecoder.WithDefaults[T, Repr, DefaultRepr, AnnoRepr]]): DerivedCsvRowDecoder[T] =
32-
new DerivedCsvRowDecoder[T] {
33-
def apply(row: CsvRow[String]): DecoderResult[T] =
34-
cc.value.fromWithDefault(row, defaults(), annotations()).map(gen.from(_))
35-
}
31+
names: Annotations.Aux[CsvName, T, NamesAnno],
32+
embeds: Annotations.Aux[CsvEmbed, T, EmbedsAnno],
33+
cc: Lazy[MapShapedCsvRowDecoder.WithDefaults[T, Repr, DefaultRepr, NamesAnno, EmbedsAnno]])
34+
: DerivedCsvRowDecoder[T] =
35+
(row: CsvRow[String]) => cc.value.fromWithDefault(row, defaults(), names(), embeds()).map(gen.from(_))
3636

3737
}

csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowEncoder.scala

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@ trait DerivedCsvRowEncoder[T] extends CsvRowEncoder[T, String]
2424

2525
object DerivedCsvRowEncoder {
2626

27-
final implicit def productWriter[T, Repr <: HList, AnnoRepr <: HList](implicit
27+
final implicit def productWriter[T, Repr <: HList, NameAnno <: HList, EmbedAnno <: HList](implicit
2828
gen: LabelledGeneric.Aux[T, Repr],
29-
annotations: Annotations.Aux[CsvName, T, AnnoRepr],
30-
cc: Lazy[MapShapedCsvRowEncoder.WithAnnotations[T, Repr, AnnoRepr]]): DerivedCsvRowEncoder[T] =
31-
(elem: T) => cc.value.fromWithAnnotation(gen.to(elem), annotations())
29+
names: Annotations.Aux[CsvName, T, NameAnno],
30+
embeds: Annotations.Aux[CsvEmbed, T, EmbedAnno],
31+
cc: Lazy[MapShapedCsvRowEncoder.WithAnnotations[T, Repr, NameAnno, EmbedAnno]]): DerivedCsvRowEncoder[T] =
32+
(elem: T) => cc.value.fromWithAnnotation(gen.to(elem), names(), embeds())
3233

3334
}

csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowDecoder.scala

Lines changed: 106 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -27,80 +27,142 @@ trait MapShapedCsvRowDecoder[Repr] extends CsvRowDecoder[Repr, String]
2727

2828
object MapShapedCsvRowDecoder extends LowPriorityMapShapedCsvRowDecoder1 {
2929

30-
implicit def hnilRowDecoder[Wrapped]: WithDefaults[Wrapped, HNil, HNil, HNil] =
31-
new WithDefaults[Wrapped, HNil, HNil, HNil] {
32-
def fromWithDefault(row: CsvRow[String], default: HNil, annotation: HNil): DecoderResult[HNil] =
33-
Right(HNil)
34-
}
30+
implicit def hnilRowDecoder[Wrapped]: WithDefaults[Wrapped, HNil, HNil, HNil, HNil] =
31+
(_: CsvRow[String], _: HNil, _: HNil, _: HNil) => Right(HNil)
3532

3633
implicit def optionHconsRowDecoder[Wrapped,
3734
Key <: Symbol,
3835
Head,
3936
Tail <: HList,
4037
DefaultTail <: HList,
41-
Anno,
42-
AnnoTail <: HList](implicit
38+
Name,
39+
NamesTail <: HList,
40+
EmbedTail <: HList](implicit
4341
witness: Witness.Aux[Key],
4442
Head: CellDecoder[Head],
45-
ev: <:<[Anno, Option[CsvName]],
46-
Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, AnnoTail]])
43+
ev: <:<[Name, Option[CsvName]],
44+
Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]])
4745
: WithDefaults[Wrapped,
4846
FieldType[Key, Option[Head]] :: Tail,
4947
Option[Option[Head]] :: DefaultTail,
50-
Anno :: AnnoTail] =
51-
new WithDefaults[Wrapped,
48+
Name :: NamesTail,
49+
None.type :: EmbedTail] =
50+
(row: CsvRow[String],
51+
default: Option[Option[Head]] :: DefaultTail,
52+
names: Name :: NamesTail,
53+
embeds: None.type :: EmbedTail) => {
54+
val head = row(names.head.fold(witness.value.name)(_.name)) match {
55+
case Some(head) if head.nonEmpty => Head(head).map(Some(_))
56+
case _ => Right(default.head.flatten)
57+
}
58+
for {
59+
head <- head
60+
tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail)
61+
} yield field[Key](head) :: tail
62+
}
63+
64+
implicit def optionHconsEmbedRowDecoder[Wrapped,
65+
Key <: Symbol,
66+
Head,
67+
Tail <: HList,
68+
DefaultTail <: HList,
69+
NamesTail <: HList,
70+
EmbedTail <: HList](implicit
71+
witness: Witness.Aux[Key],
72+
Head: CsvRowDecoder[Option[Head], String],
73+
Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]])
74+
: WithDefaults[Wrapped,
5275
FieldType[Key, Option[Head]] :: Tail,
5376
Option[Option[Head]] :: DefaultTail,
54-
Anno :: AnnoTail] {
55-
def fromWithDefault(row: CsvRow[String],
56-
default: Option[Option[Head]] :: DefaultTail,
57-
anno: Anno :: AnnoTail): DecoderResult[FieldType[Key, Option[Head]] :: Tail] = {
58-
val head = row(anno.head.fold(witness.value.name)(_.name)) match {
59-
case Some(head) if head.nonEmpty => Head(head).map(Some(_))
60-
case _ => Right(default.head.flatten)
77+
None.type :: NamesTail,
78+
Some[CsvEmbed] :: EmbedTail] =
79+
(row: CsvRow[String],
80+
default: Option[Option[Head]] :: DefaultTail,
81+
names: None.type :: NamesTail,
82+
embeds: Some[CsvEmbed] :: EmbedTail) => {
83+
for {
84+
head <- (Head(row), default.head) match {
85+
case (r @ Right(_), _) => r
86+
case (Left(_: DecoderError.ColumnMissing), Some(default)) => Right(default)
87+
case (Left(_: DecoderError.ColumnMissing), None) => Right(None)
88+
case (l @ Left(_), _) => l
6189
}
62-
for {
63-
head <- head
64-
tail <- Tail.value.fromWithDefault(row, default.tail, anno.tail)
65-
} yield field[Key](head) :: tail
66-
}
90+
tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail)
91+
} yield field[Key](head) :: tail
6792
}
6893

6994
}
7095

7196
trait LowPriorityMapShapedCsvRowDecoder1 {
7297

73-
trait WithDefaults[Wrapped, Repr, DefaultRepr, AnnoRepr] {
74-
def fromWithDefault(row: CsvRow[String], default: DefaultRepr, annotation: AnnoRepr): DecoderResult[Repr]
98+
trait WithDefaults[Wrapped, Repr, DefaultRepr, NameAnno, EmbedAnno] {
99+
def fromWithDefault(row: CsvRow[String],
100+
default: DefaultRepr,
101+
names: NameAnno,
102+
embeds: EmbedAnno): DecoderResult[Repr]
75103
}
76104

77105
implicit def hconsRowDecoder[Wrapped,
78106
Key <: Symbol,
79107
Head,
80108
Tail <: HList,
81109
DefaultTail <: HList,
82-
Anno,
83-
AnnoTail <: HList](implicit
110+
Name,
111+
NamesTail <: HList,
112+
EmbedTail <: HList](implicit
84113
witness: Witness.Aux[Key],
85114
Head: CellDecoder[Head],
86-
ev: <:<[Anno, Option[CsvName]],
87-
Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, AnnoTail]])
88-
: WithDefaults[Wrapped, FieldType[Key, Head] :: Tail, Option[Head] :: DefaultTail, Anno :: AnnoTail] =
89-
new WithDefaults[Wrapped, FieldType[Key, Head] :: Tail, Option[Head] :: DefaultTail, Anno :: AnnoTail] {
90-
def fromWithDefault(row: CsvRow[String],
91-
default: Option[Head] :: DefaultTail,
92-
anno: Anno :: AnnoTail): DecoderResult[FieldType[Key, Head] :: Tail] = {
93-
val head = row(anno.head.fold(witness.value.name)(_.name)) match {
94-
case Some(head) if head.nonEmpty =>
95-
Head(head)
96-
case _ =>
97-
default.head.liftTo[DecoderResult](new DecoderError(s"unknown column name '${witness.value.name}'"))
98-
}
99-
for {
100-
head <- head
101-
tail <- Tail.value.fromWithDefault(row, default.tail, anno.tail)
102-
} yield field[Key](head) :: tail
115+
ev: <:<[Name, Option[CsvName]],
116+
Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]])
117+
: WithDefaults[Wrapped,
118+
FieldType[Key, Head] :: Tail,
119+
Option[Head] :: DefaultTail,
120+
Name :: NamesTail,
121+
None.type :: EmbedTail] =
122+
(row: CsvRow[String],
123+
default: Option[Head] :: DefaultTail,
124+
names: Name :: NamesTail,
125+
embeds: None.type :: EmbedTail) => {
126+
val head = row(names.head.fold(witness.value.name)(_.name)) match {
127+
case Some(head) if head.nonEmpty =>
128+
Head(head)
129+
case _ =>
130+
default.head.liftTo[DecoderResult](
131+
new DecoderError.ColumnMissing(s"unknown column name '${witness.value.name}'"))
103132
}
133+
for {
134+
head <- head
135+
tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail)
136+
} yield field[Key](head) :: tail
137+
}
138+
139+
implicit def hconsEmbedRowDecoder[Wrapped,
140+
Key <: Symbol,
141+
Head,
142+
Tail <: HList,
143+
DefaultTail <: HList,
144+
NamesTail <: HList,
145+
EmbedTail <: HList](implicit
146+
witness: Witness.Aux[Key],
147+
Head: CsvRowDecoder[Head, String],
148+
Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]])
149+
: WithDefaults[Wrapped,
150+
FieldType[Key, Head] :: Tail,
151+
Option[Head] :: DefaultTail,
152+
None.type :: NamesTail,
153+
Some[CsvEmbed] :: EmbedTail] =
154+
(row: CsvRow[String],
155+
default: Option[Head] :: DefaultTail,
156+
names: None.type :: NamesTail,
157+
embeds: Some[CsvEmbed] :: EmbedTail) => {
158+
for {
159+
head <- (Head(row), default.head) match {
160+
case (r @ Right(_), _) => r
161+
case (Left(_: DecoderError.ColumnMissing), Some(default)) => Right(default)
162+
case (l @ Left(_), _) => l
163+
}
164+
tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail)
165+
} yield field[Key](head) :: tail
104166
}
105167

106168
}

csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowEncoder.scala

Lines changed: 42 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,36 +24,62 @@ trait MapShapedCsvRowEncoder[Repr] extends CsvRowEncoder[Repr, String]
2424

2525
object MapShapedCsvRowEncoder extends LowPrioMapShapedCsvRowEncoderImplicits {
2626

27-
implicit def lastElemRowEncoder[Wrapped, Repr, Anno, Key <: Symbol](implicit
27+
implicit def lastElemRowEncoder[Wrapped, Repr, NameAnno, Key <: Symbol](implicit
2828
Last: CellEncoder[Repr],
29-
ev: <:<[Anno, Option[CsvName]],
30-
witness: Witness.Aux[Key]): WithAnnotations[Wrapped, FieldType[Key, Repr] :: HNil, Anno :: HNil] =
31-
(row: Repr :: HNil, annotation: Anno :: HNil) =>
32-
CsvRow.unsafe(NonEmptyList.one(Last(row.head)),
33-
NonEmptyList.one(annotation.head.fold(witness.value.name)(_.name)))
29+
ev: <:<[NameAnno, Option[CsvName]],
30+
witness: Witness.Aux[Key])
31+
: WithAnnotations[Wrapped, FieldType[Key, Repr] :: HNil, NameAnno :: HNil, None.type :: HNil] =
32+
(row: Repr :: HNil, names: NameAnno :: HNil, _: None.type :: HNil) =>
33+
CsvRow.unsafe(NonEmptyList.one(Last(row.head)), NonEmptyList.one(names.head.fold(witness.value.name)(_.name)))
34+
35+
implicit def lastElemEmbedRowEncoder[Wrapped, Repr, NameAnno, Key <: Symbol](implicit
36+
Last: CsvRowEncoder[Repr, String],
37+
names: <:<[NameAnno, None.type], // renaming is mutually exclusive with embedding
38+
witness: Witness.Aux[Key])
39+
: WithAnnotations[Wrapped, FieldType[Key, Repr] :: HNil, NameAnno :: HNil, Some[CsvEmbed] :: HNil] =
40+
(row: Repr :: HNil, _: NameAnno :: HNil, _: Some[CsvEmbed] :: HNil) => Last(row.head)
3441

3542
}
3643

3744
trait LowPrioMapShapedCsvRowEncoderImplicits {
38-
trait WithAnnotations[Wrapped, Repr, AnnoRepr] {
39-
def fromWithAnnotation(row: Repr, annotation: AnnoRepr): CsvRow[String]
45+
trait WithAnnotations[Wrapped, Repr, NameAnnoRepr, EmbedAnnoRepr] {
46+
def fromWithAnnotation(row: Repr, names: NameAnnoRepr, embeds: EmbedAnnoRepr): CsvRow[String]
4047
}
4148

4249
implicit def hconsRowEncoder[Wrapped,
4350
Key <: Symbol,
4451
Head,
4552
Tail <: HList,
4653
DefaultTail <: HList,
47-
Anno,
48-
AnnoTail <: HList](implicit
54+
NameAnno,
55+
NameAnnoTail <: HList,
56+
EmbedAnnoTail <: HList](implicit
4957
witness: Witness.Aux[Key],
5058
Head: CellEncoder[Head],
51-
ev: <:<[Anno, Option[CsvName]],
52-
Tail: Lazy[WithAnnotations[Wrapped, Tail, AnnoTail]])
53-
: WithAnnotations[Wrapped, FieldType[Key, Head] :: Tail, Anno :: AnnoTail] =
54-
(row: FieldType[Key, Head] :: Tail, annotation: Anno :: AnnoTail) => {
55-
val tailRow = Tail.value.fromWithAnnotation(row.tail, annotation.tail)
59+
ev: <:<[NameAnno, Option[CsvName]],
60+
Tail: Lazy[WithAnnotations[Wrapped, Tail, NameAnnoTail, EmbedAnnoTail]])
61+
: WithAnnotations[Wrapped, FieldType[Key, Head] :: Tail, NameAnno :: NameAnnoTail, None.type :: EmbedAnnoTail] =
62+
(row: FieldType[Key, Head] :: Tail, names: NameAnno :: NameAnnoTail, embeds: None.type :: EmbedAnnoTail) => {
63+
val tailRow = Tail.value.fromWithAnnotation(row.tail, names.tail, embeds.tail)
5664
CsvRow.unsafe(NonEmptyList(Head(row.head), tailRow.values.toList),
57-
NonEmptyList(annotation.head.fold(witness.value.name)(_.name), tailRow.headers.get.toList))
65+
NonEmptyList(names.head.fold(witness.value.name)(_.name), tailRow.headers.get.toList))
5866
}
67+
68+
implicit def hconsEmbedRowEncoder[Wrapped,
69+
Key <: Symbol,
70+
Head,
71+
Tail <: HList,
72+
DefaultTail <: HList,
73+
NameAnnoTail <: HList,
74+
EmbedAnnoTail <: HList](implicit
75+
witness: Witness.Aux[Key],
76+
Head: CsvRowEncoder[Head, String],
77+
names: <:<[None.type, Option[CsvName]], // renaming is mutually exclusive with embedding
78+
Tail: Lazy[WithAnnotations[Wrapped, Tail, NameAnnoTail, EmbedAnnoTail]])
79+
: WithAnnotations[Wrapped,
80+
FieldType[Key, Head] :: Tail,
81+
None.type :: NameAnnoTail,
82+
Some[CsvEmbed] :: EmbedAnnoTail] =
83+
(row: FieldType[Key, Head] :: Tail, names: None.type :: NameAnnoTail, embeds: Some[CsvEmbed] :: EmbedAnnoTail) =>
84+
Head(row.head) ::: Tail.value.fromWithAnnotation(row.tail, names.tail, embeds.tail)
5985
}

csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowDecoderTest.scala

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,16 +32,29 @@ object CsvRowDecoderTest extends SimpleIOSuite {
3232
CsvRow.unsafe(NonEmptyList.of("1", "test", ""), NonEmptyList.of("i", "s", "j"))
3333
val csvRowNoJ =
3434
CsvRow.unsafe(NonEmptyList.of("1", "test"), NonEmptyList.of("i", "s"))
35+
val csvRowA = CsvRow.unsafe(NonEmptyList.of("7", "1", "test", "42"), NonEmptyList.of("a", "i", "s", "j"))
36+
val csvRowAOnly = CsvRow.unsafe(NonEmptyList.of("7"), NonEmptyList.of("a"))
37+
val csvRowAInvalidI = CsvRow.unsafe(NonEmptyList.of("7", "no-int", "test", "42"), NonEmptyList.of("a", "i", "s", "j"))
3538

3639
case class Test(i: Int = 0, s: String, j: Option[Int])
3740
case class TestOrder(s: String, j: Int, i: Int)
3841
case class TestRename(s: String, @CsvName("j") k: Int, i: Int)
3942
case class TestOptionRename(s: String, @CsvName("j") k: Option[Int], i: Int)
43+
case class TestEmbed(a: Int, @CsvEmbed inner: Test)
44+
case class TestEmbedDefault(a: Int, @CsvEmbed inner: Test = Test(0, "", None))
4045

4146
val testDecoder = deriveCsvRowDecoder[Test]
4247
val testOrderDecoder = deriveCsvRowDecoder[TestOrder]
4348
val testRenameDecoder = deriveCsvRowDecoder[TestRename]
4449
val testOptionRenameDecoder = deriveCsvRowDecoder[TestOptionRename]
50+
val testEmbedDecoder = {
51+
implicit val embedded: CsvRowDecoder[Test, String] = testDecoder
52+
deriveCsvRowDecoder[TestEmbed]
53+
}
54+
val testEmbedDefaultDecoder = {
55+
implicit val embedded: CsvRowDecoder[Test, String] = testDecoder
56+
deriveCsvRowDecoder[TestEmbedDefault]
57+
}
4558

4659
pureTest("case classes should be decoded properly by header name and not position") {
4760
expect(testDecoder(csvRow) == Right(Test(1, "test", Some(42)))) and
@@ -73,4 +86,16 @@ object CsvRowDecoderTest extends SimpleIOSuite {
7386
expect(testOptionRenameDecoder(csvRowNoJ) == Right(TestOptionRename("test", None, 1)))
7487
}
7588

89+
pureTest("case classes should be decoded respecting @CsvEmbed") {
90+
expect(testEmbedDecoder(csvRowA) == Right(TestEmbed(7, Test(1, "test", Some(42)))))
91+
}
92+
93+
pureTest("case classes should be handled with defaults on @CsvEmbed if nested fields are missing") {
94+
expect(testEmbedDefaultDecoder(csvRowAOnly) == Right(TestEmbedDefault(7)))
95+
}
96+
97+
pureTest("case classes should fail to decode on @CsvEmbed if nested fields are invalid") {
98+
expect(testEmbedDefaultDecoder(csvRowAInvalidI).isLeft)
99+
}
100+
76101
}

csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowEncoderTest.scala

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,14 +29,20 @@ object CsvRowEncoderTest extends SimpleIOSuite {
2929
val csvRowDefaultI = CsvRow.unsafe(NonEmptyList.of("", "test", "42"), NonEmptyList.of("i", "s", "j"))
3030
val csvRowEmptyJ =
3131
CsvRow.unsafe(NonEmptyList.of("1", "test", ""), NonEmptyList.of("i", "s", "j"))
32+
val csvRowA = CsvRow.unsafe(NonEmptyList.of("7", "1", "test", "42"), NonEmptyList.of("a", "i", "s", "j"))
3233

3334
case class Test(i: Int = 0, s: String, j: Option[Int])
3435
case class TestRename(i: Int, s: String, @CsvName("j") k: Int)
3536
case class TestOptionRename(i: Int, s: String, @CsvName("j") k: Option[Int])
37+
case class TestEmbed(a: Int, @CsvEmbed inner: Test)
3638

3739
val testEncoder = deriveCsvRowEncoder[Test]
3840
val testRenameEncoder = deriveCsvRowEncoder[TestRename]
3941
val testOptionRenameEncoder = deriveCsvRowEncoder[TestOptionRename]
42+
val testEmbedEncoder = {
43+
implicit val embedded: CsvRowEncoder[Test, String] = testEncoder
44+
deriveCsvRowEncoder[TestEmbed]
45+
}
4046

4147
pureTest("case classes should be encoded properly") {
4248
expect(testEncoder(Test(1, "test", Some(42))) == csvRow)
@@ -54,4 +60,8 @@ object CsvRowEncoderTest extends SimpleIOSuite {
5460
expect(testOptionRenameEncoder(TestOptionRename(1, "test", Some(42))) == csvRow)
5561
}
5662

63+
pureTest("case classes should be embedded if annotated with @CsvEmbed") {
64+
expect(testEmbedEncoder(TestEmbed(7, Test(1, "test", Some(42)))) == csvRowA)
65+
}
66+
5767
}

0 commit comments

Comments
 (0)