Skip to content

Commit

Permalink
Add support for java.time.YearMonth (#255)
Browse files Browse the repository at this point in the history
* Add support for java.time.YearMonth

* Custom private YearMonthFormatter

* Add ScynamoDecoderTest for failing YearMonth
  • Loading branch information
saeltz authored Jul 14, 2021
1 parent 7db3285 commit 2a8049a
Show file tree
Hide file tree
Showing 5 changed files with 86 additions and 23 deletions.
16 changes: 10 additions & 6 deletions src/main/scala/scynamo/ScynamoDecoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@ 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 shapeless.labelled.{field, FieldType}
import shapeless.tag.@@
import shapeless.{tag, Lazy}
import software.amazon.awssdk.services.dynamodb.model.AttributeValue

import java.time.Instant
import java.time.{Instant, YearMonth}
import java.util.UUID
import java.util.concurrent.TimeUnit
import scala.annotation.tailrec
Expand All @@ -39,8 +40,8 @@ trait ScynamoDecoder[A] extends ScynamoDecoderFunctions { self =>
def defaultValue: Option[A] = None

def withDefault(value: A): ScynamoDecoder[A] = new ScynamoDecoder[A] {
override def decode(attributeValue: AttributeValue) = self.decode(attributeValue)
override val defaultValue = Some(value)
override def decode(attributeValue: AttributeValue): EitherNec[ScynamoDecodeError, A] = self.decode(attributeValue)
override val defaultValue: Option[A] = Some(value)
}
}

Expand Down Expand Up @@ -141,6 +142,9 @@ trait DefaultScynamoDecoderInstances extends ScynamoDecoderFunctions with Scynam
implicit val durationDecoder: ScynamoDecoder[Duration] =
longDecoder.map(Duration(_, TimeUnit.NANOSECONDS))

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

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

Expand Down Expand Up @@ -170,17 +174,17 @@ trait DefaultScynamoDecoderInstances extends ScynamoDecoderFunctions with Scynam

implicit def fieldDecoder[K, V](implicit V: Lazy[ScynamoDecoder[V]]): ScynamoDecoder[FieldType[K, V]] =
new ScynamoDecoder[FieldType[K, V]] {
override def decode(attributeValue: AttributeValue) =
override def decode(attributeValue: AttributeValue): EitherNec[ScynamoDecodeError, FieldType[K, V]] =
V.value.decode(attributeValue).map(field[K][V])
override lazy val defaultValue =
override lazy val defaultValue: Option[FieldType[K, V]] =
V.value.defaultValue.map(field[K][V])
}
}

