diff --git a/build.sbt b/build.sbt index 60762e4..9a314a1 100644 --- a/build.sbt +++ b/build.sbt @@ -5,6 +5,8 @@ organization := "io.tabmo" scalaVersion := "2.11.8" scalacOptions ++= Seq( + "-Ypartial-unification", // enable fix for SI-2712 + "-Yliteral-types", // enable SIP-23 implementation "-deprecation", // Warn when deprecated API are used "-feature", // Warn for usages of features that should be importer explicitly "-unchecked", // Warn when generated code depends on assumptions @@ -24,7 +26,7 @@ libraryDependencies ++= Seq( "com.aerospike" % "aerospike-client" % "3.2.5", "ch.qos.logback" % "logback-classic" % "1.1.3", "org.scalatest" % "scalatest_2.11" % "2.2.1" % "test", - "io.github.jto" %% "validation-core" % "2.0" + "com.chuusai" %% "shapeless" % "2.3.2" ) parallelExecution in Test := false @@ -37,5 +39,7 @@ licenses += ("Apache-2.0", url("http://opensource.org/licenses/Apache-2.0")) bintrayOrganization := Some("tabmo") +scalaOrganization := "org.typelevel" + // Exclude logback file mappings in (Compile, packageBin) ~= { _.filter(!_._1.getName.endsWith(".xml")) } diff --git a/project/build.properties b/project/build.properties index 43b8278..27e88aa 100644 --- a/project/build.properties +++ b/project/build.properties @@ -1 +1 @@ -sbt.version=0.13.11 +sbt.version=0.13.13 diff --git a/src/main/scala/io/tabmo/aerospike/validation/AsDecoder.scala b/src/main/scala/io/tabmo/aerospike/validation/AsDecoder.scala new file mode 100644 index 0000000..0095d05 --- /dev/null +++ b/src/main/scala/io/tabmo/aerospike/validation/AsDecoder.scala @@ -0,0 +1,96 @@ +package io.tabmo.aerospike.validation + +import jto.validation.aerospike._ + +trait AsDecoder[A] { + self => + def decode[V <: AsValue](v: V): Result[A] + + def map[B](f: A => B): AsDecoder[B] = new AsDecoder[B] { + override def decode[V <: AsValue](v: V): Result[B] = self.decode(v).map(f) + } +} + +object Result { + def apply[A](a: A): Result[A] = Done(a) +} + +sealed abstract class Result[+A] { + def map[B](f: A => B): Result[B] + + def flatMap[B](f: A => Result[B]): Result[B] +} +case class Done[A](a: A) extends Result[A] { + override def map[B](f: (A) => B): Result[B] = Done(f(a)) + + override def flatMap[B](f: (A) => Result[B]): Result[B] = f(a) +} +case object Failed extends Result[Nothing] { + override def map[B](f: (Nothing) => B): Result[B] = Failed + + override def flatMap[B](f: (Nothing) => Result[B]): Result[B] = Failed +} + +object AsDecoder { + import shapeless._ + import shapeless.labelled._ + + def instance[A](f: AsValue => Result[A]): AsDecoder[A] = new AsDecoder[A] { + override def decode[V <: AsValue](v: V): Result[A] = f(v) + } + + implicit val longDecoder: AsDecoder[Long] = instance { + case AsLong(v) => Done(v) + case _ => Failed + } + + implicit val stringDecoder: AsDecoder[String] = instance { + case AsString(v) => Done(v) + case _ => Failed + } + + implicit def listDecoder[A](implicit ev: AsDecoder[A]): AsDecoder[List[A]] = instance { + case AsArray(seq) => seq.foldRight(Result(List[A]())){ case (e, acc) => + acc match { + case Done(s) => ev.decode(e).map(_ :: s) + case Failed => Failed + } + } + case _ => Failed + } + + implicit def optionDecoder[A](implicit ev: AsDecoder[A]): AsDecoder[Option[A]] = instance { e => + ev.decode(e) match { + case Done(x) => Done(Some(x)) + case Failed => Done(None) + } + } + + implicit val hnilDecoder: AsDecoder[HNil] = instance(_ => Done(HNil)) + + implicit def hlistDecoder[K <: Symbol, H, T <: HList]( + implicit witness: Witness.Aux[K], + hDecoder: Lazy[AsDecoder[H]], + tDecoder: Lazy[AsDecoder[T]] + ): AsDecoder[FieldType[K, H] :: T] = instance { + case o@AsObject(kv) => { + val fieldName = witness.value.name + val headField = hDecoder.value.decode(kv.getOrElse(fieldName, AsNull)) + val tailFields = tDecoder.value.decode(o) + (headField, tailFields) match { + case (Done(h), Done(t)) => Done(field[K](h) :: t) + case _ => Failed + } + } + case _ => Failed + } + + implicit def objectDecoder[A, Repr <: HList]( + implicit gen: LabelledGeneric.Aux[A, Repr], + hlistDecoder: AsDecoder[Repr] + ): AsDecoder[A] = instance { v => + hlistDecoder.decode(v).map(gen.from) + } + + def apply[A](implicit ev: AsDecoder[A]): AsDecoder[A] = ev +} diff --git a/src/main/scala/io/tabmo/aerospike/validation/AsEncoder.scala b/src/main/scala/io/tabmo/aerospike/validation/AsEncoder.scala new file mode 100644 index 0000000..0449ab1 --- /dev/null +++ b/src/main/scala/io/tabmo/aerospike/validation/AsEncoder.scala @@ -0,0 +1,58 @@ +package io.tabmo.aerospike.validation + +import jto.validation.aerospike._ + +trait AsEncoder[A] { + self => + + def encode(a: A): AsValue + + def contramap[B](f: B => A): AsEncoder[B] = new AsEncoder[B] { + override def encode(b: B): AsValue = self.encode(f(b)) + } +} + +object AsEncoder { + import shapeless._ + import shapeless.labelled._ + import shapeless.ops.hlist.IsHCons + + def instance[A](f: A => AsValue) = new AsEncoder[A] { + override def encode(a: A): AsValue = f(a) + } + + implicit val longEncoder: AsEncoder[Long] = instance(AsLong) + + implicit val stringEncoder: AsEncoder[String] = instance(AsString) + + implicit def listEncoder[A](implicit ev: AsEncoder[A]): AsEncoder[List[A]] = instance { list => + AsArray(list.map(ev.encode).toIndexedSeq) + } + + implicit def optionEncoder[A](implicit ev: AsEncoder[A]): AsEncoder[Option[A]] = instance { + case Some(a) => ev.encode(a) + case None => AsNull + } + + implicit val hnilEncoder: AsEncoder[HNil] = instance(_ => AsObject()) + + implicit def hlistEncoder[K <: Symbol, H, T <: shapeless.HList]( + implicit witness: Witness.Aux[K], + isHCons: IsHCons.Aux[H :: T, H, T], + hEncoder: Lazy[AsEncoder[H]], + tEncoder: Lazy[AsEncoder[T]] + ): AsEncoder[FieldType[K, H] :: T] = instance { o => + val head = AsObject(witness.value.name, hEncoder.value.encode(isHCons.head(o))) + val tail = tEncoder.value.encode(isHCons.tail(o)) + head ++ tail.asInstanceOf[AsObject] + } + + implicit def objectEncoder[A, Repr <: HList]( + implicit gen: LabelledGeneric.Aux[A, Repr], + hlistEncoder: AsEncoder[Repr] + ): AsEncoder[A] = instance { o => + hlistEncoder.encode(gen.to(o)) + } + + def apply[A](implicit ev: AsEncoder[A]): AsEncoder[A] = ev +} diff --git a/src/main/scala/io/tabmo/aerospike/validation/AsValue.scala b/src/main/scala/io/tabmo/aerospike/validation/AsValue.scala index d7f1b5d..67a5fe8 100644 --- a/src/main/scala/io/tabmo/aerospike/validation/AsValue.scala +++ b/src/main/scala/io/tabmo/aerospike/validation/AsValue.scala @@ -4,27 +4,36 @@ package aerospike import scala.collection.JavaConversions._ import scala.collection.JavaConverters._ +import com.aerospike.client.Bin import io.tabmo.aerospike.data.AerospikeRecord -sealed trait AsValue +sealed trait AsValue { + def asObject = this.asInstanceOf[AsObject] +} object AsValue { - def apply(value: AnyRef): AsValue = { + private def buildAST(value: AnyRef): AsValue = { if (value == null) AsNull else { value match { - case _: java.util.Map[_, _] => AsObject(value.asInstanceOf[java.util.Map[String, AnyRef]].asScala.mapValues(AsValue.apply).toMap) - case _: java.util.List[_] => AsArray(value.asInstanceOf[java.util.List[AnyRef]].map(AsValue.apply).toIndexedSeq) + case _: java.util.Map[_, _] => AsObject(value.asInstanceOf[java.util.Map[String, AnyRef]].asScala.mapValues(buildAST).toMap) + case _: java.util.List[_] => AsArray(value.asInstanceOf[java.util.List[AnyRef]].map(buildAST).toIndexedSeq) case l: java.lang.Long => AsLong(l) case s: String => AsString(s) - case _ => throw new IllegalStateException() } } } + def unpackAST(value: AsValue): Object = value match { + case AsObject(map) => map.mapValues(unpackAST).asJava + case AsArray(seq) => seq.map(unpackAST).toList.asJava + case AsLong(l) => new java.lang.Long(l) + case AsString(s) => s + } + def apply(record: AerospikeRecord): AsValue = { - this.apply(mapAsJavaMap(record.bins)) + buildAST(mapAsJavaMap(record.bins)) } def obj(fields: (String, AsValue)*): AsObject = AsObject(fields.toMap) @@ -33,7 +42,15 @@ object AsValue { def string(v: String): AsString = AsString(v) } -case class AsObject(kv: Map[String, AsValue]) extends AsValue +case class AsObject(kv: Map[String, AsValue] = Map()) extends AsValue { + def ++(obj: AsObject): AsObject = AsObject(kv ++ obj.kv) + + def toSeqBins: Seq[Bin] = kv.toList.map { case (k, v) => new Bin(k, AsValue.unpackAST(v)) } +} + +object AsObject { + def apply[V <: AsValue](key: String, value: V): AsObject = AsObject(Map(key -> value)) +} case class AsArray(array: IndexedSeq[AsValue]) extends AsValue diff --git a/src/main/scala/io/tabmo/aerospike/validation/Rules.scala b/src/main/scala/io/tabmo/aerospike/validation/Rules.scala deleted file mode 100644 index c91e8bb..0000000 --- a/src/main/scala/io/tabmo/aerospike/validation/Rules.scala +++ /dev/null @@ -1,100 +0,0 @@ -package jto.validation -package aerospike - -import scala.language.implicitConversions - -object Rules extends DefaultRules[AsValue] { - - private def recordAs[T](f: PartialFunction[AsValue, Validated[Seq[ValidationError], T]])(msg: String, args: Any*) = - Rule.fromMapping[AsValue, T](f.orElse { - case j => Invalid(Seq(ValidationError(msg, args: _*))) - }) - - implicit def asObjectR = - recordAs[AsObject] { - case v@AsObject(_) => Valid(v) - }("error.invalid", "Object") - - implicit def asStringR = - recordAs[AsString] { - case v@AsString(_) => Valid(v) - }("error.invalid", "AsString") - - implicit def asLongR = - recordAs[AsLong] { - case v@AsLong(_) => Valid(v) - }("error.invalid", "AsLong") - - implicit def asArrayR = - recordAs[AsArray] { - case v@AsArray(_) => Valid(v) - }("error.invalid", "AsArray") - - implicit def intR = - recordAs[Int] { - case AsLong(v) => Valid(v.toInt) - }("error.invalid", "Int") - - implicit def stringR = - recordAs[String] { - case AsString(v) => Valid(v) - }("error.invalid", "String") - - implicit def longR = - recordAs[Long] { - case AsLong(v) => Valid(v) - }("error.invalid", "Long") - - implicit def bigDecimalR = - recordAs[BigDecimal] { - case AsLong(v) => Valid(v) - }("error.number", "BigDecimal") - - implicit val asNullR: Rule[AsValue, AsNull.type] = recordAs[AsNull.type] { - case AsNull => Valid(AsNull) - }("error.invalid", "null") - - implicit def mapR[O](implicit r: RuleLike[AsValue, O]): Rule[AsValue, Map[String, O]] = - super.mapR[AsValue, O](r, asObjectR.map { case AsObject(fs) => fs.toSeq }) - - def optionR[AS, O](r: => Rule[AS, O], noneValues: Rule[AsValue, AsValue]*)(implicit pick: Path => Rule[AsValue, AsValue], coerce: Rule[AsValue, AS]): Path => Rule[AsValue, Option[O]] - = super.opt[AS, O](r, asNullR.map(n => n: AsValue) +: noneValues: _*) - - implicit def pickInRecord[II <: AsValue, O](p: Path)(implicit r: RuleLike[AsValue, O]): Rule[II, O] = { - def search(p: Path, asValue: AsValue): Option[AsValue] = { - p.path match { - case KeyPathNode(k) :: t => - asValue match { - case AsObject(as) => as.get(k).flatMap(search(Path(t), _)) - case _ => None - } - case IdxPathNode(i) :: t => - asValue match { - case AsArray(as) => as.lift(i).flatMap(search(Path(t), _)) - case _ => None - } - case Nil => Some(asValue) - } - } - - Rule[II, AsValue] { asValue => - search(p, asValue) match { - case None => - Invalid(Seq(Path -> Seq(ValidationError("error.required")))) - case Some(as) => Valid(as) - } - }.andThen(r) - } - - private def pickInS[T](implicit r: RuleLike[Seq[AsValue], T]): Rule[AsValue, T] = - asArrayR.map { case AsArray(fs) => fs: Seq[AsValue] }.andThen(r) - implicit def pickSeq[O](implicit r: RuleLike[AsValue, O]) = - pickInS(seqR[AsValue, O]) - implicit def pickSet[O](implicit r: RuleLike[AsValue, O]) = - pickInS(setR[AsValue, O]) - implicit def pickList[O](implicit r: RuleLike[AsValue, O]) = - pickInS(listR[AsValue, O]) - implicit def pickArray[O: scala.reflect.ClassTag](implicit r: RuleLike[AsValue, O]) = pickInS(arrayR[AsValue, O]) - implicit def pickTraversable[O](implicit r: RuleLike[AsValue, O]) = - pickInS(traversableR[AsValue, O]) -} diff --git a/src/test/scala/validation/AerospikeRulesSpec.scala b/src/test/scala/validation/AerospikeRulesSpec.scala deleted file mode 100644 index 7e3292d..0000000 --- a/src/test/scala/validation/AerospikeRulesSpec.scala +++ /dev/null @@ -1,68 +0,0 @@ -package validation - -import org.scalatest.{Matchers, WordSpec} -import jto.validation._ -import jto.validation.aerospike.Rules._ -import jto.validation.aerospike._ - -class AerospikeRulesSpec extends WordSpec with Matchers { - - "Support all type of AsValue" when { - - "null" in { - (Path \ "n") - .read[AsValue, AsNull.type] - .validate(AsValue.obj("n" -> AsNull)) shouldBe Valid(AsNull) - (Path \ "n") - .read[AsValue, AsNull.type] - .validate(AsValue.obj("n" -> AsString("foo"))) shouldBe Invalid(Seq(Path \ "n" -> Seq(ValidationError("error.invalid", "null")))) - (Path \ "n").read[AsValue, AsNull.type].validate(AsValue.obj("n" -> AsLong(4))) shouldBe Invalid(Seq(Path \ "n" -> Seq(ValidationError("error.invalid", "null")))) - } - - "Int" in { - (Path \ "n").read[AsValue, Int].validate(AsValue.obj("n" -> AsLong(4))) shouldBe Valid(4) - - (Path \ "n").read[AsValue, Int].validate(AsValue.obj("n" -> AsString("foo"))) shouldBe - Invalid(Seq(Path \ "n" -> Seq( - ValidationError("error.invalid", "Int")))) - - val errPath = Path \ "foo" - val error = Invalid(Seq(errPath -> Seq(ValidationError("error.required")))) - errPath.read[AsValue, Int].validate(AsValue.obj("n" -> AsLong(4))) shouldBe error - } - - "Long" in { - (Path \ "n").read[AsValue, Long].validate(AsValue.obj("n" -> AsLong(4))) shouldBe Valid(4L) - - (Path \ "n").read[AsValue, Long].validate(AsValue.obj("n" -> AsString("foo"))) shouldBe - Invalid(Seq(Path \ "n" -> Seq( - ValidationError("error.invalid", "Long")))) - - val errPath = Path \ "foo" - val error = Invalid(Seq(errPath -> Seq(ValidationError("error.required")))) - errPath.read[AsValue, Long].validate(AsValue.obj("n" -> AsLong(4))) shouldBe error - } - - "String" in { - (Path \ "n").read[AsValue, String].validate(AsValue.obj("n" -> AsString("foo"))) shouldBe Valid("foo") - - (Path \ "n").read[AsValue, String].validate(AsValue.obj("n" -> AsLong(1))) shouldBe - Invalid(Seq(Path \ "n" -> Seq( - ValidationError("error.invalid", "String")))) - - val errPath = Path \ "foo" - val error = Invalid(Seq(errPath -> Seq(ValidationError("error.required")))) - errPath.read[AsValue, Long].validate(AsValue.obj("n" -> AsString("foo"))) shouldBe error - } - - "Seq" in { - (Path \ "n") - .read[AsValue, Seq[String]] - .validate(AsValue.obj("n" -> AsValue.arr(AsString("foo")))) - .toOption - .get shouldBe Seq("foo") - } - - } - -} diff --git a/src/test/scala/validation/AsDecoderSpec.scala b/src/test/scala/validation/AsDecoderSpec.scala new file mode 100644 index 0000000..0ffeca9 --- /dev/null +++ b/src/test/scala/validation/AsDecoderSpec.scala @@ -0,0 +1,45 @@ +package validation + +import org.scalatest.{FlatSpec, Matchers} +import io.tabmo.aerospike.validation.{AsDecoder, Done} +import jto.validation.aerospike._ + +class AsDecoderSpec extends FlatSpec with Matchers { + + "AsDecoder" should "decode long value" in { + AsDecoder[Long].decode(AsValue.long(1L)) shouldBe Done(1L) + } + + "AsDecoder" should "decode string value" in { + AsDecoder[String].decode(AsValue.string("foo")) shouldBe Done("foo") + } + + "AsDecoder" should "decode option value" in { + AsDecoder[Option[String]].decode(AsValue.string("foo")) shouldBe Done(Some("foo")) + AsDecoder[Option[String]].decode(AsNull) shouldBe Done(None) + } + + "AsDecoder" should "decode list value" in { + AsDecoder[List[String]].decode(AsValue.arr(AsString("foo"), AsString("bar"))) shouldBe Done(List("foo", "bar")) + } + + "AsDecoder" should "decoder object value" in { + case class Contact(address: String) + case class Person(name: String, age: Long, contact: Contact, friends: Option[List[String]]) + + val personAs = AsValue.obj( + "name" -> AsString("Romain"), + "age" -> AsLong(27), + "contact" -> AsValue.obj("address" -> AsString("Rue de Thor")), + "friends" -> AsArray(Array(AsString("toto"), AsString("fifou"))) + ) + + AsDecoder[Person].decode(personAs) shouldBe Done(Person("Romain", 27, Contact("Rue de Thor"), Some(List("toto", "fifou")))) + } + + "AsDecoder" should "be map" in { + val booleanDecoder = AsDecoder[Long].map(l => if (l > 0) true else false) + booleanDecoder.decode(AsValue.long(1L)) shouldBe Done(true) + booleanDecoder.decode(AsValue.long(0L)) shouldBe Done(false) + } +} diff --git a/src/test/scala/validation/AsEncoderSpec.scala b/src/test/scala/validation/AsEncoderSpec.scala new file mode 100644 index 0000000..dcd888d --- /dev/null +++ b/src/test/scala/validation/AsEncoderSpec.scala @@ -0,0 +1,56 @@ +package validation + +import com.aerospike.client.Value.{ListValue, LongValue, MapValue, StringValue} +import org.scalatest.{FlatSpec, Matchers} +import io.tabmo.aerospike.validation.AsEncoder +import jto.validation.aerospike._ + +class AsEncoderSpec extends FlatSpec with Matchers { + + "AsEncoder" should "encode long value" in { + AsEncoder[Long].encode(1L) shouldBe AsValue.long(1L) + } + + "AsEncoder" should "encode string value" in { + AsEncoder[String].encode("foo") shouldBe AsValue.string("foo") + } + + "AsEncoder" should "encode option value" in { + AsEncoder[Option[String]].encode(Some("foo")) shouldBe AsValue.string("foo") + AsEncoder[Option[String]].encode(None) shouldBe AsNull + } + + "AsEncoder" should "encode list value" in { + AsEncoder[List[String]].encode(List("foo")) shouldBe AsValue.arr(AsValue.string("foo")) + } + + "AsEncoder" should "encode object value" in { + + case class Contact(address: String) + case class Person(name: String, age: Long, contact: Contact, friends: Option[List[String]]) + + val encodedObject = AsEncoder[Person].encode(Person("Romain", 27, Contact("Rue de Thor"), Some(List("toto", "fifou")))) + + encodedObject shouldBe AsValue.obj( + "name" -> AsString("Romain"), + "age" -> AsLong(27), + "contact" -> AsValue.obj("address" -> AsString("Rue de Thor")), + "friends" -> AsArray(Array(AsString("toto"), AsString("fifou"))) + ) + + val binSeq = encodedObject.asObject.toSeqBins //asObject is an unsafe operation + binSeq.map(_.name) shouldBe List("name", "age", "contact", "friends") + + binSeq.find(_.name == "name").get.value shouldBe a[StringValue] + binSeq.find(_.name == "age").get.value shouldBe a[LongValue] + binSeq.find(_.name == "contact").get.value shouldBe a[MapValue] + binSeq.find(_.name == "friends").get.value shouldBe a[ListValue] + } + + "AsEncoder" should "be contramap" in { + val booleanEncoder = AsEncoder[Long].contramap[Boolean](b => if (b) 1L else 0L) + booleanEncoder.encode(true) shouldBe AsValue.long(1L) + booleanEncoder.encode(false) shouldBe AsValue.long(0L) + } + +} diff --git a/src/test/scala/validation/AsValueSpec.scala b/src/test/scala/validation/AsValueSpec.scala index 5235237..32d0b93 100644 --- a/src/test/scala/validation/AsValueSpec.scala +++ b/src/test/scala/validation/AsValueSpec.scala @@ -1,18 +1,17 @@ package validation import scala.collection.JavaConversions._ -import java.lang.Long import org.scalatest.{FlatSpec, Matchers} import io.tabmo.aerospike.data.AerospikeRecord -import jto.validation.aerospike.{AsArray, AsLong, AsString, AsValue} +import jto.validation.aerospike._ class AsValueSpec extends FlatSpec with Matchers { "AsValue" should "well parse AerospikeRecord" in { val bins = Map[String, AnyRef]( "name" -> "Romain", - "age" -> new Long(27), + "age" -> new java.lang.Long(27), "contact" -> mapAsJavaMap(Map( "address" -> "foo bar" )), @@ -29,5 +28,4 @@ class AsValueSpec extends FlatSpec with Matchers { AsValue(tested) shouldBe expected } - }