Continuing to slowly poke, getting more DB work done.
This commit is contained in:
parent
ab16e3fdf0
commit
a5bea9c7de
10 changed files with 235 additions and 47 deletions
|
@ -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,
|
||||
|
|
14
modules/db/src/main/scala/gs/smolban/db/DbConstants.scala
Normal file
14
modules/db/src/main/scala/gs/smolban/db/DbConstants.scala
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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."
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Add table
Reference in a new issue