-
-
Notifications
You must be signed in to change notification settings - Fork 67
feat: database-configuration & transaction management + application.conf #182
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
770b734
cbf8b24
c694ec5
a2d640e
ed8f362
43ab13c
01c60e6
9a782b6
84fd023
0c6af8a
6cea279
1eb682a
2f0f324
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
| } |
| 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" | ||
| ) | ||
| } | ||
| } |
| 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 | ||
| } |
| 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" | ||
| ) | ||
| } | ||
| } |
| 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 {} |
| 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 { | ||
|
|
||
| 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]] | ||
| } | ||
| } | ||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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