From 08a23e5cd4344e23f0bb6de56c147a9c55cb7349 Mon Sep 17 00:00:00 2001 From: Georgi Krastev Date: Thu, 8 Jul 2021 15:06:57 +0200 Subject: [PATCH] Optimize encoders and skip nul fields (#247) --- build.sbt | 3 +- src/main/scala/scynamo/ScynamoDecoder.scala | 21 -- src/main/scala/scynamo/ScynamoEncoder.scala | 231 +++++++++++------- src/main/scala/scynamo/ScynamoError.scala | 35 +++ .../generic/GenericScynamoEncoder.scala | 4 +- .../generic/ShapelessScynamoEncoder.scala | 43 ++-- .../scala/scynamo/EncodingGoldenTest.scala | 26 +- .../scala/scynamo/ScynamoEncoderTest.scala | 20 +- .../scala/scynamo/ScynamoInstancesTest.scala | 51 +++- src/test/scala/scynamo/ScynamoTest.scala | 25 +- 10 files changed, 306 insertions(+), 153 deletions(-) diff --git a/build.sbt b/build.sbt index dbfc2e7c..10f4ea4c 100644 --- a/build.sbt +++ b/build.sbt @@ -75,7 +75,8 @@ lazy val scalacOptions_2_13 = Seq( "-Xfatal-warnings", "-Ywarn-dead-code", "-Ymacro-annotations", - "-Xlint:_,-byname-implicit", + "-Xlint:_,-byname-implicit,-unused", + "-Wunused:_,-imports,-synthetics", "-Xsource:3" ) diff --git a/src/main/scala/scynamo/ScynamoDecoder.scala b/src/main/scala/scynamo/ScynamoDecoder.scala index 3cda39f6..d3ef3766 100644 --- a/src/main/scala/scynamo/ScynamoDecoder.scala +++ b/src/main/scala/scynamo/ScynamoDecoder.scala @@ -21,27 +21,6 @@ import scala.concurrent.duration.{Duration, FiniteDuration} import scala.jdk.CollectionConverters._ import scala.util.control.NonFatal -case class ErrorStack(frames: List[StackFrame]) { - def push(frame: StackFrame): ErrorStack = ErrorStack(frame +: frames) - - override def toString: String = - frames.mkString("ErrorStack(", " -> ", ")") -} - -object ErrorStack { - val empty: ErrorStack = ErrorStack(List.empty) -} - -sealed trait StackFrame extends Product with Serializable -object StackFrame { - case class Attr(name: String) extends StackFrame - case class Case(name: String) extends StackFrame - case class Enum(name: String) extends StackFrame - case class Index(value: Int) extends StackFrame - case class MapKey[A](value: A) extends StackFrame - case class Custom(name: String) extends StackFrame -} - trait ScynamoDecoder[A] extends ScynamoDecoderFunctions { self => def decode(attributeValue: AttributeValue): EitherNec[ScynamoDecodeError, A] diff --git a/src/main/scala/scynamo/ScynamoEncoder.scala b/src/main/scala/scynamo/ScynamoEncoder.scala index 3c5b0a7b..1e4f1c68 100644 --- a/src/main/scala/scynamo/ScynamoEncoder.scala +++ b/src/main/scala/scynamo/ScynamoEncoder.scala @@ -1,8 +1,8 @@ package scynamo -import cats.data.EitherNec -import cats.syntax.either._ -import cats.syntax.parallel._ +import cats.Contravariant +import cats.data.{Chain, EitherNec, NonEmptyChain} +import cats.syntax.all._ import scynamo.StackFrame.{Index, MapKey} import scynamo.generic.auto.AutoDerivationUnlocked import scynamo.generic.{GenericScynamoEncoder, SemiautoDerivationEncoder} @@ -12,119 +12,151 @@ import shapeless.tag.@@ import software.amazon.awssdk.services.dynamodb.model.AttributeValue import java.time.Instant -import java.util.UUID +import java.util.{Collections, UUID} +import scala.collection.compat._ +import scala.collection.immutable.Seq import scala.concurrent.duration.{Duration, FiniteDuration} -import scala.jdk.CollectionConverters._ trait ScynamoEncoder[A] { self => def encode(value: A): EitherNec[ScynamoEncodeError, AttributeValue] - - def contramap[B](f: B => A): ScynamoEncoder[B] = value => self.encode(f(value)) + def contramap[B](f: B => A): ScynamoEncoder[B] = + ScynamoEncoder.instance(value => self.encode(f(value))) } 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(_) } trait DefaultScynamoEncoderInstances extends ScynamoIterableEncoder { - implicit val stringEncoder: ScynamoEncoder[String] = value => Right(AttributeValue.builder().s(value).build()) + 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) + } - private[this] val numberStringEncoder: ScynamoEncoder[String] = value => Right(AttributeValue.builder().n(value).build()) + implicit val stringEncoder: ScynamoEncoder[String] = + ScynamoEncoder.instance(value => Right(AttributeValue.builder.s(value).build())) - implicit val intEncoder: ScynamoEncoder[Int] = numberStringEncoder.contramap[Int](_.toString) + private[this] val numberStringEncoder: ScynamoEncoder[String] = + ScynamoEncoder.instance(value => Right(AttributeValue.builder.n(value).build())) - implicit val longEncoder: ScynamoEncoder[Long] = numberStringEncoder.contramap[Long](_.toString) + implicit val intEncoder: ScynamoEncoder[Int] = + numberStringEncoder.contramap(_.toString) - implicit val bigIntEncoder: ScynamoEncoder[BigInt] = numberStringEncoder.contramap[BigInt](_.toString) + implicit val longEncoder: ScynamoEncoder[Long] = + numberStringEncoder.contramap(_.toString) - implicit val floatEncoder: ScynamoEncoder[Float] = numberStringEncoder.contramap[Float](_.toString) + implicit val bigIntEncoder: ScynamoEncoder[BigInt] = + numberStringEncoder.contramap(_.toString) - implicit val doubleEncoder: ScynamoEncoder[Double] = numberStringEncoder.contramap[Double](_.toString) + implicit val floatEncoder: ScynamoEncoder[Float] = + numberStringEncoder.contramap(_.toString) - implicit val bigDecimalEncoder: ScynamoEncoder[BigDecimal] = numberStringEncoder.contramap[BigDecimal](_.toString) + implicit val doubleEncoder: ScynamoEncoder[Double] = + numberStringEncoder.contramap(_.toString) - implicit val booleanEncoder: ScynamoEncoder[Boolean] = value => Right(AttributeValue.builder().bool(value).build()) + implicit val bigDecimalEncoder: ScynamoEncoder[BigDecimal] = + numberStringEncoder.contramap(_.toString) - implicit val instantEncoder: ScynamoEncoder[Instant] = numberStringEncoder.contramap[Instant](_.toEpochMilli.toString) + implicit val booleanEncoder: ScynamoEncoder[Boolean] = + ScynamoEncoder.instance(value => Right(AttributeValue.builder.bool(value).build())) + + implicit val instantEncoder: ScynamoEncoder[Instant] = + numberStringEncoder.contramap(_.toEpochMilli.toString) implicit val instantTtlEncoder: ScynamoEncoder[Instant @@ TimeToLive] = numberStringEncoder.contramap[Instant @@ TimeToLive](_.getEpochSecond.toString) - implicit val uuidEncoder: ScynamoEncoder[UUID] = stringEncoder.contramap[UUID](_.toString) + implicit val uuidEncoder: ScynamoEncoder[UUID] = + stringEncoder.contramap(_.toString) + + implicit def seqEncoder[A](implicit element: ScynamoEncoder[A]): ScynamoEncoder[Seq[A]] = + ScynamoEncoder.instance { xs => + var allErrors = Chain.empty[ScynamoEncodeError] + val attrValues = List.newBuilder[AttributeValue] + for ((x, i) <- xs.iterator.zipWithIndex) element.encode(x) match { + case Right(attr) => attrValues += attr + case Left(errors) => allErrors ++= StackFrame.encoding(errors, Index(i)).toChain + } - implicit def seqEncoder[A: ScynamoEncoder]: ScynamoEncoder[scala.collection.immutable.Seq[A]] = - value => value.toVector.parTraverse(ScynamoEncoder[A].encode).map(xs => AttributeValue.builder().l(xs: _*).build()) + NonEmptyChain.fromChain(allErrors).toLeft(AttributeValue.builder.l(attrValues.result(): _*).build()) + } implicit def listEncoder[A: ScynamoEncoder]: ScynamoEncoder[List[A]] = - value => - value.zipWithIndex - .parTraverse { case (x, i) => - ScynamoEncoder[A].encode(x).leftMap(_.map(_.push(Index(i)))) - } - .map(xs => AttributeValue.builder().l(xs: _*).build()) + seqEncoder[A].narrow implicit def vectorEncoder[A: ScynamoEncoder]: ScynamoEncoder[Vector[A]] = - value => - value.zipWithIndex - .parTraverse { case (x, i) => - ScynamoEncoder[A].encode(x).leftMap(_.map(_.push(Index(i)))) - } - .map(xs => AttributeValue.builder().l(xs: _*).build()) - - implicit def setEncoder[A: ScynamoEncoder]: ScynamoEncoder[Set[A]] = listEncoder[A].contramap[Set[A]](x => x.toList) - - implicit def optionEncoder[A: ScynamoEncoder]: ScynamoEncoder[Option[A]] = { - case Some(value) => ScynamoEncoder[A].encode(value) - case None => Right(AttributeValue.builder().nul(true).build()) - } + seqEncoder[A].narrow - implicit def someEncoder[A: ScynamoEncoder]: ScynamoEncoder[Some[A]] = x => ScynamoEncoder[A].encode(x.get) + implicit def setEncoder[A: ScynamoEncoder]: ScynamoEncoder[Set[A]] = + listEncoder[A].contramap(_.toList) - implicit val finiteDurationEncoder: ScynamoEncoder[FiniteDuration] = longEncoder.contramap(_.toNanos) - - implicit val durationEncoder: ScynamoEncoder[Duration] = longEncoder.contramap(_.toNanos) + implicit def optionEncoder[A](implicit element: ScynamoEncoder[A]): ScynamoEncoder[Option[A]] = + ScynamoEncoder.instance { + case Some(value) => element.encode(value) + case None => rightNul + } - implicit def mapEncoder[A, B](implicit keyEncoder: ScynamoKeyEncoder[A], valueEncoder: ScynamoEncoder[B]): ScynamoEncoder[Map[A, B]] = - value => { - value.toVector - .parTraverse { case (k, v) => - (keyEncoder.encode(k), valueEncoder.encode(v)).parMapN(_ -> _).leftMap(_.map(_.push(MapKey(k)))) - } - .map { - _.foldLeft(new java.util.HashMap[String, AttributeValue]()) { case (acc, (k, v)) => - acc.put(k, v) - acc - } + implicit def someEncoder[A](implicit element: ScynamoEncoder[A]): ScynamoEncoder[Some[A]] = + ScynamoEncoder.instance(some => element.encode(some.get)) + + implicit val finiteDurationEncoder: ScynamoEncoder[FiniteDuration] = + numberStringEncoder.contramap(_.toNanos.toString) + + implicit val durationEncoder: ScynamoEncoder[Duration] = + numberStringEncoder.contramap(_.toNanos.toString) + + implicit def mapEncoder[A, B](implicit key: ScynamoKeyEncoder[A], value: ScynamoEncoder[B]): ScynamoEncoder[Map[A, B]] = + ScynamoEncoder.instance { kvs => + var allErrors = Chain.empty[ScynamoEncodeError] + val attrValues = new java.util.HashMap[String, AttributeValue](kvs.size) + kvs.foreachEntry { (k, v) => + (key.encode(k), value.encode(v)) match { + case (Right(k), Right(attr)) => + // Omit `nul` for efficiency and GSI support (see https://github.com/aws/aws-sdk-go/issues/1803) + if (!attr.nul) attrValues.put(k, attr) + case (Left(errors), Right(_)) => + allErrors ++= StackFrame.encoding(errors, MapKey(k)).toChain + case (Right(_), Left(errors)) => + allErrors ++= StackFrame.encoding(errors, MapKey(k)).toChain + case (Left(kErrors), Left(vErrors)) => + allErrors ++= StackFrame.encoding(kErrors ++ vErrors, MapKey(k)).toChain } - .map(hm => AttributeValue.builder().m(hm).build()) + } + + NonEmptyChain.fromChain(allErrors).toLeft(AttributeValue.builder.m(attrValues).build()) } - implicit val attributeValueEncoder: ScynamoEncoder[AttributeValue] = { value => - import scynamo.syntax.attributevalue._ + implicit val attributeValueEncoder: ScynamoEncoder[AttributeValue] = + ScynamoEncoder.instance { value => + import scynamo.syntax.attributevalue._ - val nonEmptyStringSet = value.asOption(ScynamoType.StringSet).map(x => ScynamoType.StringSet -> (x.size() > 0)) - val nonEmptyNumberSet = value.asOption(ScynamoType.NumberSet).map(x => ScynamoType.NumberSet -> (x.size() > 0)) - val nonEmptyBinarySet = value.asOption(ScynamoType.BinarySet).map(x => ScynamoType.BinarySet -> (x.size() > 0)) + def nonEmpty[A](typ: ScynamoType.Aux[java.util.List[A]] with ScynamoType.TypeInvalidIfEmpty) = + if (value.asOption(typ).exists(!_.isEmpty)) Right(value) + else Either.leftNec(ScynamoEncodeError.invalidEmptyValue(typ)) - nonEmptyStringSet.orElse(nonEmptyNumberSet).orElse(nonEmptyBinarySet) match { - case Some((typ, false)) => Either.leftNec(ScynamoEncodeError.invalidEmptyValue(typ)) - case Some((_, true)) | None => Right(value) + if (value.hasSs) nonEmpty(ScynamoType.StringSet) + else if (value.hasNs) nonEmpty(ScynamoType.NumberSet) + else if (value.hasBs) nonEmpty(ScynamoType.BinarySet) + else Right(value) } - } - implicit def eitherScynamoErrorEncoder[A: ScynamoEncoder]: ScynamoEncoder[EitherNec[ScynamoEncodeError, A]] = { - case Left(value) => Left(value) - case Right(value) => ScynamoEncoder[A].encode(value) - } + implicit def eitherScynamoErrorEncoder[A](implicit right: ScynamoEncoder[A]): ScynamoEncoder[EitherNec[ScynamoEncodeError, A]] = + ScynamoEncoder.instance { + case Left(errors) => Left(errors) + case Right(value) => right.encode(value) + } implicit def fieldEncoder[K, V](implicit V: Lazy[ScynamoEncoder[V]]): ScynamoEncoder[FieldType[K, V]] = - field => V.value.encode(field) + ScynamoEncoder.instance(V.value.encode) } trait ScynamoIterableEncoder extends LowestPrioAutoEncoder { def iterableEncoder[A: ScynamoEncoder]: ScynamoEncoder[Iterable[A]] = - value => - value.toList.parTraverse(ScynamoEncoder[A].encode).map(encodedValues => AttributeValue.builder().l(encodedValues.asJava).build()) + ScynamoEncoder.listEncoder[A].contramap(_.toList) } trait LowestPrioAutoEncoder { @@ -136,41 +168,60 @@ trait LowestPrioAutoEncoder { trait ObjectScynamoEncoder[A] extends ScynamoEncoder[A] { def encodeMap(value: A): EitherNec[ScynamoEncodeError, java.util.Map[String, AttributeValue]] - override def encode(value: A): EitherNec[ScynamoEncodeError, AttributeValue] = - encodeMap(value).map(AttributeValue.builder().m(_).build()) + encodeMap(value).map(AttributeValue.builder.m(_).build()) } object ObjectScynamoEncoder extends SemiautoDerivationEncoder { def apply[A](implicit instance: ObjectScynamoEncoder[A]): ObjectScynamoEncoder[A] = instance - implicit def mapEncoder[A](implicit valueEncoder: ScynamoEncoder[A]): ObjectScynamoEncoder[Map[String, A]] = - value => { - value.toList - .parTraverse { case (k, v) => valueEncoder.encode(v).map(k -> _) } - .map { - _.foldLeft(new java.util.HashMap[String, AttributeValue]()) { case (acc, (k, v)) => - acc.put(k, v) - acc - } + // SAM syntax generates anonymous classes because of non-abstract methods like `encode`. + private[scynamo] def instance[A]( + f: A => EitherNec[ScynamoEncodeError, java.util.Map[String, AttributeValue]] + ): ObjectScynamoEncoder[A] = f(_) + + implicit val catsInstances: Contravariant[ObjectScynamoEncoder] = new Contravariant[ObjectScynamoEncoder] { + override def contramap[A, B](fa: ObjectScynamoEncoder[A])(f: B => A) = + instance(value => fa.encodeMap(f(value))) + } + + implicit def mapEncoder[A](implicit value: ScynamoEncoder[A]): ObjectScynamoEncoder[Map[String, A]] = + instance { kvs => + var allErrors = Chain.empty[ScynamoEncodeError] + val attrValues = new java.util.HashMap[String, AttributeValue](kvs.size) + kvs.foreachEntry { (k, v) => + value.encode(v) match { + // Omit `nul` for efficiency and GSI support (see https://github.com/aws/aws-sdk-go/issues/1803) + case Right(attr) => if (!attr.nul) attrValues.put(k, attr) + case Left(errors) => allErrors ++= StackFrame.encoding(errors, MapKey(k)).toChain } + } + + NonEmptyChain.fromChain(allErrors).toLeft(Collections.unmodifiableMap(attrValues)) } } trait ScynamoKeyEncoder[A] { self => def encode(value: A): EitherNec[ScynamoEncodeError, String] - - def contramap[B](f: B => A): ScynamoKeyEncoder[B] = value => self.encode(f(value)) + def contramap[B](f: B => A): ScynamoKeyEncoder[B] = + ScynamoKeyEncoder.instance(value => self.encode(f(value))) } object ScynamoKeyEncoder { def apply[A](implicit encoder: ScynamoKeyEncoder[A]): ScynamoKeyEncoder[A] = encoder - implicit val stringKeyEncoder: ScynamoKeyEncoder[String] = value => - if (value.nonEmpty) - Right(value) - else - Either.leftNec(ScynamoEncodeError.invalidEmptyValue(ScynamoType.String)) + // SAM syntax generates anonymous classes because of non-abstract methods like `contramap`. + 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) + } + + implicit val stringKeyEncoder: ScynamoKeyEncoder[String] = instance { value => + if (value.nonEmpty) Right(value) + else Either.leftNec(ScynamoEncodeError.invalidEmptyValue(ScynamoType.String)) + } - implicit val uuidKeyEncoder: ScynamoKeyEncoder[UUID] = ScynamoKeyEncoder[String].contramap[UUID](_.toString) + implicit val uuidKeyEncoder: ScynamoKeyEncoder[UUID] = + ScynamoKeyEncoder[String].contramap(_.toString) } diff --git a/src/main/scala/scynamo/ScynamoError.scala b/src/main/scala/scynamo/ScynamoError.scala index 01ae2510..132c1eae 100644 --- a/src/main/scala/scynamo/ScynamoError.scala +++ b/src/main/scala/scynamo/ScynamoError.scala @@ -1,5 +1,7 @@ package scynamo +import cats.data.{EitherNec, NonEmptyChain} +import cats.syntax.all._ import cats.{Eq, Show} import scynamo.ScynamoType.TypeInvalidIfEmpty import software.amazon.awssdk.services.dynamodb.model.AttributeValue @@ -17,6 +19,39 @@ object ScynamoError { } } +case class ErrorStack(frames: List[StackFrame]) { + def push(frame: StackFrame): ErrorStack = ErrorStack(frame +: frames) + + override def toString: String = + frames.mkString("ErrorStack(", " -> ", ")") +} + +object ErrorStack { + val empty: ErrorStack = ErrorStack(List.empty) +} + +sealed trait StackFrame extends Product with Serializable +object StackFrame { + case class Attr(name: String) extends StackFrame + case class Case(name: String) extends StackFrame + case class Enum(name: String) extends StackFrame + case class Index(value: Int) extends StackFrame + case class MapKey[A](value: A) extends StackFrame + case class Custom(name: String) extends StackFrame + + private[scynamo] def encoding[A]( + encoded: EitherNec[ScynamoEncodeError, A], + frame: StackFrame + ): EitherNec[ScynamoEncodeError, A] = + encoded.leftMap(encoding(_, frame)) + + private[scynamo] def encoding[A]( + errors: NonEmptyChain[ScynamoEncodeError], + frame: StackFrame + ): NonEmptyChain[ScynamoEncodeError] = + errors.map(_.push(frame)) +} + sealed abstract class ScynamoEncodeError extends ScynamoError { def push(frame: StackFrame): ScynamoEncodeError = this match { diff --git a/src/main/scala/scynamo/generic/GenericScynamoEncoder.scala b/src/main/scala/scynamo/generic/GenericScynamoEncoder.scala index 09a8a690..3ca83e13 100644 --- a/src/main/scala/scynamo/generic/GenericScynamoEncoder.scala +++ b/src/main/scala/scynamo/generic/GenericScynamoEncoder.scala @@ -3,6 +3,8 @@ package scynamo.generic import scynamo.ObjectScynamoEncoder import shapeless.{LabelledGeneric, Lazy} +import java.util.Collections + trait GenericScynamoEncoder[A] extends ObjectScynamoEncoder[A] object GenericScynamoEncoder extends GenericScynamoEncoderInstances @@ -12,5 +14,5 @@ trait GenericScynamoEncoderInstances { gen: LabelledGeneric.Aux[F, G], sg: Lazy[ShapelessScynamoEncoder[F, G]] ): GenericScynamoEncoder[F] = - value => sg.value.encodeMap(gen.to(value)) + value => sg.value.encodeMap(gen.to(value)).map(Collections.unmodifiableMap(_)) } diff --git a/src/main/scala/scynamo/generic/ShapelessScynamoEncoder.scala b/src/main/scala/scynamo/generic/ShapelessScynamoEncoder.scala index 8b9ee234..b5584dc5 100644 --- a/src/main/scala/scynamo/generic/ShapelessScynamoEncoder.scala +++ b/src/main/scala/scynamo/generic/ShapelessScynamoEncoder.scala @@ -1,13 +1,10 @@ package scynamo.generic -import java.util -import java.util.Collections - import cats.data.EitherNec import cats.syntax.either._ import cats.syntax.parallel._ import scynamo.StackFrame.{Attr, Case} -import scynamo.{ScynamoEncodeError, ScynamoEncoder} +import scynamo.{ScynamoEncodeError, ScynamoEncoder, StackFrame} import shapeless._ import shapeless.labelled._ import software.amazon.awssdk.services.dynamodb.model.AttributeValue @@ -19,27 +16,23 @@ trait ShapelessScynamoEncoder[Base, A] { object ShapelessScynamoEncoder extends EncoderHListInstances with EncoderCoproductInstances trait EncoderHListInstances { - implicit def deriveHNil[Base]: ShapelessScynamoEncoder[Base, HNil] = _ => Right(Collections.emptyMap()) + implicit def deriveHNil[Base]: ShapelessScynamoEncoder[Base, HNil] = + _ => Right(new java.util.HashMap) implicit def deriveHCons[Base, K <: Symbol, V, T <: HList](implicit key: Witness.Aux[K], sv: ScynamoEncoder[FieldType[K, V]], st: ShapelessScynamoEncoder[Base, T], opts: ScynamoDerivationOpts[Base] = ScynamoDerivationOpts.default[Base] - ): ShapelessScynamoEncoder[Base, FieldType[K, V] :: T] = - value => { - val fieldName = opts.transform(key.value.name) - - val encodedHead = sv.encode(value.head).leftMap(_.map(_.push(Attr(fieldName)))) - val encodedTail = st.encodeMap(value.tail) - - (encodedHead, encodedTail).parMapN { case (head, tail) => - val hm = new util.HashMap[String, AttributeValue]() - hm.putAll(tail) - hm.put(fieldName, head) - hm - } + ): ShapelessScynamoEncoder[Base, FieldType[K, V] :: T] = { value => + val fieldName = opts.transform(key.value.name) + val encodedHead = StackFrame.encoding(sv.encode(value.head), Attr(fieldName)) + val encodedTail = st.encodeMap(value.tail) + (encodedHead, encodedTail).parMapN { case (head, tail) => + if (!head.nul) tail.put(fieldName, head) + tail } + } } trait EncoderCoproductInstances { @@ -52,14 +45,14 @@ trait EncoderCoproductInstances { st: ShapelessScynamoEncoder[Base, T], opts: ScynamoSealedTraitOpts[Base] = ScynamoSealedTraitOpts.default[Base] ): ShapelessScynamoEncoder[Base, FieldType[K, V] :+: T] = { - case Inl(l) => + case Inl(left) => val name = opts.transform(key.value.name) - sv.value.encode(l).leftMap(x => x.map(_.push(Case(name)))).map(_.m()).map { vs => - val hm = new util.HashMap[String, AttributeValue]() - hm.putAll(vs) - hm.put(opts.discriminator, AttributeValue.builder().s(name).build()) - hm + StackFrame.encoding(sv.value.encode(left), Case(name)).map { encoded => + val attr = new java.util.HashMap[String, AttributeValue](encoded.m()) + attr.put(opts.discriminator, AttributeValue.builder.s(name).build()) + attr } - case Inr(r) => st.encodeMap(r) + case Inr(right) => + st.encodeMap(right) } } diff --git a/src/test/scala/scynamo/EncodingGoldenTest.scala b/src/test/scala/scynamo/EncodingGoldenTest.scala index 43635e7d..c12ce3df 100644 --- a/src/test/scala/scynamo/EncodingGoldenTest.scala +++ b/src/test/scala/scynamo/EncodingGoldenTest.scala @@ -24,12 +24,34 @@ class EncodingGoldenTest extends UnitTest { 1.second ) - val result = input.encoded.map(_.toString) + val actual = + input.encoded.map(_.toString) val expected = "AttributeValue(M={testOption=AttributeValue(N=42), testInstant=AttributeValue(N=0), testString=AttributeValue(S=abc), testFiniteDuration=AttributeValue(N=1000000000), testTraitList=AttributeValue(L=[AttributeValue(M={_SCYNAMO_DEFAULT_DISCRIMINATOR_=AttributeValue(S=TraitObject)}), AttributeValue(M={testFloat=AttributeValue(N=21.21), _SCYNAMO_DEFAULT_DISCRIMINATOR_=AttributeValue(S=TraitCaseClass)})]), testUuid=AttributeValue(S=1c4b009c-ee7e-4a47-a401-36b468ef7d1e), testMap=AttributeValue(M={1c4b009c-ee7e-4a47-a401-36b468ef7d1e=AttributeValue(S=foo)}), testDuration=AttributeValue(N=1000000000)})" - result shouldBe Right(expected) + actual shouldBe Right(expected) + } + + "not change for empty fields" in { + val input = TestCaseClass( + "foobar", + None, + uuid, + testTraitList = Nil, + Map.empty, + Instant.ofEpochSecond(0), + Duration.fromNanos(0), + 0.seconds + ) + + val actual = + input.encoded.map(_.toString) + + val expected = + "AttributeValue(M={testInstant=AttributeValue(N=0), testString=AttributeValue(S=foobar), testFiniteDuration=AttributeValue(N=0), testTraitList=AttributeValue(L=[]), testUuid=AttributeValue(S=1c4b009c-ee7e-4a47-a401-36b468ef7d1e), testMap=AttributeValue(M={}), testDuration=AttributeValue(N=0)})" + + actual shouldBe Right(expected) } } } diff --git a/src/test/scala/scynamo/ScynamoEncoderTest.scala b/src/test/scala/scynamo/ScynamoEncoderTest.scala index 490394db..564ad62d 100644 --- a/src/test/scala/scynamo/ScynamoEncoderTest.scala +++ b/src/test/scala/scynamo/ScynamoEncoderTest.scala @@ -3,7 +3,7 @@ package scynamo import cats.data.EitherNec import cats.syntax.either._ import org.scalatest.Inside -import scynamo.ScynamoEncoderTest.{Foo, Foo2} +import scynamo.ScynamoEncoderTest.{Foo, Foo2, Snake} import scynamo.StackFrame.{Attr, Case, Index, MapKey} import scynamo.wrapper.{ScynamoBinarySet, ScynamoNumberSet, ScynamoStringSet} import software.amazon.awssdk.core.SdkBytes @@ -128,12 +128,27 @@ class ScynamoEncoderTest extends UnitTest { Either.leftNec(ScynamoEncodeError.invalidEmptyValue(ScynamoType.BinarySet)) ) } + + "omit empty fields from case classes" in { + ScynamoEncoder[Snake[Int]] + .encode(Snake(1, None)) + .map(attr => Option(attr.m.get("tail"))) should ===(Right(None)) + + ObjectScynamoEncoder[Snake[Int]] + .encodeMap(Snake(1, Some(Snake(2, None)))) + .map(attrs => Option(attrs.get("tail").m.get("tail"))) should ===(Right(None)) + } } } object ScynamoEncoderTest { - case class Foo(i: Int) + case class Snake[A](head: A, tail: Option[Snake[A]]) + object Snake { + implicit def codec[A: ScynamoCodec]: ObjectScynamoCodec[Snake[A]] = + scynamo.generic.semiauto.deriveScynamoCodec[Snake[A]] + } + case class Foo(i: Int) object Foo { import scynamo.syntax.attributevalue._ val prefix = "this-is-a-" @@ -149,7 +164,6 @@ object ScynamoEncoderTest { } case class Foo2(i: Int) - object Foo2 { import scynamo.syntax.attributevalue._ val prefix = "this-is-a-" diff --git a/src/test/scala/scynamo/ScynamoInstancesTest.scala b/src/test/scala/scynamo/ScynamoInstancesTest.scala index 7751d3c7..dedbcd9b 100644 --- a/src/test/scala/scynamo/ScynamoInstancesTest.scala +++ b/src/test/scala/scynamo/ScynamoInstancesTest.scala @@ -2,8 +2,9 @@ package scynamo import cats.Eq import cats.data.EitherNec -import cats.laws.discipline.{MonadTests, SemigroupKTests} +import cats.laws.discipline.{ContravariantTests, MonadTests, SemigroupKTests} import cats.laws.discipline.arbitrary._ +import cats.syntax.all._ import cats.tests.{StrictCatsEquality, TestSettings} import org.scalacheck.rng.Seed import org.scalacheck.{Arbitrary, Cogen, Gen} @@ -95,7 +96,7 @@ class ScynamoInstancesTest extends AnyFunSuite with Checkers with FunSuiteDiscip cause <- Arbitrary.arbitrary[Option[Throwable]] } yield ScynamoDecodeError.ConversionError(input, to, cause, ErrorStack.empty) - val generalErrorGen: Gen[ScynamoDecodeError.GeneralError] = for { + val generalDecodeErrorGen: Gen[ScynamoDecodeError.GeneralError] = for { message <- Arbitrary.arbitrary[String] cause <- Arbitrary.arbitrary[Option[Throwable]] } yield ScynamoDecodeError.GeneralError(message, cause, ErrorStack.empty) @@ -107,17 +108,40 @@ class ScynamoInstancesTest extends AnyFunSuite with Checkers with FunSuiteDiscip invalidCoproductCaseMapGen, invalidCoproductCaseAttrGen, conversionErrorGen, - generalErrorGen + generalDecodeErrorGen ) ) + implicit val invalidEmptyValueGen: Gen[ScynamoEncodeError.InvalidEmptyValue] = + for (tpe <- scynamoTypeGen) yield ScynamoEncodeError.InvalidEmptyValue(tpe, ErrorStack.empty) + + implicit val generalEncodeErrorGen: Gen[ScynamoEncodeError.GeneralError] = for { + message <- Arbitrary.arbitrary[String] + cause <- Arbitrary.arbitrary[Option[Throwable]] + } yield ScynamoEncodeError.GeneralError(message, cause, ErrorStack.empty) + + implicit val arbitraryEncodeError: Arbitrary[ScynamoEncodeError] = + Arbitrary(Gen.oneOf(invalidEmptyValueGen, generalEncodeErrorGen)) + implicit def arbitraryDecoder[A: Arbitrary]: Arbitrary[ScynamoDecoder[A]] = Arbitrary(Arbitrary.arbitrary[AttributeValue => EitherNec[ScynamoDecodeError, A]].map(f => f(_))) implicit def arbitraryObjectDecoder[A: Arbitrary]: Arbitrary[ObjectScynamoDecoder[A]] = Arbitrary(Arbitrary.arbitrary[AttributeMap => EitherNec[ScynamoDecodeError, A]].map(f => f(_))) + implicit def arbitraryEncoder[A: Cogen]: Arbitrary[ScynamoEncoder[A]] = + Arbitrary(Arbitrary.arbitrary[A => EitherNec[ScynamoEncodeError, AttributeValue]].map(ScynamoEncoder.instance)) + + implicit def arbitraryObjectEncoder[A: Cogen]: Arbitrary[ObjectScynamoEncoder[A]] = Arbitrary( + Arbitrary.arbitrary[A => EitherNec[ScynamoEncodeError, java.util.Map[String, AttributeValue]]].map(ObjectScynamoEncoder.instance) + ) + + implicit def arbitraryKeyEncoder[A: Cogen]: Arbitrary[ScynamoKeyEncoder[A]] = + Arbitrary(Arbitrary.arbitrary[A => EitherNec[ScynamoEncodeError, String]].map(ScynamoKeyEncoder.instance)) + implicit val decodeErrorEq: Eq[ScynamoDecodeError] = Eq.fromUniversalEquals + implicit val encodeErrorEq: Eq[ScynamoEncodeError] = Eq.fromUniversalEquals + implicit val attrValueEq: Eq[AttributeValue] = Eq.fromUniversalEquals def probabilisticEq[A: Arbitrary](f: A => Boolean): Boolean = { val params = Gen.Parameters.default.withSize(checkConfiguration.sizeRange.value) @@ -128,16 +152,31 @@ class ScynamoInstancesTest extends AnyFunSuite with Checkers with FunSuiteDiscip .forall(f) } - implicit def decoderEq[A: Eq]: Eq[ScynamoDecoder[A]] = (x, y) => + implicit def decoderEq[A: Eq]: Eq[ScynamoDecoder[A]] = Eq.instance { (x, y) => probabilisticEq[AttributeValue](value => x.decode(value) === y.decode(value)) + } - implicit def objectDecoderEq[A: Eq]: Eq[ObjectScynamoDecoder[A]] = (x, y) => - probabilisticEq[AttributeMap](attributes => x.decodeMap(attributes) === y.decodeMap(attributes)) + implicit def objectDecoderEq[A: Eq]: Eq[ObjectScynamoDecoder[A]] = + decoderEq[A].narrow + + implicit def encoderEq[A: Arbitrary]: Eq[ScynamoEncoder[A]] = Eq.instance { (x, y) => + probabilisticEq[A](value => x.encode(value) === y.encode(value)) + } + + implicit def objectEncoderEq[A: Arbitrary]: Eq[ObjectScynamoEncoder[A]] = + encoderEq[A].narrow + + implicit def keyEncoderEq[A: Arbitrary]: Eq[ScynamoKeyEncoder[A]] = Eq.instance { (x, y) => + probabilisticEq[A](value => x.encode(value) === y.encode(value)) + } checkAll("Monad[ScynamoDecoder]", MonadTests[ScynamoDecoder].monad[Int, Int, Int]) checkAll("Monad[ObjectScynamoDecoder]", MonadTests[ObjectScynamoDecoder].monad[Int, Int, Int]) checkAll("SemigroupK[ScynamoDecoder]", SemigroupKTests[ScynamoDecoder].semigroupK[Int]) checkAll("SemigroupK[ObjectScynamoDecoder]", SemigroupKTests[ObjectScynamoDecoder].semigroupK[Int]) + checkAll("Contravariant[ScynamoEncoder]", ContravariantTests[ScynamoEncoder].contravariant[Int, Int, Int]) + checkAll("Contravariant[ObjectScynamoEncoder]", ContravariantTests[ObjectScynamoEncoder].contravariant[Int, Int, Int]) + checkAll("Contravariant[ScynamoKeyEncoder]", ContravariantTests[ScynamoKeyEncoder].contravariant[Int, Int, Int]) } object ScynamoInstancesTest { diff --git a/src/test/scala/scynamo/ScynamoTest.scala b/src/test/scala/scynamo/ScynamoTest.scala index e55a249b..dd6240af 100644 --- a/src/test/scala/scynamo/ScynamoTest.scala +++ b/src/test/scala/scynamo/ScynamoTest.scala @@ -1,6 +1,10 @@ package scynamo -import software.amazon.awssdk.services.dynamodb.model.{AttributeValue, GetItemResponse, QueryResponse, ScanResponse} +import scynamo.syntax.encoder._ +import software.amazon.awssdk.services.dynamodb.model._ + +import java.util.UUID +import scala.jdk.CollectionConverters._ class ScynamoTest extends UnitTest { "Scynamo" should { @@ -34,7 +38,6 @@ class ScynamoTest extends UnitTest { } "return the decoded result if it has an item that is well formed" in { - import scynamo.syntax.encoder._ val input = Map("foo" -> "bar") val result = for { @@ -47,7 +50,6 @@ class ScynamoTest extends UnitTest { } "return the decoded query result if it has multiple items that are well formed" in { - import scynamo.syntax.encoder._ val input1 = Map("foo" -> "bar") val input2 = Map("Miami" -> "Ibiza") @@ -61,7 +63,6 @@ class ScynamoTest extends UnitTest { } "return the decoded scan result if it has multiple items that are well formed" in { - import scynamo.syntax.encoder._ val input1 = Map("foo" -> "bar") val input2 = Map("Miami" -> "Ibiza") @@ -73,5 +74,21 @@ class ScynamoTest extends UnitTest { } yield result result should ===(Right(List(input1, input2))) } + + "omit empty keys from a map with UUID keys" in { + val customer1 = UUID.randomUUID() + val customer2 = UUID.randomUUID() + val emails = Map(customer1 -> Some("john.doe@moia.io"), customer2 -> None) + val result = emails.encoded.map(_.m.keySet.asScala.toSet) + result should ===(Right(Set(customer1.toString))) + } + + "omit empty keys from a map with String keys" in { + val customer1 = UUID.randomUUID().toString + val customer2 = UUID.randomUUID().toString + val emails = Map(customer1 -> Some("john.doe@moia.io"), customer2 -> None) + val result = emails.encodedMap.map(_.keySet.asScala.toSet) + result should ===(Right(Set(customer1))) + } } }