ScalikeJDBC

ScalikeJDBCは「Scala開発者向けの洗練されたSQL中心のデータベースアクセスライブラリ」として設計された、JDBCを自然にラップして使いやすいAPIを提供するScala専用のデータベースライブラリです。「JDBCは堅実な標準インターフェースであり、ほとんどのRDBMSで広くサポートされているため、データベースアクセスは一貫したものである」という理念の下、Type-safeなQueryDSLと文字列補間を活用したSQLクエリにより、Scalaの型システムと関数型プログラミングの利点を最大限に活用した堅牢で表現力豊かなデータベース操作を実現します。

ScalaDatabaseJDBCSQLORMType-safeQueryDSL

GitHub概要

scalikejdbc/scalikejdbc

A tidy SQL-based DB access library for Scala developers. This library naturally wraps JDBC APIs and provides you easy-to-use APIs.

スター1,272
ウォッチ57
フォーク231
作成日:2011年11月18日
言語:Scala
ライセンス:Apache License 2.0

トピックス

databaseh2jdbcmysqlpostgresqlscala

スター履歴

scalikejdbc/scalikejdbc Star History
データ取得日時: 2025/7/17 07:00

ライブラリ

ScalikeJDBC

概要

ScalikeJDBCは「Scala開発者向けの洗練されたSQL中心のデータベースアクセスライブラリ」として設計された、JDBCを自然にラップして使いやすいAPIを提供するScala専用のデータベースライブラリです。「JDBCは堅実な標準インターフェースであり、ほとんどのRDBMSで広くサポートされているため、データベースアクセスは一貫したものである」という理念の下、Type-safeなQueryDSLと文字列補間を活用したSQLクエリにより、Scalaの型システムと関数型プログラミングの利点を最大限に活用した堅牢で表現力豊かなデータベース操作を実現します。

詳細

ScalikeJDBC 2025年版は、Scala 3とScala 2.13の最新機能(Union Types、Opaque Types、Given/Using等)に完全対応し、より型安全で関数型プログラミングに特化したデータベースアクセス層を提供します。JDBC標準に準拠することで、Oracle、SQL Server、PostgreSQL、MySQL、SQLite等のあらゆるRDBMSで一貫したAPIを使用可能。QueryDSLによる型安全なクエリ構築、SQL補間による直感的なクエリ記述、アノテーション不要のシンプルな設計により、エンタープライズ環境での高品質Scalaアプリケーション開発を強力にサポート。さらに、scalikejdbc-ormによるリレーション管理とN+1問題の解決、非同期サポート(ScalikeJDBC-Async)により、現代的なリアクティブアプリケーション開発にも対応しています。

主な特徴

  • JDBC標準準拠: あらゆるJDBC対応データベースとの一貫した接続
  • Type-safe QueryDSL: Scalaの型システムを活用した安全なクエリ構築
  • SQL補間: 直感的でタイプセーフなSQL記述
  • アノテーション不要: シンプルな設計で学習コストを最小化
  • 本番対応: 実際のプロジェクトで安心して使える安定性
  • ORM機能: scalikejdbc-ormによる高度なオブジェクトマッピング

メリット・デメリット

メリット

  • JDBC標準により幅広いデータベースで一貫したAPIを使用可能
  • Scalaの型システムと関数型プログラミングの利点を活用
  • SQLの知識を活かしつつ型安全性の恩恵を受けられる
  • アノテーション不要でシンプルな設計により学習コストが低い
  • 本番環境での実績が豊富で信頼性が高い
  • scalikejdbc-ormによるN+1問題の解決とリレーション管理

デメリット

  • Scala専用でクロスプラットフォーム開発には使用不可
  • 他のScalaライブラリ(Doobie、Quill等)に比べてモダンな機能が限定的
  • 非同期処理は別ライブラリ(ScalikeJDBC-Async)が必要でアルファ版段階
  • NoSQLデータベースには対応していない
  • HibernateやActive Recordのような高度なORM機能は標準では提供されない
  • 大規模なScalaプロジェクトでは設定管理が複雑になる可能性

