Skip to content

Commit

Permalink
Add more Java time instances (#259)
Browse files Browse the repository at this point in the history
* `Duration`, `LocalDate`, `LocalDateTime`, `ZonedDateTime`
 * Make `ScynamoDecoder` and `ScynamoEncoder` `instance` public
 * Add `ScynamoDecoder.emap` for partial mapping
  • Loading branch information
Georgi Krastev authored Aug 31, 2021
1 parent 8c39587 commit f3d0eb5
Show file tree
Hide file tree
Showing 5 changed files with 69 additions and 43 deletions.
46 changes: 25 additions & 21 deletions src/main/scala/scynamo/ScynamoDecoder.scala
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
package scynamo

import cats.data.{Chain, EitherNec, NonEmptyChain}
import cats.syntax.either._
import cats.syntax.all._
import cats.{Monad, SemigroupK}
import scynamo.StackFrame.{Index, MapKey}
import scynamo.generic.auto.AutoDerivationUnlocked
import scynamo.generic.{GenericScynamoDecoder, SemiautoDerivationDecoder}
import scynamo.syntax.attributevalue._
import scynamo.wrapper.YearMonthFormatter.yearMonthFormatter
import scynamo.wrapper.DateTimeFormatters
import shapeless.labelled.{field, FieldType}
import shapeless.tag.@@
import shapeless.{tag, Lazy}
import software.amazon.awssdk.services.dynamodb.model.AttributeValue

import java.time.{Instant, YearMonth}
import java.time._
import java.util.UUID
import java.util.concurrent.TimeUnit
import scala.annotation.tailrec
import scala.collection.compat._
import scala.collection.immutable.Seq
Expand Down Expand Up @@ -43,6 +42,9 @@ trait ScynamoDecoder[A] extends ScynamoDecoderFunctions { self =>
override def decode(attributeValue: AttributeValue): EitherNec[ScynamoDecodeError, A] = self.decode(attributeValue)
override val defaultValue: Option[A] = Some(value)
}

def emap[B](f: A => EitherNec[ScynamoDecodeError, B]): ScynamoDecoder[B] =
ScynamoDecoder.instance(decode(_).flatMap(f))
}

