-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
4 changed files
with
267 additions
and
12 deletions.
There are no files selected for viewing
64 changes: 64 additions & 0 deletions
64
...c/src/main/kotlin/example/spring/boot/jdbc/persistence/ClientBasedBookRecordRepository.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,64 @@ | ||
package example.spring.boot.jdbc.persistence | ||
|
||
import example.spring.boot.jdbc.business.Book | ||
import example.spring.boot.jdbc.business.BookRecord | ||
import org.slf4j.LoggerFactory.getLogger | ||
import org.springframework.dao.DuplicateKeyException | ||
import org.springframework.dao.IncorrectResultSizeDataAccessException | ||
import org.springframework.jdbc.core.simple.JdbcClient | ||
import org.springframework.stereotype.Repository | ||
import org.springframework.util.IdGenerator | ||
import java.util.UUID | ||
import kotlin.jvm.optionals.getOrNull | ||
|
||
@Repository | ||
class ClientBasedBookRecordRepository( | ||
private val client: JdbcClient, | ||
private val idGenerator: IdGenerator | ||
) { | ||
|
||
private val log = getLogger(javaClass) | ||
|
||
fun create(book: Book): BookRecord { | ||
val id = idGenerator.generateId() | ||
return try { | ||
client.sql("INSERT INTO book_records (id, title, isbn) VALUES (:id, :title, :isbn)") | ||
.param("id", id) | ||
.param("title", book.title) | ||
.param("isbn", book.isbn) | ||
.update() | ||
BookRecord(id, book) | ||
} catch (e: DuplicateKeyException) { | ||
log.warn("ID collision occurred for ID [{}] - retrying with new ID", id) | ||
create(book) | ||
} | ||
} | ||
|
||
fun update(bookRecord: BookRecord): Boolean = | ||
client.sql("UPDATE book_records SET title = :title, isbn = :isbn WHERE id = :id") | ||
.param("id", bookRecord.id) | ||
.param("title", bookRecord.book.title) | ||
.param("isbn", bookRecord.book.isbn) | ||
.update() != 0 | ||
|
||
fun findBy(id: UUID): BookRecord? = | ||
try { | ||
client.sql("SELECT * FROM book_records WHERE id = :id") | ||
.param("id", id) | ||
.query { rs, _ -> | ||
val title = rs.getString("title")!! | ||
val isbn = rs.getString("isbn") | ||
BookRecord(id, Book(title, isbn)) | ||
} | ||
.optional() | ||
.getOrNull() | ||
} catch (e: IncorrectResultSizeDataAccessException) { | ||
null | ||
} | ||
|
||
fun deleteBy(id: UUID): Boolean = | ||
client.sql("DELETE FROM book_records WHERE id = :id") | ||
.param("id", id) | ||
.update() != 0 | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
191 changes: 191 additions & 0 deletions
191
.../test/kotlin/example/spring/boot/jdbc/persistence/ClientBasedBookRecordRepositoryTests.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,191 @@ | ||
package example.spring.boot.jdbc.persistence | ||
|
||
import com.ninjasquad.springmockk.MockkBean | ||
import example.spring.boot.jdbc.business.Book | ||
import example.spring.boot.jdbc.business.BookRecord | ||
import example.spring.boot.jdbc.utils.InitializeWithContainerizedPostgreSQL | ||
import io.mockk.clearMocks | ||
import io.mockk.every | ||
import io.mockk.mockk | ||
import io.mockk.verify | ||
import org.assertj.core.api.Assertions.assertThat | ||
import org.flywaydb.core.Flyway | ||
import org.h2.jdbcx.JdbcDataSource | ||
import org.junit.jupiter.api.BeforeEach | ||
import org.junit.jupiter.api.Nested | ||
import org.junit.jupiter.api.Test | ||
import org.springframework.beans.factory.annotation.Autowired | ||
import org.springframework.boot.test.autoconfigure.jdbc.JdbcTest | ||
import org.springframework.context.annotation.Import | ||
import org.springframework.jdbc.core.simple.JdbcClient | ||
import org.springframework.test.context.ActiveProfiles | ||
import org.springframework.util.IdGenerator | ||
import java.util.UUID.randomUUID | ||
|
||
internal class ClientBasedBookRecordRepositoryTests { | ||
|
||
/** | ||
* Fastest boostrap, but only simulates PostgreSQL behaviour. | ||
*/ | ||
@Nested | ||
inner class AsUnitTest : ClientBasedBookRecordRepositoryContract() { | ||
|
||
val dataSource = JdbcDataSource() | ||
.apply { setUrl("jdbc:h2:mem:${randomUUID()};MODE=PostgreSQL;DB_CLOSE_DELAY=-1") } | ||
.apply { user = "sa"; password = "sa" } | ||
.also { | ||
Flyway.configure() | ||
.dataSource(it) | ||
.locations("classpath:db/migration") | ||
.load() | ||
.migrate() | ||
} | ||
|
||
override val idGenerator: IdGenerator = mockk() | ||
override val cut = ClientBasedBookRecordRepository(JdbcClient.create(dataSource), idGenerator) | ||
|
||
} | ||
|
||
/** | ||
* Much faster boostrap, but only simulates PostgreSQL behaviour. | ||
*/ | ||
@Nested | ||
@JdbcTest | ||
@ActiveProfiles("test", "in-memory") | ||
@MockkBean(IdGenerator::class) | ||
@Import(ClientBasedBookRecordRepository::class) | ||
inner class AsTechnologyIntegrationTestWithH2InMemoryDatabase( | ||
@Autowired override val idGenerator: IdGenerator, | ||
@Autowired override val cut: ClientBasedBookRecordRepository | ||
) : ClientBasedBookRecordRepositoryContract() | ||
|
||
/** | ||
* Takes longer to boostrap, but also provides real PostgreSQL behaviour. | ||
* | ||
* This actually discovered that with PostgreSQL and native JDBC, a UUID type | ||
* column cannot be queries using a String representation of a UUID. | ||
*/ | ||
@Nested | ||
@JdbcTest | ||
@ActiveProfiles("test", "docker") | ||
@MockkBean(IdGenerator::class) | ||
@Import(ClientBasedBookRecordRepository::class) | ||
@InitializeWithContainerizedPostgreSQL | ||
inner class AsTechnologyIntegrationTestWithDockerizedDatabase( | ||
@Autowired override val idGenerator: IdGenerator, | ||
@Autowired override val cut: ClientBasedBookRecordRepository | ||
) : ClientBasedBookRecordRepositoryContract() | ||
|
||
abstract class ClientBasedBookRecordRepositoryContract { | ||
|
||
protected abstract val idGenerator: IdGenerator | ||
protected abstract val cut: ClientBasedBookRecordRepository | ||
|
||
val cleanCode = Book("Clean Code", "9780132350884") | ||
val cleanArchitecture = Book("Clean Architecture", "9780134494166") | ||
|
||
val id1 = randomUUID() | ||
val id2 = randomUUID() | ||
|
||
@BeforeEach | ||
fun resetMocks() { | ||
clearMocks(idGenerator) | ||
} | ||
|
||
@Nested | ||
inner class Creating { | ||
|
||
@Test | ||
fun `creating a book returns a book record`() { | ||
every { idGenerator.generateId() } returns id1 | ||
val bookRecord = cut.create(cleanCode) | ||
assertThat(bookRecord).isEqualTo(BookRecord(id1, cleanCode)) | ||
} | ||
|
||
@Test | ||
fun `duplicated keys during creation are handled`() { | ||
every { idGenerator.generateId() } returnsMany listOf(id1, id1, id2) | ||
|
||
val bookRecord1 = cut.create(cleanArchitecture) | ||
val bookRecord2 = cut.create(cleanArchitecture) | ||
|
||
assertThat(bookRecord1.id).isEqualTo(id1) | ||
assertThat(bookRecord2.id).isEqualTo(id2) | ||
|
||
verify(exactly = 3) { idGenerator.generateId() } // there was a retry | ||
} | ||
|
||
} | ||
|
||
@Nested | ||
inner class Getting { | ||
|
||
@Test | ||
fun `existing book records can be found by id`() { | ||
every { idGenerator.generateId() } returns id1 | ||
|
||
val bookRecord = cut.create(cleanCode) | ||
val foundBookRecord = cut.findBy(bookRecord.id) | ||
|
||
assertThat(foundBookRecord).isEqualTo(bookRecord) | ||
} | ||
|
||
@Test | ||
fun `non existing book records are returned as null when trying to find them by id`() { | ||
assertThat(cut.findBy(id2)).isNull() | ||
} | ||
|
||
} | ||
|
||
@Nested | ||
inner class Updating { | ||
|
||
@Test | ||
fun `updating an existing book record changes all its data except the id`() { | ||
every { idGenerator.generateId() } returns id1 | ||
|
||
val created = cut.create(cleanCode) | ||
assertThat(cut.findBy(id1)).isEqualTo(created) | ||
|
||
val changed = created.copy(book = cleanArchitecture) | ||
val wasUpdated = cut.update(changed) | ||
assertThat(wasUpdated).isTrue() | ||
|
||
assertThat(cut.findBy(id1)).isEqualTo(changed) | ||
} | ||
|
||
@Test | ||
fun `updating non existing book returns false`() { | ||
val bookRecord = BookRecord(id2, cleanCode) | ||
val wasUpdated = cut.update(bookRecord) | ||
assertThat(wasUpdated).isFalse() | ||
} | ||
|
||
} | ||
|
||
@Nested | ||
inner class Deleting { | ||
|
||
@Test | ||
fun `existing book records can be deleted by id`() { | ||
every { idGenerator.generateId() } returns id1 | ||
|
||
val bookRecord = cut.create(cleanCode) | ||
assertThat(cut.findBy(id1)).isEqualTo(bookRecord) | ||
|
||
val wasDeleted = cut.deleteBy(id1) | ||
assertThat(wasDeleted).isTrue() | ||
assertThat(cut.findBy(bookRecord.id)).isNull() | ||
} | ||
|
||
@Test | ||
fun `deleting non existing book record throws exception`() { | ||
val wasDeleted = cut.deleteBy(id2) | ||
assertThat(wasDeleted).isFalse() | ||
} | ||
|
||
} | ||
|
||
} | ||
|
||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters