Skip to content

Commit

Permalink
Merge pull request #14 from guardian/coercedXmap
Browse files Browse the repository at this point in the history
Provide a simplified means of creating a DynamoFormat
  • Loading branch information
philwills committed Apr 18, 2016
2 parents 082669c + 0e4810d commit 05ee2bd
Show file tree
Hide file tree
Showing 3 changed files with 65 additions and 11 deletions.
31 changes: 31 additions & 0 deletions README.md
Expand Up @@ -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
-------

Expand Down
43 changes: 33 additions & 10 deletions 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._
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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)

/**
* {{{
Expand All @@ -117,23 +137,26 @@ 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

/**
* {{{
* prop> (l: Long) =>
* | 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)
Expand Down
2 changes: 1 addition & 1 deletion src/main/scala/com/gu/scanamo/DynamoReadError.scala
Expand Up @@ -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 {
Expand Down

0 comments on commit 05ee2bd

Please sign in to comment.