kotlinx.serialization icon indicating copy to clipboard operation
kotlinx.serialization copied to clipboard

`serialName` doesn't actually return `@SerialName` as per docs

Open chrisjenx opened this issue 9 months ago • 17 comments

Describe the bug

descriptor.serialName as per the docs should return the overridden @SerialName for that field/type, but doesn't seem to respect that.

For generated and default serializers, the serial name is equal to the corresponding class's fully qualified name or, if overridden, SerialName

To Reproduce

@Serializable
data class TestClass {
 @SerialName("notField")
 val field: String,
}


assertTrue(TestClass::serializer().getElementDescriptor(0).serialName == "notField") // fails

Unless I'm missing something, it seems impossible to get the annotated @SerialName from the descriptor at runtime.

(Semi related, it's also impossible to find the correct element descriptor at runtime as you can't reverse lookup by elementName)

Expected behavior

Not sure if serialName is returning the wrong thing? But ideally I should return notField as per the docs for val field. If that is expected behavior, then the docs need updating. And we need a method to actually get that @SerialName.

Environment

  • Kotlin version: [e.g. 1.3.30] 2.1.10
  • Library version: [e.g. 0.11.0] 1.8.0
  • Kotlin platforms: [e.g. JVM, JS, Native or their combinations] All
  • Gradle version: [e.g. 4.10] 8.13 (but any version)

I have more examples I can share, Sqkon uses this heavily

chrisjenx avatar Mar 17 '25 16:03 chrisjenx

SerialDescriptor.serialName returns a name of serializable class/serializer. If you want to get the name of property/field, use getElementName — in your case, TestClass.serializer().descriptor.getElementName(0).

sandwwraith avatar Mar 17 '25 16:03 sandwwraith

That doesn't work either, see test:

enum class TestEnum {
    FIRST,
    SECOND,

    @SerialName("unknown")
    LAST;
}


    @OptIn(InternalSerializationApi::class)
    @Test
    fun enumMissingSerialName() {

        assertEquals(
            TestEnum::class.serializer().descriptor.getElementName(TestEnum.LAST.ordinal),
            "unknown"
        )

    }
Expected :unknown
Actual   :LAST

chrisjenx avatar Mar 17 '25 17:03 chrisjenx

Lets say you want to use getElementName() which should work on most fields. But you only have the propertyName, for example.

@Serializable
data class TestClass(
 val firstString: String = "",
 @SerialName("diffName")
 val propName: String = ""
)

at runtime i only have access to the KProperty so I can derive the Reciever type and propertyName. KClass<TestClass> and propName respectively.

You would do something like:

val index = receiverDescriptor.getElementIndex(it.propertyName)
val name = receiverDescriptor.getElementName(index)

Which is impossible as the parent descriptor only holds the @SerialName, so you get invalid index (out of bounds) so you can't reverse lookup.

You could also try storing the value descriptor:

val index = it.receiverDescriptor.elementDescriptors.indexOf(it.valueDescriptor)
val name = it.receiverDescriptor.getElementName(index)

That will also fail because child descriptors are NOT unique by equality, two string descriptors are the same (understandable)

Which comes back to, we either need to be able to pull SerialName from the ValueDescriptor or store the propertyName on the receiverDescriotor so can cross index descriptors.

With reflection we could find the index of the property in it's parent, but needs to work on multi platform code etc...

chrisjenx avatar Mar 17 '25 17:03 chrisjenx

If you're interested how we use this, check https://github.com/MercuryTechnologies/sqkon/blob/main/library/src/commonMain/kotlin/com/mercury/sqkon/db/JsonPath.kt#L120-L133

chrisjenx avatar Mar 17 '25 17:03 chrisjenx

That doesn't work either, see test:

enum class TestEnum {
    FIRST,
    SECOND,

    @SerialName("unknown")
    LAST;
}


    @OptIn(InternalSerializationApi::class)
    @Test
    fun enumMissingSerialName() {

        assertEquals(
            TestEnum::class.serializer().descriptor.getElementName(TestEnum.LAST.ordinal),
            "unknown"
        )

    }
Expected :unknown
Actual   :LAST

If you want to make @SerialName to work on an enum you will have to annotate the enum as @Serializable

pdvrieze avatar Mar 18 '25 09:03 pdvrieze

As a separate point, there is no reliable way to map properties (or property names) to serial elements/the serial descriptor. You could investigate and mirror the structure of the generated serializers (and library implemented serializers). However, with custom serializers there is no need for there to even be a property/element match at all.

pdvrieze avatar Mar 18 '25 09:03 pdvrieze

If you want to make @SerialName to work on an enum you will have to annotate the enum as @Serializable

Thats inconsistant behaviour, if you serialize the object to json output will use the @SerialName regardless of having @Serializable on it. But using the descriptor directly won't.

Part of the issue is that the JSON path doesn't match because the serialized enum uses the SerialName, but the descriptor doesn't give it to us... why the discrepancy ?

chrisjenx avatar Mar 18 '25 15:03 chrisjenx

As a separate point, there is no reliable way to map properties (or property names) to serial elements/the serial descriptor. You could investigate and mirror the structure of the generated serializers (and library implemented serializers). However, with custom serializers there is no need for there to even be a property/element match at all.

