WIP: More db prepping to run tests with sqlite

This commit is contained in:
Pat Garrity 2026-01-31 19:23:36 -06:00
parent f25c9658eb
commit 8d0567195a
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
11 changed files with 79 additions and 338 deletions

View file

@ -27,7 +27,14 @@ val Deps = new {
} }
val Doobie = new { val Doobie = new {
val Core: ModuleID = "org.tpolecat" %% "doobie-core" % "1.0.0-RC11" val Core: ModuleID = "org.tpolecat" %% "doobie-core" % "1.0.0-RC11"
val Hikari: ModuleID = "org.tpolecat" %% "doobie-hikari" % "1.0.0-RC11"
val Postgres: ModuleID = "org.tpolecat" %% "doobie-postgres" % "1.0.0-RC11"
}
val JdbcDriver = new {
val Sqlite: ModuleID = "org.xerial" % "sqlite-jdbc" % "3.51.1.0"
val PostgreSQL: ModuleID = "org.postgresql" % "postgresql" % "42.7.9"
} }
val Http4s = new { val Http4s = new {
@ -96,7 +103,11 @@ lazy val db = project
.settings(name := s"${gsProjectName.value}-db") .settings(name := s"${gsProjectName.value}-db")
.settings( .settings(
libraryDependencies ++= Seq( libraryDependencies ++= Seq(
Deps.Doobie.Core Deps.Doobie.Core,
Deps.Doobie.Hikari,
Deps.Doobie.Postgres,
Deps.JdbcDriver.Sqlite,
Deps.JdbcDriver.PostgreSQL
) )
) )

View file

@ -1,14 +0,0 @@
package gs.smolban.db
object DbConstants:
val UniqueConstraintFailed: List[Int] =
List(Sqlite.UniqueConstraintFailed)
object Sqlite:
val UniqueConstraintFailed: Int = 2067
end Sqlite
end DbConstants

View file

@ -1,10 +1,12 @@
package gs.smolban.db package gs.smolban.db
import _root_.doobie.enumerated.SqlState
import gs.smolban.auth.CredentialId import gs.smolban.auth.CredentialId
import gs.smolban.model.SmolbanError import gs.smolban.model.SmolbanError
import gs.smolban.model.account.AccountId import gs.smolban.model.account.AccountId
import gs.smolban.model.account.AccountName import gs.smolban.model.account.AccountName
import gs.smolban.model.account.AccountType import gs.smolban.model.account.AccountType
import gs.smolban.model.metadata.TagValue
/** Parent type of all database errors in Smolban. /** Parent type of all database errors in Smolban.
*/ */
@ -12,6 +14,10 @@ sealed trait DbError extends SmolbanError
object DbError: object DbError:
case class GenericDatabaseError(sqlState: SqlState) extends DbError
case class TagAlreadyExists(value: TagValue) extends DbError
/** Produced when creating any new account fails because an account with the /** Produced when creating any new account fails because an account with the
* same name already exists. * same name already exists.
* *

View file

@ -1,35 +0,0 @@
package gs.smolban.db
import cats.data.EitherT
import gs.smolban.model.Group
/** Database interface for [[Group]].
*/
trait GroupDb[F[_]]:
/** Create a new [[Group]] in the database.
*
* @param group
* The [[Group]] to store.
* @return
* The [[Group]] that was stored, or an error.
*/
def createGroup(group: Group): EitherT[F, DbError, Group]
/** Read the specified [[Group]] from the database.
*
* @param id
* The unique identifier of the [[Group]] to retrieve.
* @return
* The [[Group]], or `None` if the specified ID does not exist.
*/
def readGroup(id: Group.Id): F[Option[Group]]
/** Delete the specified [[Group]]. Note that this operation deletes all
* [[Ticket]] associated with the group and should be used with care.
*
* @param id
* The unique identifier of the [[Group]] to delete.
* @return
* `true` if the group was deleted, `false` otherwise.
*/
def deleteGroup(id: Group.Id): F[Boolean]

View file

@ -1,8 +1,9 @@
package gs.smolban.db package gs.smolban.db
import cats.data.EitherT import cats.data.EitherT
import gs.smolban.model.CreatedAt import gs.smolban.model.metadata.CreatedAt
import gs.smolban.model.Tag import gs.smolban.model.metadata.Tag
import gs.smolban.model.metadata.TagValue
trait TagDb[F[_]]: trait TagDb[F[_]]:
@ -16,24 +17,24 @@ trait TagDb[F[_]]:
* The new [[Tag]], or an error if the value already exists. * The new [[Tag]], or an error if the value already exists.
*/ */
def createTag( def createTag(
tag: Tag.Value, tag: TagValue,
createdAt: CreatedAt createdAt: CreatedAt
): EitherT[F, DbError, Tag] ): EitherT[F, DbError, Tag]
/** Get the value of some [[Tag]] by ID. /** Get the value of some [[Tag]] by value.
* *
* @param id * @param tag
* The internal identifier of the [[Tag]]. * The value of the [[Tag]].
* @return * @return
* The found [[Tag]], or `None` if the ID does not exist. * The found [[Tag]], or `None` if the value does not exist.
*/ */
def readTag(id: Tag.Id): F[Option[Tag]] def readTag(tag: TagValue): F[Option[Tag]]
/** Delete a [[Tag]] by ID. /** Delete a [[Tag]] by value.
* *
* @param id * @param tag
* The unique ID of the [[Tag]] to delete. * The unique value of the [[Tag]] to delete.
* @return * @return
* `true` if the [[Tag]] was deleted, `false` otherwise. * `true` if the [[Tag]] was deleted, `false` otherwise.
*/ */
def deleteTag(id: Tag.Id): F[Boolean] def deleteTag(tag: TagValue): F[Boolean]

View file

@ -1,50 +0,0 @@
package gs.smolban.db
import cats.data.EitherT
import gs.smolban.model.Ticket
/** Database interface for [[Ticket]].
*/
trait TicketDb[F[_]]:
/** Create a new [[Ticket]] in the database.
*
* @param ticket
* The [[Ticket]] to store.
* @return
* The [[Ticket]] that was created, or an error.
*/
def createTicket(ticket: Ticket): EitherT[F, DbError, Ticket]
/** Read the specified [[Ticket]] from the database.
*
* @param ref
* The reference to the [[Ticket]] to retrieve.
* @return
* The value of the [[Ticket]], or `None` if no such [[Ticket]] exists.
*/
def readTicket(ref: Ticket.Reference): F[Option[Ticket]]
/** Given a reference to a [[Ticket]] and some new value for that [[Ticket]],
* update the [[Ticket]] in the database.
*
* @param ref
* The refrence to the [[Ticket]] to update.
* @param newValue
* The new values with which to overwrite the [[Ticket]].
* @return
* The modified [[Ticket]] if the operation succeeded, or `None` if the
* given reference does not exist.
*/
def updateTicket(
ref: Ticket.Reference,
newValue: Ticket
): F[Option[Ticket]]
/** Delete the specified [[Ticket]].
*
* @param ref
* The reference to the [[Ticket]] to delete.
* @return
* `true` if the operation deleted a [[Ticket]], `false` otherwise.
*/
def deleteTicket(ref: Ticket.Reference): F[Boolean]

View file

@ -0,0 +1,20 @@
package gs.smolban.db.doobie
import doobie.enumerated.SqlState
sealed trait CuratedSqlStates:
def uniqueViolation: SqlState
object CuratedSqlStates:
final class PostgreSQL extends CuratedSqlStates:
override val uniqueViolation: SqlState =
doobie.postgres.sqlstate.class23.UNIQUE_VIOLATION
final class Sqlite extends CuratedSqlStates:
override val uniqueViolation: SqlState =
SqlState("2067")
end CuratedSqlStates

View file

@ -1,90 +0,0 @@
package gs.smolban.db.doobie
import cats.MonadError
import cats.data.EitherT
import cats.syntax.all.*
import doobie.*
import doobie.implicits.*
import gs.smolban.db.DbError
import gs.smolban.db.GroupDb
import gs.smolban.db.doobie.DoobieTypes.*
import gs.smolban.model.CreatedAt
import gs.smolban.model.Group
final class DoobieGroupDb() extends GroupDb[ConnectionIO]:
import DoobieGroupDb.Sql
import gs.smolban.db.DbConstants
/** @inheritdoc
*/
override def createGroup(
group: Group
): EitherT[ConnectionIO, DbError, Group] =
EitherT(
Sql.createGroup(group).run.attemptSql.flatMap {
case Left(ex) =>
if DbConstants.UniqueConstraintFailed.contains(ex.getErrorCode()) then
Left(
DbError.GroupAlreadyExists(group.id, group.slug)
).pure[ConnectionIO]
else MonadError[ConnectionIO, Throwable].raiseError(ex)
case Right(_) =>
Right(group).pure[ConnectionIO]
}
)
/** @inheritdoc
*/
override def readGroup(id: Group.Id): ConnectionIO[Option[Group]] =
Sql.readGroup(id).option
/** @inheritdoc
*/
override def deleteGroup(id: Group.Id): ConnectionIO[Boolean] =
for
_ <- Sql.deleteAllTicketsForGroup(id).run
rows <- Sql.deleteGroup(id).run
yield rows > 0
object DoobieGroupDb:
private object Sql:
def createGroup(group: Group): Update0 = sql"""
INSERT INTO groups (group_id, slug, created_at)
VALUES (${group.id}, ${group.slug}, ${group.createdAt})
""".update
def readGroup(groupId: Group.Id): Query0[Group] = sql"""
SELECT slug, created_at
FROM groups
WHERE group_id = $groupId
""".query[Group]
def deleteAllTicketsForGroup(groupId: Group.Id): Update0 = sql"""
DELETE FROM tickets
WHERE group_id = $groupId
""".update
def deleteGroup(groupId: Group.Id): Update0 = sql"""
DELETE FROM groups
WHERE group_id = $groupId
""".update
end Sql
object ErrorCodes:
val UniqueConstraintFailed: List[Int] =
List(Sqlite.UniqueConstraintFailed)
object Sqlite:
val UniqueConstraintFailed: Int = 2067
end Sqlite
end ErrorCodes
end DoobieGroupDb

View file

@ -1,51 +1,47 @@
package gs.smolban.db.doobie package gs.smolban.db.doobie
import cats.MonadError
import cats.data.EitherT import cats.data.EitherT
import cats.syntax.all.*
import doobie.* import doobie.*
import doobie.implicits.* import doobie.implicits.*
import gs.smolban.db.DbError import gs.smolban.db.DbError
import gs.smolban.db.TagDb import gs.smolban.db.TagDb
import gs.smolban.db.doobie.DoobieTypes.* import gs.smolban.db.doobie.DoobieTypes.*
import gs.smolban.model.CreatedAt import gs.smolban.model.metadata.CreatedAt
import gs.smolban.model.Tag import gs.smolban.model.metadata.Tag
import gs.smolban.model.metadata.TagValue
final class DoobieTagDb() extends TagDb[ConnectionIO]: final class DoobieTagDb(
sqlStates: CuratedSqlStates
) extends TagDb[ConnectionIO]:
import DoobieTagDb.Sql import DoobieTagDb.Sql
import gs.smolban.db.DbConstants
/** @inheritdoc /** @inheritdoc
*/ */
override def createTag( override def createTag(
value: Tag.Value, value: TagValue,
createdAt: CreatedAt createdAt: CreatedAt
): EitherT[ConnectionIO, DbError, Tag] = ): EitherT[ConnectionIO, DbError, Tag] =
EitherT( EitherT(
Sql.createTag(value, createdAt).option.attemptSql.flatMap { Sql.createTag(value, createdAt).run.attemptSqlState.map {
case Left(ex) => case Left(sqlState) =>
if DbConstants.UniqueConstraintFailed.contains(ex.getErrorCode()) then if sqlState.value == sqlStates.uniqueViolation.value then
Left( Left(DbError.TagAlreadyExists(value))
DbError.TagAlreadyExists(value) else Left(DbError.GenericDatabaseError(sqlState))
).pure[ConnectionIO] case Right(_) =>
else MonadError[ConnectionIO, Throwable].raiseError(ex) Right(Tag(value, createdAt))
case Right(None) =>
Left(DbError.SilentInsertFailure("doobie-tag-db")).pure[ConnectionIO]
case Right(Some(newId)) =>
Right(Tag(newId, value, createdAt)).pure[ConnectionIO]
} }
) )
/** @inheritdoc /** @inheritdoc
*/ */
override def readTag(id: Tag.Id): ConnectionIO[Option[Tag]] = override def readTag(tag: TagValue): ConnectionIO[Option[Tag]] =
Sql.readTag(id).option Sql.readTag(tag).option
/** @inheritdoc /** @inheritdoc
*/ */
override def deleteTag(id: Tag.Id): ConnectionIO[Boolean] = override def deleteTag(tag: TagValue): ConnectionIO[Boolean] =
for rows <- Sql.deleteTag(id).run for rows <- Sql.deleteTag(tag).run
yield rows > 0 yield rows > 0
object DoobieTagDb: object DoobieTagDb:
@ -53,23 +49,22 @@ object DoobieTagDb:
private object Sql: private object Sql:
def createTag( def createTag(
tv: Tag.Value, tag: TagValue,
createdAt: CreatedAt createdAt: CreatedAt
): Query0[Tag.Id] = sql""" ): Update0 = sql"""
INSERT INTO tags (tag_value, created_at) INSERT INTO tags (tag_value, created_at)
VALUES ($tv, $createdAt) VALUES ($tag, $createdAt)
RETURNING id """.update
""".query[Tag.Id]
def readTag(tagId: Tag.Id): Query0[Tag] = sql""" def readTag(tag: TagValue): Query0[Tag] = sql"""
SELECT id, value, created_at SELECT tag_value, created_at
FROM tags FROM tags
WHERE id = $tagId WHERE tag_value = $tag
""".query[Tag] """.query[Tag]
def deleteTag(tagId: Tag.Id): Update0 = sql""" def deleteTag(tag: TagValue): Update0 = sql"""
DELETE FROM tags DELETE FROM tags
WHERE id = $tagId WHERE tag_value = $tag
""".update """.update
end Sql end Sql

View file

@ -1,101 +0,0 @@
package gs.smolban.db.doobie
import cats.data.EitherT
import cats.syntax.all.*
import doobie.*
import doobie.implicits.*
import gs.smolban.db.DbError
import gs.smolban.db.TicketDb
import gs.smolban.db.doobie.DoobieTypes.*
import gs.smolban.model.CreatedAt
import gs.smolban.model.Group
import gs.smolban.model.Tag
import gs.smolban.model.Ticket
import java.time.Instant
final class DoobieTicketDb() extends TicketDb[ConnectionIO]:
import DoobieTicketDb.Sql
/** @inheritdoc
*/
override def createTicket(ticket: Ticket)
: EitherT[ConnectionIO, DbError, Ticket] = ???
/** @inheritdoc
*/
override def readTicket(ref: Ticket.Reference): ConnectionIO[Option[Ticket]] =
Sql.readTicket(ref.number, ref.group).option.flatMap {
case None => None.pure[ConnectionIO]
case Some(ticketContents) =>
for
tags <- Sql.readTagsForTicket(ticketContents.id).stream.compile.toList
history <- Sql
.readHistoryForTicket(ticketContents.id)
.stream
.compile
.toList
yield Some(
Ticket(
number = ref.number,
group = ref.group,
createdAt = ticketContents.createdAt,
title = ticketContents.title,
description = ticketContents.description,
tags = tags,
status = ticketContents.status,
statusHistory = history
)
)
}
/** @inheritdoc
*/
override def updateTicket(
ref: Ticket.Reference,
newValue: Ticket
): ConnectionIO[Option[Ticket]] = ???
/** @inheritdoc
*/
override def deleteTicket(ref: Ticket.Reference): ConnectionIO[Boolean] = ???
object DoobieTicketDb:
private object Sql:
case class TicketContents(
id: Long,
createdAt: CreatedAt,
title: String,
description: String,
status: Ticket.Status
)
def readTicket(
ticketNumber: Ticket.Number,
groupId: Group.Id
): Query0[TicketContents] = sql"""
SELECT id, created_at, created_by, title, description, status, assignee
FROM tickets
WHERE ticket_number = $ticketNumber AND group_id = $groupId
""".query[TicketContents]
def readTagsForTicket(
ticketId: Long
): Query0[Tag] = sql"""
SELECT id, tag_value, created_at
FROM tags
WHERE ticket_id = $ticketId
""".query[Tag]
def readHistoryForTicket(
ticketId: Long
): Query0[(Ticket.Status, Instant)] = sql"""
SELECT status, set_at
FROM ticket_history
WHERE ticket_id = $ticketId
""".query[(Ticket.Status, Instant)]
end Sql
end DoobieTicketDb

View file

@ -3,10 +3,8 @@ package gs.smolban.db.doobie
import DoobieTypes.ErrorMessages import DoobieTypes.ErrorMessages
import doobie.* import doobie.*
import gs.smolban.model.metadata.CreatedAt import gs.smolban.model.metadata.CreatedAt
import gs.smolban.model.metadata.Tag
import gs.smolban.model.metadata.TagValue import gs.smolban.model.metadata.TagValue
import gs.smolban.model.ticket.CommentId import gs.smolban.model.ticket.CommentId
import gs.smolban.model.ticket.Ticket
import gs.smolban.model.ticket.TicketStatus import gs.smolban.model.ticket.TicketStatus
import gs.uuid.v0.UUID import gs.uuid.v0.UUID
import gs.uuid.v0.UUIDFormat import gs.uuid.v0.UUIDFormat