Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Migrating from 3.2 to 3.8 fails if using Jackson ObjectMapper customization for Map serialization #42596

Open
andrewazores opened this issue Aug 16, 2024 · 23 comments
Labels
area/hibernate-orm Hibernate ORM area/jackson Issues related to Jackson (JSON library) kind/bug Something isn't working

Comments

@andrewazores
Copy link

andrewazores commented Aug 16, 2024

Describe the bug

See reproducer here: https://github.com/andrewazores/quarkus-jackson-map-format

Using Jackson customization as per https://quarkus.io/guides/rest-json#json.

When using Quarkus 3.2, this simple reproducer works as expected:

$ http -f :8080/models name=foo
HTTP/1.1 200 OK
Content-Type: application/json;charset=UTF-8
content-length: 64

{
    "id": 1,
    "labels": [
        {
            "key": "hello",
            "value": "world"
        }
    ],
    "name": "foo"
}

That is, the Map<String, String> labels field of the Model gets serialized into a list of key-value objects.

When upgrading to the next LTS (3.8), I would hope that this still works.

Expected behavior

Doing quarkus update -S 3.8 and repeating the reproducer test should yield the same result.

Actual behavior

After doing quarkus update -S 3.8 and repeating the test, there are exceptions:

$ http -f :8080/models name=foo
HTTP/1.1 500 Internal Server Error
content-length: 6786
content-type: application/json; charset=utf-8

