Skip to content

Commit

Permalink
Add YamlContentPolymorphicSerializer
Browse files Browse the repository at this point in the history
  • Loading branch information
Jojo4GH committed Sep 16, 2024
1 parent f01905c commit 133edeb
Show file tree
Hide file tree
Showing 3 changed files with 248 additions and 6 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package com.charleskorn.kaml

import kotlinx.serialization.*
import kotlinx.serialization.descriptors.PolymorphicKind
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlin.reflect.KClass

@OptIn(ExperimentalSerializationApi::class)
public abstract class YamlContentPolymorphicSerializer<T : Any>(private val baseClass: KClass<T>) : KSerializer<T> {
@OptIn(InternalSerializationApi::class)
override val descriptor: SerialDescriptor = buildSerialDescriptor(
"${YamlContentPolymorphicSerializer::class.simpleName}<${baseClass.simpleName}>",
PolymorphicKind.SEALED
)

@OptIn(InternalSerializationApi::class)
override fun serialize(encoder: Encoder, value: T) {
val actualSerializer = encoder.serializersModule.getPolymorphic(baseClass, value)
?: value::class.serializerOrNull()
?: throwSubtypeNotRegistered(value::class, baseClass)
@Suppress("UNCHECKED_CAST")
(actualSerializer as KSerializer<T>).serialize(encoder, value)
}

override fun deserialize(decoder: Decoder): T {
return decoder.decodeSerializableValue(selectDeserializer((decoder as YamlInput).node))
}

public abstract fun selectDeserializer(node: YamlNode): DeserializationStrategy<T>

private fun throwSubtypeNotRegistered(subClass: KClass<*>, baseClass: KClass<*>): Nothing {
val subClassName = subClass.simpleName ?: "$subClass"
throw SerializationException("""
Class '${subClassName}' is not registered for polymorphic serialization in the scope of '${baseClass.simpleName}'.
Mark the base class as 'sealed' or register the serializer explicitly.
""".trimIndent())
}
}
21 changes: 15 additions & 6 deletions src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,10 @@ public sealed class YamlInput(
is YamlScalar -> when {
descriptor.kind is PrimitiveKind || descriptor.kind is SerialKind.ENUM || descriptor.isInline -> YamlScalarInput(node, yaml, context, configuration)
descriptor.kind is SerialKind.CONTEXTUAL -> createContextual(node, yaml, context, configuration, descriptor)
descriptor.kind is PolymorphicKind -> throw MissingTypeTagException(node.path)
descriptor.kind is PolymorphicKind -> {
if (descriptor.isContentBasedPolymorphic) createContextual(node, yaml, context, configuration, descriptor)
else throw MissingTypeTagException(node.path)
}
else -> throw IncorrectTypeException("Expected ${descriptor.kind.friendlyDescription}, but got a scalar value", node.path)
}

Expand All @@ -63,11 +66,15 @@ public sealed class YamlInput(
is StructureKind.CLASS, StructureKind.OBJECT -> YamlObjectInput(node, yaml, context, configuration)
is StructureKind.MAP -> YamlMapInput(node, yaml, context, configuration)
is SerialKind.CONTEXTUAL -> createContextual(node, yaml, context, configuration, descriptor)
is PolymorphicKind -> when (configuration.polymorphismStyle) {
PolymorphismStyle.None ->
throw IncorrectTypeException("Encountered a polymorphic map descriptor but PolymorphismStyle is 'None'", node.path)
PolymorphismStyle.Tag -> throw MissingTypeTagException(node.path)
PolymorphismStyle.Property -> createPolymorphicMapDeserializer(node, yaml, context, configuration)
is PolymorphicKind -> {
if (descriptor.isContentBasedPolymorphic) createContextual(node, yaml, context, configuration, descriptor)
else when (configuration.polymorphismStyle) {
PolymorphismStyle.None ->
throw IncorrectTypeException("Encountered a polymorphic map descriptor but PolymorphismStyle is 'None'", node.path)

PolymorphismStyle.Tag -> throw MissingTypeTagException(node.path)
PolymorphismStyle.Property -> createPolymorphicMapDeserializer(node, yaml, context, configuration)
}
}
else -> throw IncorrectTypeException("Expected ${descriptor.kind.friendlyDescription}, but got a map", node.path)
}
Expand Down Expand Up @@ -115,6 +122,8 @@ public sealed class YamlInput(
private fun YamlMap.withoutKey(key: String): YamlMap {
return this.copy(entries = entries.filterKeys { it.content != key })
}

private val SerialDescriptor.isContentBasedPolymorphic get() = serialName.startsWith(YamlContentPolymorphicSerializer::class.simpleName!!)
}

override fun <T> decodeSerializableValue(deserializer: DeserializationStrategy<T>): T {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
package com.charleskorn.kaml

import com.charleskorn.kaml.testobjects.TestSealedStructure
import com.charleskorn.kaml.testobjects.polymorphicModule
import io.kotest.assertions.asClue
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.serialization.DeserializationStrategy
import kotlinx.serialization.SerializationException
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.builtins.nullable

class YamlContentPolymorphicSerializerTest : FunSpec({
context("a YAML parser") {
context("parsing polymorphic values") {
context("given polymorphic inputs when PolymorphismStyle.None is used") {
val polymorphicYaml = Yaml(
serializersModule = polymorphicModule,
configuration = YamlConfiguration(polymorphismStyle = PolymorphismStyle.None)
)

context("given some input where the value should be a sealed class") {
val input = """
value: "asdfg"
""".trimIndent()

context("parsing that input") {
val result = polymorphicYaml.decodeFromString(TestSealedStructureBasedOnContentSerializer, input)

test("deserializes it to a Kotlin object") {
result shouldBe TestSealedStructure.SimpleSealedString("asdfg")
}
}
}

context("given some input where the value should be a sealed class (inline)") {
val input = """
"abcdef"
""".trimIndent()

context("parsing that input") {
val result = polymorphicYaml.decodeFromString(TestSealedStructureBasedOnContentSerializer, input)

test("deserializes it to a Kotlin object") {
result shouldBe TestSealedStructure.InlineSealedString("abcdef")
}
}
}

context("given some input missing without the serializer") {
val input = """
value: "asdfg"
""".trimIndent()

context("parsing that input") {
test("throws an exception with the correct location information") {
val exception = shouldThrow<IncorrectTypeException> {
polymorphicYaml.decodeFromString(TestSealedStructure.serializer(), input)
}

exception.asClue {
it.message shouldBe "Encountered a polymorphic map descriptor but PolymorphismStyle is 'None'"
it.line shouldBe 1
it.column shouldBe 1
it.path shouldBe YamlPath.root
}
}
}
}

context("given some input representing a list of polymorphic objects") {
val input = """
- value: null
- value: -987
- value: 654
- "testing"
- value: "tests"
""".trimIndent()

context("parsing that input") {
val result = polymorphicYaml.decodeFromString(
ListSerializer(TestSealedStructureBasedOnContentSerializer),
input
)

test("deserializes it to a Kotlin object") {
result shouldBe listOf(
TestSealedStructure.SimpleSealedString(null),
TestSealedStructure.SimpleSealedInt(-987),
TestSealedStructure.SimpleSealedInt(654),
TestSealedStructure.InlineSealedString("testing"),
TestSealedStructure.SimpleSealedString("tests"),
)
}
}
}

context("given some input with a tag and a type property") {
val input = """
!<sealedInt>
kind: sealedString
value: "asdfg"
""".trimIndent()

context("parsing that input") {
test("throws an exception with the correct location information") {
val exception = shouldThrow<IncorrectTypeException> {
polymorphicYaml.decodeFromString(TestSealedStructureBasedOnContentSerializer, input)
}

exception.asClue {
it.message shouldBe "Encountered a tagged polymorphic descriptor but PolymorphismStyle is 'None'"
it.line shouldBe 1
it.column shouldBe 1
it.path shouldBe YamlPath.root
}
}
}
}
}
}
}
context("a YAML serializer") {
context("serializing polymorphic values") {
context("with custom serializer") {
val polymorphicYaml = Yaml(
serializersModule = polymorphicModule,
configuration = YamlConfiguration(polymorphismStyle = PolymorphismStyle.Tag)
)

context("serializing a sealed type") {
val input = TestSealedStructure.SimpleSealedInt(5)
val output = polymorphicYaml.encodeToString(TestSealedStructureBasedOnContentSerializer, input)
val expectedYaml = """
value: 5
""".trimIndent()

test("returns the value serialized in the expected YAML form") {
output shouldBe expectedYaml
}
}

context("serializing a list of polymorphic values") {
val input = listOf(
TestSealedStructure.SimpleSealedInt(5),
TestSealedStructure.SimpleSealedString("some test"),
TestSealedStructure.SimpleSealedInt(-20),
TestSealedStructure.InlineSealedString("testing"),
TestSealedStructure.SimpleSealedString(null),
null,
)

val output = polymorphicYaml.encodeToString(
ListSerializer(TestSealedStructureBasedOnContentSerializer.nullable),
input
)

val expectedYaml = """
- value: 5
- value: "some test"
- value: -20
- "testing"
- value: null
- null
""".trimIndent()

test("returns the value serialized in the expected YAML form") {
output shouldBe expectedYaml
}
}
}
}
}
})

object TestSealedStructureBasedOnContentSerializer : YamlContentPolymorphicSerializer<TestSealedStructure>(
TestSealedStructure::class
) {
override fun selectDeserializer(node: YamlNode): DeserializationStrategy<TestSealedStructure> = when (node) {
is YamlScalar -> TestSealedStructure.InlineSealedString.serializer()
is YamlMap -> when (val value: YamlNode? = node["value"]) {
is YamlScalar -> when {
value.content.toIntOrNull() == null -> TestSealedStructure.SimpleSealedString.serializer()
else -> TestSealedStructure.SimpleSealedInt.serializer()
}
is YamlNull -> TestSealedStructure.SimpleSealedString.serializer()
else -> throw SerializationException("Unsupported property type for TestSealedStructure.value: ${value?.let { it::class.simpleName}}")
}
else -> throw SerializationException("Unsupported node type for TestSealedStructure: ${node::class.simpleName}")
}
}

0 comments on commit 133edeb

Please sign in to comment.