object ScynamoDecoder extends DefaultScynamoDecoderInstances {
Expand All @@ -51,8 +53,8 @@ object ScynamoDecoder extends DefaultScynamoDecoderInstances {
def const[A](value: A): ScynamoDecoder[A] =
instance(_ => Right(value))

// SAM syntax generates anonymous classes because of non-abstract methods like `defaultValue`.
private[scynamo] def instance[A](f: AttributeValue => EitherNec[ScynamoDecodeError, A]): ScynamoDecoder[A] = f(_)
/** SAM syntax generates anonymous classes on Scala 2 because of non-abstract methods like `defaultValue`. */
def instance[A](decode: AttributeValue => EitherNec[ScynamoDecodeError, A]): ScynamoDecoder[A] = decode(_)
}

trait DefaultScynamoDecoderInstances extends ScynamoDecoderFunctions with ScynamoIterableDecoder {
Expand Down Expand Up @@ -109,20 +111,10 @@ trait DefaultScynamoDecoderInstances extends ScynamoDecoderFunctions with Scynam
ScynamoDecoder.instance(_.asEither(ScynamoType.Bool))

implicit val instantDecoder: ScynamoDecoder[Instant] =
ScynamoDecoder.instance { attr =>
for {
number <- attr.asEither(ScynamoType.Number)
result <- convert(number, "Long")(_.toLong)
} yield Instant.ofEpochMilli(result)
}
longDecoder.map(Instant.ofEpochMilli)

implicit val instantTtlDecoder: ScynamoDecoder[Instant @@ TimeToLive] =
ScynamoDecoder.instance { attr =>
for {
number <- attr.asEither(ScynamoType.Number)
result <- convert(number, "Long")(_.toLong)
} yield tag[TimeToLive][Instant](Instant.ofEpochSecond(result))
}
longDecoder.map(seconds => tag[TimeToLive](Instant.ofEpochSecond(seconds)))

implicit def seqDecoder[A: ScynamoDecoder]: ScynamoDecoder[Seq[A]] = iterableDecoder
implicit def listDecoder[A: ScynamoDecoder]: ScynamoDecoder[List[A]] = iterableDecoder
Expand All @@ -140,13 +132,25 @@ trait DefaultScynamoDecoderInstances extends ScynamoDecoderFunctions with Scynam
longDecoder.map(Duration.fromNanos)

implicit val durationDecoder: ScynamoDecoder[Duration] =
longDecoder.map(Duration(_, TimeUnit.NANOSECONDS))
finiteDurationDecoder.widen

implicit val javaDurationDecoder: ScynamoDecoder[java.time.Duration] =
longDecoder.map(java.time.Duration.ofNanos)

implicit val yearMonthDecoder: ScynamoDecoder[YearMonth] =
ScynamoDecoder.instance(_.asEither(ScynamoType.String).flatMap(convert(_, "YearMonth")(YearMonth.parse(_, yearMonthFormatter))))
stringDecoder.emap(convert(_, "YearMonth")(YearMonth.parse(_, DateTimeFormatters.yearMonth)))

implicit val localDateDecoder: ScynamoDecoder[LocalDate] =
stringDecoder.emap(convert(_, "LocalDate")(LocalDate.parse(_, DateTimeFormatters.localDate)))

implicit val localDateTimeDecoder: ScynamoDecoder[LocalDateTime] =
stringDecoder.emap(convert(_, "LocalDateTime")(LocalDateTime.parse(_, DateTimeFormatters.localDateTime)))

implicit val zonedDateTimeDecoder: ScynamoDecoder[ZonedDateTime] =
stringDecoder.emap(convert(_, "ZonedDateTime")(ZonedDateTime.parse(_, DateTimeFormatters.zonedDateTime)))

implicit val uuidDecoder: ScynamoDecoder[UUID] =
ScynamoDecoder.instance(_.asEither(ScynamoType.String).flatMap(convert(_, "UUID")(UUID.fromString)))
stringDecoder.emap(convert(_, "UUID")(UUID.fromString))

implicit def mapDecoder[A, B](implicit key: ScynamoKeyDecoder[A], value: ScynamoDecoder[B]): ScynamoDecoder[Map[A, B]] =
ScynamoDecoder.instance(_.asEither(ScynamoType.Map).flatMap { attributes =>
Expand Down
26 changes: 19 additions & 7 deletions src/main/scala/scynamo/ScynamoEncoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ import cats.syntax.all._
import scynamo.StackFrame.{Index, MapKey}
import scynamo.generic.auto.AutoDerivationUnlocked
import scynamo.generic.{GenericScynamoEncoder, SemiautoDerivationEncoder}
import scynamo.wrapper.YearMonthFormatter.yearMonthFormatter
import scynamo.wrapper.DateTimeFormatters
import shapeless._
import shapeless.labelled.FieldType
import shapeless.tag.@@
import software.amazon.awssdk.services.dynamodb.model.AttributeValue

import java.time.{Instant, YearMonth}
import java.time._
import java.util.UUID
import scala.collection.compat._
import scala.collection.immutable.Seq
Expand All @@ -27,8 +27,8 @@ trait ScynamoEncoder[A] { self =>
object ScynamoEncoder extends DefaultScynamoEncoderInstances {
def apply[A](implicit instance: ScynamoEncoder[A]): ScynamoEncoder[A] = instance

// SAM syntax generates anonymous classes because of non-abstract methods like `contramap`.
private[scynamo] def instance[A](f: A => EitherNec[ScynamoEncodeError, AttributeValue]): ScynamoEncoder[A] = f(_)
/** SAM syntax generates anonymous classes on Scala 2 because of non-abstract methods like `contramap`. */
def instance[A](encode: A => EitherNec[ScynamoEncodeError, AttributeValue]): ScynamoEncoder[A] = encode(_)
}

trait DefaultScynamoEncoderInstances extends ScynamoIterableEncoder {
Expand Down Expand Up @@ -104,14 +104,26 @@ trait DefaultScynamoEncoderInstances extends ScynamoIterableEncoder {
implicit def someEncoder[A](implicit element: ScynamoEncoder[A]): ScynamoEncoder[Some[A]] =
ScynamoEncoder.instance(some => element.encode(some.get))

implicit val finiteDurationEncoder: ScynamoEncoder[FiniteDuration] =
implicit val durationEncoder: ScynamoEncoder[Duration] =
numberStringEncoder.contramap(_.toNanos.toString)

implicit val durationEncoder: ScynamoEncoder[Duration] =
implicit val finiteDurationEncoder: ScynamoEncoder[FiniteDuration] =
durationEncoder.narrow

implicit val javaDurationEncoder: ScynamoEncoder[java.time.Duration] =
numberStringEncoder.contramap(_.toNanos.toString)

implicit val yearMonthEncoder: ScynamoEncoder[YearMonth] =
stringEncoder.contramap(_.format(yearMonthFormatter))
stringEncoder.contramap(_.format(DateTimeFormatters.yearMonth))

implicit val localDateEncoder: ScynamoEncoder[LocalDate] =
stringEncoder.contramap(_.format(DateTimeFormatters.localDate))

implicit val localDateTimeEncoder: ScynamoEncoder[LocalDateTime] =
stringEncoder.contramap(_.format(DateTimeFormatters.localDateTime))

implicit val zonedDateTimeEncoder: ScynamoEncoder[ZonedDateTime] =
stringEncoder.contramap(_.format(DateTimeFormatters.zonedDateTime))

implicit def mapEncoder[A, B](implicit key: ScynamoKeyEncoder[A], value: ScynamoEncoder[B]): ScynamoEncoder[Map[A, B]] =
ScynamoEncoder.instance { kvs =>
Expand Down
15 changes: 15 additions & 0 deletions src/main/scala/scynamo/wrapper/DateTimeFormatters.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package scynamo.wrapper

import java.time.format.DateTimeFormatter

/** This collection of formatters ensures consistent encoding and decoding of Java time date types. */
private[scynamo] object DateTimeFormatters {

/** This is a custom formatter for `YearMonth` because "Years outside the range 0000 to 9999 must be prefixed by the plus or minus
* symbol." but the plus symbol is not added by the default `.toString`.
*/
final val yearMonth = DateTimeFormatter.ofPattern("uuuu-MM")
final def localDate = DateTimeFormatter.ISO_LOCAL_DATE
final def localDateTime = DateTimeFormatter.ISO_LOCAL_DATE_TIME
final def zonedDateTime = DateTimeFormatter.ISO_ZONED_DATE_TIME
}
10 changes: 0 additions & 10 deletions src/main/scala/scynamo/wrapper/YearMonthFormatter.scala

This file was deleted.

15 changes: 10 additions & 5 deletions src/test/scala/scynamo/ScynamoCodecProps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import scynamo.generic.semiauto._
import scynamo.wrapper.{ScynamoNumberSet, ScynamoStringSet}
import shapeless.tag

import java.time.{Instant, YearMonth}
import java.time.temporal.ChronoUnit
import java.time._
import java.util.UUID
import scala.concurrent.duration.Duration

Expand Down Expand Up @@ -83,19 +83,24 @@ class ScynamoCodecProps extends Properties("ScynamoCodec") {
propertyWithSeed("decode.encode === id (option)", propertySeed) = Prop.forAll { value: Option[Int] => decodeAfterEncodeIsIdentity(value) }

propertyWithSeed("decode.encode === id (finite duration)", propertySeed) =
Prop.forAll(Gen.chooseNum[Long](-9223372036854775807L, 9223372036854775807L)) { value: Long =>
Prop.forAll(Gen.chooseNum(-9223372036854775807L, 9223372036854775807L)) { value =>
decodeAfterEncodeIsIdentity(Duration.fromNanos(value))
}

propertyWithSeed("decode.encode === id (duration)", propertySeed) =
Prop.forAll(Gen.chooseNum[Long](-9223372036854775807L, 9223372036854775807L)) { value: Long =>
Prop.forAll(Gen.chooseNum(-9223372036854775807L, 9223372036854775807L)) { value =>
decodeAfterEncodeIsIdentity(Duration.fromNanos(value): Duration)
}

propertyWithSeed("decode.encode === id (year month)", propertySeed) = Prop.forAll { value: YearMonth =>
decodeAfterEncodeIsIdentity(value)
propertyWithSeed("decode.encode === id (java duration)", propertySeed) = Prop.forAll { value: Long =>
decodeAfterEncodeIsIdentity(java.time.Duration.ofNanos(value))
}

propertyWithSeed("decode.encode === id (year month)", propertySeed) = Prop.forAll(decodeAfterEncodeIsIdentity[YearMonth](_))
propertyWithSeed("decode.encode === id (local date)", propertySeed) = Prop.forAll(decodeAfterEncodeIsIdentity[LocalDate](_))
propertyWithSeed("decode.encode === id (local date time)", propertySeed) = Prop.forAll(decodeAfterEncodeIsIdentity[LocalDateTime](_))
propertyWithSeed("decode.encode === id (zoned date time)", propertySeed) = Prop.forAll(decodeAfterEncodeIsIdentity[ZonedDateTime](_))

propertyWithSeed("decode.encode === id (case class)", propertySeed) = Prop.forAll { value: Int =>
decodeAfterEncodeIsIdentity(ScynamoCodecProps.Foo(value))
}
Expand Down

0 comments on commit f3d0eb5

Please sign in to comment.