From ab16e3fdf0f759dfedfd8ddbadba3b87e20658d2 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Mon, 12 Aug 2024 08:52:37 -0500 Subject: [PATCH] More updates, cranking out group db --- .../main/scala/gs/smolban/db/DbError.scala | 7 ++ .../main/scala/gs/smolban/db/GroupDb.scala | 24 +++++ .../main/scala/gs/smolban/db/TicketDb.scala | 2 + .../gs/smolban/db/doobie/DoobieGroupDb.scala | 90 +++++++++++++++++++ .../gs/smolban/db/doobie/DoobieTicketDb.scala | 6 +- .../gs/smolban/db/doobie/DoobieTypes.scala | 28 +++--- 6 files changed, 136 insertions(+), 21 deletions(-) create mode 100644 modules/db/src/main/scala/gs/smolban/db/doobie/DoobieGroupDb.scala diff --git a/modules/db/src/main/scala/gs/smolban/db/DbError.scala b/modules/db/src/main/scala/gs/smolban/db/DbError.scala index 5d3652c..932ccec 100644 --- a/modules/db/src/main/scala/gs/smolban/db/DbError.scala +++ b/modules/db/src/main/scala/gs/smolban/db/DbError.scala @@ -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 diff --git a/modules/db/src/main/scala/gs/smolban/db/GroupDb.scala b/modules/db/src/main/scala/gs/smolban/db/GroupDb.scala index 4b95826..6a9c41e 100644 --- a/modules/db/src/main/scala/gs/smolban/db/GroupDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/GroupDb.scala @@ -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] diff --git a/modules/db/src/main/scala/gs/smolban/db/TicketDb.scala b/modules/db/src/main/scala/gs/smolban/db/TicketDb.scala index e636622..94bd9ad 100644 --- a/modules/db/src/main/scala/gs/smolban/db/TicketDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/TicketDb.scala @@ -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. * diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieGroupDb.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieGroupDb.scala new file mode 100644 index 0000000..3c36cbc --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieGroupDb.scala @@ -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 diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTicketDb.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTicketDb.scala index ebdf696..f81adef 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTicketDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTicketDb.scala @@ -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 diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala index 94a724e..79ad757 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTypes.scala @@ -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