diff --git a/README.md b/README.md index 8c042ddce..e3b30659e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,37 @@ res1: List[cats.data.ValidatedNel[DynamoReadError, Free]] = List(Valid(Free(Mona For more details see the [API docs](http://guardian.github.io/scanamo/latest/api/#com.gu.scanamo.Scanamo$) +### Custom Formats + +To define a serialisation format for types which Scanamo doesn't already provide: + +```scala +scala> import org.joda.time._ + +scala> import com.gu.scanamo._ +scala> import com.gu.scanamo.syntax._ + +scala> case class Foo(dateTime: DateTime) + +scala> val client = LocalDynamoDB.client() +scala> import com.amazonaws.services.dynamodbv2.model.ScalarAttributeType._ +scala> val farmersTableResult = LocalDynamoDB.createTable(client)("foo")('dateTime -> S) + +scala> implicit val jodaStringFormat = DynamoFormat.coercedXmap[DateTime, String, IllegalArgumentException]( + | DateTime.parse(_).withZone(DateTimeZone.UTC) + | )( + | _.toString + | ) +scala> val operations = for { + | _ <- ScanamoFree.put("foo")(Foo(new DateTime(0))) + | results <- ScanamoFree.scan[Foo]("foo") + | } yield results + +scala> Scanamo.exec(client)(operations).toList +res1: List[cats.data.ValidatedNel[DynamoReadError, Foo]] = List(Valid(Foo(1970-01-01T00:00:00.000Z))) +``` + + License ------- diff --git a/src/main/scala/com/gu/scanamo/DynamoFormat.scala b/src/main/scala/com/gu/scanamo/DynamoFormat.scala index 0c3d67393..910097f43 100644 --- a/src/main/scala/com/gu/scanamo/DynamoFormat.scala +++ b/src/main/scala/com/gu/scanamo/DynamoFormat.scala @@ -1,5 +1,6 @@ package com.gu.scanamo +import cats.NotNull import cats.data._ import cats.std.list._ import cats.std.map._ @@ -9,7 +10,9 @@ import com.amazonaws.services.dynamodbv2.model.AttributeValue import shapeless._ import shapeless.labelled._ import simulacrum.typeclass + import collection.convert.decorateAll._ +import scala.reflect.ClassTag /** * Type class for defining serialisation to and from @@ -46,6 +49,8 @@ import collection.convert.decorateAll._ * >>> DynamoFormat[LargelyOptional].read(DynamoFormat[Map[String, String]].write(Map("b" -> "X"))) * Valid(LargelyOptional(None,Some(X))) * }}} + * + * Custom formats can often be most easily defined using [[DynamoFormat.coercedXmap]] or [[DynamoFormat.xmap]] */ @typeclass trait DynamoFormat[T] { def read(av: AttributeValue): ValidatedNel[DynamoReadError, T] @@ -79,20 +84,35 @@ object DynamoFormat extends DerivedDynamoFormat { * ... ) * >>> DynamoFormat[DateTime].read(new AttributeValue().withN("0")) * Valid(1970-01-01T00:00:00.000Z) + * }}} + */ + def xmap[A, B](r: B => ValidatedNel[DynamoReadError, A])(w: A => B)(implicit f: DynamoFormat[B]) = new DynamoFormat[A] { + override def read(item: AttributeValue): ValidatedNel[DynamoReadError, A] = f.read(item).andThen(r) + override def write(t: A): AttributeValue = f.write(w(t)) + } + + /** + * Returns a [[DynamoFormat]] for the case where `A` can always be converted `B`, + * with `write`, but `read` may throw an exception for some value of `B` * - * >>> val jodaStringFormat = DynamoFormat.xmap[LocalDate, String]( - * ... s => Validated.valid(LocalDate.parse(s)) + * {{{ + * >>> import org.joda.time._ + * + * >>> val jodaStringFormat = DynamoFormat.coercedXmap[LocalDate, String, IllegalArgumentException]( + * ... LocalDate.parse * ... )( * ... _.toString * ... ) * >>> jodaStringFormat.read(jodaStringFormat.write(new LocalDate(2007, 8, 18))) * Valid(2007-08-18) + * + * >>> import com.amazonaws.services.dynamodbv2.model.AttributeValue + * >>> jodaStringFormat.read(new AttributeValue().withS("Togtogdenoggleplop")) + * Invalid(OneAnd(TypeCoercionError(java.lang.IllegalArgumentException: Invalid format: "Togtogdenoggleplop"),List())) * }}} */ - def xmap[A, B](r: B => ValidatedNel[DynamoReadError, A])(w: A => B)(implicit f: DynamoFormat[B]) = new DynamoFormat[A] { - override def read(item: AttributeValue): ValidatedNel[DynamoReadError, A] = f.read(item).andThen(r) - override def write(t: A): AttributeValue = f.write(w(t)) - } + def coercedXmap[A, B, T >: scala.Null <: scala.Throwable](read: B => A)(write: A => B)(implicit f: DynamoFormat[B], T: ClassTag[T], NT: NotNull[T]) = + xmap(coerce[B, A, T](read))(write) /** * {{{ @@ -117,8 +137,11 @@ object DynamoFormat extends DerivedDynamoFormat { private val numFormat = attribute(_.getN, "N")(_.withN) - private def coerce[N](f: String => N): String => ValidatedNel[DynamoReadError, N] = s => - Validated.catchOnly[NumberFormatException](f(s)).leftMap(TypeCoercionError(_)).toValidatedNel + private def coerceNumber[N](f: String => N): String => ValidatedNel[DynamoReadError, N] = + coerce[String, N, NumberFormatException](f) + + private def coerce[A, B, T >: scala.Null <: scala.Throwable](f: A => B)(implicit T: ClassTag[T], NT: NotNull[T]): A => ValidatedNel[DynamoReadError, B] = a => + Validated.catchOnly[T](f(a)).leftMap(TypeCoercionError(_)).toValidatedNel /** * {{{ @@ -126,14 +149,14 @@ object DynamoFormat extends DerivedDynamoFormat { * | DynamoFormat[Long].read(DynamoFormat[Long].write(l)) == cats.data.Validated.valid(l) * }}} */ - implicit val longFormat = xmap(coerce(_.toLong))(_.toString)(numFormat) + implicit val longFormat = xmap(coerceNumber(_.toLong))(_.toString)(numFormat) /** * {{{ * prop> (i: Int) => * | DynamoFormat[Int].read(DynamoFormat[Int].write(i)) == cats.data.Validated.valid(i) * }}} */ - implicit val intFormat = xmap(coerce(_.toInt))(_.toString)(numFormat) + implicit val intFormat = xmap(coerceNumber(_.toInt))(_.toString)(numFormat) private val javaListFormat = attribute(_.getL, "L")(_.withL) diff --git a/src/main/scala/com/gu/scanamo/DynamoReadError.scala b/src/main/scala/com/gu/scanamo/DynamoReadError.scala index 3851a848d..9e757e657 100644 --- a/src/main/scala/com/gu/scanamo/DynamoReadError.scala +++ b/src/main/scala/com/gu/scanamo/DynamoReadError.scala @@ -6,7 +6,7 @@ import cats.std.list._ sealed abstract class DynamoReadError case class PropertyReadError(name: String, problem: NonEmptyList[DynamoReadError]) extends DynamoReadError case class NoPropertyOfType(propertyType: String) extends DynamoReadError -case class TypeCoercionError(e: Exception) extends DynamoReadError +case class TypeCoercionError(t: Throwable) extends DynamoReadError case object MissingProperty extends DynamoReadError object DynamoReadError {