Quill
Quillは「Scalaのための型安全なデータベースアクセスライブラリ」として設計された、コンパイル時にSQLを生成するマクロベースのデータベースアクセス層です。「Compile-time Language Integrated Queries」の概念に基づき、Scalaの型システムとマクロ機能を活用して、実行時ではなくコンパイル時にクエリの妥当性をチェックし、型安全なSQLコードを生成します。様々なデータベース(PostgreSQL、MySQL、SQLite、H2、Cassandra、MongoDB)をサポートし、同期・非同期両方の実行モデルを提供することで、現代的なScalaアプリケーションでの高性能データアクセスを実現します。
GitHub概要
zio/zio-quill
Compile-time Language Integrated Queries for Scala
トピックス
スター履歴
ライブラリ
Quill
概要
Quillは「Scalaのための型安全なデータベースアクセスライブラリ」として設計された、コンパイル時にSQLを生成するマクロベースのデータベースアクセス層です。「Compile-time Language Integrated Queries」の概念に基づき、Scalaの型システムとマクロ機能を活用して、実行時ではなくコンパイル時にクエリの妥当性をチェックし、型安全なSQLコードを生成します。様々なデータベース(PostgreSQL、MySQL、SQLite、H2、Cassandra、MongoDB)をサポートし、同期・非同期両方の実行モデルを提供することで、現代的なScalaアプリケーションでの高性能データアクセスを実現します。
詳細
Quill 2025年版は、Scala 3の最新機能を完全活用し、より直感的で型安全なデータベースプログラミング体験を提供します。マクロによるコンパイル時SQL生成により、実行時のクエリ構築オーバーヘッドを完全に排除し、優れたパフォーマンスを実現しています。Akka Streams、ZIO、cats-effectとの統合により、リアクティブアプリケーション開発を強力にサポートします。また、Context Functionsを活用した依存性注入やGiven Instances(旧Implicit)による型クラスベースの拡張により、関数型プログラミングのベストプラクティスと自然に統合されています。
主な特徴
- コンパイル時SQL生成: マクロによる実行時オーバーヘッドゼロのクエリ
- 型安全性: Scalaの型システムを最大限活用した安全なクエリ
- マルチデータベース: リレーショナル・NoSQLデータベースの統一API
- 非同期サポート: Future、ZIO、cats-effectとの完全統合
- DSL: 直感的で表現力豊かなクエリ記述言語
- ゼロ実行時依存性: 生成されたコードは純粋なJDBC/ドライバー呼び出し
メリット・デメリット
メリット
- 実行時クエリ生成のオーバーヘッドが一切ない最高のパフォーマンス
- Scalaの型システムによる完全な型安全性とコンパイル時エラー検出
- 直感的でScalaらしいDSLによる自然なクエリ記述
- 複数データベースエンジンを同一APIで操作可能
- 関数型プログラミングパラダイムとの完全統合
- 軽量でランタイム依存性が最小限
デメリット
- Scalaマクロの理解が必要で学習コストが高い
- コンパイル時間が増加し、大規模プロジェクトでビルドが遅くなる可能性
- 動的クエリの構築が困難で、柔軟性に制限
- IDEサポートが限定的でデバッグが困難な場合がある
- 複雑なクエリでマクロ展開エラーが発生する可能性
- 日本語ドキュメントやコミュニティリソースが限定的
参考ページ
書き方の例
セットアップ
// build.sbt
val quillVersion = "4.8.0"
libraryDependencies ++= Seq(
"io.getquill" %% "quill-jdbc" % quillVersion,
"io.getquill" %% "quill-jdbc-zio" % quillVersion,
"io.getquill" %% "quill-cassandra" % quillVersion,
"io.getquill" %% "quill-cassandra-zio" % quillVersion,
// データベースドライバー
"org.postgresql" % "postgresql" % "42.7.1",
"mysql" % "mysql-connector-java" % "8.0.33",
"com.h2database" % "h2" % "2.2.224",
// 接続プール(オプション)
"com.zaxxer" % "HikariCP" % "5.1.0",
// ZIO(非同期処理用)
"dev.zio" %% "zio" % "2.0.21",
"dev.zio" %% "zio-streams" % "2.0.21"
)
// application.conf
quill {
dataSourceClassName = "org.postgresql.ds.PGSimpleDataSource"
dataSource {
user = "postgres"
password = "password"
databaseName = "mydb"
portNumber = 5432
serverName = "localhost"
}
connectionTimeout = 30000
maximumPoolSize = 10
}
基本的な使用方法
import io.getquill._
import java.time.LocalDateTime
// データベースコンテキスト設定
lazy val ctx = new PostgresJdbcContext(SnakeCase, "quill")
import ctx._
// ケースクラス定義
case class User(
id: Option[Long],
name: String,
email: String,
age: Int,
isActive: Boolean,
createdAt: LocalDateTime
)
case class Post(
id: Option[Long],
title: String,
content: String,
userId: Long,
published: Boolean,
createdAt: LocalDateTime
)
// テーブル定義とマッピング
implicit val userSchemaMeta = schemaMeta[User]("users")
implicit val postSchemaMeta = schemaMeta[Post]("posts")
// 基本CRUD操作
object UserRepository {
// ユーザー作成
def createUser(name: String, email: String, age: Int): Long = {
val user = User(None, name, email, age, isActive = true, LocalDateTime.now())
ctx.run(quote {
query[User].insertValue(lift(user)).returningGenerated(_.id)
}).get
}
// ユーザー取得
def getUserById(id: Long): Option[User] = {
ctx.run(quote {
query[User].filter(_.id.contains(lift(id)))
}).headOption
}
// 全ユーザー取得
def getAllUsers: List[User] = {
ctx.run(quote {
query[User].sortBy(_.createdAt)(Ord.desc)
})
}
// 名前検索
def searchUsersByName(namePattern: String): List[User] = {
ctx.run(quote {
query[User].filter(_.name.like(lift(s"%$namePattern%")))
})
}
// 年齢範囲フィルター
def getUsersByAgeRange(minAge: Int, maxAge: Int): List[User] = {
ctx.run(quote {
query[User].filter(u => u.age >= lift(minAge) && u.age <= lift(maxAge))
})
}
// ユーザー更新
def updateUser(id: Long, name: String, email: String, age: Int): Long = {
ctx.run(quote {
query[User]
.filter(_.id.contains(lift(id)))
.update(u => u.name -> lift(name), u => u.email -> lift(email), u => u.age -> lift(age))
})
}
// ユーザー削除
def deleteUser(id: Long): Long = {
ctx.run(quote {
query[User].filter(_.id.contains(lift(id))).delete
})
}
// アクティブユーザー取得
def getActiveUsers: List[User] = {
ctx.run(quote {
query[User].filter(_.isActive)
})
}
// ユーザー数カウント
def getUserCount: Long = {
ctx.run(quote {
query[User].size
})
}
// 年齢別統計
def getAgeStatistics: (Option[Int], Option[Int], Option[Double]) = {
val stats = ctx.run(quote {
query[User].map(u => (u.age, u.age, u.age))
.aggregate((min(_._1), max(_._2), avg(_._3)))
})
stats
}
}
// 使用例
def demonstrateBasicOperations(): Unit = {
// ユーザー作成
val userId1 = UserRepository.createUser("Alice Johnson", "[email protected]", 28)
val userId2 = UserRepository.createUser("Bob Smith", "[email protected]", 32)
val userId3 = UserRepository.createUser("Charlie Brown", "[email protected]", 25)
println(s"Created users with IDs: $userId1, $userId2, $userId3")
// ユーザー取得
val user = UserRepository.getUserById(userId1)
println(s"Retrieved user: $user")
// 名前検索
val aliceUsers = UserRepository.searchUsersByName("Alice")
println(s"Users with 'Alice' in name: $aliceUsers")
// 年齢範囲フィルター
val youngUsers = UserRepository.getUsersByAgeRange(25, 30)
println(s"Users aged 25-30: $youngUsers")
// 統計取得
val (minAge, maxAge, avgAge) = UserRepository.getAgeStatistics
println(s"Age stats - Min: $minAge, Max: $maxAge, Avg: $avgAge")
}
複雑なクエリとJOIN
object PostRepository {
// 投稿作成
def createPost(title: String, content: String, userId: Long, published: Boolean = false): Long = {
val post = Post(None, title, content, userId, published, LocalDateTime.now())
ctx.run(quote {
query[Post].insertValue(lift(post)).returningGenerated(_.id)
}).get
}
// ユーザーと投稿のJOIN
def getPostsWithAuthors: List[(Post, User)] = {
ctx.run(quote {
for {
post <- query[Post]
user <- query[User] if user.id.contains(post.userId)
} yield (post, user)
})
}
// 公開投稿のみ取得
def getPublishedPosts: List[Post] = {
ctx.run(quote {
query[Post].filter(_.published)
})
}
// ユーザー別投稿数
def getUserPostCounts: List[(String, Long)] = {
ctx.run(quote {
(for {
post <- query[Post]
user <- query[User] if user.id.contains(post.userId)
} yield (user.name, post.id))
.groupBy(_._1)
.map { case (userName, posts) => (userName, posts.size) }
})
}
// 人気ユーザー(投稿数順)
def getPopularUsers(limit: Int): List[(String, Long)] = {
ctx.run(quote {
(for {
post <- query[Post]
user <- query[User] if user.id.contains(post.userId)
} yield (user.name, post.id))
.groupBy(_._1)
.map { case (userName, posts) => (userName, posts.size) }
.sortBy(_._2)(Ord.desc)
.take(lift(limit))
})
}
// 条件付き検索
def searchPosts(
titlePattern: Option[String] = None,
userId: Option[Long] = None,
publishedOnly: Boolean = false,
limit: Int = 20
): List[Post] = {
ctx.run(quote {
query[Post]
.filter(p =>
lift(titlePattern).forall(pattern => p.title.like(s"%$pattern%")) &&
lift(userId).forall(uid => p.userId == uid) &&
(!lift(publishedOnly) || p.published)
)
.sortBy(_.createdAt)(Ord.desc)
.take(lift(limit))
})
}
// 最近の投稿統計
def getRecentPostStats(days: Int): (Long, Long, Long) = {
val cutoffDate = LocalDateTime.now().minusDays(days)
ctx.run(quote {
val recentPosts = query[Post].filter(_.createdAt > lift(cutoffDate))
val totalPosts = recentPosts.size
val publishedPosts = recentPosts.filter(_.published).size
val draftPosts = recentPosts.filter(!_.published).size
(totalPosts, publishedPosts, draftPosts)
})
}
// ユーザーの最新投稿
def getLatestPostByUser(userId: Long): Option[Post] = {
ctx.run(quote {
query[Post]
.filter(_.userId == lift(userId))
.sortBy(_.createdAt)(Ord.desc)
}).headOption
}
}
// 複雑なクエリの使用例
def demonstrateComplexQueries(): Unit = {
// 投稿作成
val postId1 = PostRepository.createPost("First Post", "This is my first post!", 1L, published = true)
val postId2 = PostRepository.createPost("Second Post", "Another interesting post", 1L, published = false)
val postId3 = PostRepository.createPost("Published Post", "This is published", 2L, published = true)
println(s"Created posts with IDs: $postId1, $postId2, $postId3")
// JOIN クエリ
val postsWithAuthors = PostRepository.getPostsWithAuthors
println(s"Posts with authors: $postsWithAuthors")
// ユーザー別投稿数
val userPostCounts = PostRepository.getUserPostCounts
println(s"User post counts: $userPostCounts")
// 人気ユーザー
val popularUsers = PostRepository.getPopularUsers(5)
println(s"Popular users: $popularUsers")
// 条件付き検索
val searchResults = PostRepository.searchPosts(
titlePattern = Some("Post"),
publishedOnly = true,
limit = 10
)
println(s"Search results: $searchResults")
// 最近の投稿統計
val (total, published, drafts) = PostRepository.getRecentPostStats(7)
println(s"Recent posts (7 days) - Total: $total, Published: $published, Drafts: $drafts")
}
ZIOとの統合(非同期処理)
import zio._
import io.getquill.context.ZioJdbc._
// ZIO対応のコンテキスト
lazy val zioCtx = new PostgresZioJdbcContext(SnakeCase)
import zioCtx._
object ZioUserService {
// 非同期ユーザー作成
def createUserAsync(name: String, email: String, age: Int): ZIO[Any, Throwable, Long] = {
val user = User(None, name, email, age, isActive = true, LocalDateTime.now())
zioCtx.run(quote {
query[User].insertValue(lift(user)).returningGenerated(_.id)
}).map(_.get)
}
// 非同期ユーザー取得
def getUserByIdAsync(id: Long): ZIO[Any, Throwable, Option[User]] = {
zioCtx.run(quote {
query[User].filter(_.id.contains(lift(id)))
}).map(_.headOption)
}
// バッチ処理
def batchCreateUsers(users: List[(String, String, Int)]): ZIO[Any, Throwable, List[Long]] = {
ZIO.foreach(users) { case (name, email, age) =>
createUserAsync(name, email, age)
}
}
// トランザクション処理
def transferDataBetweenUsers(fromId: Long, toId: Long): ZIO[Any, Throwable, Unit] = {
zioCtx.transaction {
for {
fromUser <- zioCtx.run(quote {
query[User].filter(_.id.contains(lift(fromId)))
}).map(_.headOption)
toUser <- zioCtx.run(quote {
query[User].filter(_.id.contains(lift(toId)))
}).map(_.headOption)
_ <- ZIO.when(fromUser.isEmpty || toUser.isEmpty)(
ZIO.fail(new RuntimeException("One or both users not found"))
)
_ <- zioCtx.run(quote {
query[Post].filter(_.userId == lift(fromId)).update(_.userId -> lift(toId))
})
} yield ()
}
}
// ストリーミング処理
def streamAllUsers: ZStream[Any, Throwable, User] = {
ZStream.fromZIO(zioCtx.run(quote {
query[User].sortBy(_.id)
})).flatMap(ZStream.fromIterable)
}
// 複雑な統計処理
def getUserEngagementStats: ZIO[Any, Throwable, UserEngagementStats] = {
for {
totalUsers <- zioCtx.run(quote { query[User].size })
totalPosts <- zioCtx.run(quote { query[Post].size })
avgPostsPerUser <- zioCtx.run(quote {
(for {
post <- query[Post]
user <- query[User] if user.id.contains(post.userId)
} yield post.id)
.groupBy(_ => 1)
.map { case (_, posts) => posts.size }
.avg
}).map(_.getOrElse(0.0))
topUserWithPostCount <- zioCtx.run(quote {
(for {
post <- query[Post]
user <- query[User] if user.id.contains(post.userId)
} yield (user.name, post.id))
.groupBy(_._1)
.map { case (userName, posts) => (userName, posts.size) }
.sortBy(_._2)(Ord.desc)
}).map(_.headOption)
} yield UserEngagementStats(
totalUsers = totalUsers,
totalPosts = totalPosts,
avgPostsPerUser = avgPostsPerUser,
topUser = topUserWithPostCount.map(_._1),
topUserPostCount = topUserWithPostCount.map(_._2).getOrElse(0L)
)
}
}
// 統計データ用ケースクラス
case class UserEngagementStats(
totalUsers: Long,
totalPosts: Long,
avgPostsPerUser: Double,
topUser: Option[String],
topUserPostCount: Long
)
// ZIO アプリケーション例
object QuillZioApp extends ZIOAppDefault {
def run: ZIO[ZIOAppArgs, Any, Any] = {
val program = for {
// バッチユーザー作成
userIds <- ZioUserService.batchCreateUsers(List(
("Emma Wilson", "[email protected]", 26),
("David Chen", "[email protected]", 31),
("Sarah Johnson", "[email protected]", 29)
))
_ <- Console.printLine(s"Created users with IDs: $userIds")
// ユーザー取得
user <- ZioUserService.getUserByIdAsync(userIds.head)
_ <- Console.printLine(s"Retrieved user: $user")
// 統計取得
stats <- ZioUserService.getUserEngagementStats
_ <- Console.printLine(s"Engagement stats: $stats")
// ユーザーストリーミング
_ <- ZioUserService.streamAllUsers
.take(5)
.foreach(user => Console.printLine(s"Streamed user: ${user.name}"))
} yield ()
program.catchAll { error =>
Console.printLineError(s"Application error: ${error.getMessage}")
}
}
}
エラーハンドリング
import scala.util.{Try, Success, Failure}
import zio._
// カスタム例外クラス
sealed trait QuillAppError extends Throwable
case class UserNotFoundError(userId: Long) extends QuillAppError {
override def getMessage: String = s"User not found: $userId"
}
case class DuplicateEmailError(email: String) extends QuillAppError {
override def getMessage: String = s"Email already exists: $email"
}
case class ValidationError(message: String) extends QuillAppError {
override def getMessage: String = s"Validation error: $message"
}
case class DatabaseError(cause: Throwable) extends QuillAppError {
override def getMessage: String = s"Database error: ${cause.getMessage}"
override def getCause: Throwable = cause
}
object SafeUserService {
// 安全なユーザー作成(同期版)
def createUserSafely(name: String, email: String, age: Int): Either[QuillAppError, User] = {
Try {
// バリデーション
if (name.trim.isEmpty) {
return Left(ValidationError("Name cannot be empty"))
}
if (!email.contains("@")) {
return Left(ValidationError("Invalid email format"))
}
if (age < 0 || age > 150) {
return Left(ValidationError("Age must be between 0 and 150"))
}
// 重複チェック
val existingUser = ctx.run(quote {
query[User].filter(_.email == lift(email))
}).headOption
if (existingUser.isDefined) {
return Left(DuplicateEmailError(email))
}
// ユーザー作成
val user = User(None, name, email, age, isActive = true, LocalDateTime.now())
val userId = ctx.run(quote {
query[User].insertValue(lift(user)).returningGenerated(_.id)
}).get
user.copy(id = Some(userId))
} match {
case Success(user) => Right(user)
case Failure(exception) => Left(DatabaseError(exception))
}
}
// 安全なユーザー取得(同期版)
def getUserSafely(id: Long): Either[QuillAppError, User] = {
Try {
ctx.run(quote {
query[User].filter(_.id.contains(lift(id)))
}).headOption
} match {
case Success(Some(user)) => Right(user)
case Success(None) => Left(UserNotFoundError(id))
case Failure(exception) => Left(DatabaseError(exception))
}
}
// 安全なユーザー更新(同期版)
def updateUserSafely(id: Long, name: String, email: String, age: Int): Either[QuillAppError, User] = {
Try {
// バリデーション
if (name.trim.isEmpty) {
return Left(ValidationError("Name cannot be empty"))
}
if (!email.contains("@")) {
return Left(ValidationError("Invalid email format"))
}
if (age < 0 || age > 150) {
return Left(ValidationError("Invalid age"))
}
// ユーザー存在チェック
val existingUser = ctx.run(quote {
query[User].filter(_.id.contains(lift(id)))
}).headOption
if (existingUser.isEmpty) {
return Left(UserNotFoundError(id))
}
// メール重複チェック(自分以外)
val emailConflict = ctx.run(quote {
query[User].filter(u => u.email == lift(email) && !u.id.contains(lift(id)))
}).headOption
if (emailConflict.isDefined) {
return Left(DuplicateEmailError(email))
}
// 更新実行
ctx.run(quote {
query[User]
.filter(_.id.contains(lift(id)))
.update(u => u.name -> lift(name), u => u.email -> lift(email), u => u.age -> lift(age))
})
// 更新されたユーザーを取得
ctx.run(quote {
query[User].filter(_.id.contains(lift(id)))
}).head
} match {
case Success(user) => Right(user)
case Failure(exception) => Left(DatabaseError(exception))
}
}
}
// ZIO版エラーハンドリング
object ZioSafeUserService {
// 安全なユーザー作成(ZIO版)
def createUserSafely(name: String, email: String, age: Int): ZIO[Any, QuillAppError, User] = {
for {
_ <- ZIO.when(name.trim.isEmpty)(ZIO.fail(ValidationError("Name cannot be empty")))
_ <- ZIO.when(!email.contains("@"))(ZIO.fail(ValidationError("Invalid email format")))
_ <- ZIO.when(age < 0 || age > 150)(ZIO.fail(ValidationError("Age must be between 0 and 150")))
existingUser <- zioCtx.run(quote {
query[User].filter(_.email == lift(email))
}).map(_.headOption).mapError(DatabaseError)
_ <- ZIO.when(existingUser.isDefined)(ZIO.fail(DuplicateEmailError(email)))
user = User(None, name, email, age, isActive = true, LocalDateTime.now())
userId <- zioCtx.run(quote {
query[User].insertValue(lift(user)).returningGenerated(_.id)
}).map(_.get).mapError(DatabaseError)
} yield user.copy(id = Some(userId))
}
// 安全なユーザー取得(ZIO版)
def getUserSafely(id: Long): ZIO[Any, QuillAppError, User] = {
zioCtx.run(quote {
query[User].filter(_.id.contains(lift(id)))
}).mapError(DatabaseError)
.flatMap(users =>
ZIO.fromOption(users.headOption).mapError(_ => UserNotFoundError(id))
)
}
// バルクオペレーション(ZIO版)
def bulkOperationSafely[T](operations: List[ZIO[Any, QuillAppError, T]]): ZIO[Any, QuillAppError, List[T]] = {
ZIO.collectAll(operations)
}
// データベース接続テスト
def testConnection: ZIO[Any, QuillAppError, String] = {
zioCtx.run(quote(sql"SELECT 1".as[Int]))
.map(_ => "Database connection successful")
.mapError(DatabaseError)
}
}
// エラーハンドリング使用例
def demonstrateErrorHandling(): Unit = {
// 同期版エラーハンドリング
SafeUserService.createUserSafely("Jane Doe", "[email protected]", 28) match {
case Right(user) =>
println(s"Successfully created user: $user")
SafeUserService.updateUserSafely(user.id.get, "Jane Smith", "[email protected]", 29) match {
case Right(updatedUser) => println(s"Successfully updated user: $updatedUser")
case Left(error) => println(s"Update failed: ${error.getMessage}")
}
case Left(error) =>
println(s"User creation failed: ${error.getMessage}")
}
// 無効なデータでのテスト
SafeUserService.createUserSafely("", "invalid-email", -5) match {
case Right(_) => println("This shouldn't happen")
case Left(error) => println(s"Correctly caught validation error: ${error.getMessage}")
}
// 存在しないユーザー取得テスト
SafeUserService.getUserSafely(999L) match {
case Right(user) => println(s"Found user: $user")
case Left(error) => println(s"User not found error: ${error.getMessage}")
}
}
// ZIO版エラーハンドリング使用例
val zioErrorHandlingExample: ZIO[Any, Nothing, Unit] = {
val program = for {
user <- ZioSafeUserService.createUserSafely("John Doe", "[email protected]", 30)
_ <- Console.printLine(s"Created user: $user")
retrievedUser <- ZioSafeUserService.getUserSafely(user.id.get)
_ <- Console.printLine(s"Retrieved user: $retrievedUser")
connectionStatus <- ZioSafeUserService.testConnection
_ <- Console.printLine(connectionStatus)
} yield ()
program.catchAll {
case ValidationError(message) => Console.printLineError(s"Validation error: $message")
case UserNotFoundError(userId) => Console.printLineError(s"User $userId not found")
case DuplicateEmailError(email) => Console.printLineError(s"Email $email already exists")
case DatabaseError(cause) => Console.printLineError(s"Database error: ${cause.getMessage}")
}
}