Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Feature/issue 123 #129

Merged
merged 1 commit into from
Mar 9, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"))
Expand Down
86 changes: 58 additions & 28 deletions csv/shared/src/main/scala/fs2/data/csv/CellDecoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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._
Expand All @@ -34,24 +32,21 @@ 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
*/
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
Expand All @@ -60,17 +55,15 @@ 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
*/
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
Expand All @@ -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
Expand All @@ -103,6 +95,7 @@ trait CellDecoder[T] {

object CellDecoder
extends CellDecoderInstances1
with CellDecoderInstances2
with LiteralCellDecoders
with ExportedCellDecoders
with PlatformCellDecoders {
Expand Down Expand Up @@ -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 {
Expand Down
53 changes: 41 additions & 12 deletions csv/shared/src/main/scala/fs2/data/csv/CellEncoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
package fs2.data.csv

import java.net.URI
import java.time._
import java.util.UUID

import cats._
Expand All @@ -37,6 +36,7 @@ trait CellEncoder[T] {

object CellEncoder
extends CellEncoderInstances1
with CellEncoderInstances2
with LiteralCellEncoders
with ExportedCellEncoders
with PlatformCellEncoders {
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions csv/shared/src/test/scala/fs2/data/csv/CellDecoderTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down
15 changes: 14 additions & 1 deletion csv/shared/src/test/scala/fs2/data/csv/CellEncoderTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
74 changes: 74 additions & 0 deletions csv/shared/src/test/scala/fs2/data/csv/JavaTimeRoundTripTest.scala
Original file line number Diff line number Diff line change
@@ -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
}
}