diff --git a/build.gradle b/build.gradle index 480e012f..9972af17 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,13 @@ dependencies { implementation "com.fasterxml.jackson.datatype:jackson-datatype-jdk8:$jacksonVersion" implementation "com.fasterxml.jackson.datatype:jackson-datatype-jsr310:$jacksonVersion" + + implementation 'org.jetbrains.exposed:exposed-core:0.47.0' + implementation 'org.jetbrains.exposed:exposed-jdbc:0.47.0' + implementation 'org.jetbrains.exposed:exposed-java-time:0.47.0' + implementation "org.postgresql:postgresql:42.5.3" + implementation "com.zaxxer:HikariCP:5.0.1" + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2' testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2' testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2' diff --git a/db/docker-compose.yml b/db/docker-compose.yml new file mode 100644 index 00000000..2826e7b7 --- /dev/null +++ b/db/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3' +services: + local-database: + image: "postgres:15.2" + env_file: + - postgres.env # configure postgres + ports: + - "5432:5432" + volumes: + - pg-volume:/var/lib/postgresql/data/ + test-database: + image: "postgres:15.2" + env_file: + - postgres.env + ports: + - "5433:5432" + # no volumes for test +volumes: + pg-volume: { } diff --git a/db/postgres.env b/db/postgres.env new file mode 100644 index 00000000..9697b127 --- /dev/null +++ b/db/postgres.env @@ -0,0 +1,3 @@ +POSTGRES_USER=gilded +POSTGRES_PASSWORD=rose +POSTGRES_DB=gilded-rose diff --git a/db/start-db.sh b/db/start-db.sh new file mode 100755 index 00000000..a39801b2 --- /dev/null +++ b/db/start-db.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -e +BASEDIR=$(dirname "$0") + +docker-compose --file ${BASEDIR}/docker-compose.yml up diff --git a/src/test/kotlin/com/gildedrose/persistence/ItemsTests.kt b/src/test/kotlin/com/gildedrose/persistence/ItemsTests.kt new file mode 100644 index 00000000..3661d163 --- /dev/null +++ b/src/test/kotlin/com/gildedrose/persistence/ItemsTests.kt @@ -0,0 +1,164 @@ +package com.gildedrose.com.gildedrose.persistence + +import com.gildedrose.domain.ID +import com.gildedrose.domain.Item +import com.gildedrose.domain.NonBlankString +import com.gildedrose.domain.Quality +import com.gildedrose.itemForTest +import org.jetbrains.exposed.sql.Column +import org.jetbrains.exposed.sql.Database +import org.jetbrains.exposed.sql.ResultRow +import org.jetbrains.exposed.sql.SchemaUtils +import org.jetbrains.exposed.sql.Table +import org.jetbrains.exposed.sql.insert +import org.jetbrains.exposed.sql.javatime.date +import org.jetbrains.exposed.sql.selectAll +import org.jetbrains.exposed.sql.transactions.transaction +import org.jetbrains.exposed.sql.update +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.postgresql.ds.PGSimpleDataSource +import strikt.api.expectCatching +import strikt.api.expectThat +import strikt.assertions.isA +import strikt.assertions.isEmpty +import strikt.assertions.isEqualTo +import strikt.assertions.isFailure +import java.time.LocalDate + +private val dataSource = PGSimpleDataSource().apply { + user = "gilded" + password = "rose" + databaseName = "gilded-rose" +} + +private val db = Database.connect(dataSource) + +@Disabled("Can't run on CI for now") +internal class ItemsTests { + + private val item1 = itemForTest("id-1", "name", LocalDate.of(2023, 2, 14), 42) + private val item2 = itemForTest("id-2", "another name", null, 99) + + private val items = Items() + + @BeforeEach + fun setUp() { + transaction(db) { + SchemaUtils.drop(ItemsTable) + SchemaUtils.createMissingTablesAndColumns(ItemsTable) + } + } + + @Test + fun `add item`() { + transaction(db) { + expectThat(items.all()).isEmpty() + } + + transaction(db) { + items.add(item1) + items.add(item2) + expectThat(items.all()).isEqualTo(listOf(item1, item2)) + } + + } + + @Test + fun `find by id`() { + transaction(db) { + items.add(item1) + items.add(item2) + expectThat(items.all()).isEqualTo(listOf(item1, item2)) + } + + transaction(db) { + expectThat(items.findById(ID("no-such-id")!!)) + .isEqualTo(null) + expectThat(items.findById(ID("id-1")!!)) + .isEqualTo(item1) + } + + } + + @Test + fun update() { + transaction(db) { + items.add(item1) + items.add(item2) + } + + val updatedItem = item1.copy(name = NonBlankString("new name")!!) + transaction(db) { + items.update(updatedItem) + } + + transaction(db) { + expectThat(items.findById(item1.id)) + .isEqualTo(updatedItem) + } + + transaction(db) { + expectCatching { + items.update(item1.copy(id = ID("no-such-id")!!)) + }.isFailure().isA() + } + } +} + +class Items { + fun all(): List { + return ItemsTable.all() + } + + fun add(item: Item) { + ItemsTable.insert(item) + } + + fun findById(id: ID): Item? { + val items = ItemsTable.selectAll().where { + ItemsTable.id eq id.toString() + }.map(ResultRow::toItem) + if (items.size > 1) + TODO("Handle duplicate IDs") + else + return items.firstOrNull() + } + + fun update(item: Item) { + val rowsChanged = ItemsTable.update({ ItemsTable.id eq item.id.toString() }) { + it[id] = item.id.toString() + it[name] = item.name.toString() + it[sellByDate] = item.sellByDate + it[quality] = item.quality.valueInt + } + check(rowsChanged == 1) + } +} + +object ItemsTable : Table() { + val id: Column = varchar("id", 100) + val name: Column = varchar("name", 100) + val sellByDate: Column = date("sellByDate").nullable() + val quality: Column = integer("quality") +} + +fun ItemsTable.insert(item: Item) { + insert { + it[id] = item.id.toString() + it[name] = item.name.toString() + it[sellByDate] = item.sellByDate + it[quality] = item.quality.valueInt + } +} + +fun ItemsTable.all() = selectAll().map(ResultRow::toItem) + +private fun ResultRow.toItem() = Item( + ID(this[ItemsTable.id]) ?: error("Could not parse id ${this[ItemsTable.id]}"), + NonBlankString(this[ItemsTable.name]) ?: error("Invalid name ${this[ItemsTable.name]}"), + this[ItemsTable.sellByDate], + Quality(this[ItemsTable.quality]) ?: error("Invalid quality ${this[ItemsTable.quality]}"), +) +