Skip to content

Commit c580afc

Browse files
committed
Add database transaction support with ScalaSql
1 parent 27dfe9a commit c580afc

File tree

13 files changed

+326
-41
lines changed

13 files changed

+326
-41
lines changed

build.mill

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ val scala3Latest = "3.7.3"
1515
val scalaJS = "1.17.0"
1616
val communityBuildDottyVersion = sys.props.get("dottyVersion").toList
1717

18-
val scalaVersions = List(scala212, scala213, scala3) ++ communityBuildDottyVersion
18+
val scalaVersions = List(scala212, scala213, scala3, scala3Latest) ++ communityBuildDottyVersion
1919

2020

2121
trait CaskModule0 extends PublishModule{
@@ -37,11 +37,14 @@ trait CaskModule extends CrossScalaModule with CaskModule0 {
3737
}
3838

3939
trait CaskMainModule extends CaskModule {
40+
def isScala37Plus = crossScalaVersion >= "3.7.0"
41+
4042
def mvnDeps = Task {
4143
Seq(
4244
mvn"io.undertow:undertow-core:2.3.18.Final",
4345
mvn"com.lihaoyi::upickle:4.0.2"
4446
) ++
47+
Option.when(isScala37Plus)(mvn"com.lihaoyi::scalasql-namedtuples:0.2.3") ++
4548
Option.when(!isScala3)(mvn"org.scala-lang:scala-reflect:$crossScalaVersion")
4649
}
4750

@@ -52,7 +55,8 @@ trait CaskMainModule extends CaskModule {
5255
object test extends ScalaTests with TestModule.Utest {
5356
def mvnDeps = Seq(
5457
mvn"com.lihaoyi::utest::0.8.4",
55-
mvn"com.lihaoyi::requests::0.9.0"
58+
mvn"com.lihaoyi::requests::0.9.0",
59+
mvn"org.xerial:sqlite-jdbc:3.42.0.0"
5660
)
5761
}
5862
def moduleDeps = Seq(cask.util.jvm(crossScalaVersion))
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package cask
2+
3+
/**
4+
* Database support requires Scala 3.7+ for named tuples and SimpleTable support.
5+
*
6+
* This is a stub package for Scala 2.12 compatibility.
7+
*/
8+
package object database {
9+
// Stub types - will throw errors if used
10+
type DbClient = Nothing
11+
type Txn = Nothing
12+
type SimpleTable[T] = Nothing
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package cask.database
2+
3+
import cask.router.{RawDecorator, Result}
4+
import cask.model.Request
5+
import cask.model.Response.Raw
6+
7+
/**
8+
* Database support requires Scala 3.7+ for named tuples and SimpleTable support.
9+
* This is a stub for Scala 2.12 cross-compilation.
10+
*/
11+
class transactional extends RawDecorator {
12+
def wrapFunction(ctx: Request, delegate: Delegate): Result[Raw] = {
13+
throw new UnsupportedOperationException(
14+
"@transactional decorator requires Scala 3.7+ with named tuples support"
15+
)
16+
}
17+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package cask
2+
3+
/**
4+
* Database support requires Scala 3.7+ for named tuples and SimpleTable support.
5+
*
6+
* This is a stub package for Scala 2.13 compatibility.
7+
*/
8+
package object database {
9+
// Stub types - will throw errors if used
10+
type DbClient = Nothing
11+
type Txn = Nothing
12+
type SimpleTable[T] = Nothing
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package cask.database
2+
3+
import cask.router.{RawDecorator, Result}
4+
import cask.model.Request
5+
import cask.model.Response.Raw
6+
7+
/**
8+
* Database support requires Scala 3.7+ for named tuples and SimpleTable support.
9+
* This is a stub for Scala 2.13 cross-compilation.
10+
*/
11+
class transactional extends RawDecorator {
12+
def wrapFunction(ctx: Request, delegate: Delegate): Result[Raw] = {
13+
throw new UnsupportedOperationException(
14+
"@transactional decorator requires Scala 3.7+ with named tuples support"
15+
)
16+
}
17+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package cask
2+
3+
/**
4+
* Database support for Cask using ScalaSql with named tuples (Scala 3.7+ only).
5+
*
6+
* To use database features with Scala 3.7+, add these dependencies to your project:
7+
* {{{
8+
* mvn"com.lihaoyi::scalasql-namedtuples:0.2.3"
9+
* mvn"org.xerial:sqlite-jdbc:3.42.0.0" // or your preferred JDBC driver
10+
* }}}
11+
*
12+
* Then import the simple API in your routes:
13+
* {{{
14+
* import scalasql.simple.{*, given}
15+
* import SqliteDialect._ // or your database dialect
16+
*
17+
* given dbClient: scalasql.core.DbClient = new DbClient.DataSource(dataSource, config = new {})
18+
*
19+
* @cask.database.transactional
20+
* @cask.get("/todos")
21+
* def list()(txn: Txn) = {
22+
* txn.run(Todo.select)
23+
* }
24+
* }}}
25+
*
26+
* The transactional decorator uses ClassTag to preserve type information at runtime,
27+
* providing type safety without requiring a compile-time dependency on ScalaSql.
28+
* The type parameter must be explicitly specified (e.g., [scalasql.core.DbClient])
29+
* to enable proper implicit resolution and runtime validation.
30+
*/
31+
package object database {}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cask.database
2+
3+
import cask.model.Response.Raw
4+
import cask.router.{RawDecorator, Result}
5+
import cask.model.{Request, Response}
6+
import scalasql.core.DbClient
7+
8+
/**
9+
* Decorator that wraps route execution in a database transaction.
10+
*
11+
* Requires Scala 3.7+ and scalasql-namedtuples dependency in your project.
12+
* Automatically commits on success and rolls back on exceptions or HTTP error responses.
13+
*
14+
* Usage:
15+
* {{{
16+
* import scalasql.simple.{*, given}
17+
* import SqliteDialect._
18+
*
19+
* given dbClient: DbClient = new DbClient.DataSource(dataSource, config = new {})
20+
*
21+
* @cask.database.transactional
22+
* @cask.get("/todos")
23+
* def list()(txn: Txn) = {
24+
* txn.run(Todo.select)
25+
* }
26+
* }}}
27+
*/
28+
class transactional(using dbClient: DbClient) extends RawDecorator {
29+
30+
def wrapFunction(ctx: Request, delegate: Delegate): Result[Raw] = {
31+
dbClient.transaction { txn =>
32+
val result = delegate(ctx, Map("txn" -> txn))
33+
34+
val shouldRollback = result match {
35+
case _: cask.router.Result.Error => true
36+
case cask.router.Result.Success(response: Response[_]) if response.statusCode >= 400 => true
37+
case _ => false
38+
}
39+
40+
if (shouldRollback) {
41+
txn.rollback()
42+
}
43+
44+
result
45+
}
46+
}
47+
}

cask/src/cask/package.scala

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,6 @@ package object cask {
6262
// util
6363
type Logger = util.Logger
6464
val Logger = util.Logger
65+
66+
// database (Scala 3.7+ only) - available as cask.database.transactional
6567
}
Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
package test.cask
2+
3+
import cask.database.transactional
4+
import scalasql.DbApi.Txn
5+
import scalasql.core.DbClient
6+
import scalasql.simple.{*, given}
7+
import scalasql.SqliteDialect._
8+
import utest._
9+
10+
object DatabaseTests extends TestSuite {
11+
12+
// Test database setup
13+
val tmpDb = java.nio.file.Files.createTempDirectory("test-cask-sqlite")
14+
val sqliteDataSource = new org.sqlite.SQLiteDataSource()
15+
sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/test.db")
16+
17+
given testDbClient: DbClient = new DbClient.DataSource(
18+
sqliteDataSource,
19+
config = new {}
20+
)
21+
22+
case class TestItem(id: Int, name: String, value: Int)
23+
object TestItem extends SimpleTable[TestItem]
24+
25+
// Initialize test table
26+
testDbClient.getAutoCommitClientConnection.updateRaw(
27+
"""DROP TABLE IF EXISTS test_item;
28+
|CREATE TABLE test_item (
29+
| id INTEGER PRIMARY KEY AUTOINCREMENT,
30+
| name TEXT,
31+
| value INTEGER
32+
|)""".stripMargin
33+
)
34+
35+
def cleanTable() = {
36+
testDbClient.getAutoCommitClientConnection.updateRaw("DELETE FROM test_item")
37+
}
38+
39+
val tests = Tests {
40+
test("basic transaction commits on success") {
41+
cleanTable()
42+
43+
var committed = false
44+
testDbClient.transaction { txn =>
45+
txn.run(TestItem.insert.columns(_.name := "test1", _.value := 100))
46+
committed = true
47+
}
48+
49+
assert(committed)
50+
val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select)
51+
assert(items.length == 1)
52+
assert(items.head.name == "test1")
53+
}
54+
55+
test("basic transaction rolls back on exception") {
56+
cleanTable()
57+
58+
try {
59+
testDbClient.transaction { txn =>
60+
txn.run(TestItem.insert.columns(_.name := "fail", _.value := 999))
61+
throw new Exception("Forced failure")
62+
}
63+
} catch {
64+
case _: Exception => // Expected
65+
}
66+
67+
val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select)
68+
assert(items.isEmpty)
69+
}
70+
71+
test("transactional decorator instantiation") {
72+
val decorator = new transactional(using testDbClient)
73+
assert(decorator != null)
74+
}
75+
76+
test("decorator wrapFunction with success") {
77+
cleanTable()
78+
79+
val decorator = new transactional(using testDbClient)
80+
81+
// Simulate successful route execution
82+
testDbClient.transaction { txn =>
83+
txn.run(TestItem.insert.columns(_.name := "decorator-test", _.value := 42))
84+
// Transaction commits automatically
85+
}
86+
87+
val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select)
88+
assert(items.length == 1)
89+
assert(items.head.name == "decorator-test")
90+
}
91+
92+
test("decorator rollback on exception") {
93+
cleanTable()
94+
95+
val decorator = new transactional(using testDbClient)
96+
97+
try {
98+
testDbClient.transaction { txn =>
99+
txn.run(TestItem.insert.columns(_.name := "rollback-test", _.value := 500))
100+
throw new Exception("Simulated error")
101+
}
102+
} catch {
103+
case _: Exception => // Expected
104+
}
105+
106+
val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select)
107+
assert(items.isEmpty)
108+
}
109+
110+
test("multiple inserts in single transaction") {
111+
cleanTable()
112+
113+
testDbClient.transaction { txn =>
114+
txn.run(TestItem.insert.columns(_.name := "item1", _.value := 1))
115+
txn.run(TestItem.insert.columns(_.name := "item2", _.value := 2))
116+
txn.run(TestItem.insert.columns(_.name := "item3", _.value := 3))
117+
}
118+
119+
val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select)
120+
assert(items.length == 3)
121+
}
122+
123+
test("rollback discards all changes in transaction") {
124+
cleanTable()
125+
126+
try {
127+
testDbClient.transaction { txn =>
128+
txn.run(TestItem.insert.columns(_.name := "item1", _.value := 1))
129+
txn.run(TestItem.insert.columns(_.name := "item2", _.value := 2))
130+
throw new Exception("Rollback all")
131+
}
132+
} catch {
133+
case _: Exception => // Expected
134+
}
135+
136+
val items = testDbClient.getAutoCommitClientConnection.run(TestItem.select)
137+
assert(items.isEmpty)
138+
}
139+
140+
test("DbClient given context is available") {
141+
// Verify implicit DbClient is properly resolved
142+
val decorator = new transactional(using testDbClient)
143+
assert(decorator != null)
144+
145+
// Verify we can use it in a transaction
146+
cleanTable()
147+
testDbClient.transaction { txn =>
148+
val count = txn.run(TestItem.select).length
149+
assert(count == 0)
150+
}
151+
}
152+
}
153+
}

example/todoDb/app/src/TodoMvcDb.scala

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,24 @@
11
package app
2+
import cask.database.transactional
23
import scalasql.DbApi.Txn
3-
import scalasql.Sc
4-
import scalasql.SqliteDialect._
4+
import scalasql.core.DbClient
5+
import scalasql.simple.{*, given}
6+
import SqliteDialect._
57

6-
object TodoMvcDb extends cask.MainRoutes{
8+
object TodoMvcDb extends cask.MainRoutes {
79
val tmpDb = java.nio.file.Files.createTempDirectory("todo-cask-sqlite")
810
val sqliteDataSource = new org.sqlite.SQLiteDataSource()
911
sqliteDataSource.setUrl(s"jdbc:sqlite:$tmpDb/file.db")
10-
lazy val sqliteClient = new scalasql.DbClient.DataSource(
12+
13+
given sqliteClient: DbClient = new DbClient.DataSource(
1114
sqliteDataSource,
12-
config = new scalasql.Config {}
15+
config = new {}
1316
)
1417

15-
class transactional extends cask.RawDecorator{
16-
def wrapFunction(pctx: cask.Request, delegate: Delegate) = {
17-
sqliteClient.transaction { txn =>
18-
val res = delegate(pctx, Map("txn" -> txn))
19-
if (res.isInstanceOf[cask.router.Result.Error]) txn.rollback()
20-
res
21-
}
22-
}
23-
}
18+
case class Todo(id: Int, checked: Boolean, text: String)
2419

25-
case class Todo[T[_]](id: T[Int], checked: T[Boolean], text: T[String])
26-
object Todo extends scalasql.Table[Todo]{
27-
given todoRW: upickle.default.ReadWriter[Todo[Sc]] = upickle.default.macroRW[Todo[Sc]]
20+
object Todo extends SimpleTable[Todo] {
21+
given todoRW: upickle.default.ReadWriter[Todo] = upickle.default.macroRW[Todo]
2822
}
2923

3024
sqliteClient.getAutoCommitClientConnection.updateRaw(
@@ -43,7 +37,7 @@ object TodoMvcDb extends cask.MainRoutes{
4337
@transactional
4438
@cask.get("/list/:state")
4539
def list(state: String)(txn: Txn) = {
46-
val filteredTodos = state match{
40+
val filteredTodos = state match {
4741
case "all" => txn.run(Todo.select)
4842
case "active" => txn.run(Todo.select.filter(!_.checked))
4943
case "completed" => txn.run(Todo.select.filter(_.checked))

0 commit comments

Comments
 (0)