Useful Slick Implicits
I have used slick successfully in many projects. It offers some type safety while still allowing a great deal of control over the executed SQL code.
This control comes at a price, a big portion of slick code is repetitive and it often leads to APIs difficult to use and maintain.
While working on many different projects, we noticed a few common patterns that can be abstracted and reused across projects using slick.
The purpose of this post is not to say slick is better than any other orm-like framework, if you are interested in that subject read this Sofware Mill blob post.
During this blog post I assume you are fairly familiar with slick and it's APIs, so you might want to read slick documentation.
All examples here are open sourced and developed at Advanced Telematic Systems.
Example Schema
For the following examples I'll be using the following schema:
object Schema {
case class Book(id: Int, title: String)
class BooksTable(tag: Tag) extends Table[Book](tag, "books") {
def id = column[Int]("id", O.PrimaryKey)
def repoId = column[String]("title")
override def * = (id, title) <> ((Book.apply _).tupled, Bok.unapply)
}
val books = TableQuery[BooksTable]
}
Handling Errors
Integrity Constraint Violations
Slick often returns JDBC exceptions when a query causes integrity constraint violations. These exceptions are cumbersome to handle and usually we would like to convert them to specific app level exceptions that can be handled by our business logic. This would mean writing code like this:
(books += book).asTry.flatMap {
case Success(v) => DBIO.successful(v)
case Failure(e: SQLIntegrityConstraintViolationException) =>
DBIO.failed(BookAlreadyExistsError)
case Failure(e) =>
DBIO.failed(e)
}
With the help of DbioActionExtensions we can rewrite this as:
(books += book).handleIntegrityErrors(BookAlreadyExistsError)
implicit class DbioActionExtensions[T](action: DBIO[T]) {
def handleIntegrityErrors(error: Throwable)(implicit ec: ExecutionContext): DBIO[T] = {
action.asTry.flatMap {
case Success(i) =>
DBIO.successful(i)
case Failure(e: SQLIntegrityConstraintViolationException) =>
DBIO.failed(error)
case Failure(e: BatchUpdateException) if e.getCause.isInstanceOf[SQLIntegrityConstraintViolationException] =>
DBIO.failed(error)
case Failure(e) =>
DBIO.failed(e)
}
}
}
Handling unexpected query results
Handling Missing Rows
When building slick DBIO instances you often end up with
DBIO[Option[T]], since the query can return an empty result set. But
this is awkward to handle as you then need to pattern match on the
DBIO result just to return an application level error:
val q = books.filter(_.title === "1984").result.headOption.flatMap {
case Some(book) => DBIO.successful(book)
case None => DBIO.failure(BookNotFound)
}
This can be re written if we have DBIOOptionOps in scope:
books.filter(_.title === "1984").result.failIfNone(BookNotFound)
implicit class DBIOOptionOps[T](io: DBIO[Option[T]]) {
def failIfNone(t: Throwable)
(implicit ec: ExecutionContext): DBIO[T] =
io.flatMap(_.fold[DBIO[T]](DBIO.failed(t))(DBIO.successful))
}
Fetching Rows by Id
We often need to get a specific row of a table. You might have a primary key on the table, so you know there can only be one row for that key, but with slick there is no short way to find a row by some key. This means we need to write something like this:
books.filter(_id === id).result.headOption.flatMap {
case Some(e) => DBIO.successful(e)
case None => DBIO.failure(BookNotFound)
}
It would be good generalize this and be able to write:
val q = someTable.filter(_id === id).result.failIfNotSingle(BookNotFound)
This can be done with the following implicit class in scope.
implicit class DBIOSeqOps[+T](io: DBIO[Seq[T]]) {
def failIfNotSingle(t: Throwable)(implicit ec: ExecutionContext): DBIO[T] =
DBIOOptionOps {
io.flatMap { result =>
if (result.size > 1)
DBIO.failed(Errors.TooManyElements)
else
DBIO.successful(result.headOption)
}
}.failIfNone(t)
}
This class also adds failIfEmpty which can be used to transform
DBIO[Seq[T]] into a failure if the resulting Seq[T] is empty.
Updating Rows
It's common to run update statements on one column, but if the query is not written properly you might end up update more rows than you want to update, or not updating rows at all, and the query will still be successful.
books.filter(_.title === "1984").map(_.name).update("2017")
With DbioUpdateActionExtensions in scope you could write:
books.filter(_.title === "1984")
.map(_.name)
.update("2017")
.handleSingleUpdateError(BookUpdateError)
def handleSingleUpdateError(result: Throwable)(implicit ec: ExecutionContext): DBIO[Unit] = {
action.flatMap {
case c if c == 1 =>
DBIO.successful(())
case c if c == 0 =>
DBIO.failed(result)
case _ =>
DBIO.failed(Errors.TooManyElements)
}
}
This does not prevent the app from updating the rows unless this is executed in a transaction, but it does return an error that you can investigate. It will also return an error if the row to be updated does not exist and no rows were updated.
Pagination
Pagination can be abstracted in slick using the implicits in libats:
books.paginate(offset = 10, limit = 100)
books.paginateAndSort(_.id, offset = 10, limit = 100)
Conclusion
Slick is very close to SQL, which can be very powerful and very repetitive at the same time. But as you can see slick can simply be extended using implicits.
In a future blog post I will write about abstracting Slick Mappings
that can be used to store custom types like io.circe.Json,
eu.timepit.Refined or your own BookId.
All examples in this blog post are available at libats.
comments powered by Disqus