Storing polymorphic objects with ReactiveMongo and Play

ReactiveMongo is an extremely convenient toolkit for working with MongoDB in Scala applications. But, at the same time, its documentation does not cover some of the typical scenarios, so sometimes it takes time to find the right solution. One of such tasks – storing polymorphic objects in a database – is the focus of this blog post.

Let’s assume that we have a case class Delivery which contains a list of delivery reports from various providers. And the DeliveryReport is a trait which must be mixed in by specific provider implementations. The Delivery class can look like the following:

Delivery.scala

package models

import play.api.libs.json.Json
import reactivemongo.bson.{BSONDocument, BSONHandler}

case class Delivery(reports: List[DeliveryReport])

object Delivery {
  implicit val deliveryFormat = Json.format[Delivery]

  implicit object OwnerBSONHandler extends BSONHandler[BSONDocument, Delivery] {
    override def read(doc: BSONDocument): Delivery = Delivery(
      doc.getAs[List[DeliveryReport]]("reports").get
    )

    override def write(delivery: Delivery): BSONDocument = BSONDocument(
      "reports" -> delivery.reports
    )
  }
}

Every delivery report must contain an essential set of mandatory fields as well as some additional fields which are defined by providers based on the specification of their products. This way, we need the ability to do the appropriate serialization at execution time. Let’s get back to this question a little bit later and have a look at the model classes now:

DeliveryReport.scala

package models

import java.util.Date

trait DeliveryReport {
  val baseInfo: BaseInfo
  def printReport(): Unit = { /* Report printing implementation... */ }
}

case class BaseInfo(
    order:           String,
    orderDate:       Date,
    customerId:      Long,
    deliveryMethod:  String,
    shippingAddress: String)

/* Specific implementations of provider reports */

case class ClothesProviderReport(baseInfo: BaseInfo) extends DeliveryReport

case class FurnitureProviderReport(
    baseInfo:     BaseInfo,
    needInstall:  Boolean,
    manufacturer: String,
    parts:        List[String]) extends DeliveryReport

case class FoodProviderReport(baseInfo: BaseInfo, frozen: Boolean) extends DeliveryReport

As you can see, we have three implementations of the report model for three different providers:

  • ClothesProviderReport
  • FurnitureProviderReport
  • FoodProviderReport

All of them extend DeliveryReport trait as well as contain the mandatory baseInfo field and the printReport method. Some of the reports also have additional fields that describe properties, which are specific to a certain type of product.

In order to store reports in a database, implicit Json formatters should be created for every DeliveryReport implementation as well as BSONReaders and BSONWriters. In this example, I have created the ReportUtils.scala object that contains all the Json formatters and BSON readers & writers:

ReportUtils.scala

package com.sg.examples

import java.util.Date

import models._
import play.api.libs.json.Json
import reactivemongo.bson.BSONDocument

object ReportUtils {

  object Implicits {
    implicit val baseInfoFormat = Json.format[BaseInfo]
    implicit val clothesFormat = Json.format[ClothesProviderReport]
    implicit val furnitureFormat = Json.format[FurnitureProviderReport]
    implicit val foodFormat = Json.format[FoodProviderReport]
  }

  /* BEGIN: BSON readers */

  def baseInfoRead(doc: BSONDocument) = BaseInfo(
    doc.getAs[String]("order").get,
    doc.getAs[Date]("orderDate").get,
    doc.getAs[Long]("customerId").get,
    doc.getAs[String]("deliveryMethod").get,
    doc.getAs[String]("shippingAddress").get
  )

  def clothesRead(doc: BSONDocument) = ClothesProviderReport(
    doc.getAs[BaseInfo]("baseInfo").get
  )

  def furnitureRead(doc: BSONDocument) = FurnitureProviderReport(
    doc.getAs[BaseInfo]("baseInfo").get,
    doc.getAs[Boolean]("needInstall").get,
    doc.getAs[String]("manufacturer").get,
    doc.getAs[List[String]]("parts").get
  )

  def foodRead(doc: BSONDocument) = FoodProviderReport(
      doc.getAs[BaseInfo]("baseInfo").get,
      doc.getAs[Boolean]("frozen").get
    )
  /* END: BSON readers */

  /* BEGIN: BSON writers */

  def baseInfoWrite(info: BaseInfo) = BSONDocument(
    "order"           -> info.order,
    "orderDate"       -> info.orderDate,
    "customerId"      -> info.customerId,
    "deliveryMethod"  -> info.deliveryMethod,
    "shippingAddress" -> info.shippingAddress
  )

  def clothesWrite(report: ClothesProviderReport) = BSONDocument(
    "baseInfo" -> report.baseInfo
  )

  def furnitureWrite(report: FurnitureProviderReport) = BSONDocument(
    "baseInfo"      -> report.baseInfo,
    "needInstall"   -> report.needInstall,
    "manufacturer"  -> report.manufacturer,
    "parts"         -> report.parts
  )

  def foodWrite(report: FoodProviderReport) = BSONDocument(
    "baseInfo"  -> report.baseInfo,
    "frozen"    -> report.frozen
  )
  /* END: BSON writers */

}Code language: JavaScript (javascript)

The ReportUtils.scala object is nothing but a holder for the DeliveryReport implicits and BSONReaders and BSONWriters for database models.

