Skip to content
Merged
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
24 changes: 24 additions & 0 deletions docs/docs/sql/database.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ run in `ZIO[Database, QueryError, …]`; you supply a `Database` via its `ZLayer
final case class DbConfig(
target: DbConfig.Target, // database, host, port
credentials: Option[DbConfig.Credentials], // username, password
connection: DbConfig.Connection, // ssl, timeouts, application name, extra props
pool: DbConfig.Pool, // minConnections, maxConnections, duration
logging: DbConfig.Logging, // queryLogLevel, logSql
execution: DbConfig.Execution, // result-buffer tuning
Expand All @@ -24,6 +25,7 @@ As JSON (the example app sets the same fields as YAML in `example/apps/web-serve
{
"target": { "database": "oxygen_example", "host": "localhost", "port": 5210 },
"credentials": { "username": "oxygen_username", "password": "oxygen_password" },
"connection": { "sslMode": "prefer", "connectTimeout": "PT10S", "applicationName": "oxygen_example" },
"pool": { "minConnections": 2, "maxConnections": 16, "duration": "PT5M" },
"logging": { "queryLogLevel": "Trace", "logSql": true },
"execution": { "bufferChunkSize": [16, 64, 64, 256], "bufferNumChunks": 2 }
Expand All @@ -35,6 +37,28 @@ As JSON (the example app sets the same fields as YAML in `example/apps/web-serve
- `DbConfig.Execution.default` provides sensible buffer defaults.
- Credentials are marked `@jsonSecret`, so they're redacted in safe serialization.

### JDBC connection settings — `DbConfig.Connection`

Optional, typed JDBC connection params, translated into the driver `Properties` alongside
`user`/`password`. Every field is optional; `DbConfig.Connection.default` is all-empty, which
preserves the historical behavior of supplying only credentials, so `connection` can be omitted
entirely from the JSON.

| Field | JDBC property | Notes |
|-------|---------------|-------|
| `sslMode` | `sslmode` | One of `disable`, `allow`, `prefer`, `require`, `verify-ca`, `verify-full` (Postgres `sslmode`). |
| `sslRootCert` | `sslrootcert` | Path to the trusted CA cert — typically required for `verify-ca` / `verify-full`. |
| `sslCert` | `sslcert` | Path to the client cert (mutual TLS). |
| `sslKey` | `sslkey` | Path to the client key **file** (a path, not the key material). |
| `connectTimeout` | `connectTimeout` | `Duration`, sent as whole seconds. |
| `socketTimeout` | `socketTimeout` | `Duration`, sent as whole seconds. |
| `applicationName` | `ApplicationName` | Shows up in `pg_stat_activity`. |
| `extraProperties` | *(verbatim)* | `Map[String, String]` escape hatch for any other driver knob. |

Property values are Postgres (`pgjdbc`) flavored. `extraProperties` are applied **last**, so they
override the typed properties (and credentials) on a key collision — the escape hatch always wins.
So `"ssl prefer"` from the ticket is simply `"connection": { "sslMode": "prefer" }`.

## The Database layer

`Database.layer: URLayer[DbConfig, Database]` wires the driver, connection pool, and base database
Expand Down
3 changes: 2 additions & 1 deletion modules/sql/core/src/main/scala/oxygen/sql/Database.scala
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ object Database {
def make(config: DbConfig): URIO[Scope, Database] =
for {
driver <- Driver.makePSQL
connect = driver.getConnection(config.target, config.credentials)
connect = driver.getConnection(config.target, config.credentials, config.connection)
pool <- ConnectionPool.makeZPool(connect, config.pool)
logConfigRef <- FiberRef.make(config.logging)
connectionStateRef <- FiberRef.make[ConnectionState](ConnectionState.Pool(pool))
Expand All @@ -65,6 +65,7 @@ object Database {
ZLayer.makeSome[DbConfig, Database](
ZLayer.service[DbConfig].project(_.target),
ZLayer.service[DbConfig].project(_.credentials),
ZLayer.service[DbConfig].project(_.connection),
ZLayer.service[DbConfig].project(_.pool),
ZLayer.service[DbConfig].project(_.logging),
ZLayer.service[DbConfig].project(_.execution),
Expand Down
66 changes: 66 additions & 0 deletions modules/sql/core/src/main/scala/oxygen/sql/DbConfig.scala
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package oxygen.sql

import oxygen.core.typeclass.StrictEnum
import oxygen.json.jsonSecret
import oxygen.predef.core.*
import oxygen.schema.JsonSchema
Expand All @@ -9,6 +10,7 @@ import zio.*
final case class DbConfig(
target: DbConfig.Target,
credentials: Nullable[DbConfig.Credentials],
connection: DbConfig.Connection = DbConfig.Connection.default,
pool: DbConfig.Pool = DbConfig.Pool.default,
logging: DbConfig.Logging = DbConfig.Logging.default,
execution: DbConfig.Execution = DbConfig.Execution.default,
Expand All @@ -29,6 +31,70 @@ object DbConfig {
password: String,
) derives JsonSchema

/**
* Optional JDBC connection settings, translated into the driver `Properties` alongside `user`/`password`.
*
* Every field is optional and defaults to "unset" ([[Connection.default]]), which preserves the historical
* behavior of only supplying credentials. Property names/values below are Postgres (`pgjdbc`) flavored; other
* dialects can specialize this translation later.
*
* @param sslMode Postgres `sslmode` (see [[SslMode]]).
* @param sslRootCert Path to the trusted CA cert (`sslrootcert`) — typically required for `verify-ca`/`verify-full`.
* @param sslCert Path to the client cert (`sslcert`) for mutual TLS.
* @param sslKey Path to the client key file (`sslkey`) for mutual TLS. A file path, not the key material itself.
* @param connectTimeout Max time to establish the TCP connection (`connectTimeout`, whole seconds).
* @param socketTimeout Max time a socket read may block (`socketTimeout`, whole seconds).
* @param applicationName Reported to the server as `ApplicationName` (shows up in `pg_stat_activity`).
* @param extraProperties Escape hatch: arbitrary driver properties copied verbatim. Applied last, so these
* override the typed properties (and credentials) on key collision.
*/
final case class Connection(
sslMode: Option[SslMode] = None,
sslRootCert: Option[String] = None,
sslCert: Option[String] = None,
sslKey: Option[String] = None,
connectTimeout: Option[Duration] = None,
socketTimeout: Option[Duration] = None,
applicationName: Option[String] = None,
extraProperties: Map[String, String] = Map.empty,
) derives JsonSchema {

/** Typed settings translated into `(key, value)` JDBC property pairs, followed by the verbatim escape-hatch props. */
def properties: Seq[(String, String)] =
Seq(
sslMode.map(m => "sslmode" -> m.sslmode),
sslRootCert.map("sslrootcert" -> _),
sslCert.map("sslcert" -> _),
sslKey.map("sslkey" -> _),
connectTimeout.map(d => "connectTimeout" -> d.toSeconds.toString),
socketTimeout.map(d => "socketTimeout" -> d.toSeconds.toString),
applicationName.map("ApplicationName" -> _),
).flatten ++ extraProperties.toSeq

}
object Connection {
val default: Connection = Connection()
}

/**
* Postgres `sslmode`. Encoded/decoded (case-insensitively) using the exact libpq spellings:
* `disable, allow, prefer, require, verify-ca, verify-full`.
*
* See: https://jdbc.postgresql.org/documentation/ssl/ and the libpq `sslmode` docs.
*/
enum SslMode(final val sslmode: String) {
case Disable extends SslMode("disable")
case Allow extends SslMode("allow")
case Prefer extends SslMode("prefer")
case Require extends SslMode("require")
case VerifyCa extends SslMode("verify-ca")
case VerifyFull extends SslMode("verify-full")
}
object SslMode {
given StrictEnum[SslMode] = StrictEnum.derive[SslMode](_.sslmode)
given JsonSchema[SslMode] = JsonSchema.fromPlainText
}

final case class Pool(
minConnections: Int = 2,
maxConnections: Int = 8,
Expand Down
29 changes: 23 additions & 6 deletions modules/sql/core/src/main/scala/oxygen/sql/Driver.scala
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import zio.*

trait Driver {

def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials]): Driver.GetConnection
def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): Driver.GetConnection

}
object Driver {
Expand All @@ -21,28 +21,45 @@ object Driver {

final case class JdbcDriver(dbUrlPrefix: String, driver: java.sql.Driver) extends Driver {

override def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials]): Driver.GetConnection = {
override def getConnection(target: DbConfig.Target, credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): Driver.GetConnection =
Driver.GetConnection {
Connection.wrapUnsafeJdbc { driver.connect(target.jdbcUrl(dbUrlPrefix), JdbcDriver.buildProperties(credentials, connection)) }
}

}
object JdbcDriver {

/**
* Assemble the JDBC `Properties` passed to `driver.connect`.
*
* Application order (later wins on key collision):
* 1. `user`/`password` from credentials
* 2. typed connection settings ([[DbConfig.Connection]])
* 3. `extraProperties` escape hatch (copied verbatim)
*/
def buildProperties(credentials: Option[DbConfig.Credentials], connection: DbConfig.Connection): java.util.Properties = {
val props = new java.util.Properties
credentials.foreach { credentials =>
props.put("user", credentials.username)
props.put("password", credentials.password)
}

Driver.GetConnection { Connection.wrapUnsafeJdbc { driver.connect(target.jdbcUrl(dbUrlPrefix), props) } }
connection.properties.foreach { case (k, v) => props.put(k, v) }
props
}

}

final case class GetConnection(getConnection: ZIO[Scope, ConnectionError, Connection])
object GetConnection {

val layer: URLayer[Driver & DbConfig.Target & Option[DbConfig.Credentials], Driver.GetConnection] =
val layer: URLayer[Driver & DbConfig.Target & Option[DbConfig.Credentials] & DbConfig.Connection, Driver.GetConnection] =
ZLayer.fromZIO {
for {
driver <- ZIO.service[Driver]
target <- ZIO.service[DbConfig.Target]
credentials <- ZIO.service[Option[DbConfig.Credentials]]
} yield driver.getConnection(target, credentials)
connection <- ZIO.service[DbConfig.Connection]
} yield driver.getConnection(target, credentials, connection)
}

}
Expand Down
156 changes: 156 additions & 0 deletions modules/sql/core/src/test/scala/oxygen/sql/DbConfigSpec.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
package oxygen.sql

import oxygen.predef.test.*
import oxygen.schema.JsonSchema
import scala.jdk.CollectionConverters.*
import zio.*

object DbConfigSpec extends OxygenSpecDefault {

private def propsToMap(p: java.util.Properties): Map[String, String] =
p.stringPropertyNames().asScala.iterator.map(k => k -> p.getProperty(k)).toMap

private def sslModeSpec: TestSpec =
suite("SslMode")(
test("encodes to postgres sslmode spellings") {
assertTrue(
DbConfig.SslMode.Disable.sslmode == "disable",
DbConfig.SslMode.Allow.sslmode == "allow",
DbConfig.SslMode.Prefer.sslmode == "prefer",
DbConfig.SslMode.Require.sslmode == "require",
DbConfig.SslMode.VerifyCa.sslmode == "verify-ca",
DbConfig.SslMode.VerifyFull.sslmode == "verify-full",
)
},
test("decodes (case-insensitively) via JsonSchema") {
assertTrue(
JsonSchema[DbConfig.SslMode].decode("\"prefer\"") == Right(DbConfig.SslMode.Prefer),
JsonSchema[DbConfig.SslMode].decode("\"verify-full\"") == Right(DbConfig.SslMode.VerifyFull),
JsonSchema[DbConfig.SslMode].decode("\"REQUIRE\"") == Right(DbConfig.SslMode.Require),
JsonSchema[DbConfig.SslMode].decode("\"nonsense\"").isLeft,
)
},
)

private def propertiesSpec: TestSpec =
suite("Connection.properties")(
test("default is empty") {
assertTrue(DbConfig.Connection.default.properties.isEmpty)
},
test("fully-populated typed settings translate to postgres property pairs") {
val conn = DbConfig.Connection(
sslMode = DbConfig.SslMode.Require.some,
sslRootCert = "/certs/root.crt".some,
sslCert = "/certs/client.crt".some,
sslKey = "/certs/client.key".some,
connectTimeout = 10.seconds.some,
socketTimeout = 30.seconds.some,
applicationName = "oxygen-app".some,
extraProperties = Map("tcpKeepAlive" -> "true"),
)
assertTrue(
conn.properties.toMap == Map(
"sslmode" -> "require",
"sslrootcert" -> "/certs/root.crt",
"sslcert" -> "/certs/client.crt",
"sslkey" -> "/certs/client.key",
"connectTimeout" -> "10",
"socketTimeout" -> "30",
"ApplicationName" -> "oxygen-app",
"tcpKeepAlive" -> "true",
),
)
},
)

private def buildPropertiesSpec: TestSpec =
suite("JdbcDriver.buildProperties")(
test("only user/password when connection is unset (preserves historical behavior)") {
val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, DbConfig.Connection.default)
assertTrue(propsToMap(props) == Map("user" -> "u", "password" -> "p"))
},
test("no credentials + no connection settings => empty props") {
val props = Driver.JdbcDriver.buildProperties(None, DbConfig.Connection.default)
assertTrue(propsToMap(props).isEmpty)
},
test("credentials + typed connection settings are all present") {
val conn = DbConfig.Connection(sslMode = DbConfig.SslMode.Prefer.some, applicationName = "svc".some)
val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, conn)
assertTrue(
propsToMap(props) == Map(
"user" -> "u",
"password" -> "p",
"sslmode" -> "prefer",
"ApplicationName" -> "svc",
),
)
},
test("extraProperties are applied last and override typed + credentials on collision") {
val conn = DbConfig.Connection(
sslMode = DbConfig.SslMode.Prefer.some,
extraProperties = Map("sslmode" -> "require", "password" -> "override"),
)
val props = Driver.JdbcDriver.buildProperties(DbConfig.Credentials("u", "p").some, conn)
assertTrue(
props.getProperty("sslmode") == "require",
props.getProperty("password") == "override",
props.getProperty("user") == "u",
)
},
)

private def decodingSpec: TestSpec =
suite("JSON decoding")(
test("connection block decodes from JSON") {
val json =
"""{
| "sslMode": "verify-full",
| "sslRootCert": "/certs/root.crt",
| "connectTimeout": "PT10S",
| "applicationName": "oxygen-app",
| "extraProperties": { "tcpKeepAlive": "true" }
|}""".stripMargin
assertTrue(
JsonSchema[DbConfig.Connection].decode(json) == Right(
DbConfig.Connection(
sslMode = DbConfig.SslMode.VerifyFull.some,
sslRootCert = "/certs/root.crt".some,
connectTimeout = 10.seconds.some,
applicationName = "oxygen-app".some,
extraProperties = Map("tcpKeepAlive" -> "true"),
),
),
)
},
test("full DbConfig decodes with connection omitted (defaults to empty)") {
val json =
"""{
| "target": { "database": "db", "host": "localhost", "port": 5432 },
| "credentials": { "username": "u", "password": "p" }
|}""".stripMargin
assertTrue(JsonSchema[DbConfig].decode(json).map(_.connection) == Right(DbConfig.Connection.default))
},
test("full DbConfig decodes with a connection block") {
val json =
"""{
| "target": { "database": "db", "host": "localhost", "port": 5432 },
| "credentials": { "username": "u", "password": "p" },
| "connection": { "sslMode": "require", "socketTimeout": "PT30S" }
|}""".stripMargin
assertTrue(
JsonSchema[DbConfig].decode(json).map(_.connection) == Right(
DbConfig.Connection(sslMode = DbConfig.SslMode.Require.some, socketTimeout = 30.seconds.some),
),
)
},
)

override def testSpec: TestSpec =
suite("DbConfigSpec")(
sslModeSpec,
propertiesSpec,
buildPropertiesSpec,
decodingSpec,
)

}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ object PostgresTestContainer {
password <- TestContainerService.randomAlphaString(10)
database <- TestContainerService.randomAlphaString(10)

dbConfig = DbConfig(DbConfig.Target(database, "localhost", port), DbConfig.Credentials(username, password).some, pool, logging, execution)
dbConfig = DbConfig(DbConfig.Target(database, "localhost", port), DbConfig.Credentials(username, password).some, DbConfig.Connection.default, pool, logging, execution)
container =
TestContainer
.make("postgres", "postgres", "latest")
Expand Down
Loading