Skip to content

Commit b52ff8e

Browse files
adamwclaude
andcommitted
Migrate database access from Magnum to parlance
Replaces the Magnum DB client (com.augustnagro:magnum:1.3.1) with parlance (ma.chinespirit:parlance:0.1.0). parlance is a Magnum-inspired but substantially redesigned Scala 3 ORM, so the migration adapts the API rather than just renaming the package: - Transactor[Postgres](Postgres, ds, SqlLogger.logSlowQueries(...)) with instance-level transact/connect; context type parameterized as DbTx[Postgres]/DbCon[Postgres]. - @table(SqlNameMapper.CamelToSnakeCase) + `derives EntityMeta`; PostgresDbType -> Postgres. - Repo[...]() with rawInsert; Spec -> QueryBuilder.from[E].where(...).limit(...). - infrastructure/Magnum.scala renamed to Codecs.scala (Instant/Id/Hashed/ LowerCased codecs preserved). - Logging: parlance uses java.lang.System.Logger (routed via slf4j-jdk-platform-logging), not JUL; comments updated. Backend compiles; non-DB tests pass and DB behaviour verified end-to-end against a real PostgreSQL (codecs, snake_case mapping, transactEither rollback, limit, slow-query logging). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 1769549 commit b52ff8e

16 files changed

Lines changed: 148 additions & 93 deletions

File tree

backend/src/main/scala/com/softwaremill/bootzooka/Main.scala

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import ox.OxApp.Settings
88
import ox.otel.context.PropagatingVirtualThreadFactory
99

1010
object Main extends OxApp.Simple with Logging:
11-
// route JUL to SLF4J (JUL is used by Magnum & OTEL for logging)
11+
// route JUL to SLF4J (JUL is used by OTEL for logging); parlance instead logs via java.lang.System.Logger,
12+
// which is routed to SLF4J by the slf4j-jdk-platform-logging runtime dependency, not by this bridge
1213
SLF4JBridgeHandler.removeHandlersForRootLogger()
1314
SLF4JBridgeHandler.install()
1415

backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,21 @@
11
package com.softwaremill.bootzooka.email
22

3-
import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, Spec, SqlNameMapper, Table}
4-
import com.softwaremill.bootzooka.infrastructure.Magnum.given
3+
import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, QueryBuilder, Repo, SqlNameMapper, Table}
4+
import com.softwaremill.bootzooka.infrastructure.Codecs.given
55
import com.softwaremill.bootzooka.util.Strings.Id
66
import ox.discard
77

88
/** Model for storing and retrieving scheduled emails. */
99
class EmailModel:
10-
private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]
10+
private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]()
1111

12-
def insert(email: Email)(using DbTx): Unit = emailRepo.insert(ScheduledEmails(email))
13-
def find(limit: Int)(using DbTx): Vector[Email] = emailRepo.findAll(Spec[ScheduledEmails].limit(limit)).map(_.toEmail)
14-
def count()(using DbTx): Long = emailRepo.count
15-
def delete(ids: Vector[Id[Email]])(using DbTx): Unit = emailRepo.deleteAllById(ids).discard
12+
def insert(email: Email)(using DbTx[Postgres]): Unit = emailRepo.rawInsert(ScheduledEmails(email))
13+
def find(limit: Int)(using DbTx[Postgres]): Vector[Email] = QueryBuilder.from[ScheduledEmails].limit(limit).run().map(_.toEmail)
14+
def count()(using DbTx[Postgres]): Long = emailRepo.count
15+
def delete(ids: Vector[Id[Email]])(using DbTx[Postgres]): Unit = emailRepo.deleteAllById(ids).discard
1616

17-
@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
18-
private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String):
17+
@Table(SqlNameMapper.CamelToSnakeCase)
18+
private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String) derives EntityMeta:
1919
def toEmail: Email = Email(id, EmailData(recipient, EmailSubjectContent(subject, content)))
2020

2121
private object ScheduledEmails:

backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.softwaremill.bootzooka.email
22

3-
import com.augustnagro.magnum.DbTx
3+
import ma.chinespirit.parlance.{DbTx, Postgres}
44
import com.softwaremill.bootzooka.email.sender.EmailSender
55
import com.softwaremill.bootzooka.infrastructure.DB
66
import com.softwaremill.bootzooka.logging.Logging
@@ -21,7 +21,7 @@ class EmailService(
2121
) extends EmailScheduler
2222
with Logging:
2323

