This commit is contained in:
InsanusMokrassar 2020-04-11 13:06:10 +06:00
parent e07a910039
commit e83c2b4e0b
15 changed files with 275 additions and 173 deletions

View File

@ -8,4 +8,4 @@ uuidVersion=0.1.0
gradle_bintray_plugin_version=1.8.4 gradle_bintray_plugin_version=1.8.4
core_version=0.3.0 core_version=0.4.0

View File

@ -38,6 +38,7 @@ kotlin {
dependencies { dependencies {
implementation kotlin('stdlib') implementation kotlin('stdlib')
api "org.jetbrains.kotlinx:kotlinx-coroutines-core-common:$kotlin_coroutines_version" api "org.jetbrains.kotlinx:kotlinx-coroutines-core-common:$kotlin_coroutines_version"
// api "org.jetbrains.kotlinx:kotlinx-coroutines-io:$kotlin_io_version"
api "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$kotlin_serialisation_runtime_version" api "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$kotlin_serialisation_runtime_version"
api "com.soywiz.korlibs.klock:klock:$klockVersion" api "com.soywiz.korlibs.klock:klock:$klockVersion"

View File

@ -0,0 +1 @@
kotlin_io_version=0.27.0-eap13

View File

@ -1,5 +1,7 @@
package com.insanusmokrassar.postssystem.core.content package com.insanusmokrassar.postssystem.core.content
import com.insanusmokrassar.postssystem.core.utils.ByteArrayAllocator
import com.insanusmokrassar.postssystem.core.utils.ByteArrayAllocatorSerializer
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
typealias ContentId = String typealias ContentId = String
@ -11,15 +13,23 @@ typealias ContentId = String
sealed class Content sealed class Content
@Serializable @Serializable
data class SimpleSpecialContent( data class SpecialContent(
val internalId: ContentId val internalId: ContentId
) : Content() ) : Content()
@Serializable @Serializable
data class SimpleTextContent( data class TextContent(
val text: String val text: String
) : Content() ) : Content()
@Serializable
data class BinaryContent(
val mimeType: String,
val originalFileName: String,
@Serializable(ByteArrayAllocatorSerializer::class)
val dataAllocator: ByteArrayAllocator
) : Content()
/** /**
* Content which is already registered in database. Using its [id] you can retrieve all known * Content which is already registered in database. Using its [id] you can retrieve all known
* [com.insanusmokrassar.postssystem.core.post.RegisteredPost]s by using * [com.insanusmokrassar.postssystem.core.post.RegisteredPost]s by using

View File

@ -3,6 +3,6 @@ package com.insanusmokrassar.postssystem.core.content.api
import com.insanusmokrassar.postssystem.core.content.* import com.insanusmokrassar.postssystem.core.content.*
interface WriteContentAPI { interface WriteContentAPI {
suspend fun createContent(content: Content): RegisteredContent? suspend fun registerContent(content: Content): RegisteredContent?
suspend fun deleteContent(id: ContentId): Boolean suspend fun deleteContent(id: ContentId): Boolean
} }

View File

@ -0,0 +1,20 @@
package com.insanusmokrassar.postssystem.core.utils
import kotlinx.serialization.*
import kotlinx.serialization.builtins.*
typealias ByteArrayAllocator = () -> ByteArray
object ByteArrayAllocatorSerializer : KSerializer<ByteArrayAllocator> {
private val realSerializer = ByteArraySerializer()
override val descriptor: SerialDescriptor = realSerializer.descriptor
override fun deserialize(decoder: Decoder): ByteArrayAllocator {
val bytes = decoder.decode(realSerializer)
return { bytes }
}
override fun serialize(encoder: Encoder, value: ByteArrayAllocator) {
encoder.encodeSerializableValue(realSerializer, value())
}
}

View File

@ -13,9 +13,9 @@ class ContentSerialization {
@Test @Test
fun test_that_content_correctly_serializing_and_deserializing() { fun test_that_content_correctly_serializing_and_deserializing() {
val contents = (0 until simpleTextTestEntries).map { val contents = (0 until simpleTextTestEntries).map {
SimpleTextContent("Example$it") TextContent("Example$it")
} + (0 until simpleSpecialTestEntries).map { } + (0 until simpleSpecialTestEntries).map {
SimpleSpecialContent("$it") SpecialContent("$it")
} }
val registeredContentFakes = contents.map { content -> val registeredContentFakes = contents.map { content ->

View File

@ -1,56 +0,0 @@
package com.insanusmokrassar.postssystem.core.api
import com.insanusmokrassar.postssystem.core.content.*
import com.insanusmokrassar.postssystem.core.content.api.ContentAPI
import com.insanusmokrassar.postssystem.core.utils.generateContentId
import com.insanusmokrassar.postssystem.core.utils.pagination.*
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.serialization.ImplicitReflectionSerializer
@ImplicitReflectionSerializer
class InMemoryContentAPI(
initialContent: List<RegisteredContent> = emptyList()
): ContentAPI {
private val contentCreatedBroadcastChannel = BroadcastChannel<RegisteredContent>(Channel.BUFFERED)
private val contentDeletedBroadcastChannel = BroadcastChannel<RegisteredContent>(Channel.BUFFERED)
override val contentCreatedFlow: Flow<RegisteredContent>
get() = contentCreatedBroadcastChannel.asFlow()
override val contentDeletedFlow: Flow<RegisteredContent>
get() = contentDeletedBroadcastChannel.asFlow()
private val contents: MutableMap<ContentId, RegisteredContent> = initialContent
.associateBy(RegisteredContent::id)
.toMutableMap()
override suspend fun createContent(content: Content): RegisteredContent? {
return RegisteredContent(
generateContentId(),
content
).also { registeredContent ->
contents[registeredContent.id] = registeredContent
contentCreatedBroadcastChannel.send(registeredContent)
}
}
override suspend fun getContentsIds(): Set<ContentId> = contents.keys.toSet()
override suspend fun getContentById(id: ContentId): RegisteredContent? = contents[id]
override suspend fun getContentByPagination(pagination: Pagination): PaginationResult<out RegisteredContent> {
return contents.values.asSequence().drop(pagination.firstIndex).take(pagination.size).toList().createPaginationResult(
pagination,
commonObjectsNumber = contents.size.toLong()
)
}
override suspend fun deleteContent(id: ContentId): Boolean {
return contents.remove(id)?.also { content ->
contentDeletedBroadcastChannel.send(content)
} != null
}
}

View File

@ -1,86 +0,0 @@
package com.insanusmokrassar.postssystem.core.api
import com.insanusmokrassar.postssystem.core.content.ContentId
import com.insanusmokrassar.postssystem.core.post.*
import com.insanusmokrassar.postssystem.core.post.api.PostsAPI
import com.insanusmokrassar.postssystem.core.utils.generatePostId
import com.insanusmokrassar.postssystem.core.utils.pagination.*
import com.soywiz.klock.DateTime
import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.channels.Channel.Factory.BUFFERED
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow
import kotlinx.serialization.ImplicitReflectionSerializer
/**
* Thread-unsafe sample realization of [PostsAPI]
*/
@ImplicitReflectionSerializer
class InMemoryPostsAPI(
initialPosts: List<RegisteredPost> = emptyList()
) : PostsAPI {
private val posts: MutableMap<PostId, RegisteredPost> = initialPosts.associateBy { it.id }.toMutableMap()
private val sortedByDatePosts: List<RegisteredPost>
get() = posts.values.sortedBy { it.creationDate }
private val postCreatedBroadcastChannel = BroadcastChannel<RegisteredPost>(BUFFERED)
override val postCreatedFlow: Flow<RegisteredPost> = postCreatedBroadcastChannel.asFlow()
private val postDeletedBroadcastChannel = BroadcastChannel<RegisteredPost>(BUFFERED)
override val postDeletedFlow: Flow<RegisteredPost> = postDeletedBroadcastChannel.asFlow()
private val postUpdatedBroadcastChannel = BroadcastChannel<RegisteredPost>(BUFFERED)
override val postUpdatedFlow: Flow<RegisteredPost> = postUpdatedBroadcastChannel.asFlow()
override suspend fun createPost(post: Post): RegisteredPost? {
return SimpleRegisteredPost(
generatePostId(),
post.content
).also { newPost ->
posts[newPost.id] = newPost
postCreatedBroadcastChannel.send(newPost)
}
}
override suspend fun deletePost(id: PostId): Boolean {
return posts.remove(id)?.also {
postDeletedBroadcastChannel.send(it)
} != null
}
override suspend fun updatePostContent(postId: PostId, post: Post): Boolean {
return getPostById(postId)?.also { dbPost ->
val newPost = SimpleRegisteredPost(
dbPost.id,
post.content,
dbPost.creationDate
)
posts[newPost.id] = newPost
postUpdatedBroadcastChannel.send(newPost)
} != null
}
override suspend fun getPostsIds(): Set<PostId> = posts.keys.toSet()
override suspend fun getPostById(id: PostId): RegisteredPost? = posts[id]
override suspend fun getPostsByContent(id: ContentId): List<RegisteredPost> =
posts.values.filter { post -> id in post.content }
override suspend fun getPostsByCreatingDates(from: DateTime, to: DateTime): List<RegisteredPost> =
(from .. to).let { range ->
posts.values.filter {
it.creationDate in range
}
}
override suspend fun getPostsByPagination(
pagination: Pagination
): PaginationResult<RegisteredPost> = sortedByDatePosts.subList(
pagination.firstIndex,
pagination.lastIndex
).createPaginationResult(
pagination,
commonObjectsNumber = posts.size.toLong()
)
}

View File

@ -2,20 +2,30 @@ package com.insanusmokrassar.postssystem.core.exposed
import com.insanusmokrassar.postssystem.core.content.* import com.insanusmokrassar.postssystem.core.content.*
import com.insanusmokrassar.postssystem.core.content.api.ContentAPI import com.insanusmokrassar.postssystem.core.content.api.ContentAPI
import com.insanusmokrassar.postssystem.core.exposed.content.*
import com.insanusmokrassar.postssystem.core.utils.generateContentId import com.insanusmokrassar.postssystem.core.utils.generateContentId
import com.insanusmokrassar.postssystem.core.utils.pagination.* import com.insanusmokrassar.postssystem.core.utils.pagination.*
import kotlinx.coroutines.channels.BroadcastChannel import kotlinx.coroutines.channels.BroadcastChannel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.asFlow import kotlinx.coroutines.flow.asFlow
import kotlinx.serialization.json.Json
import org.jetbrains.exposed.sql.* import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction import org.jetbrains.exposed.sql.transactions.transaction
private val Content.type
get() = when (this) {
is TextContent -> "text"
is BinaryContent -> "binary"
is SpecialContent -> "special"
}
private class ContentAPIDatabaseTable( private class ContentAPIDatabaseTable(
private val database: Database private val database: Database,
) : Table("ContentAPI"), ContentAPI { private val textHolder: ContentHolderRepo<TextContent>,
private val binaryHolder: ContentHolderRepo<BinaryContent>,
private val specialHolder: ContentHolderRepo<SpecialContent>
) : Table("ContentAPI"), ContentAPI, ContentHolderRepo<Content> {
internal val idColumn = text("_id") internal val idColumn = text("_id")
internal val dataColumn = text("data") internal val typeColumn = text("type")
init { init {
transaction(database) { transaction(database) {
@ -28,19 +38,48 @@ private class ContentAPIDatabaseTable(
override val contentCreatedFlow: Flow<RegisteredContent> = contentCreatedBroadcastChannel.asFlow() override val contentCreatedFlow: Flow<RegisteredContent> = contentCreatedBroadcastChannel.asFlow()
override val contentDeletedFlow: Flow<RegisteredContent> = contentDeletedBroadcastChannel.asFlow() override val contentDeletedFlow: Flow<RegisteredContent> = contentDeletedBroadcastChannel.asFlow()
override suspend fun createContent(content: Content): RegisteredContent? { private val String.holder
get() = when (this) {
"text" -> textHolder
"binary" -> binaryHolder
"special" -> specialHolder
else -> null
}
override suspend fun putContent(id: ContentId, content: Content) {
when (content) {
is TextContent -> textHolder.putContent(id, content)
is BinaryContent -> binaryHolder.putContent(id, content)
is SpecialContent -> specialHolder.putContent(id, content)
}
}
override suspend fun getContent(id: ContentId): Content? = transaction(database) {
select { idColumn.eq(id) }.limit(1).firstOrNull() ?.get(typeColumn)
} ?.holder ?.getContent(id)
override suspend fun removeContent(id: ContentId) {
transaction(database) {
select { idColumn.eq(id) }.limit(1).firstOrNull() ?.get(typeColumn)
} ?.holder ?.removeContent(id)
}
override suspend fun registerContent(content: Content): RegisteredContent? {
val id = generateContentId()
val type = content.type
return transaction(database) { return transaction(database) {
insert { insert {
it[idColumn] = generateContentId() it[idColumn] = id
it[dataColumn] = Json.stringify(Content.serializer(), content) it[typeColumn] = type
}.getOrNull(idColumn) ?.let { id -> }.getOrNull(idColumn)
RegisteredContent( } ?.let { id ->
id, putContent(id, content)
content RegisteredContent(
) id,
} content
)
} ?.also { } ?.also {
contentCreatedBroadcastChannel.send(it) contentCreatedBroadcastChannel.send(it)
} ?: null.also {
removeContent(id)
} }
} }
override suspend fun deleteContent(id: ContentId): Boolean { override suspend fun deleteContent(id: ContentId): Boolean {
@ -51,15 +90,18 @@ private class ContentAPIDatabaseTable(
} > 0 } > 0
}.also { }.also {
if (it) { if (it) {
removeContent(id)
contentDeletedBroadcastChannel.send(content) contentDeletedBroadcastChannel.send(content)
} }
} }
} }
private fun ResultRow.asRegisteredContent(): RegisteredContent = RegisteredContent( private fun ResultRow.asRegisteredContent(content: Content): RegisteredContent? = get(idColumn).let {
get(idColumn), RegisteredContent(
Json.parse(Content.serializer(), get(dataColumn)) it,
) content
)
}
override suspend fun getContentsIds(): Set<ContentId> { override suspend fun getContentsIds(): Set<ContentId> {
return transaction(database) { return transaction(database) {
@ -67,15 +109,16 @@ private class ContentAPIDatabaseTable(
}.toSet() }.toSet()
} }
override suspend fun getContentById(id: ContentId): RegisteredContent? { override suspend fun getContentById(id: ContentId): RegisteredContent? {
val content = getContent(id) ?: return null
return transaction(database) { return transaction(database) {
select { idColumn.eq(id) }.firstOrNull() ?.asRegisteredContent() select { idColumn.eq(id) }.limit(1).firstOrNull() ?.asRegisteredContent(content)
} }
} }
override suspend fun getContentByPagination(pagination: Pagination): PaginationResult<out RegisteredContent> { override suspend fun getContentByPagination(pagination: Pagination): PaginationResult<out RegisteredContent> {
return transaction(database) { return transaction(database) {
selectAll().count() to selectAll().paginate(pagination).map { it.asRegisteredContent() } selectAll().count() to selectAll().paginate(pagination).map { it[idColumn] }
}.let { (count, results) -> }.let { (count, results) ->
results.createPaginationResult( results.mapNotNull { RegisteredContent(it, getContent(it) ?: return@mapNotNull null) }.createPaginationResult(
pagination, pagination,
count count
) )
@ -84,5 +127,8 @@ private class ContentAPIDatabaseTable(
} }
class ExposedContentAPI ( class ExposedContentAPI (
database: Database database: Database,
) : ContentAPI by ContentAPIDatabaseTable(database) textHolder: ContentHolderRepo<TextContent> = TextContentHolderRepo(database),
binaryHolder: ContentHolderRepo<BinaryContent> = BinaryContentHolderRepo(database),
specialHolder: ContentHolderRepo<SpecialContent> = SpecialContentHolderRepo(database)
) : ContentAPI by ContentAPIDatabaseTable(database, textHolder, binaryHolder, specialHolder)

View File

@ -0,0 +1,57 @@
package com.insanusmokrassar.postssystem.core.exposed.content
import com.insanusmokrassar.postssystem.core.content.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.statements.api.ExposedBlob
import org.jetbrains.exposed.sql.transactions.transaction
private class BinaryContentHolderRepoTable(
private val database: Database
) : ContentHolderRepo<BinaryContent>, Table() {
private val idColumn = text("id")
private val dataColumn = blob("data")
private val mimeColumn = text("mimeType")
private val originalFileNameColumn = text("filename")
override val primaryKey: PrimaryKey = PrimaryKey(idColumn)
init {
transaction(database) {
SchemaUtils.createMissingTablesAndColumns(this@BinaryContentHolderRepoTable)
}
}
override suspend fun getContent(id: ContentId): BinaryContent? = transaction(database) {
select {
idColumn.eq(id)
}.limit(1).firstOrNull() ?.let {
val bytes = it[dataColumn].bytes
BinaryContent(
it[mimeColumn],
it[originalFileNameColumn]
) {
bytes
}
}
}
override suspend fun removeContent(id: ContentId) {
transaction(database) {
deleteWhere { idColumn.eq(id) }
}
}
override suspend fun putContent(id: ContentId, content: BinaryContent) {
transaction(database) {
insert {
it[idColumn] = id
it[originalFileNameColumn] = content.originalFileName
it[mimeColumn] = content.mimeType
it[dataColumn] = ExposedBlob(content.dataAllocator())
}
}
}
}
class BinaryContentHolderRepo(
database: Database
) : ContentHolderRepo<BinaryContent> by BinaryContentHolderRepoTable(database)

View File

@ -0,0 +1,10 @@
package com.insanusmokrassar.postssystem.core.exposed.content
import com.insanusmokrassar.postssystem.core.content.Content
import com.insanusmokrassar.postssystem.core.content.ContentId
interface ContentHolderRepo<T : Content> {
suspend fun getContent(id: ContentId) : T?
suspend fun removeContent(id: ContentId)
suspend fun putContent(id: ContentId, content: T)
}

View File

@ -0,0 +1,46 @@
package com.insanusmokrassar.postssystem.core.exposed.content
import com.insanusmokrassar.postssystem.core.content.*
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
private class SpecialContentHolderRepoTable(
private val database: Database
) : ContentHolderRepo<SpecialContent>, Table() {
private val idColumn = text("id")
private val internalIdColumn = text("internalId")
override val primaryKey: PrimaryKey = PrimaryKey(idColumn)
init {
transaction(database) {
SchemaUtils.createMissingTablesAndColumns(this@SpecialContentHolderRepoTable)
}
}
override suspend fun getContent(id: ContentId): SpecialContent? = transaction(database) {
select {
idColumn.eq(id)
}.limit(1).firstOrNull() ?.get(internalIdColumn) ?.let {
SpecialContent(it)
}
}
override suspend fun removeContent(id: ContentId) {
transaction(database) {
deleteWhere { idColumn.eq(id) }
}
}
override suspend fun putContent(id: ContentId, content: SpecialContent) {
transaction(database) {
insert {
it[idColumn] = id
it[internalIdColumn] = content.internalId
}
}
}
}
class SpecialContentHolderRepo(
database: Database
) : ContentHolderRepo<SpecialContent> by SpecialContentHolderRepoTable(database)

View File

@ -0,0 +1,47 @@
package com.insanusmokrassar.postssystem.core.exposed.content
import com.insanusmokrassar.postssystem.core.content.ContentId
import com.insanusmokrassar.postssystem.core.content.TextContent
import org.jetbrains.exposed.sql.*
import org.jetbrains.exposed.sql.transactions.transaction
private class TextContentHolderRepoTable(
private val database: Database
) : ContentHolderRepo<TextContent>, Table() {
private val idColumn = text("id")
private val textColumn = text("text")
override val primaryKey: PrimaryKey = PrimaryKey(idColumn)
init {
transaction(database) {
SchemaUtils.createMissingTablesAndColumns(this@TextContentHolderRepoTable)
}
}
override suspend fun getContent(id: ContentId): TextContent? = transaction(database) {
select {
idColumn.eq(id)
}.limit(1).firstOrNull() ?.get(textColumn) ?.let {
TextContent(it)
}
}
override suspend fun removeContent(id: ContentId) {
transaction(database) {
deleteWhere { idColumn.eq(id) }
}
}
override suspend fun putContent(id: ContentId, content: TextContent) {
transaction(database) {
insert {
it[idColumn] = id
it[textColumn] = content.text
}
}
}
}
class TextContentHolderRepo(
database: Database
) : ContentHolderRepo<TextContent> by TextContentHolderRepoTable(database)

View File

@ -1,12 +1,13 @@
package com.insanusmokrassar.postssystem.core.exposed package com.insanusmokrassar.postssystem.core.exposed
import com.insanusmokrassar.postssystem.core.content.SimpleTextContent import com.insanusmokrassar.postssystem.core.content.TextContent
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.jetbrains.exposed.sql.Database import org.jetbrains.exposed.sql.Database
import org.jetbrains.exposed.sql.transactions.transactionManager import org.jetbrains.exposed.sql.transactions.transactionManager
import java.io.File import java.io.File
import java.sql.Connection import java.sql.Connection
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals
class ExposedContentAPICommonTests { class ExposedContentAPICommonTests {
private val tempFolder = System.getProperty("java.io.tmpdir")!! private val tempFolder = System.getProperty("java.io.tmpdir")!!
@ -20,6 +21,10 @@ class ExposedContentAPICommonTests {
} }
val apis = databaseFiles.map { val apis = databaseFiles.map {
File(it).also {
it.delete()
it.deleteOnExit()
}
ExposedContentAPI( ExposedContentAPI(
Database.Companion.connect("jdbc:sqlite:$it", driver = "org.sqlite.JDBC").also { Database.Companion.connect("jdbc:sqlite:$it", driver = "org.sqlite.JDBC").also {
it.transactionManager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE it.transactionManager.defaultIsolationLevel = Connection.TRANSACTION_SERIALIZABLE
@ -28,9 +33,10 @@ class ExposedContentAPICommonTests {
} }
val results = apis.mapIndexed { i, api -> val results = apis.mapIndexed { i, api ->
val content = runBlocking { api.createContent(SimpleTextContent(i.toString())) } val content = runBlocking { api.registerContent(TextContent(i.toString())) }
assert(content != null) assert(content != null)
assert(runBlocking { api.getContentsIds().size == 1 }) val ids = runBlocking { api.getContentsIds() }
assertEquals(ids.size, 1)
content!! content!!
} }