diff --git a/build.sbt b/build.sbt index c9360a7..782d547 100644 --- a/build.sbt +++ b/build.sbt @@ -27,7 +27,14 @@ val Deps = 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 { @@ -96,7 +103,11 @@ lazy val db = project .settings(name := s"${gsProjectName.value}-db") .settings( libraryDependencies ++= Seq( - Deps.Doobie.Core + Deps.Doobie.Core, + Deps.Doobie.Hikari, + Deps.Doobie.Postgres, + Deps.JdbcDriver.Sqlite, + Deps.JdbcDriver.PostgreSQL ) ) diff --git a/modules/db/src/main/scala/gs/smolban/db/DbConstants.scala b/modules/db/src/main/scala/gs/smolban/db/DbConstants.scala deleted file mode 100644 index ec519ec..0000000 --- a/modules/db/src/main/scala/gs/smolban/db/DbConstants.scala +++ /dev/null @@ -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 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 279d67b..aad209c 100644 --- a/modules/db/src/main/scala/gs/smolban/db/DbError.scala +++ b/modules/db/src/main/scala/gs/smolban/db/DbError.scala @@ -1,10 +1,12 @@ package gs.smolban.db +import _root_.doobie.enumerated.SqlState import gs.smolban.auth.CredentialId import gs.smolban.model.SmolbanError import gs.smolban.model.account.AccountId import gs.smolban.model.account.AccountName import gs.smolban.model.account.AccountType +import gs.smolban.model.metadata.TagValue /** Parent type of all database errors in Smolban. */ @@ -12,6 +14,10 @@ sealed trait DbError extends SmolbanError 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 * same name already exists. * diff --git a/modules/db/src/main/scala/gs/smolban/db/GroupDb.scala b/modules/db/src/main/scala/gs/smolban/db/GroupDb.scala deleted file mode 100644 index 6a9c41e..0000000 --- a/modules/db/src/main/scala/gs/smolban/db/GroupDb.scala +++ /dev/null @@ -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] diff --git a/modules/db/src/main/scala/gs/smolban/db/TagDb.scala b/modules/db/src/main/scala/gs/smolban/db/TagDb.scala index ed82669..b9cdce4 100644 --- a/modules/db/src/main/scala/gs/smolban/db/TagDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/TagDb.scala @@ -1,8 +1,9 @@ package gs.smolban.db import cats.data.EitherT -import gs.smolban.model.CreatedAt -import gs.smolban.model.Tag +import gs.smolban.model.metadata.CreatedAt +import gs.smolban.model.metadata.Tag +import gs.smolban.model.metadata.TagValue trait TagDb[F[_]]: @@ -16,24 +17,24 @@ trait TagDb[F[_]]: * The new [[Tag]], or an error if the value already exists. */ def createTag( - tag: Tag.Value, + tag: TagValue, createdAt: CreatedAt ): EitherT[F, DbError, Tag] - /** Get the value of some [[Tag]] by ID. + /** Get the value of some [[Tag]] by value. * - * @param id - * The internal identifier of the [[Tag]]. + * @param tag + * The value of the [[Tag]]. * @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 - * The unique ID of the [[Tag]] to delete. + * @param tag + * The unique value of the [[Tag]] to delete. * @return * `true` if the [[Tag]] was deleted, `false` otherwise. */ - def deleteTag(id: Tag.Id): F[Boolean] + def deleteTag(tag: TagValue): 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 deleted file mode 100644 index 94bd9ad..0000000 --- a/modules/db/src/main/scala/gs/smolban/db/TicketDb.scala +++ /dev/null @@ -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] diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/CuratedSqlStates.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/CuratedSqlStates.scala new file mode 100644 index 0000000..bc697df --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/CuratedSqlStates.scala @@ -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 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 deleted file mode 100644 index 42db14e..0000000 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieGroupDb.scala +++ /dev/null @@ -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 diff --git a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala index 5df1dd6..0900f90 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala @@ -1,51 +1,47 @@ 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.TagDb import gs.smolban.db.doobie.DoobieTypes.* -import gs.smolban.model.CreatedAt -import gs.smolban.model.Tag +import gs.smolban.model.metadata.CreatedAt +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 gs.smolban.db.DbConstants /** @inheritdoc */ override def createTag( - value: Tag.Value, + value: TagValue, createdAt: CreatedAt ): EitherT[ConnectionIO, DbError, Tag] = EitherT( - Sql.createTag(value, createdAt).option.attemptSql.flatMap { - case Left(ex) => - if DbConstants.UniqueConstraintFailed.contains(ex.getErrorCode()) then - Left( - DbError.TagAlreadyExists(value) - ).pure[ConnectionIO] - else MonadError[ConnectionIO, Throwable].raiseError(ex) - case Right(None) => - Left(DbError.SilentInsertFailure("doobie-tag-db")).pure[ConnectionIO] - case Right(Some(newId)) => - Right(Tag(newId, value, createdAt)).pure[ConnectionIO] + Sql.createTag(value, createdAt).run.attemptSqlState.map { + case Left(sqlState) => + if sqlState.value == sqlStates.uniqueViolation.value then + Left(DbError.TagAlreadyExists(value)) + else Left(DbError.GenericDatabaseError(sqlState)) + case Right(_) => + Right(Tag(value, createdAt)) } ) /** @inheritdoc */ - override def readTag(id: Tag.Id): ConnectionIO[Option[Tag]] = - Sql.readTag(id).option + override def readTag(tag: TagValue): ConnectionIO[Option[Tag]] = + Sql.readTag(tag).option /** @inheritdoc */ - override def deleteTag(id: Tag.Id): ConnectionIO[Boolean] = - for rows <- Sql.deleteTag(id).run + override def deleteTag(tag: TagValue): ConnectionIO[Boolean] = + for rows <- Sql.deleteTag(tag).run yield rows > 0 object DoobieTagDb: @@ -53,23 +49,22 @@ object DoobieTagDb: private object Sql: def createTag( - tv: Tag.Value, + tag: TagValue, createdAt: CreatedAt - ): Query0[Tag.Id] = sql""" + ): Update0 = sql""" INSERT INTO tags (tag_value, created_at) - VALUES ($tv, $createdAt) - RETURNING id - """.query[Tag.Id] + VALUES ($tag, $createdAt) + """.update - def readTag(tagId: Tag.Id): Query0[Tag] = sql""" - SELECT id, value, created_at + def readTag(tag: TagValue): Query0[Tag] = sql""" + SELECT tag_value, created_at FROM tags - WHERE id = $tagId + WHERE tag_value = $tag """.query[Tag] - def deleteTag(tagId: Tag.Id): Update0 = sql""" + def deleteTag(tag: TagValue): Update0 = sql""" DELETE FROM tags - WHERE id = $tagId + WHERE tag_value = $tag """.update end Sql 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 deleted file mode 100644 index 43918f4..0000000 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTicketDb.scala +++ /dev/null @@ -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 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 adc65ce..b7cb04d 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 @@ -3,10 +3,8 @@ package gs.smolban.db.doobie import DoobieTypes.ErrorMessages import doobie.* import gs.smolban.model.metadata.CreatedAt -import gs.smolban.model.metadata.Tag import gs.smolban.model.metadata.TagValue import gs.smolban.model.ticket.CommentId -import gs.smolban.model.ticket.Ticket import gs.smolban.model.ticket.TicketStatus import gs.uuid.v0.UUID import gs.uuid.v0.UUIDFormat