24-
def schedule(data: EmailData)(using DbTx): Unit =
24+
def schedule(data: EmailData)(using DbTx[Postgres]): Unit =
2525
logger.debug(s"Scheduling email to be sent to: ${data.recipient}")
2626
val id = idGenerator.nextId[Email]()
2727
emailModel.insert(Email(id, data))
@@ -57,4 +57,4 @@ class EmailService(
5757
end EmailService
5858

5959
trait EmailScheduler:
60-
def schedule(data: EmailData)(using DbTx): Unit
60+
def schedule(data: EmailData)(using DbTx[Postgres]): Unit

backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala renamed to backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Codecs.scala

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,15 @@
11
package com.softwaremill.bootzooka.infrastructure
22

3-
import com.augustnagro.magnum.DbCodec
4-
import com.softwaremill.bootzooka.logging.Logging
3+
import ma.chinespirit.parlance.DbCodec
54
import com.softwaremill.bootzooka.util.Strings.*
65

76
import java.time.{Instant, OffsetDateTime, ZoneOffset}
87

9-
/** Magnum codecs for custom types, useful when writing SQL queries. */
10-
object Magnum extends Logging:
8+
/** parlance [[DbCodec]]s for custom types, useful when writing SQL queries. */
9+
object Codecs:
1110
given DbCodec[Instant] = summon[DbCodec[OffsetDateTime]].biMap(_.toInstant, _.atOffset(ZoneOffset.UTC))
1211

1312
given idCodec[T]: DbCodec[Id[T]] = DbCodec.StringCodec.biMap(_.asId[T], _.toString)
1413
given DbCodec[Hashed] = DbCodec.StringCodec.biMap(_.asHashed, _.toString)
1514
given DbCodec[LowerCased] = DbCodec.StringCodec.biMap(_.toLowerCased, _.toString)
16-
end Magnum
15+
end Codecs

backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.softwaremill.bootzooka.infrastructure
22

3-
import com.augustnagro.magnum.{DbCodec, DbTx, SqlLogger, Transactor, connect, sql}
3+
import ma.chinespirit.parlance.{DbTx, Postgres, SqlLogger, Transactor, sql}
44
import com.softwaremill.bootzooka.infrastructure.DB.LeftException
55
import com.softwaremill.bootzooka.logging.Logging
66
import com.zaxxer.hikari.{HikariConfig, HikariDataSource}
@@ -15,21 +15,20 @@ import scala.util.NotGiven
1515
import scala.util.control.{NoStackTrace, NonFatal}
1616

1717
class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable:
18-
private val transactor = Transactor(
19-
dataSource = dataSource,
20-
sqlLogger = SqlLogger.logSlowQueries(200.millis)
21-
)
18+
// the database type is pinned explicitly so that the `DbTx[Postgres]` context type matches throughout the codebase
19+
// (otherwise `Transactor(Postgres, ...)` would infer the singleton type `Postgres.type`)
20+
private val transactor = Transactor[Postgres](Postgres, dataSource, SqlLogger.logSlowQueries(200.millis))
2221

2322
/** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */
24-
def transactEither[E, T](f: DbTx ?=> Either[E, T]): Either[E, T] =
25-
try com.augustnagro.magnum.transact(transactor)(Right(f.fold(e => throw LeftException(e), identity)))
23+
def transactEither[E, T](f: DbTx[Postgres] ?=> Either[E, T]): Either[E, T] =
24+
try transactor.transact(Right(f.fold(e => throw LeftException(e), identity)))
2625
catch case e: LeftException[E] @unchecked => Left(e.left)
2726

2827
/** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if
2928
* no exception is thrown.
3029
*/
31-
def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T =
32-
com.augustnagro.magnum.transact(transactor)(f)
30+
def transact[T](f: DbTx[Postgres] ?=> T)(using NotGiven[T <:< Either[?, ?]]): T =
31+
transactor.transact(f)
3332

3433
override def close(): Unit = dataSource.close()
3534
end DB
@@ -56,7 +55,7 @@ object DB extends Logging:
5655
.load()
5756

5857
def migrate(): Unit = if config.migrateOnStart then flyway.migrate().discard
59-
def testConnection(ds: DataSource): Unit = connect(ds)(sql"SELECT 1".query[Int].run()).discard
58+
def testConnection(ds: DataSource): Unit = Transactor[Postgres](Postgres, ds).connect(sql"SELECT 1".query[Int].run()).discard
6059

6160
@tailrec
6261
def connectAndMigrate(ds: DataSource): Unit =

backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala

Lines changed: 10 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,28 @@
11
package com.softwaremill.bootzooka.passwordreset
22

3-
import com.augustnagro.magnum.{DbCodec, DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table}
4-
import com.softwaremill.bootzooka.infrastructure.Magnum.given
3+
import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, Repo, SqlName, SqlNameMapper, Table}
4+
import com.softwaremill.bootzooka.infrastructure.Codecs.given
55
import com.softwaremill.bootzooka.security.AuthTokenOps
66
import com.softwaremill.bootzooka.user.User
77
import com.softwaremill.bootzooka.util.Strings.Id
88

