ZIO SQL
ZIO SQLは「Type-safe, composable SQL for ZIO applications」として開発された、ZIOアプリケーション向けの型安全で合成可能なSQLライブラリです。通常のScalaで型安全、型推論対応、合成可能なSQLクエリを記述できるように設計されており、永続化に関するバグを事前に防ぎ、IDEを活用してSQLの記述を生産的、安全、楽しいものにします。関数型プログラミングの原則とZIOエコシステムとの深い統合により、企業レベルのScalaアプリケーション開発に新しい選択肢を提供します。
GitHub概要
zio-archive/zio-sql
Type-safe, composable SQL for ZIO applications
トピックス
スター履歴
ライブラリ
ZIO SQL
概要
ZIO SQLは「Type-safe, composable SQL for ZIO applications」として開発された、ZIOアプリケーション向けの型安全で合成可能なSQLライブラリです。通常のScalaで型安全、型推論対応、合成可能なSQLクエリを記述できるように設計されており、永続化に関するバグを事前に防ぎ、IDEを活用してSQLの記述を生産的、安全、楽しいものにします。関数型プログラミングの原則とZIOエコシステムとの深い統合により、企業レベルのScalaアプリケーション開発に新しい選択肢を提供します。
詳細
ZIO SQL 2025年版は、構造による型安全性、最大限の分散性と低階の型による優れた型推論、マクロやプラグインを必要としない価値ベースの設計により、Scala 2.xとScala 3の両方で動作します。PostgreSQL、MySQL、MSSQL Server、Oracleの4つのデータベースをサポートし、特にPostgreSQLが最も機能完備です。reified lenses、反変交差型、クエリ内nullabilityを活用してエンドユーザーの人間工学を改善し、Scalaコレクションを模倣するのではなくSQLに類似した名前を使用する意図的な設計を採用しています。
主な特徴
- 型安全性: 構造による型安全で、多くの種類のバグをコンパイル時に検出
- 合成性: 全てのZIO SQLコンポーネントは通常の値で、合理的な方法で変換・合成可能
- 型推論: 最大限の分散性と低階の型による優れた型推論機能
- 魔法なし: マクロやプラグインが不要で、全てが値として機能
- ZIO統合: ZIOエコシステムとの完全な統合とシームレスな連携
- SQL類似構文: SQLらしい構文でありながらScalaの型システムの恩恵を享受
メリット・デメリット
メリット
- コンパイル時クエリ検証により大部分の問題を早期発見
- ZIOエコシステムとの豊富な統合による高い開発効率
- 関数型で純粋な設計による予測可能性と保守性の向上
- SlickやDoobieに比べて改善された型安全性と人間工学
- 依存関係が最小限でZIOのみに依存するシンプル設計
- SQLに近い構文でありながら完全なScala型システムの活用
デメリット
- Scala/ZIOエコシステム専用でマルチ言語対応なし
- パス依存型への依存による学習曲線の存在
- 比較的新しいライブラリで成熟度はSlickやDoobieに劣る場合がある
- データベースサポートの範囲が限定的(4データベースのみ)
- 複雑な動的クエリ構築には制約がある
- 関数型プログラミングとZIOの理解が必要
参考ページ
書き方の例
セットアップ
// build.sbt
ThisBuild / scalaVersion := "2.13.12"
libraryDependencies ++= Seq(
"dev.zio" %% "zio" % "2.1.18",
"dev.zio" %% "zio-sql-postgres" % "0.1.2", // PostgreSQL
"dev.zio" %% "zio-sql-mysql" % "0.1.2", // MySQL
"dev.zio" %% "zio-sql-oracle" % "0.1.2", // Oracle
"dev.zio" %% "zio-sql-sqlserver" % "0.1.2", // SQL Server
// テスト依存関係
"dev.zio" %% "zio-test" % "2.1.18" % Test,
"dev.zio" %% "zio-test-sbt" % "2.1.18" % Test,
// JDBC ドライバー
"org.postgresql" % "postgresql" % "42.7.3"
)
testFrameworks += new TestFramework("zio.test.sbt.ZTestFramework")
// project/plugins.sbt
addSbtPlugin("io.github.davidgregory084" % "sbt-tpolecat" % "0.4.4")
addSbtPlugin("ch.epfl.scala" % "sbt-scalafix" % "0.12.1")
基本的な使い方
import zio._
import zio.sql.postgresql._
import java.util.UUID
import java.time.LocalDateTime
// テーブル定義
object Schema {
// Users テーブル
final case class Users(id: UUID, username: String, email: String, createdAt: LocalDateTime)
val users = defineTable[Users]("users")
val (userId, userName, userEmail, userCreatedAt) = users.columns
// Posts テーブル
final case class Posts(id: UUID, title: String, content: String, authorId: UUID, published: Boolean)
val posts = defineTable[Posts]("posts")
val (postId, postTitle, postContent, postAuthorId, postPublished) = posts.columns
}
object DatabaseExample extends ZIOAppDefault {
import Schema._
// データベース接続設定
private val connectionPoolConfig = ZConnectionPoolConfig.default
private val dataSourceLayer = ZLayer.scoped {
ConnectionPool.dataSourceScoped
}
private val connectionPoolLayer = dataSourceLayer >>> ConnectionPool.live(connectionPoolConfig)
def run: ZIO[Any, Any, Any] = {
val program = for {
_ <- Console.printLine("ZIO SQL サンプル開始")
// ユーザー作成
userId <- createUser("john_doe", "[email protected]")
_ <- Console.printLine(s"作成されたユーザーID: $userId")
// ユーザー検索
user <- getUserByUsername("john_doe")
_ <- Console.printLine(s"検索されたユーザー: $user")
// 投稿作成
postId <- createPost("My First Post", "This is my first post content.", userId)
_ <- Console.printLine(s"作成された投稿ID: $postId")
// 投稿一覧取得
posts <- getPostsByAuthor(userId)
_ <- Console.printLine(s"著者の投稿数: ${posts.length}")
} yield ()
program.provide(connectionPoolLayer)
.foldZIO(
error => Console.printLineError(s"エラー: $error") *> ZIO.succeed(ExitCode.failure),
_ => Console.printLine("プログラム完了") *> ZIO.succeed(ExitCode.success)
)
}
// ユーザー作成
def createUser(username: String, email: String): ZIO[ConnectionPool, Throwable, UUID] = {
val newUserId = UUID.randomUUID()
val insertQuery = insertInto(users)(userId, userName, userEmail, userCreatedAt)
.values((newUserId, username, email, LocalDateTime.now()))
for {
_ <- execute(insertQuery)
} yield newUserId
}
// ユーザー名での検索
def getUserByUsername(username: String): ZIO[ConnectionPool, Throwable, Option[Users]] = {
val selectQuery = select(userId, userName, userEmail, userCreatedAt)
.from(users)
.where(userName === username)
execute(selectQuery.to((Users.apply _).tupled)).map(_.headOption)
}
// 投稿作成
def createPost(title: String, content: String, authorId: UUID): ZIO[ConnectionPool, Throwable, UUID] = {
val newPostId = UUID.randomUUID()
val insertQuery = insertInto(posts)(postId, postTitle, postContent, postAuthorId, postPublished)
.values((newPostId, title, content, authorId, true))
for {
_ <- execute(insertQuery)
} yield newPostId
}
// 著者による投稿検索
def getPostsByAuthor(authorId: UUID): ZIO[ConnectionPool, Throwable, List[Posts]] = {
val selectQuery = select(postId, postTitle, postContent, postAuthorId, postPublished)
.from(posts)
.where(postAuthorId === authorId)
.orderBy(Ordering.Desc(postId))
execute(selectQuery.to((Posts.apply _).tupled))
}
}
高度なクエリとJOIN操作
import zio._
import zio.sql.postgresql._
object AdvancedQueries {
import Schema._
// ユーザーと投稿数のJOIN
final case class UserWithPostCount(
userId: UUID,
username: String,
email: String,
postCount: Long
)
def getUsersWithPostCount: ZIO[ConnectionPool, Throwable, List[UserWithPostCount]] = {
val query = select(userId, userName, userEmail, Count(postId))
.from(users.leftOuter(posts).on(userId === postAuthorId))
.groupBy(userId, userName, userEmail)
.orderBy(Ordering.Desc(Count(postId)))
execute(query.to((UserWithPostCount.apply _).tupled))
}
// 投稿と著者情報のJOIN
final case class PostWithAuthor(
postId: UUID,
title: String,
content: String,
authorName: String,
authorEmail: String
)
def getPostsWithAuthor: ZIO[ConnectionPool, Throwable, List[PostWithAuthor]] = {
val query = select(postId, postTitle, postContent, userName, userEmail)
.from(posts.join(users).on(postAuthorId === userId))
.where(postPublished === true)
.orderBy(Ordering.Desc(postId))
execute(query.to((PostWithAuthor.apply _).tupled))
}
// 複雑な条件検索
def searchPosts(
titleKeyword: Option[String],
authorKeyword: Option[String],
published: Option[Boolean]
): ZIO[ConnectionPool, Throwable, List[PostWithAuthor]] = {
val baseQuery = select(postId, postTitle, postContent, userName, userEmail)
.from(posts.join(users).on(postAuthorId === userId))
val queryWithConditions = (titleKeyword, authorKeyword, published) match {
case (Some(title), Some(author), Some(pub)) =>
baseQuery.where(
(postTitle like s"%$title%") &&
(userName like s"%$author%") &&
(postPublished === pub)
)
case (Some(title), Some(author), None) =>
baseQuery.where(
(postTitle like s"%$title%") &&
(userName like s"%$author%")
)
case (Some(title), None, Some(pub)) =>
baseQuery.where(
(postTitle like s"%$title%") &&
(postPublished === pub)
)
case (None, Some(author), Some(pub)) =>
baseQuery.where(
(userName like s"%$author%") &&
(postPublished === pub)
)
case (Some(title), None, None) =>
baseQuery.where(postTitle like s"%$title%")
case (None, Some(author), None) =>
baseQuery.where(userName like s"%$author%")
case (None, None, Some(pub)) =>
baseQuery.where(postPublished === pub)
case (None, None, None) =>
baseQuery
}
execute(queryWithConditions.orderBy(Ordering.Desc(postId)).to((PostWithAuthor.apply _).tupled))
}
// 集約クエリ
final case class PostStatistics(
totalPosts: Long,
publishedPosts: Long,
draftPosts: Long,
averageContentLength: Option[Double]
)
def getPostStatistics: ZIO[ConnectionPool, Throwable, PostStatistics] = {
val query = select(
Count(postId),
Count(postId).filter(postPublished === true),
Count(postId).filter(postPublished === false),
Avg(Length(postContent))
).from(posts)
execute(query.to((PostStatistics.apply _).tupled)).map(_.head)
}
// サブクエリ
def getUsersWithRecentPosts(days: Int): ZIO[ConnectionPool, Throwable, List[Users]] = {
val recentDate = LocalDateTime.now().minusDays(days.toLong)
val subquery = select(postAuthorId)
.from(posts)
.where(userCreatedAt > recentDate)
.distinct
val query = select(userId, userName, userEmail, userCreatedAt)
.from(users)
.where(userId in subquery)
.orderBy(Ordering.Desc(userCreatedAt))
execute(query.to((Users.apply _).tupled))
}
}
トランザクションとエラーハンドリング
import zio._
import zio.sql.postgresql._
object TransactionExamples {
import Schema._
// トランザクション内でのユーザーと投稿の同時作成
def createUserWithPost(
username: String,
email: String,
postTitle: String,
postContent: String
): ZIO[ConnectionPool, Throwable, (UUID, UUID)] = {
val transaction = for {
newUserId <- ZIO.succeed(UUID.randomUUID())
newPostId <- ZIO.succeed(UUID.randomUUID())
// ユーザー作成
_ <- execute(
insertInto(users)(userId, userName, userEmail, userCreatedAt)
.values((newUserId, username, email, LocalDateTime.now()))
)
// 投稿作成
_ <- execute(
insertInto(posts)(postId, postTitle, postContent, postAuthorId, postPublished)
.values((newPostId, postTitle, postContent, newUserId, false))
)
} yield (newUserId, newPostId)
transaction.atomically
}
// カスタムエラー型
sealed trait DatabaseError extends Throwable
case class UserNotFound(username: String) extends DatabaseError {
override def getMessage: String = s"User not found: $username"
}
case class DuplicateUser(username: String) extends DatabaseError {
override def getMessage: String = s"User already exists: $username"
}
case class InvalidInput(message: String) extends DatabaseError {
override def getMessage: String = s"Invalid input: $message"
}
// エラーハンドリング付きユーザー作成
def createUserSafely(
username: String,
email: String
): ZIO[ConnectionPool, DatabaseError, UUID] = {
// 入力検証
val validation = for {
_ <- ZIO.when(username.trim.isEmpty)(ZIO.fail(InvalidInput("Username cannot be empty")))
_ <- ZIO.when(email.trim.isEmpty)(ZIO.fail(InvalidInput("Email cannot be empty")))
_ <- ZIO.when(!email.contains("@"))(ZIO.fail(InvalidInput("Invalid email format")))
} yield ()
val createUser = for {
_ <- validation
// 既存ユーザーチェック
existingUser <- getUserByUsername(username)
_ <- ZIO.when(existingUser.isDefined)(ZIO.fail(DuplicateUser(username)))
// ユーザー作成
newUserId <- createUser(username, email)
} yield newUserId
createUser.mapError {
case e: DatabaseError => e
case _: java.sql.SQLException => InvalidInput("Database constraint violation")
case other => InvalidInput(s"Unexpected error: ${other.getMessage}")
}
}
// リトライ機能付きクエリ実行
def executeWithRetry[R, A](
query: ZIO[R, Throwable, A],
maxRetries: Int = 3,
delay: Duration = 1.second
): ZIO[R, Throwable, A] = {
query.retry(
Schedule.exponential(delay) && Schedule.recurs(maxRetries)
).tapError { error =>
Console.printLineError(s"Query failed after $maxRetries retries: ${error.getMessage}")
}
}
// バッチ操作
def batchUpdatePosts(
postIds: List[UUID],
published: Boolean
): ZIO[ConnectionPool, Throwable, Long] = {
val updates = postIds.map { id =>
update(posts)
.set(postPublished, published)
.where(postId === id)
}
val batchTransaction = ZIO.foreach(updates)(execute).map(_.sum)
batchTransaction.atomically
}
// 接続プール監視
def monitorConnectionPool: ZIO[ConnectionPool, Nothing, Unit] = {
val monitoring = for {
_ <- Console.printLine("Connection pool monitoring started")
_ <- ZIO.never // 実際の監視ロジックに置き換え
} yield ()
monitoring.orDie
}
}
object DatabaseService {
import Schema._
import TransactionExamples._
// サービス層の実装例
trait UserService {
def createUser(username: String, email: String): ZIO[Any, DatabaseError, UUID]
def getUser(username: String): ZIO[Any, DatabaseError, Users]
def getAllUsers: ZIO[Any, DatabaseError, List[Users]]
}
case class UserServiceLive() extends UserService {
def createUser(username: String, email: String): ZIO[Any, DatabaseError, UUID] =
createUserSafely(username, email).provide(ZLayer.succeed(ConnectionPool))
def getUser(username: String): ZIO[Any, DatabaseError, Users] =
getUserByUsername(username)
.provide(ZLayer.succeed(ConnectionPool))
.flatMap {
case Some(user) => ZIO.succeed(user)
case None => ZIO.fail(UserNotFound(username))
}
.mapError {
case e: DatabaseError => e
case other => InvalidInput(other.getMessage)
}
def getAllUsers: ZIO[Any, DatabaseError, List[Users]] = {
val query = select(userId, userName, userEmail, userCreatedAt)
.from(users)
.orderBy(Ordering.Desc(userCreatedAt))
execute(query.to((Users.apply _).tupled))
.provide(ZLayer.succeed(ConnectionPool))
.mapError(error => InvalidInput(error.getMessage))
}
}
// サービス層のZLayer
val userServiceLayer: ZLayer[ConnectionPool, Nothing, UserService] =
ZLayer.succeed(UserServiceLive())
}