{
    "details": "Error id 5b1465c9-7270-4a8c-a7c5-6cf4e392a955-1, java.lang.IllegalArgumentException: Could not deserialize string to java type: JsonJavaType(java.util.Map<java.lang.String, java.lang.String>)",
    "stack": "java.lang.IllegalArgumentException: Could not deserialize string to java type: JsonJavaType(java.util.Map<java.lang.String, java.lang.String>)\n\tat org.hibernate.type.format.jackson.JacksonJsonFormatMapper.fromString(JacksonJsonFormatMapper.java:42)\n\tat org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType.fromString(FormatMapperBasedJavaType.java:61)\n\tat org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType.deepCopy(FormatMapperBasedJavaType.java:110)\n\tat org.hibernate.type.AbstractStandardBasicType.deepCopy(AbstractStandardBasicType.java:295)\n\tat org.hibernate.type.AbstractStandardBasicType.deepCopy(AbstractStandardBasicType.java:291)\n\tat org.hibernate.type.TypeHelper.deepCopy(TypeHelper.java:52)\n\tat org.hibernate.event.internal.AbstractSaveEventListener.cloneAndSubstituteValues(AbstractSaveEventListener.java:359)\n\tat org.hibernate.event.internal.AbstractSaveEventListener.performSaveOrReplicate(AbstractSaveEventListener.java:301)\n\tat org.hibernate.event.internal.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:219)\n\tat org.hibernate.event.internal.AbstractSaveEventListener.saveWithGeneratedId(AbstractSaveEventListener.java:134)\n\tat org.hibernate.event.internal.DefaultPersistEventListener.entityIsTransient(DefaultPersistEventListener.java:175)\n\tat org.hibernate.event.internal.DefaultPersistEventListener.persist(DefaultPersistEventListener.java:93)\n\tat org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:77)\n\tat org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:54)\n\tat org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127)\n\tat org.hibernate.internal.SessionImpl.firePersist(SessionImpl.java:758)\n\tat org.hibernate.internal.SessionImpl.persist(SessionImpl.java:742)\n\tat io.quarkus.hibernate.orm.runtime.session.TransactionScopedSession.persist(TransactionScopedSession.java:145)\n\tat org.hibernate.engine.spi.SessionLazyDelegator.persist(SessionLazyDelegator.java:281)\n\tat org.hibernate.Session_OpdLahisOZ9nWRPXMsEFQmQU03A_Synthetic_ClientProxy.persist(Unknown Source)\n\tat io.quarkus.hibernate.orm.panache.common.runtime.AbstractJpaOperations.persist(AbstractJpaOperations.java:101)\n\tat io.quarkus.hibernate.orm.panache.common.runtime.AbstractJpaOperations.persist(AbstractJpaOperations.java:96)\n\tat io.quarkus.hibernate.orm.panache.PanacheEntityBase.persist(PanacheEntityBase.java:53)\n\tat org.acme.ModelsResource.create(ModelsResource.java:33)\n\tat org.acme.ModelsResource_Subclass.create$$superforward(Unknown Source)\n\tat org.acme.ModelsResource_Subclass$$function$$2.apply(Unknown Source)\n\tat io.quarkus.arc.impl.AroundInvokeInvocationContext.proceed(AroundInvokeInvocationContext.java:73)\n\tat io.quarkus.arc.impl.AroundInvokeInvocationContext.proceed(AroundInvokeInvocationContext.java:62)\n\tat io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorBase.invokeInOurTx(TransactionalInterceptorBase.java:136)\n\tat io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorBase.invokeInOurTx(TransactionalInterceptorBase.java:107)\n\tat io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorRequired.doIntercept(TransactionalInterceptorRequired.java:38)\n\tat io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorBase.intercept(TransactionalInterceptorBase.java:61)\n\tat io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorRequired.intercept(TransactionalInterceptorRequired.java:32)\n\tat io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorRequired_Bean.intercept(Unknown Source)\n\tat io.quarkus.arc.impl.InterceptorInvocation.invoke(InterceptorInvocation.java:42)\n\tat io.quarkus.arc.impl.AroundInvokeInvocationContext.perform(AroundInvokeInvocationContext.java:30)\n\tat io.quarkus.arc.impl.InvocationContexts.performAroundInvoke(InvocationContexts.java:27)\n\tat org.acme.ModelsResource_Subclass.create(Unknown Source)\n\tat org.acme.ModelsResource$quarkusrestinvoker$create_79c9f3126233a8a5542a248da47e1408f1af01a5.invoke(Unknown Source)\n\tat org.jboss.resteasy.reactive.server.handlers.InvocationHandler.handle(InvocationHandler.java:29)\n\tat io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext.invokeHandler(QuarkusResteasyReactiveRequestContext.java:141)\n\tat org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:147)\n\tat io.quarkus.vertx.core.runtime.VertxCoreRecorder$14.runWith(VertxCoreRecorder.java:582)\n\tat org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)\n\tat org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1538)\n\tat org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:29)\n\tat org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:29)\n\tat io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)\n\tat java.base/java.lang.Thread.run(Thread.java:840)\nCaused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.util.LinkedHashMap<java.lang.String,java.lang.String>` from Array value (token `JsonToken.START_ARRAY`)\n at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 1]\n\tat com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)\n\tat com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1767)\n\tat com.fasterxml.jackson.databind.DeserializationContext.handleUnexpectedToken(DeserializationContext.java:1541)\n\tat com.fasterxml.jackson.databind.deser.std.StdDeserializer._deserializeFromArray(StdDeserializer.java:222)\n\tat com.fasterxml.jackson.databind.deser.std.MapDeserializer.deserialize(MapDeserializer.java:457)\n\tat com.fasterxml.jackson.databind.deser.std.MapDeserializer.deserialize(MapDeserializer.java:32)\n\tat com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)\n\tat com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4905)\n\tat com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3848)\n\tat org.hibernate.type.format.jackson.JacksonJsonFormatMapper.fromString(JacksonJsonFormatMapper.java:39)\n\t... 48 more"
}

The devserver stack trace is more readable of course:

