From cd9eb5425e2ee40bf2d33fc8c7a701fa920cad2c Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 04:00:22 +0200 Subject: [PATCH 01/12] remove some junk --- .../src/main/scala/fs2/data/csv/RowDecoderF.scala | 8 +------- csv/shared/src/main/scala/fs2/data/csv/RowF.scala | 12 ------------ 2 files changed, 1 insertion(+), 19 deletions(-) diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala index 220d297c..025ae0fb 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala @@ -17,7 +17,7 @@ package fs2.data.csv import cats._ -import cats.data.{NonEmptyList, NonEmptyMap} +import cats.data.NonEmptyList import cats.syntax.all._ import scala.annotation.tailrec @@ -125,12 +125,6 @@ object RowDecoderF extends ExportedRowDecoderFs { def combineK[A](x: RowDecoderF[H, A, Header], y: RowDecoderF[H, A, Header]): RowDecoderF[H, A, Header] = x or y } - implicit def toMapCsvRowDecoder[Header]: CsvRowDecoder[Map[Header, String], Header] = - CsvRowDecoder.instance(_.toMap.asRight) - - implicit def toNonEmptyMapCsvRowDecoder[Header: Order]: CsvRowDecoder[NonEmptyMap[Header, String], Header] = - CsvRowDecoder.instance(_.toNonEmptyMap.asRight) - implicit val toListRowDecoder: RowDecoder[List[String]] = RowDecoder.instance(_.values.toList.asRight) 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 ad13e4f6..e630ec60 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowF.scala @@ -202,18 +202,6 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], case None => missing(header) } - /** Returns a representation of this row as Map from headers to corresponding cell values. - */ - def toMap(implicit @unused hasHeaders: HasHeaders[H, Header]): Map[Header, String] = - byHeader: @nowarn("msg=HasHeaders") - - /** Returns a representation of this row as NonEmptyMap from headers to corresponding cell values. - */ - def toNonEmptyMap(implicit - @unused hasHeaders: HasHeaders[H, Header], - order: Order[Header]): NonEmptyMap[Header, String] = - headers.get.zip(values).toNem - /** Drop all headers (if any). * @return a row without headers, but same values */ From 30a7355e5dde44822a69ae96578bae62db98bc31 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 04:02:26 +0200 Subject: [PATCH 02/12] remove CsvRowDecoder, CsvRowEncoder --- .../scala/fs2/data/csv/CsvRowDecoder.scala | 27 ---- .../scala/fs2/data/csv/CsvRowEncoder.scala | 29 ---- .../main/scala/fs2/data/csv/RowDecoderF.scala | 8 - .../main/scala/fs2/data/csv/RowEncoderF.scala | 14 +- .../src/main/scala/fs2/data/csv/package.scala | 153 ------------------ 5 files changed, 2 insertions(+), 229 deletions(-) delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala diff --git a/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala deleted file mode 100644 index 964a206c..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 - -object CsvRowDecoder { - - @inline - def apply[T: CsvRowDecoder[*, Header], Header]: CsvRowDecoder[T, Header] = implicitly[CsvRowDecoder[T, Header]] - - @inline - def instance[T, Header](f: CsvRow[Header] => DecoderResult[T]): CsvRowDecoder[T, Header] = f(_) - -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala deleted file mode 100644 index a183546e..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 - -object CsvRowEncoder { - - @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)) -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala index 025ae0fb..53bd5f4f 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala @@ -131,18 +131,10 @@ object RowDecoderF extends ExportedRowDecoderFs { implicit val toNelRowDecoder: RowDecoder[NonEmptyList[String]] = RowDecoder.instance(_.values.asRight) - // the following two can't be unified va RowDecoderF because type inference fails us - implicit def decodeResultCsvRowDecoder[Header, T](implicit - dec: CsvRowDecoder[T, Header]): CsvRowDecoder[DecoderResult[T], Header] = - r => Right(dec(r)) - implicit def decodeResultRowDecoder[T](implicit dec: RowDecoder[T]): RowDecoder[DecoderResult[T]] = r => Right(dec(r)) } trait ExportedRowDecoderFs { implicit def exportedRowDecoders[A](implicit exported: Exported[RowDecoder[A]]): RowDecoder[A] = exported.instance - - implicit def exportedCsvRowDecoders[A](implicit - exported: Exported[CsvRowDecoder[A, String]]): CsvRowDecoder[A, String] = exported.instance } diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala index cc1f0788..21ad7e04 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala @@ -31,7 +31,7 @@ import scala.annotation.{implicitNotFound, unused} def contramap[B](f: B => T): RowEncoderF[H, B, Header] = elem => apply(f(elem)) } -object RowEncoderF extends ExportedRowEncoderFs { +object RowEncoderF extends ExportedRowEncoders { implicit def identityRowEncoderF[H[+a] <: Option[a], Header]: RowEncoderF[H, RowF[H, Header], Header] = identity @@ -48,21 +48,11 @@ object RowEncoderF extends ExportedRowEncoderFs { @inline def instance[H[+a] <: Option[a], T, Header](f: T => RowF[H, Header]): RowEncoderF[H, T, Header] = f(_) - @deprecated("retained for bincompat", "1.6.0") - private[csv] implicit def fromNonEmptyMapCsvRowEncoder[Header](implicit - @unused ev1: Order[Header]): CsvRowEncoder[NonEmptyMap[Header, String], Header] = - CsvRowEncoder.instance(_.toNel) - - implicit def fromNonEmptyMapCsvRowEncoder[Header]: CsvRowEncoder[NonEmptyMap[Header, String], Header] = - CsvRowEncoder.instance(_.toNel) - implicit val fromNelRowEncoder: RowEncoder[NonEmptyList[String]] = RowEncoder.instance(r => r) } -trait ExportedRowEncoderFs { +trait ExportedRowEncoders { implicit def exportedRowEncoder[A](implicit exported: Exported[RowEncoder[A]]): RowEncoder[A] = exported.instance - implicit def exportedCsvRowEncoderF[A](implicit - exported: Exported[CsvRowEncoder[A, String]]): CsvRowEncoder[A, String] = exported.instance } diff --git a/csv/shared/src/main/scala/fs2/data/csv/package.scala b/csv/shared/src/main/scala/fs2/data/csv/package.scala index 13c250f0..5e661e54 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/package.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/package.scala @@ -64,24 +64,6 @@ package object csv { "No implicit RowEncoder found for type ${T}.\nYou can define one using RowEncoder.instance, by calling contramap on another RowEncoder 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 rowEncoder: RowEncoder[${T}] = deriveRowEncoder\nMake sure to have instances of CellEncoder for every member type in scope.\n") type RowEncoder[T] = RowEncoderF[NoneF, T, Nothing] - /** Describes how a row can be decoded to the given type. - * - * `CsvRowDecoder` provides convenient methods such as `map`, `emap`, or `flatMap` - * to build new decoders out of more basic one. - * - * Actually, `CsvRowDecoder` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] - * instance. To get the full power of it, import `cats.syntax.all._`. - */ - @implicitNotFound( - "No implicit CsvRowDecoder found for type ${T}.\nYou can define one using CsvRowDecoder.instance, by calling map on another CsvRowDecoder 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 csvRowDecoder: CsvRowDecoder[${T}] = deriveCsvRowDecoder\nMake sure to have instances of CellDecoder for every member type in scope.\n") - type CsvRowDecoder[T, Header] = RowDecoderF[Some, T, Header] - - /** 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") - type CsvRowEncoder[T, Header] = RowEncoderF[Some, T, Header] - @nowarn sealed trait QuoteHandling @@ -135,51 +117,6 @@ package object csv { lowlevel.rows(separator, quoteHandling) andThen lowlevel.skipHeaders andThen lowlevel.decode } - /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, - * assuming the file contains headers and they need to be taken into account for decoding. - */ - def decodeUsingHeaders[T]: PartiallyAppliedDecodeUsingHeaders[T] = - new PartiallyAppliedDecodeUsingHeaders[T](dummy = true) - - @nowarn - class PartiallyAppliedDecodeUsingHeaders[T](val dummy: Boolean) extends AnyVal { - def apply[F[_], C, Header](separator: Char = ',', quoteHandling: QuoteHandling = QuoteHandling.RFCCompliant)( - implicit - F: RaiseThrowable[F], - C: CharLikeChunks[F, C], - T: CsvRowDecoder[T, Header], - H: ParseableHeader[Header]): Pipe[F, C, T] = - lowlevel.rows(separator, quoteHandling) andThen lowlevel.headers andThen lowlevel.decodeRow - } - - /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type. - * - * Scenarios: - * - If skipHeaders is false, then the file contains no headers. - * - If skipHeaders is true, then the headers in the file will be skipped. - * - * For both scenarios the file is assumed to be compliant with the set of headers given. - */ - def decodeGivenHeaders[T]: PartiallyAppliedDecodeGivenHeaders[T] = - new PartiallyAppliedDecodeGivenHeaders(dummy = true) - - @nowarn - class PartiallyAppliedDecodeGivenHeaders[T](val dummy: Boolean) extends AnyVal { - def apply[F[_], C, Header](headers: NonEmptyList[Header], - skipHeaders: Boolean = false, - separator: Char = ',', - quoteHandling: QuoteHandling = QuoteHandling.RFCCompliant)(implicit - F: RaiseThrowable[F], - C: CharLikeChunks[F, C], - T: CsvRowDecoder[T, Header]): Pipe[F, C, T] = { - if (skipHeaders) - lowlevel.rows(separator, quoteHandling) andThen lowlevel.skipHeaders andThen - lowlevel.withHeaders(headers) andThen lowlevel.decodeRow - else - lowlevel.rows(separator, quoteHandling) andThen lowlevel.withHeaders(headers) andThen lowlevel.decodeRow - } - } - /** Encode a specified type into a CSV that contains no headers. */ def encodeWithoutHeaders[T]: PartiallyAppliedEncodeWithoutHeaders[T] = new PartiallyAppliedEncodeWithoutHeaders[T](dummy = true) @@ -217,25 +154,6 @@ package object csv { } } - /** Encode a specified type into a CSV that contains the headers determined by encoding the first element. Empty if input is. */ - def encodeUsingFirstHeaders[T]: PartiallyAppliedEncodeUsingFirstHeaders[T] = - new PartiallyAppliedEncodeUsingFirstHeaders(dummy = true) - - @nowarn - class PartiallyAppliedEncodeUsingFirstHeaders[T](val dummy: Boolean) extends AnyVal { - def apply[F[_], Header](fullRows: Boolean = false, - separator: Char = ',', - newline: String = "\n", - escape: EscapeMode = EscapeMode.Auto)(implicit - T: CsvRowEncoder[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 lowlevel.encodeRowWithFirstHeaders[F, Header] 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. @@ -298,23 +216,6 @@ package object csv { def attemptDecode[F[_], R](implicit R: RowDecoder[R]): Pipe[F, Row, DecoderResult[R]] = _.map(R(_)) - /** Decodes [[CsvRow]]s (with headers) into a specified type using a suitable [[CsvRowDecoder]]. */ - def decodeRow[F[_], Header, R](implicit - F: RaiseThrowable[F], - R: CsvRowDecoder[R, Header]): Pipe[F, CsvRow[Header], R] = - _.map(R(_)).rethrow - - /** Decodes [[CsvRow]]s (with headers) into a specified type using a suitable [[CsvRowDecoder]], but signal errors as values. */ - def attemptDecodeRow[F[_], Header, R](implicit - R: CsvRowDecoder[R, Header]): Pipe[F, CsvRow[Header], DecoderResult[R]] = - _.map(R(_)) - - /** Decodes [[CsvRow]]s (with headers) into a specified type using a suitable [[CsvRowDecoder]], but signal errors as values from both header as well as rows. */ - def attemptFlatMapDecodeRow[F[_], Header, R](implicit R: CsvRowDecoder[R, Header]) - : Stream[F, Either[CsvException, CsvRow[Header]]] => Stream[F, Either[CsvException, R]] = { - _.map(_.flatMap(R(_))) - } - /** Encode a given type into CSV rows using a set of explicitly given headers. */ def writeWithHeaders[F[_], Header](headers: NonEmptyList[Header])(implicit H: WriteableHeader[Header]): Pipe[F, Row, NonEmptyList[String]] = @@ -349,10 +250,6 @@ package object csv { def encode[F[_], R](implicit R: RowEncoder[R]): Pipe[F, R, Row] = _.map(R(_)) - /** Encode a given type into CSV rows with headers. */ - def encodeRow[F[_], Header, R](implicit R: CsvRowEncoder[R, Header]): Pipe[F, R, CsvRow[Header]] = - _.map(R(_)) - /** Encode a given type into CSV row with headers taken from the first element. * If the input stream is empty, the output is as well. */ @@ -369,56 +266,6 @@ package object csv { } object lenient { - - /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, with failures at the - * element level instead of failing the stream. - * - * Scenarios: - * - If skipHeaders is false, then the file contains no headers. - * - If skipHeaders is true, then the headers in the file will be skipped. - * - * For both scenarios the file is assumed to be compliant with the set of headers given. - */ - def attemptDecodeGivenHeaders[T]: PartiallyAppliedDecodeAttemptGivenHeaders[T] = - new PartiallyAppliedDecodeAttemptGivenHeaders[T](dummy = true) - - class PartiallyAppliedDecodeAttemptGivenHeaders[T](val dummy: Boolean) extends AnyVal { - def apply[F[_], C, Header](headers: NonEmptyList[Header], - skipHeaders: Boolean = false, - separator: Char = ',', - quoteHandling: QuoteHandling = QuoteHandling.RFCCompliant)(implicit - F: RaiseThrowable[F], - C: CharLikeChunks[F, C], - T: CsvRowDecoder[T, Header]): Pipe[F, C, Either[CsvException, T]] = { - if (skipHeaders) - lowlevel.rows(separator, quoteHandling) andThen lowlevel.skipHeaders andThen - lowlevel.attemptWithHeaders(headers) andThen lowlevel.attemptFlatMapDecodeRow - else - lowlevel.rows(separator, quoteHandling) andThen lowlevel.attemptWithHeaders( - headers) andThen lowlevel.attemptFlatMapDecodeRow - } - } - - /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, with failures at the - * element level instead of failing the stream. - * - * This function assumes the file contains headers and they need to be taken into account for decoding. - */ - def attemptDecodeUsingHeaders[T]: PartiallyAppliedDecodeAttemptUsingHeaders[T] = - new PartiallyAppliedDecodeAttemptUsingHeaders[T](dummy = true) - - class PartiallyAppliedDecodeAttemptUsingHeaders[T](val dummy: Boolean) extends AnyVal { - def apply[F[_], C, Header](separator: Char = ',', quoteHandling: QuoteHandling = QuoteHandling.RFCCompliant)( - implicit - F: RaiseThrowable[F], - C: CharLikeChunks[F, C], - T: CsvRowDecoder[T, Header], - H: ParseableHeader[Header]): Pipe[F, C, Either[CsvException, T]] = { - lowlevel.rows(separator, quoteHandling) andThen lowlevel.headersAttempt( - H) andThen lowlevel.attemptFlatMapDecodeRow - } - } - /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, with failures at the * element level instead of failing the stream. * From b5b4dc6187238b44dce122f1e0ac179e28ef1d63 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 04:32:26 +0200 Subject: [PATCH 03/12] get rid of RowEncoder, RowDecoder companion objects --- .../main/scala/fs2/data/csv/RowDecoder.scala | 34 ------------------- .../main/scala/fs2/data/csv/RowDecoderF.scala | 10 ++++-- .../main/scala/fs2/data/csv/RowEncoder.scala | 28 --------------- .../main/scala/fs2/data/csv/RowEncoderF.scala | 18 +++++----- 4 files changed, 16 insertions(+), 74 deletions(-) delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala deleted file mode 100644 index d6956bc0..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala +++ /dev/null @@ -1,34 +0,0 @@ -/* - * 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 - -/** Describes how a row can be decoded to the given type. - * - * `RowDecoder` provides convenient methods such as `map`, `emap`, or `flatMap` - * to build new decoders out of more basic one. - * - * Actually, `RowDecoder` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] - * instance. To get the full power of it, import `cats.syntax.all._`. - */ - -object RowDecoder { - @inline - def apply[T: RowDecoder]: RowDecoder[T] = implicitly[RowDecoder[T]] - - @inline - def instance[T](f: Row => DecoderResult[T]): RowDecoder[T] = row => f(row) -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala index 53bd5f4f..34bd3032 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala @@ -90,6 +90,12 @@ import scala.annotation.tailrec object RowDecoderF extends ExportedRowDecoderFs { + @inline + def apply[T: RowDecoder]: RowDecoder[T] = implicitly[RowDecoder[T]] + + @inline + def instance[T](f: Row => DecoderResult[T]): RowDecoder[T] = row => f(row) + implicit def identityRowDecoderF[H[+a] <: Option[a], Header]: RowDecoderF[H, RowF[H, Header], Header] = _.asRight implicit def RowDecoderFInstances[H[+a] <: Option[a], Header] @@ -126,10 +132,10 @@ object RowDecoderF extends ExportedRowDecoderFs { } implicit val toListRowDecoder: RowDecoder[List[String]] = - RowDecoder.instance(_.values.toList.asRight) + RowDecoderF.instance(_.values.toList.asRight) implicit val toNelRowDecoder: RowDecoder[NonEmptyList[String]] = - RowDecoder.instance(_.values.asRight) + RowDecoderF.instance(_.values.asRight) implicit def decodeResultRowDecoder[T](implicit dec: RowDecoder[T]): RowDecoder[DecoderResult[T]] = r => Right(dec(r)) diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala deleted file mode 100644 index 4f0009e1..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 - -object RowEncoder { - - @inline - def apply[T: RowEncoder]: RowEncoder[T] = implicitly[RowEncoder[T]] - - @inline - def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => Row(f(t)) -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala index 21ad7e04..60bfa306 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala @@ -17,9 +17,9 @@ package fs2.data.csv import cats._ -import cats.data.{NonEmptyList, NonEmptyMap} +import cats.data.NonEmptyList -import scala.annotation.{implicitNotFound, unused} +import scala.annotation.implicitNotFound /** Describes how a row can be encoded from a value of the given type. */ @@ -32,6 +32,11 @@ import scala.annotation.{implicitNotFound, unused} } object RowEncoderF extends ExportedRowEncoders { + @inline + def apply[T: RowEncoder]: RowEncoder[T] = implicitly[RowEncoder[T]] + + @inline + def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => Row(f(t)) implicit def identityRowEncoderF[H[+a] <: Option[a], Header]: RowEncoderF[H, RowF[H, Header], Header] = identity @@ -41,15 +46,8 @@ object RowEncoderF extends ExportedRowEncoders { fa.contramap(f) } - @inline - def apply[H[+a] <: Option[a], T: RowEncoderF[H, *, Header], Header]: RowEncoderF[H, T, Header] = - implicitly[RowEncoderF[H, T, Header]] - - @inline - def instance[H[+a] <: Option[a], T, Header](f: T => RowF[H, Header]): RowEncoderF[H, T, Header] = f(_) - implicit val fromNelRowEncoder: RowEncoder[NonEmptyList[String]] = - RowEncoder.instance(r => r) + instance[NonEmptyList[String]](r => r) } trait ExportedRowEncoders { From eb95304d0fa95831ad35afc0e0bce08586d24bd6 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 04:41:38 +0200 Subject: [PATCH 04/12] get rid of CsvRow and HasHeaders --- .../src/main/scala/fs2/data/csv/CsvRow.scala | 51 -------- .../main/scala/fs2/data/csv/HasHeaders.scala | 29 ----- .../src/main/scala/fs2/data/csv/RowF.scala | 110 ------------------ .../fs2/data/csv/internals/CsvRowParser.scala | 55 --------- .../src/main/scala/fs2/data/csv/package.scala | 49 +------- 5 files changed, 1 insertion(+), 293 deletions(-) delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/CsvRow.scala delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/HasHeaders.scala delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/internals/CsvRowParser.scala diff --git a/csv/shared/src/main/scala/fs2/data/csv/CsvRow.scala b/csv/shared/src/main/scala/fs2/data/csv/CsvRow.scala deleted file mode 100644 index 75180e6b..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/CsvRow.scala +++ /dev/null @@ -1,51 +0,0 @@ -/* - * 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.syntax.all._ - -object CsvRow { - - /** Constructs a [[CsvRow]] and checks that the size of values and headers match. */ - def apply[Header](values: NonEmptyList[String], - headers: NonEmptyList[Header], - line: Option[Long] = None): Either[CsvException, CsvRow[Header]] = - if (values.length =!= headers.length) - Left(new HeaderSizeError(headers.length, values.length, line)) - else - Right(new CsvRow(values, Some(headers), line)) - - def unsafe[Header](values: NonEmptyList[String], headers: NonEmptyList[Header]): CsvRow[Header] = - apply(values, headers).fold(throw _, identity) - - def liftRow[Header](headers: NonEmptyList[Header])(row: Row): Either[CsvException, CsvRow[Header]] = - apply(row.values, headers, row.line) - - def fromListHeaders[Header](l: List[(Header, String)]): Option[CsvRow[Header]] = { - val (hs, vs) = l.unzip - (NonEmptyList.fromList(vs), NonEmptyList.fromList(hs)).mapN((v, h) => new CsvRow(v, Some(h))) - } - - def fromNelHeaders[Header](nel: NonEmptyList[(Header, String)]): CsvRow[Header] = { - val (hs, vs) = nel.toList.unzip - new CsvRow(NonEmptyList.fromListUnsafe(vs), Some(NonEmptyList.fromListUnsafe(hs))) - } - - def unapply[Header](arg: CsvRow[Header]): Some[(NonEmptyList[String], NonEmptyList[Header])] = Some( - (arg.values, arg.headers.get)) -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/HasHeaders.scala b/csv/shared/src/main/scala/fs2/data/csv/HasHeaders.scala deleted file mode 100644 index 898db7b9..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/HasHeaders.scala +++ /dev/null @@ -1,29 +0,0 @@ -/* - * 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 - -/** Witness that a [[RowF]] has headers of a certain type. - * @tparam H - * @tparam Header - */ -sealed trait HasHeaders[H[+a] <: Option[a], Header] extends (RowF[H, Header] => CsvRow[Header]) - -object HasHeaders { - implicit def hasHeaders[Header]: HasHeaders[Some, Header] = new HasHeaders[Some, Header] { - override def apply(value: RowF[Some, Header]): CsvRow[Header] = value - } -} 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 e630ec60..266926dc 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowF.scala @@ -20,8 +20,6 @@ import cats._ import cats.data._ import cats.syntax.all._ -import scala.annotation.{nowarn, unused} - /** A CSV row with or without headers. The presence of headers is encoded via the first type param * which is a subtype of [[scala.Option]]. By preserving this information in types, it's possible to define * [[Row]] and [[CsvRow]] aliases as if they were plain case classes while keeping the code DRY. @@ -94,43 +92,10 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], else new RowF[H, Header](values.zipWithIndex.map { case (cell, i) => if (i === idx) f(cell) else cell }, headers) - /** Modifies the cell content at the given `header` using the function `f`. - * - * **Note:** Only the first occurrence of the values with the given header - * will be modified. It shouldn't be a problem in the general case as headers - * should not be duplicated. - */ - def modify(header: Header)(f: String => String)(implicit hasHeaders: HasHeaders[H, Header]): CsvRow[Header] = - hasHeaders(modifyAt(headers.get.toList.indexOf(header))(f)) - /** Returns the row with the cell at `idx` modified to `value`. */ def updatedAt(idx: Int, value: String): RowF[H, Header] = modifyAt(idx)(_ => value) - /** Returns the row with the cell at `header` modified to `value`. - * - * **Note:** Only the first occurrence of the values with the given header - * will be modified. It shouldn't be a problem in the general case as headers - * should not be duplicated. - */ - def updated(header: Header, value: String)(implicit hasHeaders: HasHeaders[H, Header]): CsvRow[Header] = - hasHeaders(updatedAt(headers.get.toList.indexOf(header), value)) - - /** Returns the row with the cell at `header` modified to `value`. - * If the header wasn't present in the row, it is added to the end of the fields. - * - * **Note:** Only the first occurrence of the values with the given header - * will be modified. It shouldn't be a problem in the general case as headers - * should not be duplicated. - */ - def set(header: Header, value: String)(implicit hasHeaders: HasHeaders[H, Header]): CsvRow[Header] = { - val idx = headers.get.toList.indexOf(header) - if (idx < 0) - hasHeaders(new RowF(values :+ value, headers.map(_ :+ header).asInstanceOf[H[NonEmptyList[Header]]])) - else - hasHeaders(updatedAt(idx, value)) - } - /** Returns the row without the cell at the given `idx`. * If the resulting row is empty, returns `None`. */ @@ -146,76 +111,6 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], (NonEmptyList.fromList(before ++ after.tail), nh).mapN(new RowF[H, Header](_, _)) } - /** Returns the row without the cell at the given `header`. - * If the resulting row is empty, returns `None`. - * - * **Note:** Only the first occurrence of the values with the given header - * will be deleted. It shouldn't be a problem in the general case as headers - * should not be duplicated. - */ - def delete(header: Header)(implicit hasHeaders: HasHeaders[H, Header]): Option[CsvRow[Header]] = - deleteAt(headers.get.toList.indexOf(header)).map(hasHeaders) - - /** Returns the content of the cell at `header` if it exists. - * Returns `None` if `header` does not exist for the row. - * An empty cell value results in `Some("")`. - */ - def apply(header: Header)(implicit @unused hasHeaders: HasHeaders[H, Header]): Option[String] = - byHeader.get(header): @nowarn("msg=HasHeaders") - - /** Returns the decoded content of the cell at `header`. - * Fails if the field doesn't exist or cannot be decoded - * to the expected type. - */ - def as[T]( - header: Header)(implicit @unused hasHeaders: HasHeaders[H, Header], decoder: CellDecoder[T]): DecoderResult[T] = - (byHeader: @nowarn("msg=HasHeaders")).get(header) match { - case Some(v) => decoder(v) - case None => Left(new DecoderError(s"unknown field $header")) - } - - /** Returns the decoded content of the cell at `header` wrapped in Some if the cell is non-empty, None otherwise. - * Fails if the field doesn't exist or cannot be decoded - * to the expected type. - */ - @deprecated(message = - "Use `RowF.asOptional` instead, as it gives more flexibility and has the same default behavior.", - since = "fs2-data 1.7.0") - def asNonEmpty[T]( - header: Header)(implicit hasHeaders: HasHeaders[H, Header], decoder: CellDecoder[T]): DecoderResult[Option[T]] = - asOptional(header) - - /** Returns the decoded content of the cell at `header` 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 field 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 asOptional[T](header: Header, - missing: Header => DecoderResult[Option[T]] = (header: Header) => - Left(new DecoderError(s"unknown field $header")), - isEmpty: String => Boolean = _.isEmpty)(implicit - @unused hasHeaders: HasHeaders[H, Header], - decoder: CellDecoder[T]): DecoderResult[Option[T]] = - (byHeader: @nowarn("msg=HasHeaders")).get(header) match { - case Some(v) if isEmpty(v) => Right(None) - case Some(v) => decoder(v).map(Some(_)) - case None => missing(header) - } - - /** Drop all headers (if any). - * @return a row without headers, but same values - */ - def dropHeaders: Row = Row(values) - - // 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` - // instance is provided, meaning that the `Option` is `Some` - // of course using a lazy val prevents us to make this constraint statistically checked, but - // the gain is significant enough to allow for this local unsafety - @deprecated("Have you checked that you have a `HasHeaders` instance in scope?", "fs2-data 1.5.0") - private lazy val byHeader: Map[Header, String] = - headers.get.toList.zip(values.toList).toMap - // Like Traverse[Option], but preserves the H type private def htraverse[G[_]: Applicative, A, B](h: H[A])(f: A => G[B]): G[H[B]] = h match { case Some(a) => f(a).map(Some(_)).asInstanceOf[G[H[B]]] @@ -223,8 +118,3 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], } } -object RowF { - implicit object functor extends Functor[CsvRow[*]] { - override def map[A, B](fa: CsvRow[A])(f: A => B): CsvRow[B] = fa.copy(headers = Some(fa.headers.get.map(f))) - } -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/internals/CsvRowParser.scala b/csv/shared/src/main/scala/fs2/data/csv/internals/CsvRowParser.scala deleted file mode 100644 index 0f75024e..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/internals/CsvRowParser.scala +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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 -package data -package csv -package internals - -import cats.syntax.all._ - -private[csv] object CsvRowParser { - - def pipe[F[_], Header](implicit - F: RaiseThrowable[F], - Header: ParseableHeader[Header] - ): Pipe[F, Row, CsvRow[Header]] = - _.through(pipeAttempt[F, Header]).rethrow - - /** Like `pipe` except that instead of failing the stream on parse errors, it emits `Left` elements for bad rows */ - def pipeAttempt[F[_], Header](implicit - Header: ParseableHeader[Header]): Pipe[F, Row, Either[CsvException, CsvRow[Header]]] = - _.pull.uncons1 - .flatMap { - case Some((firstRow, tail)) => - Header(firstRow.values) match { - case Left(error) => Pull.output1(Left(error)) - case Right(headers) if headers.length =!= firstRow.values.length => - val error = new HeaderError( - s"Got ${headers.length} headers, but ${firstRow.values.length} columns. Both numbers must match!", - firstRow.line) - Pull.output1(Left(error)) - case Right(headers) => - tail - .map(CsvRow.liftRow(headers)) - .pull - .echo - } - case None => Pull.done - } - .stream - -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/package.scala b/csv/shared/src/main/scala/fs2/data/csv/package.scala index 5e661e54..8d9b8056 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/package.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/package.scala @@ -23,7 +23,7 @@ import csv.internals._ import cats.data._ import cats.syntax.all._ -import scala.annotation.{implicitNotFound, unused} +import scala.annotation.implicitNotFound import scala.annotation.nowarn package object csv { @@ -36,12 +36,6 @@ package object csv { */ type Row = RowF[NoneF, Nothing] - /** A CSV row with headers, that can be used to access the cell values. - * - * '''Note:''' the following invariant holds when using this class: `values` and `headers` have the same size. - */ - type CsvRow[Header] = RowF[Some, Header] - type HeaderResult[T] = Either[HeaderError, NonEmptyList[T]] type DecoderResult[T] = Either[DecoderError, T] @@ -174,33 +168,6 @@ package object csv { T: CharLikeChunks[F, T]): Pipe[F, T, Row] = RowParser.pipe[F, T](separator, quoteHandling) - /** Transforms a stream of raw CSV rows into parsed CSV rows with headers. */ - def headers[F[_], Header](implicit - F: RaiseThrowable[F], - Header: ParseableHeader[Header]): Pipe[F, Row, CsvRow[Header]] = - CsvRowParser.pipe[F, Header] - - /** Transforms a stream of raw CSV rows into parsed CSV rows with headers, with failures at the element level instead of failing the stream */ - def headersAttempt[F[_], Header](implicit - Header: ParseableHeader[Header]): Pipe[F, Row, Either[CsvException, CsvRow[Header]]] = - CsvRowParser.pipeAttempt[F, Header] - - // left here for bincompat - private[csv] def headersAttempt[F[_], Header](implicit - @unused F: RaiseThrowable[F], - Header: ParseableHeader[Header]): Pipe[F, Row, Either[CsvException, CsvRow[Header]]] = - CsvRowParser.pipeAttempt[F, Header] - - /** Transforms a stream of raw CSV rows into parsed CSV rows with given headers. */ - def withHeaders[F[_], Header](headers: NonEmptyList[Header])(implicit - F: RaiseThrowable[F]): Pipe[F, Row, CsvRow[Header]] = - _.map(CsvRow.liftRow(headers)).rethrow - - /** Transforms a stream of raw CSV rows into parsed CSV rows with given headers. */ - def attemptWithHeaders[F[_], Header]( - headers: NonEmptyList[Header]): Pipe[F, Row, Either[CsvException, CsvRow[Header]]] = - _.map(CsvRow.liftRow(headers)) - /** Transforms a stream of raw CSV rows into rows. */ def noHeaders[F[_]]: Pipe[F, Row, Row] = identity @@ -249,20 +216,6 @@ package object csv { /** Encode a given type into simple CSV rows without headers. */ def encode[F[_], R](implicit R: RowEncoder[R]): Pipe[F, R, Row] = _.map(R(_)) - - /** Encode a given type into CSV row with headers taken from the first element. - * If the input stream is empty, the output is as well. - */ - def encodeRowWithFirstHeaders[F[_], Header](implicit - H: WriteableHeader[Header]): Pipe[F, CsvRow[Header], NonEmptyList[String]] = - _.pull.peek1 - .flatMap { - case Some((CsvRow(_, headers), stream)) => - Pull.output1(H(headers)) >> stream.map(_.values).pull.echo - case None => Pull.done - } - .stream - } object lenient { From 28faf3ce3fc0a9e10d4d4920bc0704960e65d6a1 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 05:41:26 +0200 Subject: [PATCH 05/12] remove Headers from RowF --- .../src/main/scala/fs2/data/csv/Row.scala | 24 ------------------- .../main/scala/fs2/data/csv/RowEncoderF.scala | 2 +- .../src/main/scala/fs2/data/csv/RowF.scala | 15 ++---------- .../fs2/data/csv/internals/RowParser.scala | 16 ++++++------- 4 files changed, 11 insertions(+), 46 deletions(-) delete mode 100644 csv/shared/src/main/scala/fs2/data/csv/Row.scala diff --git a/csv/shared/src/main/scala/fs2/data/csv/Row.scala b/csv/shared/src/main/scala/fs2/data/csv/Row.scala deleted file mode 100644 index 453b156c..00000000 --- a/csv/shared/src/main/scala/fs2/data/csv/Row.scala +++ /dev/null @@ -1,24 +0,0 @@ -/* - * 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 - -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) -} diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala index 60bfa306..e533c050 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala @@ -36,7 +36,7 @@ object RowEncoderF extends ExportedRowEncoders { def apply[T: RowEncoder]: RowEncoder[T] = implicitly[RowEncoder[T]] @inline - def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => Row(f(t)) + def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => RowF(f(t)) implicit def identityRowEncoderF[H[+a] <: Option[a], Header]: RowEncoderF[H, RowF[H, Header], Header] = identity 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 266926dc..78e6fab4 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowF.scala @@ -30,7 +30,6 @@ import cats.syntax.all._ * '''Note:''' the following invariant holds when using this class: `values` and `headers` have the same size if headers are present. */ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], - headers: H[NonEmptyList[Header]], line: Option[Long] = None) { /** Number of cells in the row. */ @@ -90,7 +89,7 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], if (idx < 0 || idx >= values.size) this else - new RowF[H, Header](values.zipWithIndex.map { case (cell, i) => if (i === idx) f(cell) else cell }, headers) + new RowF[H, Header](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): RowF[H, Header] = @@ -104,17 +103,7 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], Some(this) } else { val (before, after) = values.toList.splitAt(idx) - val nh = htraverse(headers) { headers => - val (h1, h2) = headers.toList.splitAt(idx) - NonEmptyList.fromList(h1 ::: h2.tail) - } - (NonEmptyList.fromList(before ++ after.tail), nh).mapN(new RowF[H, Header](_, _)) + (NonEmptyList.fromList(before ++ after.tail)).map(new RowF[H, Header](_)) } - // Like Traverse[Option], but preserves the H type - private def htraverse[G[_]: Applicative, A, B](h: H[A])(f: A => G[B]): G[H[B]] = h match { - case Some(a) => f(a).map(Some(_)).asInstanceOf[G[H[B]]] - case _ => Applicative[G].pure(None).asInstanceOf[G[H[B]]] - } } - diff --git a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala index c7662cab..33f44102 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala @@ -47,14 +47,14 @@ private[csv] object RowParser { state match { case State.BeginningOfField => if (tail.nonEmpty) - Pull.output1(Row(NonEmptyList("", tail).reverse, Some(line))) >> Pull.done + Pull.output1(RowF[NoneF, Nothing](NonEmptyList("", tail).reverse, Some(line))) >> Pull.done else Pull.done case State.InUnquoted | State.InQuotedSeenQuote | State.ExpectNewLine => - Pull.output1(Row(NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done + Pull.output1(RowF[NoneF, Nothing](NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done case State.InUnquotedSeenCr => Pull.output1( - Row(NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done + RowF[NoneF, Nothing](NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done case State.InQuoted => Pull.raiseError[F](new CsvException("unexpected end of input", Some(line))) } @@ -87,7 +87,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) } else if (c == '\r') { rows(T.advance(context), currentField, tail, State.ExpectNewLine, line, chunkAcc) } else { @@ -104,7 +104,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) } else { // this is an error Pull.output(Chunk.from(chunkAcc.result())) >> Pull.raiseError[F]( @@ -125,7 +125,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList("", tail).reverse, Some(line))) + chunkAcc += RowF(NonEmptyList("", tail).reverse, Some(line))) } else { rows(T.advance(context), currentField, Nil, State.BeginningOfField, line + 1, chunkAcc) } @@ -149,7 +149,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) } else if (c == '\r') { rows(T.advance(context), currentField, tail, State.InUnquotedSeenCr, line, chunkAcc) } else { @@ -165,7 +165,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) } else { currentField.append('\r') if (c == separator) { From 3ebda9f498d3a293fb4cf71ff82ef27b386a9b7c Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 05:46:51 +0200 Subject: [PATCH 06/12] remove `Header` parameter --- .../main/scala/fs2/data/csv/RowDecoderF.scala | 40 +++++++++---------- .../main/scala/fs2/data/csv/RowEncoderF.scala | 14 +++---- .../src/main/scala/fs2/data/csv/RowF.scala | 15 ++++--- .../fs2/data/csv/internals/RowParser.scala | 6 +-- .../src/main/scala/fs2/data/csv/package.scala | 6 +-- 5 files changed, 40 insertions(+), 41 deletions(-) diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala index 34bd3032..58f5055d 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala @@ -30,15 +30,15 @@ import scala.annotation.tailrec * Actually, `RowDecoderF` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] * instance. To get the full power of it, import `cats.syntax.all._`. */ -@FunctionalInterface trait RowDecoderF[H[+a] <: Option[a], T, Header] { - def apply(row: RowF[H, Header]): DecoderResult[T] +@FunctionalInterface trait RowDecoderF[H[+a] <: Option[a], T] { + def apply(row: RowF[H]): DecoderResult[T] /** Map the parsed value. * @param f the mapping function * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def map[T2](f: T => T2): RowDecoderF[H, T2, Header] = + def map[T2](f: T => T2): RowDecoderF[H, T2] = row => apply(row).map(f) /** Map the parsed value to a new decoder, which in turn will be applied to @@ -47,7 +47,7 @@ import scala.annotation.tailrec * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def flatMap[T2](f: T => RowDecoderF[H, T2, Header]): RowDecoderF[H, T2, Header] = + def flatMap[T2](f: T => RowDecoderF[H, T2]): RowDecoderF[H, T2] = row => apply(row).flatMap(f(_)(row)) /** Map the parsed value, potentially failing. @@ -55,7 +55,7 @@ import scala.annotation.tailrec * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def emap[T2](f: T => DecoderResult[T2]): RowDecoderF[H, T2, Header] = + def emap[T2](f: T => DecoderResult[T2]): RowDecoderF[H, T2] = row => apply(row).flatMap(f) /** Fail-over. If this decoder fails, try the supplied other decoder. @@ -63,7 +63,7 @@ import scala.annotation.tailrec * @tparam TT the return type * @return a decoder combining this and the other decoder */ - def or[TT >: T](cd: => RowDecoderF[H, TT, Header]): RowDecoderF[H, TT, Header] = + def or[TT >: T](cd: => RowDecoderF[H, TT]): RowDecoderF[H, TT] = row => apply(row) match { case Left(_) => cd(row) @@ -76,7 +76,7 @@ import scala.annotation.tailrec * @tparam B the type the alternative decoder returns * @return a decoder combining both decoders */ - def either[B](cd: RowDecoderF[H, B, Header]): RowDecoderF[H, Either[T, B], Header] = + def either[B](cd: RowDecoderF[H, B]): RowDecoderF[H, Either[T, B]] = row => apply(row) match { case Left(_) => @@ -96,30 +96,30 @@ object RowDecoderF extends ExportedRowDecoderFs { @inline def instance[T](f: Row => DecoderResult[T]): RowDecoder[T] = row => f(row) - implicit def identityRowDecoderF[H[+a] <: Option[a], Header]: RowDecoderF[H, RowF[H, Header], Header] = _.asRight + implicit def identityRowDecoderF[H[+a] <: Option[a]]: RowDecoderF[H, RowF[H]] = _.asRight - implicit def RowDecoderFInstances[H[+a] <: Option[a], Header] - : MonadError[RowDecoderF[H, *, Header], DecoderError] with SemigroupK[RowDecoderF[H, *, Header]] = - new MonadError[RowDecoderF[H, *, Header], DecoderError] with SemigroupK[RowDecoderF[H, *, Header]] { - override def map[A, B](fa: RowDecoderF[H, A, Header])(f: A => B): RowDecoderF[H, B, Header] = + implicit def RowDecoderFInstances[H[+a] <: Option[a]] + : MonadError[RowDecoderF[H, *], DecoderError] with SemigroupK[RowDecoderF[H, *]] = + new MonadError[RowDecoderF[H, *], DecoderError] with SemigroupK[RowDecoderF[H, *]] { + override def map[A, B](fa: RowDecoderF[H, A])(f: A => B): RowDecoderF[H, B] = fa.map(f) - def flatMap[A, B](fa: RowDecoderF[H, A, Header])(f: A => RowDecoderF[H, B, Header]): RowDecoderF[H, B, Header] = + def flatMap[A, B](fa: RowDecoderF[H, A])(f: A => RowDecoderF[H, B]): RowDecoderF[H, B] = fa.flatMap(f) - def handleErrorWith[A](fa: RowDecoderF[H, A, Header])( - f: DecoderError => RowDecoderF[H, A, Header]): RowDecoderF[H, A, Header] = + def handleErrorWith[A](fa: RowDecoderF[H, A])( + f: DecoderError => RowDecoderF[H, A]): RowDecoderF[H, A] = row => fa(row).leftFlatMap(f(_)(row)) - def pure[A](x: A): RowDecoderF[H, A, Header] = + def pure[A](x: A): RowDecoderF[H, A] = _ => Right(x) - def raiseError[A](e: DecoderError): RowDecoderF[H, A, Header] = + def raiseError[A](e: DecoderError): RowDecoderF[H, A] = _ => Left(e) - def tailRecM[A, B](a: A)(f: A => RowDecoderF[H, Either[A, B], Header]): RowDecoderF[H, B, Header] = { + def tailRecM[A, B](a: A)(f: A => RowDecoderF[H, Either[A, B]]): RowDecoderF[H, B] = { @tailrec - def step(row: RowF[H, Header], a: A): DecoderResult[B] = + def step(row: RowF[H], a: A): DecoderResult[B] = f(a)(row) match { case left @ Left(_) => left.rightCast[B] case Right(Left(a)) => step(row, a) @@ -128,7 +128,7 @@ object RowDecoderF extends ExportedRowDecoderFs { row => step(row, a) } - def combineK[A](x: RowDecoderF[H, A, Header], y: RowDecoderF[H, A, Header]): RowDecoderF[H, A, Header] = x or y + def combineK[A](x: RowDecoderF[H, A], y: RowDecoderF[H, A]): RowDecoderF[H, A] = x or y } implicit val toListRowDecoder: RowDecoder[List[String]] = diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala index e533c050..34d1a7a1 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala @@ -25,10 +25,10 @@ import scala.annotation.implicitNotFound */ @implicitNotFound( "No implicit RowEncoderF[H, found for type ${T}.\nYou can define one using RowEncoderF[H, .instance, by calling contramap on another RowEncoderF[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: RowEncoderF[H, [${T}] = deriveRowEncoderF[H, \nMake sure to have instances of CellEncoder for every member type in scope.\n") -@FunctionalInterface trait RowEncoderF[H[+a] <: Option[a], T, Header] { - def apply(elem: T): RowF[H, Header] +@FunctionalInterface trait RowEncoderF[H[+a] <: Option[a], T] { + def apply(elem: T): RowF[H] - def contramap[B](f: B => T): RowEncoderF[H, B, Header] = elem => apply(f(elem)) + def contramap[B](f: B => T): RowEncoderF[H, B] = elem => apply(f(elem)) } object RowEncoderF extends ExportedRowEncoders { @@ -38,11 +38,11 @@ object RowEncoderF extends ExportedRowEncoders { @inline def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => RowF(f(t)) - implicit def identityRowEncoderF[H[+a] <: Option[a], Header]: RowEncoderF[H, RowF[H, Header], Header] = identity + implicit def identityRowEncoderF[H[+a] <: Option[a]]: RowEncoderF[H, RowF[H]] = identity - implicit def RowEncoderF[H[+a] <: Option[a], Header]: Contravariant[RowEncoderF[H, *, Header]] = - new Contravariant[RowEncoderF[H, *, Header]] { - override def contramap[A, B](fa: RowEncoderF[H, A, Header])(f: B => A): RowEncoderF[H, B, Header] = + implicit def RowEncoderF[H[+a] <: Option[a]]: Contravariant[RowEncoderF[H, *]] = + new Contravariant[RowEncoderF[H, *]] { + override def contramap[A, B](fa: RowEncoderF[H, A])(f: B => A): RowEncoderF[H, B] = fa.contramap(f) } 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 78e6fab4..c1c64457 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowF.scala @@ -16,7 +16,6 @@ package fs2.data.csv -import cats._ import cats.data._ import cats.syntax.all._ @@ -29,7 +28,7 @@ import cats.syntax.all._ * * '''Note:''' the following invariant holds when using this class: `values` and `headers` have the same size if headers are present. */ -case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], +case class RowF[H[+a] <: Option[a]](values: NonEmptyList[String], line: Option[Long] = None) { /** Number of cells in the row. */ @@ -38,7 +37,7 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], /** * Set the line number for this row. */ - def withLine(line: Option[Long]): RowF[H, Header] = + def withLine(line: Option[Long]): RowF[H] = copy(line = line) /** Returns the content of the cell at `idx` if it exists. @@ -85,25 +84,25 @@ case class RowF[H[+a] <: Option[a], Header](values: NonEmptyList[String], /** Modifies the cell content at the given `idx` using the function `f`. */ - def modifyAt(idx: Int)(f: String => String): RowF[H, Header] = + def modifyAt(idx: Int)(f: String => String): RowF[H] = if (idx < 0 || idx >= values.size) this else - new RowF[H, Header](values.zipWithIndex.map { case (cell, i) => if (i === idx) f(cell) else cell }) + new RowF[H](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): RowF[H, Header] = + def updatedAt(idx: Int, value: String): RowF[H] = 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): Option[RowF[H, Header]] = + def deleteAt(idx: Int): Option[RowF[H]] = if (idx < 0 || idx >= values.size) { Some(this) } else { val (before, after) = values.toList.splitAt(idx) - (NonEmptyList.fromList(before ++ after.tail)).map(new RowF[H, Header](_)) + (NonEmptyList.fromList(before ++ after.tail)).map(new RowF[H](_)) } } diff --git a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala index 33f44102..fcdd40ce 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala @@ -47,14 +47,14 @@ private[csv] object RowParser { state match { case State.BeginningOfField => if (tail.nonEmpty) - Pull.output1(RowF[NoneF, Nothing](NonEmptyList("", tail).reverse, Some(line))) >> Pull.done + Pull.output1(RowF[NoneF](NonEmptyList("", tail).reverse, Some(line))) >> Pull.done else Pull.done case State.InUnquoted | State.InQuotedSeenQuote | State.ExpectNewLine => - Pull.output1(RowF[NoneF, Nothing](NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done + Pull.output1(RowF[NoneF](NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done case State.InUnquotedSeenCr => Pull.output1( - RowF[NoneF, Nothing](NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done + RowF[NoneF](NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done case State.InQuoted => Pull.raiseError[F](new CsvException("unexpected end of input", Some(line))) } diff --git a/csv/shared/src/main/scala/fs2/data/csv/package.scala b/csv/shared/src/main/scala/fs2/data/csv/package.scala index 8d9b8056..f60339f5 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/package.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/package.scala @@ -34,7 +34,7 @@ package object csv { /** A CSV row without headers. */ - type Row = RowF[NoneF, Nothing] + type Row = RowF[NoneF] type HeaderResult[T] = Either[HeaderError, NonEmptyList[T]] @@ -50,13 +50,13 @@ package object csv { */ @implicitNotFound( "No implicit RowDecoder found for type ${T}.\nYou can define one using RowDecoder.instance, by calling map on another RowDecoder 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 rowDecoder: RowDecoder[${T}] = deriveRowDecoder\nMake sure to have instances of CellDecoder for every member type in scope.\n") - type RowDecoder[T] = RowDecoderF[NoneF, T, Nothing] + type RowDecoder[T] = RowDecoderF[NoneF, T] /** Describes how a row can be encoded from a value of the given type. */ @implicitNotFound( "No implicit RowEncoder found for type ${T}.\nYou can define one using RowEncoder.instance, by calling contramap on another RowEncoder 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 rowEncoder: RowEncoder[${T}] = deriveRowEncoder\nMake sure to have instances of CellEncoder for every member type in scope.\n") - type RowEncoder[T] = RowEncoderF[NoneF, T, Nothing] + type RowEncoder[T] = RowEncoderF[NoneF, T] @nowarn sealed trait QuoteHandling From b7bf4b7fe664fe8df75eb255b9c839f1fdf622b5 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 05:51:12 +0200 Subject: [PATCH 07/12] get rid of `H` type parameter --- .../main/scala/fs2/data/csv/RowDecoderF.scala | 38 +++++++++---------- .../main/scala/fs2/data/csv/RowEncoderF.scala | 16 ++++---- .../src/main/scala/fs2/data/csv/RowF.scala | 14 +++---- .../fs2/data/csv/internals/RowParser.scala | 6 +-- .../src/main/scala/fs2/data/csv/package.scala | 6 +-- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala index 58f5055d..f578cb03 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala @@ -30,15 +30,15 @@ import scala.annotation.tailrec * Actually, `RowDecoderF` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] * instance. To get the full power of it, import `cats.syntax.all._`. */ -@FunctionalInterface trait RowDecoderF[H[+a] <: Option[a], T] { - def apply(row: RowF[H]): DecoderResult[T] +@FunctionalInterface trait RowDecoderF[T] { + def apply(row: RowF): DecoderResult[T] /** Map the parsed value. * @param f the mapping function * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def map[T2](f: T => T2): RowDecoderF[H, T2] = + def map[T2](f: T => T2): RowDecoderF[T2] = row => apply(row).map(f) /** Map the parsed value to a new decoder, which in turn will be applied to @@ -47,7 +47,7 @@ import scala.annotation.tailrec * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def flatMap[T2](f: T => RowDecoderF[H, T2]): RowDecoderF[H, T2] = + def flatMap[T2](f: T => RowDecoderF[T2]): RowDecoderF[T2] = row => apply(row).flatMap(f(_)(row)) /** Map the parsed value, potentially failing. @@ -55,7 +55,7 @@ import scala.annotation.tailrec * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def emap[T2](f: T => DecoderResult[T2]): RowDecoderF[H, T2] = + def emap[T2](f: T => DecoderResult[T2]): RowDecoderF[T2] = row => apply(row).flatMap(f) /** Fail-over. If this decoder fails, try the supplied other decoder. @@ -63,7 +63,7 @@ import scala.annotation.tailrec * @tparam TT the return type * @return a decoder combining this and the other decoder */ - def or[TT >: T](cd: => RowDecoderF[H, TT]): RowDecoderF[H, TT] = + def or[TT >: T](cd: => RowDecoderF[TT]): RowDecoderF[TT] = row => apply(row) match { case Left(_) => cd(row) @@ -76,7 +76,7 @@ import scala.annotation.tailrec * @tparam B the type the alternative decoder returns * @return a decoder combining both decoders */ - def either[B](cd: RowDecoderF[H, B]): RowDecoderF[H, Either[T, B]] = + def either[B](cd: RowDecoderF[B]): RowDecoderF[Either[T, B]] = row => apply(row) match { case Left(_) => @@ -96,30 +96,30 @@ object RowDecoderF extends ExportedRowDecoderFs { @inline def instance[T](f: Row => DecoderResult[T]): RowDecoder[T] = row => f(row) - implicit def identityRowDecoderF[H[+a] <: Option[a]]: RowDecoderF[H, RowF[H]] = _.asRight + implicit def identityRowDecoderF[H[+a] <: Option[a]]: RowDecoderF[RowF] = _.asRight implicit def RowDecoderFInstances[H[+a] <: Option[a]] - : MonadError[RowDecoderF[H, *], DecoderError] with SemigroupK[RowDecoderF[H, *]] = - new MonadError[RowDecoderF[H, *], DecoderError] with SemigroupK[RowDecoderF[H, *]] { - override def map[A, B](fa: RowDecoderF[H, A])(f: A => B): RowDecoderF[H, B] = + : MonadError[RowDecoderF[*], DecoderError] with SemigroupK[RowDecoderF[*]] = + new MonadError[RowDecoderF[*], DecoderError] with SemigroupK[RowDecoderF[*]] { + override def map[A, B](fa: RowDecoderF[A])(f: A => B): RowDecoderF[B] = fa.map(f) - def flatMap[A, B](fa: RowDecoderF[H, A])(f: A => RowDecoderF[H, B]): RowDecoderF[H, B] = + def flatMap[A, B](fa: RowDecoderF[A])(f: A => RowDecoderF[B]): RowDecoderF[B] = fa.flatMap(f) - def handleErrorWith[A](fa: RowDecoderF[H, A])( - f: DecoderError => RowDecoderF[H, A]): RowDecoderF[H, A] = + def handleErrorWith[A](fa: RowDecoderF[A])( + f: DecoderError => RowDecoderF[A]): RowDecoderF[A] = row => fa(row).leftFlatMap(f(_)(row)) - def pure[A](x: A): RowDecoderF[H, A] = + def pure[A](x: A): RowDecoderF[A] = _ => Right(x) - def raiseError[A](e: DecoderError): RowDecoderF[H, A] = + def raiseError[A](e: DecoderError): RowDecoderF[A] = _ => Left(e) - def tailRecM[A, B](a: A)(f: A => RowDecoderF[H, Either[A, B]]): RowDecoderF[H, B] = { + def tailRecM[A, B](a: A)(f: A => RowDecoderF[Either[A, B]]): RowDecoderF[B] = { @tailrec - def step(row: RowF[H], a: A): DecoderResult[B] = + def step(row: RowF, a: A): DecoderResult[B] = f(a)(row) match { case left @ Left(_) => left.rightCast[B] case Right(Left(a)) => step(row, a) @@ -128,7 +128,7 @@ object RowDecoderF extends ExportedRowDecoderFs { row => step(row, a) } - def combineK[A](x: RowDecoderF[H, A], y: RowDecoderF[H, A]): RowDecoderF[H, A] = x or y + def combineK[A](x: RowDecoderF[A], y: RowDecoderF[A]): RowDecoderF[A] = x or y } implicit val toListRowDecoder: RowDecoder[List[String]] = diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala index 34d1a7a1..8103aa35 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala @@ -24,11 +24,11 @@ import scala.annotation.implicitNotFound /** Describes how a row can be encoded from a value of the given type. */ @implicitNotFound( - "No implicit RowEncoderF[H, found for type ${T}.\nYou can define one using RowEncoderF[H, .instance, by calling contramap on another RowEncoderF[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: RowEncoderF[H, [${T}] = deriveRowEncoderF[H, \nMake sure to have instances of CellEncoder for every member type in scope.\n") -@FunctionalInterface trait RowEncoderF[H[+a] <: Option[a], T] { - def apply(elem: T): RowF[H] + "No implicit RowEncoderF[ found for type ${T}.\nYou can define one using RowEncoderF[.instance, by calling contramap on another RowEncoderF[ 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: RowEncoderF[[${T}] = deriveRowEncoderF[\nMake sure to have instances of CellEncoder for every member type in scope.\n") +@FunctionalInterface trait RowEncoderF[T] { + def apply(elem: T): RowF - def contramap[B](f: B => T): RowEncoderF[H, B] = elem => apply(f(elem)) + def contramap[B](f: B => T): RowEncoderF[B] = elem => apply(f(elem)) } object RowEncoderF extends ExportedRowEncoders { @@ -38,11 +38,11 @@ object RowEncoderF extends ExportedRowEncoders { @inline def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => RowF(f(t)) - implicit def identityRowEncoderF[H[+a] <: Option[a]]: RowEncoderF[H, RowF[H]] = identity + implicit def identityRowEncoderF[H[+a] <: Option[a]]: RowEncoderF[RowF] = identity - implicit def RowEncoderF[H[+a] <: Option[a]]: Contravariant[RowEncoderF[H, *]] = - new Contravariant[RowEncoderF[H, *]] { - override def contramap[A, B](fa: RowEncoderF[H, A])(f: B => A): RowEncoderF[H, B] = + implicit def RowEncoderF[H[+a] <: Option[a]]: Contravariant[RowEncoderF[*]] = + new Contravariant[RowEncoderF[*]] { + override def contramap[A, B](fa: RowEncoderF[A])(f: B => A): RowEncoderF[B] = fa.contramap(f) } 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 c1c64457..985f027f 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowF.scala @@ -28,7 +28,7 @@ import cats.syntax.all._ * * '''Note:''' the following invariant holds when using this class: `values` and `headers` have the same size if headers are present. */ -case class RowF[H[+a] <: Option[a]](values: NonEmptyList[String], +case class RowF(values: NonEmptyList[String], line: Option[Long] = None) { /** Number of cells in the row. */ @@ -37,7 +37,7 @@ case class RowF[H[+a] <: Option[a]](values: NonEmptyList[String], /** * Set the line number for this row. */ - def withLine(line: Option[Long]): RowF[H] = + def withLine(line: Option[Long]): RowF = copy(line = line) /** Returns the content of the cell at `idx` if it exists. @@ -84,25 +84,25 @@ case class RowF[H[+a] <: Option[a]](values: NonEmptyList[String], /** Modifies the cell content at the given `idx` using the function `f`. */ - def modifyAt(idx: Int)(f: String => String): RowF[H] = + def modifyAt(idx: Int)(f: String => String): RowF = if (idx < 0 || idx >= values.size) this else - new RowF[H](values.zipWithIndex.map { case (cell, i) => if (i === idx) f(cell) else cell }) + new RowF(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): RowF[H] = + def updatedAt(idx: Int, value: String): RowF = 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): Option[RowF[H]] = + def deleteAt(idx: Int): Option[RowF] = if (idx < 0 || idx >= values.size) { Some(this) } else { val (before, after) = values.toList.splitAt(idx) - (NonEmptyList.fromList(before ++ after.tail)).map(new RowF[H](_)) + (NonEmptyList.fromList(before ++ after.tail)).map(new RowF(_)) } } diff --git a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala index fcdd40ce..54be2b14 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala @@ -47,14 +47,14 @@ private[csv] object RowParser { state match { case State.BeginningOfField => if (tail.nonEmpty) - Pull.output1(RowF[NoneF](NonEmptyList("", tail).reverse, Some(line))) >> Pull.done + Pull.output1(RowF(NonEmptyList("", tail).reverse, Some(line))) >> Pull.done else Pull.done case State.InUnquoted | State.InQuotedSeenQuote | State.ExpectNewLine => - Pull.output1(RowF[NoneF](NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done + Pull.output1(RowF(NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done case State.InUnquotedSeenCr => Pull.output1( - RowF[NoneF](NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done + RowF(NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done case State.InQuoted => Pull.raiseError[F](new CsvException("unexpected end of input", Some(line))) } diff --git a/csv/shared/src/main/scala/fs2/data/csv/package.scala b/csv/shared/src/main/scala/fs2/data/csv/package.scala index f60339f5..e1bfb5de 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/package.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/package.scala @@ -34,7 +34,7 @@ package object csv { /** A CSV row without headers. */ - type Row = RowF[NoneF] + type Row = RowF type HeaderResult[T] = Either[HeaderError, NonEmptyList[T]] @@ -50,13 +50,13 @@ package object csv { */ @implicitNotFound( "No implicit RowDecoder found for type ${T}.\nYou can define one using RowDecoder.instance, by calling map on another RowDecoder 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 rowDecoder: RowDecoder[${T}] = deriveRowDecoder\nMake sure to have instances of CellDecoder for every member type in scope.\n") - type RowDecoder[T] = RowDecoderF[NoneF, T] + type RowDecoder[T] = RowDecoderF[T] /** Describes how a row can be encoded from a value of the given type. */ @implicitNotFound( "No implicit RowEncoder found for type ${T}.\nYou can define one using RowEncoder.instance, by calling contramap on another RowEncoder 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 rowEncoder: RowEncoder[${T}] = deriveRowEncoder\nMake sure to have instances of CellEncoder for every member type in scope.\n") - type RowEncoder[T] = RowEncoderF[NoneF, T] + type RowEncoder[T] = RowEncoderF[T] @nowarn sealed trait QuoteHandling From ce17a1628ef56a19d244c02201eed31a62fc3b96 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 05:54:48 +0200 Subject: [PATCH 08/12] remove F suffix from type names --- .../fs2/data/csv/{RowF.scala => Row.scala} | 14 +++--- .../{RowDecoderF.scala => RowDecoder.scala} | 46 +++++++++---------- .../{RowEncoderF.scala => RowEncoder.scala} | 18 ++++---- .../fs2/data/csv/internals/RowParser.scala | 16 +++---- .../src/main/scala/fs2/data/csv/package.scala | 18 -------- .../scala/fs2/data/csv/CsvParserTest.scala | 4 +- 6 files changed, 49 insertions(+), 67 deletions(-) rename csv/shared/src/main/scala/fs2/data/csv/{RowF.scala => Row.scala} (91%) rename csv/shared/src/main/scala/fs2/data/csv/{RowDecoderF.scala => RowDecoder.scala} (72%) rename csv/shared/src/main/scala/fs2/data/csv/{RowEncoderF.scala => RowEncoder.scala} (78%) diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala b/csv/shared/src/main/scala/fs2/data/csv/Row.scala similarity index 91% rename from csv/shared/src/main/scala/fs2/data/csv/RowF.scala rename to csv/shared/src/main/scala/fs2/data/csv/Row.scala index 985f027f..53d992aa 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/Row.scala @@ -28,7 +28,7 @@ import cats.syntax.all._ * * '''Note:''' the following invariant holds when using this class: `values` and `headers` have the same size if headers are present. */ -case class RowF(values: NonEmptyList[String], +case class Row(values: NonEmptyList[String], line: Option[Long] = None) { /** Number of cells in the row. */ @@ -37,7 +37,7 @@ case class RowF(values: NonEmptyList[String], /** * Set the line number for this row. */ - def withLine(line: Option[Long]): RowF = + def withLine(line: Option[Long]): Row = copy(line = line) /** Returns the content of the cell at `idx` if it exists. @@ -84,25 +84,25 @@ case class RowF(values: NonEmptyList[String], /** Modifies the cell content at the given `idx` using the function `f`. */ - def modifyAt(idx: Int)(f: String => String): RowF = + def modifyAt(idx: Int)(f: String => String): Row = if (idx < 0 || idx >= values.size) this else - new RowF(values.zipWithIndex.map { case (cell, i) => if (i === idx) f(cell) else cell }) + 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): RowF = + 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): Option[RowF] = + def deleteAt(idx: Int): Option[Row] = if (idx < 0 || idx >= values.size) { Some(this) } else { val (before, after) = values.toList.splitAt(idx) - (NonEmptyList.fromList(before ++ after.tail)).map(new RowF(_)) + (NonEmptyList.fromList(before ++ after.tail)).map(new Row(_)) } } diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala similarity index 72% rename from csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala rename to csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala index f578cb03..1f64f936 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala @@ -30,15 +30,15 @@ import scala.annotation.tailrec * Actually, `RowDecoderF` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] * instance. To get the full power of it, import `cats.syntax.all._`. */ -@FunctionalInterface trait RowDecoderF[T] { - def apply(row: RowF): DecoderResult[T] +@FunctionalInterface trait RowDecoder[T] { + def apply(row: Row): DecoderResult[T] /** Map the parsed value. * @param f the mapping function * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def map[T2](f: T => T2): RowDecoderF[T2] = + def map[T2](f: T => T2): RowDecoder[T2] = row => apply(row).map(f) /** Map the parsed value to a new decoder, which in turn will be applied to @@ -47,7 +47,7 @@ import scala.annotation.tailrec * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def flatMap[T2](f: T => RowDecoderF[T2]): RowDecoderF[T2] = + def flatMap[T2](f: T => RowDecoder[T2]): RowDecoder[T2] = row => apply(row).flatMap(f(_)(row)) /** Map the parsed value, potentially failing. @@ -55,7 +55,7 @@ import scala.annotation.tailrec * @tparam T2 the result type * @return a row decoder reading the mapped type */ - def emap[T2](f: T => DecoderResult[T2]): RowDecoderF[T2] = + def emap[T2](f: T => DecoderResult[T2]): RowDecoder[T2] = row => apply(row).flatMap(f) /** Fail-over. If this decoder fails, try the supplied other decoder. @@ -63,7 +63,7 @@ import scala.annotation.tailrec * @tparam TT the return type * @return a decoder combining this and the other decoder */ - def or[TT >: T](cd: => RowDecoderF[TT]): RowDecoderF[TT] = + def or[TT >: T](cd: => RowDecoder[TT]): RowDecoder[TT] = row => apply(row) match { case Left(_) => cd(row) @@ -76,7 +76,7 @@ import scala.annotation.tailrec * @tparam B the type the alternative decoder returns * @return a decoder combining both decoders */ - def either[B](cd: RowDecoderF[B]): RowDecoderF[Either[T, B]] = + def either[B](cd: RowDecoder[B]): RowDecoder[Either[T, B]] = row => apply(row) match { case Left(_) => @@ -88,7 +88,7 @@ import scala.annotation.tailrec } } -object RowDecoderF extends ExportedRowDecoderFs { +object RowDecoder extends ExportedRowDecoderFs { @inline def apply[T: RowDecoder]: RowDecoder[T] = implicitly[RowDecoder[T]] @@ -96,30 +96,30 @@ object RowDecoderF extends ExportedRowDecoderFs { @inline def instance[T](f: Row => DecoderResult[T]): RowDecoder[T] = row => f(row) - implicit def identityRowDecoderF[H[+a] <: Option[a]]: RowDecoderF[RowF] = _.asRight + implicit def identityRowDecoder: RowDecoder[Row] = _.asRight - implicit def RowDecoderFInstances[H[+a] <: Option[a]] - : MonadError[RowDecoderF[*], DecoderError] with SemigroupK[RowDecoderF[*]] = - new MonadError[RowDecoderF[*], DecoderError] with SemigroupK[RowDecoderF[*]] { - override def map[A, B](fa: RowDecoderF[A])(f: A => B): RowDecoderF[B] = + implicit def RowDecoderFInstances + : MonadError[RowDecoder[*], DecoderError] with SemigroupK[RowDecoder[*]] = + new MonadError[RowDecoder[*], DecoderError] with SemigroupK[RowDecoder[*]] { + override def map[A, B](fa: RowDecoder[A])(f: A => B): RowDecoder[B] = fa.map(f) - def flatMap[A, B](fa: RowDecoderF[A])(f: A => RowDecoderF[B]): RowDecoderF[B] = + def flatMap[A, B](fa: RowDecoder[A])(f: A => RowDecoder[B]): RowDecoder[B] = fa.flatMap(f) - def handleErrorWith[A](fa: RowDecoderF[A])( - f: DecoderError => RowDecoderF[A]): RowDecoderF[A] = + def handleErrorWith[A](fa: RowDecoder[A])( + f: DecoderError => RowDecoder[A]): RowDecoder[A] = row => fa(row).leftFlatMap(f(_)(row)) - def pure[A](x: A): RowDecoderF[A] = + def pure[A](x: A): RowDecoder[A] = _ => Right(x) - def raiseError[A](e: DecoderError): RowDecoderF[A] = + def raiseError[A](e: DecoderError): RowDecoder[A] = _ => Left(e) - def tailRecM[A, B](a: A)(f: A => RowDecoderF[Either[A, B]]): RowDecoderF[B] = { + def tailRecM[A, B](a: A)(f: A => RowDecoder[Either[A, B]]): RowDecoder[B] = { @tailrec - def step(row: RowF, a: A): DecoderResult[B] = + def step(row: Row, a: A): DecoderResult[B] = f(a)(row) match { case left @ Left(_) => left.rightCast[B] case Right(Left(a)) => step(row, a) @@ -128,14 +128,14 @@ object RowDecoderF extends ExportedRowDecoderFs { row => step(row, a) } - def combineK[A](x: RowDecoderF[A], y: RowDecoderF[A]): RowDecoderF[A] = x or y + def combineK[A](x: RowDecoder[A], y: RowDecoder[A]): RowDecoder[A] = x or y } implicit val toListRowDecoder: RowDecoder[List[String]] = - RowDecoderF.instance(_.values.toList.asRight) + RowDecoder.instance(_.values.toList.asRight) implicit val toNelRowDecoder: RowDecoder[NonEmptyList[String]] = - RowDecoderF.instance(_.values.asRight) + RowDecoder.instance(_.values.asRight) implicit def decodeResultRowDecoder[T](implicit dec: RowDecoder[T]): RowDecoder[DecoderResult[T]] = r => Right(dec(r)) diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala similarity index 78% rename from csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala rename to csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala index 8103aa35..f8856839 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoderF.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala @@ -25,24 +25,24 @@ import scala.annotation.implicitNotFound */ @implicitNotFound( "No implicit RowEncoderF[ found for type ${T}.\nYou can define one using RowEncoderF[.instance, by calling contramap on another RowEncoderF[ 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: RowEncoderF[[${T}] = deriveRowEncoderF[\nMake sure to have instances of CellEncoder for every member type in scope.\n") -@FunctionalInterface trait RowEncoderF[T] { - def apply(elem: T): RowF +@FunctionalInterface trait RowEncoder[T] { + def apply(elem: T): Row - def contramap[B](f: B => T): RowEncoderF[B] = elem => apply(f(elem)) + def contramap[B](f: B => T): RowEncoder[B] = elem => apply(f(elem)) } -object RowEncoderF extends ExportedRowEncoders { +object RowEncoder extends ExportedRowEncoders { @inline def apply[T: RowEncoder]: RowEncoder[T] = implicitly[RowEncoder[T]] @inline - def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => RowF(f(t)) + def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => Row(f(t)) - implicit def identityRowEncoderF[H[+a] <: Option[a]]: RowEncoderF[RowF] = identity + implicit def identityRowEncoder: RowEncoder[Row] = identity - implicit def RowEncoderF[H[+a] <: Option[a]]: Contravariant[RowEncoderF[*]] = - new Contravariant[RowEncoderF[*]] { - override def contramap[A, B](fa: RowEncoderF[A])(f: B => A): RowEncoderF[B] = + implicit def RowEncoder: Contravariant[RowEncoder[*]] = + new Contravariant[RowEncoder[*]] { + override def contramap[A, B](fa: RowEncoder[A])(f: B => A): RowEncoder[B] = fa.contramap(f) } diff --git a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala index 54be2b14..c7662cab 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala @@ -47,14 +47,14 @@ private[csv] object RowParser { state match { case State.BeginningOfField => if (tail.nonEmpty) - Pull.output1(RowF(NonEmptyList("", tail).reverse, Some(line))) >> Pull.done + Pull.output1(Row(NonEmptyList("", tail).reverse, Some(line))) >> Pull.done else Pull.done case State.InUnquoted | State.InQuotedSeenQuote | State.ExpectNewLine => - Pull.output1(RowF(NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done + Pull.output1(Row(NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done case State.InUnquotedSeenCr => Pull.output1( - RowF(NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done + Row(NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done case State.InQuoted => Pull.raiseError[F](new CsvException("unexpected end of input", Some(line))) } @@ -87,7 +87,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) } else if (c == '\r') { rows(T.advance(context), currentField, tail, State.ExpectNewLine, line, chunkAcc) } else { @@ -104,7 +104,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) } else { // this is an error Pull.output(Chunk.from(chunkAcc.result())) >> Pull.raiseError[F]( @@ -125,7 +125,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += RowF(NonEmptyList("", tail).reverse, Some(line))) + chunkAcc += Row(NonEmptyList("", tail).reverse, Some(line))) } else { rows(T.advance(context), currentField, Nil, State.BeginningOfField, line + 1, chunkAcc) } @@ -149,7 +149,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) } else if (c == '\r') { rows(T.advance(context), currentField, tail, State.InUnquotedSeenCr, line, chunkAcc) } else { @@ -165,7 +165,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += RowF(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) } else { currentField.append('\r') if (c == separator) { diff --git a/csv/shared/src/main/scala/fs2/data/csv/package.scala b/csv/shared/src/main/scala/fs2/data/csv/package.scala index e1bfb5de..ef31c129 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/package.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/package.scala @@ -23,19 +23,10 @@ import csv.internals._ import cats.data._ import cats.syntax.all._ -import scala.annotation.implicitNotFound import scala.annotation.nowarn package object csv { - /** Higher kinded version of [[scala.None]]. Ignores the type param. - */ - type NoneF[+A] = None.type - - /** A CSV row without headers. - */ - type Row = RowF - type HeaderResult[T] = Either[HeaderError, NonEmptyList[T]] type DecoderResult[T] = Either[DecoderError, T] @@ -48,15 +39,6 @@ package object csv { * Actually, `RowDecoder` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] * instance. To get the full power of it, import `cats.syntax.all._`. */ - @implicitNotFound( - "No implicit RowDecoder found for type ${T}.\nYou can define one using RowDecoder.instance, by calling map on another RowDecoder 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 rowDecoder: RowDecoder[${T}] = deriveRowDecoder\nMake sure to have instances of CellDecoder for every member type in scope.\n") - type RowDecoder[T] = RowDecoderF[T] - - /** Describes how a row can be encoded from a value of the given type. - */ - @implicitNotFound( - "No implicit RowEncoder found for type ${T}.\nYou can define one using RowEncoder.instance, by calling contramap on another RowEncoder 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 rowEncoder: RowEncoder[${T}] = deriveRowEncoder\nMake sure to have instances of CellEncoder for every member type in scope.\n") - type RowEncoder[T] = RowEncoderF[T] @nowarn sealed trait QuoteHandling diff --git a/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala b/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala index 9507aa41..1f4168f4 100644 --- a/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala +++ b/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala @@ -33,7 +33,7 @@ object CsvParserTest extends SimpleIOSuite { case class Test(i: Int = 0, s: String, j: Option[Int]) case class TestData(name: String, age: Int, description: String) - implicit val testDataCsvRowDecoder: CsvRowDecoder[TestData, String] = (row: RowF[Some, String]) => { + implicit val testDataCsvRowDecoder: CsvRowDecoder[TestData, String] = (row: Row[Some, String]) => { ( row.as[String]("name"), row.as[Int]("age"), @@ -44,7 +44,7 @@ object CsvParserTest extends SimpleIOSuite { } implicit val testDataRowDecoder: RowDecoder[TestData] = new RowDecoder[TestData] { - override def apply(row: RowF[NoneF, Nothing]): DecoderResult[TestData] = { + override def apply(row: Row[NoneF, Nothing]): DecoderResult[TestData] = { ( row.asAt[String](0), row.asAt[Int](1), From a75d0a8c34cfaddcfd336e5bbdacd4803f5fa9b5 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 28 May 2024 09:03:04 +0200 Subject: [PATCH 09/12] remove some remnants of old naming --- csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala index 1f64f936..1a63a894 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala @@ -88,7 +88,7 @@ import scala.annotation.tailrec } } -object RowDecoder extends ExportedRowDecoderFs { +object RowDecoder extends ExportedRowDecoders { @inline def apply[T: RowDecoder]: RowDecoder[T] = implicitly[RowDecoder[T]] @@ -98,7 +98,7 @@ object RowDecoder extends ExportedRowDecoderFs { implicit def identityRowDecoder: RowDecoder[Row] = _.asRight - implicit def RowDecoderFInstances + implicit def RowDecoderInstances : MonadError[RowDecoder[*], DecoderError] with SemigroupK[RowDecoder[*]] = new MonadError[RowDecoder[*], DecoderError] with SemigroupK[RowDecoder[*]] { override def map[A, B](fa: RowDecoder[A])(f: A => B): RowDecoder[B] = @@ -141,6 +141,6 @@ object RowDecoder extends ExportedRowDecoderFs { r => Right(dec(r)) } -trait ExportedRowDecoderFs { +trait ExportedRowDecoders { implicit def exportedRowDecoders[A](implicit exported: Exported[RowDecoder[A]]): RowDecoder[A] = exported.instance } From 1ee6e31248dd761feef49bd92bc1df09e173ddb1 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Sun, 16 Jun 2024 16:33:40 +0200 Subject: [PATCH 10/12] remove obsolete comments --- csv/shared/src/main/scala/fs2/data/csv/Row.scala | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/csv/shared/src/main/scala/fs2/data/csv/Row.scala b/csv/shared/src/main/scala/fs2/data/csv/Row.scala index 53d992aa..44b1b665 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/Row.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/Row.scala @@ -19,14 +19,10 @@ package fs2.data.csv import cats.data._ import cats.syntax.all._ -/** A CSV row with or without headers. The presence of headers is encoded via the first type param - * which is a subtype of [[scala.Option]]. By preserving this information in types, it's possible to define - * [[Row]] and [[CsvRow]] aliases as if they were plain case classes while keeping the code DRY. - * +/** A CSV row without headers. + * * Operations on columns can always be performed using 0-based indices and additionally using a specified header value * if headers are present (and this fact statically known). - * - * '''Note:''' the following invariant holds when using this class: `values` and `headers` have the same size if headers are present. */ case class Row(values: NonEmptyList[String], line: Option[Long] = None) { From fe2b4ff09da8cf04bad087b9cb7443cd6357e422 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 18 Jun 2024 01:36:48 +0200 Subject: [PATCH 11/12] make everything work again --- .../scala/fs2/data/csv/CsvRowDecoder.scala | 48 +++++++ .../scala/fs2/data/csv/CsvRowEncoder.scala | 42 ++++++ .../src/main/scala/fs2/data/csv/Row.scala | 19 +-- .../main/scala/fs2/data/csv/RowDecoder.scala | 9 +- .../main/scala/fs2/data/csv/RowEncoder.scala | 20 ++- .../scala/fs2/data/csv/WriteableHeader.scala | 5 +- .../main/scala/fs2/data/csv/exceptions.scala | 2 +- .../fs2/data/csv/internals/RowParser.scala | 18 ++- .../src/main/scala/fs2/data/csv/package.scala | 131 +++++++++++++++++- .../scala/fs2/data/csv/CsvExceptionSpec.scala | 5 +- .../scala/fs2/data/csv/CsvParserTest.scala | 96 +++++-------- .../data/csv/LineNumberPreservationTest.scala | 42 ------ ...ecoderFTest.scala => RowDecoderTest.scala} | 20 +-- .../test/scala/fs2/data/csv/RowFTest.scala | 75 ---------- .../src/test/scala/fs2/data/csv/RowTest.scala | 62 +++++++++ 15 files changed, 367 insertions(+), 227 deletions(-) create mode 100644 csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala create mode 100644 csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala delete mode 100644 csv/shared/src/test/scala/fs2/data/csv/LineNumberPreservationTest.scala rename csv/shared/src/test/scala/fs2/data/csv/{RowDecoderFTest.scala => RowDecoderTest.scala} (82%) delete mode 100644 csv/shared/src/test/scala/fs2/data/csv/RowFTest.scala create mode 100644 csv/shared/src/test/scala/fs2/data/csv/RowTest.scala diff --git a/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala new file mode 100644 index 00000000..3afef615 --- /dev/null +++ b/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala @@ -0,0 +1,48 @@ +package fs2.data.csv + +import cats.syntax.all._ +import cats._ + +trait CsvRowDecoder[T] { + def apply(headers: List[String]): Either[DecoderError, RowDecoder[T]] +} + +object CsvRowDecoder { + def as[T: CellDecoder](name: String): CsvRowDecoder[T] = { + (headers: List[String]) => + val i = headers.toList.indexOf(name) + Either.cond( + i >= 0, + (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") + ) + } + + 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)) + } + + implicit val CsvRowDecoderInstances: Applicative[CsvRowDecoder] with SemigroupK[CsvRowDecoder] = + new Applicative[CsvRowDecoder] with SemigroupK[CsvRowDecoder] { + + override def combineK[A](x: CsvRowDecoder[A], y: CsvRowDecoder[A]): CsvRowDecoder[A] = + headers => + (x(headers), y(headers)).mapN(SemigroupK[RowDecoder].combineK) + + override def map[A, B](fa: CsvRowDecoder[A])(f: A => B): CsvRowDecoder[B] = + headers => fa(headers).map(_.map(f)) + + def pure[A](x: A): CsvRowDecoder[A] = + _ => Right(x.pure[RowDecoder]) + + def ap[A, B](ff: CsvRowDecoder[A => B])(fa: CsvRowDecoder[A]): CsvRowDecoder[B] = + headers => + (ff(headers), fa(headers)).mapN((a, b) => Applicative[RowDecoder].ap(a)(b)) + } +} diff --git a/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala new file mode 100644 index 00000000..a0a714b8 --- /dev/null +++ b/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala @@ -0,0 +1,42 @@ +package fs2.data.csv + +import cats.ContravariantMonoidal + +trait CsvRowEncoder[T] { + def headers: List[String] + def encoder: RowEncoder[T] +} + +object CsvRowEncoder { + 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)] = + ContravariantMonoidal[RowEncoder].product(fa.encoder, fb.encoder) + + } + + 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) + + } + + override def unit: CsvRowEncoder[Unit] = + new CsvRowEncoder[Unit] { + def headers = Nil + def encoder = ContravariantMonoidal[RowEncoder].unit + } + + + } +} diff --git a/csv/shared/src/main/scala/fs2/data/csv/Row.scala b/csv/shared/src/main/scala/fs2/data/csv/Row.scala index 44b1b665..29801c51 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/Row.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/Row.scala @@ -16,15 +16,13 @@ package fs2.data.csv -import cats.data._ import cats.syntax.all._ -/** A CSV row without headers. +/** A CSV row * - * Operations on columns can always be performed using 0-based indices and additionally using a specified header value - * if headers are present (and this fact statically known). + * Operations on columns can be performed using 0-based indices */ -case class Row(values: NonEmptyList[String], +case class Row(values: List[String], line: Option[Long] = None) { /** Number of cells in the row. */ @@ -93,12 +91,17 @@ case class Row(values: NonEmptyList[String], /** Returns the row without the cell at the given `idx`. * If the resulting row is empty, returns `None`. */ - def deleteAt(idx: Int): Option[Row] = + def deleteAt(idx: Int): Row = if (idx < 0 || idx >= values.size) { - Some(this) + this } else { val (before, after) = values.toList.splitAt(idx) - (NonEmptyList.fromList(before ++ after.tail)).map(new Row(_)) + this.copy(values = before ++ after.tail) } } + +object Row { + def unapply(row: Row): Option[List[String]] = + Some(row.values) +} diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala index 1a63a894..23ce9515 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala @@ -17,7 +17,6 @@ package fs2.data.csv import cats._ -import cats.data.NonEmptyList import cats.syntax.all._ import scala.annotation.tailrec @@ -134,11 +133,17 @@ object RowDecoder extends ExportedRowDecoders { implicit val toListRowDecoder: RowDecoder[List[String]] = RowDecoder.instance(_.values.toList.asRight) - implicit val toNelRowDecoder: RowDecoder[NonEmptyList[String]] = + implicit val toNelRowDecoder: RowDecoder[List[String]] = RowDecoder.instance(_.values.asRight) implicit def decodeResultRowDecoder[T](implicit dec: RowDecoder[T]): RowDecoder[DecoderResult[T]] = r => Right(dec(r)) + + def asAt[T: CellDecoder](idx: Int): RowDecoder[T] = + (row: Row) => + row.values.toList.lift(idx).toRight { + new DecoderError(s"unknown index $idx") + }.flatMap(CellDecoder[T].apply) } trait ExportedRowDecoders { diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala index f8856839..762d7e7f 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala @@ -17,7 +17,6 @@ package fs2.data.csv import cats._ -import cats.data.NonEmptyList import scala.annotation.implicitNotFound @@ -36,18 +35,27 @@ object RowEncoder extends ExportedRowEncoders { def apply[T: RowEncoder]: RowEncoder[T] = implicitly[RowEncoder[T]] @inline - def instance[T](f: T => NonEmptyList[String]): RowEncoder[T] = (t: T) => Row(f(t)) + def instance[T](f: T => List[String]): RowEncoder[T] = (t: T) => Row(f(t)) implicit def identityRowEncoder: RowEncoder[Row] = identity - implicit def RowEncoder: Contravariant[RowEncoder[*]] = - new Contravariant[RowEncoder[*]] { + implicit def RowEncoder: ContravariantMonoidal[RowEncoder] = + new ContravariantMonoidal[RowEncoder] { + override def product[A, B](fa: RowEncoder[A], fb: RowEncoder[B]): RowEncoder[(A, B)] = + ContravariantMonoidal[RowEncoder].product(fa, fb) + + override def unit: RowEncoder[Unit] = new RowEncoder[Unit] { + + override def apply(elem: Unit): Row = Row(Nil, None) + + } + override def contramap[A, B](fa: RowEncoder[A])(f: B => A): RowEncoder[B] = fa.contramap(f) } - implicit val fromNelRowEncoder: RowEncoder[NonEmptyList[String]] = - instance[NonEmptyList[String]](r => r) + implicit val fromNelRowEncoder: RowEncoder[List[String]] = + instance[List[String]](r => r) } trait ExportedRowEncoders { diff --git a/csv/shared/src/main/scala/fs2/data/csv/WriteableHeader.scala b/csv/shared/src/main/scala/fs2/data/csv/WriteableHeader.scala index d70c565a..ea4bc38c 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/WriteableHeader.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/WriteableHeader.scala @@ -17,10 +17,9 @@ package fs2.data.csv import cats.Contravariant -import cats.data.NonEmptyList trait WriteableHeader[Header] { - def apply(headers: NonEmptyList[Header]): NonEmptyList[String] + def apply(headers: List[Header]): List[String] def contramap[B](f: B => Header): WriteableHeader[B] = headers => apply(headers.map(f)) } @@ -37,7 +36,7 @@ object WriteableHeader { _.map(encode) implicit object StringWriteableHeader extends WriteableHeader[String] { - def apply(names: NonEmptyList[String]): NonEmptyList[String] = names + def apply(names: List[String]): List[String] = names } implicit object WriteableHeaderInstances extends Contravariant[WriteableHeader] { 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 bd5f0ccd..c68ac590 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/exceptions.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/exceptions.scala @@ -27,7 +27,7 @@ class DecoderError(msg: String, override val line: Option[Long] = None, inner: T } class HeaderError(msg: String, override val line: Option[Long] = None, inner: Throwable = null) - extends CsvException(msg, line, inner) { + extends DecoderError(msg, line, inner) { override def withLine(line: Option[Long]): HeaderError = new HeaderError(msg, line, inner) } diff --git a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala index c7662cab..2b6ce8fe 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/internals/RowParser.scala @@ -21,8 +21,6 @@ package internals import text._ -import cats.data.{State => _, _} - import scala.collection.immutable.VectorBuilder private[csv] object RowParser { @@ -47,14 +45,14 @@ private[csv] object RowParser { state match { case State.BeginningOfField => if (tail.nonEmpty) - Pull.output1(Row(NonEmptyList("", tail).reverse, Some(line))) >> Pull.done + Pull.output1(Row(("" :: tail).reverse, Some(line))) >> Pull.done else Pull.done case State.InUnquoted | State.InQuotedSeenQuote | State.ExpectNewLine => - Pull.output1(Row(NonEmptyList(currentField.result(), tail).reverse, Some(line))) >> Pull.done + Pull.output1(Row((currentField.result() :: tail).reverse, Some(line))) >> Pull.done case State.InUnquotedSeenCr => Pull.output1( - Row(NonEmptyList(currentField.append('\r').result(), tail).reverse, Some(line))) >> Pull.done + Row((currentField.append('\r').result() :: tail).reverse, Some(line))) >> Pull.done case State.InQuoted => Pull.raiseError[F](new CsvException("unexpected end of input", Some(line))) } @@ -87,7 +85,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row((field :: tail).reverse, Some(line))) } else if (c == '\r') { rows(T.advance(context), currentField, tail, State.ExpectNewLine, line, chunkAcc) } else { @@ -104,7 +102,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row((field :: tail).reverse, Some(line))) } else { // this is an error Pull.output(Chunk.from(chunkAcc.result())) >> Pull.raiseError[F]( @@ -125,7 +123,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList("", tail).reverse, Some(line))) + chunkAcc += Row(("" :: tail).reverse, Some(line))) } else { rows(T.advance(context), currentField, Nil, State.BeginningOfField, line + 1, chunkAcc) } @@ -149,7 +147,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row((field :: tail).reverse, Some(line))) } else if (c == '\r') { rows(T.advance(context), currentField, tail, State.InUnquotedSeenCr, line, chunkAcc) } else { @@ -165,7 +163,7 @@ private[csv] object RowParser { Nil, State.BeginningOfField, line + 1, - chunkAcc += Row(NonEmptyList(field, tail).reverse, Some(line))) + chunkAcc += Row((field :: tail).reverse, Some(line))) } else { currentField.append('\r') if (c == separator) { diff --git a/csv/shared/src/main/scala/fs2/data/csv/package.scala b/csv/shared/src/main/scala/fs2/data/csv/package.scala index ef31c129..6688ebfe 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/package.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/package.scala @@ -93,6 +93,22 @@ package object csv { lowlevel.rows(separator, quoteHandling) andThen lowlevel.skipHeaders andThen lowlevel.decode } + /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, + * assuming the file contains headers and they need to be taken into account for decoding. + */ + def decodeUsingHeaders[T]: PartiallyAppliedDecodeUsingHeaders[T] = + new PartiallyAppliedDecodeUsingHeaders[T](dummy = true) + + @nowarn + class PartiallyAppliedDecodeUsingHeaders[T](val dummy: Boolean) extends AnyVal { + def apply[F[_], C](separator: Char = ',', quoteHandling: QuoteHandling = QuoteHandling.RFCCompliant)( + implicit + F: RaiseThrowable[F], + C: CharLikeChunks[F, C], + T: CsvRowDecoder[T]): Pipe[F, C, T] = + lowlevel.rows(separator, quoteHandling) andThen lowlevel.decodeRow[F, T] + } + /** Encode a specified type into a CSV that contains no headers. */ def encodeWithoutHeaders[T]: PartiallyAppliedEncodeWithoutHeaders[T] = new PartiallyAppliedEncodeWithoutHeaders[T](dummy = true) @@ -116,7 +132,7 @@ package object csv { @nowarn class PartiallyAppliedEncodeGivenHeaders[T](val dummy: Boolean) extends AnyVal { - def apply[F[_], Header](headers: NonEmptyList[Header], + def apply[F[_], Header](headers: List[Header], fullRows: Boolean = false, separator: Char = ',', newline: String = "\n", @@ -130,6 +146,24 @@ package object csv { } } + /** Encode a specified type into a CSV that contains the headers determined by encoding the first element. Empty if input is. */ + def encodeUsingHeaders[T]: PartiallyAppliedEncodeUsingFirstHeaders[T] = + new PartiallyAppliedEncodeUsingFirstHeaders(dummy = true) + + @nowarn + class PartiallyAppliedEncodeUsingFirstHeaders[T](val dummy: Boolean) extends AnyVal { + def apply[F[_]](fullRows: Boolean = false, + separator: Char = ',', + newline: String = "\n", + escape: EscapeMode = EscapeMode.Auto)(implicit + T: CsvRowEncoder[T]): Pipe[F, T, String] = { + val stringPipe = + if (fullRows) lowlevel.toRowStrings[F](separator, newline, escape) + else lowlevel.toStrings[F](separator, newline, escape) + lowlevel.encodeRowWithHeaders[F, T] 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. @@ -165,19 +199,45 @@ package object csv { def attemptDecode[F[_], R](implicit R: RowDecoder[R]): Pipe[F, Row, DecoderResult[R]] = _.map(R(_)) + /** Decodes [[CsvRow]]s (with headers) into a specified type using a suitable [[CsvRowDecoder]]. */ + def decodeRow[F[_], R](implicit + F: RaiseThrowable[F], + R: CsvRowDecoder[R]): Pipe[F, Row, R] = + s => s.through(attemptDecodeRow[F, R]).rethrow + + /** Decodes [[CsvRow]]s (with headers) into a specified type using a suitable [[CsvRowDecoder]], but signal errors as values. */ + def attemptDecodeRow[F[_], R](implicit + R: CsvRowDecoder[R]): Pipe[F, Row, DecoderResult[R]] = + (_: Stream[F, Row]).pull.uncons1 + .flatMap { + case Some((headers, tail)) => + tail.through(attemptDecodeRowWithHeaders[F, R](headers.values)).pull.echo + case None => Pull.done + }.stream + + def attemptDecodeRowWithHeaders[F[_], R](headers: List[String])(implicit + R: CsvRowDecoder[R]): Pipe[F, Row, DecoderResult[R]] = + stream => + R(headers) match { + case Left(err) => + Stream(Left(err)) + case Right(p) => + stream.through(attemptDecode(p)) + } + /** Encode a given type into CSV rows using a set of explicitly given headers. */ - def writeWithHeaders[F[_], Header](headers: NonEmptyList[Header])(implicit - H: WriteableHeader[Header]): Pipe[F, Row, NonEmptyList[String]] = + def writeWithHeaders[F[_], Header](headers: List[Header])(implicit + H: WriteableHeader[Header]): Pipe[F, Row, List[String]] = Stream(H(headers)) ++ _.map(_.values) /** Encode a given type into CSV rows without headers. */ - def writeWithoutHeaders[F[_]]: Pipe[F, Row, NonEmptyList[String]] = + def writeWithoutHeaders[F[_]]: Pipe[F, Row, List[String]] = _.map(_.values) /** Serialize a CSV row to Strings. No guarantees are given on how the resulting Strings are cut. */ def toStrings[F[_]](separator: Char = ',', newline: String = "\n", - escape: EscapeMode = EscapeMode.Auto): Pipe[F, NonEmptyList[String], String] = { + escape: EscapeMode = EscapeMode.Auto): Pipe[F, List[String], String] = { _.flatMap(nel => Stream .emits(nel.toList) @@ -188,7 +248,7 @@ package object csv { /** Serialize a CSV row to Strings. Guaranteed to emit one String per CSV row (= one line if no quoted newlines are contained in the value). */ def toRowStrings[F[_]](separator: Char = ',', newline: String = "\n", - escape: EscapeMode = EscapeMode.Auto): Pipe[F, NonEmptyList[String], String] = { + escape: EscapeMode = EscapeMode.Auto): Pipe[F, List[String], String] = { // explicit Show avoids mapping the NEL before val showColumn: Show[String] = Show.show(RowWriter.encodeColumn(separator, escape)) @@ -198,9 +258,68 @@ package object csv { /** Encode a given type into simple CSV rows without headers. */ def encode[F[_], R](implicit R: RowEncoder[R]): Pipe[F, R, Row] = _.map(R(_)) + + /** Encode a given type into CSV rows with headers. */ + def encodeRow[F[_], R](implicit R: CsvRowEncoder[R]): Pipe[F, R, Row] = + _.map(R.encoder(_)) + + /** Encode a given type into CSV row with headers taken from the first element. + * If the input stream is empty, the output contains only the headers + */ + def encodeRowWithHeaders[F[_], R: CsvRowEncoder]: Pipe[F, R, List[String]] = + stream => + Stream(CsvRowEncoder[R].headers) ++ + stream.through(encodeRow[F, R]).map(_.values) } object lenient { + /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, with failures at the + * element level instead of failing the stream. + * + * Scenarios: + * - If skipHeaders is false, then the file contains no headers. + * - If skipHeaders is true, then the headers in the file will be skipped. + * + * For both scenarios the file is assumed to be compliant with the set of headers given. + */ + def attemptDecodeGivenHeaders[T]: PartiallyAppliedDecodeAttemptGivenHeaders[T] = + new PartiallyAppliedDecodeAttemptGivenHeaders[T](dummy = true) + + class PartiallyAppliedDecodeAttemptGivenHeaders[T](val dummy: Boolean) extends AnyVal { + def apply[F[_], C](headers: List[String], + skipHeaders: Boolean = false, + separator: Char = ',', + quoteHandling: QuoteHandling = QuoteHandling.RFCCompliant)(implicit + F: RaiseThrowable[F], + C: CharLikeChunks[F, C], + T: CsvRowDecoder[T]): Pipe[F, C, Either[CsvException, T]] = { + if (skipHeaders) + lowlevel.rows(separator, quoteHandling) andThen lowlevel.skipHeaders andThen + lowlevel.attemptDecodeRowWithHeaders(headers) + else + lowlevel.rows(separator, quoteHandling) andThen lowlevel.attemptDecodeRowWithHeaders( + headers) + } + } + + /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, with failures at the + * element level instead of failing the stream. + * + * This function assumes the file contains headers and they need to be taken into account for decoding. + */ + def attemptDecodeUsingHeaders[T]: PartiallyAppliedDecodeAttemptUsingHeaders[T] = + new PartiallyAppliedDecodeAttemptUsingHeaders[T](dummy = true) + + class PartiallyAppliedDecodeAttemptUsingHeaders[T](val dummy: Boolean) extends AnyVal { + def apply[F[_], C, Header](separator: Char = ',', quoteHandling: QuoteHandling = QuoteHandling.RFCCompliant)( + implicit + F: RaiseThrowable[F], + C: CharLikeChunks[F, C], + T: CsvRowDecoder[T]): Pipe[F, C, Either[CsvException, T]] = { + lowlevel.rows(separator, quoteHandling) andThen lowlevel.attemptDecodeRow[F, T] + } + } + /** Decode a char-like stream (see [[fs2.data.text.CharLikeChunks]]) into a specified type, with failures at the * element level instead of failing the stream. * diff --git a/csv/shared/src/test/scala/fs2/data/csv/CsvExceptionSpec.scala b/csv/shared/src/test/scala/fs2/data/csv/CsvExceptionSpec.scala index 332e2d64..e86bd5ac 100644 --- a/csv/shared/src/test/scala/fs2/data/csv/CsvExceptionSpec.scala +++ b/csv/shared/src/test/scala/fs2/data/csv/CsvExceptionSpec.scala @@ -16,7 +16,6 @@ package fs2.data.csv -import cats.data.NonEmptyList import fs2.{Fallible, Stream} import weaver._ @@ -33,8 +32,8 @@ object CsvExceptionSpec extends SimpleIOSuite { expect(stream.compile.toList match { case Right( - List(Right(Row(NonEmptyList("1", List("2", "3")))), - Right(Row(NonEmptyList("a", List("b", "c")))), + List(Right(Row(List("1", "2", "3"))), + Right(Row(List("a", "b", "c"))), Left(e: CsvException))) => e.line.contains(3L) // check that we have the correct line number here case _ => false diff --git a/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala b/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala index 1f4168f4..739904be 100644 --- a/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala +++ b/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala @@ -17,7 +17,6 @@ package fs2.data.csv import cats.Eq -import cats.data.NonEmptyList import io.circe.parser.parse import fs2._ import fs2.io.file.{Files, Flags, Path} @@ -33,27 +32,35 @@ object CsvParserTest extends SimpleIOSuite { case class Test(i: Int = 0, s: String, j: Option[Int]) case class TestData(name: String, age: Int, description: String) - implicit val testDataCsvRowDecoder: CsvRowDecoder[TestData, String] = (row: Row[Some, String]) => { + case class CsvRow(toMap: Map[String, String]) + object CsvRow { + implicit val decoder: CsvRowDecoder[CsvRow] = + (headers: List[String]) => + Right { (row: Row) => + Right(CsvRow(headers.zip(row.values).toList.toMap)) + } + + def fromListHeaders(l: List[(String, String)]): Option[CsvRow] = + Some(CsvRow(l.toMap)) + } + + implicit val testDataCsvRowDecoder: CsvRowDecoder[TestData] = ( - row.as[String]("name"), - row.as[Int]("age"), - row.as[String]("description") + CsvRowDecoder.as[String]("name"), + CsvRowDecoder.as[Int]("age"), + CsvRowDecoder.as[String]("description") ).mapN { case (name, age, description) => TestData(name, age, description) } - } - implicit val testDataRowDecoder: RowDecoder[TestData] = new RowDecoder[TestData] { - override def apply(row: Row[NoneF, Nothing]): DecoderResult[TestData] = { + implicit val testDataRowDecoder: RowDecoder[TestData] = ( - row.asAt[String](0), - row.asAt[Int](1), - row.asAt[String](2) + RowDecoder.asAt[String](0), + RowDecoder.asAt[Int](1), + RowDecoder.asAt[String](2) ).mapN { case (name, age, description) => TestData(name, age, description) } - } - } implicit val decoderErrorEq: Eq[CsvException] = Eq.by(_.toString) implicit val decoderTestDataEq: Eq[TestData] = Eq.fromUniversalEquals @@ -82,7 +89,7 @@ object CsvParserTest extends SimpleIOSuite { .evalMap { case (path, expected) => Files[IO] .readAll(path, 1024, Flags.Read) - .through(decodeUsingHeaders[CsvRow[String]]()) + .through(decodeUsingHeaders[CsvRow]()) .compile .toList .map(_.map(_.toMap)) @@ -96,13 +103,17 @@ object CsvParserTest extends SimpleIOSuite { allExpected .evalTap { case (path, _) => log.info(path.fileName.toString) } .evalMap { case (_, expected) => + implicit val dec = new CsvRowEncoder[CsvRow] { + val headers = expected.head.keys.toList + val encoder = RowEncoder.instance(row => headers.map(row.toMap)) + } Stream .emits(expected) .map(m => CsvRow.fromListHeaders(m.toList)) .unNone - .through[IO, String](encodeUsingFirstHeaders[CsvRow[String]]()) + .through[IO, String](encodeUsingHeaders[CsvRow]()) .covary[IO] - .through(decodeUsingHeaders[CsvRow[String]]()) + .through(decodeUsingHeaders[CsvRow]()) .compile .toList .map(_.map(_.toMap)) @@ -131,7 +142,7 @@ object CsvParserTest extends SimpleIOSuite { Stream .emit(content) .covary[IO] - .through(decodeUsingHeaders[CsvRow[String]](',', QuoteHandling.Literal)) + .through(decodeUsingHeaders[CsvRow](',', QuoteHandling.Literal)) .compile .toList .map(_.map(_.toMap)) @@ -178,9 +189,6 @@ object CsvParserTest extends SimpleIOSuite { val expected = List( Left(new DecoderError("unknown field name", None)), - Left(new HeaderSizeError(3, 2, Some(3L))), - Left(new DecoderError("unknown field name", None)), - Left(new HeaderSizeError(3, 2, Some(5L))) ) val stream = Stream @@ -218,7 +226,7 @@ object CsvParserTest extends SimpleIOSuite { .through( lenient.attemptDecodeGivenHeaders[TestData](separator = ',', skipHeaders = true, - headers = NonEmptyList.of("name", "age", "description"))) + headers = List("name", "age", "description"))) .compile .toList @@ -249,7 +257,7 @@ object CsvParserTest extends SimpleIOSuite { .through( lenient.attemptDecodeGivenHeaders[TestData](separator = ',', skipHeaders = false, - headers = NonEmptyList.of("name", "age", "description"))) + headers = List("name", "age", "description"))) .compile .toList @@ -317,16 +325,9 @@ object CsvParserTest extends SimpleIOSuite { } pureTest("should fail if a required string field is missing") { - val row = CsvRow - .unsafe( - NonEmptyList.of("12", "3"), - NonEmptyList.of("i", "j") - ) - .withLine(Some(2)) - - testDataCsvRowDecoder(row) match { + testDataCsvRowDecoder(List("i", "j")) match { case Left(error) => expect.eql(error.getMessage, "unknown field name") - case Right(x) => failure(s"Stream succeeded with value $x") + case Right(x) => failure(s"Stream succeeded with value $x") } } @@ -359,35 +360,6 @@ object CsvParserTest extends SimpleIOSuite { } } - pureTest("Parser should return only errors as values when using attemptDecodeUsingHeaders with wrong header") { - val content = - """name1,age,description - |John Doe,47,description 1 - |Jane Doe,50 - |Bob Smith,80,description 2 - |Alice Grey,78 - |""".stripMargin - - val expected = List( - Left(new DecoderError("unknown field name", None)), - Left(new HeaderSizeError(3, 2, Some(3L))), - Left(new DecoderError("unknown field name", None)), - Left(new HeaderSizeError(3, 2, Some(5L))) - ) - - val stream = Stream - .emit(content) - .covary[Fallible] - .through(lenient.attemptDecodeUsingHeaders[TestData](',')) - .compile - .toList - - stream match { - case Right(actual) => expect.eql(expected, actual) - case Left(x) => failure(s"Stream failed with value $x") - } - } - pureTest("Parser should return all decoder results as values when using attemptDecodeGivenHeaders") { val content = """name-should-not-be-used,age-should-not-be-used,description-should-not-be-used @@ -410,7 +382,7 @@ object CsvParserTest extends SimpleIOSuite { .through( lenient.attemptDecodeGivenHeaders[TestData](separator = ',', skipHeaders = true, - headers = NonEmptyList.of("name", "age", "description"))) + headers = List("name", "age", "description"))) .compile .toList @@ -441,7 +413,7 @@ object CsvParserTest extends SimpleIOSuite { .through( lenient.attemptDecodeGivenHeaders[TestData](separator = ',', skipHeaders = false, - headers = NonEmptyList.of("name", "age", "description"))) + headers = List("name", "age", "description"))) .compile .toList diff --git a/csv/shared/src/test/scala/fs2/data/csv/LineNumberPreservationTest.scala b/csv/shared/src/test/scala/fs2/data/csv/LineNumberPreservationTest.scala deleted file mode 100644 index 381025ad..00000000 --- a/csv/shared/src/test/scala/fs2/data/csv/LineNumberPreservationTest.scala +++ /dev/null @@ -1,42 +0,0 @@ -/* - * 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.effect._ -import cats.syntax.all._ -import fs2._ -import fs2.data._ -import weaver._ - -object LineNumberPreservationTest extends SimpleIOSuite { - - test("lowlevel.headers pipe preserves line numbers") { - Stream("""h1,h2,h3 - |a,b,c - |d,e,f - |g,h,i - |""".stripMargin) - .covary[IO] - .through(csv.lowlevel.rows()) - .through(csv.lowlevel.headers[IO, String]) - .zipWithIndex - .map { case (row, idx) => expect.eql((idx + 2).some, row.line) } // idx +1 for headers, +1 as lines are 1-based - .compile - .foldMonoid - } - -} diff --git a/csv/shared/src/test/scala/fs2/data/csv/RowDecoderFTest.scala b/csv/shared/src/test/scala/fs2/data/csv/RowDecoderTest.scala similarity index 82% rename from csv/shared/src/test/scala/fs2/data/csv/RowDecoderFTest.scala rename to csv/shared/src/test/scala/fs2/data/csv/RowDecoderTest.scala index 0924151c..22243db7 100644 --- a/csv/shared/src/test/scala/fs2/data/csv/RowDecoderFTest.scala +++ b/csv/shared/src/test/scala/fs2/data/csv/RowDecoderTest.scala @@ -20,8 +20,10 @@ package csv import cats.effect.IO import weaver._ +import cats.syntax.all._ +import fs2.data.csv.lenient.attemptDecodeUsingHeaders -object RowDecoderFTest extends SimpleIOSuite { +object RowDecoderTest extends SimpleIOSuite { case class TwoNumbers(a: Int, b: Int) @@ -32,12 +34,13 @@ object RowDecoderFTest extends SimpleIOSuite { } yield TwoNumbers(a, b) } - implicit val csvRowDecoder: CsvRowDecoder[TwoNumbers, String] = CsvRowDecoder.instance { row => - for { - a <- row.as[Int]("a") - b <- row.as[Int]("b") - } yield TwoNumbers(a, b) - } + implicit val csvRowDecoder: CsvRowDecoder[TwoNumbers] = + ( + CsvRowDecoder.as[Int]("a"), + CsvRowDecoder.as[Int]("b") + ).mapN( + TwoNumbers(_, _) + ) test("can parse CSV with rows that do not convert by an attempting RowDecoder") { val rows = List( @@ -62,12 +65,11 @@ object RowDecoderFTest extends SimpleIOSuite { ) Stream .emits[IO, String](rows) - .through(decodeUsingHeaders[DecoderResult[TwoNumbers]]()) + .through(attemptDecodeUsingHeaders[TwoNumbers]()) .compile .toList .map { result => expect(result.head.isRight) and expect(result.tail.head.isLeft) } } - } diff --git a/csv/shared/src/test/scala/fs2/data/csv/RowFTest.scala b/csv/shared/src/test/scala/fs2/data/csv/RowFTest.scala deleted file mode 100644 index 3f18f2b4..00000000 --- a/csv/shared/src/test/scala/fs2/data/csv/RowFTest.scala +++ /dev/null @@ -1,75 +0,0 @@ -/* - * 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 weaver._ -import cats.data.NonEmptyList - -object RowFTest extends SimpleIOSuite { - - pureTest("RowF.set should change the value at existing header") { - val row = CsvRow.unsafe(NonEmptyList.of("1", "2", "3"), NonEmptyList.of("a", "b", "c")) - expect.eql(NonEmptyList.of("1", "4", "3"), row.set("b", "4").values) - } - - pureTest("RowF.set should add the value at end of row in case of missing header") { - val row = CsvRow.unsafe(NonEmptyList.of("1", "2", "3"), NonEmptyList.of("a", "b", "c")) - val extended = row.set("d", "4") - expect.eql(NonEmptyList.of("1", "2", "3", "4"), extended.values) and - expect.eql(NonEmptyList.of("a", "b", "c", "d"), extended.headers.get) - } - - pureTest("CsvRow.asOptional should return None for empty cells") { - val row = CsvRow.unsafe(NonEmptyList.of("", "2", "3"), NonEmptyList.of("a", "b", "c")) - expect(row.asOptional[Int]("a").contains(None)) - } - - pureTest("CsvRow.asOptional should return None for missing cells") { - val row = CsvRow.unsafe(NonEmptyList.of("", "2", "3"), NonEmptyList.of("a", "b", "c")) - expect(row.asOptional[Int]("d", missing = _ => Right(None)).contains(None)) - } - - pureTest("CsvRow.asOptional should return None for values considered empty") { - val row = CsvRow.unsafe(NonEmptyList.of("N/A", "2", "3"), NonEmptyList.of("a", "b", "c")) - expect(row.asOptional[Int]("a", isEmpty = _ == "N/A").contains(None)) - } - - pureTest("CsvRow.asOptional should return decoded value for non-empty cells") { - val row = CsvRow.unsafe(NonEmptyList.of("", "2", "3"), NonEmptyList.of("a", "b", "c")) - expect(row.asOptional[Int]("b").contains(Some(2))) - } - - pureTest("Row.asOptionalAt should return None for empty cells") { - val row = Row(NonEmptyList.of("", "2", "3")) - expect(row.asOptionalAt[Int](0).contains(None)) - } - - pureTest("CsvRow.asOptionalAt should return None for missing cells") { - val row = Row(NonEmptyList.of("", "2", "3")) - expect(row.asOptionalAt[Int](7, missing = _ => Right(None)).contains(None)) - } - - pureTest("CsvRow.asOptionalAt should return None for values considered empty") { - val row = Row(NonEmptyList.of("N/A", "2", "3")) - expect(row.asOptionalAt[Int](0, isEmpty = _ == "N/A").contains(None)) - } - - pureTest("Row.asOptionalAt should return decoded value for non-empty cells") { - val row = Row(NonEmptyList.of("", "2", "3")) - expect(row.asOptionalAt[Int](1).contains(Some(2))) - } -} diff --git a/csv/shared/src/test/scala/fs2/data/csv/RowTest.scala b/csv/shared/src/test/scala/fs2/data/csv/RowTest.scala new file mode 100644 index 00000000..7f9c011f --- /dev/null +++ b/csv/shared/src/test/scala/fs2/data/csv/RowTest.scala @@ -0,0 +1,62 @@ +/* + * 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 weaver._ + +object RowTest extends SimpleIOSuite { + + pureTest("Row.asOptionalAt should return None for empty cells") { + val row = new Row(List("", "2", "3")) + expect(row.asOptionalAt[Int](0).contains(None)) + } + + pureTest("Row.asOptionalAt should return None for missing cells") { + val row = new Row(List("", "2", "3")) + expect(row.asOptionalAt[Int](3, missing = _ => Right(None)).contains(None)) + } + + pureTest("Row.asOptionalAt should return None for values considered empty") { + val row = new Row(List("N/A", "2", "3")) + expect(row.asOptionalAt[Int](0, isEmpty = _ == "N/A").contains(None)) + } + + pureTest("Row.asOptionalAt should return decoded value for non-empty cells") { + val row = new Row(List("", "2", "3")) + expect(row.asOptionalAt[Int](1).contains(Some(2))) + } + + pureTest("Row.asOptionalAt should return None for empty cells") { + val row = Row(List("", "2", "3")) + expect(row.asOptionalAt[Int](0).contains(None)) + } + + pureTest("Row.asOptionalAt should return None for missing cells") { + val row = Row(List("", "2", "3")) + expect(row.asOptionalAt[Int](7, missing = _ => Right(None)).contains(None)) + } + + pureTest("Row.asOptionalAt should return None for values considered empty") { + val row = Row(List("N/A", "2", "3")) + expect(row.asOptionalAt[Int](0, isEmpty = _ == "N/A").contains(None)) + } + + pureTest("Row.asOptionalAt should return decoded value for non-empty cells") { + val row = Row(List("", "2", "3")) + expect(row.asOptionalAt[Int](1).contains(Some(2))) + } +} From 15b508d24b0f1d9672b24735a648f1bd03b4dd21 Mon Sep 17 00:00:00 2001 From: Matthias Berndt Date: Tue, 18 Jun 2024 15:37:43 +0200 Subject: [PATCH 12/12] make Decoder/Encoder traits sealed --- .../scala/fs2/data/csv/CsvRowDecoder.scala | 33 ++++++++------- .../scala/fs2/data/csv/CsvRowEncoder.scala | 21 +++++++--- .../main/scala/fs2/data/csv/RowDecoder.scala | 41 +++++++++++-------- .../main/scala/fs2/data/csv/RowEncoder.scala | 10 ++--- .../scala/fs2/data/csv/CsvParserTest.scala | 21 ++++++---- 5 files changed, 75 insertions(+), 51 deletions(-) diff --git a/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala index 3afef615..ca6cce3e 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/CsvRowDecoder.scala @@ -3,22 +3,28 @@ package fs2.data.csv import cats.syntax.all._ import cats._ -trait CsvRowDecoder[T] { +sealed trait CsvRowDecoder[T] { def apply(headers: List[String]): Either[DecoderError, RowDecoder[T]] } object CsvRowDecoder { - def as[T: CellDecoder](name: String): CsvRowDecoder[T] = { - (headers: List[String]) => + 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, - (row: Row) => + RowDecoder.instance { (row: Row) => row.values.toList.lift(i).toRight { new HeaderSizeError(headers.size, row.values.size, row.line) - }.flatMap(CellDecoder[T].apply), + }.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) } implicit def decodeResultCsvRowDecoder[T](implicit @@ -30,19 +36,18 @@ object CsvRowDecoder { 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] = - headers => - (x(headers), y(headers)).mapN(SemigroupK[RowDecoder].combineK) - + instance((x.apply, y.apply).mapN(_ <+> _)) + override def map[A, B](fa: CsvRowDecoder[A])(f: A => B): CsvRowDecoder[B] = - headers => fa(headers).map(_.map(f)) + instance(a.map(fa.apply)(f)) def pure[A](x: A): CsvRowDecoder[A] = - _ => Right(x.pure[RowDecoder]) + instance(a.pure(x)) - def ap[A, B](ff: CsvRowDecoder[A => B])(fa: CsvRowDecoder[A]): CsvRowDecoder[B] = - headers => - (ff(headers), fa(headers)).mapN((a, b) => Applicative[RowDecoder].ap(a)(b)) + def ap[A, B](ff: CsvRowDecoder[A => B])(fa: CsvRowDecoder[A]): CsvRowDecoder[B] = { + instance(a.ap(ff.apply)(fa.apply)) + } } } diff --git a/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala index a0a714b8..f3f8ed84 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/CsvRowEncoder.scala @@ -1,13 +1,26 @@ package fs2.data.csv import cats.ContravariantMonoidal +import cats.syntax.all._ -trait CsvRowEncoder[T] { +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] { @@ -15,10 +28,10 @@ object 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 + fa.headers |+| fb.headers.toList override def encoder: RowEncoder[(A, B)] = - ContravariantMonoidal[RowEncoder].product(fa.encoder, fb.encoder) + (fa.encoder, fb.encoder).tupled } @@ -36,7 +49,5 @@ object CsvRowEncoder { def headers = Nil def encoder = ContravariantMonoidal[RowEncoder].unit } - - } } diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala index 23ce9515..90fcd92b 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowDecoder.scala @@ -29,7 +29,8 @@ import scala.annotation.tailrec * Actually, `RowDecoderF` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] * instance. To get the full power of it, import `cats.syntax.all._`. */ -@FunctionalInterface trait RowDecoder[T] { +@FunctionalInterface sealed trait RowDecoder[T] { + import RowDecoder.instance def apply(row: Row): DecoderResult[T] /** Map the parsed value. @@ -38,7 +39,7 @@ import scala.annotation.tailrec * @return a row decoder reading the mapped type */ def map[T2](f: T => T2): RowDecoder[T2] = - row => apply(row).map(f) + instance(row => apply(row).map(f)) /** Map the parsed value to a new decoder, which in turn will be applied to * the parsed value. @@ -47,7 +48,7 @@ import scala.annotation.tailrec * @return a row decoder reading the mapped type */ def flatMap[T2](f: T => RowDecoder[T2]): RowDecoder[T2] = - row => apply(row).flatMap(f(_)(row)) + instance(row => apply(row).flatMap(f(_)(row))) /** Map the parsed value, potentially failing. * @param f the mapping function @@ -55,7 +56,7 @@ import scala.annotation.tailrec * @return a row decoder reading the mapped type */ def emap[T2](f: T => DecoderResult[T2]): RowDecoder[T2] = - row => apply(row).flatMap(f) + instance(row => apply(row).flatMap(f)) /** Fail-over. If this decoder fails, try the supplied other decoder. * @param cd the fail-over decoder @@ -63,12 +64,12 @@ import scala.annotation.tailrec * @return a decoder combining this and the other decoder */ def or[TT >: T](cd: => RowDecoder[TT]): RowDecoder[TT] = - row => + instance { row => apply(row) match { case Left(_) => cd(row) case r @ Right(_) => r.leftCast[DecoderError] } - + } /** Similar to [[or]], but return the result as an Either signaling which row decoder succeeded. Allows for parsing * an unrelated type in case of failure. * @param cd the alternative decoder @@ -76,7 +77,7 @@ import scala.annotation.tailrec * @return a decoder combining both decoders */ def either[B](cd: RowDecoder[B]): RowDecoder[Either[T, B]] = - row => + instance { row => apply(row) match { case Left(_) => cd(row) match { @@ -85,6 +86,7 @@ import scala.annotation.tailrec } case Right(value) => Right(Left(value)) } + } } object RowDecoder extends ExportedRowDecoders { @@ -93,9 +95,13 @@ object RowDecoder extends ExportedRowDecoders { def apply[T: RowDecoder]: RowDecoder[T] = implicitly[RowDecoder[T]] @inline - def instance[T](f: Row => DecoderResult[T]): RowDecoder[T] = row => f(row) + private [csv] def instance[T](f: Row => DecoderResult[T]): RowDecoder[T] = + new RowDecoder[T] { + def apply(row: Row) = f(row) + } + - implicit def identityRowDecoder: RowDecoder[Row] = _.asRight + implicit def identityRowDecoder: RowDecoder[Row] = instance(_.asRight) implicit def RowDecoderInstances : MonadError[RowDecoder[*], DecoderError] with SemigroupK[RowDecoder[*]] = @@ -108,13 +114,13 @@ object RowDecoder extends ExportedRowDecoders { def handleErrorWith[A](fa: RowDecoder[A])( f: DecoderError => RowDecoder[A]): RowDecoder[A] = - row => fa(row).leftFlatMap(f(_)(row)) + instance(row => fa(row).leftFlatMap(f(_)(row))) def pure[A](x: A): RowDecoder[A] = - _ => Right(x) + instance(_ => Right(x)) def raiseError[A](e: DecoderError): RowDecoder[A] = - _ => Left(e) + instance(_ => Left(e)) def tailRecM[A, B](a: A)(f: A => RowDecoder[Either[A, B]]): RowDecoder[B] = { @tailrec @@ -124,26 +130,27 @@ object RowDecoder extends ExportedRowDecoders { case Right(Left(a)) => step(row, a) case Right(right @ Right(_)) => right.leftCast[DecoderError] } - row => step(row, a) + instance(row => step(row, a)) } def combineK[A](x: RowDecoder[A], y: RowDecoder[A]): RowDecoder[A] = x or y } implicit val toListRowDecoder: RowDecoder[List[String]] = - RowDecoder.instance(_.values.toList.asRight) + instance(_.values.toList.asRight) implicit val toNelRowDecoder: RowDecoder[List[String]] = - RowDecoder.instance(_.values.asRight) + instance(_.values.asRight) implicit def decodeResultRowDecoder[T](implicit dec: RowDecoder[T]): RowDecoder[DecoderResult[T]] = - r => Right(dec(r)) + instance(r => Right(dec(r))) def asAt[T: CellDecoder](idx: Int): RowDecoder[T] = - (row: Row) => + instance { (row: Row) => row.values.toList.lift(idx).toRight { new DecoderError(s"unknown index $idx") }.flatMap(CellDecoder[T].apply) + } } trait ExportedRowDecoders { diff --git a/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala b/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala index 762d7e7f..c2825c81 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/RowEncoder.scala @@ -17,6 +17,7 @@ package fs2.data.csv import cats._ +import cats.syntax.all._ import scala.annotation.implicitNotFound @@ -42,13 +43,10 @@ object RowEncoder extends ExportedRowEncoders { implicit def RowEncoder: ContravariantMonoidal[RowEncoder] = new ContravariantMonoidal[RowEncoder] { override def product[A, B](fa: RowEncoder[A], fb: RowEncoder[B]): RowEncoder[(A, B)] = - ContravariantMonoidal[RowEncoder].product(fa, fb) + (fa, fb).tupled - override def unit: RowEncoder[Unit] = new RowEncoder[Unit] { - - override def apply(elem: Unit): Row = Row(Nil, None) - - } + override def unit: RowEncoder[Unit] = + _ => Row(Nil, None) override def contramap[A, B](fa: RowEncoder[A])(f: B => A): RowEncoder[B] = fa.contramap(f) diff --git a/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala b/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala index 739904be..5c0a9ca9 100644 --- a/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala +++ b/csv/shared/src/test/scala/fs2/data/csv/CsvParserTest.scala @@ -35,11 +35,12 @@ object CsvParserTest extends SimpleIOSuite { case class CsvRow(toMap: Map[String, String]) object CsvRow { implicit val decoder: CsvRowDecoder[CsvRow] = - (headers: List[String]) => - Right { (row: Row) => - Right(CsvRow(headers.zip(row.values).toList.toMap)) - } - + CsvRowDecoder.instance { + (headers: List[String]) => + Right { RowDecoder.instance { (row: Row) => + Right(CsvRow(headers.zip(row.values).toList.toMap)) + }} + } def fromListHeaders(l: List[(String, String)]): Option[CsvRow] = Some(CsvRow(l.toMap)) } @@ -103,10 +104,12 @@ object CsvParserTest extends SimpleIOSuite { allExpected .evalTap { case (path, _) => log.info(path.fileName.toString) } .evalMap { case (_, expected) => - implicit val dec = new CsvRowEncoder[CsvRow] { - val headers = expected.head.keys.toList - val encoder = RowEncoder.instance(row => headers.map(row.toMap)) - } + val headers = expected.head.keys.toList + implicit val dec = + CsvRowEncoder.instance[CsvRow]( + headers, + RowEncoder.instance(row => headers.map(row.toMap)) + ) Stream .emits(expected) .map(m => CsvRow.fromListHeaders(m.toList))