At this point we have all our objects ready to be stored in a database. Since we do not store reports as instances of specific implementation types, but as instances of DeliveryReport trait, there is still a question of how the program is going to determine which one should be serialized at runtime. To resolve this, I have created the DeliveryReport companion object which contains two essential things: custom apply/unapply methods and implicit BSONHandler. Please, take a look at the code snippet to see an example implementation of two companion objects, for the DeliveryReport trait and for the BaseInfo case class, which contains only BSONHandler.

DeliveryReport.scala

package models

import play.api.libs.json.{JsValue, Json}
import com.sg.examples.ReportUtils.Implicits._
import com.sg.examples.ReportUtils._
import reactivemongo.bson.{BSONDocument, BSONHandler}

object BaseInfo {
  implicit object BaseInfoBSONHandler extends BSONHandler[BSONDocument, BaseInfo] {
    override def read(bson: BSONDocument): BaseInfo = baseInfoRead(bson)
    override def write(info: BaseInfo): BSONDocument = baseInfoWrite(info)
  }
}

object DeliveryReport {

  implicit val deliveryFormat = Json.format[DeliveryReport]

  /**
    * Converts DeliveryReport trait into one of its instances as a JSON.
    */
  def unapply(report: DeliveryReport): Option[(String, JsValue)] = {
    val (prod: Product, sub) = report match {
      case c: ClothesProviderReport     => (c, Json.toJson(c)(clothesFormat))
      case fu: FurnitureProviderReport  => (fu, Json.toJson(fu)(furnitureFormat))
      case fo: FoodProviderReport       => (fo, Json.toJson(fo)(foodFormat))
    }
    Some(prod.productPrefix -> sub)
  }

  /**
    * Converts a JSON object into the DeliveryReport instance.
    */
  def apply(`class`: String, data: JsValue): DeliveryReport = {
    (`class` match {
      case "ClothesProviderReport"    => Json.fromJson[ClothesProviderReport](data)(clothesFormat)
      case "FurnitureProviderReport"  => Json.fromJson[FurnitureProviderReport](data)(furnitureFormat)
      case "FoodProviderReport"       => Json.fromJson[FoodProviderReport](data)(foodFormat)
    }).get
  }

  /**
    * Implicitly reads/writes the DeliveryReport trait into/from a BSONDocument.
    */
  implicit object DeliveryBSONHandler extends BSONHandler[BSONDocument, DeliveryReport] {
    override def read(bson: BSONDocument): DeliveryReport = bson.productPrefix match {
      case "ClothesProviderReport"    => clothesRead(bson)
      case "FurnitureProviderReport"  => furnitureRead(bson)
      case "FoodProviderReport"       => foodRead(bson)
    }

    override def write(report: DeliveryReport): BSONDocument = report match {
      case c: ClothesProviderReport     => clothesWrite(c)
      case fu: FurnitureProviderReport  => furnitureWrite(fu)
      case fo: FoodProviderReport       => foodWrite(fo)
    }
  }
}Code language: JavaScript (javascript)

The custom unapply method is responsible for detecting what kind of report we are dealing with. The apply method, in turn, works the same way, but in the opposite direction.

A service that handles the database access can look like this:

DeliveryReportService.scala

 import reactivemongo.api.collections.bson._

    val reportCollection: BSONCollection = ???

    def store(report: DeliveryReport): Future[WriteResult] =
        reportCollection.insert(report) /* ... or process the result somehow */Code language: JavaScript (javascript)

Finally, storing any kind of the DeliveryReport case classes into a database is going to be performed in the following way.

AnyClass.scala

  val furnitureReport = FurnitureProviderReport(
        baseInfo: BaseInfo(
            order           = "orderId1",
            orderDate       = System.currentTimeMillis,
            customerId      = 0,
            deliveryMethod  = "auto",
            shippingAddress = "Elm St. 10"
        ),
        parts: List("chair legs", "chair seat")
    )
    
    val foodReport = FoodProviderReport(
        baseInfo = BaseInfo(
            order           = "orderId2",
            orderDate       = System.currentTimeMillis,
            customerId      = 123,
            deliveryMethod  = "avia",
            shippingAddress = "Elm St. 15"
        ),
        frozen = true
    )
    
    DeliveryReportService.store(furnitureReport)
    DeliveryReportService.store(foodReport)Code language: PHP (php)

As a result, we get report documents, stored in the mongodb collection:

FurnitureProviderReport_mongodb_document

{
    class: "FurnitureProviderReport",
    data: {
        baseInfo: {
            order: "orderId1",
            orderDate: "1467101459346",
            customerId: "0",
            deliveryMethod: "auto",
            shippingAddress: "Elm St. 10"
        },
        parts: [
            "chair legs",
            "chair seat"
        ]
    }
},
{
    class: "FoodProviderReport",
    data: {
        baseInfo: {
            order: "orderId2",
            orderDate: "1467101456354",
            customerId: "123",
            deliveryMethod: "avia",
            shippingAddress: "Elm St. 15"
        },
        frozen: true
    }
}Code language: JavaScript (javascript)

Note! The example above has been created using the following software versions:
– ReactiveMongo: 0.11.13
– MongoDB: 3.2.2
– Play Framework: 2.5.x
– Scala: 2.11.7