diff --git a/.gitignore b/.gitignore index 0e79824..266baaf 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ target/ project/target/ project/project/ .version + +# the test directory is used when running tests as an ephemeral dumping ground +test/* +!test/README.md diff --git a/build.sbt b/build.sbt index 782d547..c816889 100644 --- a/build.sbt +++ b/build.sbt @@ -55,12 +55,17 @@ val Deps = new { } val MUnit: ModuleID = "org.scalameta" %% "munit" % "1.2.1" + + val Slf4j = new { + val Nop: ModuleID = "org.slf4j" % "slf4j-nop" % "2.0.17" + } } lazy val testSettings = Seq( libraryDependencies ++= Seq( Deps.MUnit % Test, - Deps.Gs.Datagen % Test + Deps.Gs.Datagen % Test, + Deps.Slf4j.Nop % Test ) ) 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 index bc697df..f305894 100644 --- a/modules/db/src/main/scala/gs/smolban/db/doobie/CuratedSqlStates.scala +++ b/modules/db/src/main/scala/gs/smolban/db/doobie/CuratedSqlStates.scala @@ -2,16 +2,26 @@ package gs.smolban.db.doobie import doobie.enumerated.SqlState +/** Used to support semantic SQL states (for error handling) across database + * implementations. Smolban supports both SQLite and PostgreSQL. + */ sealed trait CuratedSqlStates: def uniqueViolation: SqlState object CuratedSqlStates: + lazy val postgres: CuratedSqlStates = new PostgreSQL + lazy val sqlite: CuratedSqlStates = new Sqlite + + /** Should be injected when Smolban is run using PostgreSQL. + */ final class PostgreSQL extends CuratedSqlStates: override val uniqueViolation: SqlState = doobie.postgres.sqlstate.class23.UNIQUE_VIOLATION + /** Should be injected when Smolban is run using SQLite. + */ final class Sqlite extends CuratedSqlStates: override val uniqueViolation: SqlState = 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 0900f90..c00ba51 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 @@ -52,18 +52,18 @@ object DoobieTagDb: tag: TagValue, createdAt: CreatedAt ): Update0 = sql""" - INSERT INTO tags (tag_value, created_at) + INSERT INTO tag (tag_value, created_at) VALUES ($tag, $createdAt) """.update def readTag(tag: TagValue): Query0[Tag] = sql""" SELECT tag_value, created_at - FROM tags + FROM tag WHERE tag_value = $tag """.query[Tag] def deleteTag(tag: TagValue): Update0 = sql""" - DELETE FROM tags + DELETE FROM tag WHERE tag_value = $tag """.update diff --git a/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala b/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala new file mode 100644 index 0000000..8f16ddb --- /dev/null +++ b/modules/db/src/test/scala/gs/smolban/db/doobie/DoobieTagDbTests.scala @@ -0,0 +1,74 @@ +package gs.smolban.db.doobie + +import cats.effect.IO +import cats.effect.Resource +import cats.effect.unsafe.IORuntime +import doobie.* +import doobie.hikari.Config +import doobie.hikari.HikariTransactor +import doobie.implicits.* +import doobie.util.transactor.Transactor +import gs.smolban.db.TagDb +import gs.smolban.model.metadata.CreatedAt +import gs.smolban.model.metadata.Tag +import gs.smolban.model.metadata.TagValue +import java.time.Clock +import munit.Location + +class DoobieTagDbTests extends munit.FunSuite: + given IORuntime = IORuntime.global + + def iotest( + name: String + )( + f: => IO[Unit] + )( + using + Location + ): Unit = + test(name)(f.unsafeRunSync()) + + private val dbConfig: Config = Config( + jdbcUrl = "jdbc:sqlite:test/doobie_tag_db_tests", + driverClassName = Some("org.sqlite.JDBC") + ) + + private val transactor: Resource[IO, Transactor[IO]] = + HikariTransactor.fromConfig[IO](dbConfig) + + private val tagDb: TagDb[ConnectionIO] = new DoobieTagDb( + CuratedSqlStates.sqlite + ) + + private val clock = Clock.systemDefaultZone() + + iotest("should create, read, and delete a tag") { + val tagValue = TagValue.validate("x").get + val createdAt = CreatedAt.now(clock) + transactor.use { xa => + (for + _ <- dropTable().run + _ <- createTable().run + tag <- tagDb.createTag(tagValue, createdAt).value + t2 <- tagDb.readTag(tagValue) + result <- tagDb.deleteTag(tagValue) + yield + assertEquals(tag, Right(Tag(tagValue, createdAt))) + assertEquals(t2, tag.toOption) + assert(result) + ).transact(xa) + } + } + + def dropTable(): Update0 = + sql""" + DROP TABLE IF EXISTS tag + """.update + + def createTable(): Update0 = + sql""" + CREATE TABLE IF NOT EXISTS tag( + tag_value TEXT NOT NULL PRIMARY KEY, + created_at DATETIME NOT NULL + ) + """.update diff --git a/modules/model/src/main/scala/gs/smolban/model/metadata/CreatedAt.scala b/modules/model/src/main/scala/gs/smolban/model/metadata/CreatedAt.scala index 998536e..741bd9d 100644 --- a/modules/model/src/main/scala/gs/smolban/model/metadata/CreatedAt.scala +++ b/modules/model/src/main/scala/gs/smolban/model/metadata/CreatedAt.scala @@ -3,9 +3,13 @@ package gs.smolban.model.metadata import cats.Show import java.time.Clock import java.time.Instant +import java.time.temporal.ChronoUnit /** Describes an instant at which something was created. Opaque type for * `java.time.Instant`. + * + * This type always truncates to milliseconds. This is the lowest common + * denominator for database representations (SQLite). */ opaque type CreatedAt = Instant @@ -18,7 +22,8 @@ object CreatedAt: * @return * The new instance. */ - def apply(timestamp: Instant): CreatedAt = timestamp + def apply(timestamp: Instant): CreatedAt = + timestamp.truncatedTo(ChronoUnit.MILLIS) /** Instantiate a new [[CreatedAt]] representing the current instant. * @@ -27,7 +32,7 @@ object CreatedAt: * @return * The new [[CreatedAt]] instance. */ - def now(clock: Clock): CreatedAt = clock.instant() + def now(clock: Clock): CreatedAt = apply(clock.instant()) /** Instantiate a new [[CreatedAt]]. * diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..56d968d --- /dev/null +++ b/test/README.md @@ -0,0 +1,6 @@ +# Test Data + +This directory provides a dumping ground for test data that is produced by tests +while running -- for example, SQLite databases. + +All files (aside from this file) in this directory are ignored.