Skip to content
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
Expand Up @@ -16,18 +16,25 @@

package fs2.data.csv.generic.internal

import fs2.data.csv.CsvRowEncoder
import cats.data.NonEmptyList
import fs2.data.csv.{CsvRow, CsvRowEncoder, StaticHeaders}
import fs2.data.csv.generic.CsvName
import shapeless._

trait DerivedCsvRowEncoder[T] extends CsvRowEncoder[T, String]
trait DerivedCsvRowEncoder[T] extends CsvRowEncoder[T, String] with StaticHeaders[T, String]

object DerivedCsvRowEncoder {

final implicit def productWriter[T, Repr <: HList, AnnoRepr <: HList](implicit
gen: LabelledGeneric.Aux[T, Repr],
annotations: Annotations.Aux[CsvName, T, AnnoRepr],
cc: Lazy[MapShapedCsvRowEncoder.WithAnnotations[Repr, AnnoRepr]]): DerivedCsvRowEncoder[T] =
(elem: T) => cc.value.fromWithAnnotation(gen.to(elem), annotations())
cc: Lazy[MapShapedCsvRowEncoder.WithAnnotations[Repr, AnnoRepr]]): DerivedCsvRowEncoder[T] = {
val annos = annotations()
new DerivedCsvRowEncoder[T] {
override val headers: NonEmptyList[String] = NonEmptyList.fromListUnsafe(cc.value.headers(annos))
override def apply(elem: T): CsvRow[String] =
CsvRow.unsafe(NonEmptyList.fromListUnsafe(cc.value.rawRow(gen.to(elem))), headers)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,8 @@

package fs2.data.csv.generic.internal

import cats.data.NonEmptyList
import fs2.data.csv.generic.CsvName
import fs2.data.csv.{CellEncoder, CsvRow, CsvRowEncoder}
import fs2.data.csv.{CellEncoder, CsvRowEncoder}
import shapeless._
import shapeless.labelled._

Expand All @@ -30,26 +29,33 @@ object MapShapedCsvRowEncoder extends LowPrioMapShapedCsvRowEncoderImplicits {
Last: CellEncoder[Repr],
ev: <:<[Anno, Option[CsvName]],
witness: Witness.Aux[Key]): WithAnnotations[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)))
new WithAnnotations[FieldType[Key, Repr] :: HNil, Anno :: HNil] {
override def headers(annotation: Anno :: HNil): List[String] =
annotation.head.fold(witness.value.name)(_.name) :: Nil
override def rawRow(repr: FieldType[Key, Repr] :: HNil): List[String] =
Last(repr.head) :: Nil
}

}

