I have recently started designing a set of ORM classes to use with scala and MongoDB in a service built using Akka. I'd like my entities to be immutable classes (case classes ideally) with optional fields and to have some built in smartness, e.g., a method that returns the MongoDB collection name and a method that returns the contents of the object as a map with key/value as they would be in the MongoDB collection. Due to the intrinsic limitation to 22 fields, using case classes is out of question here (besides, converting a case class to a map is not trivial and requires using reflection), so I had to resort to a different approach.
After doing some research, e.g., this question, I came up with a possible solution that has the above properties. Here's a simple entity:
sealed trait MongoEntity {
val id: Option[String]
val collectionName: String
val properties: Map[String, Any]
}
sealed trait FirstEntity extends MongoEntity {
val collectionName = "first"
val x: String
val y: Option[String]
val z: Option[Int]
}
object FirstEntity {
def apply(id: Option[String],
x: String,
y: Option[String] = None,
z: Option[Int] = None): FirstEntity =
new FirstEntityImpl(id, x, y, z)
// not required, just "nice to have"
def unapply(f: FirstEntity) = Some((f.id, f.x, f.y, f.z))
private case class FirstEntityImpl(id: Option[String] = None,
properties: Map[String, Any])
extends FirstEntity {
def this(id: Option[String],
x: String,
y: Option[String],
z: Option[Int]) =
this(id,{
val m = collection.mutable.Map[String, Any]()
m("x") = x
addTo(m, "y", y)
addTo(m, "z", z)
Map() ++ m.toMap // results in an immutable map
})
val x: String = properties("x").asInstanceOf[String]
val y: Option[String] = properties.get("y").asInstanceOf[Option[String]]
val z: Option[Int] = properties.get("z").asInstanceOf[Option[Int]]
}
}
private def addTo(map: collection.mutable.Map[String, Any],
k: String,
v: Option[Any]) = v.foreach(map(k) = _)
and here's an example of an entity that can contain another one (or have a relationship to in JPA world):
sealed trait SecondEntity extends MongoEntity {
val collectionName = "second"
val w: Option[FirstEntity]
val t: Float
val i: Option[String]
}
object SecondEntity {
def apply(id: Option[String],
w: Option[FirstEntity] = None,
t: Float,
i: Option[String] = None): SecondEntity =
new SecondEntityImpl(id, w, t, i)
private case class SecondEntityImpl(id: Option[String] = None,
properties: Map[String, Any])
extends SecondEntity {
def this(id: Option[String],
w: Option[FirstEntity],
t: Float,
i: Option[String]) =
this(id,{
val m = collection.mutable.Map[String, Any]()
m("t") = t
addTo(m, "w", w)
addTo(m, "i", i)
Map() ++ m.toMap
})
val w: Option[FirstEntity] = properties.get("w").asInstanceOf[Option[FirstEntity]]
val t: Float = properties("t").asInstanceOf[Float]
val i: Option[String] = properties.get("i").asInstanceOf[Option[String]]
}
}
And here's an example of how to use the entities:
object SomeORM extends App {
val f = FirstEntity(id = Some("123"), x = "asdf", z = Some(5324))
val g = FirstEntity(id = Some("123"), x = "asdf", z = Some(5324))
assert(f == g)
assert(f.id.get == "123")
assert(f.x == "asdf")
assert(f.y.isEmpty)
assert(f.z.get == 5324)
assert(f.properties == Map("x" -> "asdf", "z" -> 5324))
val x = SecondEntity(id = None, w = Some(f), t = 123.232f, i = Some("blah"))
// this line will cause: recursive value f needs type
// val z = SecondEntity(id = None, w = Some(f), t = 123.232f, i = Some("blah"))
val y = SecondEntity(id = None, w = Some(g), t = 123.232f, i = Some("blah"))
assert(x == y)
assert(x.properties == Map("w" -> f, "t" -> 123.232f, "i" -> "blah"))
doSomething(f)
def doSomething(f: FirstEntity) {
f match {
case FirstEntity(Some(id), xx, Some(yy), Some(zz)) => println(s"$id, $xx, $yy, $zz")
case FirstEntity(Some(id), xx, None, Some(zz)) => println(s"$id, $xx, $zz")
case _ => println("not matched")
}
}
}
(As a side note the commented line seems to trigger this bug.)
Here's a list of pros and cons (in no particular order) for the above code:
PROS:
- all entities are traits -> additional logic/mixins possible;
- compiler-generated equals and hashCode methods -> less boilerplate/errors;
- pattern matching is possible (with less than 22 fields) -> nice to have;
- immutable entities (case classes/immutable map) -> more safety;
- the map represent the BSON structure easily -> less boilerplate/errors;
- the map allows easy renaming of fields in their serialized form -> no need for annotations;
- the object corresponding to each trait might be generated using a Scala macro -> less boilerplate/errors;
- all entities are factually sealed and can't be extended -> more security.
CONS:
- pattern matching impossible for entities with more than 22 fields -> not a deal breaker;
- need to write: apply (and unapply), accessors in case class, additional constructor -> more boilerplate (unless macro-generated);
- requires one cast per accessor in the case class creation -> more boilerplate/less safety.
Now I'm wondering if there are better ways to do this or if there are ways to improve the code above, specifically: how to avoid the cast in the entity's accessors; how to build the map in a more efficient way in the entity's constructor. Finally, do you guys see any more PROs or CONs? Note that I'm mostly concerned with external safety, i.e., for API users, than with having to add a few more lines of boilerplate that I can thoroughly test.
Note that designing a MongoDB ORM can be seen as an exercise to design any kind of ORM, NoSQL or RDBMS.