From 35f30e34e5c8a91660fd27cd6b71d5b9f4f2dd5f Mon Sep 17 00:00:00 2001 From: Yannick Heiber Date: Tue, 2 Mar 2021 22:21:31 +0100 Subject: [PATCH] #45: Add CsvEmbed annotation Allows to map nested case class structures from and to flat CSV. Fixes #45. --- .../scala/fs2/data/csv/generic/CsvEmbed.scala | 7 + .../csv/generic/DerivedCsvRowDecoder.scala | 14 +- .../csv/generic/DerivedCsvRowEncoder.scala | 9 +- .../csv/generic/MapShapedCsvRowDecoder.scala | 150 +++++++++++++----- .../csv/generic/MapShapedCsvRowEncoder.scala | 58 +++++-- .../data/csv/generic/CsvRowDecoderTest.scala | 25 +++ .../data/csv/generic/CsvRowEncoderTest.scala | 10 ++ .../src/main/scala/fs2/data/csv/RowF.scala | 13 +- .../main/scala/fs2/data/csv/exceptions.scala | 8 + documentation/docs/csv/generic.md | 29 ++++ 10 files changed, 250 insertions(+), 73 deletions(-) create mode 100644 csv/generic/src/main/scala/fs2/data/csv/generic/CsvEmbed.scala diff --git a/csv/generic/src/main/scala/fs2/data/csv/generic/CsvEmbed.scala b/csv/generic/src/main/scala/fs2/data/csv/generic/CsvEmbed.scala new file mode 100644 index 000000000..ef1b7d4bd --- /dev/null +++ b/csv/generic/src/main/scala/fs2/data/csv/generic/CsvEmbed.scala @@ -0,0 +1,7 @@ +package fs2.data.csv.generic + +import scala.annotation.Annotation + +/** Mark a field of a case class to be embedded (= to be parsed from the same row, but inlined as value) + */ +case class CsvEmbed() extends Annotation diff --git a/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowDecoder.scala b/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowDecoder.scala index 282b1c509..3bc1a842e 100644 --- a/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowDecoder.scala +++ b/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowDecoder.scala @@ -24,14 +24,14 @@ trait DerivedCsvRowDecoder[T] extends CsvRowDecoder[T, String] object DerivedCsvRowDecoder { - final implicit def productReader[T, Repr <: HList, DefaultRepr <: HList, AnnoRepr <: HList](implicit + final implicit def productReader[T, Repr <: HList, DefaultRepr <: HList, NamesAnno <: HList, EmbedsAnno <: HList]( + implicit gen: LabelledGeneric.Aux[T, Repr], defaults: Default.AsOptions.Aux[T, DefaultRepr], - annotations: Annotations.Aux[CsvName, T, AnnoRepr], - cc: Lazy[MapShapedCsvRowDecoder.WithDefaults[T, Repr, DefaultRepr, AnnoRepr]]): DerivedCsvRowDecoder[T] = - new DerivedCsvRowDecoder[T] { - def apply(row: CsvRow[String]): DecoderResult[T] = - cc.value.fromWithDefault(row, defaults(), annotations()).map(gen.from(_)) - } + names: Annotations.Aux[CsvName, T, NamesAnno], + embeds: Annotations.Aux[CsvEmbed, T, EmbedsAnno], + cc: Lazy[MapShapedCsvRowDecoder.WithDefaults[T, Repr, DefaultRepr, NamesAnno, EmbedsAnno]]) + : DerivedCsvRowDecoder[T] = + (row: CsvRow[String]) => cc.value.fromWithDefault(row, defaults(), names(), embeds()).map(gen.from(_)) } diff --git a/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowEncoder.scala b/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowEncoder.scala index 8f58eb848..b0ff0bc53 100644 --- a/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowEncoder.scala +++ b/csv/generic/src/main/scala/fs2/data/csv/generic/DerivedCsvRowEncoder.scala @@ -24,10 +24,11 @@ trait DerivedCsvRowEncoder[T] extends CsvRowEncoder[T, String] object DerivedCsvRowEncoder { - final implicit def productWriter[T, Repr <: HList, AnnoRepr <: HList](implicit + final implicit def productWriter[T, Repr <: HList, NameAnno <: HList, EmbedAnno <: HList](implicit gen: LabelledGeneric.Aux[T, Repr], - annotations: Annotations.Aux[CsvName, T, AnnoRepr], - cc: Lazy[MapShapedCsvRowEncoder.WithAnnotations[T, Repr, AnnoRepr]]): DerivedCsvRowEncoder[T] = - (elem: T) => cc.value.fromWithAnnotation(gen.to(elem), annotations()) + names: Annotations.Aux[CsvName, T, NameAnno], + embeds: Annotations.Aux[CsvEmbed, T, EmbedAnno], + cc: Lazy[MapShapedCsvRowEncoder.WithAnnotations[T, Repr, NameAnno, EmbedAnno]]): DerivedCsvRowEncoder[T] = + (elem: T) => cc.value.fromWithAnnotation(gen.to(elem), names(), embeds()) } diff --git a/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowDecoder.scala b/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowDecoder.scala index fe835c45f..8aebd0a9a 100644 --- a/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowDecoder.scala +++ b/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowDecoder.scala @@ -27,51 +27,79 @@ trait MapShapedCsvRowDecoder[Repr] extends CsvRowDecoder[Repr, String] object MapShapedCsvRowDecoder extends LowPriorityMapShapedCsvRowDecoder1 { - implicit def hnilRowDecoder[Wrapped]: WithDefaults[Wrapped, HNil, HNil, HNil] = - new WithDefaults[Wrapped, HNil, HNil, HNil] { - def fromWithDefault(row: CsvRow[String], default: HNil, annotation: HNil): DecoderResult[HNil] = - Right(HNil) - } + implicit def hnilRowDecoder[Wrapped]: WithDefaults[Wrapped, HNil, HNil, HNil, HNil] = + (_: CsvRow[String], _: HNil, _: HNil, _: HNil) => Right(HNil) implicit def optionHconsRowDecoder[Wrapped, Key <: Symbol, Head, Tail <: HList, DefaultTail <: HList, - Anno, - AnnoTail <: HList](implicit + Name, + NamesTail <: HList, + EmbedTail <: HList](implicit witness: Witness.Aux[Key], Head: CellDecoder[Head], - ev: <:<[Anno, Option[CsvName]], - Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, AnnoTail]]) + ev: <:<[Name, Option[CsvName]], + Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]]) : WithDefaults[Wrapped, FieldType[Key, Option[Head]] :: Tail, Option[Option[Head]] :: DefaultTail, - Anno :: AnnoTail] = - new WithDefaults[Wrapped, + Name :: NamesTail, + None.type :: EmbedTail] = + (row: CsvRow[String], + default: Option[Option[Head]] :: DefaultTail, + names: Name :: NamesTail, + embeds: None.type :: EmbedTail) => { + val head = row(names.head.fold(witness.value.name)(_.name)) match { + case Some(head) if head.nonEmpty => Head(head).map(Some(_)) + case _ => Right(default.head.flatten) + } + for { + head <- head + tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail) + } yield field[Key](head) :: tail + } + + implicit def optionHconsEmbedRowDecoder[Wrapped, + Key <: Symbol, + Head, + Tail <: HList, + DefaultTail <: HList, + NamesTail <: HList, + EmbedTail <: HList](implicit + witness: Witness.Aux[Key], + Head: CsvRowDecoder[Option[Head], String], + Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]]) + : WithDefaults[Wrapped, FieldType[Key, Option[Head]] :: Tail, Option[Option[Head]] :: DefaultTail, - Anno :: AnnoTail] { - def fromWithDefault(row: CsvRow[String], - default: Option[Option[Head]] :: DefaultTail, - anno: Anno :: AnnoTail): DecoderResult[FieldType[Key, Option[Head]] :: Tail] = { - val head = row(anno.head.fold(witness.value.name)(_.name)) match { - case Some(head) if head.nonEmpty => Head(head).map(Some(_)) - case _ => Right(default.head.flatten) + None.type :: NamesTail, + Some[CsvEmbed] :: EmbedTail] = + (row: CsvRow[String], + default: Option[Option[Head]] :: DefaultTail, + names: None.type :: NamesTail, + embeds: Some[CsvEmbed] :: EmbedTail) => { + for { + head <- (Head(row), default.head) match { + case (r @ Right(_), _) => r + case (Left(_: DecoderError.ColumnMissing), Some(default)) => Right(default) + case (Left(_: DecoderError.ColumnMissing), None) => Right(None) + case (l @ Left(_), _) => l } - for { - head <- head - tail <- Tail.value.fromWithDefault(row, default.tail, anno.tail) - } yield field[Key](head) :: tail - } + tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail) + } yield field[Key](head) :: tail } } trait LowPriorityMapShapedCsvRowDecoder1 { - trait WithDefaults[Wrapped, Repr, DefaultRepr, AnnoRepr] { - def fromWithDefault(row: CsvRow[String], default: DefaultRepr, annotation: AnnoRepr): DecoderResult[Repr] + trait WithDefaults[Wrapped, Repr, DefaultRepr, NameAnno, EmbedAnno] { + def fromWithDefault(row: CsvRow[String], + default: DefaultRepr, + names: NameAnno, + embeds: EmbedAnno): DecoderResult[Repr] } implicit def hconsRowDecoder[Wrapped, @@ -79,28 +107,62 @@ trait LowPriorityMapShapedCsvRowDecoder1 { Head, Tail <: HList, DefaultTail <: HList, - Anno, - AnnoTail <: HList](implicit + Name, + NamesTail <: HList, + EmbedTail <: HList](implicit witness: Witness.Aux[Key], Head: CellDecoder[Head], - ev: <:<[Anno, Option[CsvName]], - Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, AnnoTail]]) - : WithDefaults[Wrapped, FieldType[Key, Head] :: Tail, Option[Head] :: DefaultTail, Anno :: AnnoTail] = - new WithDefaults[Wrapped, FieldType[Key, Head] :: Tail, Option[Head] :: DefaultTail, Anno :: AnnoTail] { - def fromWithDefault(row: CsvRow[String], - default: Option[Head] :: DefaultTail, - anno: Anno :: AnnoTail): DecoderResult[FieldType[Key, Head] :: Tail] = { - val head = row(anno.head.fold(witness.value.name)(_.name)) match { - case Some(head) if head.nonEmpty => - Head(head) - case _ => - default.head.liftTo[DecoderResult](new DecoderError(s"unknown column name '${witness.value.name}'")) - } - for { - head <- head - tail <- Tail.value.fromWithDefault(row, default.tail, anno.tail) - } yield field[Key](head) :: tail + ev: <:<[Name, Option[CsvName]], + Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]]) + : WithDefaults[Wrapped, + FieldType[Key, Head] :: Tail, + Option[Head] :: DefaultTail, + Name :: NamesTail, + None.type :: EmbedTail] = + (row: CsvRow[String], + default: Option[Head] :: DefaultTail, + names: Name :: NamesTail, + embeds: None.type :: EmbedTail) => { + val head = row(names.head.fold(witness.value.name)(_.name)) match { + case Some(head) if head.nonEmpty => + Head(head) + case _ => + default.head.liftTo[DecoderResult]( + new DecoderError.ColumnMissing(s"unknown column name '${witness.value.name}'")) } + for { + head <- head + tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail) + } yield field[Key](head) :: tail + } + + implicit def hconsEmbedRowDecoder[Wrapped, + Key <: Symbol, + Head, + Tail <: HList, + DefaultTail <: HList, + NamesTail <: HList, + EmbedTail <: HList](implicit + witness: Witness.Aux[Key], + Head: CsvRowDecoder[Head, String], + Tail: Lazy[WithDefaults[Wrapped, Tail, DefaultTail, NamesTail, EmbedTail]]) + : WithDefaults[Wrapped, + FieldType[Key, Head] :: Tail, + Option[Head] :: DefaultTail, + None.type :: NamesTail, + Some[CsvEmbed] :: EmbedTail] = + (row: CsvRow[String], + default: Option[Head] :: DefaultTail, + names: None.type :: NamesTail, + embeds: Some[CsvEmbed] :: EmbedTail) => { + for { + head <- (Head(row), default.head) match { + case (r @ Right(_), _) => r + case (Left(_: DecoderError.ColumnMissing), Some(default)) => Right(default) + case (l @ Left(_), _) => l + } + tail <- Tail.value.fromWithDefault(row, default.tail, names.tail, embeds.tail) + } yield field[Key](head) :: tail } } diff --git a/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowEncoder.scala b/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowEncoder.scala index 309fd4dd9..111eb399b 100644 --- a/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowEncoder.scala +++ b/csv/generic/src/main/scala/fs2/data/csv/generic/MapShapedCsvRowEncoder.scala @@ -24,19 +24,26 @@ trait MapShapedCsvRowEncoder[Repr] extends CsvRowEncoder[Repr, String] object MapShapedCsvRowEncoder extends LowPrioMapShapedCsvRowEncoderImplicits { - implicit def lastElemRowEncoder[Wrapped, Repr, Anno, Key <: Symbol](implicit + implicit def lastElemRowEncoder[Wrapped, Repr, NameAnno, Key <: Symbol](implicit Last: CellEncoder[Repr], - ev: <:<[Anno, Option[CsvName]], - witness: Witness.Aux[Key]): WithAnnotations[Wrapped, FieldType[Key, Repr] :: HNil, Anno :: HNil] = - (row: Repr :: HNil, annotation: Anno :: HNil) => - CsvRow.unsafe(NonEmptyList.one(Last(row.head)), - NonEmptyList.one(annotation.head.fold(witness.value.name)(_.name))) + ev: <:<[NameAnno, Option[CsvName]], + witness: Witness.Aux[Key]) + : WithAnnotations[Wrapped, FieldType[Key, Repr] :: HNil, NameAnno :: HNil, None.type :: HNil] = + (row: Repr :: HNil, names: NameAnno :: HNil, _: None.type :: HNil) => + CsvRow.unsafe(NonEmptyList.one(Last(row.head)), NonEmptyList.one(names.head.fold(witness.value.name)(_.name))) + + implicit def lastElemEmbedRowEncoder[Wrapped, Repr, NameAnno, Key <: Symbol](implicit + Last: CsvRowEncoder[Repr, String], + names: <:<[NameAnno, None.type], // renaming is mutually exclusive with embedding + witness: Witness.Aux[Key]) + : WithAnnotations[Wrapped, FieldType[Key, Repr] :: HNil, NameAnno :: HNil, Some[CsvEmbed] :: HNil] = + (row: Repr :: HNil, _: NameAnno :: HNil, _: Some[CsvEmbed] :: HNil) => Last(row.head) } trait LowPrioMapShapedCsvRowEncoderImplicits { - trait WithAnnotations[Wrapped, Repr, AnnoRepr] { - def fromWithAnnotation(row: Repr, annotation: AnnoRepr): CsvRow[String] + trait WithAnnotations[Wrapped, Repr, NameAnnoRepr, EmbedAnnoRepr] { + def fromWithAnnotation(row: Repr, names: NameAnnoRepr, embeds: EmbedAnnoRepr): CsvRow[String] } implicit def hconsRowEncoder[Wrapped, @@ -44,16 +51,35 @@ trait LowPrioMapShapedCsvRowEncoderImplicits { Head, Tail <: HList, DefaultTail <: HList, - Anno, - AnnoTail <: HList](implicit + NameAnno, + NameAnnoTail <: HList, + EmbedAnnoTail <: HList](implicit witness: Witness.Aux[Key], Head: CellEncoder[Head], - ev: <:<[Anno, Option[CsvName]], - Tail: Lazy[WithAnnotations[Wrapped, Tail, AnnoTail]]) - : WithAnnotations[Wrapped, FieldType[Key, Head] :: Tail, Anno :: AnnoTail] = - (row: FieldType[Key, Head] :: Tail, annotation: Anno :: AnnoTail) => { - val tailRow = Tail.value.fromWithAnnotation(row.tail, annotation.tail) + ev: <:<[NameAnno, Option[CsvName]], + Tail: Lazy[WithAnnotations[Wrapped, Tail, NameAnnoTail, EmbedAnnoTail]]) + : WithAnnotations[Wrapped, FieldType[Key, Head] :: Tail, NameAnno :: NameAnnoTail, None.type :: EmbedAnnoTail] = + (row: FieldType[Key, Head] :: Tail, names: NameAnno :: NameAnnoTail, embeds: None.type :: EmbedAnnoTail) => { + val tailRow = Tail.value.fromWithAnnotation(row.tail, names.tail, embeds.tail) CsvRow.unsafe(NonEmptyList(Head(row.head), tailRow.values.toList), - NonEmptyList(annotation.head.fold(witness.value.name)(_.name), tailRow.headers.get.toList)) + NonEmptyList(names.head.fold(witness.value.name)(_.name), tailRow.headers.get.toList)) } + + implicit def hconsEmbedRowEncoder[Wrapped, + Key <: Symbol, + Head, + Tail <: HList, + DefaultTail <: HList, + NameAnnoTail <: HList, + EmbedAnnoTail <: HList](implicit + witness: Witness.Aux[Key], + Head: CsvRowEncoder[Head, String], + names: <:<[None.type, Option[CsvName]], // renaming is mutually exclusive with embedding + Tail: Lazy[WithAnnotations[Wrapped, Tail, NameAnnoTail, EmbedAnnoTail]]) + : WithAnnotations[Wrapped, + FieldType[Key, Head] :: Tail, + None.type :: NameAnnoTail, + Some[CsvEmbed] :: EmbedAnnoTail] = + (row: FieldType[Key, Head] :: Tail, names: None.type :: NameAnnoTail, embeds: Some[CsvEmbed] :: EmbedAnnoTail) => + Head(row.head) ::: Tail.value.fromWithAnnotation(row.tail, names.tail, embeds.tail) } diff --git a/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowDecoderTest.scala b/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowDecoderTest.scala index 326f79b72..987ac8c7b 100644 --- a/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowDecoderTest.scala +++ b/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowDecoderTest.scala @@ -32,16 +32,29 @@ object CsvRowDecoderTest extends SimpleIOSuite { CsvRow.unsafe(NonEmptyList.of("1", "test", ""), NonEmptyList.of("i", "s", "j")) val csvRowNoJ = CsvRow.unsafe(NonEmptyList.of("1", "test"), NonEmptyList.of("i", "s")) + val csvRowA = CsvRow.unsafe(NonEmptyList.of("7", "1", "test", "42"), NonEmptyList.of("a", "i", "s", "j")) + val csvRowAOnly = CsvRow.unsafe(NonEmptyList.of("7"), NonEmptyList.of("a")) + val csvRowAInvalidI = CsvRow.unsafe(NonEmptyList.of("7", "no-int", "test", "42"), NonEmptyList.of("a", "i", "s", "j")) case class Test(i: Int = 0, s: String, j: Option[Int]) case class TestOrder(s: String, j: Int, i: Int) case class TestRename(s: String, @CsvName("j") k: Int, i: Int) case class TestOptionRename(s: String, @CsvName("j") k: Option[Int], i: Int) + case class TestEmbed(a: Int, @CsvEmbed inner: Test) + case class TestEmbedDefault(a: Int, @CsvEmbed inner: Test = Test(0, "", None)) val testDecoder = deriveCsvRowDecoder[Test] val testOrderDecoder = deriveCsvRowDecoder[TestOrder] val testRenameDecoder = deriveCsvRowDecoder[TestRename] val testOptionRenameDecoder = deriveCsvRowDecoder[TestOptionRename] + val testEmbedDecoder = { + implicit val embedded: CsvRowDecoder[Test, String] = testDecoder + deriveCsvRowDecoder[TestEmbed] + } + val testEmbedDefaultDecoder = { + implicit val embedded: CsvRowDecoder[Test, String] = testDecoder + deriveCsvRowDecoder[TestEmbedDefault] + } pureTest("case classes should be decoded properly by header name and not position") { expect(testDecoder(csvRow) == Right(Test(1, "test", Some(42)))) and @@ -73,4 +86,16 @@ object CsvRowDecoderTest extends SimpleIOSuite { expect(testOptionRenameDecoder(csvRowNoJ) == Right(TestOptionRename("test", None, 1))) } + pureTest("case classes should be decoded respecting @CsvEmbed") { + expect(testEmbedDecoder(csvRowA) == Right(TestEmbed(7, Test(1, "test", Some(42))))) + } + + pureTest("case classes should be handled with defaults on @CsvEmbed if nested fields are missing") { + expect(testEmbedDefaultDecoder(csvRowAOnly) == Right(TestEmbedDefault(7))) + } + + pureTest("case classes should fail to decode on @CsvEmbed if nested fields are invalid") { + expect(testEmbedDefaultDecoder(csvRowAInvalidI).isLeft) + } + } diff --git a/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowEncoderTest.scala b/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowEncoderTest.scala index b896fc81f..b764f162b 100644 --- a/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowEncoderTest.scala +++ b/csv/generic/src/test/scala/fs2/data/csv/generic/CsvRowEncoderTest.scala @@ -29,14 +29,20 @@ object CsvRowEncoderTest extends SimpleIOSuite { val csvRowDefaultI = CsvRow.unsafe(NonEmptyList.of("", "test", "42"), NonEmptyList.of("i", "s", "j")) val csvRowEmptyJ = CsvRow.unsafe(NonEmptyList.of("1", "test", ""), NonEmptyList.of("i", "s", "j")) + val csvRowA = CsvRow.unsafe(NonEmptyList.of("7", "1", "test", "42"), NonEmptyList.of("a", "i", "s", "j")) case class Test(i: Int = 0, s: String, j: Option[Int]) case class TestRename(i: Int, s: String, @CsvName("j") k: Int) case class TestOptionRename(i: Int, s: String, @CsvName("j") k: Option[Int]) + case class TestEmbed(a: Int, @CsvEmbed inner: Test) val testEncoder = deriveCsvRowEncoder[Test] val testRenameEncoder = deriveCsvRowEncoder[TestRename] val testOptionRenameEncoder = deriveCsvRowEncoder[TestOptionRename] + val testEmbedEncoder = { + implicit val embedded: CsvRowEncoder[Test, String] = testEncoder + deriveCsvRowEncoder[TestEmbed] + } pureTest("case classes should be encoded properly") { expect(testEncoder(Test(1, "test", Some(42))) == csvRow) @@ -54,4 +60,8 @@ object CsvRowEncoderTest extends SimpleIOSuite { expect(testOptionRenameEncoder(TestOptionRename(1, "test", Some(42))) == csvRow) } + pureTest("case classes should be embedded if annotated with @CsvEmbed") { + expect(testEmbedEncoder(TestEmbed(7, Test(1, "test", Some(42)))) == csvRowA) + } + } diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowF.scala index 9c52a83f3..0bdff5c3d 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowF.scala @@ -128,12 +128,21 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], header def toMap(implicit hasHeaders: HasHeaders[H, Header]): Map[Header, String] = byHeader - /** - * Drop all headers (if any). + /** Drop all headers (if any). * @return a row without headers, but same values */ def dropHeaders: Row = Row(values) + /** Concat this row with another. Header types must match, headers must be distinct. + * @param other the row to append + * @return a row combining both + */ + def :::(other: RowF[H, Header]): RowF[H, Header] = + new RowF[H, Header]( + values ::: other.values, + (headers: Option[NonEmptyList[Header]], other.headers).mapN(_ ::: _).asInstanceOf[H[NonEmptyList[Header]]] + ) + private def byHeader(implicit hasHeaders: HasHeaders[H, Header]): Map[Header, String] = headers.get.toList.zip(values.toList).toMap diff --git a/csv/shared/src/main/scala/fs2/data/csv/exceptions.scala b/csv/shared/src/main/scala/fs2/data/csv/exceptions.scala index ea9055534..d380c465b 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/exceptions.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/exceptions.scala @@ -19,6 +19,14 @@ class CsvException(msg: String, inner: Throwable = null) extends Exception(msg, class DecoderError(msg: String, inner: Throwable = null) extends CsvException(msg, inner) +object DecoderError { + + /** Signals that a column was missing. Use this exception in your own decoders in this case to ensure + * correct behaviour when using defaults and @CsvEmbed. + */ + class ColumnMissing(msg: String, inner: Throwable = null) extends DecoderError(msg, inner) +} + class HeaderError(msg: String, inner: Throwable = null) extends CsvException(msg, inner) /** Raised when processing a Csv row whose width doesn't match the width of the Csv header row */ diff --git a/documentation/docs/csv/generic.md b/documentation/docs/csv/generic.md index f7a5308c6..19868a41f 100644 --- a/documentation/docs/csv/generic.md +++ b/documentation/docs/csv/generic.md @@ -131,5 +131,34 @@ val decoded = stream.through(headers[Fallible, String]).through(decodeRow[Fallib decoded.compile.toList ``` +#### Annotations for generic derivation on case classes +The derivation of `CsvRowDecoder` and `CsvRowEncoder` cam be influenced by two annotations: `CsvName` and `CsvEmbed`. + +`CsvName` maps a field of the case class to a given header name in the CSV file. Example: + +```scala mdoc:nest +import fs2.data.csv.generic.auto._ + +case class MyRowRenamed(@CsvName("i") a: Int, j: Int, s: String) + +val decoded = stream.through(headers[Fallible, String]).through(decodeRow[Fallible, String, MyRowRenamed]) +decoded.compile.toList +``` + +`CsvEmbed` allows for encoding of non-flat case class hierarchies into the flat structure of a CSV file and vice versa. +The CSV must contain columns for all fields in the hierarchy which are not annotated with `CsvEmbed` (or have defaults). Example: + +```scala mdoc:nest +import fs2.data.csv.generic.auto._ + +case class Idx(i: Int) +case class MyRowEmbed(@CsvEmbed ídx: Idx, j: Int, s: String) + +val decoded = stream.through(headers[Fallible, String]).through(decodeRow[Fallible, String, MyRowEmbed]) +decoded.compile.toList +``` + +NOTE: `CsvEmbed` and `CsvName` are mutually exclusive on the same field as the name of the embedded field is irrelevant. + [csv-doc]: /documentation/csv/ [shapeless]: https://github.com/milessabin/shapeless