2024-08-16 11:44:02,040 ERROR [io.qua.ver.htt.run.QuarkusErrorHandler] (executor-thread-1) HTTP Request to /models failed, error id: 5b1465c9-7270-4a8c-a7c5-6cf4e392a955-1: java.lang.IllegalArgumentException: Could not deserialize string to java type: JsonJavaType(java.util.Map<java.lang.String, java.lang.String>)
	at org.hibernate.type.format.jackson.JacksonJsonFormatMapper.fromString(JacksonJsonFormatMapper.java:42)
	at org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType.fromString(FormatMapperBasedJavaType.java:61)
	at org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType.deepCopy(FormatMapperBasedJavaType.java:110)
	at org.hibernate.type.AbstractStandardBasicType.deepCopy(AbstractStandardBasicType.java:295)
	at org.hibernate.type.AbstractStandardBasicType.deepCopy(AbstractStandardBasicType.java:291)
	at org.hibernate.type.TypeHelper.deepCopy(TypeHelper.java:52)
	at org.hibernate.event.internal.AbstractSaveEventListener.cloneAndSubstituteValues(AbstractSaveEventListener.java:359)
	at org.hibernate.event.internal.AbstractSaveEventListener.performSaveOrReplicate(AbstractSaveEventListener.java:301)
	at org.hibernate.event.internal.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:219)
	at org.hibernate.event.internal.AbstractSaveEventListener.saveWithGeneratedId(AbstractSaveEventListener.java:134)
	at org.hibernate.event.internal.DefaultPersistEventListener.entityIsTransient(DefaultPersistEventListener.java:175)
	at org.hibernate.event.internal.DefaultPersistEventListener.persist(DefaultPersistEventListener.java:93)
	at org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:77)
	at org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:54)
	at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127)
	at org.hibernate.internal.SessionImpl.firePersist(SessionImpl.java:758)
	at org.hibernate.internal.SessionImpl.persist(SessionImpl.java:742)
	at io.quarkus.hibernate.orm.runtime.session.TransactionScopedSession.persist(TransactionScopedSession.java:145)
	at org.hibernate.engine.spi.SessionLazyDelegator.persist(SessionLazyDelegator.java:281)
	at org.hibernate.Session_OpdLahisOZ9nWRPXMsEFQmQU03A_Synthetic_ClientProxy.persist(Unknown Source)
	at io.quarkus.hibernate.orm.panache.common.runtime.AbstractJpaOperations.persist(AbstractJpaOperations.java:101)
	at io.quarkus.hibernate.orm.panache.common.runtime.AbstractJpaOperations.persist(AbstractJpaOperations.java:96)
	at io.quarkus.hibernate.orm.panache.PanacheEntityBase.persist(PanacheEntityBase.java:53)
	at org.acme.ModelsResource.create(ModelsResource.java:33)
	at org.acme.ModelsResource_Subclass.create$$superforward(Unknown Source)
	at org.acme.ModelsResource_Subclass$$function$$2.apply(Unknown Source)
	at io.quarkus.arc.impl.AroundInvokeInvocationContext.proceed(AroundInvokeInvocationContext.java:73)
	at io.quarkus.arc.impl.AroundInvokeInvocationContext.proceed(AroundInvokeInvocationContext.java:62)
	at io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorBase.invokeInOurTx(TransactionalInterceptorBase.java:136)
	at io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorBase.invokeInOurTx(TransactionalInterceptorBase.java:107)
	at io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorRequired.doIntercept(TransactionalInterceptorRequired.java:38)
	at io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorBase.intercept(TransactionalInterceptorBase.java:61)
	at io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorRequired.intercept(TransactionalInterceptorRequired.java:32)
	at io.quarkus.narayana.jta.runtime.interceptor.TransactionalInterceptorRequired_Bean.intercept(Unknown Source)
	at io.quarkus.arc.impl.InterceptorInvocation.invoke(InterceptorInvocation.java:42)
	at io.quarkus.arc.impl.AroundInvokeInvocationContext.perform(AroundInvokeInvocationContext.java:30)
	at io.quarkus.arc.impl.InvocationContexts.performAroundInvoke(InvocationContexts.java:27)
	at org.acme.ModelsResource_Subclass.create(Unknown Source)
	at org.acme.ModelsResource$quarkusrestinvoker$create_79c9f3126233a8a5542a248da47e1408f1af01a5.invoke(Unknown Source)
	at org.jboss.resteasy.reactive.server.handlers.InvocationHandler.handle(InvocationHandler.java:29)
	at io.quarkus.resteasy.reactive.server.runtime.QuarkusResteasyReactiveRequestContext.invokeHandler(QuarkusResteasyReactiveRequestContext.java:141)
	at org.jboss.resteasy.reactive.common.core.AbstractResteasyReactiveContext.run(AbstractResteasyReactiveContext.java:147)
	at io.quarkus.vertx.core.runtime.VertxCoreRecorder$14.runWith(VertxCoreRecorder.java:582)
	at org.jboss.threads.EnhancedQueueExecutor$Task.run(EnhancedQueueExecutor.java:2513)
	at org.jboss.threads.EnhancedQueueExecutor$ThreadBody.run(EnhancedQueueExecutor.java:1538)
	at org.jboss.threads.DelegatingRunnable.run(DelegatingRunnable.java:29)
	at org.jboss.threads.ThreadLocalResettingRunnable.run(ThreadLocalResettingRunnable.java:29)
	at io.netty.util.concurrent.FastThreadLocalRunnable.run(FastThreadLocalRunnable.java:30)
	at java.base/java.lang.Thread.run(Thread.java:840)
Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot deserialize value of type `java.util.LinkedHashMap<java.lang.String,java.lang.String>` from Array value (token `JsonToken.START_ARRAY`)
 at [Source: REDACTED (`StreamReadFeature.INCLUDE_SOURCE_IN_LOCATION` disabled); line: 1, column: 1]
	at com.fasterxml.jackson.databind.exc.MismatchedInputException.from(MismatchedInputException.java:59)
	at com.fasterxml.jackson.databind.DeserializationContext.reportInputMismatch(DeserializationContext.java:1767)
	at com.fasterxml.jackson.databind.DeserializationContext.handleUnexpectedToken(DeserializationContext.java:1541)
	at com.fasterxml.jackson.databind.deser.std.StdDeserializer._deserializeFromArray(StdDeserializer.java:222)
	at com.fasterxml.jackson.databind.deser.std.MapDeserializer.deserialize(MapDeserializer.java:457)
	at com.fasterxml.jackson.databind.deser.std.MapDeserializer.deserialize(MapDeserializer.java:32)
	at com.fasterxml.jackson.databind.deser.DefaultDeserializationContext.readRootValue(DefaultDeserializationContext.java:342)
	at com.fasterxml.jackson.databind.ObjectMapper._readMapAndClose(ObjectMapper.java:4905)
	at com.fasterxml.jackson.databind.ObjectMapper.readValue(ObjectMapper.java:3848)
	at org.hibernate.type.format.jackson.JacksonJsonFormatMapper.fromString(JacksonJsonFormatMapper.java:39)
	... 48 more

