hilla icon indicating copy to clipboard operation
hilla copied to clipboard

[24.8] Kotlin Nullability plugin fails when JsonProperty value is different from property name

Open taefi opened this issue 7 months ago • 1 comments

Describe the bug

Having an entity defined as the following:

class User(
    @field:JsonProperty("email_addresses") val emailAddresses: Set<String>
)

and use it in an endpoint, fails the application startup with the following exception:

Caused by: java.lang.ClassCastException: class com.vaadin.hilla.parser.plugins.backbone.nodes.CompositeTypeSignatureNode cannot be cast to class com.vaadin.hilla.parser.plugins.backbone.nodes.TypeSignatureNode (com.vaadin.hilla.parser.plugins.backbone.nodes.CompositeTypeSignatureNode and com.vaadin.hilla.parser.plugins.backbone.nodes.TypeSignatureNode are in unnamed module of loader 'app')
	at com.vaadin.hilla.parser.plugins.nonnull.kotlin.KotlinNullabilityPlugin.resolveTypedNode(KotlinNullabilityPlugin.kt:135) ~[hilla-parser-jvm-plugin-nonnull-kotlin-24.8.0.alpha5.jar:na]
	at com.vaadin.hilla.parser.plugins.nonnull.kotlin.KotlinNullabilityPlugin.resolve(KotlinNullabilityPlugin.kt:119) ~[hilla-parser-jvm-plugin-nonnull-kotlin-24.8.0.alpha5.jar:na]
	at com.vaadin.hilla.parser.core.AbstractCompositePlugin.resolve(AbstractCompositePlugin.java:38) ~[hilla-parser-jvm-core-24.8.0.alpha5.jar:na]
	at com.vaadin.hilla.parser.core.PluginExecutor$EnterTask.lambda$execute$0(PluginExecutor.java:86) ~[hilla-parser-jvm-core-24.8.0.alpha5.jar:na]

However, the same works if the value of the JsonProperty matches the field's name, so the following works without any problems:

class User(
    @field:JsonProperty("emailAddresses") val emailAddresses: Set<String>
)

The above may not be applicable as a workaround, in case the above is coming from an external service.

Expected-behavior

The application should start without any problems.

Reproduction

  1. Clone the https://github.com/taefi/hilla-kotlin-gradle-demo/tree/24.8
  2. Make the value of the JsonProperty annotation different from the field's name.
  3. Start the application

System Info

Vaadin: 24.8.0.alpha5

Special thanks to @mikhalchenko-alexander for reporting this.

taefi avatar Apr 28 '25 07:04 taefi

Let's make sure that all supported Jackson annotations work with Kotlin plugin.

platosha avatar May 06 '25 11:05 platosha

Debugged this a bit, and realized that with a class like this:

class User(
    @field:JsonProperty("email_addresses") val emailAddresses: Set<String>
)

in the resolve method of KotlinNullabilityPlugin, while traversing the nodes I have this state for the emailAddresses field:

parentPath: Root(ListN)/KEntity(User)/Property(email_addresses)/CompositeTypeSignature(ArrayList)
node:       CompositeTypeSignature(ArrayList)

I don't understand why there's a list representing the type arguments when @JsonProperty is present? Is it because it's considered as property defined by a pair of setter/getter? Or is it one defined by the property itself, and one define by JsonPropery?

Same happens for its parent path (see the attached screenshots): Image Image

If I define the class like this (Set<String> but no @JsonProperty):

class User(
    val emailAddresses: Set<String>
)

or like this:

class User(
    @field:JsonProperty("email_addresses") val emailAddresses: String
)

Then it's not a CompositeTypeSignature anymore. Not sure whether that's a bug or that's how it is supposed to work, but I don't see any use for that duplicity.

taefi avatar Jun 27 '25 11:06 taefi

Anyway, ignoring the underlying reason for getting a CompositeTypeSignatureNode for this sample property, the code can change so that no more ClassCastException happens at runtime.

But, regardless of the node type, looking at the parent path for this property:

parentPath: Root(ListN)/KEntity(User)/Property(email_addresses)/CompositeTypeSignature(ArrayList)

shows that the difference between the name specified by @JsonProperty("email_addresses") prevents it to be match the member property (defined as "emailAddresses"), and this makes it complicated, since the KType of the field and type argument remains unreachable.

We can try using some assumptions and heuristics to match them (e.g. in simple cases that they could be matched when '_' characters are removed and the rest transformed to be camelcase), but it would be fragile, and can introduce other bugs.

Maybe this could be closed as won't fix, and the solution could be the user code utilize a mapper to convert received object from 3rd party libraries to a standard type.

taefi avatar Jun 27 '25 12:06 taefi

We can try using some assumptions and heuristics to match them (e.g. in simple cases that they could be matched when '_' characters are removed and the rest transformed to be camelcase), but it would be fragile, and can introduce other bugs.

Can't it be matched by the same annotation via reflection? Like if we don't find a member property named email_addresses we look for a member property marked with @JsonProperty("email_addresses"). This way it will be reliable. It may hit performance because of the reflection though, but still it will be working.

mikhalchenko-alexander avatar Jun 27 '25 15:06 mikhalchenko-alexander

if we don't find a member property named email_addresses we look for a member property marked with @JsonProperty("email_addresses")

Not that I'm against this suggestion, but to me, this sounds like one of many strategies we can employ, and it's not eliminating the issue completely, for instance, what if the object defines that field as emails or email_address_list instead of email_addresses? The possibilities are endless, and looking for a member property marked with @JsonProperty("email_addresses") is only resolving one corner case.

taefi avatar Jul 02 '25 08:07 taefi

Not that I'm against this suggestion, but to me, this sounds like one of many strategies we can employ, and it's not eliminating the issue completely, for instance, what if the object defines that field as emails or email_address_list instead of email_addresses? The possibilities are endless, and looking for a member property marked with @JsonProperty("email_addresses") is only resolving one corner case.

I mean, if we know the name of the property from Jackson annotation like here

parentPath: Root(ListN)/KEntity(User)/Property(email_addresses)/CompositeTypeSignature(ArrayList)

is it possible to look for the @JsonProperty annotation and not the field itself? As I understand, currently it looks for the property named email_addresses which in fact is called emailAdresses. What I suggest, is after the property with name email_addresses is searched and not found, we could loop through all the properties and search for the @JsonProperty("email_addresses") annotation and then use the property annotated with it regardless of the property name itself. Not sure if it is possible, just an idea 🙂

mikhalchenko-alexander avatar Jul 03 '25 08:07 mikhalchenko-alexander