Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions build.mill
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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")
}

Expand All @@ -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))
Expand Down
13 changes: 13 additions & 0 deletions cask/src-2.12/cask/database/package.scala
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions cask/src-2.12/cask/database/transactional.scala
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
13 changes: 13 additions & 0 deletions cask/src-2.13/cask/database/package.scala
Original file line number Diff line number Diff line change
@@ -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
}
17 changes: 17 additions & 0 deletions cask/src-2.13/cask/database/transactional.scala
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
31 changes: 31 additions & 0 deletions cask/src-3.7/cask/database/package.scala
Original file line number Diff line number Diff line change
@@ -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 {}
47 changes: 47 additions & 0 deletions cask/src-3.7/cask/database/transactional.scala
Original file line number Diff line number Diff line change
@@ -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
}
}
}
2 changes: 2 additions & 0 deletions cask/src/cask/package.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
153 changes: 153 additions & 0 deletions cask/test/src-3.7/cask/DatabaseTests.scala
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
30 changes: 12 additions & 18 deletions example/todoDb/app/src/TodoMvcDb.scala
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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))
Expand Down
Loading