Continuing to slowly poke, getting more DB work done.

This commit is contained in:
Pat Garrity 2024-08-13 21:31:07 -05:00
parent ab16e3fdf0
commit a5bea9c7de
Signed by: pfm
GPG key ID: 5CA5D21BAB7F3A76
10 changed files with 235 additions and 47 deletions

View file

@ -11,11 +11,11 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_groups_group_id ON groups(group_id);
CREATE TABLE IF NOT EXISTS tags( CREATE TABLE IF NOT EXISTS tags(
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,
value TEXT NOT NULL, tag_value TEXT NOT NULL,
created_at DATETIME 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( CREATE TABLE IF NOT EXISTS tickets(
id BIGINT PRIMARY KEY, id BIGINT PRIMARY KEY,

View file

@ -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

View file

@ -3,6 +3,7 @@ package gs.smolban.db
import gs.slug.v0.Slug import gs.slug.v0.Slug
import gs.smolban.model.Group import gs.smolban.model.Group
import gs.smolban.model.SmolbanError import gs.smolban.model.SmolbanError
import gs.smolban.model.Tag
import gs.smolban.model.Ticket import gs.smolban.model.Ticket
/** Parent type of all database errors in Smolban. /** Parent type of all database errors in Smolban.
@ -24,4 +25,10 @@ object DbError:
slug: Slug slug: Slug
) extends DbError ) extends DbError
case class TagAlreadyExists(
value: Tag.Value
) extends DbError
case class SilentInsertFailure(context: String) extends DbError
end DbError end DbError

View file

@ -1,9 +1,39 @@
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.Tag import gs.smolban.model.Tag
trait TagDb[F[_]]: 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]] 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] def deleteTag(id: Tag.Id): F[Boolean]

View file

@ -13,8 +13,8 @@ import gs.smolban.model.Group
final class DoobieGroupDb() extends GroupDb[ConnectionIO]: final class DoobieGroupDb() extends GroupDb[ConnectionIO]:
import DoobieGroupDb.ErrorCodes
import DoobieGroupDb.Sql import DoobieGroupDb.Sql
import gs.smolban.db.DbConstants
/** @inheritdoc /** @inheritdoc
*/ */
@ -24,7 +24,7 @@ final class DoobieGroupDb() extends GroupDb[ConnectionIO]:
EitherT( EitherT(
Sql.createGroup(group).run.attemptSql.flatMap { Sql.createGroup(group).run.attemptSql.flatMap {
case Left(ex) => case Left(ex) =>
if ErrorCodes.UniqueConstraintFailed.contains(ex.getErrorCode()) then if DbConstants.UniqueConstraintFailed.contains(ex.getErrorCode()) then
Left( Left(
DbError.GroupAlreadyExists(group.id, group.slug) DbError.GroupAlreadyExists(group.id, group.slug)
).pure[ConnectionIO] ).pure[ConnectionIO]

View file

@ -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

View file

@ -1,7 +1,7 @@
package gs.smolban.db.doobie package gs.smolban.db.doobie
import cats.data.EitherT import cats.data.EitherT
import cats.effect.Async import cats.syntax.all.*
import doobie.* import doobie.*
import doobie.implicits.* import doobie.implicits.*
import gs.smolban.db.DbError import gs.smolban.db.DbError
@ -9,61 +9,93 @@ import gs.smolban.db.TicketDb
import gs.smolban.db.doobie.DoobieTypes.* import gs.smolban.db.doobie.DoobieTypes.*
import gs.smolban.model.CreatedAt import gs.smolban.model.CreatedAt
import gs.smolban.model.Group import gs.smolban.model.Group
import gs.smolban.model.Tag
import gs.smolban.model.Ticket import gs.smolban.model.Ticket
import java.time.Instant
final class DoobieTicketDb[F[_]: Async]( final class DoobieTicketDb() extends TicketDb[ConnectionIO]:
val xa: Transactor[F]
) extends TicketDb[F]:
import DoobieTicketDb.Sql import DoobieTicketDb.Sql
/** @inheritdoc /** @inheritdoc
*/ */
override def createTicket(ticket: Ticket): EitherT[F, DbError, Ticket] = ??? override def createTicket(ticket: Ticket)
: EitherT[ConnectionIO, DbError, Ticket] = ???
/** @inheritdoc /** @inheritdoc
*/ */
override def readTicket(ref: Ticket.Reference): F[Option[Ticket]] = override def readTicket(ref: Ticket.Reference): ConnectionIO[Option[Ticket]] =
??? Sql.readTicket(ref.number, ref.group).option.flatMap {
// Sql.readTicket(ref.id, ref.group).option.transact(xa) case None => None.pure[ConnectionIO]
// for compose multiple reads 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 /** @inheritdoc
*/ */
override def updateTicket( override def updateTicket(
ref: Ticket.Reference, ref: Ticket.Reference,
newValue: Ticket newValue: Ticket
): F[Option[Ticket]] = ??? ): ConnectionIO[Option[Ticket]] = ???
/** @inheritdoc /** @inheritdoc
*/ */
override def deleteTicket(ref: Ticket.Reference): F[Boolean] = ??? override def deleteTicket(ref: Ticket.Reference): ConnectionIO[Boolean] = ???
object DoobieTicketDb: object DoobieTicketDb:
private object Sql: private object Sql:
case class TicketContents( case class TicketContents(
id: Long,
createdAt: CreatedAt, createdAt: CreatedAt,
title: String, title: String,
description: String, description: String,
status: Ticket.Status status: Ticket.Status
) )
// TODO: figure out how to read tags
// also storing them
// make tagdb and such...
def readTicket( def readTicket(
ticketId: Ticket.Id, ticketNumber: Ticket.Number,
groupId: Group.Id groupId: Group.Id
): Query0[TicketContents] = sql""" ): Query0[TicketContents] = sql"""
SELECT SELECT id, created_at, created_by, title, description, status, assignee
created_at, created_by, title, description, status, assignee
FROM tickets FROM tickets
WHERE WHERE ticket_number = $ticketNumber AND group_id = $groupId
ticket_id = $ticketId AND group_id = $groupId
""".query[TicketContents] """.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 Sql
end DoobieTicketDb end DoobieTicketDb

View file

@ -5,16 +5,18 @@ import doobie.*
import gs.slug.v0.Slug import gs.slug.v0.Slug
import gs.smolban.model.CreatedAt import gs.smolban.model.CreatedAt
import gs.smolban.model.Group import gs.smolban.model.Group
import gs.smolban.model.Tag
import gs.smolban.model.Ticket import gs.smolban.model.Ticket
import gs.uuid.v0.UUID import gs.uuid.v0.UUID
import gs.uuid.v0.UUIDFormat import gs.uuid.v0.UUIDFormat
import java.time.Instant
trait DoobieTypes: trait DoobieTypes:
implicit val ticketIdGet: Get[Ticket.Id] = implicit val ticketNumberGet: Get[Ticket.Number] =
Get[Long].tmap(Ticket.Id(_)) Get[Long].tmap(Ticket.Number(_))
implicit val ticketIdPut: Put[Ticket.Id] = implicit val ticketNumberPut: Put[Ticket.Number] =
Put[Long].tcontramap(_.toLong()) Put[Long].tcontramap(_.toLong())
implicit val ticketStatusGet: Get[Ticket.Status] = implicit val ticketStatusGet: Get[Ticket.Status] =
@ -39,6 +41,12 @@ trait DoobieTypes:
implicit val createdAtPut: Put[CreatedAt] = implicit val createdAtPut: Put[CreatedAt] =
Put[Long].tcontramap(_.toMilliseconds()) 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] = implicit val slugGet: Get[Slug] =
Get[String].temap { slug => Get[String].temap { slug =>
Slug.validate(slug) match Slug.validate(slug) match
@ -49,10 +57,29 @@ trait DoobieTypes:
implicit val slugPut: Put[Slug] = implicit val slugPut: Put[Slug] =
Put[String].tcontramap(_.str()) 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 DoobieTypes extends DoobieTypes:
object ErrorMessages: object ErrorMessages:
def invalidTagValue(candidate: String): String =
s"'$candidate' is not a valid tag value."
def invalidTicketStatus(candidate: String): String = def invalidTicketStatus(candidate: String): String =
s"'$candidate' is not a valid ticket status." s"'$candidate' is not a valid ticket status."

View file

@ -1,7 +1,6 @@
package gs.smolban.model package gs.smolban.model
import cats.Show import cats.Show
import java.time.Instant
/** Represents a smolban "tag", or arbitrary string descriptor. /** Represents a smolban "tag", or arbitrary string descriptor.
* *
@ -15,7 +14,7 @@ import java.time.Instant
case class Tag( case class Tag(
id: Tag.Id, id: Tag.Id,
value: Tag.Value, value: Tag.Value,
createdAt: Instant createdAt: CreatedAt
) )
object Tag: object Tag:
@ -34,6 +33,8 @@ object Tag:
given Show[Id] = t => t.toString() given Show[Id] = t => t.toString()
extension (id: Id) def toLong(): Long = id
end Id end Id
/** Opaque type which represents a [[Tag]] value. These values are non-empty, /** Opaque type which represents a [[Tag]] value. These values are non-empty,

View file

@ -5,8 +5,8 @@ import java.time.Instant
/** Tickets represent some tracked work. /** Tickets represent some tracked work.
* *
* @param id * @param number
* Unique identifier _within_ the [[Group]]. * Unique number _within_ the [[Group]] Typically ascending integers.
* @param group * @param group
* Unique identifier of the [[Group]] that owns this ticket. * Unique identifier of the [[Group]] that owns this ticket.
* @param createdAt * @param createdAt
@ -23,7 +23,7 @@ import java.time.Instant
* Linear history of this ticket in terms of status changes. * Linear history of this ticket in terms of status changes.
*/ */
case class Ticket( case class Ticket(
id: Ticket.Id, number: Ticket.Number,
group: Group.Id, group: Group.Id,
createdAt: CreatedAt, createdAt: CreatedAt,
title: String, title: String,
@ -32,19 +32,19 @@ case class Ticket(
status: Ticket.Status, status: Ticket.Status,
statusHistory: List[(Ticket.Status, Instant)] 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: object Ticket:
/** Composite reference that uniquely addresses any [[Ticket]]. /** Composite reference that uniquely addresses any [[Ticket]].
* *
* @param id * @param number
* The ticket's unique identifier within the [[Group]]. * The ticket's unique identifier within the [[Group]].
* @param group * @param group
* The unique identifier of the [[Group]]. * The unique identifier of the [[Group]].
*/ */
case class Reference( case class Reference(
id: Ticket.Id, number: Ticket.Number,
group: Group.Id group: Group.Id
) )
@ -52,32 +52,32 @@ object Ticket:
* an opaque type for a Long. In general, [[Ticket]] identifiers are * an opaque type for a Long. In general, [[Ticket]] identifiers are
* sequences within a group. * 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. * The underlying Long.
* @return * @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. /** Unwrap this Ticket ID.
* *
* @return * @return
* The underlying Long value. * 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 /** Enumeration that describes the status of a [[Ticket]] in Smolban. Smolban
* does not yet support custom status/workflow. * does not yet support custom status/workflow.