When designing data schemas it is very important to normalize many to many data relationships.

In this blog post I am going to go over how to use slick to work with many to many data relationships within Scala code.

I will assume that you have sbt 0.13.13 or later installed on your machine.

First we will create a scala hello world project to get up and running quickly,

$ sbt new sbt/scala-seed.g8
....
Minimum Scala build.

name [My Something Project]: many2many

Template applied in ./many2many

To run the newly created scala app run the following commands,

cd many2many
sbt run

The next thing we will do is add Slick and other required dependencies to our project.

Replace the contents of build.sbt with the following code,

lazy val root = (project in file(".")).
  settings(
    inThisBuild(List(
      organization := "com.example",
      scalaVersion := "2.12.3",
      version      := "0.1.0-SNAPSHOT"
    )),
    name := "Hello",
    libraryDependencies ++= Seq(
      "com.h2database" % "h2" % "1.4.192",
      "com.typesafe.slick" %% "slick" % "3.2.1",
      "org.slf4j" % "slf4j-nop" % "1.6.4",
      "com.typesafe.slick" %% "slick-hikaricp" % "3.2.1",
      "com.typesafe" % "config" % "1.3.1"
    )
  )

You should also delete the auto created test directory.

After this, we will create the following file, src/main/resources/application.conf with the following contents,

h2mem1 = {
  url = "jdbc:h2:mem:test1"
  driver = org.h2.Driver
  connectionPool = disabled
  keepAliveConnection = true
}

This will be configure our database to be an in-memory database. This way we do not have to spin up and configure an instance of a postgresql or mysql database.

The many to many data relationship we will model will be books to authors.

  • An author can have many books
  • A book can have many authors.

Now that we have our slick dependencies defined we can create our scala model case classes and Slick table row classes.

// Simple Scala data model case classes
case class Author(id: Int, name: String)
case class Book(id: Int, name: String)

Then add within our Hello example object the slick Table row classes,

  // Slick table row classes
  class Authors(tag: Tag) extends Table[Author](tag, "author") {
    def id = column[Int]("id", O.PrimaryKey)
    def name = column[String]("name")
    def * = (id, name) <> ((Author.apply _).tupled, Author.unapply)
  }
  val authors = TableQuery[Authors]

  class Books(tag: Tag) extends Table[Book](tag, "author") {
    def id = column[Int]("id", O.PrimaryKey)
    def name = column[String]("name")
    def * = (id, name) <> ((Book.apply _).tupled, Book.unapply)
  }
  val books = TableQuery[Books]

We now have the ability to insert and query books and authors from our in memory database, lets try it out.

Add the following lines of code below the table row classes,

// Create a connection to our in-memory database
  val db = Database.forConfig("h2mem1")

  val stephenKing = Author(1, "Stephen King")
  val jkRowling = Author(2, "J. K. Rowling")
  val jrrTolkien = Author(3, "J. R. R. Tolkien")
  val danBrown = Author(4, "Dan Brown")

  val theShining = Book(1, "The Shining")
  val harryPotter = Book(2, "Harry Potter")
  val theLordOfTheRings = Book(3, "Lord of the Rings")
  val theDaVinciCode = Book(4, "The Da Vinci Code")
  val fictiousBook1 = Book(5, "Ficticious Book 1")
  val fictiousBook2 = Book(6, "Ficticious Book 2")

  try {
    val setup = DBIO.seq(
      // Create the tables
      (authors.schema ++ books.schema).create,

      // Insert some authors
      authors += stephenKing,
      authors += jkRowling,
      authors += jrrTolkien,
      authors += danBrown,

      // Insert some books
      books += theShining,
      books += harryPotter,
      books += theLordOfTheRings,
      books += theDaVinciCode,
      books += fictiousBook1,
      books += fictiousBook2,
    )

    val setupFuture = db.run(setup)

    val futureResult = setupFuture.flatMap { _ =>
      // Read all authors and print them to the console
      println("Authors:")
      db.run(authors.result).map(_.foreach(println))
    }.flatMap { _ =>
      // Read all books and print them to the console
      println("Books:")
      db.run(books.result).map(_.foreach(println))
    }

    Await.result(futureResult, Duration.Inf)
  } finally db.close

