diff --git a/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/serialization/FieldFlatteningSerializer.kt b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/serialization/FieldFlatteningSerializer.kt new file mode 100644 index 0000000..6eee9fe --- /dev/null +++ b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/serialization/FieldFlatteningSerializer.kt @@ -0,0 +1,91 @@ +package cn.rdlevel.rdkt.core.serialization + +import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialInfo +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonTransformingSerializer +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject + +/** + * Annotation to indicate that a field should be flattened when serialized. + */ +@OptIn(ExperimentalSerializationApi::class) +@SerialInfo +@RDKTInternalAPI +@Target(AnnotationTarget.PROPERTY) +@Retention(AnnotationRetention.RUNTIME) +@MustBeDocumented +public annotation class Flatten + +/** + * A serializer that can handle flattening of fields annotated with [Flatten]. + * + * When serializing, the fields annotated with [Flatten] will be flattened into the parent JSON object. + * When deserializing, the fields annotated with [Flatten] will be deserialized from the parent JSON object, + * with elements that do not belong to any non-flattened fields. + * + * It's recommended to annotate classes that need to be flattened with [JsonIgnoreUnknownKeys][kotlinx.serialization.json.JsonIgnoreUnknownKeys] + * when there are multiple flattened fields, so that fields that only belong to some classes can be ignored when deserializing other classes. + * + * This serializer only supports JSON encoding and decoding. + * + * @param tSerializer The serializer, usually the [generated one][kotlinx.serialization.KeepGeneratedSerializer], for the type T that this serializer will delegate to. + */ +@RDKTInternalAPI +public class FieldFlatteningSerializer( + tSerializer: KSerializer +) : JsonTransformingSerializer(tSerializer) { + private val flattenFieldNames: MutableList = mutableListOf() + private val notFlattenFieldNames: MutableList = mutableListOf() + + init { + (0..().isEmpty()) { + notFlattenFieldNames += name + } else { + flattenFieldNames += name + } + } + } + + override fun transformSerialize(element: JsonElement): JsonElement { + return buildJsonObject { + element.jsonObject.forEach { (k, v) -> + if (k !in flattenFieldNames) { + put(k, v) + } else { + v.jsonObject.forEach { (k2, v2) -> + put(k2, v2) + } + } + } + } + } + + override fun transformDeserialize(element: JsonElement): JsonElement { + return buildJsonObject base@{ + val flattened = buildJsonObject flattened@{ + element.jsonObject.forEach { (k, v) -> + if (k in notFlattenFieldNames) { + this@base.put(k, v) + } else { + put(k, v) + } + } + } + flattenFieldNames.forEach { + put(it, flattened) + } + } + } +} + +/** + * Converts a [KSerializer] into a [FieldFlatteningSerializer] that can handle flattening of fields annotated with [Flatten]. + */ +@RDKTInternalAPI +public fun KSerializer.flatten(): FieldFlatteningSerializer = FieldFlatteningSerializer(this) \ No newline at end of file diff --git a/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/serialization/LegacyFieldFlatteningSerializer.kt b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/serialization/LegacyFieldFlatteningSerializer.kt new file mode 100644 index 0000000..248481f --- /dev/null +++ b/core/src/commonMain/kotlin/cn/rdlevel/rdkt/core/serialization/LegacyFieldFlatteningSerializer.kt @@ -0,0 +1,101 @@ +package cn.rdlevel.rdkt.core.serialization + +import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.* +import kotlinx.serialization.serializer +import kotlin.reflect.KMutableProperty1 + +/** + * A [KSerializer] that flattens the fields of the serialized type into the same level as the base type. + * + * Fields to be flattened should be annotated with [kotlinx.serialization.Transient], + * and types to be flattened or serialized should be annotated with [JsonIgnoreUnknownKeys]. + * + * This serializer only supports JSON encoding and decoding. + * + * This serializer is legacy, but I want to keep it because I think it's cool. + * + * @property tSerializer The original serializer for the base type. + */ +@RDKTInternalAPI +public class LegacyFieldFlatteningSerializer( + private val tSerializer: KSerializer, +) : KSerializer { + public class Entry( + private val serializer: KSerializer, + private val supplier: (T) -> V, + private val consumer: (T, V) -> Unit, + ) { + public fun encodeEntry(builder: JsonObjectBuilder, json: Json, baseValue: T) { + json.encodeToJsonElement(serializer, supplier(baseValue)).jsonObject.forEach { (k, v) -> + builder.put(k, v) + } + } + + public fun decodeEntry(json: Json, baseValue: T, element: JsonElement) { + consumer(baseValue, json.decodeFromJsonElement(serializer, element)) + } + } + + private val flatteningEntries: MutableList> = mutableListOf() + + public fun flatten(entry: Entry) { + flatteningEntries.add(entry) + } + + override val descriptor: SerialDescriptor get() = tSerializer.descriptor + + override fun serialize(encoder: Encoder, value: Type) { + val jsonEncoder = encoder as? JsonEncoder + ?: error("${LegacyFieldFlatteningSerializer::class.simpleName} only supports JSON encoding.") + + val element = buildJsonObject { + val json = jsonEncoder.json + json.encodeToJsonElement(tSerializer, value).jsonObject.forEach { (k, v) -> + put(k, v) + } + flatteningEntries.forEach { it.encodeEntry(this, json, value) } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Type { + val jsonDecoder = decoder as? JsonDecoder + ?: error("${LegacyFieldFlatteningSerializer::class.simpleName} only supports JSON decoding.") + val element = jsonDecoder.decodeJsonElement() + val json = jsonDecoder.json + val value = json.decodeFromJsonElement(tSerializer, element) + flatteningEntries.forEach { it.decodeEntry(json, value, element) } + return value + } +} + +/** + * Adds a flattened field to the serializer. + */ +@RDKTInternalAPI +public inline fun , T, reified F> Ser.flatten( + property: KMutableProperty1, + fieldSerializer: KSerializer? = null, +): Ser = apply { + flatten( + LegacyFieldFlatteningSerializer.Entry( + fieldSerializer ?: serializer(), + property::get, + property::set, + ) + ) +} + +/** + * Converts a [KSerializer] to a [LegacyFieldFlatteningSerializer] and adds a flattened field to it. + */ +@RDKTInternalAPI +public inline fun KSerializer.flatten( + property: KMutableProperty1, + fieldSerializer: KSerializer? = null, +): LegacyFieldFlatteningSerializer = LegacyFieldFlatteningSerializer(this).flatten(property, fieldSerializer) \ No newline at end of file diff --git a/core/src/commonTest/kotlin/cn/rdlevel/rdkt/core/test/FieldFlatteningSerializerTest.kt b/core/src/commonTest/kotlin/cn/rdlevel/rdkt/core/test/FieldFlatteningSerializerTest.kt new file mode 100644 index 0000000..d4cdaf7 --- /dev/null +++ b/core/src/commonTest/kotlin/cn/rdlevel/rdkt/core/test/FieldFlatteningSerializerTest.kt @@ -0,0 +1,42 @@ +@file:OptIn(RDKTInternalAPI::class, ExperimentalSerializationApi::class) + +package cn.rdlevel.rdkt.core.test + +import cn.rdlevel.rdkt.core.annotations.RDKTInternalAPI +import cn.rdlevel.rdkt.core.serialization.Flatten +import cn.rdlevel.rdkt.core.serialization.flatten +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.KSerializer +import kotlinx.serialization.KeepGeneratedSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals + +class FieldFlatteningSerializerTest { + @Serializable + data class Child( + val a: Int, + val b: String, + ) + + @Serializable(Parent.Serializer::class) + @KeepGeneratedSerializer + data class Parent( + val c: Double, + val d: Boolean, + @Flatten + val child: Child, + ) { + object Serializer : KSerializer by generatedSerializer().flatten() + } + + @Test + fun test() { + val data = Parent(1.23, true, Child(42, "hello")) + val json = Json.encodeToString(Parent.Serializer, data) + assertEquals("""{"c":1.23,"d":true,"a":42,"b":"hello"}""", json) + val data2: Parent = Json.decodeFromString(json) + assertEquals(data2, data2) + } +} \ No newline at end of file