参考ページ

書き方の例

セットアップ

// build.sbt
ThisBuild / scalaVersion := "2.13.12"

libraryDependencies ++= Seq(
  // ScalikeJDBC core
  "org.scalikejdbc" %% "scalikejdbc" % "4.3.0",
  "org.scalikejdbc" %% "scalikejdbc-config" % "4.3.0",
  "org.scalikejdbc" %% "scalikejdbc-streams" % "4.3.0",
  
  // ORM機能
  "org.scalikejdbc" %% "scalikejdbc-orm" % "4.3.0",
  
  // データベースドライバー
  "org.postgresql" % "postgresql" % "42.7.1",
  "mysql" % "mysql-connector-java" % "8.0.33",
  "com.h2database" % "h2" % "2.2.224",
  
  // ログ出力
  "ch.qos.logback" % "logback-classic" % "1.4.14",
  
  // テスト
  "org.scalikejdbc" %% "scalikejdbc-test" % "4.3.0" % Test,
  "org.scalatest" %% "scalatest" % "3.2.17" % Test
)
// src/main/resources/application.conf
db {
  default {
    driver = "org.postgresql.Driver"
    url = "jdbc:postgresql://localhost:5432/myapp"
    user = "myuser"
    password = "mypassword"
    
    # Connection pool settings
    poolInitialSize = 10
    poolMaxSize = 20
    poolConnectionTimeoutMillis = 1000
    poolValidationQuery = "select 1 as one"
    poolFactoryName = "commons-dbcp2"
  }
  
  test {
    driver = "org.h2.Driver"
    url = "jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"
    user = "sa"
    password = ""
  }
}
// src/main/scala/config/DBConfig.scala
package config

import scalikejdbc._
import scalikejdbc.config._

object DBConfig {
  def initialize(): Unit = {
    // 設定ファイルからの自動設定
    DBs.setupAll()
    
    // グローバル設定
    GlobalSettings.loggingSQLErrors = true
    GlobalSettings.loggingSQLAndTime = LoggingSQLAndTimeSettings(
      enabled = true,
      singleLineMode = false,
      printUnprocessedStackTrace = false,
      stackTraceDepth = 15,
      logLevel = Symbol("info"),
      warningEnabled = true,
      warningThresholdMillis = 3000L,
      warningLogLevel = Symbol("warn")
    )
  }
  
  def close(): Unit = {
    DBs.closeAll()
  }
}

基本的なCRUD操作

// src/main/scala/models/User.scala
package models

import scalikejdbc._
import java.time.{LocalDateTime, ZonedDateTime}
import java.util.UUID

case class User(
  id: Option[Long] = None,
  username: String,
  email: String,
  hashedPassword: String,
  isActive: Boolean = true,
  createdAt: LocalDateTime = LocalDateTime.now(),
  updatedAt: Option[LocalDateTime] = None
)

object User extends SQLSyntaxSupport[User] {
  override val tableName = "users"
  override val columns = Seq("id", "username", "email", "hashed_password", "is_active", "created_at", "updated_at")
  
  // カラムエイリアス
  val u = User.syntax("u")
  
  // ResultSetからUserオブジェクトへの変換
  def apply(rs: WrappedResultSet): User = User(
    id = rs.longOpt("id"),
    username = rs.string("username"),
    email = rs.string("email"),
    hashedPassword = rs.string("hashed_password"),
    isActive = rs.boolean("is_active"),
    createdAt = rs.localDateTime("created_at"),
    updatedAt = rs.localDateTimeOpt("updated_at")
  )
  
  def apply(u: SyntaxProvider[User])(rs: WrappedResultSet): User = apply(rs)
  
  // CRUD操作
  def create(username: String, email: String, hashedPassword: String)(implicit session: DBSession = autoSession): User = {
    val now = LocalDateTime.now()
    val id = withSQL {
      insert.into(User).namedValues(
        column.username -> username,
        column.email -> email,
        column.hashedPassword -> hashedPassword,
        column.isActive -> true,
        column.createdAt -> now
      )
    }.updateAndReturnGeneratedKey.apply()
    
    User(
      id = Some(id),
      username = username,
      email = email,
      hashedPassword = hashedPassword,
      isActive = true,
      createdAt = now
    )
  }
  
