Database Programming: JDBC, Slick, and Doobie
Database programming is a fundamental aspect of most Scala applications. Scala offers several excellent libraries for database interaction, each with different approaches and trade-offs. In this lesson, we'll explore three main approaches: JDBC for direct SQL access, Slick for functional relational mapping, and Doobie for purely functional database programming.
Understanding Database Programming Approaches
JDBC (Java Database Connectivity)
- Direct SQL execution
- Low-level, imperative style
- Maximum flexibility and control
- Requires manual resource management
Slick (Scala Language-Integrated Connection Kit)
- Type-safe database queries
- Functional relational mapping (FRM)
- Scala collections-like API
- Compile-time query validation
Doobie
- Purely functional database programming
- Composable queries
- Effect-typed operations
- Excellent integration with Cats Effect
JDBC: Direct Database Access
JDBC provides the foundation for database programming in the JVM ecosystem. While low-level, it offers maximum control and flexibility.
Setting Up JDBC
// build.sbt
libraryDependencies ++= Seq(
"com.h2database" % "h2" % "2.2.224", // H2 database for testing
"org.postgresql" % "postgresql" % "42.6.0", // PostgreSQL driver
"com.mysql" % "mysql-connector-j" % "8.1.0", // MySQL driver
"com.zaxxer" % "HikariCP" % "5.0.1" // Connection pooling
)
Basic JDBC Operations
import java.sql.{Connection, DriverManager, PreparedStatement, ResultSet}
import scala.util.{Try, Using}
case class User(id: Long, name: String, email: String, age: Int)
class UserRepository {
private val url = "jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1"
private val username = "sa"
private val password = ""
// Initialize database
def initDatabase(): Unit = {
Using(DriverManager.getConnection(url, username, password)) { conn =>
val stmt = conn.createStatement()
stmt.execute("""
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
age INT NOT NULL
)
""")
}.get
}
def createUser(user: User): Try[Long] = {
Using(DriverManager.getConnection(url, username, password)) { conn =>
val sql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)"
val stmt = conn.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)
stmt.setString(1, user.name)
stmt.setString(2, user.email)
stmt.setInt(3, user.age)
stmt.executeUpdate()
val keys = stmt.getGeneratedKeys
if (keys.next()) keys.getLong(1)
else throw new RuntimeException("Failed to get generated ID")
}
}
def findUserById(id: Long): Try[Option[User]] = {
Using(DriverManager.getConnection(url, username, password)) { conn =>
val sql = "SELECT id, name, email, age FROM users WHERE id = ?"
val stmt = conn.prepareStatement(sql)
stmt.setLong(1, id)
val rs = stmt.executeQuery()
if (rs.next()) {
Some(User(
id = rs.getLong("id"),
name = rs.getString("name"),
email = rs.getString("email"),
age = rs.getInt("age")
))
} else None
}
}
}
Connection Pooling with HikariCP
import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
import java.sql.Connection
import scala.util.Using
class PooledUserRepository {
private val config = new HikariConfig()
config.setJdbcUrl("jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1")
config.setUsername("sa")
config.setPassword("")
config.setMaximumPoolSize(10)
config.setMinimumIdle(2)
config.setConnectionTimeout(30000)
config.setIdleTimeout(600000)
config.setMaxLifetime(1800000)
private val dataSource = new HikariDataSource(config)
def getConnection: Connection = dataSource.getConnection
def createUser(user: User): Try[Long] = {
Using(getConnection) { conn =>
val sql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)"
val stmt = conn.prepareStatement(sql, PreparedStatement.RETURN_GENERATED_KEYS)
stmt.setString(1, user.name)
stmt.setString(2, user.email)
stmt.setInt(3, user.age)
stmt.executeUpdate()
val keys = stmt.getGeneratedKeys
if (keys.next()) keys.getLong(1)
else throw new RuntimeException("Failed to get generated ID")
}
}
def close(): Unit = dataSource.close()
}
Slick: Functional Relational Mapping
Slick provides a type-safe, functional approach to database programming with a Scala collections-like API.
Setting Up Slick
// build.sbt
libraryDependencies ++= Seq(
"com.typesafe.slick" %% "slick" % "3.4.1",
"com.typesafe.slick" %% "slick-hikaricp" % "3.4.1",
"com.h2database" % "h2" % "2.2.224",
"org.slf4j" % "slf4j-nop" % "1.7.36"
)
Basic Slick Setup
import slick.jdbc.H2Profile.api._
import slick.jdbc.meta.MTable
import scala.concurrent.{Future, Await}
import scala.concurrent.duration._
import scala.concurrent.ExecutionContext.Implicits.global
case class User(id: Long, name: String, email: String, age: Int)
// Define the Users table
class Users(tag: Tag) extends Table[User](tag, "USERS") {
def id = column[Long]("ID", O.PrimaryKey, O.AutoInc)
def name = column[String]("NAME")
def email = column[String]("EMAIL", O.Unique)
def age = column[Int]("AGE")
def * = (id, name, email, age) <> (User.tupled, User.unapply)
}
// Table query
val users = TableQuery[Users]
class SlickUserRepository {
private val db = Database.forConfig("h2mem")
def initDatabase(): Future[Unit] = {
db.run(users.schema.createIfNotExists)
}
def createUser(name: String, email: String, age: Int): Future[Long] = {
val insertQuery = users.map(u => (u.name, u.email, u.age)) returning users.map(_.id)
db.run(insertQuery += (name, email, age))
}
def findUserById(id: Long): Future[Option[User]] = {
db.run(users.filter(_.id === id).result.headOption)
}
def findUsersByAge(minAge: Int): Future[Seq[User]] = {
db.run(users.filter(_.age >= minAge).sortBy(_.name).result)
}
def updateUser(user: User): Future[Int] = {
db.run(users.filter(_.id === user.id).update(user))
}
def deleteUser(id: Long): Future[Int] = {
db.run(users.filter(_.id === id).delete)
}
def close(): Unit = db.close()
}
Advanced Slick Queries
// Complex queries with joins
case class Post(id: Long, userId: Long, title: String, content: String)
class Posts(tag: Tag) extends Table[Post](tag, "POSTS") {
def id = column[Long]("ID", O.PrimaryKey, O.AutoInc)
def userId = column[Long]("USER_ID")
def title = column[String]("TITLE")
def content = column[String]("CONTENT")
def * = (id, userId, title, content) <> (Post.tupled, Post.unapply)
// Foreign key
def user = foreignKey("USER_FK", userId, users)(_.id)
}
val posts = TableQuery[Posts]
class BlogRepository {
private val db = Database.forURL(
"jdbc:h2:mem:blogdb;DB_CLOSE_DELAY=-1",
driver = "org.h2.Driver"
)
// Join query: get users with their posts
def getUsersWithPosts(): Future[Seq[(User, Seq[Post])]] = {
val query = for {
user <- users
post <- posts if post.userId === user.id
} yield (user, post)
db.run(query.result).map { results =>
results.groupBy(_._1).map { case (user, userPosts) =>
(user, userPosts.map(_._2))
}.toSeq
}
}
// Left join: get all users and their posts (including users without posts)
def getAllUsersWithOptionalPosts(): Future[Seq[(User, Option[Post])]] = {
val query = users.joinLeft(posts).on(_.id === _.userId)
db.run(query.result)
}
// Aggregation query
def getPostCountByUser(): Future[Seq[(String, Int)]] = {
val query = users
.join(posts).on(_.id === _.userId)
.groupBy(_._1.name)
.map { case (name, group) => (name, group.length) }
db.run(query.result)
}
}
Doobie: Purely Functional Database Programming
Doobie provides a purely functional approach to database programming with excellent composability and type safety.
Setting Up Doobie
// build.sbt
libraryDependencies ++= Seq(
"org.tpolecat" %% "doobie-core" % "1.0.0-RC4",
"org.tpolecat" %% "doobie-h2" % "1.0.0-RC4",
"org.tpolecat" %% "doobie-hikari" % "1.0.0-RC4",
"org.tpolecat" %% "doobie-scalatest" % "1.0.0-RC4" % Test,
"org.typelevel" %% "cats-effect" % "3.5.2"
)
Basic Doobie Setup
import doobie._
import doobie.implicits._
import doobie.h2.H2Transactor
import cats.effect._
import cats.implicits._
case class User(id: Long, name: String, email: String, age: Int)
class DoobieUserRepository[F[_]: Async] {
// Create transactor
val transactor: Resource[F, Transactor[F]] =
H2Transactor.newH2Transactor[F](
"jdbc:h2:mem:doobiedb;DB_CLOSE_DELAY=-1",
"sa",
"",
ExecutionContexts.synchronous // Use appropriate EC in real apps
)
// SQL queries
def initDatabase(): ConnectionIO[Unit] = {
sql"""
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) NOT NULL UNIQUE,
age INT NOT NULL
)
""".update.run.void
}
def insertUser(name: String, email: String, age: Int): ConnectionIO[Long] = {
sql"INSERT INTO users (name, email, age) VALUES ($name, $email, $age)"
.update
.withUniqueGeneratedKeys[Long]("id")
}
def findById(id: Long): ConnectionIO[Option[User]] = {
sql"SELECT id, name, email, age FROM users WHERE id = $id"
.query[User]
.option
}
def findByEmail(email: String): ConnectionIO[Option[User]] = {
sql"SELECT id, name, email, age FROM users WHERE email = $email"
.query[User]
.option
}
def findByAgeRange(minAge: Int, maxAge: Int): ConnectionIO[List[User]] = {
sql"SELECT id, name, email, age FROM users WHERE age BETWEEN $minAge AND $maxAge ORDER BY name"
.query[User]
.to[List]
}
def updateUser(user: User): ConnectionIO[Int] = {
sql"UPDATE users SET name = ${user.name}, email = ${user.email}, age = ${user.age} WHERE id = ${user.id}"
.update
.run
}
def deleteUser(id: Long): ConnectionIO[Int] = {
sql"DELETE FROM users WHERE id = $id"
.update
.run
}
// Batch operations
def insertUsers(users: List[(String, String, Int)]): ConnectionIO[Int] = {
val sql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)"
Update[(String, String, Int)](sql).updateMany(users)
}
// Streaming large result sets
def streamAllUsers(): fs2.Stream[ConnectionIO, User] = {
sql"SELECT id, name, email, age FROM users"
.query[User]
.stream
}
}
Advanced Doobie Features
Custom Data Types and Mappings
import java.time.LocalDateTime
import java.util.UUID
case class User(
id: UUID,
name: String,
email: String,
createdAt: LocalDateTime,
status: UserStatus
)
sealed trait UserStatus
case object Active extends UserStatus
case object Inactive extends UserStatus
case object Suspended extends UserStatus
// Custom Meta instance for UserStatus
implicit val userStatusMeta: Meta[UserStatus] =
Meta[String].imap {
case "active" => Active
case "inactive" => Inactive
case "suspended" => Suspended
case other => throw new RuntimeException(s"Unknown status: $other")
} {
case Active => "active"
case Inactive => "inactive"
case Suspended => "suspended"
}
class AdvancedDoobieRepository[F[_]: Async] {
def createUser(name: String, email: String, status: UserStatus): ConnectionIO[UUID] = {
val id = UUID.randomUUID()
val now = LocalDateTime.now()
sql"""
INSERT INTO users (id, name, email, created_at, status)
VALUES ($id, $name, $email, $now, $status)
""".update.run.as(id)
}
def findActiveUsers(): ConnectionIO[List[User]] = {
sql"""
SELECT id, name, email, created_at, status
FROM users
WHERE status = ${Active: UserStatus}
ORDER BY created_at DESC
""".query[User].to[List]
}
def updateUserStatus(id: UUID, status: UserStatus): ConnectionIO[Boolean] = {
sql"UPDATE users SET status = $status WHERE id = $id"
.update
.run
.map(_ > 0)
}
}
Composable Queries and Fragments
object UserQueries {
// Base query fragment
private val baseSelect: Fragment =
sql"SELECT id, name, email, age FROM users"
// Conditional fragments
def byAge(age: Int): Fragment = sql"age = $age"
def byAgeRange(min: Int, max: Int): Fragment = sql"age BETWEEN $min AND $max"
def byNameLike(pattern: String): Fragment = sql"name LIKE $pattern"
def byEmail(email: String): Fragment = sql"email = $email"
// Composable query builder
def findUsers(
ageFilter: Option[Int] = None,
ageRange: Option[(Int, Int)] = None,
namePattern: Option[String] = None,
emailFilter: Option[String] = None,
limit: Option[Int] = None
): ConnectionIO[List[User]] = {
val filters = List(
ageFilter.map(byAge),
ageRange.map { case (min, max) => byAgeRange(min, max) },
namePattern.map(byNameLike),
emailFilter.map(byEmail)
).flatten
val whereClause = filters match {
case Nil => Fragment.empty
case head :: tail => tail.foldLeft(sql" WHERE " ++ head) { (acc, filter) =>
acc ++ sql" AND " ++ filter
}
}
val limitClause = limit.fold(Fragment.empty)(l => sql" LIMIT $l")
val orderClause = sql" ORDER BY name"
val query = baseSelect ++ whereClause ++ orderClause ++ limitClause
query.query[User].to[List]
}
}
Testing with Doobie
import doobie.scalatest._
import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
class DoobieRepositorySpec extends AnyFlatSpec with Matchers with IOChecker {
val transactor = H2Transactor.newH2Transactor[IO](
"jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1",
"sa",
"",
ExecutionContexts.synchronous
).allocated.unsafeRunSync()._1
val repository = new DoobieUserRepository[IO]
// Initialize test database
repository.initDatabase().transact(transactor).unsafeRunSync()
"UserRepository queries" should "be well-typed" in {
check(repository.insertUser("test", "test@example.com", 25))
check(repository.findById(1L))
check(repository.findByEmail("test@example.com"))
check(repository.findByAgeRange(20, 30))
check(repository.updateUser(User(1L, "test", "test@example.com", 25)))
check(repository.deleteUser(1L))
}
"insertUser" should "create a new user" in {
val program = for {
id <- repository.insertUser("John Doe", "john@example.com", 30)
user <- repository.findById(id)
} yield (id, user)
val (id, user) = program.transact(transactor).unsafeRunSync()
id should be > 0L
user shouldBe defined
user.get.name shouldBe "John Doe"
user.get.email shouldBe "john@example.com"
user.get.age shouldBe 30
}
}
Database Migrations and Schema Management
Flyway Integration
// build.sbt
libraryDependencies += "org.flywaydb" % "flyway-core" % "9.22.3"
import org.flywaydb.core.Flyway
class DatabaseMigration {
def migrate(jdbcUrl: String, username: String, password: String): Unit = {
val flyway = Flyway.configure()
.dataSource(jdbcUrl, username, password)
.locations("classpath:db/migration")
.load()
flyway.migrate()
}
}
// Migration files in src/main/resources/db/migration/
// V1__Create_users_table.sql
// V2__Add_posts_table.sql
// V3__Add_user_status_column.sql
Best Practices and Performance Tips
Connection Pooling
// Hikari configuration for production
val config = new HikariConfig()
config.setJdbcUrl("jdbc:postgresql://localhost:5432/mydb")
config.setUsername("user")
config.setPassword("password")
config.setMaximumPoolSize(20)
config.setMinimumIdle(5)
config.setConnectionTimeout(30000)
config.setIdleTimeout(600000)
config.setMaxLifetime(1800000)
config.setLeakDetectionThreshold(60000)
val dataSource = new HikariDataSource(config)
Prepared Statement Reuse
// Good: Use prepared statements
def findUsersByAge(age: Int): ConnectionIO[List[User]] = {
sql"SELECT * FROM users WHERE age >= $age".query[User].to[List]
}
// Avoid: String concatenation (SQL injection risk)
def findUsersByAgeUnsafe(age: Int): ConnectionIO[List[User]] = {
Fragment.const(s"SELECT * FROM users WHERE age >= $age").query[User].to[List]
}
Batch Operations
// Efficient batch inserts
def insertManyUsers(users: List[(String, String, Int)]): ConnectionIO[Int] = {
val sql = "INSERT INTO users (name, email, age) VALUES (?, ?, ?)"
Update[(String, String, Int)](sql).updateMany(users)
}
// Streaming for large datasets
def processLargeDataset(): fs2.Stream[ConnectionIO, Unit] = {
sql"SELECT id, name, email, age FROM users"
.query[User]
.stream
.chunkN(1000)
.map(processChunk)
}
Error Handling
def findUserSafely(id: Long): ConnectionIO[Either[String, User]] = {
findById(id)
.map(_.toRight(s"User with id $id not found"))
.attemptSql
.map(_.leftMap(_.getMessage))
}
def transactionalOperation(): ConnectionIO[Unit] = {
(for {
userId <- insertUser("John", "john@example.com", 30)
_ <- insertPost(userId, "My First Post", "Hello World!")
_ <- updateUserLastLogin(userId)
} yield ()).attemptSql.flatMap {
case Right(result) => result.pure[ConnectionIO]
case Left(error) =>
// Log error and potentially retry
MonadError[ConnectionIO, Throwable].raiseError(error)
}
}
Conclusion
Database programming in Scala offers multiple approaches, each with its strengths:
- JDBC: Maximum control and flexibility, suitable for complex SQL and performance-critical applications
- Slick: Type-safe queries with a functional approach, excellent for domain-driven designs
- Doobie: Purely functional database programming with excellent composability and effect management
Choose the approach that best fits your project's needs:
- Use JDBC for maximum control and when working with existing SQL-heavy codebases
- Use Slick for type-safe, object-relational mapping with functional programming benefits
- Use Doobie for purely functional applications with Cats Effect and when composability is important
All three approaches can coexist in the same project, allowing you to use the best tool for each specific use case.
Comments
Be the first to comment on this lesson!