99
import java.time.Instant
1010

1111
class PasswordResetCodeModel:
12-
private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]
12+
private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]()
1313

14-
def insert(pr: PasswordResetCode)(using DbTx): Unit = passwordResetCodeRepo.insert(pr)
15-
def delete(id: Id[PasswordResetCode])(using DbTx): Unit = passwordResetCodeRepo.deleteById(id)
16-
def findById(id: Id[PasswordResetCode])(using DbTx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id)
14+
def insert(pr: PasswordResetCode)(using DbTx[Postgres]): Unit = passwordResetCodeRepo.rawInsert(pr)
15+
def delete(id: Id[PasswordResetCode])(using DbTx[Postgres]): Unit = passwordResetCodeRepo.deleteById(id)
16+
def findById(id: Id[PasswordResetCode])(using DbTx[Postgres]): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id)
1717

18-
@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
18+
@Table(SqlNameMapper.CamelToSnakeCase)
1919
@SqlName("password_reset_codes")
20-
case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant)
20+
case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant) derives EntityMeta
2121

2222
class PasswordResetAuthToken(passwordResetCodeModel: PasswordResetCodeModel) extends AuthTokenOps[PasswordResetCode]:
2323
override def tokenName: String = "PasswordResetCode"
24-
override def findById: DbTx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById
25-
override def delete: DbTx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id)
24+
override def findById: DbTx[Postgres] ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById
25+
override def delete: DbTx[Postgres] ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id)
2626
override def userId: PasswordResetCode => Id[User] = _.userId
2727
override def validUntil: PasswordResetCode => Instant = _.validUntil
2828
// password reset code is a one-time token

backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.softwaremill.bootzooka.passwordreset
22

3-
import com.augustnagro.magnum.DbTx
3+
import ma.chinespirit.parlance.{DbTx, Postgres}
44
import com.softwaremill.bootzooka.Fail
55
import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplates}
66
import com.softwaremill.bootzooka.infrastructure.DB
@@ -23,22 +23,22 @@ class PasswordResetService(
2323
clock: Clock,
2424
db: DB
2525
) extends Logging:
26-
def forgotPassword(loginOrEmail: String)(using DbTx): Unit =
26+
def forgotPassword(loginOrEmail: String)(using DbTx[Postgres]): Unit =
2727
userModel.findByLoginOrEmail(loginOrEmail.toLowerCased) match
2828
case None => logger.debug(s"Could not find user with $loginOrEmail login/email")
2929
case Some(user) =>
3030
val pcr = createCode(user)
3131
sendCode(user, pcr)
3232

33-
private def createCode(user: User)(using DbTx): PasswordResetCode =
33+
private def createCode(user: User)(using DbTx[Postgres]): PasswordResetCode =
3434
logger.debug(s"Creating password reset code for user: ${user.id}")
3535
val id = idGenerator.nextId[PasswordResetCode]()
3636
val validUntil = clock.now().plusMillis(config.codeValid.toMillis)
3737
val passwordResetCode = PasswordResetCode(id, user.id, validUntil)
3838
passwordResetCodeModel.insert(passwordResetCode)
3939
passwordResetCode
4040

41-
private def sendCode(user: User, code: PasswordResetCode)(using DbTx): Unit =
41+
private def sendCode(user: User, code: PasswordResetCode)(using DbTx[Postgres]): Unit =
4242
logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}")
4343
emailScheduler.schedule(EmailData(user.emailLowerCase, prepareResetEmail(user, code)))
4444

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,31 @@
11
package com.softwaremill.bootzooka.security
22

3-
import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql}
4-
import com.softwaremill.bootzooka.infrastructure.Magnum.given
3+
import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql}
4+
import com.softwaremill.bootzooka.infrastructure.Codecs.given
55
import com.softwaremill.bootzooka.user.User
66
import com.softwaremill.bootzooka.util.Strings.Id
77
import ox.discard
88