Fairly straight forward code for working with some simple data being stored in our relational in memory database.

Next up, our many-to-many relationship.

We can normalize many-to-many data relationships with an associative table that contains an author_id foreign key and a book_id foreign key, together representing a composite primary key.

Here is the simple model class,

case class AuthorBook(authorId: Int, bookId: Int)

and the corresponding Slick table class,

  class AuthorBooks(tag: Tag) extends Table[AuthorBook](tag, "author_book") {
    def authorId = column[Int]("author_id")
    def bookId = column[Int]("book_id")
    def authorFk = foreignKey("author_fk", authorId, authors)(_.id, onUpdate=ForeignKeyAction.Cascade, onDelete=ForeignKeyAction.Cascade)
    def bookFk = foreignKey("book_fk", bookId, books)(_.id, onUpdate=ForeignKeyAction.Cascade, onDelete=ForeignKeyAction.Cascade)
    def pk = primaryKey("pk", (authorId, bookId))
    def * = (authorId, bookId) <> ((AuthorBook.apply _).tupled, AuthorBook.unapply)
  }
  val authorBooks = TableQuery[AuthorBooks]

Now lets add some author books.

// make sure to add the table to our in memory DB
(authors.schema ++ books.schema ++ authorBooks.schema).create,

// Create normalized many to many relationships
authorBooks += AuthorBook(stephenKing.id, theShining.id),
authorBooks += AuthorBook(jkRowling.id, harryPotter.id),
authorBooks += AuthorBook(jrrTolkien.id, theLordOfTheRings.id),
authorBooks += AuthorBook(danBrown.id, theDaVinciCode.id),
authorBooks += AuthorBook(jkRowling.id, fictitiousBook1.id),
authorBooks += AuthorBook(jrrTolkien.id, fictitiousBook1.id),
authorBooks += AuthorBook(stephenKing.id, fictitiousBook2.id),
authorBooks += AuthorBook(danBrown.id, fictitiousBook2.id),

Now that all our data including the relational many to many data is in place we can query it,

val futureResult = setupFuture.flatMap { _ =>
  // Read all books by Stephen King
  println("Stephen Kings Books:")
  val booksJoinedToAuthorBooks = books join authorBooks on (_.id === _.bookId)
  val booksFilteredToStephenKing = booksJoinedToAuthorBooks.filter(_._2.authorId === stephenKing.id)
  db.run(booksFilteredToStephenKing.result).map(_.map(_._1).foreach(println))
}.flatMap { _ =>
  // Read all books by Stephen King
  println("Fictitious Book 2's Authors:")
  val authorsJoinedToAuthorBooks = authors join authorBooks on (_.id === _.authorId)
  val authorsFilteredToFictitiousBook2 = authorsJoinedToAuthorBooks.filter(_._2.bookId === fictitiousBook2.id)
  db.run(authorsFilteredToFictitiousBook2.result).map(_.map(_._1).foreach(println))
}

Here we are joining the associative table with the table we are interested in and applying a filter to it.

I personally think it is very cool the way slick allows you to create very declarative functional queries against defined table schema classes with type safety (which I could not overstate my praises for).

Finally putting all the work together into a single runnable file,

package example

import slick.jdbc.H2Profile.api._
import scala.concurrent.Await
import scala.concurrent.duration.Duration

import scala.concurrent.ExecutionContext.Implicits.global

case class Author(id: Int, name: String)
case class Book(id: Int, name: String)
case class AuthorBook(authorId: Int, bookId: Int)

object Hello extends App {

  class Authors(tag: Tag) extends Table[Author](tag, "author") {
    def id = column[Int]("id", O.PrimaryKey)
    def name = column[String]("name")
    def * = (id, name) <> ((Author.apply _).tupled, Author.unapply)
  }
  val authors = TableQuery[Authors]

  class Books(tag: Tag) extends Table[Book](tag, "book") {
    def id = column[Int]("id", O.PrimaryKey)
    def name = column[String]("name")
    def * = (id, name) <> ((Book.apply _).tupled, Book.unapply)
  }
  val books = TableQuery[Books]

