Skip to content

Instantly share code, notes, and snippets.

@almeidap
Last active March 25, 2023 12:26
Show Gist options
  • Star 52 You must be signed in to star a gist
  • Fork 17 You must be signed in to fork a gist
  • Save almeidap/5685801 to your computer and use it in GitHub Desktop.
Save almeidap/5685801 to your computer and use it in GitHub Desktop.
DAO design for ReactiveMongo using JSONCollection and Play2 Scala JSON API (work in progress).
package core.dao
import scala.concurrent.Future
import play.api.Logger
import reactivemongo.core.commands.LastError
import reactivemongo.core.errors.DatabaseException
import core.db.MongoHelper
import core.exceptions._
/* Implicits */
import play.modules.reactivemongo.json.BSONFormats._
/**
* Base DAO for MongoDB resources.
*
* @author Pedro De Almeida (almeidap)
*/
trait BaseDAO extends MongoHelper {
val collectionName: String
def ensureIndexes: Future[List[Boolean]]
def Recover[S](operation: Future[LastError])(success: => S): Future[Either[ServiceException, S]] = {
operation.map {
lastError => lastError.inError match {
case true => {
Logger.error(s"DB operation did not perform successfully: [lastError=$lastError]")
Left(DBServiceException(lastError))
}
case false => {
Right(success)
}
}
} recover {
case exception =>
Logger.error(s"DB operation failed: [message=${exception.getMessage}]")
// TODO: better failure handling here
val handling: Option[Either[ServiceException, S]] = exception match {
case e: DatabaseException => {
e.code.map(code => {
Logger.error(s"DatabaseException: [code=${code}, isNotAPrimaryError=${e.isNotAPrimaryError}]")
code match {
case 10148 => {
Left(OperationNotAllowedException("", nestedException = e))
}
case 11000 => {
Left(DuplicateResourceException(nestedException = e))
}
}
})
}
}
handling.getOrElse(Left(UnexpectedServiceException(exception.getMessage, nestedException = exception)))
}
}
}
package core.helpers
import scala.concurrent.ExecutionContext
/**
* Helper around implicit contexts.
*
* @author Pedro De Almeida (almeidap)
*/
trait ContextHelper {
implicit def ec: ExecutionContext = ExecutionContext.Implicits.global
}
package core.db
import play.api.libs.json.{Writes, Json, JsObject}
import reactivemongo.bson.BSONObjectID
/* Implicits */
import play.modules.reactivemongo.json.BSONFormats._
/**
* Query builder wrapping common queries and MongoDB operators.
*
* TODO: create a real query `builder`
*
* @author Pedro De Almeida (almeidap)
*/
object DBQueryBuilder {
def id(objectId: String): JsObject = id(BSONObjectID(objectId))
def id(objectId: BSONObjectID): JsObject = Json.obj("_id" -> objectId)
def set(field: String, data: JsObject): JsObject = set(Json.obj(field -> data))
def set[T](field: String, data: T)(implicit writer: Writes[T]): JsObject = set(Json.obj(field -> data))
def set(data: JsObject): JsObject = Json.obj("$set" -> data)
def set[T](data: T)(implicit writer: Writes[T]): JsObject = Json.obj("$set" -> data)
def push[T](field: String, data: T)(implicit writer: Writes[T]): JsObject = Json.obj("$push" -> Json.obj(field -> data))
def pull[T](field: String, query: T)(implicit writer: Writes[T]): JsObject = Json.obj("$pull" -> Json.obj(field -> query))
def unset(field: String): JsObject = Json.obj("$unset" -> Json.obj(field -> 1))
def inc(field: String, amount: Int) = Json.obj("$inc" -> Json.obj(field -> amount))
def or(criterias: JsObject*): JsObject = Json.obj("$or" -> criterias)
def gt[T](field: String, value: T)(implicit writer: Writes[T]) = Json.obj(field -> Json.obj("$gt" -> value))
def lt[T](field: String, value: T)(implicit writer: Writes[T]) = Json.obj(field -> Json.obj("$lt" -> value))
def query[T](query: T)(implicit writer: Writes[T]): JsObject = Json.obj("$query" -> query)
def orderBy[T](query: T)(implicit writer: Writes[T]): JsObject = Json.obj("$orderby" -> query)
}
package core.dao
import org.joda.time.DateTime
import scala.concurrent.Future
import play.api.Logger
import play.api.libs.json._
import play.modules.reactivemongo.json.collection.JSONCollection
import reactivemongo.api.indexes.{Index, IndexType}
import reactivemongo.bson._
import core.db.DBQueryBuilder
import core.exceptions.ServiceException
import core.models.TemporalModel
/**
* DAO for MongoDB documents.
*
* @author Pedro De Almeida (almeidap)
*/
trait DocumentDAO[T <: TemporalModel] extends BaseDAO {
lazy val collection = db.collection[JSONCollection](collectionName)
def insert(document: T)(implicit writer: Writes[T]): Future[Either[ServiceException, T]] = {
document._id = Some(BSONObjectID.generate)
document.created = Some(DateTime.now)
document.updated = Some(DateTime.now)
Logger.debug(s"Inserting document: [collection=$collectionName, data=$document]")
Recover(collection.insert(document)) {
document
}
}
def find(query: JsObject = Json.obj())(implicit reader: Reads[T]): Future[List[T]] = {
Logger.debug(s"Finding documents: [collection=$collectionName, query=$query]")
collection.find(query).cursor[T].collect[List]()
}
def findById(id: String)(implicit reader: Reads[T]): Future[Option[T]] = findOne(DBQueryBuilder.id(id))
def findById(id: BSONObjectID)(implicit reader: Reads[T]): Future[Option[T]] = findOne(DBQueryBuilder.id(id))
def findOne(query: JsObject)(implicit reader: Reads[T]): Future[Option[T]] = {
Logger.debug(s"Finding one: [collection=$collectionName, query=$query]")
collection.find(query).one[T]
}
def update(id: String, document: T)(implicit writer: Writes[T]): Future[Either[ServiceException, T]] = {
document.updated = Some(new DateTime())
Logger.debug(s"Updating document: [collection=$collectionName, id=$id, document=$document]")
Recover(collection.update(DBQueryBuilder.id(id), DBQueryBuilder.set(document))) {
document
}
}
def update(id: String, query: JsObject): Future[Either[ServiceException, JsObject]] = {
val data = updated(query)
Logger.debug(s"Updating by query: [collection=$collectionName, id=$id, query=$data]")
Recover(collection.update(DBQueryBuilder.id(id), data)) {
data
}
}
def push[S](id: String, field: String, data: S)(implicit writer: Writes[S]): Future[Either[ServiceException, S]] = {
Logger.debug(s"Pushing to document: [collection=$collectionName, id=$id, field=$field data=$data]")
Recover(collection.update(DBQueryBuilder.id(id), DBQueryBuilder.push(field, data)
)) {
data
}
}
def pull[S](id: String, field: String, query: S)(implicit writer: Writes[S]): Future[Either[ServiceException, Boolean]] = {
Logger.debug(s"Pulling from document: [collection=$collectionName, id=$id, field=$field query=$query]")
Recover(collection.update(DBQueryBuilder.id(id), DBQueryBuilder.pull(field, query))) {
true
}
}
def unset(id: String, field: String): Future[Either[ServiceException, Boolean]] = {
Logger.debug(s"Unsetting from document: [collection=$collectionName, id=$id, field=$field]")
Recover(collection.update(DBQueryBuilder.id(id), DBQueryBuilder.unset(field))) {
true
}
}
def remove(id: String): Future[Either[ServiceException, Boolean]] = remove(BSONObjectID(id))
def remove(id: BSONObjectID): Future[Either[ServiceException, Boolean]] = {
Logger.debug(s"Removing document: [collection=$collectionName, id=$id]")
Recover(
collection.remove(DBQueryBuilder.id(id))
) {
true
}
}
def remove(query: JsObject, firstMatchOnly: Boolean = false): Future[Either[ServiceException, Boolean]] = {
Logger.debug(s"Removing document(s): [collection=$collectionName, firstMatchOnly=$firstMatchOnly, query=$query]")
Recover(
collection.remove(query, firstMatchOnly = firstMatchOnly)
) {
true
}
}
def updated(data: JsObject) = {
data.validate((__ \ '$set).json.update(
__.read[JsObject].map{ o => o ++ Json.obj("updated" -> DateTime.now) }
)).fold(
error => data,
success => success
)
}
def ensureIndex(
key: List[(String, IndexType)],
name: Option[String] = None,
unique: Boolean = false,
background: Boolean = false,
dropDups: Boolean = false,
sparse: Boolean = false,
version: Option[Int] = None,
options: BSONDocument = BSONDocument()) = {
val index = Index(key, name, unique, background, dropDups, sparse, version, options)
Logger.info(s"Ensuring index: $index")
collection.indexesManager.ensure(index)
}
}
package core.dao
import scala.concurrent.Future
import play.api.Logger
import play.api.libs.iteratee.Enumerator
import play.api.libs.json.{Reads, Json, JsObject}
import reactivemongo.api.Cursor
import reactivemongo.api.gridfs.{DefaultFileToSave, FileToSave, ReadFile, GridFS}
import reactivemongo.bson.{BSONValue, BSONObjectID}
import core.db.DBQueryBuilder
import core.exceptions.ServiceException
/* Implicits */
import play.modules.reactivemongo.json.ImplicitBSONHandlers._
import reactivemongo.api.gridfs.Implicits.DefaultReadFileReader
/**
* DAO for MongoDB `GridFS` files.
*
* @author Pedro De Almeida (almeidap)
*/
trait FileDAO extends BaseDAO {
lazy val gfs = GridFS(db, collectionName)
def insert(enumerator: Enumerator[Array[Byte]], file: DefaultFileToSave): Future[ReadFile[BSONValue]] = {
gfs.save(enumerator, file)
}
def find(query: JsObject = Json.obj()): Cursor[ReadFile[BSONValue]] = {
Logger.debug(s"Finding files: [collection=$collectionName, query=$query]")
gfs.find(query)
}
def findById(id: String): Future[Option[ReadFile[BSONValue]]] = find(DBQueryBuilder.id(id)).headOption
def findOne(query: JsObject = Json.obj()): Future[Option[ReadFile[BSONValue]]] = {
Logger.debug(s"Finding one file: [collection=$collectionName, query=$query]")
gfs.find(query).headOption
}
def removeById(id: String): Future[Either[ServiceException, Boolean]] = {
Recover(gfs.remove(BSONObjectID(id))) {
true
}
}
def enumerate(file: ReadFile[_ <: BSONValue]): Enumerator[Array[Byte]] = {
gfs.enumerate(file)
}
override def ensureIndexes = {
// Let's build an index on our gridfs chunks collection if none:
gfs.ensureIndex.map {
case status =>
Logger.info(s"GridFS index: [collection=$collectionName, status=$status]")
List(status)
}
}
}
package core.models
import reactivemongo.bson.BSONObjectID
/**
* Base model for `identifiable` documents.
*
* @author Pedro De Almeida (almeidap)
*/
trait IdentifiableModel {
var _id: Option[BSONObjectID]
def identify = _id.map(value => value.stringify).getOrElse("")
}
package core.db
import play.api.Play.current
import play.modules.reactivemongo.ReactiveMongoPlugin
import reactivemongo.bson.{BSONObjectID, BSONValue}
import core.helpers.ContextHelper
/**
* Helper around `MongoDB` resources.
*
* @author Pedro De Almeida (almeidap)
*/
trait MongoHelper extends ContextHelper{
lazy val db = ReactiveMongoPlugin.db
}
object MongoHelper extends MongoHelper {
def identify(bson: BSONValue) = bson.asInstanceOf[BSONObjectID].stringify
}
package core.exceptions
/**
* Trait for service exceptions.
*
* @author Pedro De Almeida (almeidap)
*/
trait ServiceException extends Exception {
val message: String
val nestedException: Throwable
}
package core.exceptions
import reactivemongo.core.commands.LastError
import core.exceptions.ServiceException
/**
* Service-related exceptions.
*
* @author Pedro De Almeida (almeidap)
*/
case class UnexpectedServiceException(
message: String,
nestedException: Throwable = null
) extends ServiceException
case class DBServiceException(
message: String,
lastError: Option[LastError] = None,
nestedException: Throwable = null
) extends ServiceException
object DBServiceException {
def apply(lastError: LastError): ServiceException = {
DBServiceException(lastError.errMsg.getOrElse(lastError.message), Some(lastError))
}
}
case class DuplicateResourceException(
message: String = "error.duplicate.resource",
nestedException: Throwable = null
) extends ServiceException
case class OperationNotAllowedException(
message: String = "error.operation.not.allowed",
nestedException: Throwable = null
) extends ServiceException
case class ResourceNotFoundException(
id: String,
message: String = "error.resource.not.found",
nestedException: Throwable = null
) extends ServiceException
package core.models
import org.joda.time.DateTime
/**
* Base model for `temporal` documents.
*
* @author Pedro De Almeida (almeidap)
*/
trait TemporalModel extends IdentifiableModel {
var created: Option[DateTime]
var updated: Option[DateTime]
}
@scalastic
Copy link

Very useful and well designed examples !
Adding this other command https://github.com/analytically/mapmailer/blob/master/app/reactivemongo/commands.scala and it do the job very well.

Thanks

@ashrithr
Copy link

Very well implemented DAO design. Would it be possible to update this to use Play DI and also provide sample Dao extending Document DAO for common use case's. Thanks!

@mancvso
Copy link

mancvso commented Jul 11, 2016

By documentation

ReactiveMongoPlugin is deprecated, long live to ReactiveMongoModule and ReactiveMongoApi.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment