A versioning trait for lift-mongodb-record models with rogue
package mojolly
package mongo
package tests
import net.liftweb.record.field._
import net.liftweb.mongodb.record.field._
import org.specs2.specification.After
import mongo.MongoConfig.MongoConfiguration
import com.mongodb.casbah.Imports._
import LibraryImports._
import net.liftweb.mongodb.{ JObjectParser, MongoDB }
object ModelVersioningSpec {
class Note extends VersionedModel[Note] {
object content extends StringField(this, 300)
object tags extends MongoListField[Note, String](this)
object title extends StringField(this, 100)
object name extends StringField(this, 50)
def meta = Note
}
object Note extends Note with VersionedModelMeta[Note] {
def buildNote(name: String = "the name", title: String = "the title", content: String = "the content", tags: List[String] = List("a", "b", "c", "d"))(modifier: String) = {
val note = Note.createRecord
note.name set (name + " " + modifier)
note.title set (title + " " + modifier)
note.content set (content + " " + modifier)
note.tags set tags
note
}
}
}
class ModelVersioningSpec extends LiftMongoDbSpecification {
import ModelVersioningSpec._
def databaseName = "model_versioning_spec"
def is = sequential ^
"A versioned model should" ^
"collect multiple version" ! context.collectMultipleVersions ^
"make a new version on save" ! context.makeANewVersionOnSave ^
"only include the changed fields" ! context.onlyIncludeChangedFields ^
"retrieve versions as seq" ! context.retrieveVersionsAsSeq ^
"be able to retrieve a version by version number" ! context.retrieveVersionByNumber ^
"be able to retrieve a version by date" ! context.retrieveVersionByDate ^ end
object context {
def collectMultipleVersions = {
val note3 = makeNotes("foo")
val ver3 = findVersions(note.id)
(ver3 must haveSize(2)) and (note3.rev.value must_== 3)
}
def makeANewVersionOnSave = {
val note = Note.buildNote()("").save
val ver1 = findVersions(note.id)
val note2 = Note.find(note.id).open_!
note2.name set "another name"
note2.save
val ver2 = findVersions(note2.id)
(ver1 must beEmpty) and (ver2 must not beEmpty)
}
def onlyIncludeChangedFields = {
val note = Note.buildNote()("blah").save
val dbo = Note.find(note.id).open_!
dbo.name set "another name in blah"
dbo.tags set List("d", "e", "f", "g")
val res = Note createNewVersion dbo
val ver = res.as[DBObject]("$push").as[DBObject]("versions").as[DBObject]("fields")
((ver.keys must contain("name")) and (ver.keys must contain("tags")) and (ver.keys must not contain ("title")) and
(ver.keys must not contain ("content")))
}
def retrieveVersionByNumber = {
val note3 = makeNotes("bar")
val rev = note3.revision(2)
((rev must beSome[ModelVersion]) and (rev.get.version must_== 2) and
((rev.get.fields \\ "title").extract[String] must_== "the title bar"))
}
def retrieveVersionByDate = {
val note3 = makeNotes("baz")
val rev = note3.revision(2)
note3.revisionFor(rev.get.timestamp) must_== rev
}
def retrieveVersionsAsSeq = {
val note3 = makeNotes("foos")
(note3.revisions must haveSize(2))
}
def makeNotes(suff: String) = {
val note = Note.buildNote()(suff).save
val note2 = Note.find(note.id).open_!
note2.name set ("another name in " + suff)
note2.tags set List("d", "e", "f", "g")
note2.save
val note3 = Note.find(note.id).open_!
note3.title set "the newer title"
note3.save
}
def findVersions(id: Any): MongoDBList =
(MongoDB.useCollection(Note.collectionName) { _.findOne(id, Map("versions" -> 1)) }).getAsOrElse[BasicDBList]("versions", new BasicDBList)
}
}
package mojolly
package mongo
import LibraryImports._
import net.liftweb.record.{ LifecycleCallbacks }
import net.liftweb.record.field.LongField
import collection.JavaConversions._
import net.liftweb.mongodb.record.MongoId
import net.liftweb.json._
import net.liftweb.json.JsonDSL._
import com.mongodb.DB
import com.mongodb.casbah.Imports._
import MongoDBImports._
object ModelVersion {
implicit val formats = MongoConfig.formats
def apply(dbo: DBObject): ModelVersion = ModelVersion(
dbo.as[Long]("version"),
dbo.as[DateTime]("timestamp"),
Extraction.decompose(dbo.as[DBObject]("fields")))
}
case class ModelVersion(version: Long, timestamp: DateTime, fields: JValue) {
import ModelVersion._
def asJValue = ("version" -> version) ~ ("timestamp" -> timestamp.toString(ISO8601_DATE)) ~ ("fields" -> fields)
def asDBObject = MongoDBObject("version" -> version, "timestamp" -> timestamp.toDate, "fields" -> fields.extract[DBObject])
}
trait VersionedModel[BaseModel <: VersionedModel[BaseModel]] extends MongoModel[BaseModel] with MongoId[BaseModel] { self: BaseModel ⇒
object rev extends LongField(this) with LifecycleCallbacks {
private var changes: DBObject = null
override def beforeSave {
changes = owner.createNewVersion()
set(value + 1)
}
override def afterSave {
if (changes != null && changes.toMap.nonEmpty) {
owner.meta.update(Map("_id" -> owner.id), changes)
}
}
}
def meta: VersionedModelMeta[BaseModel]
def createNewVersion() = meta.createNewVersion(this)
}
trait VersionedModelMeta[BaseModel <: VersionedModel[BaseModel]] extends MongoMetaModel[BaseModel] { self: BaseModel ⇒
def createNewVersion(model: BaseModel): DBObject = {
val original = (find(model.id).map(_.asJValue.camelizeKeys) openOr JNothing.asInstanceOf[JValue])
if (original != JNothing) {
val Diff(mod, add, del) = model.asJValue diff original
$push ("versions" -> ModelVersion(model.rev.value, DateTime.now, mod merge add merge del).asDBObject)
} else $set ("versions" -> List.empty[DBObject])
}
def findVersions(id: AnyRef): DBObject =
conf.db(collectionName).findOneByID(id, Map("rev" -> 1, "versions" -> 1)) getOrElse MongoDBObject()
override def save(inst: BaseModel, concern: WriteConcern): Boolean = saveOp(inst) {
useColl { coll ⇒
val newObj = $set(inst.asDBObject.filterKeys(k ⇒ !(List("_id", "versions") contains k)).toSeq: _*)
coll.findAndModify(Map("_id" -> inst.id), MongoDBObject(), MongoDBObject(), false, newObj, true, true)
}
}
override def save(inst: BaseModel, db: DB, concern: WriteConcern): Boolean = saveOp(inst) {
val newObj = $set(inst.asDBObject.filterKeys(k ⇒ !(List("_id", "versions") contains k)).toSeq: _*)
conf.db(collectionName).findAndModify(Map("_id" -> inst.id), MongoDBObject(), MongoDBObject(), false, newObj, true, true)
}
}
package mojolly
package mongo
package tests
import org.specs2.Specification
import org.specs2.specification.{ Step, Fragments }
import net.liftweb.mongodb._
import mongo.MongoConfig.MongoConfiguration
trait LiftMongoDbSpecification extends Specification {
override def map(fs: ⇒ Fragments) = Step(openDatabase) ^ super.map(fs) ^ Step(stopDatabase)
def databaseName: String
def mongoHost = "127.0.0.1"
def mongoPort = 27017
MongoConfig.config = MongoConfiguration(mongoHost, mongoPort, databaseName, queryLogLevel = "TRACE")
def openDatabase {
MongoDB.defineDb(DefaultMongoIdentifier, MongoConfig.config.asMongoAddress)
MongoDB.use(DefaultMongoIdentifier) { _.dropDatabase() }
}
def stopDatabase {
MongoDB.close
}
}