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

re-engineering of fs2-data-csv #611

Draft
wants to merge 12 commits into
base: main
Choose a base branch
from
51 changes: 0 additions & 51 deletions csv/shared/src/main/scala/fs2/data/csv/CsvRow.scala

This file was deleted.

66 changes: 46 additions & 20 deletions csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala
Original file line number Diff line number Diff line change
@@ -1,27 +1,53 @@
/*
* Copyright 2024 fs2-data Project
*
* 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.syntax.all._
import cats._

sealed trait CsvRowDecoder[T] {
def apply(headers: List[String]): Either[DecoderError, RowDecoder[T]]
}

object CsvRowDecoder {
def as[T: CellDecoder](name: String): CsvRowDecoder[T] = new CsvRowDecoder[T] {
def apply(headers: List[String]) = {
val i = headers.toList.indexOf(name)
Either.cond(
i >= 0,
RowDecoder.instance { (row: Row) =>
row.values.toList.lift(i).toRight {
new HeaderSizeError(headers.size, row.values.size, row.line)
}.flatMap(CellDecoder[T].apply)
},
new DecoderError(s"unknown field $name")
)
}
}

private[csv] def instance[T](f: List[String] => Either[DecoderError, RowDecoder[T]]): CsvRowDecoder[T] = new CsvRowDecoder[T] {
def apply(headers: List[String]) = f(headers)
}

@inline
def apply[T: CsvRowDecoder[*, Header], Header]: CsvRowDecoder[T, Header] = implicitly[CsvRowDecoder[T, Header]]
implicit def decodeResultCsvRowDecoder[T](implicit
dec: CsvRowDecoder[T]): CsvRowDecoder[DecoderResult[T]] =
new CsvRowDecoder[DecoderResult[T]] {
override def apply(headers: List[String]): Either[DecoderError,RowDecoder[DecoderResult[T]]] =
dec(headers).map(_.map(_.asRight))
}

@inline
def instance[T, Header](f: CsvRow[Header] => DecoderResult[T]): CsvRowDecoder[T, Header] = f(_)
implicit val CsvRowDecoderInstances: Applicative[CsvRowDecoder] with SemigroupK[CsvRowDecoder] =
new Applicative[CsvRowDecoder] with SemigroupK[CsvRowDecoder] {
val a = Applicative[List[String] => *].compose(Applicative[Either[DecoderError, *]].compose(Applicative[RowDecoder]))
override def combineK[A](x: CsvRowDecoder[A], y: CsvRowDecoder[A]): CsvRowDecoder[A] =
instance((x.apply, y.apply).mapN(_ <+> _))

override def map[A, B](fa: CsvRowDecoder[A])(f: A => B): CsvRowDecoder[B] =
instance(a.map(fa.apply)(f))

def pure[A](x: A): CsvRowDecoder[A] =
instance(a.pure(x))

def ap[A, B](ff: CsvRowDecoder[A => B])(fa: CsvRowDecoder[A]): CsvRowDecoder[B] = {
instance(a.ap(ff.apply)(fa.apply))
}
}
}
68 changes: 46 additions & 22 deletions csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala
Original file line number Diff line number Diff line change
@@ -1,29 +1,53 @@
/*
* Copyright 2024 fs2-data Project
*
* 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.data.NonEmptyList
import cats.ContravariantMonoidal
import cats.syntax.all._

sealed trait CsvRowEncoder[T] {
def headers: List[String]
def encoder: RowEncoder[T]
}

object CsvRowEncoder {
private[csv] def instance[T](
headers: List[String],
encoder: RowEncoder[T]
) = {
def h = headers
def e = encoder
new CsvRowEncoder[T] {
def headers = h
def encoder = e
}
}

def apply[T](implicit ev: CsvRowEncoder[T]): CsvRowEncoder[T] = ev

implicit val csvRowEncoderInstances = new ContravariantMonoidal[CsvRowEncoder] {

override def product[A, B](fa: CsvRowEncoder[A], fb: CsvRowEncoder[B]): CsvRowEncoder[(A, B)] =
new CsvRowEncoder[(A, B)] {
override def headers: List[String] =
fa.headers |+| fb.headers.toList

override def encoder: RowEncoder[(A, B)] =
(fa.encoder, fb.encoder).tupled

}

override def contramap[A, B](fa: CsvRowEncoder[A])(f: B => A): CsvRowEncoder[B] =
new CsvRowEncoder[B] {

override def headers: List[String] = fa.headers

override def encoder: RowEncoder[B] = fa.encoder.contramap(f)

@inline
def apply[T: CsvRowEncoder[*, Header], Header]: CsvRowEncoder[T, Header] = implicitly[CsvRowEncoder[T, Header]]
}

@inline
def instance[T, Header](f: T => NonEmptyList[(Header, String)]): CsvRowEncoder[T, Header] =
(t: T) => CsvRow.fromNelHeaders(f(t))
override def unit: CsvRowEncoder[Unit] =
new CsvRowEncoder[Unit] {
def headers = Nil
def encoder = ContravariantMonoidal[RowEncoder].unit
}
}
}
29 changes: 0 additions & 29 deletions csv/shared/src/main/scala/fs2/data/csv/HasHeaders.scala

This file was deleted.

89 changes: 86 additions & 3 deletions csv/shared/src/main/scala/fs2/data/csv/Row.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,92 @@

package fs2.data.csv

import cats.data.NonEmptyList
import cats.syntax.all._

/** A CSV row
*
* Operations on columns can be performed using 0-based indices
*/
case class Row(values: List[String],
line: Option[Long] = None) {

/** Number of cells in the row. */
def size: Int = values.size

/**
* Set the line number for this row.
*/
def withLine(line: Option[Long]): Row =
copy(line = line)

/** Returns the content of the cell at `idx` if it exists.
* Returns `None` if `idx` is out of row bounds.
* An empty cell value results in `Some("")`.
*/
def at(idx: Int): Option[String] =
values.get(idx.toLong)

/** Returns the decoded content of the cell at `idx`.
* Fails if the index doesn't exist or cannot be decoded
* to the expected type.
*/
def asAt[T](idx: Int)(implicit decoder: CellDecoder[T]): DecoderResult[T] =
values.get(idx.toLong) match {
case Some(v) => decoder(v)
case None => Left(new DecoderError(s"unknown index $idx"))
}

/** Returns the decoded content of the cell at `idx` wrapped in Some if the cell is non-empty, None otherwise.
* Fails if the index doesn't exist or cannot be decoded
* to the expected type.
*/
@deprecated(message =
"Use `RowF.asOptionalAt` instead, as it gives more flexibility and has the same default behavior.",
since = "fs2-data 1.7.0")
def asNonEmptyAt[T](idx: Int)(implicit decoder: CellDecoder[T]): DecoderResult[Option[T]] =
asOptionalAt(idx)

/** Returns the decoded content of the cell at `idx` wrapped in Some if the cell is non-empty, `None` otherwise.
* The meaning of _empty_ can be tuned by setting providing a custom `isEmpty` predicate (by default, matches the empty string).
* In case the index does not exist, the `missing` parameter defines the behavior (by default, it faile)
* Fails if the index cannot be decoded to the expected type.
*/
def asOptionalAt[T](
idx: Int,
missing: Int => DecoderResult[Option[T]] = (idx: Int) => Left(new DecoderError(s"unknown index $idx")),
isEmpty: String => Boolean = _.isEmpty)(implicit decoder: CellDecoder[T]): DecoderResult[Option[T]] =
values.get(idx.toLong) match {
case Some(v) if isEmpty(v) => Right(None)
case Some(v) => decoder.apply(v).map(Some(_))
case None => missing(idx)
}

/** Modifies the cell content at the given `idx` using the function `f`.
*/
def modifyAt(idx: Int)(f: String => String): Row =
if (idx < 0 || idx >= values.size)
this
else
new Row(values.zipWithIndex.map { case (cell, i) => if (i === idx) f(cell) else cell })

/** Returns the row with the cell at `idx` modified to `value`. */
def updatedAt(idx: Int, value: String): Row =
modifyAt(idx)(_ => value)

/** Returns the row without the cell at the given `idx`.
* If the resulting row is empty, returns `None`.
*/
def deleteAt(idx: Int): Row =
if (idx < 0 || idx >= values.size) {
this
} else {
val (before, after) = values.toList.splitAt(idx)
this.copy(values = before ++ after.tail)
}

}

object Row {
def apply(values: NonEmptyList[String], line: Option[Long] = None): Row = new Row(values, None, line)
def unapply(arg: Row): Some[NonEmptyList[String]] = Some(arg.values)
def unapply(row: Row): Option[List[String]] =
Some(row.values)
}
Loading
Loading