How to Reproduce?

See reproducer here: https://github.com/andrewazores/quarkus-jackson-map-format

  1. Clone reproducer
  2. quarkus dev
  3. http -f :8080/models name=foo
  4. Stop devserver
  5. quarkus update -S 3.8
  6. quarkus dev
  7. http -f :8080/models name=foo

Output of uname -a or ver

No response

Output of java -version

openjdk version "17.0.12" 2024-07-16 OpenJDK Runtime Environment (Red_Hat-17.0.12.0.7-2) (build 17.0.12+7) OpenJDK 64-Bit Server VM (Red_Hat-17.0.12.0.7-2) (build 17.0.12+7, mixed mode, sharing)

Quarkus version or git rev

3.2.12 , 3.8.5

Build tool (ie. output of mvnw --version or gradlew --version)

3.9.1

Additional information

It seems that in 3.2, the ObjectMapper instance that I am customizing is either a different instance, or is not used by Panache/Hibernate. Whereas in 3.8, the customization I apply (which I want for the purposes of my REST API and the format desired by the API client) becomes incorrectly applied to some other internals.

@andrewazores andrewazores added the kind/bug Something isn't working label Aug 16, 2024
@gsmet gsmet added area/hibernate-orm Hibernate ORM area/jackson Issues related to Jackson (JSON library) labels Aug 16, 2024
@gsmet
Copy link
Member

gsmet commented Aug 16, 2024

@marko-bekhta maybe something for you given you created

?

@marko-bekhta
Copy link
Contributor

hey, yeah ... I'll take a look at this one 🙈

@marko-bekhta
Copy link
Contributor

I looked at the attached example, and I'd say that a failing result is expected in this case ...
There is a custom map serializer that serializes the map as an array of objects containing key and value properties

[
    {
        "key": "hello",
        "value": "world"
    }
]

but the app does not set up a corresponding deserializer. This means that once ORM stores the map as an array of objects it cannot deserialize this array back to the map...
The solution would be to implement the corresponding map deserializer that matches the serialization and add this new deserializer to the ObjectMapperCustomizer. Or alternatively, if the JSON stored in ORM entities has to be serialized/deserialized differently, it can be customized with a FormatMapper and @JsonFormat, see https://quarkus.io/guides/hibernate-orm#json_xml_serialization_deserialization

@andrewazores
Copy link
Author

I'll give that a try, thanks.

