casualjim
8/25/2011 - 7:49 PM

A versioning trait for lift-mongodb-record models with rogue

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
  }
}