From a5bea9c7de23f9eb3ad5d75482730515a610d8d8 Mon Sep 17 00:00:00 2001 From: Pat Garrity Date: Tue, 13 Aug 2024 21:31:07 -0500 Subject: [PATCH] Continuing to slowly poke, getting more DB work done. --- modules/db/src/main/resources/sql/1.sql | 4 +- .../scala/gs/smolban/db/DbConstants.scala | 14 ++++ .../main/scala/gs/smolban/db/DbError.scala | 7 ++ .../src/main/scala/gs/smolban/db/TagDb.scala | 32 +++++++- .../gs/smolban/db/doobie/DoobieGroupDb.scala | 4 +- .../gs/smolban/db/doobie/DoobieTagDb.scala | 77 +++++++++++++++++++ .../gs/smolban/db/doobie/DoobieTicketDb.scala | 72 ++++++++++++----- .../gs/smolban/db/doobie/DoobieTypes.scala | 33 +++++++- .../src/main/scala/gs/smolban/model/Tag.scala | 5 +- .../main/scala/gs/smolban/model/Ticket.scala | 34 ++++---- 10 files changed, 235 insertions(+), 47 deletions(-) create mode 100644 modules/db/src/main/scala/gs/smolban/db/DbConstants.scala create mode 100644 modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala diff --git a/modules/db/src/main/resources/sql/1.sql b/modules/db/src/main/resources/sql/1.sql index 2488718..d765247 100644 --- a/modules/db/src/main/resources/sql/1.sql +++ b/modules/db/src/main/resources/sql/1.sql @@ -11,11 +11,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id); CREATE TABLE IF NOT EXISTS tags( id BIGINT PRIMARY KEY, - value TEXT NOT NULL, + tag_value TEXT NOT NULL, created_at DATETIME NOT NULL ); -CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_value ON tags(value); +CREATE UNIQUE INDEX IF NOT EXISTS idx_tags_value ON tags(tag_value); CREATE TABLE IF NOT EXISTS tickets( id BIGINT PRIMARY KEY, diff --git a/modules/db/src/main/scala/gs/smolban/db/DbConstants.scala b/modules/db/src/main/scala/gs/smolban/db/DbConstants.scala new file mode 100644 index 0000000..ec519ec --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/DbConstants.scala @@ -0,0 +1,14 @@ +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 932ccec..739c4bb 100644 --- a/modules/db/src/main/scala/gs/smolban/db/DbError.scala +++ b/modules/db/src/main/scala/gs/smolban/db/DbError.scala @@ -3,6 +3,7 @@ package gs.smolban.db import gs.slug.v0.Slug import gs.smolban.model.Group import gs.smolban.model.SmolbanError +import gs.smolban.model.Tag import gs.smolban.model.Ticket /** Parent type of all database errors in Smolban. @@ -24,4 +25,10 @@ object DbError: slug: Slug ) extends DbError + case class TagAlreadyExists( + value: Tag.Value + ) extends DbError + + case class SilentInsertFailure(context: String) extends DbError + end DbError 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 263d293..ed82669 100644 --- a/modules/db/src/main/scala/gs/smolban/db/TagDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/TagDb.scala @@ -1,9 +1,39 @@ package gs.smolban.db import cats.data.EitherT +import gs.smolban.model.CreatedAt import gs.smolban.model.Tag trait TagDb[F[_]]: - def createTag(tag: Tag.Value): EitherT[F, DbError, Tag] + + /** Create a new [[Tag]]. + * + * @param tag + * The value of the tag to create (some arbitrary string). + * @param createdAt + * The instant at which this tag was created. + * @return + * The new [[Tag]], or an error if the value already exists. + */ + def createTag( + tag: Tag.Value, + createdAt: CreatedAt + ): EitherT[F, DbError, Tag] + + /** Get the value of some [[Tag]] by ID. + * + * @param id + * The internal identifier of the [[Tag]]. + * @return + * The found [[Tag]], or `None` if the ID does not exist. + */ def readTag(id: Tag.Id): F[Option[Tag]] + + /** Delete a [[Tag]] by ID. + * + * @param id + * The unique ID of the [[Tag]] to delete. + * @return + * `true` if the [[Tag]] was deleted, `false` otherwise. + */ def deleteTag(id: Tag.Id): F[Boolean] 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 index 3c36cbc..42db14e 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieGroupDb.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieGroupDb.scala @@ -13,8 +13,8 @@ import gs.smolban.model.Group final class DoobieGroupDb() extends GroupDb[ConnectionIO]: - import DoobieGroupDb.ErrorCodes import DoobieGroupDb.Sql + import gs.smolban.db.DbConstants /** @inheritdoc */ @@ -24,7 +24,7 @@ final class DoobieGroupDb() extends GroupDb[ConnectionIO]: EitherT( Sql.createGroup(group).run.attemptSql.flatMap { case Left(ex) => - if ErrorCodes.UniqueConstraintFailed.contains(ex.getErrorCode()) then + if DbConstants.UniqueConstraintFailed.contains(ex.getErrorCode()) then Left( DbError.GroupAlreadyExists(group.id, group.slug) ).pure[ConnectionIO] 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 new file mode 100644 index 0000000..5df1dd6 --- /dev/null +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/DoobieTagDb.scala @@ -0,0 +1,77 @@ +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 + +final class DoobieTagDb() extends TagDb[ConnectionIO]: + + import DoobieTagDb.Sql + import gs.smolban.db.DbConstants + + /** @inheritdoc + */ + override def createTag( + value: Tag.Value, + 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] + } + ) + + /** @inheritdoc + */ + override def readTag(id: Tag.Id): ConnectionIO[Option[Tag]] = + Sql.readTag(id).option + + /** @inheritdoc + */ + override def deleteTag(id: Tag.Id): ConnectionIO[Boolean] = + for rows <- Sql.deleteTag(id).run + yield rows > 0 + +object DoobieTagDb: + + private object Sql: + + def createTag( + tv: Tag.Value, + createdAt: CreatedAt + ): Query0[Tag.Id] = sql""" + INSERT INTO tags (tag_value, created_at) + VALUES ($tv, $createdAt) + RETURNING id + """.query[Tag.Id] + + def readTag(tagId: Tag.Id): Query0[Tag] = sql""" + SELECT id, value, created_at + FROM tags + WHERE id = $tagId + """.query[Tag] + + def deleteTag(tagId: Tag.Id): Update0 = sql""" + DELETE FROM tags + WHERE id = $tagId + """.update + + end Sql + +end DoobieTagDb 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 f81adef..43918f4 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 @@ -1,7 +1,7 @@ package gs.smolban.db.doobie import cats.data.EitherT -import cats.effect.Async +import cats.syntax.all.* import doobie.* import doobie.implicits.* import gs.smolban.db.DbError @@ -9,61 +9,93 @@ 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[F[_]: Async]( - val xa: Transactor[F] -) extends TicketDb[F]: +final class DoobieTicketDb() extends TicketDb[ConnectionIO]: import DoobieTicketDb.Sql /** @inheritdoc */ - override def createTicket(ticket: Ticket): EitherT[F, DbError, Ticket] = ??? + override def createTicket(ticket: Ticket) + : EitherT[ConnectionIO, DbError, Ticket] = ??? /** @inheritdoc */ - override def readTicket(ref: Ticket.Reference): F[Option[Ticket]] = - ??? - // Sql.readTicket(ref.id, ref.group).option.transact(xa) - // for compose multiple reads + 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 - ): F[Option[Ticket]] = ??? + ): ConnectionIO[Option[Ticket]] = ??? /** @inheritdoc */ - override def deleteTicket(ref: Ticket.Reference): F[Boolean] = ??? + 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 ) - // TODO: figure out how to read tags - // also storing them - // make tagdb and such... - def readTicket( - ticketId: Ticket.Id, + ticketNumber: Ticket.Number, groupId: Group.Id ): Query0[TicketContents] = sql""" - SELECT - created_at, created_by, title, description, status, assignee + SELECT id, created_at, created_by, title, description, status, assignee FROM tickets - WHERE - ticket_id = $ticketId AND group_id = $groupId + 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 79ad757..5265c94 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 @@ -5,16 +5,18 @@ import doobie.* import gs.slug.v0.Slug import gs.smolban.model.CreatedAt import gs.smolban.model.Group +import gs.smolban.model.Tag import gs.smolban.model.Ticket import gs.uuid.v0.UUID import gs.uuid.v0.UUIDFormat +import java.time.Instant trait DoobieTypes: - implicit val ticketIdGet: Get[Ticket.Id] = - Get[Long].tmap(Ticket.Id(_)) + implicit val ticketNumberGet: Get[Ticket.Number] = + Get[Long].tmap(Ticket.Number(_)) - implicit val ticketIdPut: Put[Ticket.Id] = + implicit val ticketNumberPut: Put[Ticket.Number] = Put[Long].tcontramap(_.toLong()) implicit val ticketStatusGet: Get[Ticket.Status] = @@ -39,6 +41,12 @@ trait DoobieTypes: implicit val createdAtPut: Put[CreatedAt] = Put[Long].tcontramap(_.toMilliseconds()) + implicit val instantGet: Get[Instant] = + Get[Long].tmap(Instant.ofEpochMilli) + + implicit val instantPut: Put[Instant] = + Put[Long].tcontramap(_.toEpochMilli()) + implicit val slugGet: Get[Slug] = Get[String].temap { slug => Slug.validate(slug) match @@ -49,10 +57,29 @@ trait DoobieTypes: implicit val slugPut: Put[Slug] = Put[String].tcontramap(_.str()) + implicit val tagIdGet: Get[Tag.Id] = + Get[Long].tmap(Tag.Id(_)) + + implicit val tagIdPut: Put[Tag.Id] = + Put[Long].tcontramap(_.toLong()) + + implicit val tagValueGet: Get[Tag.Value] = + Get[String].temap { value => + Tag.Value.validate(value) match + case None => Left(ErrorMessages.invalidTagValue(value)) + case Some(s) => Right(s) + } + + implicit val tagValuePut: Put[Tag.Value] = + Put[String].tcontramap(_.toString()) + object DoobieTypes extends DoobieTypes: object ErrorMessages: + def invalidTagValue(candidate: String): String = + s"'$candidate' is not a valid tag value." + def invalidTicketStatus(candidate: String): String = s"'$candidate' is not a valid ticket status." diff --git a/modules/model/src/main/scala/gs/smolban/model/Tag.scala b/modules/model/src/main/scala/gs/smolban/model/Tag.scala index 3d5d49f..0c1566c 100644 --- a/modules/model/src/main/scala/gs/smolban/model/Tag.scala +++ b/modules/model/src/main/scala/gs/smolban/model/Tag.scala @@ -1,7 +1,6 @@ package gs.smolban.model import cats.Show -import java.time.Instant /** Represents a smolban "tag", or arbitrary string descriptor. * @@ -15,7 +14,7 @@ import java.time.Instant case class Tag( id: Tag.Id, value: Tag.Value, - createdAt: Instant + createdAt: CreatedAt ) object Tag: @@ -34,6 +33,8 @@ object Tag: given Show[Id] = t => t.toString() + extension (id: Id) def toLong(): Long = id + end Id /** Opaque type which represents a [[Tag]] value. These values are non-empty, diff --git a/modules/model/src/main/scala/gs/smolban/model/Ticket.scala b/modules/model/src/main/scala/gs/smolban/model/Ticket.scala index 0a9b85f..0d3d88a 100644 --- a/modules/model/src/main/scala/gs/smolban/model/Ticket.scala +++ b/modules/model/src/main/scala/gs/smolban/model/Ticket.scala @@ -5,8 +5,8 @@ import java.time.Instant /** Tickets represent some tracked work. * - * @param id - * Unique identifier _within_ the [[Group]]. + * @param number + * Unique number _within_ the [[Group]] Typically ascending integers. * @param group * Unique identifier of the [[Group]] that owns this ticket. * @param createdAt @@ -23,7 +23,7 @@ import java.time.Instant * Linear history of this ticket in terms of status changes. */ case class Ticket( - id: Ticket.Id, + number: Ticket.Number, group: Group.Id, createdAt: CreatedAt, title: String, @@ -32,19 +32,19 @@ case class Ticket( status: Ticket.Status, statusHistory: List[(Ticket.Status, Instant)] ): - lazy val reference: Ticket.Reference = Ticket.Reference(id, group) + lazy val reference: Ticket.Reference = Ticket.Reference(number, group) object Ticket: /** Composite reference that uniquely addresses any [[Ticket]]. * - * @param id + * @param number * The ticket's unique identifier within the [[Group]]. * @param group * The unique identifier of the [[Group]]. */ case class Reference( - id: Ticket.Id, + number: Ticket.Number, group: Group.Id ) @@ -52,32 +52,32 @@ object Ticket: * an opaque type for a Long. In general, [[Ticket]] identifiers are * sequences within a group. */ - opaque type Id = Long + opaque type Number = Long - object Id: + object Number: - /** Instantiate a new [[Ticket.Id]]. + /** Instantiate a new [[Ticket.Number]]. * - * @param id + * @param number * The underlying Long. * @return - * The new [[Ticket.Id]] instance. + * The new [[Ticket.Number]] instance. */ - def apply(id: Long): Id = id + def apply(number: Long): Number = number - given CanEqual[Id, Id] = CanEqual.derived + given CanEqual[Number, Number] = CanEqual.derived - given Show[Id] = _.toLong().toString() + given Show[Number] = _.toLong().toString() - extension (id: Id) + extension (number: Number) /** Unwrap this Ticket ID. * * @return * The underlying Long value. */ - def toLong(): Long = id + def toLong(): Long = number - end Id + end Number /** Enumeration that describes the status of a [[Ticket]] in Smolban. Smolban * does not yet support custom status/workflow.