private[generic] trait LowPrioMapShapedCsvRowEncoderImplicits {
trait WithAnnotations[Repr, AnnoRepr] {
def fromWithAnnotation(row: Repr, annotation: AnnoRepr): CsvRow[String]
def headers(annotation: AnnoRepr): List[String]
def rawRow(repr: Repr): List[String]
Comment thread
satabin marked this conversation as resolved.
}

implicit def hconsRowEncoder[Key <: Symbol, Head, Tail <: HList, DefaultTail <: HList, Anno, AnnoTail <: HList](
implicit
witness: Witness.Aux[Key],
Head: CellEncoder[Head],
ev: <:<[Anno, Option[CsvName]],
Tail: Lazy[WithAnnotations[Tail, AnnoTail]]): WithAnnotations[FieldType[Key, Head] :: Tail, Anno :: AnnoTail] =
(row: FieldType[Key, Head] :: Tail, annotation: Anno :: AnnoTail) => {
val tailRow = Tail.value.fromWithAnnotation(row.tail, annotation.tail)
CsvRow.unsafe(NonEmptyList(Head(row.head), tailRow.values.toList),
NonEmptyList(annotation.head.fold(witness.value.name)(_.name), tailRow.headers.get.toList))
Tail: Lazy[WithAnnotations[Tail, AnnoTail]]): WithAnnotations[FieldType[Key, Head] :: Tail, Anno :: AnnoTail] = {
val tail = Tail.value
new WithAnnotations[FieldType[Key, Head] :: Tail, Anno :: AnnoTail] {
override def headers(annotation: Anno :: AnnoTail): List[String] =
annotation.head.fold(witness.value.name)(_.name) :: tail.headers(annotation.tail)
override def rawRow(repr: FieldType[Key, Head] :: Tail): List[String] =
Head(repr.head) :: tail.rawRow(repr.tail)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ object semiauto {
def deriveCsvRowDecoder[T](implicit T: Lazy[DerivedCsvRowDecoder[T]]): CsvRowDecoder[T, String] =
T.value

def deriveCsvRowEncoder[T](implicit T: Lazy[DerivedCsvRowEncoder[T]]): CsvRowEncoder[T, String] =
def deriveCsvRowEncoder[T](implicit T: Lazy[DerivedCsvRowEncoder[T]]): StaticCsvRowEncoder[T, String] =
T.value

def deriveCellDecoder[T](implicit T: Lazy[DerivedCellDecoder[T]]): CellDecoder[T] =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ import scala.annotation.nowarn

def deriveRowEncoder[T](using ic: K0.ProductInstances[CellEncoder, T]): RowEncoder[T] = new RowEncoder[T] {
override def apply(elem: T): Row = Row(
cats.data.NonEmptyList
NonEmptyList
.fromListUnsafe(ic.foldLeft(elem)(List.empty[String])([t] =>
(acc: List[String], ce: CellEncoder[t], e: t) => Continue[List[String]](ce(e) :: acc)))
.reverse)
Expand All @@ -76,15 +76,18 @@ import scala.annotation.nowarn
}

def deriveCsvRowEncoder[T](using ic: K0.ProductInstances[CellEncoder, T], naming: Names[T]) =
new CsvRowEncoder[T, String] {
new CsvRowEncoder[T, String] with StaticHeaders[T, String] {
val names: List[String] = naming.names
type Acc = (List[String], List[(String, String)])

override val headers: NonEmptyList[String] = NonEmptyList.fromListUnsafe(names)

override def apply(elem: T): CsvRow[String] = {
val columns = ic
.foldLeft[Acc](elem)(names -> List.empty[(String, String)])([t] =>
(acc: Acc, ce: CellEncoder[t], e: t) => Continue((acc._1.tail, ((acc._1.head -> ce(e)) :: acc._2))))
._2
CsvRow.fromNelHeaders(cats.data.NonEmptyList.fromListUnsafe(columns.reverse))
CsvRow.fromNelHeaders(NonEmptyList.fromListUnsafe(columns.reverse))
}
}

Expand All @@ -104,7 +107,7 @@ import scala.annotation.nowarn

private[generic] def deriveCsvRowEncoder[T](ic: K0.ProductInstances[CellEncoder, T],
labels: Labelling[T],
annotations: Annotations[CsvName, T]): CsvRowEncoder[T, String] = {
annotations: Annotations[CsvName, T]): StaticCsvRowEncoder[T, String] = {
given Labelling[T] = labels
given Annotations[CsvName, T] = annotations
deriveCsvRowEncoder[T](using ic = ic, naming = summon[Names[T]])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,10 @@ object CsvRowEncoderTest extends SimpleIOSuite {
expect(testOptionRenameEncoder(TestOptionRename(1, "test", Some(42))) == csvRow)
}

pureTest("headers should be available statically") {
forEach(List(testEncoder, testRenameEncoder, testOptionRenameEncoder)) { encoder =>
expect(encoder.headers === NonEmptyList.of("i", "s", "j"))
}
}

}
4 changes: 2 additions & 2 deletions csv/shared/src/main/scala/fs2/data/csv/RowF.scala
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,10 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String],
order: Order[Header]): NonEmptyMap[Header, String] =
headers.get.zip(values).toNem

/** Drop all headers (if any).
/** Drop all headers (if any). Retains the line number if set.
* @return a row without headers, but same values
*/
def dropHeaders: Row = Row(values)
def dropHeaders: Row = Row(values, line)
Comment thread
satabin marked this conversation as resolved.

// let's cache this to avoid recomputing it for every call to `as` or similar method
// the `Option.get` call is safe since this field is only called in a context where a `HasHeaders`
Expand Down
46 changes: 46 additions & 0 deletions csv/shared/src/main/scala/fs2/data/csv/StaticHeaders.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/*
* Copyright 2024 fs2-data Project
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I need to update that year...

*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package fs2.data.csv

import cats.Functor
import cats.data.NonEmptyList

/** Type class for types that have statically known headers. This is useful for encoding/decoding CSV rows
* where the headers are fixed and can be determined at compile time. */
trait StaticHeaders[T, H] {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I really think we need to drop this parametric header type for version 2, it makes things harder to read for no real benefit. Or do we have a strong point in favor of keeping it? I don't remember why I did that choice at the time...

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yeah, I think its weight is not justified by its value. Especially as we also don't use it in the generic module (which could also be generic over the header type via the type classes instead of being hardcoded to String, but isn't). And something like a NonEmptyMap[MyEnumType, String] (roughly isomorphic to our CsvRow) can be decoded from String headers almost as easily.

def headers: NonEmptyList[H]
}

object StaticHeaders {
@inline
def apply[T, H](implicit sh: StaticHeaders[T, H]): StaticHeaders[T, H] = sh

@inline
def instance[T, H](hs: NonEmptyList[H]): StaticHeaders[T, H] =
new StaticHeaders[T, H] {
override def headers: NonEmptyList[H] = hs
}

implicit def headerFunctor[T]: Functor[StaticHeaders[T, *]] = new Functor[StaticHeaders[T, *]] {
override def map[A, B](fa: StaticHeaders[T, A])(f: A => B): StaticHeaders[T, B] = instance(fa.headers.map(f))
}

implicit def forWriteableHeader[T, H](implicit
W: WriteableHeader[H],
H: StaticHeaders[T, H]): StaticHeaders[T, String] =
instance(WriteableHeader[H].apply(H.headers))
}
35 changes: 34 additions & 1 deletion csv/shared/src/main/scala/fs2/data/csv/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,18 @@ package object csv {
/** Describes how a row can be encoded from a value of the given type.
*/
@implicitNotFound(
"No implicit CsvRowEncoderF[H, found for type ${T}.\nYou can define one using CsvRowEncoderF[H, .instance, by calling contramap on another CsvRowEncoderF[H, or by using generic derivation for product types like case classes.\nFor that, add the fs2-data-csv-generic module to your dependencies and use either full-automatic derivation:\nimport fs2.data.csv.generic.auto._\nor the recommended semi-automatic derivation:\nimport fs2.data.csv.generic.semiauto._\nimplicit val csvRowEncoder: CsvRowEncoderF[H, [${T}] = deriveCsvRowEncoderF[H, \nMake sure to have instances of CellEncoder for every member type in scope.\n")
"No implicit CsvRowEncoder found for type ${T}.\nYou can define one using CsvRowEncoder.instance, by calling contramap on another CsvRowEncoder or by using generic derivation for product types like case classes.\nFor that, add the fs2-data-csv-generic module to your dependencies and use either full-automatic derivation:\nimport fs2.data.csv.generic.auto._\nor the recommended semi-automatic derivation:\nimport fs2.data.csv.generic.semiauto._\nimplicit val csvRowEncoder: CsvRowEncoder[${T}] = deriveCsvRowEncoder\nMake sure to have instances of CellEncoder for every member type in scope.\n")
type CsvRowEncoder[T, Header] = RowEncoderF[Some, T, Header]

/**
* Convenience type alias for a [[CsvRowEncoder]] that also has a [[StaticHeaders]] instance,
* so that the headers are determined solely by the type and not the input data.
*
* This type should not be taken as implicit parameter, prefer taking the two type class instances separately,
* but it can be useful as a return type for functions that need both capabilities.
*/
type StaticCsvRowEncoder[T, Header] = CsvRowEncoder[T, Header] & StaticHeaders[T, Header]

@nowarn
sealed trait QuoteHandling

Expand Down Expand Up @@ -261,6 +270,30 @@ package object csv {
}
}

/** Encode a specified type into a CSV using the headers from the [[StaticHeaders]] instance.
* If the input is empty, the headers will still be emitted. */
def encodeUsingStaticHeaders[T]: PartiallyAppliedEncodeUsingStaticHeaders[T] =
new PartiallyAppliedEncodeUsingStaticHeaders(dummy = true)

@nowarn
class PartiallyAppliedEncodeUsingStaticHeaders[T](val dummy: Boolean) extends AnyVal {
def apply[F[_]: RaiseThrowable, Header](fullRows: Boolean = false,
separator: Char = ',',
newline: String = "\n",
escape: EscapeMode = EscapeMode.Auto)(implicit
T: CsvRowEncoder[T, Header],
SH: StaticHeaders[T, Header],
H: WriteableHeader[Header]): Pipe[F, T, String] = {
val stringPipe =
if (fullRows) lowlevel.toRowStrings[F](separator, newline, escape)
else lowlevel.toStrings[F](separator, newline, escape)
lowlevel.encodeRow[F, Header, T] andThen
(_.map(_.dropHeaders)) andThen
lowlevel.writeWithGivenHeaders[F, Header](SH.headers) andThen
stringPipe
}
}

/** Low level pipes for CSV handling. All pipes only perform one step in a CSV (de)serialization pipeline,
* so use these if you want to customise. All standard use cases should be covered by the higher level pipes directly
* on the csv package which are composed of the lower level ones here.
Expand Down
35 changes: 35 additions & 0 deletions csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -507,4 +507,39 @@ object CsvParserTest extends SimpleIOSuite {
case Left(x) => failure(s"Stream failed with value $x")
}
}

test("empty CSV with headers should round-trip correctly") {
val content = "name,age,description\n"

case class Data(name: String, age: Int, description: String)

implicit val dataDecoder: CsvRowDecoder[Data, String] = (row: CsvRow[String]) =>
(
row.as[String]("name"),
row.as[Int]("age"),
row.as[String]("description")
).mapN(Data.apply)

implicit val dataEncoder: CsvRowEncoder[Data, String] with StaticHeaders[Data, String] =
new CsvRowEncoder[Data, String] with StaticHeaders[Data, String] {
override val headers: NonEmptyList[String] = NonEmptyList.of("name", "age", "description")

override def apply(elem: Data): CsvRow[String] = CsvRow.fromNelHeaders(
NonEmptyList.of(
"name" -> elem.name,
"age" -> elem.age.toString,
"description" -> elem.description
)
)
}

Stream
.emit(content)
.covary[IO]
.through(decodeUsingHeaders[Data]())
.through(encodeUsingStaticHeaders[Data]())
.compile
.string
.map(actual => expect.eql(content, actual))
}
}
Loading