99
import java.time.Instant
1010

1111
class ApiKeyModel:
12-
private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]
12+
private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]()
1313
private val a = TableInfo[ApiKey, ApiKey, Id[ApiKey]]
1414

15-
def insert(apiKey: ApiKey)(using DbTx): Unit = apiKeyRepo.insert(apiKey)
16-
def findById(id: Id[ApiKey])(using DbTx): Option[ApiKey] = apiKeyRepo.findById(id)
17-
def deleteAllForUser(id: Id[User])(using DbTx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard
18-
def delete(id: Id[ApiKey])(using DbTx): Unit = apiKeyRepo.deleteById(id)
15+
def insert(apiKey: ApiKey)(using DbTx[Postgres]): Unit = apiKeyRepo.rawInsert(apiKey)
16+
def findById(id: Id[ApiKey])(using DbTx[Postgres]): Option[ApiKey] = apiKeyRepo.findById(id)
17+
def deleteAllForUser(id: Id[User])(using DbTx[Postgres]): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard
18+
def delete(id: Id[ApiKey])(using DbTx[Postgres]): Unit = apiKeyRepo.deleteById(id)
1919
end ApiKeyModel
2020

21-
@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)
21+
@Table(SqlNameMapper.CamelToSnakeCase)
2222
@SqlName("api_keys")
23-
case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant)
23+
case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant) derives EntityMeta
2424

2525
class ApiKeyAuthToken(apiKeyModel: ApiKeyModel) extends AuthTokenOps[ApiKey]:
2626
override def tokenName: String = "ApiKey"
27-
override def findById: DbTx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById
28-
override def delete: DbTx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id)
27+
override def findById: DbTx[Postgres] ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById
28+
override def delete: DbTx[Postgres] ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id)
2929
override def userId: ApiKey => Id[User] = _.userId
3030
override def validUntil: ApiKey => Instant = _.validUntil
3131
override def deleteWhenValid: Boolean = false
Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.softwaremill.bootzooka.security
22

3-
import com.augustnagro.magnum.DbTx
3+
import ma.chinespirit.parlance.{DbTx, Postgres}
44
import com.softwaremill.bootzooka.logging.Logging
55
import com.softwaremill.bootzooka.user.User
66
import com.softwaremill.bootzooka.util.Strings.Id
@@ -10,7 +10,7 @@ import java.time.temporal.ChronoUnit
1010
import scala.concurrent.duration.Duration
1111

1212
class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: Clock) extends Logging:
13-
def create(userId: Id[User], valid: Duration)(using DbTx): ApiKey =
13+
def create(userId: Id[User], valid: Duration)(using DbTx[Postgres]): ApiKey =
1414
val id = idGenerator.nextId[ApiKey]()
1515
val now = clock.now()
1616
val validUntil = now.plus(valid.toMillis, ChronoUnit.MILLIS)
@@ -20,11 +20,11 @@ class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: C
2020
apiKey
2121
end create
2222

23-
def invalidate(id: Id[ApiKey])(using DbTx): Unit =
23+
def invalidate(id: Id[ApiKey])(using DbTx[Postgres]): Unit =
2424
logger.debug(s"Invalidating api key $id")
2525
apiKeyModel.delete(id)
2626

27-
def invalidateAllForUser(userId: Id[User])(using DbTx): Unit =
27+
def invalidateAllForUser(userId: Id[User])(using DbTx[Postgres]): Unit =
2828
logger.debug(s"Invalidating all api keys for user $userId")
2929
apiKeyModel.deleteAllForUser(userId)
3030
end ApiKeyService

backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.softwaremill.bootzooka.security
22

3-
import com.augustnagro.magnum.DbTx
3+
import ma.chinespirit.parlance.{DbTx, Postgres}
44
import com.softwaremill.bootzooka.*
55
import com.softwaremill.bootzooka.infrastructure.DB
66
import com.softwaremill.bootzooka.logging.Logging
@@ -44,8 +44,8 @@ end Auth
4444
*/
4545
trait AuthTokenOps[T]:
4646
def tokenName: String
47-
def findById: DbTx ?=> Id[T] => Option[T]
48-
def delete: DbTx ?=> T => Unit
47+
def findById: DbTx[Postgres] ?=> Id[T] => Option[T]
48+
def delete: DbTx[Postgres] ?=> T => Unit
4949
def userId: T => Id[User]
5050
def validUntil: T => Instant
5151
def deleteWhenValid: Boolean

0 commit comments

Comments
 (0)