Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add sphere-mongo-derivation-magnolia #190

Merged
merged 1 commit into from
Apr 29, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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