Skip to content
Closed
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
11 changes: 8 additions & 3 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,15 @@ 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"
mvn"com.lihaoyi::upickle:4.0.2",
mvn"com.typesafe:config:1.4.3"
) ++
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 +56,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/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[scalasql.core.DbClient]
* @cask.get("/todos")
* def list()(using ctx: scalasql.core.DbApi.Txn) = {
* ctx.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 {}
68 changes: 68 additions & 0 deletions cask/src-3/cask/database/transactional.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package cask.database

import cask.model.Response.Raw
import cask.router.{RawDecorator, Result}
import cask.model.{Request, Response}

import scala.reflect.ClassTag

/**
* 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.
*
* The type parameter `T` preserves the database client type at runtime using ClassTag,
* providing type safety while avoiding compile-time dependency on ScalaSql.
*
* Usage:
* {{{
* import scalasql.simple.{*, given}
* import SqliteDialect._
*
* given dbClient: scalasql.core.DbClient = new DbClient.DataSource(dataSource, config = new {})
*
* @cask.database.transactional[scalasql.core.DbClient]
* @cask.get("/todos")
* def list()(using ctx: scalasql.core.DbApi.Txn) = {
* ctx.run(Todo.select)
* }
* }}}
*/
class transactional[T <: AnyRef : ClassTag](using dbClient: T) extends RawDecorator {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's drop the class tag and just hardcode the compile-time dependency on ScalaSql. If anyone wants an alternative database library, they can define their own decorator to do so, no need to try and make this one generic


def wrapFunction(ctx: Request, delegate: Delegate): Result[Raw] = {
// Validate type at runtime using ClassTag
val clientClass = implicitly[ClassTag[T]].runtimeClass
if (!clientClass.isInstance(dbClient)) {
throw new IllegalArgumentException(
s"Type mismatch: expected ${clientClass.getName} but got ${dbClient.getClass.getName}"
)
}

// Use reflection to call methods on dbClient without importing scalasql
// This works for both Scala 3.3.4 and 3.7.3
val client = dbClient.asInstanceOf[Any]
val transactionMethod = client.getClass.getMethod("transaction", classOf[Function1[Any, Any]])

transactionMethod.invoke(client, new Function1[Any, Any] {
def apply(txn: Any): Any = {
val result = delegate(ctx, Map("ctx" -> 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) {
// Use reflection to call rollback
val rollbackMethod = txn.getClass.getMethod("rollback")
rollbackMethod.invoke(txn)
}

result
}
}).asInstanceOf[cask.router.Result[Response.Raw]]
}
}
102 changes: 102 additions & 0 deletions cask/src/cask/Config.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cask

import cask.internal.{Config => InternalConfig}

/**
* Global configuration access point.
*
* Auto-loads configuration at startup from:
* - application.conf
* - application-{CASK_ENV}.conf
* - System properties
* - Environment variables
*
* Example:
* {{{
* // application.conf
* app {
* name = "my-app"
* port = 8080
* database.url = ${?DATABASE_URL}
* }
*
* // Usage
* val name = cask.Config.getString("app.name")
* val port = cask.Config.getInt("app.port")
* }}}
*/
object Config {

type ConfigError = InternalConfig.ConfigError
val ConfigError = InternalConfig.ConfigError

type Environment = InternalConfig.Environment
val Environment = InternalConfig.Environment

/** Lazily loaded configuration */
private lazy val loader: InternalConfig.Loader =
InternalConfig.Loader.loadOrThrow()
Comment on lines +36 to +38
Copy link
Copy Markdown
Member

@lihaoyi lihaoyi Nov 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the scope of this config? Is it process-scoped, thread-local, or something else?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configuration is process-scoped and loaded once lazily when first accessed. The config. is immutable after loading; changes to config files require app restart

Added clear documentation


/** Get string configuration value */
def getString(key: String): Either[ConfigError, String] =
loader.getString(key)

/** Get int configuration value */
def getInt(key: String): Either[ConfigError, Int] =
loader.getInt(key)

/** Get boolean configuration value */
def getBoolean(key: String): Either[ConfigError, Boolean] =
loader.getBoolean(key)

/** Get long configuration value */
def getLong(key: String): Either[ConfigError, Long] =
loader.getLong(key)

/** Get double configuration value */
def getDouble(key: String): Either[ConfigError, Double] =
loader.getDouble(key)

/** Get optional string */
def getStringOpt(key: String): Option[String] =
loader.getStringOpt(key)

/** Get optional int */
def getIntOpt(key: String): Option[Int] =
loader.getIntOpt(key)

/** Get optional boolean */
def getBooleanOpt(key: String): Option[Boolean] =
loader.getBooleanOpt(key)

/** Get string or throw exception */
def getStringOrThrow(key: String): String =
getString(key) match {
case Right(value) => value
case Left(error) => throw new RuntimeException(error.message)
}

/** Get int or throw exception */
def getIntOrThrow(key: String): Int =
getInt(key) match {
case Right(value) => value
case Left(error) => throw new RuntimeException(error.message)
}

/** Get boolean or throw exception */
def getBooleanOrThrow(key: String): Boolean =
getBoolean(key) match {
case Right(value) => value
case Left(error) => throw new RuntimeException(error.message)
}

/** Access underlying Typesafe Config for advanced usage */
def underlying: com.typesafe.config.Config =
loader.underlying

/** Reload configuration (useful for testing) */
private[cask] def reload(): Unit = {
// Force re-evaluation of lazy val
val _ = InternalConfig.Loader.loadOrThrow()
}
}
Loading