diff --git a/build.mill b/build.mill index 8f4836d07f..e563f89719 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,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") } @@ -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)) 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/cask/database/package.scala b/cask/src-3/cask/database/package.scala new file mode 100644 index 0000000000..7864faa01e --- /dev/null +++ b/cask/src-3/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[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 {} diff --git a/cask/src-3/cask/database/transactional.scala b/cask/src-3/cask/database/transactional.scala new file mode 100644 index 0000000000..a2eb854b27 --- /dev/null +++ b/cask/src-3/cask/database/transactional.scala @@ -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 { + + 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]] + } +} diff --git a/cask/src/cask/Config.scala b/cask/src/cask/Config.scala new file mode 100644 index 0000000000..c548eee31b --- /dev/null +++ b/cask/src/cask/Config.scala @@ -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() + + /** 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() + } +} diff --git a/cask/src/cask/internal/Config.scala b/cask/src/cask/internal/Config.scala new file mode 100644 index 0000000000..e33a3af0db --- /dev/null +++ b/cask/src/cask/internal/Config.scala @@ -0,0 +1,134 @@ +package cask.internal + +import com.typesafe.config.{Config => TypesafeConfig, ConfigFactory, ConfigException} +import scala.util.{Try, Success, Failure} +import scala.util.control.NonFatal + +/** + * Configuration loading and access with functional error handling. + */ +object Config { + + /** Configuration error ADT */ + sealed trait ConfigError { + def message: String + } + + object ConfigError { + final case class Missing(key: String) extends ConfigError { + def message = s"Configuration key '$key' is missing" + } + + final case class InvalidType(key: String, expected: String, actual: String) extends ConfigError { + def message = s"Configuration key '$key': expected $expected but got $actual" + } + + final case class LoadFailure(cause: String) extends ConfigError { + def message = s"Failed to load configuration: $cause" + } + + final case class ParseFailure(key: String, value: String, cause: String) extends ConfigError { + def message = s"Failed to parse '$key' value '$value': $cause" + } + } + + /** Environment for profile loading */ + sealed trait Environment { + def name: String + } + + object Environment { + case object Development extends Environment { val name = "dev" } + case object Test extends Environment { val name = "test" } + case object Production extends Environment { val name = "prod" } + final case class Custom(name: String) extends Environment + + def fromString(s: String): Environment = s.toLowerCase match { + case "dev" | "development" => Development + case "test" => Test + case "prod" | "production" => Production + case other => Custom(other) + } + + def current: Environment = + sys.env.get("CASK_ENV") + .map(fromString) + .getOrElse(Development) + } + + /** Configuration loader with resource safety */ + final class Loader private (config: TypesafeConfig) { + + def getString(key: String): Either[ConfigError, String] = + safeGet(key)(config.getString) + + def getInt(key: String): Either[ConfigError, Int] = + safeGet(key)(config.getInt) + + def getBoolean(key: String): Either[ConfigError, Boolean] = + safeGet(key)(config.getBoolean) + + def getLong(key: String): Either[ConfigError, Long] = + safeGet(key)(config.getLong) + + def getDouble(key: String): Either[ConfigError, Double] = + safeGet(key)(config.getDouble) + + def getStringOpt(key: String): Option[String] = + getString(key).toOption + + def getIntOpt(key: String): Option[Int] = + getInt(key).toOption + + def getBooleanOpt(key: String): Option[Boolean] = + getBoolean(key).toOption + + private def safeGet[A](key: String)(f: String => A): Either[ConfigError, A] = + Try(f(key)) match { + case Success(value) => Right(value) + case Failure(e: ConfigException.Missing) => + Left(ConfigError.Missing(key)) + case Failure(e: ConfigException.WrongType) => + Left(ConfigError.InvalidType(key, "unknown", e.getMessage)) + case Failure(e) => + Left(ConfigError.ParseFailure(key, "unknown", e.getMessage)) + } + + /** Underlying config for advanced usage */ + def underlying: TypesafeConfig = config + } + + object Loader { + + /** + * Load configuration with environment profile. + * + * Loading order (later overrides earlier): + * 1. reference.conf (library defaults) + * 2. application.conf (user config) + * 3. application-{env}.conf (environment profile) + * 4. System properties + * 5. Environment variables + */ + def load(env: Environment = Environment.current): Either[ConfigError, Loader] = + Try { + val base = ConfigFactory.load("application") + val profile = Try(ConfigFactory.load(s"application-${env.name}")) + .getOrElse(ConfigFactory.empty()) + + profile + .withFallback(base) + .resolve() + } match { + case Success(config) => Right(new Loader(config)) + case Failure(e) => Left(ConfigError.LoadFailure(e.getMessage)) + } + + /** Load with default environment */ + def loadOrThrow(): Loader = + load() match { + case Right(loader) => loader + case Left(error) => throw new RuntimeException(error.message) + } + } +} 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/cask/ConfigTests.scala b/cask/test/src/cask/ConfigTests.scala new file mode 100644 index 0000000000..7e8b44fd0a --- /dev/null +++ b/cask/test/src/cask/ConfigTests.scala @@ -0,0 +1,90 @@ +package test.cask + +import utest._ + +object ConfigTests extends TestSuite { + + val tests = Tests { + test("ADT error types") { + val missing = cask.Config.ConfigError.Missing("test.key") + assert(missing.message.contains("missing")) + + val invalidType = cask.Config.ConfigError.InvalidType("test.key", "String", "Int") + assert(invalidType.message.contains("expected")) + } + + test("Environment from string") { + import cask.Config.Environment._ + + assert(fromString("dev") == Development) + assert(fromString("development") == Development) + assert(fromString("test") == Test) + assert(fromString("prod") == Production) + assert(fromString("production") == Production) + assert(fromString("staging").isInstanceOf[Custom]) + } + + test("Config loader Either pattern") { + val result = cask.Config.getString("nonexistent.key") + assert(result.isLeft) + + result match { + case Left(error) => assert(error.message.contains("missing")) + case Right(_) => assert(false) + } + } + + test("Optional config accessors") { + val opt = cask.Config.getStringOpt("nonexistent.key") + assert(opt.isEmpty) + } + + test("Type-safe accessors with different types") { + // These will work if application.conf exists with proper values + // Test that methods exist and return correct types + locally { + val _: Either[cask.Config.ConfigError, String] = cask.Config.getString("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Int] = cask.Config.getInt("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Boolean] = cask.Config.getBoolean("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Long] = cask.Config.getLong("any.key") + } + locally { + val _: Either[cask.Config.ConfigError, Double] = cask.Config.getDouble("any.key") + } + } + + test("Environment detection from CASK_ENV") { + val env = cask.Config.Environment.current + // Should default to Development if CASK_ENV not set + assert(env.isInstanceOf[cask.Config.Environment]) + } + + test("Config error pattern matching") { + import cask.Config.ConfigError._ + + val errors: Seq[cask.Config.ConfigError] = Seq( + Missing("key1"), + InvalidType("key2", "String", "Int"), + LoadFailure("cause"), + ParseFailure("key3", "value", "cause") + ) + + errors.foreach { error => + error match { + case Missing(key) => assert(key.nonEmpty) + case InvalidType(key, expected, actual) => + assert(key.nonEmpty && expected.nonEmpty && actual.nonEmpty) + case LoadFailure(cause) => assert(cause.nonEmpty) + case ParseFailure(key, value, cause) => + assert(key.nonEmpty && cause.nonEmpty) + } + } + } + } +} diff --git a/example/config/app/resources/application-prod.conf b/example/config/app/resources/application-prod.conf new file mode 100644 index 0000000000..c71bff175b --- /dev/null +++ b/example/config/app/resources/application-prod.conf @@ -0,0 +1,6 @@ +app { + features { + debug = false + cache-enabled = true + } +} diff --git a/example/config/app/resources/application.conf b/example/config/app/resources/application.conf new file mode 100644 index 0000000000..e1263a7076 --- /dev/null +++ b/example/config/app/resources/application.conf @@ -0,0 +1,14 @@ +app { + name = "config-example" + + server { + port = 8080 + port = ${?PORT} + host = "0.0.0.0" + } + + features { + debug = true + cache-enabled = false + } +} diff --git a/example/config/app/src/ConfigExample.scala b/example/config/app/src/ConfigExample.scala new file mode 100644 index 0000000000..788742e2ff --- /dev/null +++ b/example/config/app/src/ConfigExample.scala @@ -0,0 +1,32 @@ +package app + +object ConfigExample extends cask.MainRoutes { + + // Configuration loaded automatically at startup + val appName = cask.Config.getStringOrThrow("app.name") + val debugMode = cask.Config.getBooleanOrThrow("app.features.debug") + + override def port = cask.Config.getIntOrThrow("app.server.port") + override def host = cask.Config.getStringOrThrow("app.server.host") + + @cask.get("/") + def index() = { + val env = cask.Config.Environment.current.name + s""" + |App: $appName + |Environment: $env + |Debug: $debugMode + |Port: $port + |""".stripMargin + } + + @cask.get("/config/:key") + def getConfig(key: String) = { + cask.Config.getString(key) match { + case Right(value) => s"$key = $value" + case Left(error) => cask.Response(error.message, statusCode = 404) + } + } + + initialize() +} diff --git a/example/config/package.mill b/example/config/package.mill new file mode 100644 index 0000000000..d1784e334d --- /dev/null +++ b/example/config/package.mill @@ -0,0 +1,7 @@ +package build.example.config +import mill._, scalalib._ + +object `package` extends Module { + object app extends Cross[AppModule](build.scala3Latest) + trait AppModule extends build.LocalModule +} diff --git a/example/todoDb/app/resources/application-prod.conf b/example/todoDb/app/resources/application-prod.conf new file mode 100644 index 0000000000..2214ee1246 --- /dev/null +++ b/example/todoDb/app/resources/application-prod.conf @@ -0,0 +1,14 @@ +database { + # Production database uses persistent storage + path = "/var/lib/cask-todo/data" + + pool { + max-connections = 25 + min-idle = 5 + } +} + +# Disable initial data insertion in production +initial-data { + enabled = false +} diff --git a/example/todoDb/app/resources/application.conf b/example/todoDb/app/resources/application.conf new file mode 100644 index 0000000000..f2c2b64cd2 --- /dev/null +++ b/example/todoDb/app/resources/application.conf @@ -0,0 +1,24 @@ +database { + # SQLite database configuration + driver = "org.sqlite.SQLiteDataSource" + + # Database path - uses temp directory by default + # Override with DATABASE_PATH environment variable + path = "temp" + path = ${?DATABASE_PATH} + + # Connection pool settings + pool { + max-connections = 10 + min-idle = 2 + } +} + +# Initial data to insert +initial-data { + enabled = true + todos = [ + { checked: true, text: "Get started with Cask" } + { checked: false, text: "Profit!" } + ] +} diff --git a/example/todoDb/app/src/TodoMvcDb.scala b/example/todoDb/app/src/TodoMvcDb.scala index d715008251..281504911e 100644 --- a/example/todoDb/app/src/TodoMvcDb.scala +++ b/example/todoDb/app/src/TodoMvcDb.scala @@ -1,61 +1,66 @@ package app -import scalasql.DbApi.Txn -import scalasql.Sc -import scalasql.SqliteDialect._ +import scalasql.simple.{*, given} +import SqliteDialect._ + +object TodoMvcDb extends cask.MainRoutes { + // Database path from config, fallback to temp directory + val dbPath = cask.Config.getStringOpt("database.path") match { + case Some("temp") | None => + java.nio.file.Files.createTempDirectory("todo-cask-sqlite").toString + case Some(path) => + val dir = java.nio.file.Paths.get(path) + java.nio.file.Files.createDirectories(dir) + path + } -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( + sqliteDataSource.setUrl(s"jdbc:sqlite:$dbPath/file.db") + + given dbClient: scalasql.core.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( + // Initialize database schema + dbClient.getAutoCommitClientConnection.updateRaw( """CREATE TABLE todo ( | id INTEGER PRIMARY KEY AUTOINCREMENT, | checked BOOLEAN, | text TEXT - |); - | - |INSERT INTO todo (checked, text) VALUES - |(1, 'Get started with Cask'), - |(0, 'Profit!'); - |""".stripMargin + |);""".stripMargin ) - @transactional + // Insert initial data if enabled in config + if (cask.Config.getBooleanOpt("initial-data.enabled").getOrElse(true)) { + dbClient.getAutoCommitClientConnection.updateRaw( + """INSERT INTO todo (checked, text) VALUES + |(1, 'Get started with Cask'), + |(0, 'Profit!');""".stripMargin + ) + } + + @cask.database.transactional[scalasql.core.DbClient] @cask.get("/list/:state") - def list(state: String)(txn: Txn) = { - 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)) + def list(state: String)(using ctx: scalasql.core.DbApi.Txn) = { + val filteredTodos = state match { + case "all" => ctx.run(Todo.select) + case "active" => ctx.run(Todo.select.filter(!_.checked)) + case "completed" => ctx.run(Todo.select.filter(_.checked)) } upickle.default.write(filteredTodos) } - @transactional + @cask.database.transactional[scalasql.core.DbClient] @cask.post("/add") - def add(request: cask.Request)(txn: Txn) = { + def add(request: cask.Request)(using ctx: scalasql.core.DbApi.Txn) = { val body = request.text() - txn.run( + ctx.run( Todo .insert .columns(_.checked := false, _.text := body) @@ -66,16 +71,16 @@ object TodoMvcDb extends cask.MainRoutes{ if (body == "FORCE FAILURE") throw new Exception("FORCE FAILURE BODY") } - @transactional + @cask.database.transactional[scalasql.core.DbClient] @cask.post("/toggle/:index") - def toggle(index: Int)(txn: Txn) = { - txn.run(Todo.update(_.id === index).set(p => p.checked := !p.checked)) + def toggle(index: Int)(using ctx: scalasql.core.DbApi.Txn) = { + ctx.run(Todo.update(_.id === index).set(p => p.checked := !p.checked)) } - @transactional + @cask.database.transactional[scalasql.core.DbClient] @cask.post("/delete/:index") - def delete(index: Int)(txn: Txn) = { - txn.run(Todo.delete(_.id === index)) + def delete(index: Int)(using ctx: scalasql.core.DbApi.Txn) = { + ctx.run(Todo.delete(_.id === index)) } initialize() diff --git a/example/todoDb/package.mill b/example/todoDb/package.mill index feb02276f3..2bd4c0bd62 100644 --- a/example/todoDb/package.mill +++ b/example/todoDb/package.mill @@ -8,7 +8,7 @@ trait AppModule extends CrossScalaModule{ 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..87b9644371 100644 --- a/example/todoDbWithLoom/app/src/TodoMvcDbWithLoom.scala +++ b/example/todoDbWithLoom/app/src/TodoMvcDbWithLoom.scala @@ -1,7 +1,6 @@ package app -import scalasql.DbApi.Txn -import scalasql.Sc -import scalasql.SqliteDialect._ +import scalasql.simple.{*, given} +import SqliteDialect._ import java.util.concurrent.{ExecutorService, Executors} @@ -9,9 +8,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 dbClient: scalasql.core.DbClient = new DbClient.DataSource( sqliteDataSource, - config = new scalasql.Config {} + config = new {} ) private val executor = Executors.newFixedThreadPool(4) @@ -19,22 +19,13 @@ 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( + dbClient.getAutoCommitClientConnection.updateRaw( """CREATE TABLE todo ( | id INTEGER PRIMARY KEY AUTOINCREMENT, | checked BOOLEAN, @@ -47,22 +38,22 @@ object TodoMvcDbWithLoom extends cask.MainRoutes { |""".stripMargin ) - @transactional + @cask.database.transactional[scalasql.core.DbClient] @cask.get("/list/:state") - def list(state: String)(txn: Txn) = { - 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)) + def list(state: String)(using ctx: scalasql.core.DbApi.Txn) = { + val filteredTodos = state match { + case "all" => ctx.run(Todo.select) + case "active" => ctx.run(Todo.select.filter(!_.checked)) + case "completed" => ctx.run(Todo.select.filter(_.checked)) } upickle.default.write(filteredTodos) } - @transactional + @cask.database.transactional[scalasql.core.DbClient] @cask.post("/add") - def add(request: cask.Request)(txn: Txn) = { + def add(request: cask.Request)(using ctx: scalasql.core.DbApi.Txn) = { val body = request.text() - txn.run( + ctx.run( Todo .insert .columns(_.checked := false, _.text := body) @@ -73,16 +64,16 @@ object TodoMvcDbWithLoom extends cask.MainRoutes { if (body == "FORCE FAILURE") throw new Exception("FORCE FAILURE BODY") } - @transactional + @cask.database.transactional[scalasql.core.DbClient] @cask.post("/toggle/:index") - def toggle(index: Int)(txn: Txn) = { - txn.run(Todo.update(_.id === index).set(p => p.checked := !p.checked)) + def toggle(index: Int)(using ctx: scalasql.core.DbApi.Txn) = { + ctx.run(Todo.update(_.id === index).set(p => p.checked := !p.checked)) } - @transactional + @cask.database.transactional[scalasql.core.DbClient] @cask.post("/delete/:index") - def delete(index: Int)(txn: Txn) = { - txn.run(Todo.delete(_.id === index)) + def delete(index: Int)(using ctx: scalasql.core.DbApi.Txn) = { + ctx.run(Todo.delete(_.id === index)) } initialize() diff --git a/example/todoDbWithLoom/package.mill b/example/todoDbWithLoom/package.mill index 563619781e..1b3eed8ac4 100644 --- a/example/todoDbWithLoom/package.mill +++ b/example/todoDbWithLoom/package.mill @@ -32,7 +32,7 @@ trait AppModule extends CrossScalaModule{ 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 {