Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#45: Add @CsvEmbed #126

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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(_))

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

}
Original file line number Diff line number Diff line change
Expand Up @@ -27,80 +27,142 @@ 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)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a somewhat tricky case as it could be that some of the fields of the embedded value are present, some are invalid. In this edge case, it depends on the order of fields whether the default is taken or the error of an invalid field is bubbled up. The only way to mitigate this would be to introduce error-accumulating parsing (which would indeed be an interesting addition), but as the exact combination of

  • embedding
  • invalid fields for the embedded value
  • missing fields for the embedded value and
  • a default for the embedded field

should be extremely rare in the wild, I believe this is fine.

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,
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]])
: 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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -24,36 +24,62 @@ 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,
Key <: Symbol,
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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

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

}
Loading