Though I'm not sure I understand why there is a deserialization happening in this test case - why does the JSON need to be deserialized somewhere deeper in the call stack after entity.persist()? Does this mean the entity fields are being serialized and deserialized in the ORM layer?

@andrewazores
Copy link
Author

andrewazores commented Aug 16, 2024

Also, sorry, but with a FormatMapper and @JsonFormat, this means I'm registering something like a singleton global handler that will intercept all JSON (de)serialization - is that really the new recommended way to go about configuring serializations like this?

Having to implement a deserializer on the other side - when the application isn't even actually trying to deserialize these entities directly - is also a bit painful because we're running into Java generics and type erasure. mapModule.addDeserializer(List.class, new KeyValueDeserializer()) seems like it will be an ugly implementation because it will need to determine whether this is a list of key-value pair objects, then handle if it is or somehow delegate to the default implementation if it isn't?

I guess what I am surprised about here is that the Jackson customization is actually being applied at the ORM layer. It doesn't matter to me if the JSON column in this Postgres db table is serialized with this customization or not, all that matters to me is the REST API response format. Is it no longer possible to customize only that serialization format?

@gsmet
Copy link
Member

gsmet commented Aug 17, 2024

I will have to refresh my memory but at some point we discussed the ability to customize the ObjectMapper for a specific purpose.

I think having a global one that is used everywhere might become problematic, especially for ORM.

Let’s say you change the way something is serialized/deserialized for your REST services and all of a sudden you are unable to deserialize what you pushed to your database.

I think we need to discuss this.

@gsmet
Copy link
Member

gsmet commented Aug 17, 2024

@gsmet
Copy link
Member

gsmet commented Aug 17, 2024

And in the case of ORM, I think we shouldn’t copy the default one: we should have a specific one that you can customize.

Now the big problem is that it’s going to be a massive breaking change…

@marko-bekhta
Copy link
Contributor

hmmm... we had this one #32029 and I think the idea there was to use the default object mapper, but have those @JsonFormat / @XmlFormat as a way to override how the ORM behaves... i.e. create a custom mapper for ORM specific needs, and it can even be done per persistence unit...
I guess I can see how using the globally configured object mapper has a benefit, since an object mapper can be configured once for the entire application...

Let’s say you change the way something is serialized/deserialized for your REST services and all of a sudden you are unable to deserialize what you pushed to your database.

But then ... yeah if something changes in serialization, then retrieving existing entities may result in errors ... I guess then the @JsonFormat / @XmlFormat can be used, or the data in the database can be migrated to the new format ... which also makes sense, the format changed -- that should be reflected in the stored data too ...

@gsmet
Copy link
Member

gsmet commented Aug 17, 2024

Yeah problem is the global object mapper is used as is to customize the REST services output.

And then it cascades all over the place, which is probably not what you want when dealing with very specific use-cases such as saving data to your database.

@gsmet
Copy link
Member

gsmet commented Aug 17, 2024

In an ideal world:

  • you would have a root ObjectMapper that cascades everywhere (and has sane defaults)
  • you could tweak the ObjectMapper for each particular use case - REST server, REST client, persistence...

Problem in #32029 was that the ObjectMapper was badly configured in the first place and then you probably want to be able to customize it. But the fact that you have to tweak the default ObjectMapper for the server usage and cannot apply things only to REST server is a problem.

I think our only way out while not breaking existing applications would be to:

  • keep the global ObjectMapper around, configured by the Quarkus config
  • allow to customize the server ObjectMapper used by Quarkus REST in the same as the REST Client one
  • allow the same for Hibernate ORM

Document this extremely carefully.

@andrewazores would this approach works for you?

@marko-bekhta
Copy link
Contributor

sooo we need to add a customization of a REST server mapper, and what was done to customize the mapper in this issue would be moved from a global customization to this new thing. We already can customize the global mapper, the one for ORM and the one for a REST client ... Or I'm missing something 🙈

@andrewazores
Copy link
Author

andrewazores commented Aug 17, 2024

@gsmet I think that would work. The shape of the POJO is one thing, and custom serialization formats are a contextual thing, so it makes sense to have different formatters or ObjectMappers for different purposes.

Even a simple transformation like changing a timestamp format from ISO-8601 to epoch seconds would be another similar example where this concept applies. Maybe the database has epoch seconds, I want my API to send ISO-8601 out, and I have a REST client talking to a Golang service where I want to use Go's standard format.

