ZIO SQL

ZIO SQLは「Type-safe, composable SQL for ZIO applications」として開発された、ZIOアプリケーション向けの型安全で合成可能なSQLライブラリです。通常のScalaで型安全、型推論対応、合成可能なSQLクエリを記述できるように設計されており、永続化に関するバグを事前に防ぎ、IDEを活用してSQLの記述を生産的、安全、楽しいものにします。関数型プログラミングの原則とZIOエコシステムとの深い統合により、企業レベルのScalaアプリケーション開発に新しい選択肢を提供します。

ScalaZIOFunctional ProgrammingType-safeSQLCompile-time

GitHub概要

zio-archive/zio-sql

Type-safe, composable SQL for ZIO applications

スター239
ウォッチ11
フォーク116
作成日:2019年11月30日
言語:Scala
ライセンス:Apache License 2.0

トピックス

scalasqlzio

スター履歴

zio-archive/zio-sql Star History
データ取得日時: 2025/7/17 07:01

ライブラリ

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())
}