Search code examples
scalaentityrecordshapelesshlist

Generic entity records scala - introduce id field with shapeless


Problem: Describe a record with an ID field (which makes it an Entity) The ID field will need to be autogenerated, so that Record (also known as A) + ID = Entity (also known as B)

trait Record extends Product
trait Entity {
  type Id
}
case class Book(title: String, author: String, publication: Int)
case class PersistentBook(id: Long, title: String, author: String, publication: Int) extends Entity {
  type Id = Long
}

object PersistentRecords {

  def main(args: Array[String]): Unit = {
    val bookGen = Generic[Book]
    val persistentBookGen = Generic[PersistentBook]

    val scalaBook = Book("Programming in Scala", "Odersky, Spoon, Venners", 2008)


    val scalaBookHlist = bookGen.to(scalaBook)

    val persistentScalaBookHList = 15L :: scalaBookHlist
    val persistentScalaBookFromGeneric: PersistentBook = persistentBookGen.from(persistentScalaBookHList)
    println(s"Book: $scalaBook")
    println(s"PBook: $persistentScalaBookFromGeneric")

    val genHListScalaBook = injectFieldSimpleGeneric(scalaBook, 15L)
    println(s"GenBook: $genHListScalaBook")

    val persistedScalaBook = injectFieldGeneric(scalaBook, 16L)
    println(s"PersistedBook: $persistedScalaBook")
  }

  // OK
  def injectField[F](baseRecord: HList, field: F): HList =
    field :: baseRecord

  // OK
  def injectFieldSimpleGeneric[A, ARepr <: HList, F](baseRecord: A, field: F)(implicit aGen: LabelledGeneric.Aux[A, ARepr]): HList = {
    val baseHList = aGen.to(baseRecord)
    val compositeHList: HList = field :: baseHList
    compositeHList
  }


  def injectFieldGeneric[A, ARepr <: HList, B <: Entity, BRepr <: HList, F <: Entity#Id ](baseRecord: A, idField: F)(
    implicit aGen: LabelledGeneric.Aux[A, ARepr],
             bGen: LabelledGeneric.Aux[B, BRepr]): B = {
    val baseHList = aGen.to(baseRecord)
    val compositeHList  = idField :: baseHList
    bGen.from(compositeHList) //Type mismatch. Required BRepr, found F :: ARepr
  }
}

Output:

Book: Book(Programming in Scala,Odersky, Spoon, Venners,2008)

PBook: PersistentBook(15,Programming in Scala,Odersky, Spoon, Venners,2008)

GenBook: 15 :: Programming in Scala :: Odersky, Spoon, Venners :: 2008 :: HNil

That's the closest i got so far is the injectFieldSimpleGeneric, but it returns an HList, not a B the objective is to be able to generate IDs for records so that i can insert them with self generated IDs when i try to expand it to produce a B, the HList to B is incompatible


Solution

  • There are two issues here. The first is that you haven't provided the compiler with any evidence that ARepr and BRepr are related by some shared structure. You could do this by changing the bGen constraint:

    import shapeless._, shapeless.labelled.{FieldType, field}
    
    trait Record extends Product
    trait Entity { type Id }
    case class Book(title: String, author: String, publication: Int)
    case class PersistentBook(id: Long, title: String, author: String, publication: Int) extends
      Entity { type Id = Long }
    
    def injectFieldGeneric[A, ARepr <: HList, B <: Entity, F <: B#Id](baseRecord: A, idField: F)(
      implicit aGen: LabelledGeneric.Aux[A, ARepr],
               bGen: LabelledGeneric.Aux[B, FieldType[Witness.`'id`.T, F] :: ARepr]
      ): B = {
        val baseHList = aGen.to(baseRecord)
        val compositeHList  = field[Witness.`'id`.T](idField) :: baseHList
        bGen.from(compositeHList)
      }
    

    This works:

    val bookGen = LabelledGeneric[Book]
    val scalaBook = Book("Programming in Scala", "Odersky, Spoon, Venners", 2008)
    val persistedScalaBook =
      injectFieldGeneric[Book, bookGen.Repr, PersistentBook, Long](scalaBook, 16L)
    

    And then:

    scala> println(persistedScalaBook)
    PersistentBook(16,Programming in Scala,Odersky, Spoon, Venners,2008)
    

    Unfortunately you definitely don't want to have to provide all the type parameters every time you call this method, and the compiler can't infer them:

    scala> val persistedScalaBook = injectFieldGeneric(scalaBook, 16L)
                                    ^
           error: inferred type arguments [Book,Nothing,Nothing,Long] do not conform to method injectFieldGeneric's type parameter bounds [A,ARepr <: shapeless.HList,B <: Entity,F <: B#Id]
                                                       ^
           error: type mismatch;
            found   : Book
            required: A
                                                                  ^
           error: type mismatch;
            found   : Long(16L)
            required: F
                                                      ^
           error: could not find implicit value for parameter aGen: shapeless.LabelledGeneric.Aux[A,ARepr]
    

    The problem is that even though you've given the compiler evidence that A and B share structure, you haven't told it how to pick B. B doesn't appear anywhere in the explicit arguments here, and the compiler's not going to enumerate all of the case classes in scope trying to find one with an appropriate LabelledGeneric instance.

    There are two ways you could resolve this issue. One would be to have some kind of type class like this:

    trait HasEntity[A] { type E }
    object HasEntity { type Aux[A, E0] = HasEntity[A] { type E = E0 } }
    

    And then provide instances like HasEntity.Aux[Book, PersistentBook] for each pair of case classes. The other approach would be to rewrite your injectFieldGeneric so that you can provide a single type parameter:

    class PartiallyAppliedInject[B <: Entity] {
      type IdK = Witness.`'id`.T
    
      def apply[A, ARepr <: HList, F <: B#Id, BRepr <: HList](baseRecord: A, idField: F)(
        implicit aGen: LabelledGeneric.Aux[A, ARepr],
                 bGen: LabelledGeneric.Aux[B, FieldType[IdK, F] :: ARepr]
        ): B = {
          val baseHList = aGen.to(baseRecord)
          val compositeHList  = field[IdK](idField) :: baseHList
          bGen.from(compositeHList)
        }
    }
    
    def injectFieldGeneric[B <: Entity]: PartiallyAppliedInject[B] =
      new PartiallyAppliedInject[B]
    

    And then:

    scala> val persistedScalaBook = injectFieldGeneric[PersistentBook](scalaBook, 16L)
    persistedScalaBook: PersistentBook = PersistentBook(16,Programming in Scala,Odersky, Spoon, Venners,2008)
    

    Here you still have to specify the target, but the compiler will be able to verify that it's a valid match and to put together the required mapping.