Regarding this being a breaking change: I understand and empathize 🙂 I discovered this to begin with because this is already a breaking change from 3.2->3.8. This reproducer is a boiled-down example of a real thing in Cryostat.

@andrewazores
Copy link
Author

@gsmet @marko-bekhta any further thoughts on this? I'd mostly like to know:

  1. If this will be "fixed" - even if it looks different than the original way as outlined in the reproducer. I just need the ability to customize the serialization on the REST side, I don't want or need to change how it gets persisted to the database side. But registering a deserialization mapper that intercepts every database read, and I have to somehow figure out the context and determine if the read is of a serialized Map and decide which format to deserialize it back into, is not going to work for me.
  2. If some fix like this will appear, if there is any chance of it landing in 3.8. If not, then if it would land in the next LTS, and when that next version might become available

@marko-bekhta
Copy link
Contributor

Hey @andrewazores

Yes, we still want to address this; it's just that we haven't had time to get back to it yet. (not sure into which version the fix will get into though ... )
As for the way to address it for you at the moment I think that the best option would be to provide a custom Hibernate-ORM specific format mapper (it will only be affecting your DB serialization/deserialization) https://quarkus.io/guides/hibernate-orm#json_xml_serialization_deserialization
and you probably can keep it as simple as the built-in one in ORM org.hibernate.type.format.jackson.JacksonJsonFormatMapper

@andrewazores
Copy link
Author

I'm not sure if I understand. Do you mean to do something like this to the reproducer?

diff --git a/pom.xml b/pom.xml
index 4246acf..0bceaac 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,15 +6,15 @@
     <version>1.0.0-SNAPSHOT</version>
 
     <properties>
-        <compiler-plugin.version>3.11.0</compiler-plugin.version>
+        <compiler-plugin.version>3.12.1</compiler-plugin.version>
         <maven.compiler.release>17</maven.compiler.release>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
         <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
         <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
-        <quarkus.platform.version>3.2.12.Final</quarkus.platform.version>
+        <quarkus.platform.version>3.8.6</quarkus.platform.version>
         <skipITs>true</skipITs>
-        <surefire-plugin.version>3.0.0</surefire-plugin.version>
+        <surefire-plugin.version>3.2.3</surefire-plugin.version>
     </properties>
 
     <dependencyManagement>
diff --git a/src/main/java/org/acme/HibernateMapperCustomization.java b/src/main/java/org/acme/HibernateMapperCustomization.java
new file mode 100644
index 0000000..944492f
--- /dev/null
+++ b/src/main/java/org/acme/HibernateMapperCustomization.java
@@ -0,0 +1,35 @@
+package org.acme;
+
+import org.hibernate.type.descriptor.WrapperOptions;
+import org.hibernate.type.descriptor.java.JavaType;
+import org.hibernate.type.format.FormatMapper;
+import org.hibernate.type.format.jackson.JacksonJsonFormatMapper;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.quarkus.hibernate.orm.PersistenceUnitExtension;
+import jakarta.inject.Inject;
+
+@JsonFormat
+@PersistenceUnitExtension
+public class HibernateMapperCustomization implements FormatMapper {
+
+    JacksonJsonFormatMapper delegate;
+
+    @Inject
+    HibernateMapperCustomization(ObjectMapper objectMapper) {
+        this.delegate = new JacksonJsonFormatMapper(objectMapper);
+    }
+
+    @Override
+    public <T> T fromString(CharSequence charSequence, JavaType<T> javaType, WrapperOptions wrapperOptions) {
+        return delegate.fromString(charSequence, javaType, wrapperOptions);
+    }
+
+    @Override
+    public <T> String toString(T value, JavaType<T> javaType, WrapperOptions wrapperOptions) {
+        return delegate.toString(value, javaType, wrapperOptions);
+    }
+
+}

I tried this just now and it still results in a deserialization failure:

