More updates, cranking out group db

This commit is contained in:
Pat Garrity 2024-08-12 08:52:37 -05:00
parent fcc774390d
commit ab16e3fdf0
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
6 changed files with 136 additions and 21 deletions

View file

@ -1,5 +1,7 @@
package gs.smolban.db
import gs.slug.v0.Slug
import gs.smolban.model.Group
import gs.smolban.model.SmolbanError
import gs.smolban.model.Ticket
@ -17,4 +19,9 @@ object DbError:
*/
case class TicketAlreadyExists(ref: Ticket.Reference) extends DbError
case class GroupAlreadyExists(
id: Group.Id,
slug: Slug
) extends DbError
end DbError

View file

@ -3,9 +3,33 @@ 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

@ -3,6 +3,8 @@ 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.
*

View file

@ -0,0 +1,90 @@
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.ErrorCodes
import DoobieGroupDb.Sql
/** @inheritdoc
*/
override def createGroup(
group: Group
): EitherT[ConnectionIO, DbError, Group] =
EitherT(
Sql.createGroup(group).run.attemptSql.flatMap {
case Left(ex) =>
if ErrorCodes.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

@ -8,10 +8,8 @@ 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.CreatedBy
import gs.smolban.model.Group
import gs.smolban.model.Ticket
import gs.smolban.model.users.User
final class DoobieTicketDb[F[_]: Async](
val xa: Transactor[F]
@ -46,11 +44,9 @@ object DoobieTicketDb:
case class TicketContents(
createdAt: CreatedAt,
createdBy: CreatedBy,
title: String,
description: String,
status: Ticket.Status,
assignee: Option[User.Id]
status: Ticket.Status
)
// TODO: figure out how to read tags

View file

@ -2,11 +2,10 @@ package gs.smolban.db.doobie
import DoobieTypes.ErrorMessages
import doobie.*
import gs.slug.v0.Slug
import gs.smolban.model.CreatedAt
import gs.smolban.model.CreatedBy
import gs.smolban.model.Group
import gs.smolban.model.Ticket
import gs.smolban.model.users.User
import gs.uuid.v0.UUID
import gs.uuid.v0.UUIDFormat
@ -34,27 +33,21 @@ trait DoobieTypes:
implicit val groupIdPut: Put[Group.Id] =
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
implicit val userIdGet: Get[User.Id] =
Get[Array[Byte]].tmap(bytes => User.Id(UUID(UUIDFormat.fromBytes(bytes))))
implicit val userIdPut: Put[User.Id] =
Put[Array[Byte]].tcontramap(id => UUIDFormat.toBytes(id.toUUID().toUUID()))
implicit val createdAtGet: Get[CreatedAt] =
Get[Long].tmap(CreatedAt.fromMilliseconds)
implicit val createdAtPut: Put[CreatedAt] =
Put[Long].tcontramap(_.toMilliseconds())
implicit val createdByGet: Get[CreatedBy] =
Get[Array[Byte]].tmap(bytes =>
CreatedBy(User.Id(UUID(UUIDFormat.fromBytes(bytes))))
)
implicit val slugGet: Get[Slug] =
Get[String].temap { slug =>
Slug.validate(slug) match
case None => Left(ErrorMessages.invalidSlug(slug))
case Some(s) => Right(s)
}
implicit val createdByPut: Put[CreatedBy] =
Put[Array[Byte]].tcontramap(id =>
UUIDFormat.toBytes(id.toUserId().toUUID().toUUID())
)
implicit val slugPut: Put[Slug] =
Put[String].tcontramap(_.str())
object DoobieTypes extends DoobieTypes:
@ -63,6 +56,9 @@ object DoobieTypes extends DoobieTypes:
def invalidTicketStatus(candidate: String): String =
s"'$candidate' is not a valid ticket status."
def invalidSlug(candidate: String): String =
s"'$candidate' is not a valid Group slug."
end ErrorMessages
end DoobieTypes