  def findById(id: Long)(implicit session: DBSession = readOnlySession): Option[User] = {
    withSQL {
      select.from(User as u).where.eq(u.id, id)
    }.map(User(u)).single.apply()
  }
  
  def findByUsername(username: String)(implicit session: DBSession = readOnlySession): Option[User] = {
    withSQL {
      select.from(User as u).where.eq(u.username, username)
    }.map(User(u)).single.apply()
  }
  
  def findByEmail(email: String)(implicit session: DBSession = readOnlySession): Option[User] = {
    withSQL {
      select.from(User as u).where.eq(u.email, email)
    }.map(User(u)).single.apply()
  }
  
  def findAll(limit: Int = 100, offset: Int = 0)(implicit session: DBSession = readOnlySession): List[User] = {
    withSQL {
      select.from(User as u)
        .orderBy(u.createdAt.desc)
        .limit(limit).offset(offset)
    }.map(User(u)).list.apply()
  }
  
  def findActiveUsers()(implicit session: DBSession = readOnlySession): List[User] = {
    withSQL {
      select.from(User as u)
        .where.eq(u.isActive, true)
        .orderBy(u.username.asc)
    }.map(User(u)).list.apply()
  }
  
  def update(user: User)(implicit session: DBSession = autoSession): Int = {
    withSQL {
      scalikejdbc.update(User).set(
        column.username -> user.username,
        column.email -> user.email,
        column.hashedPassword -> user.hashedPassword,
        column.isActive -> user.isActive,
        column.updatedAt -> LocalDateTime.now()
      ).where.eq(column.id, user.id)
    }.update.apply()
  }
  
  def delete(id: Long)(implicit session: DBSession = autoSession): Int = {
    withSQL {
      deleteFrom(User).where.eq(column.id, id)
    }.update.apply()
  }
  
  def deactivate(id: Long)(implicit session: DBSession = autoSession): Int = {
    withSQL {
      scalikejdbc.update(User).set(
        column.isActive -> false,
        column.updatedAt -> LocalDateTime.now()
      ).where.eq(column.id, id)
    }.update.apply()
  }
  
  // 検索機能
  def search(query: String, limit: Int = 20)(implicit session: DBSession = readOnlySession): List[User] = {
    val pattern = s"%$query%"
    withSQL {
      select.from(User as u)
        .where(
          sqls.like(u.username, pattern)
            .or.like(u.email, pattern)
        )
        .and.eq(u.isActive, true)
        .orderBy(u.username.asc)
        .limit(limit)
    }.map(User(u)).list.apply()
  }
  
  // 統計情報
  def countActiveUsers()(implicit session: DBSession = readOnlySession): Long = {
    withSQL {
      select(sqls.count).from(User as u).where.eq(u.isActive, true)
    }.map(_.long(1)).single.apply().getOrElse(0L)
  }
}

関連エンティティとJOIN操作

// src/main/scala/models/Post.scala
package models

import scalikejdbc._
import java.time.LocalDateTime

case class Post(
  id: Option[Long] = None,
  userId: Long,
  title: String,
  content: String,
  published: Boolean = false,
  createdAt: LocalDateTime = LocalDateTime.now(),
  updatedAt: Option[LocalDateTime] = None
)

object Post extends SQLSyntaxSupport[Post] {
  override val tableName = "posts"
  override val columns = Seq("id", "user_id", "title", "content", "published", "created_at", "updated_at")
  
  val p = Post.syntax("p")
  
  def apply(rs: WrappedResultSet): Post = Post(
    id = rs.longOpt("id"),
    userId = rs.long("user_id"),
    title = rs.string("title"),
    content = rs.string("content"),
    published = rs.boolean("published"),
    createdAt = rs.localDateTime("created_at"),
    updatedAt = rs.localDateTimeOpt("updated_at")
  )
  