2024-09-11 14:58:49,212 ERROR [io.qua.ver.htt.run.QuarkusErrorHandler] (executor-thread-1) HTTP Request to /models failed, error id: a123de88-b7e9-4b68-9512-bf34f71274c8-1: java.lang.IllegalArgumentException: Could not deserialize string to java type: JsonJavaType(java.util.Map<java.lang.String, java.lang.String>)
	at org.hibernate.type.format.jackson.JacksonJsonFormatMapper.fromString(JacksonJsonFormatMapper.java:42)
	at org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType.fromString(FormatMapperBasedJavaType.java:61)
	at org.hibernate.type.descriptor.java.spi.FormatMapperBasedJavaType.deepCopy(FormatMapperBasedJavaType.java:110)
	at org.hibernate.type.AbstractStandardBasicType.deepCopy(AbstractStandardBasicType.java:295)
	at org.hibernate.type.AbstractStandardBasicType.deepCopy(AbstractStandardBasicType.java:291)
	at org.hibernate.type.TypeHelper.deepCopy(TypeHelper.java:52)
	at org.hibernate.event.internal.AbstractSaveEventListener.cloneAndSubstituteValues(AbstractSaveEventListener.java:359)
	at org.hibernate.event.internal.AbstractSaveEventListener.performSaveOrReplicate(AbstractSaveEventListener.java:301)
	at org.hibernate.event.internal.AbstractSaveEventListener.performSave(AbstractSaveEventListener.java:219)
	at org.hibernate.event.internal.AbstractSaveEventListener.saveWithGeneratedId(AbstractSaveEventListener.java:134)
	at org.hibernate.event.internal.DefaultPersistEventListener.entityIsTransient(DefaultPersistEventListener.java:175)
	at org.hibernate.event.internal.DefaultPersistEventListener.persist(DefaultPersistEventListener.java:93)
	at org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:77)
	at org.hibernate.event.internal.DefaultPersistEventListener.onPersist(DefaultPersistEventListener.java:54)
	at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:127)
	at org.hibernate.internal.SessionImpl.firePersist(SessionImpl.java:758)
	at org.hibernate.internal.SessionImpl.persist(SessionImpl.java:742)
	at io.quarkus.hibernate.orm.runtime.session.TransactionScopedSession.persist(TransactionScopedSession.java:145)
	at org.hibernate.engine.spi.SessionLazyDelegator.persist(SessionLazyDelegator.java:281)
	at org.hibernate.Session_OpdLahisOZ9nWRPXMsEFQmQU03A_Synthetic_ClientProxy.persist(Unknown Source)
	at io.quarkus.hibernate.orm.panache.common.runtime.AbstractJpaOperations.persist(AbstractJpaOperations.java:101)
	at io.quarkus.hibernate.orm.panache.common.runtime.AbstractJpaOperations.persist(AbstractJpaOperations.java:96)
	at io.quarkus.hibernate.orm.panache.PanacheEntityBase.persist(PanacheEntityBase.java:53)
	at org.acme.ModelsResource.create(ModelsResource.java:33)

So it looks like in any case, whether it's the "global" customizer or the Hibernate-specific one, I would need to implement a deserializer to handle the data coming back out of the database, because in Quarkus 3.7+ this key-value customization I want to apply can only be applied in such a way that it also ends up affecting Hibernate. But then, like I was saying before, now I have to try to parse all CharSequences coming out of the database and decide if they look like List<KeyValue>, so this seems cumbersome and like it will degrade performance compared to how it was before.

@gsmet 's idea of having a global ObjectMapper, a REST client ObjectMapper customization (which already exists), a REST server customization, and a persistence customization (or per-unit customizations) sounds like it would be perfect.

@marko-bekhta
Copy link
Contributor

almost 🙈 😃... before the introduction of the @JsonFormat in Quarkus the ORM's FormatMapper would just create a default object mapper and work with that ...

https://github.com/hibernate/hibernate-orm/blob/c57a90e0889265b8d8a3f61128aa198bf058c7d2/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonJsonFormatMapper.java#L27

In your example, you are trying to inject the object mapper, which will be customized with your serializer.... so instead create a clean mapper (without any custom serialization you are doing for REST side) and pass that one to the delegate:

@JsonFormat
@PersistenceUnitExtension
public class HibernateMapperCustomization implements FormatMapper {

    JacksonJsonFormatMapper delegate;

    HibernateMapperCustomization() {
        this.delegate = new JacksonJsonFormatMapper( new ObjectMapper().findAndRegisterModules() );
    }

    @Override
    public <T> T fromString(CharSequence charSequence, JavaType<T> javaType, WrapperOptions wrapperOptions) {
        return delegate.fromString(charSequence, javaType, wrapperOptions);
    }