Not sure what you mean here? We use descriptors at runtime to work out json paths based on KProperties and KClass's. The only info I have at runtime is the KProperty.name and the descriptor for the parent class and the field.

If you are iterating across fields it's fine you don't need that, but as the SerialName gets erased and the propertyName is not kept it's really hard to find the serialName for a property name.

I did look at internal implementation, unless I missed something, not sure what would help me here?

chrisjenx avatar Mar 18 '25 15:03 chrisjenx

Not sure what you mean here? We use descriptors at runtime to work out json paths based on KProperties and KClass's. The only info I have at runtime is the KProperty.name and the descriptor for the parent class and the field.

If you are iterating across fields it's fine you don't need that, but as the SerialName gets erased and the propertyName is not kept it's really hard to find the serialName for a property name.

As you say in the second paragraph this information is not available. The other point I was making is that even this poor availability is not actually applicable to custom serializers that could make up elements (from a descriptor perspective) from whole cloth or perform complex transformations. (this means that the mapping is not possible at all)

I did look at internal implementation, unless I missed something, not sure what would help me here?

On some platforms it may be possible to use annotations with runtime retention that also have a @SerialInfo annotation, and can thus be mapped between both contexts: reflection/properties and serialization/elementName.

My understanding is that you want to be able to use typesafe accessors to query into an object database. I would say that the best way to do this is a separate (or extended) compiler plugin (maybe using ksp) that generates such accessors. These accessors might even be derived from the serial descriptor (even custom ones), but it would certainly complicate matters.

pdvrieze avatar Mar 18 '25 19:03 pdvrieze

Yeah, I think might have to go down the route of kotlin compiler (don't want to rely on KSP as it would be super small compiler plugin)

If you could loop back on why json.serializeToString() will handle enum SerialName correctly but the descriptor doesn't would be interested to know. That does feel like a discrepancy to me?

chrisjenx avatar Mar 18 '25 19:03 chrisjenx

Any update on why kotlinx will serialize the enum name without the @Serializaton annotation, but the descriptor doesn't?

chrisjenx avatar Mar 27 '25 16:03 chrisjenx

@chrisjenx The enum thing (assuming you made the enum @Serializable - note that enum serialization works without the annotation, but does not process annotations) would be due to serial names distinguishing between element names (what is the attribute name where used - even if there are no attributes / normally what is annotated on attributes) and type names (the name of the type / annotated on the type). Enum names/annotations are based on their use/attribute/element side, not on the type side.

pdvrieze avatar Mar 28 '25 10:03 pdvrieze

It seems that there are multiple questions at once. I'll try to answer all of them:

  1. Problems with enum class and @SerialName. Yes, sometimes serial names can be unavailable for enum classes specifically. It is because of the implementation detail we cannot change: for non-@Serializable enums serializer is generated by the plugin at the place of usage (e.g. other serializable class). If you're using reflection, then you're not using a serializer generated by the plugin, but its runtime substitution that has limited information

  2. KClass::serializer However, because of serializer() intensification, you will get serial names if you invoke serializer<TestEnum>() function which gets replaced by a proper plugin-generated serializer call. SomeClass::class.serializer() is an internal API with a lot of limitations and is not recommended for general use.

  3. KProperty <> SerialName.

Since KProperty is a part of the reflection package, and kotlinx.serialization aims to provide all its capabilities without reflection, we do not intend to have any kind of interop with KProperty or KClass besides what is strictly necessary. As you've noticed yourself, reflection capabilities on multiplatform are really limited, and you cannot even get KProperty on Native without referencing it directly (TestClass::field).

If you need KProperty and others to build a schema for your classes, I suggest using SerialDescriptor directly without involving reflection.

sandwwraith avatar Mar 31 '25 13:03 sandwwraith

Sorry I read that a couple of times.

I still don't get why:

enum class TestEnum {
    FIRST,
    SECOND,

    @SerialName("unknown")
    LAST;
}


    @OptIn(InternalSerializationApi::class)
    @Test
    fun enumMissingSerialName() {

        assertEquals(
            TestEnum::class.serializer().descriptor.getElementName(TestEnum.LAST.ordinal),
            "unknown"
        )

    }

Will fail.

But:

@Serializable
data class Test {
  val enum: TestEnum,
}
Json.encodeToString(Test(TestEnum.LAST)) == "{ 'enum': 'unknown' }"

Despite TestEnum missing @Serializable

chrisjenx avatar Apr 01 '25 17:04 chrisjenx

Because in cases when enum is missing @Serializable, the serializer with proper names is 'baked' by the plugin in the code of other serializers — such as Test. This is the trade-off we chose instead of requiring every enum to be annotated with @Serializable.

sandwwraith avatar Apr 07 '25 13:04 sandwwraith

So enums without @Serializable will ignore SerialName on non jvm platforms then?

chrisjenx avatar Apr 07 '25 14:04 chrisjenx

No, non-JVM platforms also have plugin applied, and classes such as Test are processed on all platforms

sandwwraith avatar Apr 07 '25 14:04 sandwwraith