  def apply(p: SyntaxProvider[Post])(rs: WrappedResultSet): Post = apply(rs)
  
  // 関連オブジェクトを含む結合データ
  case class PostWithUser(post: Post, user: User)
  
  def create(userId: Long, title: String, content: String, published: Boolean = false)(implicit session: DBSession = autoSession): Post = {
    val now = LocalDateTime.now()
    val id = withSQL {
      insert.into(Post).namedValues(
        column.userId -> userId,
        column.title -> title,
        column.content -> content,
        column.published -> published,
        column.createdAt -> now
      )
    }.updateAndReturnGeneratedKey.apply()
    
    Post(
      id = Some(id),
      userId = userId,
      title = title,
      content = content,
      published = published,
      createdAt = now
    )
  }
  
  def findById(id: Long)(implicit session: DBSession = readOnlySession): Option[Post] = {
    withSQL {
      select.from(Post as p).where.eq(p.id, id)
    }.map(Post(p)).single.apply()
  }
  
  def findByUserId(userId: Long, limit: Int = 50)(implicit session: DBSession = readOnlySession): List[Post] = {
    withSQL {
      select.from(Post as p)
        .where.eq(p.userId, userId)
        .orderBy(p.createdAt.desc)
        .limit(limit)
    }.map(Post(p)).list.apply()
  }
  
  def findPublishedPosts(limit: Int = 20, offset: Int = 0)(implicit session: DBSession = readOnlySession): List[Post] = {
    withSQL {
      select.from(Post as p)
        .where.eq(p.published, true)
        .orderBy(p.createdAt.desc)
        .limit(limit).offset(offset)
    }.map(Post(p)).list.apply()
  }
  
  // JOINクエリ:投稿とユーザー情報の結合
  def findPostsWithUsers(limit: Int = 20)(implicit session: DBSession = readOnlySession): List[PostWithUser] = {
    withSQL {
      select
        .from(Post as p)
        .innerJoin(User as User.u).on(p.userId, User.u.id)
        .where.eq(p.published, true)
        .and.eq(User.u.isActive, true)
        .orderBy(p.createdAt.desc)
        .limit(limit)
    }.map { rs =>
      PostWithUser(
        post = Post(p)(rs),
        user = User(User.u)(rs)
      )
    }.list.apply()
  }
  
  // 検索機能(コンテンツ全体検索)
  def searchPosts(query: String, publishedOnly: Boolean = true, limit: Int = 20)(implicit session: DBSession = readOnlySession): List[PostWithUser] = {
    val pattern = s"%$query%"
    withSQL {
      select
        .from(Post as p)
        .innerJoin(User as User.u).on(p.userId, User.u.id)
        .where(
          sqls.like(p.title, pattern)
            .or.like(p.content, pattern)
        )
        .and(if (publishedOnly) sqls.eq(p.published, true) else sqls.empty)
        .and.eq(User.u.isActive, true)
        .orderBy(p.createdAt.desc)
        .limit(limit)
    }.map { rs =>
      PostWithUser(
        post = Post(p)(rs),
        user = User(User.u)(rs)
      )
    }.list.apply()
  }
  
  def update(post: Post)(implicit session: DBSession = autoSession): Int = {
    withSQL {
      scalikejdbc.update(Post).set(
        column.title -> post.title,
        column.content -> post.content,
        column.published -> post.published,
        column.updatedAt -> LocalDateTime.now()
      ).where.eq(column.id, post.id)
    }.update.apply()
  }
  
  def publish(id: Long)(implicit session: DBSession = autoSession): Int = {
    withSQL {
      scalikejdbc.update(Post).set(
        column.published -> true,
        column.updatedAt -> LocalDateTime.now()
      ).where.eq(column.id, id)
    }.update.apply()
  }
  
  def delete(id: Long)(implicit session: DBSession = autoSession): Int = {
    withSQL {
      deleteFrom(Post).where.eq(column.id, id)
    }.update.apply()
  }
}

集約クエリと統計情報

// src/main/scala/models/Analytics.scala
package models

import scalikejdbc._