trait ScynamoIterableDecoder extends LowestPrioAutoDecoder {
import scynamo.syntax.attributevalue._

def iterableDecoder[A, C[x] <: Iterable[x]](implicit element: ScynamoDecoder[A], factory: Factory[A, C[A]]): ScynamoDecoder[C[A]] =
def iterableDecoder[A, C[_] <: Iterable[_]](implicit element: ScynamoDecoder[A], factory: Factory[A, C[A]]): ScynamoDecoder[C[A]] =
ScynamoDecoder.instance(_.asEither(ScynamoType.List).flatMap { attributes =>
var allErrors = Chain.empty[ScynamoDecodeError]
val allValues = factory.newBuilder
Expand Down
12 changes: 8 additions & 4 deletions src/main/scala/scynamo/ScynamoEncoder.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +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 shapeless._
import shapeless.labelled.FieldType
import shapeless.tag.@@
import software.amazon.awssdk.services.dynamodb.model.AttributeValue

import java.time.Instant
import java.time.{Instant, YearMonth}
import java.util.{Collections, UUID}
import scala.collection.compat._
import scala.collection.immutable.Seq
Expand All @@ -34,7 +35,7 @@ trait DefaultScynamoEncoderInstances extends ScynamoIterableEncoder {
private val rightNul = Right(AttributeValue.builder.nul(true).build())

implicit val catsInstances: Contravariant[ScynamoEncoder] = new Contravariant[ScynamoEncoder] {
override def contramap[A, B](fa: ScynamoEncoder[A])(f: B => A) = fa.contramap(f)
override def contramap[A, B](fa: ScynamoEncoder[A])(f: B => A): ScynamoEncoder[B] = fa.contramap(f)
}

implicit val stringEncoder: ScynamoEncoder[String] =
Expand Down Expand Up @@ -109,6 +110,9 @@ trait DefaultScynamoEncoderInstances extends ScynamoIterableEncoder {
implicit val durationEncoder: ScynamoEncoder[Duration] =
numberStringEncoder.contramap(_.toNanos.toString)

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

implicit def mapEncoder[A, B](implicit key: ScynamoKeyEncoder[A], value: ScynamoEncoder[B]): ScynamoEncoder[Map[A, B]] =
ScynamoEncoder.instance { kvs =>
var allErrors = Chain.empty[ScynamoEncodeError]
Expand Down Expand Up @@ -181,7 +185,7 @@ object ObjectScynamoEncoder extends SemiautoDerivationEncoder {
): ObjectScynamoEncoder[A] = f(_)

implicit val catsInstances: Contravariant[ObjectScynamoEncoder] = new Contravariant[ObjectScynamoEncoder] {
override def contramap[A, B](fa: ObjectScynamoEncoder[A])(f: B => A) =
override def contramap[A, B](fa: ObjectScynamoEncoder[A])(f: B => A): ObjectScynamoEncoder[B] =
instance(value => fa.encodeMap(f(value)))
}

Expand Down Expand Up @@ -214,7 +218,7 @@ object ScynamoKeyEncoder {
private[scynamo] def instance[A](f: A => EitherNec[ScynamoEncodeError, String]): ScynamoKeyEncoder[A] = f(_)

implicit val catsInstances: Contravariant[ScynamoKeyEncoder] = new Contravariant[ScynamoKeyEncoder] {
override def contramap[A, B](fa: ScynamoKeyEncoder[A])(f: B => A) = fa.contramap(f)
override def contramap[A, B](fa: ScynamoKeyEncoder[A])(f: B => A): ScynamoKeyEncoder[B] = fa.contramap(f)
}

implicit val stringKeyEncoder: ScynamoKeyEncoder[String] = instance { value =>
Expand Down
11 changes: 11 additions & 0 deletions src/main/scala/scynamo/wrapper/YearMonthFormatter.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package scynamo.wrapper

import java.time.format.DateTimeFormatter

/** 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`.
*/
private[scynamo] object YearMonthFormatter {
final val yearMonthFormatter = DateTimeFormatter.ofPattern("uuuu-MM")
}
46 changes: 35 additions & 11 deletions src/test/scala/scynamo/ScynamoCodecProps.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import scynamo.generic.semiauto._
import scynamo.wrapper.{ScynamoNumberSet, ScynamoStringSet}
import shapeless.tag

import java.time.Instant
import java.time.{Instant, YearMonth}
import java.time.temporal.ChronoUnit
import java.util.UUID
import scala.concurrent.duration.Duration
Expand All @@ -22,15 +22,25 @@ class ScynamoCodecProps extends Properties("ScynamoCodec") {
suffix <- Gen.nonEmptyListOf(Gen.numChar).map(_.mkString)
} yield BigDecimal(s"$prefix.$suffix")

propertyWithSeed("decode.encode === id (int)", propertySeed) = Prop.forAll { value: Int => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (int)", propertySeed) = Prop.forAll { value: Int =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (long)", propertySeed) = Prop.forAll { value: Long => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (long)", propertySeed) = Prop.forAll { value: Long =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (BigInt)", propertySeed) = Prop.forAll { value: BigInt => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (BigInt)", propertySeed) = Prop.forAll { value: BigInt =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (float)", propertySeed) = Prop.forAll { value: Float => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (float)", propertySeed) = Prop.forAll { value: Float =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (double)", propertySeed) = Prop.forAll { value: Double => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (double)", propertySeed) = Prop.forAll { value: Double =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (BigDecimal)", propertySeed) = Prop.forAll { value: BigDecimal =>
decodeAfterEncodeIsIdentity(value)
Expand All @@ -41,7 +51,9 @@ class ScynamoCodecProps extends Properties("ScynamoCodec") {

property("decode.encode === id (empty string)") = decodeAfterEncodeIsIdentity("")

propertyWithSeed("decode.encode === id (boolean)", propertySeed) = Prop.forAll { value: Boolean => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (boolean)", propertySeed) = Prop.forAll { value: Boolean =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (instant)", propertySeed) = Prop.forAll(Gen.calendar.map(_.toInstant)) { value: Instant =>
decodeAfterEncodeIsIdentity(value)
Expand All @@ -56,11 +68,17 @@ class ScynamoCodecProps extends Properties("ScynamoCodec") {
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (list)", propertySeed) = Prop.forAll { value: List[Int] => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (list)", propertySeed) = Prop.forAll { value: List[Int] =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (vector)", propertySeed) = Prop.forAll { value: Vector[Int] => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (vector)", propertySeed) = Prop.forAll { value: Vector[Int] =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (set)", propertySeed) = Prop.forAll { value: Set[Int] => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (set)", propertySeed) = Prop.forAll { value: Set[Int] =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (option)", propertySeed) = Prop.forAll { value: Option[Int] => decodeAfterEncodeIsIdentity(value) }

Expand All @@ -74,6 +92,10 @@ class ScynamoCodecProps extends Properties("ScynamoCodec") {
decodeAfterEncodeIsIdentity(Duration.fromNanos(value): Duration)
}

propertyWithSeed("decode.encode === id (year month)", propertySeed) = Prop.forAll { value: YearMonth =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (case class)", propertySeed) = Prop.forAll { value: Int =>
decodeAfterEncodeIsIdentity(ScynamoCodecProps.Foo(value))
}
Expand All @@ -82,7 +104,9 @@ class ScynamoCodecProps extends Properties("ScynamoCodec") {
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (uuid)", propertySeed) = Prop.forAll { value: UUID => decodeAfterEncodeIsIdentity(value) }
propertyWithSeed("decode.encode === id (uuid)", propertySeed) = Prop.forAll { value: UUID =>
decodeAfterEncodeIsIdentity(value)
}

propertyWithSeed("decode.encode === id (scala map)", propertySeed) = Prop.forAll { value: Map[UUID, Int] =>
decodeAfterEncodeIsIdentity(value)
Expand Down
24 changes: 22 additions & 2 deletions src/test/scala/scynamo/ScynamoDecoderTest.scala
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
package scynamo

import java.util.Collections

import cats.data.EitherNec
import org.scalatest.{Inside, Inspectors}
import scynamo.StackFrame.{Attr, Case, Enum}
import scynamo.generic.ScynamoSealedTraitOpts
import software.amazon.awssdk.services.dynamodb.model.AttributeValue

import java.util.Collections

class ScynamoDecoderTest extends UnitTest {
"ScynamoDecoder" should {
"support map" in {
Expand Down Expand Up @@ -81,6 +81,26 @@ class ScynamoDecoderTest extends UnitTest {
result should ===(Right(Foo("the-a", None)))
}

"fail to decode YearMonths encoded using the default formatter" in {
import scynamo.generic.auto._

import java.time.YearMonth

case class Foo(a: String, b: YearMonth)

val yearMonth = YearMonth.of(200000, 12).toString
val attrValues = new java.util.HashMap[String, AttributeValue]
attrValues.put("a", AttributeValue.builder().s("the-a").build())
attrValues.put("b", AttributeValue.builder().s(yearMonth).build())
val input = AttributeValue.builder().m(attrValues).build()

val result = ScynamoDecoder[Foo].decode(input)

Inside.inside(result) { case Left(errs) =>
errs.head.stack.frames should ===(List[StackFrame](Attr("b")))
}
}

"provide a stack to the error for nested case classes" in {
import scynamo.generic.auto._
import scynamo.syntax.encoder._
Expand Down

0 comments on commit 2a8049a

Please sign in to comment.