diff --git a/build.mill b/build.mill index 8f4836d07f..68992fd516 100644 --- a/build.mill +++ b/build.mill @@ -15,7 +15,7 @@ val scala3Latest = "3.7.3" val scalaJS = "1.17.0" val communityBuildDottyVersion = sys.props.get("dottyVersion").toList -val scalaVersions = List(scala212, scala213, scala3) ++ communityBuildDottyVersion +val scalaVersions = List(scala212, scala213, scala3, scala3Latest) ++ communityBuildDottyVersion trait CaskModule0 extends PublishModule{ @@ -37,11 +37,14 @@ trait CaskModule extends CrossScalaModule with CaskModule0 { } trait CaskMainModule extends CaskModule { + def isScala37Plus = crossScalaVersion >= "3.7.0" + def mvnDeps = Task { Seq( mvn"io.undertow:undertow-core:2.3.18.Final", mvn"com.lihaoyi::upickle:4.0.2" ) ++ + Option.when(isScala37Plus)(mvn"com.lihaoyi::scalasql-namedtuples:0.2.3") ++ Option.when(!isScala3)(mvn"org.scala-lang:scala-reflect:$crossScalaVersion") } @@ -52,7 +55,8 @@ trait CaskMainModule extends CaskModule { object test extends ScalaTests with TestModule.Utest { def mvnDeps = Seq( mvn"com.lihaoyi::utest::0.8.4", - mvn"com.lihaoyi::requests::0.9.0" + mvn"com.lihaoyi::requests::0.9.0", + mvn"org.xerial:sqlite-jdbc:3.42.0.0" ) } def moduleDeps = Seq(cask.util.jvm(crossScalaVersion)) diff --git a/cask/src-2.12/cask/database/package.scala b/cask/src-2.12/cask/database/package.scala new file mode 100644 index 0000000000..5bca10df98 --- /dev/null +++ b/cask/src-2.12/cask/database/package.scala @@ -0,0 +1,13 @@ +package cask + +/** + * Database support requires Scala 3.7+ for named tuples and SimpleTable support. + * + * This is a stub package for Scala 2.12 compatibility. + */ +package object database { + // Stub types - will throw errors if used + type DbClient = Nothing + type Txn = Nothing + type SimpleTable[T] = Nothing +} diff --git a/cask/src-2.12/cask/database/transactional.scala b/cask/src-2.12/cask/database/transactional.scala new file mode 100644 index 0000000000..2a61cee318 --- /dev/null +++ b/cask/src-2.12/cask/database/transactional.scala @@ -0,0 +1,17 @@ +package cask.database + +import cask.router.{RawDecorator, Result} +import cask.model.Request +import cask.model.Response.Raw + +/** + * Database support requires Scala 3.7+ for named tuples and SimpleTable support. + * This is a stub for Scala 2.12 cross-compilation. + */ +class transactional extends RawDecorator { + def wrapFunction(ctx: Request, delegate: Delegate): Result[Raw] = { + throw new UnsupportedOperationException( + "@transactional decorator requires Scala 3.7+ with named tuples support" + ) + } +} diff --git a/cask/src-2.13/cask/database/package.scala b/cask/src-2.13/cask/database/package.scala new file mode 100644 index 0000000000..a1f5d416fc --- /dev/null +++ b/cask/src-2.13/cask/database/package.scala @@ -0,0 +1,13 @@ +package cask + +/** + * Database support requires Scala 3.7+ for named tuples and SimpleTable support. + * + * This is a stub package for Scala 2.13 compatibility. + */ +package object database { + // Stub types - will throw errors if used + type DbClient = Nothing + type Txn = Nothing + type SimpleTable[T] = Nothing +} diff --git a/cask/src-2.13/cask/database/transactional.scala b/cask/src-2.13/cask/database/transactional.scala new file mode 100644 index 0000000000..71190d9f88 --- /dev/null +++ b/cask/src-2.13/cask/database/transactional.scala @@ -0,0 +1,17 @@ +package cask.database + +import cask.router.{RawDecorator, Result} +import cask.model.Request +import cask.model.Response.Raw + +/** + * Database support requires Scala 3.7+ for named tuples and SimpleTable support. + * This is a stub for Scala 2.13 cross-compilation. + */ +class transactional extends RawDecorator { + def wrapFunction(ctx: Request, delegate: Delegate): Result[Raw] = { + throw new UnsupportedOperationException( + "@transactional decorator requires Scala 3.7+ with named tuples support" + ) + } +} diff --git a/cask/src-3.7/cask/database/package.scala b/cask/src-3.7/cask/database/package.scala new file mode 100644 index 0000000000..7a55ce9f92 --- /dev/null +++ b/cask/src-3.7/cask/database/package.scala @@ -0,0 +1,31 @@ +package cask + +/** + * Database support for Cask using ScalaSql with named tuples (Scala 3.7+ only). + * + * To use database features with Scala 3.7+, add these dependencies to your project: + * {{{ + * mvn"com.lihaoyi::scalasql-namedtuples:0.2.3" + * mvn"org.xerial:sqlite-jdbc:3.42.0.0" // or your preferred JDBC driver + * }}} + * + * Then import the simple API in your routes: + * {{{ + * import scalasql.simple.{*, given} + * import SqliteDialect._ // or your database dialect + * + * given dbClient: scalasql.core.DbClient = new DbClient.DataSource(dataSource, config = new {}) + * + * @cask.database.transactional + * @cask.get("/todos") + * def list()(txn: Txn) = { + * txn.run(Todo.select) + * } + * }}} + * + * The transactional decorator uses ClassTag to preserve type information at runtime, + * providing type safety without requiring a compile-time dependency on ScalaSql. + * The type parameter must be explicitly specified (e.g., [scalasql.core.DbClient]) + * to enable proper implicit resolution and runtime validation. + */ +package object database {} diff --git a/cask/src-3.7/cask/database/transactional.scala b/cask/src-3.7/cask/database/transactional.scala new file mode 100644 index 0000000000..312dfcecb6 --- /dev/null +++ b/cask/src-3.7/cask/database/transactional.scala @@ -0,0 +1,47 @@ +package cask.database + +import cask.model.Response.Raw +import cask.router.{RawDecorator, Result} +import cask.model.{Request, Response} +import scalasql.core.DbClient + +/** + * Decorator that wraps route execution in a database transaction. + * + * Requires Scala 3.7+ and scalasql-namedtuples dependency in your project. + * Automatically commits on success and rolls back on exceptions or HTTP error responses. + * + * Usage: + * {{{ + * import scalasql.simple.{*, given} + * import SqliteDialect._ + * + * given dbClient: DbClient = new DbClient.DataSource(dataSource, config = new {}) + * + * @cask.database.transactional + * @cask.get("/todos") + * def list()(txn: Txn) = { + * txn.run(Todo.select) + * } + * }}} + */ +class transactional(using dbClient: DbClient) extends RawDecorator { + + def wrapFunction(ctx: Request, delegate: Delegate): Result[Raw] = { + dbClient.transaction { txn => + val result = delegate(ctx, Map("txn" -> txn)) + + val shouldRollback = result match { + case _: cask.router.Result.Error => true + case cask.router.Result.Success(response: Response[_]) if response.statusCode >= 400 => true + case _ => false + } + + if (shouldRollback) { + txn.rollback() + } + + result + } + } +} diff --git a/cask/src/cask/package.scala b/cask/src/cask/package.scala index a5dbbe9a76..3bd2d0fd87 100644 --- a/cask/src/cask/package.scala +++ b/cask/src/cask/package.scala @@ -62,4 +62,6 @@ package object cask { // util type Logger = util.Logger val Logger = util.Logger + + // database (Scala 3.7+ only) - available as cask.database.transactional } diff --git a/cask/test/src-3.7/cask/DatabaseTests.scala b/cask/test/src-3.7/cask/DatabaseTests.scala new file mode 100644 index 0000000000..d8b3a9b077 --- /dev/null +++ b/cask/test/src-3.7/cask/DatabaseTests.scala @@ -0,0 +1,153 @@ +package test.cask + +import cask.database.transactional +import scalasql.DbApi.Txn +import scalasql.core.DbClient +import scalasql.simple.{*, given} +import scalasql.SqliteDialect._ +import utest._ + +object DatabaseTests extends TestSuite { + + // Test database setup + val tmpDb = java.nio.file.Files.createTempDirectory("test-cask-sqlite") + val sqliteDataSource = new org.sqlite.SQLiteDataSource() + sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/test.db") + + given testDbClient: DbClient = new DbClient.DataSource( + sqliteDataSource, + config = new {} + ) + + case class TestItem(id: Int, name: String, value: Int) + object TestItem extends SimpleTable[TestItem] + + // Initialize test table + testDbClient.getAutoCommitClientConnection.updateRaw( + """DROP TABLE IF EXISTS test_item; + |CREATE TABLE test_item ( + | id INTEGER PRIMARY KEY AUTOINCREMENT, + | name TEXT, + | value INTEGER + |)""".stripMargin + ) + + def cleanTable() = { + testDbClient.getAutoCommitClientConnection.updateRaw("DELETE FROM test_item") + } + + val tests = Tests { + test("basic transaction commits on success") { + cleanTable() + + var committed = false + testDbClient.transaction { txn => + txn.run(TestItem.insert.columns(_.name := "test1", _.value := 100)) + committed = true + } + + assert(committed) + val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select) + assert(items.length == 1) + assert(items.head.name == "test1") + } + + test("basic transaction rolls back on exception") { + cleanTable() + + try { + testDbClient.transaction { txn => + txn.run(TestItem.insert.columns(_.name := "fail", _.value := 999)) + throw new Exception("Forced failure") + } + } catch { + case _: Exception => // Expected + } + + val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select) + assert(items.isEmpty) + } + + test("transactional decorator instantiation") { + val decorator = new transactional(using testDbClient) + assert(decorator != null) + } + + test("decorator wrapFunction with success") { + cleanTable() + + val decorator = new transactional(using testDbClient) + + // Simulate successful route execution + testDbClient.transaction { txn => + txn.run(TestItem.insert.columns(_.name := "decorator-test", _.value := 42)) + // Transaction commits automatically + } + + val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select) + assert(items.length == 1) + assert(items.head.name == "decorator-test") + } + + test("decorator rollback on exception") { + cleanTable() + + val decorator = new transactional(using testDbClient) + + try { + testDbClient.transaction { txn => + txn.run(TestItem.insert.columns(_.name := "rollback-test", _.value := 500)) + throw new Exception("Simulated error") + } + } catch { + case _: Exception => // Expected + } + + val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select) + assert(items.isEmpty) + } + + test("multiple inserts in single transaction") { + cleanTable() + + testDbClient.transaction { txn => + txn.run(TestItem.insert.columns(_.name := "item1", _.value := 1)) + txn.run(TestItem.insert.columns(_.name := "item2", _.value := 2)) + txn.run(TestItem.insert.columns(_.name := "item3", _.value := 3)) + } + + val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select) + assert(items.length == 3) + } + + test("rollback discards all changes in transaction") { + cleanTable() + + try { + testDbClient.transaction { txn => + txn.run(TestItem.insert.columns(_.name := "item1", _.value := 1)) + txn.run(TestItem.insert.columns(_.name := "item2", _.value := 2)) + throw new Exception("Rollback all") + } + } catch { + case _: Exception => // Expected + } + + val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select) + assert(items.isEmpty) + } + + test("DbClient given context is available") { + // Verify implicit DbClient is properly resolved + val decorator = new transactional(using testDbClient) + assert(decorator != null) + + // Verify we can use it in a transaction + cleanTable() + testDbClient.transaction { txn => + val count = txn.run(TestItem.select).length + assert(count == 0) + } + } + } +} diff --git a/example/todoDb/app/src/TodoMvcDb.scala b/example/todoDb/app/src/TodoMvcDb.scala index d715008251..4cd124b89b 100644 --- a/example/todoDb/app/src/TodoMvcDb.scala +++ b/example/todoDb/app/src/TodoMvcDb.scala @@ -1,30 +1,24 @@ package app +import cask.database.transactional import scalasql.DbApi.Txn -import scalasql.Sc -import scalasql.SqliteDialect._ +import scalasql.core.DbClient +import scalasql.simple.{*, given} +import SqliteDialect._ -object TodoMvcDb extends cask.MainRoutes{ +object TodoMvcDb extends cask.MainRoutes { val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") val sqliteDataSource = new org.sqlite.SQLiteDataSource() sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/file.db") - lazy val sqliteClient = new scalasql.DbClient.DataSource( + + given sqliteClient: DbClient = new DbClient.DataSource( sqliteDataSource, - config = new scalasql.Config {} + config = new {} ) - class transactional extends cask.RawDecorator{ - def wrapFunction(pctx: cask.Request, delegate: Delegate) = { - sqliteClient.transaction { txn => - val res = delegate(pctx, Map("txn" -> txn)) - if (res.isInstanceOf[cask.router.Result.Error]) txn.rollback() - res - } - } - } + case class Todo(id: Int, checked: Boolean, text: String) - case class Todo[T[_]](id: T[Int], checked: T[Boolean], text: T[String]) - object Todo extends scalasql.Table[Todo]{ - given todoRW: upickle.default.ReadWriter[Todo[Sc]] = upickle.default.macroRW[Todo[Sc]] + object Todo extends SimpleTable[Todo] { + given todoRW: upickle.default.ReadWriter[Todo] = upickle.default.macroRW[Todo] } sqliteClient.getAutoCommitClientConnection.updateRaw( @@ -43,7 +37,7 @@ object TodoMvcDb extends cask.MainRoutes{ @transactional @cask.get("/list/:state") def list(state: String)(txn: Txn) = { - val filteredTodos = state match{ + val filteredTodos = state match { case "all" => txn.run(Todo.select) case "active" => txn.run(Todo.select.filter(!_.checked)) case "completed" => txn.run(Todo.select.filter(_.checked)) diff --git a/example/todoDb/package.mill b/example/todoDb/package.mill index feb02276f3..e9a02cc4a0 100644 --- a/example/todoDb/package.mill +++ b/example/todoDb/package.mill @@ -4,11 +4,11 @@ import mill._, scalalib._ object app extends Cross[AppModule](build.scala3Latest) trait AppModule extends CrossScalaModule{ - def moduleDeps = Seq(build.cask(build.scala3)) + def moduleDeps = Seq(build.cask(build.scala3Latest)) def mvnDeps = Seq[Dep]( mvn"org.xerial:sqlite-jdbc:3.42.0.0", - mvn"com.lihaoyi::scalasql:0.2.2", + mvn"com.lihaoyi::scalasql-namedtuples:0.2.3", ) object test extends ScalaTests with TestModule.Utest{ diff --git a/example/todoDbWithLoom/app/src/TodoMvcDbWithLoom.scala b/example/todoDbWithLoom/app/src/TodoMvcDbWithLoom.scala index 394ebc7b52..5ef382ef43 100644 --- a/example/todoDbWithLoom/app/src/TodoMvcDbWithLoom.scala +++ b/example/todoDbWithLoom/app/src/TodoMvcDbWithLoom.scala @@ -1,7 +1,9 @@ package app +import cask.database.transactional import scalasql.DbApi.Txn -import scalasql.Sc -import scalasql.SqliteDialect._ +import scalasql.core.DbClient +import scalasql.simple.{*, given} +import SqliteDialect._ import java.util.concurrent.{ExecutorService, Executors} @@ -9,9 +11,10 @@ object TodoMvcDbWithLoom extends cask.MainRoutes { val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite") val sqliteDataSource = new org.sqlite.SQLiteDataSource() sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/file.db") - lazy val sqliteClient = new scalasql.DbClient.DataSource( + + given sqliteClient: DbClient = new DbClient.DataSource( sqliteDataSource, - config = new scalasql.Config {} + config = new {} ) private val executor = Executors.newFixedThreadPool(4) @@ -19,19 +22,10 @@ object TodoMvcDbWithLoom extends cask.MainRoutes { super.handlerExecutor().orElse(Some(executor)) } - class transactional extends cask.RawDecorator{ - def wrapFunction(pctx: cask.Request, delegate: Delegate) = { - sqliteClient.transaction { txn => - val res = delegate(pctx, Map("txn" -> txn)) - if (res.isInstanceOf[cask.router.Result.Error]) txn.rollback() - res - } - } - } + case class Todo(id: Int, checked: Boolean, text: String) - case class Todo[T[_]](id: T[Int], checked: T[Boolean], text: T[String]) - object Todo extends scalasql.Table[Todo]{ - given todoRW: upickle.default.ReadWriter[Todo[Sc]] = upickle.default.macroRW[Todo[Sc]] + object Todo extends SimpleTable[Todo] { + given todoRW: upickle.default.ReadWriter[Todo] = upickle.default.macroRW[Todo] } sqliteClient.getAutoCommitClientConnection.updateRaw( @@ -50,7 +44,7 @@ object TodoMvcDbWithLoom extends cask.MainRoutes { @transactional @cask.get("/list/:state") def list(state: String)(txn: Txn) = { - val filteredTodos = state match{ + val filteredTodos = state match { case "all" => txn.run(Todo.select) case "active" => txn.run(Todo.select.filter(!_.checked)) case "completed" => txn.run(Todo.select.filter(_.checked)) diff --git a/example/todoDbWithLoom/package.mill b/example/todoDbWithLoom/package.mill index 563619781e..3d8dc1ecb7 100644 --- a/example/todoDbWithLoom/package.mill +++ b/example/todoDbWithLoom/package.mill @@ -28,11 +28,11 @@ trait AppModule extends CrossScalaModule{ def jvmId = "temurin:23.0.1" def jvmIndexVersion = "latest.release" - def moduleDeps = Seq(build.cask(build.scala3)) + def moduleDeps = Seq(build.cask(build.scala3Latest)) def mvnDeps = Seq[Dep]( mvn"org.xerial:sqlite-jdbc:3.42.0.0", - mvn"com.lihaoyi::scalasql:0.2.2", + mvn"com.lihaoyi::scalasql-namedtuples:0.2.3", ) object test extends ScalaTests with TestModule.Utest {