object Analytics {
  case class UserPostCount(user: User, postCount: Long)
  case class PostStats(totalPosts: Long, publishedPosts: Long, unpublishedPosts: Long)
  case class MonthlyPostCount(year: Int, month: Int, count: Long)
  
  // ユーザー別投稿数の取得
  def getUserPostCounts(limit: Int = 50)(implicit session: DBSession = readOnlySession): List[UserPostCount] = {
    withSQL {
      select(
        User.u.result.*,
        sqls.count(Post.p.id).as("post_count")
      )
      .from(User as User.u)
      .leftJoin(Post as Post.p).on(User.u.id, Post.p.userId)
      .where.eq(User.u.isActive, true)
      .groupBy(User.u.id)
      .orderBy(sqls"post_count".desc)
      .limit(limit)
    }.map { rs =>
      UserPostCount(
        user = User(User.u)(rs),
        postCount = rs.long("post_count")
      )
    }.list.apply()
  }
  
  // 投稿統計情報
  def getPostStats()(implicit session: DBSession = readOnlySession): PostStats = {
    val totalPosts = withSQL {
      select(sqls.count).from(Post as Post.p)
    }.map(_.long(1)).single.apply().getOrElse(0L)
    
    val publishedPosts = withSQL {
      select(sqls.count).from(Post as Post.p).where.eq(Post.p.published, true)
    }.map(_.long(1)).single.apply().getOrElse(0L)
    
    PostStats(
      totalPosts = totalPosts,
      publishedPosts = publishedPosts,
      unpublishedPosts = totalPosts - publishedPosts
    )
  }
  
  // 月別投稿数
  def getMonthlyPostCounts(months: Int = 12)(implicit session: DBSession = readOnlySession): List[MonthlyPostCount] = {
    withSQL {
      select(
        sqls"EXTRACT(YEAR FROM created_at)".as("year"),
        sqls"EXTRACT(MONTH FROM created_at)".as("month"),
        sqls.count(Post.p.id).as("post_count")
      )
      .from(Post as Post.p)
      .where.ge(Post.p.createdAt, sqls"NOW() - INTERVAL '$months months'")
      .groupBy(sqls"EXTRACT(YEAR FROM created_at)", sqls"EXTRACT(MONTH FROM created_at)")
      .orderBy(sqls"year".desc, sqls"month".desc)
    }.map { rs =>
      MonthlyPostCount(
        year = rs.int("year"),
        month = rs.int("month"),
        count = rs.long("post_count")
      )
    }.list.apply()
  }
  
  // 活発なユーザーの取得(最近の投稿が多い)
  def getActiveUsers(days: Int = 30, limit: Int = 10)(implicit session: DBSession = readOnlySession): List[UserPostCount] = {
    withSQL {
      select(
        User.u.result.*,
        sqls.count(Post.p.id).as("recent_post_count")
      )
      .from(User as User.u)
      .innerJoin(Post as Post.p).on(User.u.id, Post.p.userId)
      .where.ge(Post.p.createdAt, sqls"NOW() - INTERVAL '$days days'")
      .and.eq(User.u.isActive, true)
      .groupBy(User.u.id)
      .having(sqls.gt(sqls.count(Post.p.id), 0))
      .orderBy(sqls"recent_post_count".desc)
      .limit(limit)
    }.map { rs =>
      UserPostCount(
        user = User(User.u)(rs),
        postCount = rs.long("recent_post_count")
      )
    }.list.apply()
  }
}

トランザクション処理

// src/main/scala/services/UserService.scala
package services

import scalikejdbc._
import models.{User, Post}
import java.time.LocalDateTime

class UserService {
  
