From 51b3569381c79997d1da65f6e84c7a94937edb85 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 08:14:14 +0000 Subject: [PATCH 1/8] task: Resolve parlance API and swap the build dependency --- build.sbt | 7 +++-- parlance-migration-notes.md | 57 +++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 parlance-migration-notes.md diff --git a/build.sbt b/build.sbt index 048281db9..9b159f012 100644 --- a/build.sbt +++ b/build.sbt @@ -14,7 +14,7 @@ val otelVersion = "1.62.0" val otelInstrumentationVersion = "2.17.1-alpha" val dbDependencies = Seq( - "com.augustnagro" %% "magnum" % "1.3.1", // Scala DB client + "ma.chinespirit" %% "parlance" % "0.1.0", // Scala DB client (Magnum-inspired) "org.postgresql" % "postgresql" % "42.7.11", // JDBC driver "com.zaxxer" % "HikariCP" % "7.0.2", // connection pool "org.flywaydb" % "flyway-database-postgresql" % "12.6.2" // database migrations @@ -47,9 +47,10 @@ val jsonDependencies = Seq( val loggingDependencies = Seq( "ch.qos.logback" % "logback-classic" % "1.5.33", // main logging library - "org.slf4j" % "jul-to-slf4j" % "2.0.18", // forward e.g. OTEL and Magnum logs which use JUL to SLF4J + "org.slf4j" % "jul-to-slf4j" % "2.0.18", // forward e.g. OTEL logs which use JUL to SLF4J "com.softwaremill.ox" %% "mdc-logback" % oxVersion, // support MDCs which propagate within Ox scopes - "org.slf4j" % "slf4j-jdk-platform-logging" % "2.0.18" % Runtime // route Java's platform logging (separate from JUL) to SLF4J + // route Java's platform logging (java.lang.System.Logger, separate from JUL) to SLF4J; parlance logs via this + "org.slf4j" % "slf4j-jdk-platform-logging" % "2.0.18" % Runtime ) val configDependencies = Seq( diff --git a/parlance-migration-notes.md b/parlance-migration-notes.md new file mode 100644 index 000000000..21a3ed9ea --- /dev/null +++ b/parlance-migration-notes.md @@ -0,0 +1,57 @@ +# Magnum → parlance migration notes + +Scratch note for the multi-step migration on branch `migrate-magnum-to-parlance`. +Produced by the dependency-swap task; consumed by later code-migration tasks. + +## Dependency coordinates (confirmed) + +- **sbt:** `"ma.chinespirit" %% "parlance" % "0.1.0"` (replaces `"com.augustnagro" %% "magnum" % "1.3.1"`) +- Resolved artifact: `ma/chinespirit/parlance_3/0.1.0/parlance_3-0.1.0.jar` from Maven Central (`sbt backend/update` succeeds). +- **Scala 3.8.3:** parlance publishes a single Scala-3 artifact `parlance_3` (built with 3.8.2 per its `build.sbt`; no `crossScalaVersions`). Scala 3 is binary/TASTy-forward-compatible, so the `_3` artifact resolves and compiles under this project's 3.8.3. There is no separate 3.8.3 cross-build — the `_3` artifact is the cross-build. +- Source/package: `ma.chinespirit.parlance` (GitHub `lbialy/parlance`, group domain `chinespirit.ma`). Other modules exist but are **not** needed: `parlance-pg`, `parlance-migrate`. + +## Logging (build.sbt review done) + +parlance's `SqlLogger` logs via **`java.lang.System.Logger`** (platform logging), **not** JUL — same mechanism Magnum used. It is routed to SLF4J by `slf4j-jdk-platform-logging` (build.sbt line ~52), **not** by `jul-to-slf4j` (line ~50). The old line-50 comment claiming Magnum "uses JUL" was inaccurate; updated so line 50 mentions only OTEL and line 52 notes parlance. + +## IMPORTANT: parlance is NOT a drop-in Magnum fork + +parlance 0.1.0 is a redesigned, Active-Record-inspired ORM (92 core source files vs Magnum's handful). The low-level names overlap, but several APIs the migration assumed differ. The code migration is **substantial**, not a package rename. + +## API mapping + +| Magnum (current) | parlance | Notes | +|---|---|---| +| `import com.augustnagro.magnum.*` | `import ma.chinespirit.parlance.*` | package rename | +| `DbCodec`, `.biMap(to, from)` | same | ✓ identical | +| `DbCodec.StringCodec` | `DbCodec[String]` (i.e. `summon[DbCodec[String]]`) | `StringCodec` given exists; prefer `DbCodec[String]` | +| `summon[DbCodec[OffsetDateTime]]` | same | ✓ `OffsetDateTimeCodec` given exists | +| custom `given DbCodec[Instant]` (in `infrastructure/Magnum.scala`) | **remove** | parlance already ships `given DbCodec[Instant]`; keeping the custom one is an ambiguous-given conflict | +| `Transactor(dataSource = ds, sqlLogger = ...)` | `Transactor(Postgres, ds, SqlLogger.logSlowQueries(200.millis))` | **DB type is now a required first arg**; `Transactor[D <: DatabaseType]` | +| `SqlLogger.logSlowQueries(200.millis)` | same | ✓ | +| top-level `transact(transactor)(f)` | **`transactor.transact(f)`** (instance method) | `def transact[T](f: DbTx[D] ?=> T): T` | +| top-level `connect(ds)(f)` | **`transactor.connect(f)`** (instance method) | `connect` takes no DataSource — needs a `Transactor`; `def connect[T](f: DbCon[D] ?=> T): T`. DB.scala `testConnection(ds)` must build/reuse a Transactor instead of passing a raw `DataSource`. | +| `(using DbTx)`, `DbTx ?=> T` | **`(using DbTx[Postgres])`, `DbTx[Postgres] ?=> T`** | `DbCon`/`DbTx` are parameterized by DB type. Touches *every* model/service/api file with a `DbTx` param (UserModel, UserService, ApiKeyModel, ApiKeyService, Auth, PasswordResetCodeModel, PasswordResetService, EmailModel, EmailService, the `*Api` files, `AuthTokenOps`). Read-only sites may use `DbCon[Postgres]`. | +| `sql"..."` interpolator | same | ✓ available via wildcard import; if selectively imported, confirm the `sql` name is importable | +| `.query[T].run()`, `.update.run()` | same | ✓ require a `DbCon`/`DbTx` in scope | +| `Frag` | same | ✓ exists | +| `Repo[EC, E, ID]` (no parens) | `Repo[EC, E, ID]()` | parlance `Repo` is an `open class` with default ctor args — needs `()` | +| `userRepo.insert(e)` | **`userRepo.rawInsert(e)`** (or `create`) | no `insert` method; `rawInsert(ec)` inserts, `create(ec): E` inserts & returns the entity | +| `findById`, `findAll`, `count`, `deleteById`, `deleteAllById` | same | ✓ (read ops on `ImmutableRepo[E, ID]`) | +| `TableInfo[EC, E, ID]` + `u.colName` + `sql"$u"` | `TableInfo[EC, E, ID]` | exists; exact column-ref / table-interpolation API **unverified — confirm during code migration** | +| `Spec[E].where(sql"...")` / `.limit(n)` | **`QueryBuilder.from[E].where(...).limit(n).run()`** | **`Spec` does not exist.** `.where` idiomatically takes a typed lambda (`_.field > x`); whether it accepts a raw `WhereFrag`/`sql"..."` is **unverified**. Affects `UserModel.findBy*` and `EmailModel.find(limit)`. | +| entity = plain `case class` + `@Table` | `case class ... derives EntityMeta` | **entities must add `derives EntityMeta`** (User, ApiKey, PasswordResetCode, ScheduledEmails). Creator types (if EC≠E) use `derives DbCodec`. | +| `@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)` | **`@Table(SqlNameMapper.CamelToSnakeCase)`** | `@Table` takes only a `nameMapper`; the DB type moved to the `Transactor` type param | +| `PostgresDbType` | **`Postgres`** (`object Postgres extends Postgres`, in `DatabaseType.scala`) | used as the `Transactor` arg / `DbTx[Postgres]` type, no longer in `@Table` | +| `@SqlName("...")` | same | ✓ | +| `SqlNameMapper.CamelToSnakeCase` | same | ✓ | + +## Open items for code-migration tasks + +1. `Spec` → `QueryBuilder.from[E]`: verify whether `.where` accepts raw `sql`/`WhereFrag` or only typed lambdas; rewrite `UserModel.findByEmail/findByLogin/findByLoginOrEmail` and `EmailModel.find` accordingly. +2. `TableInfo` column-reference + `sql"$u"` table interpolation: confirm the API matches Magnum's usage in `UserModel`/`ApiKeyModel`. +3. Add `derives EntityMeta` to all entities; drop the DB-type arg from every `@Table`. +4. Parameterize all `DbTx`/`DbCon` usages with `[Postgres]`. +5. Rework `DB.scala`: `Transactor(Postgres, ds, ...)`, instance `transact`/`connect`, and `testConnection` (no raw-DataSource `connect`). +6. Remove the custom `given DbCodec[Instant]`; switch `DbCodec.StringCodec` → `DbCodec[String]`. +7. `insert` → `rawInsert`; add `()` to `Repo[...]` constructions. From b1b539b4ac2bd2033694c934c7e5673f4df93d1b Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 08:21:30 +0000 Subject: [PATCH 2/8] task: Migrate the infrastructure layer (DB.scala and Magnum.scala) to parlance --- .../bootzooka/email/EmailModel.scala | 2 +- .../{Magnum.scala => Codecs.scala} | 9 ++++----- .../bootzooka/infrastructure/DB.scala | 19 +++++++++---------- .../PasswordResetCodeModel.scala | 2 +- .../bootzooka/security/ApiKeyModel.scala | 2 +- .../bootzooka/user/UserModel.scala | 2 +- docs/devtips.md | 6 +++--- parlance-migration-notes.md | 14 +++++++------- 8 files changed, 27 insertions(+), 29 deletions(-) rename backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/{Magnum.scala => Codecs.scala} (71%) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala index e9b2f4d50..5b4848510 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala @@ -1,7 +1,7 @@ package com.softwaremill.bootzooka.email import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, Spec, SqlNameMapper, Table} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.util.Strings.Id import ox.discard diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Codecs.scala similarity index 71% rename from backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala rename to backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Codecs.scala index f942c7c1b..89f89116e 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Magnum.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Codecs.scala @@ -1,16 +1,15 @@ package com.softwaremill.bootzooka.infrastructure -import com.augustnagro.magnum.DbCodec -import com.softwaremill.bootzooka.logging.Logging +import ma.chinespirit.parlance.DbCodec import com.softwaremill.bootzooka.util.Strings.* import java.time.{Instant, OffsetDateTime, ZoneOffset} -/** Magnum codecs for custom types, useful when writing SQL queries. */ -object Magnum extends Logging: +/** parlance [[DbCodec]]s for custom types, useful when writing SQL queries. */ +object Codecs: given DbCodec[Instant] = summon[DbCodec[OffsetDateTime]].biMap(_.toInstant, _.atOffset(ZoneOffset.UTC)) given idCodec[T]: DbCodec[Id[T]] = DbCodec.StringCodec.biMap(_.asId[T], _.toString) given DbCodec[Hashed] = DbCodec.StringCodec.biMap(_.asHashed, _.toString) given DbCodec[LowerCased] = DbCodec.StringCodec.biMap(_.toLowerCased, _.toString) -end Magnum +end Codecs diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala index f30a6d62e..620499b69 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.infrastructure -import com.augustnagro.magnum.{DbCodec, DbTx, SqlLogger, Transactor, connect, sql} +import ma.chinespirit.parlance.{DbTx, Postgres, SqlLogger, Transactor, sql} import com.softwaremill.bootzooka.infrastructure.DB.LeftException import com.softwaremill.bootzooka.logging.Logging import com.zaxxer.hikari.{HikariConfig, HikariDataSource} @@ -15,21 +15,20 @@ import scala.util.NotGiven import scala.util.control.{NoStackTrace, NonFatal} class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable: - private val transactor = Transactor( - dataSource = dataSource, - sqlLogger = SqlLogger.logSlowQueries(200.millis) - ) + // the database type is pinned explicitly so that the `DbTx[Postgres]` context type matches throughout the codebase + // (otherwise `Transactor(Postgres, ...)` would infer the singleton type `Postgres.type`) + private val transactor = Transactor[Postgres](Postgres, dataSource, SqlLogger.logSlowQueries(200.millis)) /** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */ - def transactEither[E, T](f: DbTx ?=> Either[E, T]): Either[E, T] = - try com.augustnagro.magnum.transact(transactor)(Right(f.fold(e => throw LeftException(e), identity))) + def transactEither[E, T](f: DbTx[Postgres] ?=> Either[E, T]): Either[E, T] = + try transactor.transact(Right(f.fold(e => throw LeftException(e), identity))) catch case e: LeftException[E] @unchecked => Left(e.left) /** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if * no exception is thrown. */ - def transact[T](f: DbTx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = - com.augustnagro.magnum.transact(transactor)(f) + def transact[T](f: DbTx[Postgres] ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = + transactor.transact(f) override def close(): Unit = dataSource.close() end DB @@ -56,7 +55,7 @@ object DB extends Logging: .load() def migrate(): Unit = if config.migrateOnStart then flyway.migrate().discard - def testConnection(ds: DataSource): Unit = connect(ds)(sql"SELECT 1".query[Int].run()).discard + def testConnection(ds: DataSource): Unit = Transactor[Postgres](Postgres, ds).connect(sql"SELECT 1".query[Int].run()).discard @tailrec def connectAndMigrate(ds: DataSource): Unit = diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala index e6ef57a7e..8257ab7cb 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala @@ -1,7 +1,7 @@ package com.softwaremill.bootzooka.passwordreset import com.augustnagro.magnum.{DbCodec, DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.security.AuthTokenOps import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala index b5133fdce..c15ca4c95 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala @@ -1,7 +1,7 @@ package com.softwaremill.bootzooka.security import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id import ox.discard diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala index 68bc9e2fe..60c09395e 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala @@ -2,7 +2,7 @@ package com.softwaremill.bootzooka.user import com.augustnagro.magnum.{DbCodec, DbTx, Frag, PostgresDbType, Repo, Spec, SqlName, SqlNameMapper, Table, TableInfo, sql} import com.password4j.{Argon2Function, Password} -import com.softwaremill.bootzooka.infrastructure.Magnum.given +import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.user.User.PasswordHashing import com.softwaremill.bootzooka.user.User.PasswordHashing.Argon2Config.* import com.softwaremill.bootzooka.util.PasswordVerificationStatus diff --git a/docs/devtips.md b/docs/devtips.md index f9b9a4089..7f5aa4b42 100644 --- a/docs/devtips.md +++ b/docs/devtips.md @@ -44,11 +44,11 @@ There are two imports that are useful when developing a new functionality: If you are defining database queries or running transactions, add the following imports: ```scala -import com.softwaremill.bootzooka.infrastructure.Magnum.given -import com.augustnagro.magnum.{sql, DbTx} +import com.softwaremill.bootzooka.infrastructure.Codecs.given +import ma.chinespirit.parlance.{sql, DbTx, Postgres} ``` -This will bring into scope custom [Magnum](https://github.com/AugustNagro/magnum) codecs, the sql query interpolator +This will bring into scope custom [parlance](https://github.com/lbialy/parlance) codecs, the sql query interpolator as well as the given instance which is required by methods that should run in a transaction. ### HTTP API diff --git a/parlance-migration-notes.md b/parlance-migration-notes.md index 21a3ed9ea..0810e8add 100644 --- a/parlance-migration-notes.md +++ b/parlance-migration-notes.md @@ -24,13 +24,13 @@ parlance 0.1.0 is a redesigned, Active-Record-inspired ORM (92 core source files |---|---|---| | `import com.augustnagro.magnum.*` | `import ma.chinespirit.parlance.*` | package rename | | `DbCodec`, `.biMap(to, from)` | same | ✓ identical | -| `DbCodec.StringCodec` | `DbCodec[String]` (i.e. `summon[DbCodec[String]]`) | `StringCodec` given exists; prefer `DbCodec[String]` | +| `DbCodec.StringCodec` | `DbCodec.StringCodec` (also `DbCodec[String]`) | ✓ `StringCodec` is a given in `object DbCodec`; `DbCodec.StringCodec.biMap(...)` compiles as-is | | `summon[DbCodec[OffsetDateTime]]` | same | ✓ `OffsetDateTimeCodec` given exists | -| custom `given DbCodec[Instant]` (in `infrastructure/Magnum.scala`) | **remove** | parlance already ships `given DbCodec[Instant]`; keeping the custom one is an ambiguous-given conflict | -| `Transactor(dataSource = ds, sqlLogger = ...)` | `Transactor(Postgres, ds, SqlLogger.logSlowQueries(200.millis))` | **DB type is now a required first arg**; `Transactor[D <: DatabaseType]` | +| custom `given DbCodec[Instant]` (in `infrastructure/Codecs.scala`) | **keep** | parlance ships `given InstantCodec: DbCodec[Instant]` in `object DbCodec`, but an *imported* given (via `import Codecs.given`) takes precedence over implicit/companion scope — **verified: no ambiguity**. (Corrects the earlier note that said to remove it.) | +| `Transactor(dataSource = ds, sqlLogger = ...)` | `Transactor[Postgres](Postgres, ds, SqlLogger.logSlowQueries(200.millis))` | **DB type is a required first arg** AND must be **pinned** as `[Postgres]` — `Transactor(Postgres, ...)` infers the singleton `Postgres.type`, which then mismatches `DbTx[Postgres]` everywhere. | | `SqlLogger.logSlowQueries(200.millis)` | same | ✓ | -| top-level `transact(transactor)(f)` | **`transactor.transact(f)`** (instance method) | `def transact[T](f: DbTx[D] ?=> T): T` | -| top-level `connect(ds)(f)` | **`transactor.connect(f)`** (instance method) | `connect` takes no DataSource — needs a `Transactor`; `def connect[T](f: DbCon[D] ?=> T): T`. DB.scala `testConnection(ds)` must build/reuse a Transactor instead of passing a raw `DataSource`. | +| top-level `transact(transactor)(f)` | **`transactor.transact(f)`** (instance method) | `def transact[T](f: DbTx[D] ?=> T): T`. Rolls back only on a thrown exception (then rethrows) — so the `LeftException` rollback-on-`Left` trick still works unchanged. | +| top-level `connect(ds)(f)` | **`transactor.connect(f)`** (instance method) | `connect` takes no DataSource — needs a `Transactor`; `def connect[T](f: DbCon[D] ?=> T): T`. DB.scala `testConnection(ds)` builds a throwaway `Transactor[Postgres](Postgres, ds)` to run the test query. | | `(using DbTx)`, `DbTx ?=> T` | **`(using DbTx[Postgres])`, `DbTx[Postgres] ?=> T`** | `DbCon`/`DbTx` are parameterized by DB type. Touches *every* model/service/api file with a `DbTx` param (UserModel, UserService, ApiKeyModel, ApiKeyService, Auth, PasswordResetCodeModel, PasswordResetService, EmailModel, EmailService, the `*Api` files, `AuthTokenOps`). Read-only sites may use `DbCon[Postgres]`. | | `sql"..."` interpolator | same | ✓ available via wildcard import; if selectively imported, confirm the `sql` name is importable | | `.query[T].run()`, `.update.run()` | same | ✓ require a `DbCon`/`DbTx` in scope | @@ -52,6 +52,6 @@ parlance 0.1.0 is a redesigned, Active-Record-inspired ORM (92 core source files 2. `TableInfo` column-reference + `sql"$u"` table interpolation: confirm the API matches Magnum's usage in `UserModel`/`ApiKeyModel`. 3. Add `derives EntityMeta` to all entities; drop the DB-type arg from every `@Table`. 4. Parameterize all `DbTx`/`DbCon` usages with `[Postgres]`. -5. Rework `DB.scala`: `Transactor(Postgres, ds, ...)`, instance `transact`/`connect`, and `testConnection` (no raw-DataSource `connect`). -6. Remove the custom `given DbCodec[Instant]`; switch `DbCodec.StringCodec` → `DbCodec[String]`. +5. ~~Rework `DB.scala`~~ **DONE** (and `infrastructure/Magnum.scala` → `infrastructure/Codecs.scala`, with the four import sites updated). +6. ~~Remove the custom `given DbCodec[Instant]`~~ — keep it; `DbCodec.StringCodec` works as-is (see table). 7. `insert` → `rawInsert`; add `()` to `Repo[...]` constructions. From 60ac12267cbce874bb495620773c9f3bcbd5fbc1 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 08:31:43 +0000 Subject: [PATCH 3/8] task: Migrate the four model classes to parlance --- .../bootzooka/email/EmailModel.scala | 16 ++++----- .../PasswordResetCodeModel.scala | 18 +++++----- .../bootzooka/security/ApiKeyModel.scala | 20 +++++------ .../bootzooka/user/UserModel.scala | 34 +++++++++---------- 4 files changed, 43 insertions(+), 45 deletions(-) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala index 5b4848510..7ffcc38b2 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala @@ -1,21 +1,21 @@ package com.softwaremill.bootzooka.email -import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, Spec, SqlNameMapper, Table} +import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, QueryBuilder, Repo, SqlNameMapper, Table} import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.util.Strings.Id import ox.discard /** Model for storing and retrieving scheduled emails. */ class EmailModel: - private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]] + private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]() - def insert(email: Email)(using DbTx): Unit = emailRepo.insert(ScheduledEmails(email)) - def find(limit: Int)(using DbTx): Vector[Email] = emailRepo.findAll(Spec[ScheduledEmails].limit(limit)).map(_.toEmail) - def count()(using DbTx): Long = emailRepo.count - def delete(ids: Vector[Id[Email]])(using DbTx): Unit = emailRepo.deleteAllById(ids).discard + def insert(email: Email)(using DbTx[Postgres]): Unit = emailRepo.rawInsert(ScheduledEmails(email)) + def find(limit: Int)(using DbTx[Postgres]): Vector[Email] = QueryBuilder.from[ScheduledEmails].limit(limit).run().map(_.toEmail) + def count()(using DbTx[Postgres]): Long = emailRepo.count + def delete(ids: Vector[Id[Email]])(using DbTx[Postgres]): Unit = emailRepo.deleteAllById(ids).discard -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) -private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String): +@Table(SqlNameMapper.CamelToSnakeCase) +private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String) derives EntityMeta: def toEmail: Email = Email(id, EmailData(recipient, EmailSubjectContent(subject, content))) private object ScheduledEmails: diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala index 8257ab7cb..09ac2c8ec 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.passwordreset -import com.augustnagro.magnum.{DbCodec, DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table} +import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, Repo, SqlName, SqlNameMapper, Table} import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.security.AuthTokenOps import com.softwaremill.bootzooka.user.User @@ -9,20 +9,20 @@ import com.softwaremill.bootzooka.util.Strings.Id import java.time.Instant class PasswordResetCodeModel: - private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]] + private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]() - def insert(pr: PasswordResetCode)(using DbTx): Unit = passwordResetCodeRepo.insert(pr) - def delete(id: Id[PasswordResetCode])(using DbTx): Unit = passwordResetCodeRepo.deleteById(id) - def findById(id: Id[PasswordResetCode])(using DbTx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id) + def insert(pr: PasswordResetCode)(using DbTx[Postgres]): Unit = passwordResetCodeRepo.rawInsert(pr) + def delete(id: Id[PasswordResetCode])(using DbTx[Postgres]): Unit = passwordResetCodeRepo.deleteById(id) + def findById(id: Id[PasswordResetCode])(using DbTx[Postgres]): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id) -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) +@Table(SqlNameMapper.CamelToSnakeCase) @SqlName("password_reset_codes") -case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant) +case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validUntil: Instant) derives EntityMeta class PasswordResetAuthToken(passwordResetCodeModel: PasswordResetCodeModel) extends AuthTokenOps[PasswordResetCode]: override def tokenName: String = "PasswordResetCode" - override def findById: DbTx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById - override def delete: DbTx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id) + override def findById: DbTx[Postgres] ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById + override def delete: DbTx[Postgres] ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id) override def userId: PasswordResetCode => Id[User] = _.userId override def validUntil: PasswordResetCode => Instant = _.validUntil // password reset code is a one-time token diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala index c15ca4c95..afca36e38 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.security -import com.augustnagro.magnum.{DbTx, PostgresDbType, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} +import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id @@ -9,23 +9,23 @@ import ox.discard import java.time.Instant class ApiKeyModel: - private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]] + private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]() private val a = TableInfo[ApiKey, ApiKey, Id[ApiKey]] - def insert(apiKey: ApiKey)(using DbTx): Unit = apiKeyRepo.insert(apiKey) - def findById(id: Id[ApiKey])(using DbTx): Option[ApiKey] = apiKeyRepo.findById(id) - def deleteAllForUser(id: Id[User])(using DbTx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard - def delete(id: Id[ApiKey])(using DbTx): Unit = apiKeyRepo.deleteById(id) + def insert(apiKey: ApiKey)(using DbTx[Postgres]): Unit = apiKeyRepo.rawInsert(apiKey) + def findById(id: Id[ApiKey])(using DbTx[Postgres]): Option[ApiKey] = apiKeyRepo.findById(id) + def deleteAllForUser(id: Id[User])(using DbTx[Postgres]): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard + def delete(id: Id[ApiKey])(using DbTx[Postgres]): Unit = apiKeyRepo.deleteById(id) end ApiKeyModel -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) +@Table(SqlNameMapper.CamelToSnakeCase) @SqlName("api_keys") -case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant) +case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUntil: Instant) derives EntityMeta class ApiKeyAuthToken(apiKeyModel: ApiKeyModel) extends AuthTokenOps[ApiKey]: override def tokenName: String = "ApiKey" - override def findById: DbTx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById - override def delete: DbTx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id) + override def findById: DbTx[Postgres] ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById + override def delete: DbTx[Postgres] ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id) override def userId: ApiKey => Id[User] = _.userId override def validUntil: ApiKey => Instant = _.validUntil override def deleteWhenValid: Boolean = false diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala index 60c09395e..0f3d10a47 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.user -import com.augustnagro.magnum.{DbCodec, DbTx, Frag, PostgresDbType, Repo, Spec, SqlName, SqlNameMapper, Table, TableInfo, sql} +import ma.chinespirit.parlance.{DbTx, EntityMeta, Frag, Postgres, QueryBuilder, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql, unsafeAsWhere} import com.password4j.{Argon2Function, Password} import com.softwaremill.bootzooka.infrastructure.Codecs.given import com.softwaremill.bootzooka.user.User.PasswordHashing @@ -12,37 +12,35 @@ import ox.discard import java.time.Instant class UserModel: - private val userRepo = Repo[User, User, Id[User]] + private val userRepo = Repo[User, User, Id[User]]() private val u = TableInfo[User, User, Id[User]] - export userRepo.{insert, findById} + export userRepo.{rawInsert as insert, findById} - def findByEmail(email: LowerCased)(using DbTx): Option[User] = findBy( - Spec[User].where(sql"${u.emailLowerCase} = $email") - ) - def findByLogin(login: LowerCased)(using DbTx): Option[User] = findBy( - Spec[User].where(sql"${u.loginLowerCase} = $login") - ) - def findByLoginOrEmail(loginOrEmail: LowerCased)(using DbTx): Option[User] = - findBy(Spec[User].where(sql"${u.loginLowerCase} = ${loginOrEmail: String} OR ${u.emailLowerCase} = $loginOrEmail")) + def findByEmail(email: LowerCased)(using DbTx[Postgres]): Option[User] = + findBy(sql"${u.emailLowerCase} = $email") + def findByLogin(login: LowerCased)(using DbTx[Postgres]): Option[User] = + findBy(sql"${u.loginLowerCase} = $login") + def findByLoginOrEmail(loginOrEmail: LowerCased)(using DbTx[Postgres]): Option[User] = + findBy(sql"${u.loginLowerCase} = ${loginOrEmail: String} OR ${u.emailLowerCase} = $loginOrEmail") - private def findBy(by: Spec[User])(using DbTx): Option[User] = - userRepo.findAll(by).headOption + private def findBy(condition: Frag)(using DbTx[Postgres]): Option[User] = + QueryBuilder.from[User].where(condition.unsafeAsWhere).first() - def updatePassword(userId: Id[User], newPassword: Hashed)(using DbTx): Unit = + def updatePassword(userId: Id[User], newPassword: Hashed)(using DbTx[Postgres]): Unit = sql"""UPDATE $u SET ${u.passwordHash} = $newPassword WHERE ${u.id} = $userId""".update.run().discard - def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using DbTx): Unit = + def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using DbTx[Postgres]): Unit = sql"""UPDATE $u SET ${u.login} = $newLogin, login_lowercase = ${newLoginLowerCase: String} WHERE ${u.id} = $userId""".update .run() .discard - def updateEmail(userId: Id[User], newEmail: LowerCased)(using DbTx): Unit = + def updateEmail(userId: Id[User], newEmail: LowerCased)(using DbTx[Postgres]): Unit = sql"""UPDATE $u SET ${u.emailLowerCase} = $newEmail WHERE ${u.id} = $userId""".update.run().discard end UserModel -@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase) +@Table(SqlNameMapper.CamelToSnakeCase) @SqlName("users") case class User( id: Id[User], @@ -51,7 +49,7 @@ case class User( @SqlName("email_lowercase") emailLowerCase: LowerCased, @SqlName("password") passwordHash: Hashed, createdOn: Instant -): +) derives EntityMeta: def verifyPassword(password: String): PasswordVerificationStatus = if Password.check(password, passwordHash).`with`(PasswordHashing.Argon2) then PasswordVerificationStatus.Verified else PasswordVerificationStatus.VerificationFailed From bbb22a61206ece070f6ac10772b56595176c2ad8 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 08:35:43 +0000 Subject: [PATCH 4/8] task: Migrate the DbTx import in service and auth classes --- .../bootzooka/email/EmailService.scala | 6 +++--- .../passwordreset/PasswordResetService.scala | 8 ++++---- .../bootzooka/security/ApiKeyService.scala | 8 ++++---- .../com/softwaremill/bootzooka/security/Auth.scala | 6 +++--- .../softwaremill/bootzooka/user/UserService.scala | 14 +++++++------- 5 files changed, 21 insertions(+), 21 deletions(-) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala index a3416e8b8..b488b0300 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.email -import com.augustnagro.magnum.DbTx +import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.email.sender.EmailSender import com.softwaremill.bootzooka.infrastructure.DB import com.softwaremill.bootzooka.logging.Logging @@ -21,7 +21,7 @@ class EmailService( ) extends EmailScheduler with Logging: - def schedule(data: EmailData)(using DbTx): Unit = + def schedule(data: EmailData)(using DbTx[Postgres]): Unit = logger.debug(s"Scheduling email to be sent to: ${data.recipient}") val id = idGenerator.nextId[Email]() emailModel.insert(Email(id, data)) @@ -57,4 +57,4 @@ class EmailService( end EmailService trait EmailScheduler: - def schedule(data: EmailData)(using DbTx): Unit + def schedule(data: EmailData)(using DbTx[Postgres]): Unit diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala index d289077cd..bada0bef4 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.passwordreset -import com.augustnagro.magnum.DbTx +import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.Fail import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplates} import com.softwaremill.bootzooka.infrastructure.DB @@ -23,14 +23,14 @@ class PasswordResetService( clock: Clock, db: DB ) extends Logging: - def forgotPassword(loginOrEmail: String)(using DbTx): Unit = + def forgotPassword(loginOrEmail: String)(using DbTx[Postgres]): Unit = userModel.findByLoginOrEmail(loginOrEmail.toLowerCased) match case None => logger.debug(s"Could not find user with $loginOrEmail login/email") case Some(user) => val pcr = createCode(user) sendCode(user, pcr) - private def createCode(user: User)(using DbTx): PasswordResetCode = + private def createCode(user: User)(using DbTx[Postgres]): PasswordResetCode = logger.debug(s"Creating password reset code for user: ${user.id}") val id = idGenerator.nextId[PasswordResetCode]() val validUntil = clock.now().plusMillis(config.codeValid.toMillis) @@ -38,7 +38,7 @@ class PasswordResetService( passwordResetCodeModel.insert(passwordResetCode) passwordResetCode - private def sendCode(user: User, code: PasswordResetCode)(using DbTx): Unit = + private def sendCode(user: User, code: PasswordResetCode)(using DbTx[Postgres]): Unit = logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}") emailScheduler.schedule(EmailData(user.emailLowerCase, prepareResetEmail(user, code))) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala index 13a79cc40..b42803c21 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.security -import com.augustnagro.magnum.DbTx +import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id @@ -10,7 +10,7 @@ import java.time.temporal.ChronoUnit import scala.concurrent.duration.Duration class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: Clock) extends Logging: - def create(userId: Id[User], valid: Duration)(using DbTx): ApiKey = + def create(userId: Id[User], valid: Duration)(using DbTx[Postgres]): ApiKey = val id = idGenerator.nextId[ApiKey]() val now = clock.now() val validUntil = now.plus(valid.toMillis, ChronoUnit.MILLIS) @@ -20,11 +20,11 @@ class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: C apiKey end create - def invalidate(id: Id[ApiKey])(using DbTx): Unit = + def invalidate(id: Id[ApiKey])(using DbTx[Postgres]): Unit = logger.debug(s"Invalidating api key $id") apiKeyModel.delete(id) - def invalidateAllForUser(userId: Id[User])(using DbTx): Unit = + def invalidateAllForUser(userId: Id[User])(using DbTx[Postgres]): Unit = logger.debug(s"Invalidating all api keys for user $userId") apiKeyModel.deleteAllForUser(userId) end ApiKeyService diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala index be2370ce2..b75491605 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.security -import com.augustnagro.magnum.DbTx +import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.* import com.softwaremill.bootzooka.infrastructure.DB import com.softwaremill.bootzooka.logging.Logging @@ -44,8 +44,8 @@ end Auth */ trait AuthTokenOps[T]: def tokenName: String - def findById: DbTx ?=> Id[T] => Option[T] - def delete: DbTx ?=> T => Unit + def findById: DbTx[Postgres] ?=> Id[T] => Option[T] + def delete: DbTx[Postgres] ?=> T => Unit def userId: T => Id[User] def validUntil: T => Instant def deleteWhenValid: Boolean diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala index 6410d951b..b71020782 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.user -import com.augustnagro.magnum.DbTx +import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.* import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailTemplates} import com.softwaremill.bootzooka.logging.Logging @@ -26,7 +26,7 @@ class UserService( private val EmailAlreadyUsed = "E-mail already in use!" private val IncorrectLoginOrPassword = "Incorrect login/email or password" - def registerNewUser(login: String, email: String, password: String)(using DbTx): Either[Fail, ApiKey] = + def registerNewUser(login: String, email: String, password: String)(using DbTx[Postgres]): Either[Fail, ApiKey] = val loginClean = login.trim() val emailClean = email.trim() @@ -56,17 +56,17 @@ class UserService( doRegister() end registerNewUser - def findById(id: Id[User])(using DbTx): Either[Fail, User] = userOrNotFound(userModel.findById(id)) + def findById(id: Id[User])(using DbTx[Postgres]): Either[Fail, User] = userOrNotFound(userModel.findById(id)) - def login(loginOrEmail: String, password: String, apiKeyValid: Option[Duration])(using DbTx): Either[Fail, ApiKey] = either: + def login(loginOrEmail: String, password: String, apiKeyValid: Option[Duration])(using DbTx[Postgres]): Either[Fail, ApiKey] = either: val loginOrEmailClean = loginOrEmail.trim() val user = userOrNotFound(userModel.findByLoginOrEmail(loginOrEmailClean.toLowerCased)).ok() verifyPassword(user, password, validationErrorMsg = IncorrectLoginOrPassword).ok() apiKeyService.create(user.id, apiKeyValid.getOrElse(config.defaultApiKeyValid)) - def logout(id: Id[ApiKey])(using DbTx): Unit = apiKeyService.invalidate(id) + def logout(id: Id[ApiKey])(using DbTx[Postgres]): Unit = apiKeyService.invalidate(id) - def changeUser(userId: Id[User], newLogin: String, newEmail: String)(using DbTx): Either[Fail, Unit] = + def changeUser(userId: Id[User], newLogin: String, newEmail: String)(using DbTx[Postgres]): Either[Fail, Unit] = val newLoginClean = newLogin.trim() val newEmailClean = newEmail.trim() val newEmailtoLowerCased = newEmailClean.toLowerCased @@ -111,7 +111,7 @@ class UserService( if anyUpdate then sendMail(findById(userId).ok()) end changeUser - def changePassword(userId: Id[User], currentPassword: String, newPassword: String)(using DbTx): Either[Fail, ApiKey] = + def changePassword(userId: Id[User], currentPassword: String, newPassword: String)(using DbTx[Postgres]): Either[Fail, ApiKey] = def validateUserPassword(userId: Id[User], currentPassword: String): Either[Fail, User] = for user <- userOrNotFound(userModel.findById(userId)) From b96fb85f6edede6b399b29d13143cf49edcc0dc8 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 08:43:55 +0000 Subject: [PATCH 5/8] task: Compile and run the backend test suite, fixing migration regressions --- backend/src/main/scala/com/softwaremill/bootzooka/Main.scala | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala index 7ba4a8078..8fc99e0ad 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/Main.scala @@ -8,7 +8,8 @@ import ox.OxApp.Settings import ox.otel.context.PropagatingVirtualThreadFactory object Main extends OxApp.Simple with Logging: - // route JUL to SLF4J (JUL is used by Magnum & OTEL for logging) + // route JUL to SLF4J (JUL is used by OTEL for logging); parlance instead logs via java.lang.System.Logger, + // which is routed to SLF4J by the slf4j-jdk-platform-logging runtime dependency, not by this bridge SLF4JBridgeHandler.removeHandlersForRootLogger() SLF4JBridgeHandler.install() From c04f82d7ff7637f52e3bed5dc65e2019d543c734 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 08:44:43 +0000 Subject: [PATCH 6/8] task: Update documentation and code comments referencing Magnum --- docs/stack.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/stack.md b/docs/stack.md index 1a3d47999..155ffd656 100644 --- a/docs/stack.md +++ b/docs/stack.md @@ -15,7 +15,7 @@ Bootzooka's stack consists of the following technologies/tools, on the backend: - [Tapir](https://github.com/softwaremill/tapir) (endpoint description library) + [netty](https://netty.io) (backend networking layer) - SQL database, by default [PostgreSQL](https://www.postgresql.org) (persistence) -- [Magnum](https://github.com/AugustNagro/magnum) (type-safe database access) + [flyway](https://flywaydb.org) (schema +- [parlance](https://github.com/lbialy/parlance) (type-safe database access) + [flyway](https://flywaydb.org) (schema evolution) - [Ox](https://github.com/softwaremill/ox) (error handling, concurrency & resource management) - [SBT](https://www.scala-sbt.org) (build tool) From b56ef2b30352fde8473c357d626a083419c46e50 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 09:00:32 +0000 Subject: [PATCH 7/8] task: Remove the parlance migration scratch notes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Magnum → parlance migration is complete; all plan items are implemented and the backend compiles. Drop the scratch note. Co-Authored-By: Claude Opus 4.8 (1M context) --- parlance-migration-notes.md | 57 ------------------------------------- 1 file changed, 57 deletions(-) delete mode 100644 parlance-migration-notes.md diff --git a/parlance-migration-notes.md b/parlance-migration-notes.md deleted file mode 100644 index 0810e8add..000000000 --- a/parlance-migration-notes.md +++ /dev/null @@ -1,57 +0,0 @@ -# Magnum → parlance migration notes - -Scratch note for the multi-step migration on branch `migrate-magnum-to-parlance`. -Produced by the dependency-swap task; consumed by later code-migration tasks. - -## Dependency coordinates (confirmed) - -- **sbt:** `"ma.chinespirit" %% "parlance" % "0.1.0"` (replaces `"com.augustnagro" %% "magnum" % "1.3.1"`) -- Resolved artifact: `ma/chinespirit/parlance_3/0.1.0/parlance_3-0.1.0.jar` from Maven Central (`sbt backend/update` succeeds). -- **Scala 3.8.3:** parlance publishes a single Scala-3 artifact `parlance_3` (built with 3.8.2 per its `build.sbt`; no `crossScalaVersions`). Scala 3 is binary/TASTy-forward-compatible, so the `_3` artifact resolves and compiles under this project's 3.8.3. There is no separate 3.8.3 cross-build — the `_3` artifact is the cross-build. -- Source/package: `ma.chinespirit.parlance` (GitHub `lbialy/parlance`, group domain `chinespirit.ma`). Other modules exist but are **not** needed: `parlance-pg`, `parlance-migrate`. - -## Logging (build.sbt review done) - -parlance's `SqlLogger` logs via **`java.lang.System.Logger`** (platform logging), **not** JUL — same mechanism Magnum used. It is routed to SLF4J by `slf4j-jdk-platform-logging` (build.sbt line ~52), **not** by `jul-to-slf4j` (line ~50). The old line-50 comment claiming Magnum "uses JUL" was inaccurate; updated so line 50 mentions only OTEL and line 52 notes parlance. - -## IMPORTANT: parlance is NOT a drop-in Magnum fork - -parlance 0.1.0 is a redesigned, Active-Record-inspired ORM (92 core source files vs Magnum's handful). The low-level names overlap, but several APIs the migration assumed differ. The code migration is **substantial**, not a package rename. - -## API mapping - -| Magnum (current) | parlance | Notes | -|---|---|---| -| `import com.augustnagro.magnum.*` | `import ma.chinespirit.parlance.*` | package rename | -| `DbCodec`, `.biMap(to, from)` | same | ✓ identical | -| `DbCodec.StringCodec` | `DbCodec.StringCodec` (also `DbCodec[String]`) | ✓ `StringCodec` is a given in `object DbCodec`; `DbCodec.StringCodec.biMap(...)` compiles as-is | -| `summon[DbCodec[OffsetDateTime]]` | same | ✓ `OffsetDateTimeCodec` given exists | -| custom `given DbCodec[Instant]` (in `infrastructure/Codecs.scala`) | **keep** | parlance ships `given InstantCodec: DbCodec[Instant]` in `object DbCodec`, but an *imported* given (via `import Codecs.given`) takes precedence over implicit/companion scope — **verified: no ambiguity**. (Corrects the earlier note that said to remove it.) | -| `Transactor(dataSource = ds, sqlLogger = ...)` | `Transactor[Postgres](Postgres, ds, SqlLogger.logSlowQueries(200.millis))` | **DB type is a required first arg** AND must be **pinned** as `[Postgres]` — `Transactor(Postgres, ...)` infers the singleton `Postgres.type`, which then mismatches `DbTx[Postgres]` everywhere. | -| `SqlLogger.logSlowQueries(200.millis)` | same | ✓ | -| top-level `transact(transactor)(f)` | **`transactor.transact(f)`** (instance method) | `def transact[T](f: DbTx[D] ?=> T): T`. Rolls back only on a thrown exception (then rethrows) — so the `LeftException` rollback-on-`Left` trick still works unchanged. | -| top-level `connect(ds)(f)` | **`transactor.connect(f)`** (instance method) | `connect` takes no DataSource — needs a `Transactor`; `def connect[T](f: DbCon[D] ?=> T): T`. DB.scala `testConnection(ds)` builds a throwaway `Transactor[Postgres](Postgres, ds)` to run the test query. | -| `(using DbTx)`, `DbTx ?=> T` | **`(using DbTx[Postgres])`, `DbTx[Postgres] ?=> T`** | `DbCon`/`DbTx` are parameterized by DB type. Touches *every* model/service/api file with a `DbTx` param (UserModel, UserService, ApiKeyModel, ApiKeyService, Auth, PasswordResetCodeModel, PasswordResetService, EmailModel, EmailService, the `*Api` files, `AuthTokenOps`). Read-only sites may use `DbCon[Postgres]`. | -| `sql"..."` interpolator | same | ✓ available via wildcard import; if selectively imported, confirm the `sql` name is importable | -| `.query[T].run()`, `.update.run()` | same | ✓ require a `DbCon`/`DbTx` in scope | -| `Frag` | same | ✓ exists | -| `Repo[EC, E, ID]` (no parens) | `Repo[EC, E, ID]()` | parlance `Repo` is an `open class` with default ctor args — needs `()` | -| `userRepo.insert(e)` | **`userRepo.rawInsert(e)`** (or `create`) | no `insert` method; `rawInsert(ec)` inserts, `create(ec): E` inserts & returns the entity | -| `findById`, `findAll`, `count`, `deleteById`, `deleteAllById` | same | ✓ (read ops on `ImmutableRepo[E, ID]`) | -| `TableInfo[EC, E, ID]` + `u.colName` + `sql"$u"` | `TableInfo[EC, E, ID]` | exists; exact column-ref / table-interpolation API **unverified — confirm during code migration** | -| `Spec[E].where(sql"...")` / `.limit(n)` | **`QueryBuilder.from[E].where(...).limit(n).run()`** | **`Spec` does not exist.** `.where` idiomatically takes a typed lambda (`_.field > x`); whether it accepts a raw `WhereFrag`/`sql"..."` is **unverified**. Affects `UserModel.findBy*` and `EmailModel.find(limit)`. | -| entity = plain `case class` + `@Table` | `case class ... derives EntityMeta` | **entities must add `derives EntityMeta`** (User, ApiKey, PasswordResetCode, ScheduledEmails). Creator types (if EC≠E) use `derives DbCodec`. | -| `@Table(PostgresDbType, SqlNameMapper.CamelToSnakeCase)` | **`@Table(SqlNameMapper.CamelToSnakeCase)`** | `@Table` takes only a `nameMapper`; the DB type moved to the `Transactor` type param | -| `PostgresDbType` | **`Postgres`** (`object Postgres extends Postgres`, in `DatabaseType.scala`) | used as the `Transactor` arg / `DbTx[Postgres]` type, no longer in `@Table` | -| `@SqlName("...")` | same | ✓ | -| `SqlNameMapper.CamelToSnakeCase` | same | ✓ | - -## Open items for code-migration tasks - -1. `Spec` → `QueryBuilder.from[E]`: verify whether `.where` accepts raw `sql`/`WhereFrag` or only typed lambdas; rewrite `UserModel.findByEmail/findByLogin/findByLoginOrEmail` and `EmailModel.find` accordingly. -2. `TableInfo` column-reference + `sql"$u"` table interpolation: confirm the API matches Magnum's usage in `UserModel`/`ApiKeyModel`. -3. Add `derives EntityMeta` to all entities; drop the DB-type arg from every `@Table`. -4. Parameterize all `DbTx`/`DbCon` usages with `[Postgres]`. -5. ~~Rework `DB.scala`~~ **DONE** (and `infrastructure/Magnum.scala` → `infrastructure/Codecs.scala`, with the four import sites updated). -6. ~~Remove the custom `given DbCodec[Instant]`~~ — keep it; `DbCodec.StringCodec` works as-is (see table). -7. `insert` → `rawInsert`; add `()` to `Repo[...]` constructions. From 2c0fdc7f76541dd860bb26c09cd2a4eadbe4a7a1 Mon Sep 17 00:00:00 2001 From: Adam Warski Date: Fri, 29 May 2026 11:25:43 +0000 Subject: [PATCH 8/8] task: Reduce parlance API friction with a Tx alias and typed query DSL Introduce `infrastructure.Tx = DbTx[Postgres]` so call sites stop repeating the database type parameter, and rewrite the UserModel lookups to use parlance's typed column DSL (`_.col === value`) instead of the `unsafeAsWhere` raw-SQL escape hatch, gaining compile-time column checking. Behaviour-preserving: column names and bound parameters are unchanged. Backend compiles (incl. tests); DB-backed tests require Docker and were not run in this environment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../bootzooka/email/EmailModel.scala | 11 ++++---- .../bootzooka/email/EmailService.scala | 7 +++--- .../bootzooka/infrastructure/DB.scala | 8 +++--- .../bootzooka/infrastructure/Tx.scala | 9 +++++++ .../PasswordResetCodeModel.scala | 13 +++++----- .../passwordreset/PasswordResetService.scala | 9 +++---- .../bootzooka/security/ApiKeyModel.scala | 15 +++++------ .../bootzooka/security/ApiKeyService.scala | 8 +++--- .../bootzooka/security/Auth.scala | 7 +++--- .../bootzooka/user/UserModel.scala | 25 +++++++++---------- .../bootzooka/user/UserService.scala | 14 +++++------ 11 files changed, 67 insertions(+), 59 deletions(-) create mode 100644 backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Tx.scala diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala index 7ffcc38b2..5a4cadfab 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailModel.scala @@ -1,7 +1,8 @@ package com.softwaremill.bootzooka.email -import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, QueryBuilder, Repo, SqlNameMapper, Table} +import ma.chinespirit.parlance.{EntityMeta, QueryBuilder, Repo, SqlNameMapper, Table} import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.util.Strings.Id import ox.discard @@ -9,10 +10,10 @@ import ox.discard class EmailModel: private val emailRepo = Repo[ScheduledEmails, ScheduledEmails, Id[Email]]() - def insert(email: Email)(using DbTx[Postgres]): Unit = emailRepo.rawInsert(ScheduledEmails(email)) - def find(limit: Int)(using DbTx[Postgres]): Vector[Email] = QueryBuilder.from[ScheduledEmails].limit(limit).run().map(_.toEmail) - def count()(using DbTx[Postgres]): Long = emailRepo.count - def delete(ids: Vector[Id[Email]])(using DbTx[Postgres]): Unit = emailRepo.deleteAllById(ids).discard + def insert(email: Email)(using Tx): Unit = emailRepo.rawInsert(ScheduledEmails(email)) + def find(limit: Int)(using Tx): Vector[Email] = QueryBuilder.from[ScheduledEmails].limit(limit).run().map(_.toEmail) + def count()(using Tx): Long = emailRepo.count + def delete(ids: Vector[Id[Email]])(using Tx): Unit = emailRepo.deleteAllById(ids).discard @Table(SqlNameMapper.CamelToSnakeCase) private case class ScheduledEmails(id: Id[Email], recipient: String, subject: String, content: String) derives EntityMeta: diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala index b488b0300..dda1241de 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/email/EmailService.scala @@ -1,8 +1,7 @@ package com.softwaremill.bootzooka.email -import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.email.sender.EmailSender -import com.softwaremill.bootzooka.infrastructure.DB +import com.softwaremill.bootzooka.infrastructure.{DB, Tx} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.metrics.Metrics import com.softwaremill.bootzooka.util.IdGenerator @@ -21,7 +20,7 @@ class EmailService( ) extends EmailScheduler with Logging: - def schedule(data: EmailData)(using DbTx[Postgres]): Unit = + def schedule(data: EmailData)(using Tx): Unit = logger.debug(s"Scheduling email to be sent to: ${data.recipient}") val id = idGenerator.nextId[Email]() emailModel.insert(Email(id, data)) @@ -57,4 +56,4 @@ class EmailService( end EmailService trait EmailScheduler: - def schedule(data: EmailData)(using DbTx[Postgres]): Unit + def schedule(data: EmailData)(using Tx): Unit diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala index 620499b69..7bf4040f4 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/DB.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.infrastructure -import ma.chinespirit.parlance.{DbTx, Postgres, SqlLogger, Transactor, sql} +import ma.chinespirit.parlance.{Postgres, SqlLogger, Transactor, sql} import com.softwaremill.bootzooka.infrastructure.DB.LeftException import com.softwaremill.bootzooka.logging.Logging import com.zaxxer.hikari.{HikariConfig, HikariDataSource} @@ -15,19 +15,19 @@ import scala.util.NotGiven import scala.util.control.{NoStackTrace, NonFatal} class DB(dataSource: DataSource & Closeable) extends Logging with AutoCloseable: - // the database type is pinned explicitly so that the `DbTx[Postgres]` context type matches throughout the codebase + // the database type is pinned explicitly so that the `Tx` context type matches throughout the codebase // (otherwise `Transactor(Postgres, ...)` would infer the singleton type `Postgres.type`) private val transactor = Transactor[Postgres](Postgres, dataSource, SqlLogger.logSlowQueries(200.millis)) /** Runs `f` in a transaction. The transaction is commited if the result is a [[Right]], and rolled back otherwise. */ - def transactEither[E, T](f: DbTx[Postgres] ?=> Either[E, T]): Either[E, T] = + def transactEither[E, T](f: Tx ?=> Either[E, T]): Either[E, T] = try transactor.transact(Right(f.fold(e => throw LeftException(e), identity))) catch case e: LeftException[E] @unchecked => Left(e.left) /** Runs `f` in a transaction. The result cannot be an `Either`, as then [[transactEither]] should be used. The transaction is commited if * no exception is thrown. */ - def transact[T](f: DbTx[Postgres] ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = + def transact[T](f: Tx ?=> T)(using NotGiven[T <:< Either[?, ?]]): T = transactor.transact(f) override def close(): Unit = dataSource.close() diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Tx.scala b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Tx.scala new file mode 100644 index 000000000..f8283417f --- /dev/null +++ b/backend/src/main/scala/com/softwaremill/bootzooka/infrastructure/Tx.scala @@ -0,0 +1,9 @@ +package com.softwaremill.bootzooka.infrastructure + +import ma.chinespirit.parlance.{DbTx, Postgres} + +/** A transaction context for the application's single (Postgres) database. Use as a `(using Tx)` parameter on methods that must run within a + * transaction; an instance is provided by [[DB.transact]] / [[DB.transactEither]]. Centralises the choice of database type so call sites + * don't repeat `DbTx[Postgres]`. + */ +type Tx = DbTx[Postgres] diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala index 09ac2c8ec..fcb6b7126 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetCodeModel.scala @@ -1,7 +1,8 @@ package com.softwaremill.bootzooka.passwordreset -import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, Repo, SqlName, SqlNameMapper, Table} +import ma.chinespirit.parlance.{EntityMeta, Repo, SqlName, SqlNameMapper, Table} import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.security.AuthTokenOps import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id @@ -11,9 +12,9 @@ import java.time.Instant class PasswordResetCodeModel: private val passwordResetCodeRepo = Repo[PasswordResetCode, PasswordResetCode, Id[PasswordResetCode]]() - def insert(pr: PasswordResetCode)(using DbTx[Postgres]): Unit = passwordResetCodeRepo.rawInsert(pr) - def delete(id: Id[PasswordResetCode])(using DbTx[Postgres]): Unit = passwordResetCodeRepo.deleteById(id) - def findById(id: Id[PasswordResetCode])(using DbTx[Postgres]): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id) + def insert(pr: PasswordResetCode)(using Tx): Unit = passwordResetCodeRepo.rawInsert(pr) + def delete(id: Id[PasswordResetCode])(using Tx): Unit = passwordResetCodeRepo.deleteById(id) + def findById(id: Id[PasswordResetCode])(using Tx): Option[PasswordResetCode] = passwordResetCodeRepo.findById(id) @Table(SqlNameMapper.CamelToSnakeCase) @SqlName("password_reset_codes") @@ -21,8 +22,8 @@ case class PasswordResetCode(id: Id[PasswordResetCode], userId: Id[User], validU class PasswordResetAuthToken(passwordResetCodeModel: PasswordResetCodeModel) extends AuthTokenOps[PasswordResetCode]: override def tokenName: String = "PasswordResetCode" - override def findById: DbTx[Postgres] ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById - override def delete: DbTx[Postgres] ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id) + override def findById: Tx ?=> Id[PasswordResetCode] => Option[PasswordResetCode] = passwordResetCodeModel.findById + override def delete: Tx ?=> PasswordResetCode => Unit = ak => passwordResetCodeModel.delete(ak.id) override def userId: PasswordResetCode => Id[User] = _.userId override def validUntil: PasswordResetCode => Instant = _.validUntil // password reset code is a one-time token diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala index bada0bef4..6d48339d1 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/passwordreset/PasswordResetService.scala @@ -1,9 +1,8 @@ package com.softwaremill.bootzooka.passwordreset -import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.Fail import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailSubjectContent, EmailTemplates} -import com.softwaremill.bootzooka.infrastructure.DB +import com.softwaremill.bootzooka.infrastructure.{DB, Tx} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.security.Auth import com.softwaremill.bootzooka.user.{User, UserModel} @@ -23,14 +22,14 @@ class PasswordResetService( clock: Clock, db: DB ) extends Logging: - def forgotPassword(loginOrEmail: String)(using DbTx[Postgres]): Unit = + def forgotPassword(loginOrEmail: String)(using Tx): Unit = userModel.findByLoginOrEmail(loginOrEmail.toLowerCased) match case None => logger.debug(s"Could not find user with $loginOrEmail login/email") case Some(user) => val pcr = createCode(user) sendCode(user, pcr) - private def createCode(user: User)(using DbTx[Postgres]): PasswordResetCode = + private def createCode(user: User)(using Tx): PasswordResetCode = logger.debug(s"Creating password reset code for user: ${user.id}") val id = idGenerator.nextId[PasswordResetCode]() val validUntil = clock.now().plusMillis(config.codeValid.toMillis) @@ -38,7 +37,7 @@ class PasswordResetService( passwordResetCodeModel.insert(passwordResetCode) passwordResetCode - private def sendCode(user: User, code: PasswordResetCode)(using DbTx[Postgres]): Unit = + private def sendCode(user: User, code: PasswordResetCode)(using Tx): Unit = logger.debug(s"Scheduling e-mail with reset code for user: ${user.id}") emailScheduler.schedule(EmailData(user.emailLowerCase, prepareResetEmail(user, code))) diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala index afca36e38..f5e304879 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyModel.scala @@ -1,7 +1,8 @@ package com.softwaremill.bootzooka.security -import ma.chinespirit.parlance.{DbTx, EntityMeta, Postgres, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} +import ma.chinespirit.parlance.{EntityMeta, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id import ox.discard @@ -12,10 +13,10 @@ class ApiKeyModel: private val apiKeyRepo = Repo[ApiKey, ApiKey, Id[ApiKey]]() private val a = TableInfo[ApiKey, ApiKey, Id[ApiKey]] - def insert(apiKey: ApiKey)(using DbTx[Postgres]): Unit = apiKeyRepo.rawInsert(apiKey) - def findById(id: Id[ApiKey])(using DbTx[Postgres]): Option[ApiKey] = apiKeyRepo.findById(id) - def deleteAllForUser(id: Id[User])(using DbTx[Postgres]): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard - def delete(id: Id[ApiKey])(using DbTx[Postgres]): Unit = apiKeyRepo.deleteById(id) + def insert(apiKey: ApiKey)(using Tx): Unit = apiKeyRepo.rawInsert(apiKey) + def findById(id: Id[ApiKey])(using Tx): Option[ApiKey] = apiKeyRepo.findById(id) + def deleteAllForUser(id: Id[User])(using Tx): Unit = sql"""DELETE FROM $a WHERE ${a.userId} = $id""".update.run().discard + def delete(id: Id[ApiKey])(using Tx): Unit = apiKeyRepo.deleteById(id) end ApiKeyModel @Table(SqlNameMapper.CamelToSnakeCase) @@ -24,8 +25,8 @@ case class ApiKey(id: Id[ApiKey], userId: Id[User], createdOn: Instant, validUnt class ApiKeyAuthToken(apiKeyModel: ApiKeyModel) extends AuthTokenOps[ApiKey]: override def tokenName: String = "ApiKey" - override def findById: DbTx[Postgres] ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById - override def delete: DbTx[Postgres] ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id) + override def findById: Tx ?=> Id[ApiKey] => Option[ApiKey] = apiKeyModel.findById + override def delete: Tx ?=> ApiKey => Unit = ak => apiKeyModel.delete(ak.id) override def userId: ApiKey => Id[User] = _.userId override def validUntil: ApiKey => Instant = _.validUntil override def deleteWhenValid: Boolean = false diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala index b42803c21..69ad0767c 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/ApiKeyService.scala @@ -1,6 +1,6 @@ package com.softwaremill.bootzooka.security -import ma.chinespirit.parlance.{DbTx, Postgres} +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.Strings.Id @@ -10,7 +10,7 @@ import java.time.temporal.ChronoUnit import scala.concurrent.duration.Duration class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: Clock) extends Logging: - def create(userId: Id[User], valid: Duration)(using DbTx[Postgres]): ApiKey = + def create(userId: Id[User], valid: Duration)(using Tx): ApiKey = val id = idGenerator.nextId[ApiKey]() val now = clock.now() val validUntil = now.plus(valid.toMillis, ChronoUnit.MILLIS) @@ -20,11 +20,11 @@ class ApiKeyService(apiKeyModel: ApiKeyModel, idGenerator: IdGenerator, clock: C apiKey end create - def invalidate(id: Id[ApiKey])(using DbTx[Postgres]): Unit = + def invalidate(id: Id[ApiKey])(using Tx): Unit = logger.debug(s"Invalidating api key $id") apiKeyModel.delete(id) - def invalidateAllForUser(userId: Id[User])(using DbTx[Postgres]): Unit = + def invalidateAllForUser(userId: Id[User])(using Tx): Unit = logger.debug(s"Invalidating all api keys for user $userId") apiKeyModel.deleteAllForUser(userId) end ApiKeyService diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala index b75491605..7f31bf323 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/security/Auth.scala @@ -1,8 +1,7 @@ package com.softwaremill.bootzooka.security -import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.* -import com.softwaremill.bootzooka.infrastructure.DB +import com.softwaremill.bootzooka.infrastructure.{DB, Tx} import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.user.User import com.softwaremill.bootzooka.util.* @@ -44,8 +43,8 @@ end Auth */ trait AuthTokenOps[T]: def tokenName: String - def findById: DbTx[Postgres] ?=> Id[T] => Option[T] - def delete: DbTx[Postgres] ?=> T => Unit + def findById: Tx ?=> Id[T] => Option[T] + def delete: Tx ?=> T => Unit def userId: T => Id[User] def validUntil: T => Instant def deleteWhenValid: Boolean diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala index 0f3d10a47..ae48f0fcb 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserModel.scala @@ -1,8 +1,9 @@ package com.softwaremill.bootzooka.user -import ma.chinespirit.parlance.{DbTx, EntityMeta, Frag, Postgres, QueryBuilder, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql, unsafeAsWhere} +import ma.chinespirit.parlance.{===, ||, EntityMeta, QueryBuilder, Repo, SqlName, SqlNameMapper, Table, TableInfo, sql} import com.password4j.{Argon2Function, Password} import com.softwaremill.bootzooka.infrastructure.Codecs.given +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.user.User.PasswordHashing import com.softwaremill.bootzooka.user.User.PasswordHashing.Argon2Config.* import com.softwaremill.bootzooka.util.PasswordVerificationStatus @@ -17,25 +18,23 @@ class UserModel: export userRepo.{rawInsert as insert, findById} - def findByEmail(email: LowerCased)(using DbTx[Postgres]): Option[User] = - findBy(sql"${u.emailLowerCase} = $email") - def findByLogin(login: LowerCased)(using DbTx[Postgres]): Option[User] = - findBy(sql"${u.loginLowerCase} = $login") - def findByLoginOrEmail(loginOrEmail: LowerCased)(using DbTx[Postgres]): Option[User] = - findBy(sql"${u.loginLowerCase} = ${loginOrEmail: String} OR ${u.emailLowerCase} = $loginOrEmail") + // queries use parlance's typed column DSL (`_.col === value`), which is checked against the entity's fields at compile time + def findByEmail(email: LowerCased)(using Tx): Option[User] = + QueryBuilder.from[User].where(_.emailLowerCase === email).first() + def findByLogin(login: LowerCased)(using Tx): Option[User] = + QueryBuilder.from[User].where(_.loginLowerCase === login).first() + def findByLoginOrEmail(loginOrEmail: LowerCased)(using Tx): Option[User] = + QueryBuilder.from[User].where(u => u.loginLowerCase === loginOrEmail || u.emailLowerCase === loginOrEmail).first() - private def findBy(condition: Frag)(using DbTx[Postgres]): Option[User] = - QueryBuilder.from[User].where(condition.unsafeAsWhere).first() - - def updatePassword(userId: Id[User], newPassword: Hashed)(using DbTx[Postgres]): Unit = + def updatePassword(userId: Id[User], newPassword: Hashed)(using Tx): Unit = sql"""UPDATE $u SET ${u.passwordHash} = $newPassword WHERE ${u.id} = $userId""".update.run().discard - def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using DbTx[Postgres]): Unit = + def updateLogin(userId: Id[User], newLogin: String, newLoginLowerCase: LowerCased)(using Tx): Unit = sql"""UPDATE $u SET ${u.login} = $newLogin, login_lowercase = ${newLoginLowerCase: String} WHERE ${u.id} = $userId""".update .run() .discard - def updateEmail(userId: Id[User], newEmail: LowerCased)(using DbTx[Postgres]): Unit = + def updateEmail(userId: Id[User], newEmail: LowerCased)(using Tx): Unit = sql"""UPDATE $u SET ${u.emailLowerCase} = $newEmail WHERE ${u.id} = $userId""".update.run().discard end UserModel diff --git a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala index b71020782..8e93b164c 100644 --- a/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala +++ b/backend/src/main/scala/com/softwaremill/bootzooka/user/UserService.scala @@ -1,8 +1,8 @@ package com.softwaremill.bootzooka.user -import ma.chinespirit.parlance.{DbTx, Postgres} import com.softwaremill.bootzooka.* import com.softwaremill.bootzooka.email.{EmailData, EmailScheduler, EmailTemplates} +import com.softwaremill.bootzooka.infrastructure.Tx import com.softwaremill.bootzooka.logging.Logging import com.softwaremill.bootzooka.security.{ApiKey, ApiKeyService} import com.softwaremill.bootzooka.util.* @@ -26,7 +26,7 @@ class UserService( private val EmailAlreadyUsed = "E-mail already in use!" private val IncorrectLoginOrPassword = "Incorrect login/email or password" - def registerNewUser(login: String, email: String, password: String)(using DbTx[Postgres]): Either[Fail, ApiKey] = + def registerNewUser(login: String, email: String, password: String)(using Tx): Either[Fail, ApiKey] = val loginClean = login.trim() val emailClean = email.trim() @@ -56,17 +56,17 @@ class UserService( doRegister() end registerNewUser - def findById(id: Id[User])(using DbTx[Postgres]): Either[Fail, User] = userOrNotFound(userModel.findById(id)) + def findById(id: Id[User])(using Tx): Either[Fail, User] = userOrNotFound(userModel.findById(id)) - def login(loginOrEmail: String, password: String, apiKeyValid: Option[Duration])(using DbTx[Postgres]): Either[Fail, ApiKey] = either: + def login(loginOrEmail: String, password: String, apiKeyValid: Option[Duration])(using Tx): Either[Fail, ApiKey] = either: val loginOrEmailClean = loginOrEmail.trim() val user = userOrNotFound(userModel.findByLoginOrEmail(loginOrEmailClean.toLowerCased)).ok() verifyPassword(user, password, validationErrorMsg = IncorrectLoginOrPassword).ok() apiKeyService.create(user.id, apiKeyValid.getOrElse(config.defaultApiKeyValid)) - def logout(id: Id[ApiKey])(using DbTx[Postgres]): Unit = apiKeyService.invalidate(id) + def logout(id: Id[ApiKey])(using Tx): Unit = apiKeyService.invalidate(id) - def changeUser(userId: Id[User], newLogin: String, newEmail: String)(using DbTx[Postgres]): Either[Fail, Unit] = + def changeUser(userId: Id[User], newLogin: String, newEmail: String)(using Tx): Either[Fail, Unit] = val newLoginClean = newLogin.trim() val newEmailClean = newEmail.trim() val newEmailtoLowerCased = newEmailClean.toLowerCased @@ -111,7 +111,7 @@ class UserService( if anyUpdate then sendMail(findById(userId).ok()) end changeUser - def changePassword(userId: Id[User], currentPassword: String, newPassword: String)(using DbTx[Postgres]): Either[Fail, ApiKey] = + def changePassword(userId: Id[User], currentPassword: String, newPassword: String)(using Tx): Either[Fail, ApiKey] = def validateUserPassword(userId: Id[User], currentPassword: String): Either[Fail, User] = for user <- userOrNotFound(userModel.findById(userId))