diff --git a/build.sbt b/build.sbt index dd1c15086..2f07d1914 100644 --- a/build.sbt +++ b/build.sbt @@ -3,6 +3,7 @@ val scala213 = "2.13.5" val fs2Version = "2.5.3" val circeVersion = "0.13.0" val shapelessVersion = "2.3.3" +val scalaJavaTimeVersion = "2.2.0" val commonSettings = List( scalaVersion := scala213, @@ -105,8 +106,11 @@ lazy val csv = crossProject(JVMPlatform, JSPlatform) .settings(commonSettings) .settings(publishSettings) .settings(name := "fs2-data-csv", description := "Streaming CSV manipulation library") - .jsSettings(libraryDependencies += "io.github.cquiroz" %%% "scala-java-time" % "2.2.0" % Test) - + .jsSettings( + libraryDependencies ++= List( + "io.github.cquiroz" %%% "scala-java-time" % scalaJavaTimeVersion % Test, + "io.github.cquiroz" %%% "scala-java-time-tzdb" % scalaJavaTimeVersion % Test + )) lazy val csvGeneric = crossProject(JVMPlatform, JSPlatform) .crossType(CrossType.Pure) .in(file("csv/generic")) diff --git a/csv/shared/src/main/scala/fs2/data/csv/CellDecoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CellDecoder.scala index 9a6fbdd33..a05590267 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/CellDecoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/CellDecoder.scala @@ -16,8 +16,6 @@ package fs2.data.csv import java.net.{MalformedURLException, URI} -import java.time._ -import java.time.format.DateTimeParseException import java.util.UUID import cats._ @@ -34,15 +32,13 @@ import scala.concurrent.duration.{Duration, FiniteDuration} * * Actually, `CellDecoder` has a [[https://typelevel.org/cats/api/cats/MonadError.html cats `MonadError`]] * instance. To get the full power of it, import `cats.implicits._`. - * */ @implicitNotFound( "No implicit CellDecoder found for type ${T}.\nYou can define one using CellDecoder.instance, by calling map on another CellDecoder or by using generic derivation for coproducts and unary products.\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 cellDecoder: CellDecoder[${T}] = deriveCellDecoder\n\n") trait CellDecoder[T] { def apply(cell: String): DecoderResult[T] - /** - * Map the parsed value. + /** Map the parsed value. * @param f the mapping function * @tparam T2 the result type * @return a cell decoder reading the mapped type @@ -50,8 +46,7 @@ trait CellDecoder[T] { def map[T2](f: T => T2): CellDecoder[T2] = s => apply(s).map(f) - /** - * Map the parsed value to a new decoder, which in turn will be applied to + /** Map the parsed value to a new decoder, which in turn will be applied to * the parsed value. * @param f the mapping function * @tparam T2 the result type @@ -60,8 +55,7 @@ trait CellDecoder[T] { def flatMap[T2](f: T => CellDecoder[T2]): CellDecoder[T2] = s => apply(s).flatMap(f(_)(s)) - /** - * Map the parsed value, potentially failing. + /** Map the parsed value, potentially failing. * @param f the mapping function * @tparam T2 the result type * @return a cell decoder reading the mapped type @@ -69,8 +63,7 @@ trait CellDecoder[T] { def emap[T2](f: T => DecoderResult[T2]): CellDecoder[T2] = s => apply(s).flatMap(f) - /** - * Fail-over. If this decoder fails, try the supplied other decoder. + /** Fail-over. If this decoder fails, try the supplied other decoder. * @param cd the fail-over decoder * @tparam TT the return type * @return a decoder combining this and the other decoder @@ -82,8 +75,7 @@ trait CellDecoder[T] { case r @ Right(_) => r.leftCast[DecoderError] } - /** - * Similar to [[or]], but return the result as an Either signaling which cell decoder succeeded. Allows for parsing + /** Similar to [[or]], but return the result as an Either signaling which cell decoder succeeded. Allows for parsing * an unrelated type in case of failure. * @param cd the alternative decoder * @tparam B the type the alternative decoder returns @@ -103,6 +95,7 @@ trait CellDecoder[T] { object CellDecoder extends CellDecoderInstances1 + with CellDecoderInstances2 with LiteralCellDecoders with ExportedCellDecoders with PlatformCellDecoders { @@ -204,30 +197,67 @@ object CellDecoder Either .catchOnly[IllegalArgumentException](UUID.fromString(s)) .leftMap(t => new DecoderError(s"couldn't parse UUID", t)) +} + +trait CellDecoderInstances1 { + implicit val durationDecoder: CellDecoder[Duration] = s => + Either + .catchNonFatal(Duration(s)) + .leftMap(t => new DecoderError("couldn't parse Duration", t)) +} + +// Java Time Decoders +trait CellDecoderInstances2 { + import java.time._ + import java.time.format.DateTimeFormatter - // Java Time implicit val instantDecoder: CellDecoder[Instant] = javaTimeDecoder("Instant", Instant.parse) implicit val periodDecoder: CellDecoder[Period] = javaTimeDecoder("Period", Period.parse) - implicit val localDateDecoder: CellDecoder[LocalDate] = javaTimeDecoder("LocalDate", LocalDate.parse) - implicit val localDateTimeDecoder: CellDecoder[LocalDateTime] = javaTimeDecoder("LocalDateTime", LocalDateTime.parse) - implicit val localTimeDecoder: CellDecoder[LocalTime] = javaTimeDecoder("LocalTime", LocalTime.parse) - implicit val offsetDateTimeDecoder: CellDecoder[OffsetDateTime] = - javaTimeDecoder("OffsetDateTime", OffsetDateTime.parse) - implicit val offsetTimeDecoder: CellDecoder[OffsetTime] = javaTimeDecoder("OffsetTime", OffsetTime.parse) - implicit val zonedDateTimeDecoder: CellDecoder[ZonedDateTime] = javaTimeDecoder("ZonedDateTime", ZonedDateTime.parse) + implicit def localDateDecoder(implicit + localDateDecodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE): CellDecoder[LocalDate] = + javaTimeDecoderWithFmt("LocalDate", LocalDate.parse)(localDateDecodeFmt) + implicit def localDateTimeDecoder(implicit + localDateTimeDecodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME): CellDecoder[LocalDateTime] = + javaTimeDecoderWithFmt("LocalDateTime", LocalDateTime.parse)(localDateTimeDecodeFmt) + implicit def localTimeDecoder(implicit + localTimeDecodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_TIME): CellDecoder[LocalTime] = + javaTimeDecoderWithFmt("LocalTime", LocalTime.parse)(localTimeDecodeFmt) + implicit def offsetDateTimeDecoder(implicit + offsetDateTimeDecodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME) + : CellDecoder[OffsetDateTime] = + javaTimeDecoderWithFmt("OffsetDateTime", OffsetDateTime.parse)(offsetDateTimeDecodeFmt) + implicit def offsetTimeDecoder(implicit + offsetTimeDecodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_TIME): CellDecoder[OffsetTime] = + javaTimeDecoderWithFmt("OffsetTime", OffsetTime.parse)(offsetTimeDecodeFmt) + implicit def zonedDateTimeDecoder(implicit + zonedDateTimeDecodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_ZONED_DATE_TIME): CellDecoder[ZonedDateTime] = + javaTimeDecoderWithFmt("ZonedDateTime", ZonedDateTime.parse)(zonedDateTimeDecodeFmt) + implicit val dayOfWeekDecoder: CellDecoder[DayOfWeek] = + javaTimeDecoder("DayOfWeek", t => DayOfWeek.valueOf(t.toUpperCase)) + implicit val javaTimeDurationDecoder: CellDecoder[java.time.Duration] = + javaTimeDecoder("java.time.Duration", java.time.Duration.parse) + implicit val monthDecoder: CellDecoder[Month] = javaTimeDecoder("Month", t => Month.valueOf(t.toUpperCase)) + implicit val monthDayDecoder: CellDecoder[MonthDay] = javaTimeDecoder("MonthDay", MonthDay.parse) + implicit val yearDayDecoder: CellDecoder[Year] = javaTimeDecoder("Year", Year.parse) + implicit val yearMonthDayDecoder: CellDecoder[YearMonth] = javaTimeDecoder("YearMonth", YearMonth.parse) + implicit val zoneIdDecoder: CellDecoder[ZoneId] = javaTimeDecoder("ZoneId", ZoneId.of) + implicit val zoneOffsetDecoder: CellDecoder[ZoneOffset] = javaTimeDecoder("ZoneOffset", ZoneOffset.of) private def javaTimeDecoder[T](name: String, t: String => T): CellDecoder[T] = s => Either - .catchOnly[DateTimeParseException](t(s)) + .catchOnly[DateTimeException](t(s)) .leftMap(t => new DecoderError(s"couldn't parse $name", t)) -} -trait CellDecoderInstances1 { - implicit val durationDecoder: CellDecoder[Duration] = s => - Either - .catchNonFatal(Duration(s)) - .leftMap(t => new DecoderError(s"couldn't parse Duration", t)) + private def javaTimeDecoderWithFmt[T](name: String, t: (String, DateTimeFormatter) => T)(implicit + fmt: DateTimeFormatter): CellDecoder[T] = { + CellDecoder[String] + .emap { s => + Either + .catchOnly[DateTimeException](t(s, fmt)) + .leftMap(t => new DecoderError(s"couldn't parse $name with fmt: ${fmt}", t)) + } + } } trait ExportedCellDecoders { diff --git a/csv/shared/src/main/scala/fs2/data/csv/CellEncoder.scala b/csv/shared/src/main/scala/fs2/data/csv/CellEncoder.scala index 4339f6599..314281a7e 100644 --- a/csv/shared/src/main/scala/fs2/data/csv/CellEncoder.scala +++ b/csv/shared/src/main/scala/fs2/data/csv/CellEncoder.scala @@ -16,7 +16,6 @@ package fs2.data.csv import java.net.URI -import java.time._ import java.util.UUID import cats._ @@ -37,6 +36,7 @@ trait CellEncoder[T] { object CellEncoder extends CellEncoderInstances1 + with CellEncoderInstances2 with LiteralCellEncoders with ExportedCellEncoders with PlatformCellEncoders { @@ -77,17 +77,6 @@ object CellEncoder implicit val javaUriEncoder: CellEncoder[URI] = fromToString(_) implicit val uuidEncoder: CellEncoder[UUID] = fromToString(_) - // Java Time - implicit val instantEncoder: CellEncoder[Instant] = fromToString(_) - implicit val periodEncoder: CellEncoder[Period] = fromToString(_) - implicit val localDateEncoder: CellEncoder[LocalDate] = fromToString(_) - implicit val localDateTimeEncoder: CellEncoder[LocalDateTime] = fromToString(_) - implicit val localTimeEncoder: CellEncoder[LocalTime] = fromToString(_) - implicit val offsetDateTimeEncoder: CellEncoder[OffsetDateTime] = - fromToString(_) - implicit val offsetTimeEncoder: CellEncoder[OffsetTime] = fromToString(_) - implicit val zonedDateTimeEncoder: CellEncoder[ZonedDateTime] = fromToString(_) - // Option implicit def optionEncoder[Cell: CellEncoder]: CellEncoder[Option[Cell]] = _.fold("")(CellEncoder[Cell].apply) @@ -98,6 +87,46 @@ trait CellEncoderInstances1 { implicit val durationEncoder: CellEncoder[Duration] = _.toString } +// Java Time Encoders +trait CellEncoderInstances2 { + import java.time._ + import java.time.format.DateTimeFormatter + import java.time.temporal.TemporalAccessor + + implicit val instantEncoder: CellEncoder[Instant] = _.toString + implicit val periodEncoder: CellEncoder[Period] = _.toString + implicit def localDateEncoder(implicit + localDateEncodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE): CellEncoder[LocalDate] = + javaTimeEncoderWithFmt(_)(localDateEncodeFmt) + implicit def localDateTimeEncoder(implicit + localDateTimeEncodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_DATE_TIME): CellEncoder[LocalDateTime] = + javaTimeEncoderWithFmt(_)(localDateTimeEncodeFmt) + implicit def localTimeEncoder(implicit + localTimeEncodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_LOCAL_TIME): CellEncoder[LocalTime] = + javaTimeEncoderWithFmt(_)(localTimeEncodeFmt) + implicit def offsetDateTimeEncoder(implicit + offsetDateTimeEncodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_DATE_TIME) + : CellEncoder[OffsetDateTime] = javaTimeEncoderWithFmt(_)(offsetDateTimeEncodeFmt) + implicit def offsetTimeEncoder(implicit + offsetTimeEncodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_OFFSET_TIME): CellEncoder[OffsetTime] = + javaTimeEncoderWithFmt(_)(offsetTimeEncodeFmt) + implicit def zonedDateTimeEncoder(implicit + zonedDateTimeEncodeFmt: DateTimeFormatter = DateTimeFormatter.ISO_ZONED_DATE_TIME): CellEncoder[ZonedDateTime] = + javaTimeEncoderWithFmt(_)(zonedDateTimeEncodeFmt) + implicit val dayOfWeekEncoder: CellEncoder[DayOfWeek] = _.toString + implicit val javaTimeDurationEncoder: CellEncoder[java.time.Duration] = _.toString + implicit val monthEncoder: CellEncoder[Month] = _.toString + implicit val monthDayEncoder: CellEncoder[MonthDay] = _.toString + implicit val yearDayEncoder: CellEncoder[Year] = _.toString + implicit val yearMonthDayEncoder: CellEncoder[YearMonth] = _.toString + implicit val zoneIdEncoder: CellEncoder[ZoneId] = _.toString + implicit val zoneOffsetEncoder: CellEncoder[ZoneOffset] = _.toString + + private def javaTimeEncoderWithFmt[T <: TemporalAccessor](value: T)(implicit fmt: DateTimeFormatter): String = { + fmt.format(value) + } +} + trait ExportedCellEncoders { implicit def exportedCellEncoders[A](implicit exported: Exported[CellEncoder[A]]): CellEncoder[A] = exported.instance diff --git a/csv/shared/src/test/scala/fs2/data/csv/CellDecoderTest.scala b/csv/shared/src/test/scala/fs2/data/csv/CellDecoderTest.scala index 9afd8b73b..51fc3f4b4 100644 --- a/csv/shared/src/test/scala/fs2/data/csv/CellDecoderTest.scala +++ b/csv/shared/src/test/scala/fs2/data/csv/CellDecoderTest.scala @@ -21,8 +21,21 @@ class CellDecoderTest extends AnyFlatSpec with Matchers with EitherValues { CellDecoder[java.net.URI] CellDecoder[java.util.UUID] CellDecoder[java.time.Instant] + CellDecoder[java.time.Period] + CellDecoder[java.time.LocalDate] + CellDecoder[java.time.LocalDateTime] CellDecoder[java.time.LocalTime] + CellDecoder[java.time.OffsetDateTime] + CellDecoder[java.time.OffsetTime] CellDecoder[java.time.ZonedDateTime] + CellDecoder[java.time.DayOfWeek] + CellDecoder[java.time.Duration] + CellDecoder[java.time.Month] + CellDecoder[java.time.MonthDay] + CellDecoder[java.time.Year] + CellDecoder[java.time.YearMonth] + CellDecoder[java.time.ZoneId] + CellDecoder[java.time.ZoneOffset] CellDecoder[DecoderResult[Char]] CellDecoder[Either[String, Char]] diff --git a/csv/shared/src/test/scala/fs2/data/csv/CellEncoderTest.scala b/csv/shared/src/test/scala/fs2/data/csv/CellEncoderTest.scala index 0d8af952f..fb5d0a508 100644 --- a/csv/shared/src/test/scala/fs2/data/csv/CellEncoderTest.scala +++ b/csv/shared/src/test/scala/fs2/data/csv/CellEncoderTest.scala @@ -22,9 +22,22 @@ class CellEncoderTest extends AnyFlatSpec with Matchers with EitherValues { CellEncoder[java.net.URI] CellEncoder[java.util.UUID] - CellEncoder[java.time.Instant] + CellDecoder[java.time.Instant] + CellEncoder[java.time.Period] + CellEncoder[java.time.LocalDate] + CellEncoder[java.time.LocalDateTime] CellEncoder[java.time.LocalTime] + CellEncoder[java.time.OffsetDateTime] + CellEncoder[java.time.OffsetTime] CellEncoder[java.time.ZonedDateTime] + CellEncoder[java.time.DayOfWeek] + CellEncoder[java.time.Duration] + CellEncoder[java.time.Month] + CellEncoder[java.time.MonthDay] + CellEncoder[java.time.Year] + CellEncoder[java.time.YearMonth] + CellEncoder[java.time.ZoneId] + CellEncoder[java.time.ZoneOffset] } it should "decode standard types correctly" in { diff --git a/csv/shared/src/test/scala/fs2/data/csv/JavaTimeRoundTripTest.scala b/csv/shared/src/test/scala/fs2/data/csv/JavaTimeRoundTripTest.scala new file mode 100644 index 000000000..36fde88e4 --- /dev/null +++ b/csv/shared/src/test/scala/fs2/data/csv/JavaTimeRoundTripTest.scala @@ -0,0 +1,74 @@ +package fs2.data.csv + +import java.time._ +import java.time.format.DateTimeFormatter +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import org.scalatest.{Assertion, EitherValues} +import scala.jdk.CollectionConverters._ + +class JavaTimeRoundTripTest extends AnyFlatSpec with Matchers with EitherValues { + private def validateRoundTrip[T](value: T)(implicit enc: CellEncoder[T], dec: CellDecoder[T]): Assertion = { + val encoded: String = enc.apply(value) + val decoded: DecoderResult[T] = dec.apply(encoded) + encoded shouldBe value.toString + decoded shouldBe Right(value) + } + + it should "round trip java time classes with default fmts" in { + val expectedZonedDateTime: ZonedDateTime = ZonedDateTime.of(2021, 3, 8, 13, 4, 29, 6, ZoneId.of("America/New_York")) + val expectedInstant: Instant = expectedZonedDateTime.toInstant + val expectedPeriod: Period = Period.ofDays(10) + val expectedLocalDate: LocalDate = expectedZonedDateTime.toLocalDate + val expectedLocalDateTime: LocalDateTime = expectedZonedDateTime.toLocalDateTime + val expectedLocalTime: LocalTime = expectedZonedDateTime.toLocalTime + val expectedOffsetDateTime: OffsetDateTime = expectedZonedDateTime.toOffsetDateTime + val expectedOffsetTime: OffsetTime = expectedOffsetDateTime.toOffsetTime + val expectedDuration: Duration = Duration.of(30, temporal.ChronoUnit.DAYS) + val expectedMonthDay: MonthDay = MonthDay.of(expectedZonedDateTime.getMonth, expectedZonedDateTime.getDayOfMonth) + val expectedYear: Year = Year.of(expectedZonedDateTime.getYear) + val expectedYearMonth: YearMonth = YearMonth.from(expectedZonedDateTime) + + validateRoundTrip[Instant](expectedInstant) + validateRoundTrip[Period](expectedPeriod) + validateRoundTrip[LocalDate](expectedLocalDate) + validateRoundTrip[LocalDateTime](expectedLocalDateTime) + validateRoundTrip[LocalTime](expectedLocalTime) + validateRoundTrip[OffsetDateTime](expectedOffsetDateTime) + validateRoundTrip[OffsetTime](expectedOffsetTime) + validateRoundTrip[ZonedDateTime](expectedZonedDateTime) + DayOfWeek.values().toList.map(validateRoundTrip[DayOfWeek](_)) + validateRoundTrip[Duration](expectedDuration) + Month.values().toList.map(validateRoundTrip[Month](_)) + validateRoundTrip[MonthDay](expectedMonthDay) + validateRoundTrip[Year](expectedYear) + validateRoundTrip[YearMonth](expectedYearMonth) + ZoneId.getAvailableZoneIds.asScala.map(ZoneId.of(_)).map(validateRoundTrip[ZoneId](_)) + List(ZoneOffset.UTC, ZoneOffset.MAX, ZoneOffset.MAX).map(validateRoundTrip[ZoneOffset](_)) + } + + it should "round trip with overridden formats" in { + val expectedZonedDateTime: ZonedDateTime = + ZonedDateTime.of(LocalDateTime.of(2021, 3, 8, 13, 4, 29), ZoneId.of("America/New_York")) + val expectedString: String = "3/8/2021 13:04:29-0500" + val encoded: String = enc(expectedZonedDateTime) + val decoded: DecoderResult[ZonedDateTime] = dec(encoded) + encoded shouldBe expectedString + decoded.map { d => + d.toLocalDateTime shouldBe expectedZonedDateTime.toLocalDateTime + d.getOffset shouldBe expectedZonedDateTime.getOffset + } + } + + def enc(zdt: ZonedDateTime): String = { + implicit val zonedDateTimeEncodeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("M/d/yyyy HH:mm:ssZ") + val encoded: String = CellEncoder[ZonedDateTime].apply(zdt) + encoded + } + + def dec(encoded: String): DecoderResult[ZonedDateTime] = { + implicit val zonedDateTimeDecodeFmt: DateTimeFormatter = DateTimeFormatter.ofPattern("M/d/yyyy HH:mm:ssZ") + val decoded: DecoderResult[ZonedDateTime] = CellDecoder[ZonedDateTime].apply(encoded) + decoded + } +}