  // トランザクション内でのユーザー作成と初期投稿
  def createUserWithWelcomePost(
    username: String,
    email: String,
    hashedPassword: String
  ): Either[String, (User, Post)] = {
    
    DB localTx { implicit session =>
      try {
        // ユーザーの重複チェック
        User.findByUsername(username) match {
          case Some(_) => return Left(s"Username '$username' already exists")
          case None =>
        }
        
        User.findByEmail(email) match {
          case Some(_) => return Left(s"Email '$email' already exists")
          case None =>
        }
        
        // ユーザー作成
        val user = User.create(username, email, hashedPassword)
        
        // ウェルカム投稿作成
        val welcomePost = Post.create(
          userId = user.id.get,
          title = s"Welcome ${user.username}!",
          content = "Thank you for joining our platform. This is your first post!",
          published = true
        )
        
        Right((user, welcomePost))
        
      } catch {
        case ex: Exception =>
          session.rollback()
          Left(s"Failed to create user: ${ex.getMessage}")
      }
    }
  }
  
  // 一括ユーザー操作
  def batchUpdateUserStatus(userIds: List[Long], isActive: Boolean): Either[String, Int] = {
    DB localTx { implicit session =>
      try {
        val updatedCount = userIds.map { userId =>
          if (isActive) {
            User.update(
              User.findById(userId).getOrElse(return Left(s"User $userId not found"))
                .copy(isActive = true, updatedAt = Some(LocalDateTime.now()))
            )
          } else {
            User.deactivate(userId)
          }
        }.sum
        
        Right(updatedCount)
        
      } catch {
        case ex: Exception =>
          session.rollback()
          Left(s"Batch update failed: ${ex.getMessage}")
      }
    }
  }
  
  // 複雑なビジネスロジック:ユーザー削除と投稿アーカイブ
  def deactivateUserAndArchivePosts(userId: Long): Either[String, (User, Int)] = {
    DB localTx { implicit session =>
      try {
        val user = User.findById(userId).getOrElse(return Left("User not found"))
        
        if (!user.isActive) {
          return Left("User is already inactive")
        }
        
        // ユーザーの全投稿を非公開にする
        val archivedPostsCount = withSQL {
          scalikejdbc.update(Post).set(
            Post.column.published -> false,
            Post.column.updatedAt -> LocalDateTime.now()
          ).where.eq(Post.column.userId, userId)
        }.update.apply()
        
        // ユーザーを非アクティブにする
        User.deactivate(userId)
        val updatedUser = User.findById(userId).get
        
        Right((updatedUser, archivedPostsCount))
        
      } catch {
        case ex: Exception =>
          session.rollback()
          Left(s"User deactivation failed: ${ex.getMessage}")
      }
    }
  }
}

テスト実装

// src/test/scala/models/UserSpec.scala
package models

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
import scalikejdbc._
import scalikejdbc.scalatest.AutoRollback

class UserSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach with AutoRollback {
  
  override def db(): DB = NamedDB("test").toDB()
  
  override def beforeEach(): Unit = {
    super.beforeEach()
    // テストデータのセットアップ
    DB localTx { implicit session =>
      sql"""
        CREATE TABLE IF NOT EXISTS users (
          id BIGINT AUTO_INCREMENT PRIMARY KEY,
          username VARCHAR(255) NOT NULL UNIQUE,
          email VARCHAR(255) NOT NULL UNIQUE,
          hashed_password VARCHAR(255) NOT NULL,
          is_active BOOLEAN DEFAULT TRUE,
          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
          updated_at TIMESTAMP NULL
        )
      """.execute.apply()
      
      sql"""
        CREATE TABLE IF NOT EXISTS posts (
          id BIGINT AUTO_INCREMENT PRIMARY KEY,
          user_id BIGINT NOT NULL,
          title VARCHAR(255) NOT NULL,
          content TEXT NOT NULL,
          published BOOLEAN DEFAULT FALSE,
          created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
          updated_at TIMESTAMP NULL,
          FOREIGN KEY (user_id) REFERENCES users(id)
        )
      """.execute.apply()
    }
  }
  
  "User.create" should "create a new user successfully" in { implicit session =>
    val username = "testuser"
    val email = "[email protected]"
    val password = "hashedpassword123"
    
    val user = User.create(username, email, password)
    
    user.id should not be None
    user.username should be(username)
    user.email should be(email)
    user.hashedPassword should be(password)
    user.isActive should be(true)
    user.createdAt should not be null
  }
  
