Search code examples
scalapattern-matchingshapeless

Dynamically populate some attributes for a set of case classes


  • I have a set of model classes and a subset of those models have 2 properties called createdBy and modifiedBy.
  • I need to populate those attributes only for those objects that has those attributes. Currently I'm doing it in a pattern match with some boilerplate code.
  case class DataSourceInstanceRow(id: Int, value: String, createdBy: Option[String], modifiedBy: Option[String])
  case class FormDefinitionRow(id: Int, formData: String, createdBy: Option[String], modifiedBy: Option[String])
  case class DecisionTableDefinitionRow(id: Int, rows: Int, definitions: List[String], createdBy: Option[String], modifiedBy: Option[String])
  case class ReportDef(id: Int, reportType: Int, reportName: String)


  def populateLogs[T](t: T, user: String): T = {
    t match {
      case ds: DataSourceInstanceRow =>
        if(ds.id == -1) ds.copy(modifiedBy = Some(user), createdBy = Some(user)).asInstanceOf[T]
        else ds.copy(modifiedBy = Some(user)).asInstanceOf[T]
      case fd: FormDefinitionRow =>
        if(fd.id == -1) fd.copy(modifiedBy = Some(user), createdBy = Some(user)).asInstanceOf[T]
        else fd.copy(modifiedBy = Some(user)).asInstanceOf[T]
      case dtd: DecisionTableDefinitionRow =>
        if(dtd.id == -1) dtd.copy(modifiedBy = Some(user), createdBy = Some(user)).asInstanceOf[T]
        else dtd.copy(modifiedBy = Some(user)).asInstanceOf[T]
      case o => o
    }
  }

  • DataSourceInstanceRow, FormDefinitionRow, DecisiontableDefinitionRow have modifiedBy and createdBy properties. But not ReportDef

How can I use shapeless to create an abstraction to remove the boilerplate from above pattern matching?


Solution

  • You can do this kind of thing with Shapeless's Updater:

    import shapeless.{ LabelledGeneric, HList, Witness }
    import shapeless.labelled.{FieldType, field}
    import shapeless.ops.record.Updater
    
    type CreatedBy = Witness.`'createdBy`.T
    type ModifiedBy = Witness.`'modifiedBy`.T
    
    def populateLogs[T, R <: HList](t: T, user: String)(implicit
      gen: LabelledGeneric.Aux[T, R],
      cb: Updater.Aux[R, FieldType[CreatedBy, Option[String]], R] = null,
      mb: Updater.Aux[R, FieldType[ModifiedBy, Option[String]], R] = null
    ): T = (
      for {
        createdBy <- Option(cb)
        modifiedBy <- Option(mb)
      } yield gen.from(
        createdBy(modifiedBy(gen.to(t), field(Some(user))), field(Some(user)))
      )
    ).getOrElse(t)
    

    And then:

    scala> populateLogs(DataSourceInstanceRow(1, "abc", None, None), "foo")
    res0: DataSourceInstanceRow = DataSourceInstanceRow(1,abc,Some(foo),Some(foo))
    
    scala> populateLogs(ReportDef(1, 2, "abc"), "foo")
    res1: ReportDef = ReportDef(1,2,abc)
    

    This implementation uses a trick based on the fact that you can put a null default value on an implicit parameter and the compiler will use that if it can't find an implicit. It's simple and works just fine, but some people hate it. A more principled approach uses implicit prioritization:

    trait UpdateBoth[T] extends ((T, String) => T)
    
    object UpdateBoth extends LowPriorityUpdateBothInstances {
      implicit def updateWithFields[T, R <: HList](implicit
        gen: LabelledGeneric.Aux[T, R],
        cb: Updater.Aux[R, FieldType[CreatedBy, Option[String]], R],
        mb: Updater.Aux[R, FieldType[ModifiedBy, Option[String]], R]
      ): UpdateBoth[T] = (t, user) =>
        gen.from(cb(mb(gen.to(t), field(Some(user))), field(Some(user))))
    }
    
    trait LowPriorityUpdateBothInstances {
      implicit def updateAny[T]: UpdateBoth[T] = (t, _) => t
    }
    
    def populateLogs[T](t: T, user: String)(implicit update: UpdateBoth[T]): T =
      update(t, user)
    

    That will work exactly the same way.