diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt new file mode 100644 index 00000000..a9f1f02c --- /dev/null +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt @@ -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(private val baseClass: KClass) : KSerializer { + @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).serialize(encoder, value) + } + + override fun deserialize(decoder: Decoder): T { + return decoder.decodeSerializableValue(selectDeserializer((decoder as YamlInput).node)) + } + + public abstract fun selectDeserializer(node: YamlNode): DeserializationStrategy + + 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()) + } +} diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlContextualInput.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlContextualInput.kt index 6e80a01d..627b3f5f 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlContextualInput.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlContextualInput.kt @@ -24,7 +24,11 @@ import kotlinx.serialization.modules.SerializersModule internal class YamlContextualInput(node: YamlNode, yaml: Yaml, context: SerializersModule, configuration: YamlConfiguration) : YamlInput(node, yaml, context, configuration) { override fun decodeElementIndex(descriptor: SerialDescriptor): Int = throw IllegalStateException("Must call beginStructure() and use returned Decoder") - override fun decodeValue(): Any = throw IllegalStateException("Must call beginStructure() and use returned Decoder") + override fun decodeValue(): Any = when (node) { + is YamlScalar -> node.content + is YamlNull -> throw UnexpectedNullValueException(node.path) + else -> throw IllegalStateException("Must call beginStructure() and use returned Decoder") + } override fun beginStructure(descriptor: SerialDescriptor): CompositeDecoder = createFor(node, yaml, serializersModule, configuration, descriptor) diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt index a0acf52e..4eb8b16d 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlInput.kt @@ -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) } @@ -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) } @@ -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 decodeSerializableValue(deserializer: DeserializationStrategy): T { diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlListInput.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlListInput.kt index 9675c986..1c5509e7 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlListInput.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlListInput.kt @@ -18,6 +18,7 @@ package com.charleskorn.kaml +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.CompositeDecoder @@ -65,6 +66,13 @@ internal class YamlListInput(val list: YamlList, yaml: Yaml, context: Serializer override fun decodeChar(): Char = currentElementDecoder.decodeChar() override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = currentElementDecoder.decodeEnum(enumDescriptor) + override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { + if (!haveStartedReadingElements) { + return super.decodeSerializableValue(deserializer) + } + return currentElementDecoder.decodeSerializableValue(deserializer) + } + private val haveStartedReadingElements: Boolean get() = nextElementIndex > 0 diff --git a/src/commonMain/kotlin/com/charleskorn/kaml/YamlMapLikeInputBase.kt b/src/commonMain/kotlin/com/charleskorn/kaml/YamlMapLikeInputBase.kt index 4bd2b292..bfabb190 100644 --- a/src/commonMain/kotlin/com/charleskorn/kaml/YamlMapLikeInputBase.kt +++ b/src/commonMain/kotlin/com/charleskorn/kaml/YamlMapLikeInputBase.kt @@ -18,6 +18,7 @@ package com.charleskorn.kaml +import kotlinx.serialization.DeserializationStrategy import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.modules.SerializersModule @@ -45,6 +46,15 @@ internal sealed class YamlMapLikeInputBase(map: YamlMap, yaml: Yaml, context: Se override fun decodeChar(): Char = fromCurrentValue { decodeChar() } override fun decodeEnum(enumDescriptor: SerialDescriptor): Int = fromCurrentValue { decodeEnum(enumDescriptor) } + override fun decodeSerializableValue(deserializer: DeserializationStrategy): T { + if (!haveStartedReadingEntries) { + return super.decodeSerializableValue(deserializer) + } + return fromCurrentValue { + decodeSerializableValue(deserializer) + } + } + protected fun fromCurrentValue(action: YamlInput.() -> T): T { try { return action(currentValueDecoder) diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt new file mode 100644 index 00000000..77d58bd6 --- /dev/null +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlContentPolymorphicSerializer.kt @@ -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 { + 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 = """ + ! + kind: sealedString + value: "asdfg" + """.trimIndent() + + context("parsing that input") { + test("throws an exception with the correct location information") { + val exception = shouldThrow { + 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::class +) { + override fun selectDeserializer(node: YamlNode): DeserializationStrategy = 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}") + } +} diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt index 5709999d..2b2d618c 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlReadingTest.kt @@ -1470,6 +1470,20 @@ class YamlReadingTest : FlatFunSpec({ } } + context("given some input for an object where the property value should be a sealed class (inline)") { + val input = """ + element: ! "abcdef" + """.trimIndent() + + context("parsing that input") { + val result = polymorphicYaml.decodeFromString(SealedWrapper.serializer(), input) + + test("deserializes it to a Kotlin object") { + result shouldBe SealedWrapper(TestSealedStructure.InlineSealedString("abcdef")) + } + } + } + context("given some input for an object where the property value is a literal") { val input = """ test: ! 42 @@ -1492,6 +1506,7 @@ class YamlReadingTest : FlatFunSpec({ value: -987 - ! value: 654 + - ! "testing" - ! value: "tests" """.trimIndent() @@ -1505,6 +1520,7 @@ class YamlReadingTest : FlatFunSpec({ TestSealedStructure.SimpleSealedString(null), TestSealedStructure.SimpleSealedInt(-987), TestSealedStructure.SimpleSealedInt(654), + TestSealedStructure.InlineSealedString("testing"), TestSealedStructure.SimpleSealedString("tests"), ) } @@ -1603,11 +1619,11 @@ class YamlReadingTest : FlatFunSpec({ val exception = shouldThrow { polymorphicYaml.decodeFromString(TestSealedStructure.serializer(), input) } exception.asClue { - it.message shouldBe "Unknown type 'someOtherType'. Known types are: sealedInt, sealedString" + it.message shouldBe "Unknown type 'someOtherType'. Known types are: inlineString, sealedInt, sealedString" it.line shouldBe 1 it.column shouldBe 1 it.typeName shouldBe "someOtherType" - it.validTypeNames shouldBe setOf("sealedInt", "sealedString") + it.validTypeNames shouldBe setOf("inlineString", "sealedInt", "sealedString") it.path shouldBe YamlPath.root } } @@ -1624,11 +1640,11 @@ class YamlReadingTest : FlatFunSpec({ val exception = shouldThrow { polymorphicYaml.decodeFromString(TestSealedStructure.serializer(), input) } exception.asClue { - it.message shouldBe "Unknown type 'someOtherType'. Known types are: sealedInt, sealedString" + it.message shouldBe "Unknown type 'someOtherType'. Known types are: inlineString, sealedInt, sealedString" it.line shouldBe 1 it.column shouldBe 1 it.typeName shouldBe "someOtherType" - it.validTypeNames shouldBe setOf("sealedInt", "sealedString") + it.validTypeNames shouldBe setOf("inlineString", "sealedInt", "sealedString") it.path shouldBe YamlPath.root } } @@ -1818,11 +1834,11 @@ class YamlReadingTest : FlatFunSpec({ val exception = shouldThrow { polymorphicYaml.decodeFromString(TestSealedStructure.serializer(), input) } exception.asClue { - it.message shouldBe "Unknown type 'someOtherType'. Known types are: sealedInt, sealedString" + it.message shouldBe "Unknown type 'someOtherType'. Known types are: inlineString, sealedInt, sealedString" it.line shouldBe 1 it.column shouldBe 7 it.typeName shouldBe "someOtherType" - it.validTypeNames shouldBe setOf("sealedInt", "sealedString") + it.validTypeNames shouldBe setOf("inlineString", "sealedInt", "sealedString") it.path shouldBe YamlPath.root.withMapElementKey("type", Location(1, 1)).withMapElementValue(Location(1, 7)) } } @@ -2028,11 +2044,11 @@ class YamlReadingTest : FlatFunSpec({ val exception = shouldThrow { polymorphicYaml.decodeFromString(TestSealedStructure.serializer(), input) } exception.asClue { - it.message shouldBe "Unknown type 'someOtherType'. Known types are: sealedInt, sealedString" + it.message shouldBe "Unknown type 'someOtherType'. Known types are: inlineString, sealedInt, sealedString" it.line shouldBe 1 it.column shouldBe 7 it.typeName shouldBe "someOtherType" - it.validTypeNames shouldBe setOf("sealedInt", "sealedString") + it.validTypeNames shouldBe setOf("inlineString", "sealedInt", "sealedString") it.path shouldBe YamlPath.root.withMapElementKey("kind", Location(1, 1)).withMapElementValue(Location(1, 7)) } } @@ -2724,10 +2740,7 @@ private object DecodingFromYamlNodeSerializer : KSerializer { override fun deserialize(decoder: Decoder): DatabaseListing { check(decoder is YamlInput) - val currentMap = decoder.node.yamlMap.get("databaseListing") - checkNotNull(currentMap) - - val list = currentMap.entries.map { (_, value) -> + val list = decoder.node.yamlMap.entries.map { (_, value) -> decoder.yaml.decodeFromYamlNode(Database.serializer(), value) } diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt index 22c8e5f9..2ed733d6 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/YamlWritingTest.kt @@ -845,6 +845,18 @@ class YamlWritingTest : FlatFunSpec({ } } + context("serializing a sealed type (inline)") { + val input = TestSealedStructure.InlineSealedString("abc") + val output = polymorphicYaml.encodeToString(TestSealedStructure.serializer(), input) + val expectedYaml = """ + ! "abc" + """.trimIndent() + + test("returns the value serialized in the expected YAML form") { + output shouldBe expectedYaml + } + } + context("serializing an unsealed type") { val input = UnsealedString("blah") val output = polymorphicYaml.encodeToString(PolymorphicSerializer(UnsealedClass::class), input) @@ -883,11 +895,24 @@ class YamlWritingTest : FlatFunSpec({ } } + context("serializing a polymorphic value (inline) as a property value") { + val input = SealedWrapper(TestSealedStructure.InlineSealedString("abc")) + val output = polymorphicYaml.encodeToString(SealedWrapper.serializer(), input) + val expectedYaml = """ + element: ! "abc" + """.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("more test"), TestSealedStructure.SimpleSealedString(null), null, ) @@ -901,6 +926,7 @@ class YamlWritingTest : FlatFunSpec({ value: "some test" - ! value: -20 + - ! "more test" - ! value: null - null diff --git a/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/PolymorphicTestObjects.kt b/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/PolymorphicTestObjects.kt index 72786a6a..b38af980 100644 --- a/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/PolymorphicTestObjects.kt +++ b/src/commonTest/kotlin/com/charleskorn/kaml/testobjects/PolymorphicTestObjects.kt @@ -28,16 +28,22 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder import kotlinx.serialization.modules.SerializersModule +import kotlin.jvm.JvmInline @Serializable -sealed class TestSealedStructure { +sealed interface TestSealedStructure { @Serializable @SerialName("sealedInt") - data class SimpleSealedInt(val value: Int) : TestSealedStructure() + data class SimpleSealedInt(val value: Int) : TestSealedStructure @Serializable @SerialName("sealedString") - data class SimpleSealedString(val value: String?) : TestSealedStructure() + data class SimpleSealedString(val value: String?) : TestSealedStructure + + @Serializable + @SerialName("inlineString") + @JvmInline + value class InlineSealedString(val value: String) : TestSealedStructure } @Serializable