  class AuthorBooks(tag: Tag) extends Table[AuthorBook](tag, "author_book") {
    def authorId = column[Int]("author_id")
    def bookId = column[Int]("book_id")
    def authorFk = foreignKey("author_fk", authorId, authors)(_.id, onUpdate=ForeignKeyAction.Cascade, onDelete=ForeignKeyAction.Cascade)
    def bookFk = foreignKey("book_fk", bookId, books)(_.id, onUpdate=ForeignKeyAction.Cascade, onDelete=ForeignKeyAction.Cascade)
    def pk = primaryKey("pk", (authorId, bookId))
    def * = (authorId, bookId) <> ((AuthorBook.apply _).tupled, AuthorBook.unapply)
  }
  val authorBooks = TableQuery[AuthorBooks]

  // Create a connection to our in-memory database
  val db = Database.forConfig("h2mem1")

  val stephenKing = Author(1, "Stephen King")
  val jkRowling = Author(2, "J. K. Rowling")
  val jrrTolkien = Author(3, "J. R. R. Tolkien")
  val danBrown = Author(4, "Dan Brown")

  val theShining = Book(1, "The Shining")
  val harryPotter = Book(2, "Harry Potter")
  val theLordOfTheRings = Book(3, "Lord of the Rings")
  val theDaVinciCode = Book(4, "The Da Vinci Code")
  val fictitiousBook1 = Book(5, "Ficticious Book 1")
  val fictitiousBook2 = Book(6, "Ficticious Book 2")

  try {
    val setup = DBIO.seq(
      // Create the tables
      (authors.schema ++ books.schema ++ authorBooks.schema).create,

      // Insert some authors
      authors += stephenKing,
      authors += jkRowling,
      authors += jrrTolkien,
      authors += danBrown,

      // Insert some books
      books += theShining,
      books += harryPotter,
      books += theLordOfTheRings,
      books += theDaVinciCode,
      books += fictitiousBook1,
      books += fictitiousBook2,

      // Create normalized many to many relationships
      authorBooks += AuthorBook(stephenKing.id, theShining.id),
      authorBooks += AuthorBook(jkRowling.id, harryPotter.id),
      authorBooks += AuthorBook(jrrTolkien.id, theLordOfTheRings.id),
      authorBooks += AuthorBook(danBrown.id, theDaVinciCode.id),
      authorBooks += AuthorBook(jkRowling.id, fictitiousBook1.id),
      authorBooks += AuthorBook(jrrTolkien.id, fictitiousBook1.id),
      authorBooks += AuthorBook(stephenKing.id, fictitiousBook2.id),
      authorBooks += AuthorBook(danBrown.id, fictitiousBook2.id),
    )

    val setupFuture = db.run(setup)

    val futureResult = setupFuture.flatMap { _ =>
      // Read all authors and print them to the console
      println("Authors:")
      db.run(authors.result).map(_.foreach(println))
    }.flatMap { _ =>
      // Read all books and print them to the console
      println("Books:")
      db.run(books.result).map(_.foreach(println))
    }.flatMap { _ =>
      // Read all normalized relationships
      println("AuthorBooks:")
      db.run(authorBooks.result).map(_.foreach(println))
    }.flatMap { _ =>
      // Read all books by Stephen King
      println("Stephen Kings Books:")
      val booksJoinedToAuthorBooks = books join authorBooks on (_.id === _.bookId)
      val booksFilteredToStephenKing = booksJoinedToAuthorBooks.filter(_._2.authorId === stephenKing.id)
      db.run(booksFilteredToStephenKing.result).map(_.map(_._1).foreach(println))
    }.flatMap { _ =>
      // Read all books by Stephen King
      println("Fictitious Book 2's Authors:")
      val authorsJoinedToAuthorBooks = authors join authorBooks on (_.id === _.authorId)
      val authorsFilteredToFictitiousBook2 = authorsJoinedToAuthorBooks.filter(_._2.bookId === fictitiousBook2.id)
      db.run(authorsFilteredToFictitiousBook2.result).map(_.map(_._1).foreach(println))
    }

    Await.result(futureResult, Duration.Inf)
  } finally db.close

}

Even if you are not a Slick expert I hope this post gives you a taste of how Slick can be used with normalized data models.

As always, feel free to ping me with any questions or feedback you might have for the post.

Thanks!