Skip to content

Commit

Permalink
add sphere-mongo-derivation-magnolia
Browse files Browse the repository at this point in the history
Step 2 of #174

The tests are similar to the ones in sphere-mongo-derivation.
We have to make sure the keep those tests consistent in the future.
  • Loading branch information
yanns committed Apr 29, 2020
1 parent a83a731 commit bad7e55
Show file tree
Hide file tree
Showing 17 changed files with 888 additions and 1 deletion.
6 changes: 5 additions & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ lazy val `sphere-libs` = project.in(file("."))
publishArtifact := false,
publish := {})
.disablePlugins(BintrayPlugin)
.aggregate(`sphere-util`, `sphere-json`, `sphere-mongo`)
.aggregate(`sphere-util`, `sphere-json`, `sphere-mongo`, `sphere-mongo-derivation-magnolia`)

lazy val `sphere-util` = project.in(file("./util"))
.settings(standardSettings: _*)
Expand All @@ -59,6 +59,10 @@ lazy val `sphere-mongo-derivation` = project.in(file("./mongo/mongo-derivation")
.settings(Fmpp.settings: _*)
.dependsOn(`sphere-mongo-core`)

lazy val `sphere-mongo-derivation-magnolia` = project.in(file("./mongo/mongo-derivation-magnolia"))
.settings(standardSettings: _*)
.dependsOn(`sphere-mongo-core`)

lazy val `sphere-mongo` = project.in(file("./mongo"))
.settings(standardSettings: _*)
.settings(homepage := Some(url("https://github.com/sphereio/sphere-scala-libs/blob/master/mongo/README.md")))
Expand Down
3 changes: 3 additions & 0 deletions mongo/mongo-derivation-magnolia/dependencies.sbt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
libraryDependencies ++= Seq(
"com.propensive" %% "magnolia" % "0.16.0"
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.sphere.mongo.generic

import scala.annotation.StaticAnnotation

class MongoEmbedded extends StaticAnnotation
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.sphere.mongo.generic

import scala.annotation.StaticAnnotation

class MongoIgnore extends StaticAnnotation
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.sphere.mongo.generic

import scala.annotation.StaticAnnotation

case class MongoKey(value: String) extends StaticAnnotation
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package io.sphere.mongo.generic

import scala.annotation.StaticAnnotation

case class MongoTypeHint(value: String) extends StaticAnnotation
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.sphere.mongo.generic

import scala.annotation.StaticAnnotation

case class MongoTypeHintField(value: String = MongoTypeHintField.defaultValue) extends StaticAnnotation

object MongoTypeHintField {
final val defaultValue: String = "type"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,266 @@
package io.sphere.mongo

import com.mongodb.{BasicDBObject, DBObject}
import io.sphere.mongo.format.{MongoFormat, MongoNothing, toMongo}
import io.sphere.util.{Logging, Memoizer}
import magnolia._
import org.bson.BSONObject

import scala.language.experimental.macros

package object generic extends Logging {

/**
* Creates a MongoFormat instance for an Enumeration type that encodes the `toString`
* representations of the enumeration values.
*/
def mongoEnum(e: Enumeration): MongoFormat[e.Value] = new MongoFormat[e.Value] {
def toMongoValue(a: e.Value): Any = a.toString
def fromMongoValue(any: Any): e.Value = e.withName(any.asInstanceOf[String])
}

type Typeclass[T] = MongoFormat[T]

def deriveMongoFormat[T]: MongoFormat[T] = macro Magnolia.gen[T]
def mongoProduct[T]: MongoFormat[T] = macro Magnolia.gen[T]

def combine[T <: Product](caseClass: CaseClass[MongoFormat, T]): MongoFormat[T] = new MongoFormat[T] {
private val mongoClass = getMongoClassMeta(caseClass)
private val _fields = mongoClass.fields

override def toMongoValue(r: T): Any = {
val dbo = new BasicDBObject
mongoClass.typeHint
.foreach(th => dbo.put(th.field, th.value))

var i = 0
caseClass.parameters.foreach { p =>
writeField(dbo, _fields(i), p.dereference(r))(p.typeclass)
i += 1
}
dbo
}

override def fromMongoValue(any: Any): T = any match {
case dbo: DBObject =>
var i = -1
val fieldValues: Seq[Any] = caseClass.parameters.map { p =>
i += 1
readField(_fields(i), dbo)(p.typeclass)
}
caseClass.rawConstruct(fieldValues)
case _ => sys.error("Deserialization failed. DBObject expected.")
}

override val fields: Set[String] = calculateFields()
private def calculateFields(): Set[String] = {
val builder = Set.newBuilder[String]
var i = 0
caseClass.parameters.foreach { p =>
val f = _fields(i)
if (!f.ignored) {
if (f.embedded)
builder ++= p.typeclass.fields
else
builder += f.name
}
i += 1
}
builder.result()
}
}

def dispatch[T](sealedTrait: SealedTrait[MongoFormat, T]): MongoFormat[T] = new MongoFormat[T] {

val allSelectors = sealedTrait.subtypes.map { subType =>
typeSelector(subType)
}
val readMapBuilder = Map.newBuilder[String, TypeSelector[_]]
val writeMapBuilder = Map.newBuilder[TypeName, TypeSelector[_]]
allSelectors.foreach { s =>
readMapBuilder += (s.typeValue -> s)
writeMapBuilder += (s.subType.typeName -> s)
}
val readMap = readMapBuilder.result
val writeMap = writeMapBuilder.result

private val typeField = sealedTrait.annotations.collectFirst {
case a: MongoTypeHintField => a.value
}.getOrElse(defaultTypeFieldName)

override def toMongoValue(t: T): Any = {
sealedTrait.dispatch(t) { subtype =>
writeMap.get(subtype.typeName) match {
case None => new BasicDBObject(defaultTypeFieldName, defaultTypeValue(subtype.typeName))
case Some(w) => subtype.typeclass.toMongoValue(subtype.cast(t)) match {
case dbo: BSONObject => findTypeValue(dbo, w.typeField) match {
case Some(_) => dbo
case None =>
dbo.put(w.typeField, w.typeValue)
dbo
}
case _ => throw new Exception("Excepted 'BSONObject'")
}
}
}
}

override def fromMongoValue(any: Any): T = {
any match {
case dbo: BSONObject =>
findTypeValue(dbo, typeField) match {
case Some(t) => readMap.get(t) match {
case Some(r) => r.subType.typeclass.fromMongoValue(dbo).asInstanceOf[T]
case None => sys.error("Invalid type value '" + t + "' in DBObject '%s'.".format(dbo))
}
case None => sys.error("Missing type field '" + typeField + "' in DBObject '%s'.".format(dbo))
}
case _ => sys.error("DBObject expected.")
}
}
}

private val defaultTypeFieldName: String = MongoTypeHintField.defaultValue

private case class MongoClassMeta(typeHint: Option[MongoClassMeta.TypeHint], fields: IndexedSeq[MongoFieldMeta])
private object MongoClassMeta {
case class TypeHint(field: String, value: String)
}
private case class MongoFieldMeta(
name: String,
default: Option[Any] = None,
embedded: Boolean = false,
ignored: Boolean = false
)

private val getMongoClassMeta = new Memoizer[CaseClass[MongoFormat, _], MongoClassMeta](caseClass => {
def hintVal(h: generic.MongoTypeHint): String =
if (h.value.isEmpty) defaultTypeValue(caseClass.typeName)
else h.value

log.trace("Initializing Mongo metadata for %s".format(caseClass.typeName.full))

val annotations = caseClass.annotations

val typeHintFieldAnnot: Option[MongoTypeHintField] = annotations.collectFirst {
case h: MongoTypeHintField => h
}
val typeHintAnnot: Option[generic.MongoTypeHint] = annotations.collectFirst {
case h: generic.MongoTypeHint => h
}
val typeField = typeHintFieldAnnot.map(_.value)
val typeValue = typeHintAnnot.map(hintVal)

MongoClassMeta(
typeHint = (typeField, typeValue) match {
case (Some(field), Some(hint)) => Some(MongoClassMeta.TypeHint(field, hint))
case (None , Some(hint)) => Some(MongoClassMeta.TypeHint(defaultTypeFieldName, hint))
case (Some(field), None) => Some(MongoClassMeta.TypeHint(field, defaultTypeValue(caseClass.typeName)))
case (None , None) => None
},
fields = getMongoFieldMeta(caseClass)
)
})

private val getMongoClassMetaFromSubType = new Memoizer[Subtype[MongoFormat, _], MongoClassMeta](subType => {
def hintVal(h: generic.MongoTypeHint): String =
if (h.value.isEmpty) defaultTypeValue(subType.typeName)
else h.value

log.trace("Initializing Mongo metadata for %s".format(subType.typeName.full))

val annotations = subType.annotations

val typeHintFieldAnnot: Option[MongoTypeHintField] = annotations.collectFirst {
case h: MongoTypeHintField => h
}
val typeHintAnnot: Option[generic.MongoTypeHint] = annotations.collectFirst {
case h: generic.MongoTypeHint => h
}
val typeField = typeHintFieldAnnot.map(_.value)
val typeValue = typeHintAnnot.map(hintVal)

MongoClassMeta(
typeHint = (typeField, typeValue) match {
case (Some(field), Some(hint)) => Some(MongoClassMeta.TypeHint(field, hint))
case (None , Some(hint)) => Some(MongoClassMeta.TypeHint(defaultTypeFieldName, hint))
case (Some(field), None) => Some(MongoClassMeta.TypeHint(field, defaultTypeValue(subType.typeName)))
case (None , None) => None
},
fields = IndexedSeq[MongoFieldMeta]()
)
})

private def getMongoFieldMeta(caseClass: CaseClass[MongoFormat, _]): IndexedSeq[MongoFieldMeta] = {
caseClass.parameters.map { p =>
val annotations = p.annotations
val name = annotations.collectFirst {
case h: MongoKey => h
}.fold(p.label)(_.value)
val embedded = annotations.exists {
case _: MongoEmbedded => true
case _ => false
}
val ignored = annotations.exists {
case _: MongoIgnore => true
case _ => false
}
if (ignored && p.default.isEmpty) {
throw new Exception("Ignored Mongo field '%s' must have a default value.".format(p.label))
}
MongoFieldMeta(name, p.default, embedded, ignored)
}.toIndexedSeq
}

private def writeField[A: MongoFormat](dbo: DBObject, field: MongoFieldMeta, e: A): Unit = {
if (!field.ignored) {
if (field.embedded)
toMongo(e) match {
case dbo2: DBObject => dbo.putAll(dbo2)
case MongoNothing => ()
case x => dbo.put(field.name, x)
}
else
toMongo(e) match {
case MongoNothing => ()
case x => dbo.put(field.name, x)
}
}
}

private def readField[A: MongoFormat](f: MongoFieldMeta, dbo: DBObject): A = {
val mf = MongoFormat[A]
def default = f.default.asInstanceOf[Option[A]].orElse(mf.default)
if (f.ignored)
default.getOrElse {
throw new Exception("Missing default for ignored field '%s'.".format(f.name))
}
else if (f.embedded) mf.fromMongoValue(dbo)
else {
val value = dbo.get(f.name)
if (value != null) mf.fromMongoValue(value)
else {
default.getOrElse {
throw new Exception("Missing required field '%s' on deserialization.".format(f.name))
}
}
}
}

private def findTypeValue(dbo: BSONObject, typeField: String): Option[String] =
Option(dbo.get(typeField)).map(_.toString)

private case class TypeSelector[A](val typeField: String, val typeValue: String, subType: Subtype[MongoFormat, A])

private def typeSelector[A](subType: Subtype[MongoFormat, A]): TypeSelector[A] = {
val (typeField, typeValue) = getMongoClassMetaFromSubType(subType).typeHint match {
case Some(hint) => (hint.field, hint.value)
case None => (defaultTypeFieldName, defaultTypeValue(subType.typeName))
}
new TypeSelector[A](typeField, typeValue, subType)
}

private def defaultTypeValue(typeName: TypeName): String =
typeName.short.replace("$", "")

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.sphere.mongo
import com.mongodb.BasicDBObject

object MongoUtils {

def dbObj(pairs: (String, Any)*) =
pairs.foldLeft(new BasicDBObject){case (obj, (key, value)) => obj.append(key, value)}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package io.sphere.mongo

import com.mongodb.{BasicDBObject, DBObject}
import org.scalatest.matchers.must.Matchers
import io.sphere.mongo.format.MongoFormat
import io.sphere.mongo.format.DefaultMongoFormats._
import org.scalatest.wordspec.AnyWordSpec

object SerializationTest {
case class Something(a: Option[Int], b: Int = 2)

object Color extends Enumeration {
val Blue, Red, Yellow = Value
}
}

class SerializationTest extends AnyWordSpec with Matchers {
import SerializationTest._

"mongoProduct" must {
"deserialize mongo object" in {
val dbo = new BasicDBObject()
dbo.put("a", Integer.valueOf(3))
dbo.put("b", Integer.valueOf(4))

val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat
val something = mongoFormat.fromMongoValue(dbo)
something must be (Something(Some(3), 4))
}

"generate a format that serializes optional fields with value None as BSON objects without that field" in {
val testFormat: MongoFormat[Something] = io.sphere.mongo.generic.mongoProduct[Something]
val serializedObject = testFormat.toMongoValue(Something(None, 1)).asInstanceOf[DBObject]
serializedObject.keySet().contains("b") must be(true)
serializedObject.keySet().contains("a") must be(false)
}

"generate a format that use default values" in {
val dbo = new BasicDBObject()
dbo.put("a", Integer.valueOf(3))

val mongoFormat: MongoFormat[Something] = io.sphere.mongo.generic.deriveMongoFormat
val something = mongoFormat.fromMongoValue(dbo)
something must be (Something(Some(3), 2))
}
}

"mongoEnum" must {
"serialize and deserialize enums" in {
val mongo: MongoFormat[Color.Value] = generic.mongoEnum(Color)

// mongo java driver knows how to encode/decode Strings
val serializedObject = mongo.toMongoValue(Color.Red).asInstanceOf[String]
serializedObject must be ("Red")

val enumValue = mongo.fromMongoValue(serializedObject)
enumValue must be (Color.Red)
}
}

}
Loading

0 comments on commit bad7e55

Please sign in to comment.