use mutable entities

add audiability for Book and Serie
changed Serie to Book relationship to unidirectional for now
This commit is contained in:
Gauthier Roebroeck 2019-08-20 15:02:52 +08:00
parent 1c674916ce
commit f3cbb5a960
19 changed files with 290 additions and 106 deletions

3
.gitignore vendored
View file

@ -10,7 +10,8 @@
.springBeans
### IntelliJ IDEA ###
.idea
!.idea/
.idea/*
!/.idea/runConfigurations/
*.iws
*.iml

View file

@ -4,6 +4,7 @@ import org.gotson.komga.infrastructure.configuration.KomgaProperties
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.boot.runApplication
import org.springframework.data.jpa.repository.config.EnableJpaAuditing
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@ -11,6 +12,7 @@ import org.springframework.scheduling.annotation.EnableScheduling
KomgaProperties::class
)
@EnableScheduling
@EnableJpaAuditing
class Application
fun main(args: Array<String>) {

View file

@ -0,0 +1,21 @@
package org.gotson.komga.domain.model
import org.springframework.data.annotation.CreatedDate
import org.springframework.data.annotation.LastModifiedDate
import org.springframework.data.jpa.domain.support.AuditingEntityListener
import java.time.LocalDateTime
import javax.persistence.Column
import javax.persistence.EntityListeners
import javax.persistence.MappedSuperclass
@MappedSuperclass
@EntityListeners(AuditingEntityListener::class)
abstract class AuditableEntity {
@CreatedDate
@Column(name = "created_date", updatable = false, nullable = false)
var createdDate: LocalDateTime? = null
@LastModifiedDate
@Column(name = "last_modified_date", nullable = false)
var lastModifiedDate: LocalDateTime? = null
}

View file

@ -11,37 +11,30 @@ import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.ManyToOne
import javax.persistence.OneToOne
import javax.persistence.PrimaryKeyJoinColumn
import javax.persistence.Table
import javax.validation.constraints.NotBlank
import javax.validation.constraints.NotNull
@Entity
@Table(name = "book")
class Book(
@NotBlank
@Column(name = "name", nullable = false)
val name: String,
var name: String,
@Column(name = "url", nullable = false)
val url: URL,
var url: URL,
@Column(name = "updated", nullable = false)
val updated: LocalDateTime
) {
@Column(name = "file_last_modified", nullable = false)
var fileLastModified: LocalDateTime
) : AuditableEntity() {
@Id
@GeneratedValue
@Column(name = "id", nullable = false)
@PrimaryKeyJoinColumn
var id: Long = 0
@NotNull
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "serie_id", nullable = false)
lateinit var serie: Serie
@OneToOne(optional = false, orphanRemoval = true, cascade = [CascadeType.ALL], fetch = FetchType.LAZY)
@JoinColumn(name = "book_metadata_id", nullable = false)
var metadata: BookMetadata = BookMetadata().also { it.book = this }

View file

@ -19,16 +19,18 @@ import javax.persistence.Table
class BookMetadata(
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
val status: Status = Status.UNKNOWN,
var status: Status = Status.UNKNOWN,
@Column(name = "media_type")
val mediaType: String? = null,
var mediaType: String? = null,
@Column(name = "thumbnail")
@Lob
val thumbnail: ByteArray? = null,
var thumbnail: ByteArray? = null,
pages: List<BookPage> = emptyList()
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "book_metadata_page", joinColumns = [JoinColumn(name = "book_metadata_id")])
var pages: MutableList<BookPage> = mutableListOf()
) {
@Id
@GeneratedValue
@ -38,15 +40,11 @@ class BookMetadata(
@OneToOne(optional = false, fetch = FetchType.LAZY, mappedBy = "metadata")
lateinit var book: Book
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "book_metadata_page", joinColumns = [JoinColumn(name = "book_metadata_id")])
private val _pages: MutableList<BookPage> = mutableListOf()
val pages: List<BookPage>
get() = _pages.toList()
init {
_pages.addAll(pages)
fun reset() {
status = Status.UNKNOWN
mediaType = null
thumbnail = null
pages = mutableListOf()
}
}

View file

@ -8,6 +8,8 @@ import javax.persistence.Entity
import javax.persistence.FetchType
import javax.persistence.GeneratedValue
import javax.persistence.Id
import javax.persistence.JoinColumn
import javax.persistence.JoinTable
import javax.persistence.OneToMany
import javax.persistence.Table
import javax.validation.constraints.NotBlank
@ -17,30 +19,31 @@ import javax.validation.constraints.NotBlank
class Serie(
@NotBlank
@Column(name = "name", nullable = false)
val name: String,
var name: String,
@Column(name = "url", nullable = false)
val url: URL,
var url: URL,
@Column(name = "updated", nullable = false)
val updated: LocalDateTime
) {
@Column(name = "file_last_modified", nullable = false)
var fileLastModified: LocalDateTime,
books: MutableList<Book>
) : AuditableEntity() {
@Id
@GeneratedValue
@Column(name = "id", nullable = false, unique = true)
var id: Long = 0
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, mappedBy = "serie", orphanRemoval = true)
private var _books: MutableList<Book> = mutableListOf()
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.LAZY, orphanRemoval = true)
@JoinTable(name = "serie_books_mapping", joinColumns = [JoinColumn(name = "serie_id")], inverseJoinColumns = [JoinColumn(name = "book_id")])
var books: MutableList<Book> = mutableListOf()
set(value) {
value.forEach { it.serie = this }
field = value
books.clear()
books.addAll(value)
}
val books: List<Book>
get() = _books.toList()
fun setBooks(books: List<Book>) {
_books = books.toMutableList()
init {
this.books = books
}
}

View file

@ -2,16 +2,16 @@ package org.gotson.komga.domain.persistence
import org.gotson.komga.domain.model.Book
import org.gotson.komga.domain.model.Status
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.data.jpa.repository.JpaRepository
import org.springframework.stereotype.Repository
import java.net.URL
@Repository
interface BookRepository : JpaRepository<Book, Long> {
fun findAllBySerieId(serieId: Long, pageable: Pageable): Page<Book>
// fun findAllBySerieId(serieId: Long, pageable: Pageable): Page<Book>
// fun findAllBySerieIdAndUrlNotIn(serieId: Long, urls: Iterable<URL>): List<Book>
fun findByUrl(url: URL): Book?
fun findAllByMetadataStatus(status: Status): List<Book>
fun findAllByMetadataStatusAndSerieId(status: Status, serieId: Long, pageable: Pageable): Page<Book>
// fun findAllByMetadataStatusAndSerieId(status: Status, serieId: Long, pageable: Pageable): Page<Book>
// fun deleteAllBySerieId(serieId: Long)
}

View file

@ -8,7 +8,7 @@ import java.net.URL
@Repository
interface SerieRepository : JpaRepository<Serie, Long>, JpaSpecificationExecutor<Serie> {
fun deleteAllByUrlNotIn(urls: Iterable<URL>)
fun countByUrlNotIn(urls: Iterable<URL>): Long
// fun deleteAllByUrlNotIn(urls: Iterable<URL>)
fun findByUrlNotIn(urls: Iterable<URL>): List<Serie>
fun findByUrl(url: URL): Serie?
}

View file

@ -54,7 +54,7 @@ class BookParser(
null
}
return BookMetadata(mediaType = mediaType, status = Status.READY, pages = pages, thumbnail = thumbnail)
return BookMetadata(mediaType = mediaType, status = Status.READY, pages = pages.toMutableList(), thumbnail = thumbnail)
}
fun getPageStream(book: Book, number: Int): InputStream {

View file

@ -44,7 +44,7 @@ class FileSystemScanner {
Book(
name = FilenameUtils.getBaseName(it.fileName.toString()),
url = it.toUri().toURL(),
updated = it.getUpdatedTime()
fileLastModified = it.getUpdatedTime()
)
}.toList()
.sortedWith(
@ -54,8 +54,9 @@ class FileSystemScanner {
Serie(
name = dir.fileName.toString(),
url = dir.toUri().toURL(),
updated = dir.getUpdatedTime()
).also { it.setBooks(books) }
fileLastModified = dir.getUpdatedTime(),
books = books.toMutableList()
)
}.toList()
}.also {
val countOfBooks = scannedSeries.sumBy { it.books.size }

View file

@ -5,6 +5,7 @@ import org.gotson.komga.domain.model.Library
import org.gotson.komga.domain.model.Status
import org.gotson.komga.domain.persistence.BookRepository
import org.gotson.komga.domain.persistence.SerieRepository
import org.springframework.data.auditing.AuditingHandler
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import kotlin.system.measureTimeMillis
@ -16,7 +17,8 @@ class LibraryManager(
private val fileSystemScanner: FileSystemScanner,
private val serieRepository: SerieRepository,
private val bookRepository: BookRepository,
private val bookManager: BookManager
private val bookManager: BookManager,
private val auditingHandler: AuditingHandler
) {
@Transactional
@ -30,33 +32,49 @@ class LibraryManager(
logger.info { "Scan returned no series, deleting all existing series" }
serieRepository.deleteAll()
} else {
val urls = series.map { it.url }
val countOfSeriesToDelete = serieRepository.countByUrlNotIn(urls)
if (countOfSeriesToDelete > 0) {
logger.info { "Deleting $countOfSeriesToDelete series not on disk anymore" }
serieRepository.deleteAllByUrlNotIn(urls)
}
}
// match IDs for existing entities
series.forEach { newSerie ->
serieRepository.findByUrl(newSerie.url)?.let { existingSerie ->
newSerie.id = existingSerie.id
newSerie.books.forEach { newBook ->
bookRepository.findByUrl(newBook.url)?.let { existingBook ->
newBook.id = existingBook.id
// conserve metadata if book has not changed
if (newBook.updated == existingBook.updated)
newBook.metadata = existingBook.metadata
else
logger.info { "Book changed on disk, reset metadata status: ${newBook.url}" }
}
series.map { it.url }.let { urls ->
serieRepository.findByUrlNotIn(urls).forEach {
urls.forEach { logger.info { "Deleting serie not on disk anymore: $it" } }
serieRepository.delete(it)
}
}
}
serieRepository.saveAll(series)
series.forEach { newSerie ->
val existingSerie = serieRepository.findByUrl(newSerie.url)
// if serie does not exist, save it
if (existingSerie == null) {
serieRepository.save(newSerie)
} else {
// if serie already exists, update it
if (newSerie.fileLastModified != existingSerie.fileLastModified) {
logger.info { "Serie changed on disk, updating: ${newSerie.url}" }
existingSerie.name = newSerie.name
var anyBookchanged = false
// update list of books with existing entities if they exist
existingSerie.books = newSerie.books.map { newBook ->
val existingBook = bookRepository.findByUrl(newBook.url) ?: newBook
if (newBook.fileLastModified != existingBook.fileLastModified) {
logger.info { "Book changed on disk, update and reset metadata status: ${newBook.url}" }
existingBook.fileLastModified = newBook.fileLastModified
existingBook.name = newBook.name
existingBook.metadata.reset()
anyBookchanged = true
}
existingBook
}.toMutableList()
// propagate modification of any of the books to the serie, so that LastModifiedDate is updated
if (anyBookchanged)
auditingHandler.markModified(existingSerie)
serieRepository.save(existingSerie)
}
}
}
}.also { logger.info { "Library update finished in $it ms" } }
}

View file

@ -53,14 +53,16 @@ class SerieController(
@GetMapping("{id}/books")
fun getAllBooksBySerie(
@PathVariable id: Long,
@RequestParam(value = "readyonly", defaultValue = "true") readyFilter: Boolean,
page: Pageable
): Page<BookDto> {
if (!serieRepository.existsById(id)) throw ResponseStatusException(HttpStatus.NOT_FOUND)
@RequestParam(value = "readyonly", defaultValue = "true") readyFilter: Boolean
// page: Pageable
): List<BookDto> {
val serie = serieRepository.findByIdOrNull(id) ?: throw ResponseStatusException(HttpStatus.NOT_FOUND)
return if (readyFilter) {
bookRepository.findAllByMetadataStatusAndSerieId(Status.READY, id, page)
// bookRepository.findAllByMetadataStatusAndSerieId(Status.READY, id, page)
serie.books.filter { it.metadata.status == Status.READY }
} else {
bookRepository.findAllBySerieId(id, page)
// bookRepository.findAllBySerieId(id, page)
serie.books
}.map { it.toDto() }
}

View file

@ -2,18 +2,19 @@ create sequence hibernate_sequence start with 1 increment by 1;
create table book
(
id bigint generated by default as identity,
name varchar not null,
updated timestamp not null,
url varchar not null,
book_metadata_id bigint not null,
serie_id bigint not null,
id bigint not null,
created_date timestamp not null,
last_modified_date timestamp not null,
file_last_modified timestamp not null,
name varchar not null,
url varchar not null,
book_metadata_id bigint not null,
primary key (id)
);
create table book_metadata
(
id bigint generated by default as identity,
id bigint not null,
media_type varchar,
status varchar not null,
thumbnail blob,
@ -29,21 +30,35 @@ create table book_metadata_page
create table serie
(
id bigint generated by default as identity,
name varchar not null,
updated timestamp not null,
url varchar not null,
id bigint not null,
created_date timestamp not null,
last_modified_date timestamp not null,
file_last_modified timestamp not null,
name varchar not null,
url varchar not null,
primary key (id)
);
create table serie_books_mapping
(
serie_id bigint not null,
book_id bigint not null
);
alter table book
add constraint uk_book_book_metadata_id unique (book_metadata_id);
alter table serie_books_mapping
add constraint UK_ya6l4fjcs4rlhwx1fwqwsmm unique (book_id);
alter table book
add constraint fk_book_book_metadata_book_metadata_id foreign key (book_metadata_id) references book_metadata (id);
alter table book
add constraint fk_book_serie_serie_id foreign key (serie_id) references serie (id);
alter table book_metadata_page
add constraint fk_book_metadata_page_book_metadata_book_metadata_id foreign key (book_metadata_id) references book_metadata (id);
alter table serie_books_mapping
add constraint fk_serie_books_mapping_book_book_id foreign key (book_id) references book (id);
alter table serie_books_mapping
add constraint fk_serie_books_mapping_serie_serie_id foreign key (serie_id) references serie (id);

View file

@ -3,11 +3,15 @@ package org.gotson.komga.domain.model
import java.net.URL
import java.time.LocalDateTime
fun makeBook(name: String, url: String = "file:/$name") =
Book(name = name, url = URL(url), updated = LocalDateTime.now())
fun makeBook(name: String, url: String = "file:/$name", fileLastModified: LocalDateTime = LocalDateTime.now()): Book {
Thread.sleep(5)
return Book(name = name, url = URL(url), fileLastModified = fileLastModified)
}
fun makeSerie(name: String, url: String = "file:/$name", books: List<Book> = listOf()) =
Serie(name = name, url = URL(url), updated = LocalDateTime.now()).also { it.setBooks(books) }
fun makeSerie(name: String, url: String = "file:/$name", books: List<Book> = listOf()): Serie {
Thread.sleep(5)
return Serie(name = name, url = URL(url), fileLastModified = LocalDateTime.now(), books = books.toMutableList())
}
fun makeBookPage(name: String) =
BookPage(name, "image/png")

View file

@ -0,0 +1,103 @@
package org.gotson.komga.domain.persistence
import org.assertj.core.api.Assertions.assertThat
import org.gotson.komga.domain.model.makeBook
import org.gotson.komga.domain.model.makeSerie
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest
import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager
import org.springframework.test.context.junit.jupiter.SpringExtension
import org.springframework.transaction.annotation.Transactional
import java.time.LocalDateTime
@ExtendWith(SpringExtension::class)
@DataJpaTest
@Transactional
class AuditableEntityTest(
@Autowired private val serieRepository: SerieRepository,
@Autowired private val entityManager: TestEntityManager
) {
@AfterEach
fun `clear repository`() {
entityManager.clear()
}
@Test
fun `given serie with book when saving then created and modified date is also saved`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
// when
serieRepository.save(serie)
// then
assertThat(serie.createdDate).isBefore(LocalDateTime.now())
assertThat(serie.lastModifiedDate).isBefore(LocalDateTime.now())
assertThat(serie.books.first().createdDate).isBefore(LocalDateTime.now())
assertThat(serie.books.first().lastModifiedDate).isBefore(LocalDateTime.now())
}
@Test
fun `given existing serie with book when updating serie only then created date is kept and modified date is changed for serie only`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
serieRepository.save(serie)
val creationTimeApprox = LocalDateTime.now()
Thread.sleep(1000)
// when
serie.name = "serieUpdated"
serieRepository.saveAndFlush(serie)
val modificationTimeApprox = LocalDateTime.now()
// then
assertThat(serie.createdDate)
.isBefore(creationTimeApprox)
.isNotEqualTo(serie.lastModifiedDate)
assertThat(serie.lastModifiedDate)
.isAfter(creationTimeApprox)
.isBefore(modificationTimeApprox)
assertThat(serie.books.first().createdDate)
.isBefore(creationTimeApprox)
.isEqualTo(serie.books.first().lastModifiedDate)
}
@Test
fun `given existing serie with book when updating book only then created date is kept and modified date is changed for book only`() {
// given
val serie = makeSerie(name = "serie", books = listOf(makeBook("book1")))
serieRepository.save(serie)
val creationTimeApprox = LocalDateTime.now()
Thread.sleep(1000)
// when
serie.books.first().name = "bookUpdated"
serieRepository.saveAndFlush(serie)
val modificationTimeApprox = LocalDateTime.now()
// then
assertThat(serie.createdDate)
.isBefore(creationTimeApprox)
.isEqualTo(serie.lastModifiedDate)
assertThat(serie.books.first().createdDate)
.isBefore(creationTimeApprox)
.isNotEqualTo(serie.books.first().lastModifiedDate)
assertThat(serie.books.first().lastModifiedDate)
.isAfter(creationTimeApprox)
.isBefore(modificationTimeApprox)
}
}

View file

@ -52,7 +52,7 @@ class PersistenceTest(
// when
val book = bookRepository.findAll().first()
book.metadata = BookMetadata(status = Status.READY, mediaType = "test", pages = listOf(makeBookPage("page1")))
book.metadata = BookMetadata(status = Status.READY, mediaType = "test", pages = mutableListOf(makeBookPage("page1")))
bookRepository.save(book)

View file

@ -69,7 +69,7 @@ class LibraryManagerTest(
assertThat(series).hasSize(1)
assertThat(series.first().books).hasSize(2)
assertThat(series.first().books.map { it.name }).containsExactlyInAnyOrder("book1", "book2")
assertThat(series.first().books.map { it.name }).containsExactly("book1", "book2")
}
@Test
@ -95,7 +95,7 @@ class LibraryManagerTest(
assertThat(series).hasSize(1)
assertThat(series.first().books).hasSize(1)
assertThat(series.first().books.map { it.name }).containsExactlyInAnyOrder("book1")
assertThat(series.first().books.map { it.name }).containsExactly("book1")
assertThat(bookRepository.count()).describedAs("Orphan book has been removed").isEqualTo(1)
}
@ -121,8 +121,10 @@ class LibraryManagerTest(
verify(exactly = 2) { mockScanner.scanRootFolder(any()) }
assertThat(series).hasSize(1)
assertThat(series.first().lastModifiedDate).isNotEqualTo(series.first().createdDate)
assertThat(series.first().books).hasSize(1)
assertThat(series.first().books.map { it.name }).containsExactlyInAnyOrder("book1updated")
assertThat(series.first().books.map { it.name }).containsExactly("book1updated")
assertThat(series.first().books.first().lastModifiedDate).isNotEqualTo(series.first().books.first().createdDate)
}
@Test
@ -150,14 +152,15 @@ class LibraryManagerTest(
@Test
fun `given existing Book with metadata when rescanning then metadata is kept intact`() {
//given
val book1 = makeBook("book1")
every { mockScanner.scanRootFolder(any()) }
.returnsMany(
listOf(makeSerie(name = "serie", books = listOf(makeBook("book1")))),
listOf(makeSerie(name = "serie", books = listOf(makeBook("book1"))))
listOf(makeSerie(name = "serie", books = listOf(book1))),
listOf(makeSerie(name = "serie", books = listOf(makeBook(name = "book1", fileLastModified = book1.fileLastModified))))
)
libraryManager.scanRootFolder(library)
every { mockParser.parse(any()) } returns BookMetadata(status = Status.READY, mediaType = "application/zip", pages = listOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")))
every { mockParser.parse(any()) } returns BookMetadata(status = Status.READY, mediaType = "application/zip", pages = mutableListOf(makeBookPage("1.jpg"), makeBookPage("2.jpg")))
bookRepository.findAll().forEach { bookManager.parseAndPersist(it) }
// when
@ -172,5 +175,6 @@ class LibraryManagerTest(
assertThat(book.metadata.mediaType).isEqualTo("application/zip")
assertThat(book.metadata.pages).hasSize(2)
assertThat(book.metadata.pages.map { it.fileName }).containsExactly("1.jpg", "2.jpg")
assertThat(book.lastModifiedDate).isNotEqualTo(book.createdDate)
}
}

View file

@ -0,0 +1,19 @@
package org.gotson.komga.infrastructure
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.extension.ExtendWith
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.ActiveProfiles
import org.springframework.test.context.junit.jupiter.SpringExtension
@ExtendWith(SpringExtension::class)
@SpringBootTest
@AutoConfigureTestDatabase
@ActiveProfiles("flyway")
class FlywayTest {
@Test
fun `Application loads properly with flyway profile`() {
}
}

View file

@ -2,10 +2,10 @@ application.version: TESTING
spring:
flyway:
enabled: true
enabled: false
jpa:
hibernate:
ddl-auto: validate
ddl-auto: none
jpa:
properties:
hibernate: