Safe(r) mappings in Slick with Shapeless
A colleague asked me today if there’s any way to reduce the boilerplate around mapping common columns in data types and database tables; for example, the id
column or created_at
and updated_at
. We’re using Slick, so this post will be based around that.
Before diving into the Slick APIs, let’s first get a basic question out of the way: how should we represent the id
(and other metadata) on our data types themselves? We’ll use the venerable Person
data type as our running example:
case class Person(name: String, age: Int)
Modeling metadata fields on case classes
The following straightforward representation first comes to mind:
case class Person(id: Int, name: String, age: Int)
What’s the problem with it? To see it clearly, let’s consider a method that handles the name
and age
inputs from a user and saves them as a new entry in a database:
import monix.eval.Task
trait DB {
def insert(person: Person): Task[Unit]
}
def handleUserInput(db: DB, name: String, age: Int): Task[Unit] =
db.insert(Person(???, name, age))
We hit a roadblock pretty early where we must make up a value for the id
field. Well, no worries- let’s just make it optional! We’ll also modify the DB
interface to return the Person
after it has been assigned an ID by the database:
case class Person(id: Option[Int], name: String, age: Int)
trait DB {
def insert(person: Person): Task[Person]
}
def handleUserInput(db: DB, name: String, age: Int): Task[Person] =
db.insert(Person(None, name, age))
This works, but it’s not optimal. Every time we handle an instance of Person
, we’ll need to do an awkward dance with id
’s optionaliy; essentially, we’re making an invalid state representable. Which is exactly the opposite of what we should try to achieve with a capable type system. The id
field cannot and will never be empty.
Making invalid states unrepresentable
We can try and approach the solution from our desired interface for reading and writing data from the database: when we write data, we supply all fields but id
, and get back both id
and the rest of Person
. When we read data, we get back an Option[Person]
:
trait DB {
def insert(name: String, age: Int): Task[Person]
def query(id: Int): Task[Option[Person]]
}
This is getting closer, but has the unfortunate effect of replicating all of the Person
fields on all method signatures requiring interaction with it. Instead, we can use this handy representation:
case class WithId[T](id: Long, data: T)
case class Person(name: String, age: Int)
type Row = WithId[Person]
We can now model the database interactions safely and correctly:
trait DB {
def insert(person: Person): Task[WithId[Person]]
def query(id: Int): Task[Option[Person]]
}
So, back to Slick.
Functional/relational mapping for WithId[T]
Using this representation carries an unfortunate repetition for mapping the id
column with the rest of Person
’s data. This boilerplate can be higher if there are more such database assigned fields. Here’s the first version of the Slick schema for the Person
table:
import slick.jdbc.H2Profile.api._
class People(tag: Tag) extends Table[WithId[Person]](tag, "people") {
def id = column[Long]("id", O.AutoInc)
def name = column[String]("name")
def age = column[Int]("age")
def * = (id, name, age) <> (
{ case (id, name, age) => WithId(id, Person(name, age)) },
{ p: WithId[Person] => Some((p.id, p.data.name, p.data.age)) }
)
}
We have to manually construct and deconstruct the instance of WithId[Person]
in the *
projection method, and cannot use the handly Person.apply
and Person.unapply
methods. Pretty unfortunate and annoying. What if, given a tuple t: (Long, String, Int)
, we could generate a function call to WithId(t._1, Person(t._2, t._3))
? And conversely, given p: WithId[Person]
, generate a value Some((p.id, p.data.name, p.data.age))
? Extra points if we could do this generically forall T
.
Whenever I see operations that reassociate tuples, add fields to tuples and in general abstract over amount and types of fields, I turn to Shapeless.
Lessening the projection boilerplate
Our objective is to write generic versions of the following two functions, that Slick’s API requires when mapping the projection to/from our data types:
def construct(data: (Long, String, Int)): WithId[Person] = ???
def deconstruct(p: WithId[Person]): Option[(Long, String, Int)] = ???
The following actions seem feasible for performing construct
generically:
- Convert
(Long, String, Int)
to an HList -Long :: String :: Int :: HNil
. - Split the HList to
id: Long
anddata: String :: Int :: HNil
. - Convert
data
back to thePerson
case class. - Wrap the created
Person
instance inWithId(id, _)
.
The inverse, deconstruct
, is pretty similar.
Ok! Let’s do this. We need 3 typeclasses from shapeless:
Generic.Aux[Product, Out]
will let us move between tuples and HLists;IsHCons.Aux[In, Head, Tail]
will let us assert that the resulting HList has aLong
up front;Tupler.Aux[In, Tuple]
will let us go from an HList back to a tuple.
Here’s our generic construct
:
import shapeless.{ HList, ::, Generic }
import shapeless.ops.hlist.{ IsHCons, Tupler }
def construct[In <: Product, All <: HList, Data <: HList, Out](in: In)(
implicit
inGen: Generic.Aux[In, All],
uncons: IsHCons.Aux[All, Long, Data],
outGen: Generic.Aux[Out, Data]
): WithId[Out] = {
val all = inGen.to(in)
val id = uncons.head(all)
val data = uncons.tail(all)
val out = outGen.from(data)
WithId(id, out)
}
Surprisingly short and succinct. Let’s see that it’s actually working:
scala> val personWithId: WithId[Person] = construct((4L, "Person", 42))
personWithId: WithId[Person] = WithId(4,Person(Person,42))
The above only works when adding a type annotation to personWithId
, otherwise Nothing
will be inferred for the Out
parameter. In the Slick schema, we can’t quite conveniently annotate the projection. Instead, we’ll use Rob Norris’s kinda-curried type application trick so we can specify only the Out
parameter:
class ConstructHelper[Out] {
def apply[In <: Product, All <: HList, Data <: HList](in: In)(
implicit
inGen: Generic.Aux[In, All],
uncons: IsHCons.Aux[All, Long, Data],
outGen: Generic.Aux[Out, Data]
): WithId[Out] = {
val all = inGen.to(in)
val id = uncons.head(all)
val data = uncons.tail(all)
val out = outGen.from(data)
WithId(id, out)
}
}
def construct[Out] = new ConstructHelper[Out]
And we can now use it as such:
scala> val personWithId = construct[Person]((4L, "Person", 42))
personWithId: WithId[Person] = WithId(4,Person(Person,42))
The inverse operation, deconstruct
, is much simpler:
def deconstruct[In, Data <: HList, Out <: Product](in: WithId[In])(
implicit
inGen: Generic.Aux[In, Data],
dataTupler: Tupler.Aux[Long :: Data, Out]
): Option[Out] =
Some(dataTupler(in.id :: inGen.to(in.data)))
And is used as such:
scala> deconstruct(WithId(4L, Person("Person", 42)))
res6: Option[(Long, String, Int)] = Some((4,Person,42))
And we can now happily scrap away some boilerplate from our schema definition:
class People(tag: Tag) extends Table[WithId[Person]](tag, "people") {
def id = column[Long]("id", O.AutoInc)
def name = column[String]("name")
def age = column[Int]("age")
def * = (id, name, age) <> (
construct[Person](_),
deconstruct(_: WithId[Person])
)
}
I couldn’t get rid of the type annotation on deconstruct
, even when I used the partially-applied types trick and moved In
to the helper class. Happy to hear any ideas anyone has.
We can even move some of the definitions to a base class:
abstract class TableWithId[T](tag: Tag, tableName: String)
extends Table[WithId[T]](tag, tableName) {
def id = column[Long]("id", O.AutoInc)
}
Ok, that’s it. Here are a few next steps I might pursue, time permitting:
- pack away both calls to
construct
anddeconstruct
in a function that returns a tuple of(Tuple => Data, Data => Option[Tuple])
; - add support for nested case classes - currently this won’t work because we need to compute a flat representation of the HList;
- perhaps add an implicit enrichment to
TableQuery[People]
and add a version of+=
that takes aPerson
rather thanWithId[Person]
, which makes more sense.
Hope you’ll find this helpful!
This post was typechecked with tut on Scala 2.12.4 with shapeless 2.3.3. The parts that use Slick were tested manually because tut refuses to compile them, for some reason.