    @Override
    public <T> String toString(T value, JavaType<T> javaType, WrapperOptions wrapperOptions) {
        return delegate.toString(value, javaType, wrapperOptions);
    }

}

this way the serialization/deserialization in ORM should happen exactly as before in 3.2...

@marko-bekhta
Copy link
Contributor

@gsmet 's idea of having a global ObjectMapper, a REST client ObjectMapper customization (which already exists), a REST server customization, and a persistence customization (or per-unit customizations) sounds like it would be perfect.

yes, indeed 👍🏻 we just need to get to work on it 🙈 😕

@andrewazores
Copy link
Author

Thanks for the pointer @marko-bekhta , I'm trying that approach now...

diff --git a/pom.xml b/pom.xml
index 4246acf..1e12433 100644
--- a/pom.xml
+++ b/pom.xml
@@ -12,7 +12,7 @@
         <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
         <quarkus.platform.artifact-id>quarkus-bom</quarkus.platform.artifact-id>
         <quarkus.platform.group-id>io.quarkus.platform</quarkus.platform.group-id>
-        <quarkus.platform.version>3.2.12.Final</quarkus.platform.version>
+        <quarkus.platform.version>3.8.6</quarkus.platform.version>
         <skipITs>true</skipITs>
         <surefire-plugin.version>3.0.0</surefire-plugin.version>
     </properties>
diff --git a/src/main/java/org/acme/HibernateMapperCustomization.java b/src/main/java/org/acme/HibernateMapperCustomization.java
new file mode 100644
index 0000000..b699853
--- /dev/null
+++ b/src/main/java/org/acme/HibernateMapperCustomization.java
@@ -0,0 +1,41 @@
+package org.acme;
+
+import org.hibernate.type.descriptor.WrapperOptions;
+import org.hibernate.type.descriptor.java.JavaType;
+import org.hibernate.type.format.FormatMapper;
+import org.hibernate.type.format.jackson.JacksonJsonFormatMapper;
+import org.jboss.logging.Logger;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import io.quarkus.hibernate.orm.PersistenceUnitExtension;
+import jakarta.inject.Inject;
+
+@JsonFormat
+@PersistenceUnitExtension
+public class HibernateMapperCustomization implements FormatMapper {
+
+    JacksonJsonFormatMapper delegate;
+    Logger logger;
+
+    @Inject
+    HibernateMapperCustomization(Logger logger) {
+        logger.info("Created customizer");
+        this.delegate = new JacksonJsonFormatMapper(new ObjectMapper().findAndRegisterModules());
+        this.logger = logger;
+    }
+
+    @Override
+    public <T> T fromString(CharSequence charSequence, JavaType<T> javaType, WrapperOptions wrapperOptions) {
+        logger.info("fromString");
+        return delegate.fromString(charSequence, javaType, wrapperOptions);
+    }
+
+    @Override
+    public <T> String toString(T value, JavaType<T> javaType, WrapperOptions wrapperOptions) {
+        logger.info("toString");
+        return delegate.toString(value, javaType, wrapperOptions);
+    }
+
+}

and then mvn clean ; quarkus dev.

Confusingly, I don't see any logger output indicating that this customization is even instantiated, let alone that it gets called into. I get the same java.lang.IllegalArgumentException: Could not deserialize string to java type: JsonJavaType(java.util.Map<java.lang.String, java.lang.String>) stack trace as before. What am I still missing here?

@marko-bekhta
Copy link
Contributor

import com.fasterxml.jackson.annotation.JsonFormat;

should be a import io.quarkus.hibernate.orm.JsonFormat; 🙈

@andrewazores
Copy link
Author

🤦 thank you @marko-bekhta , that did the trick for the reproducer!

I'll apply this technique to the actual project and make sure it all turns out as expected too, but I think this unblocks me :-)

@andrewazores
Copy link
Author

It looks like that workaround does work for my current needs. Thanks for all the help and explanation @marko-bekhta !

@marko-bekhta
Copy link
Contributor

Happy to help and glad it worked for you! 😃

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area/hibernate-orm Hibernate ORM area/jackson Issues related to Jackson (JSON library) kind/bug Something isn't working
Projects
None yet
Development

No branches or pull requests

3 participants