  "User.findById" should "return user when exists" in { implicit session =>
    val user = User.create("findtest", "[email protected]", "password")
    val foundUser = User.findById(user.id.get)
    
    foundUser should not be None
    foundUser.get.username should be("findtest")
    foundUser.get.email should be("[email protected]")
  }
  
  it should "return None when user does not exist" in { implicit session =>
    val foundUser = User.findById(999999L)
    foundUser should be(None)
  }
  
  "User.findByUsername" should "return user when username exists" in { implicit session =>
    User.create("uniqueuser", "[email protected]", "password")
    val foundUser = User.findByUsername("uniqueuser")
    
    foundUser should not be None
    foundUser.get.username should be("uniqueuser")
  }
  
  "User.update" should "update user information correctly" in { implicit session =>
    val user = User.create("updatetest", "[email protected]", "password")
    val updatedUser = user.copy(
      username = "updatedname",
      email = "[email protected]"
    )
    
    val updateCount = User.update(updatedUser)
    updateCount should be(1)
    
    val retrieved = User.findById(user.id.get).get
    retrieved.username should be("updatedname")
    retrieved.email should be("[email protected]")
    retrieved.updatedAt should not be None
  }
  
  "User.search" should "find users by username and email" in { implicit session =>
    User.create("alice", "[email protected]", "password")
    User.create("bob", "[email protected]", "password")
    User.create("charlie", "[email protected]", "password")
    
    val searchResults = User.search("alice")
    searchResults should have length 1
    searchResults.head.username should be("alice")
    
    val emailSearchResults = User.search("example.com")
    emailSearchResults should have length 2
  }
  
  "User.countActiveUsers" should "return correct count" in { implicit session =>
    User.create("active1", "[email protected]", "password")
    User.create("active2", "[email protected]", "password")
    val inactiveUser = User.create("inactive", "[email protected]", "password")
    User.deactivate(inactiveUser.id.get)
    
    val activeCount = User.countActiveUsers()
    activeCount should be(2)
  }
}

// src/test/scala/services/UserServiceSpec.scala
package services

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers
import org.scalatest.BeforeAndAfterEach
import scalikejdbc._
import scalikejdbc.scalatest.AutoRollback
import models.{User, Post}

class UserServiceSpec extends AnyFlatSpec with Matchers with BeforeAndAfterEach with AutoRollback {
  
  override def db(): DB = NamedDB("test").toDB()
  val userService = new UserService()
  
  override def beforeEach(): Unit = {
    super.beforeEach()
    // テーブル作成(Userテストと同様)
  }
  
  "UserService.createUserWithWelcomePost" should "create user and welcome post in transaction" in { implicit session =>
    val result = userService.createUserWithWelcomePost(
      "newuser", 
      "[email protected]", 
      "hashedpassword"
    )
    
    result should be a 'right
    val (user, post) = result.right.get
    
    user.username should be("newuser")
    user.email should be("[email protected]")
    
    post.userId should be(user.id.get)
    post.title should include("Welcome newuser!")
    post.published should be(true)
  }
  
  it should "fail when username already exists" in { implicit session =>
    User.create("existinguser", "[email protected]", "password")
    
    val result = userService.createUserWithWelcomePost(
      "existinguser", 
      "[email protected]", 
      "password"
    )
    
    result should be a 'left
    result.left.get should include("Username 'existinguser' already exists")
  }
  
  "UserService.batchUpdateUserStatus" should "update multiple users" in { implicit session =>
    val user1 = User.create("batch1", "[email protected]", "password")
    val user2 = User.create("batch2", "[email protected]", "password")
    val user3 = User.create("batch3", "[email protected]", "password")
    
    User.deactivate(user1.id.get)
    User.deactivate(user2.id.get)
    
    val result = userService.batchUpdateUserStatus(
      List(user1.id.get, user2.id.get),
      isActive = true
    )
    
    result should be a 'right
    result.right.get should be(2)
    
    User.findById(user1.id.get).get.isActive should be(true)
    User.findById(user2.id.get).get.isActive should be(true)
    User.findById(user3.id.get).get.isActive should be(true)
  }
}