From fd2673fbc349ac23be115c25a7f5e2c855dad52f Mon Sep 17 00:00:00 2001 From: Nick Rayburn <52075362+nrayburn-tech@users.noreply.github.com> Date: Sun, 30 Nov 2025 21:10:25 -0600 Subject: [PATCH] HHH-19890 Add Jackson 3 FormatMapper support --- .../asciidoc/introduction/Configuration.adoc | 4 +- .../main/asciidoc/introduction/Mapping.adoc | 2 +- hibernate-core/hibernate-core.gradle | 4 + .../SessionFactoryOptionsBuilder.java | 43 ++- .../internal/StrategySelectorBuilder.java | 12 + .../jackson/Jackson3JsonFormatMapper.java | 92 +++++ .../jackson/Jackson3XmlFormatMapper.java | 314 ++++++++++++++++++ .../format/jackson/JacksonIntegration.java | 51 +++ .../basic/JsonJavaTimeMappingTests.java | 223 +++++++++++++ .../test/mapping/basic/JsonMappingTests.java | 13 + .../mapping/basic/PolymorphicJsonTests.java | 7 + .../test/mapping/basic/XmlMappingTests.java | 8 + .../mapping/type/format/XmlFormatterTest.java | 3 +- settings.gradle | 4 + 14 files changed, 764 insertions(+), 16 deletions(-) create mode 100644 hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3JsonFormatMapper.java create mode 100644 hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3XmlFormatMapper.java create mode 100644 hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonJavaTimeMappingTests.java diff --git a/documentation/src/main/asciidoc/introduction/Configuration.adoc b/documentation/src/main/asciidoc/introduction/Configuration.adoc index 26af305533cd..e9a412b316e0 100644 --- a/documentation/src/main/asciidoc/introduction/Configuration.adoc +++ b/documentation/src/main/asciidoc/introduction/Configuration.adoc @@ -116,8 +116,8 @@ and `org.ehcache:ehcache` and `com.github.ben-manes.caffeine:jcache` | Distributed second-level cache support via {infinispan}[Infinispan] | `org.infinispan:infinispan-hibernate-cache-v60` // | SCRAM authentication support for PostgreSQL | `com.ongres.scram:client:2.1` -| A JSON serialization library for working with JSON datatypes, for example, {jackson}[Jackson] or {yasson}[Yasson] | -`com.fasterxml.jackson.core:jackson-databind` + +| A JSON serialization library for working with JSON datatypes, for example, {jackson}[Jackson 2], {jackson3}[Jackson 3] or {yasson}[Yasson] | +`com.fasterxml.jackson.core:jackson-databind`, `tools.jackson.core:jackson-databind` + or `org.eclipse:yasson` | <> | `org.hibernate.orm:hibernate-spatial` | <>, for auditing historical data | `org.hibernate.orm:hibernate-envers` diff --git a/documentation/src/main/asciidoc/introduction/Mapping.adoc b/documentation/src/main/asciidoc/introduction/Mapping.adoc index 15541c2bf723..7069f3d5b5e4 100644 --- a/documentation/src/main/asciidoc/introduction/Mapping.adoc +++ b/documentation/src/main/asciidoc/introduction/Mapping.adoc @@ -809,7 +809,7 @@ class Person { ---- We also need to add Jackson or an implementation of JSONB—for example, Yasson—to our runtime classpath. -To use Jackson we could add this line to our Gradle build: +To use Jackson 2 we could add this line to our Gradle build: [source,groovy] ---- diff --git a/hibernate-core/hibernate-core.gradle b/hibernate-core/hibernate-core.gradle index a9bd5791df96..cbf8b102d844 100644 --- a/hibernate-core/hibernate-core.gradle +++ b/hibernate-core/hibernate-core.gradle @@ -42,6 +42,8 @@ dependencies { compileOnly jakartaLibs.jsonbApi compileOnly libs.jackson compileOnly libs.jacksonXml + compileOnly libs.jackson3 + compileOnly libs.jackson3Xml compileOnly jdbcLibs.postgresql compileOnly jdbcLibs.edb @@ -79,6 +81,8 @@ dependencies { testImplementation libs.jackson testRuntimeOnly libs.jacksonXml testRuntimeOnly libs.jacksonJsr310 + testImplementation libs.jackson3 + testImplementation libs.jackson3Xml testAnnotationProcessor project( ':hibernate-processor' ) diff --git a/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java b/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java index 7a54f65f4167..bbca3700687d 100644 --- a/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java +++ b/hibernate-core/src/main/java/org/hibernate/boot/internal/SessionFactoryOptionsBuilder.java @@ -101,10 +101,11 @@ import static org.hibernate.jpa.internal.util.CacheModeHelper.interpretCacheMode; import static org.hibernate.jpa.internal.util.ConfigurationHelper.getFlushMode; import static org.hibernate.stat.Statistics.DEFAULT_QUERY_STATISTICS_MAX_SIZE; +import static org.hibernate.type.format.jackson.JacksonIntegration.getJsonJackson3FormatMapperOrNull; import static org.hibernate.type.format.jackson.JacksonIntegration.getJsonJacksonFormatMapperOrNull; import static org.hibernate.type.format.jackson.JacksonIntegration.getOsonJacksonFormatMapperOrNull; +import static org.hibernate.type.format.jackson.JacksonIntegration.getXMLJackson3FormatMapperOrNull; import static org.hibernate.type.format.jackson.JacksonIntegration.getXMLJacksonFormatMapperOrNull; -import static org.hibernate.type.format.jackson.JacksonIntegration.isJacksonOsonExtensionAvailable; import static org.hibernate.type.format.jakartajson.JakartaJsonIntegration.getJakartaJsonBFormatMapperOrNull; /** @@ -851,14 +852,25 @@ private static FormatMapper jsonFormatMapper(Object setting, boolean osonExtensi setting, selector, () -> { + final FormatMapper jackson3FormatMapper = getJsonJackson3FormatMapperOrNull( creationContext ); + if ( jackson3FormatMapper != null ) { + return jackson3FormatMapper; + } + // Prefer the OSON Jackson FormatMapper by default if available - final FormatMapper jsonJacksonFormatMapper = - osonExtensionEnabled && isJacksonOsonExtensionAvailable() - ? getOsonJacksonFormatMapperOrNull( creationContext ) - : getJsonJacksonFormatMapperOrNull( creationContext ); - return jsonJacksonFormatMapper != null - ? jsonJacksonFormatMapper - : getJakartaJsonBFormatMapperOrNull(); + final FormatMapper jacksonOsonFormatMapper = osonExtensionEnabled + ? getOsonJacksonFormatMapperOrNull( creationContext ) + : null; + if ( jacksonOsonFormatMapper != null ) { + return jacksonOsonFormatMapper; + } + + final FormatMapper jacksonFormatMapper = getJsonJacksonFormatMapperOrNull( creationContext ); + if ( jacksonFormatMapper != null ) { + return jacksonFormatMapper; + } + + return getJakartaJsonBFormatMapperOrNull(); }, creationContext ); @@ -869,10 +881,17 @@ private static FormatMapper xmlFormatMapper(Object setting, StrategySelector sel setting, selector, () -> { - final FormatMapper jacksonFormatMapper = getXMLJacksonFormatMapperOrNull( creationContext ); - return jacksonFormatMapper != null - ? jacksonFormatMapper - : new JaxbXmlFormatMapper( legacyFormat ); + final FormatMapper jackson3FormatMapper = getXMLJackson3FormatMapperOrNull( creationContext ); + if (jackson3FormatMapper != null) { + return jackson3FormatMapper; + } + + final FormatMapper jacksonFormatMapper = getXMLJacksonFormatMapperOrNull( creationContext ); + if (jacksonFormatMapper != null) { + return jacksonFormatMapper; + } + + return new JaxbXmlFormatMapper( legacyFormat ); }, creationContext ); diff --git a/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java b/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java index d4228f87ecf2..fc23f9bb305d 100644 --- a/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java +++ b/hibernate-core/src/main/java/org/hibernate/boot/registry/selector/internal/StrategySelectorBuilder.java @@ -45,6 +45,8 @@ import org.hibernate.resource.transaction.backend.jta.internal.JtaTransactionCoordinatorBuilderImpl; import org.hibernate.resource.transaction.spi.TransactionCoordinatorBuilder; import org.hibernate.type.format.FormatMapper; +import org.hibernate.type.format.jackson.Jackson3JsonFormatMapper; +import org.hibernate.type.format.jackson.Jackson3XmlFormatMapper; import org.hibernate.type.format.jackson.JacksonIntegration; import org.hibernate.type.format.jackson.JacksonJsonFormatMapper; import org.hibernate.type.format.jackson.JacksonOsonFormatMapper; @@ -303,6 +305,11 @@ private static void addJsonFormatMappers(StrategySelectorImpl strategySelector) JsonBJsonFormatMapper.SHORT_NAME, JsonBJsonFormatMapper.class ); + strategySelector.registerStrategyImplementor( + FormatMapper.class, + Jackson3JsonFormatMapper.SHORT_NAME, + Jackson3JsonFormatMapper.class + ); strategySelector.registerStrategyImplementor( FormatMapper.class, JacksonJsonFormatMapper.SHORT_NAME, @@ -318,6 +325,11 @@ private static void addJsonFormatMappers(StrategySelectorImpl strategySelector) } private static void addXmlFormatMappers(StrategySelectorImpl strategySelector) { + strategySelector.registerStrategyImplementor( + FormatMapper.class, + Jackson3XmlFormatMapper.SHORT_NAME, + Jackson3XmlFormatMapper.class + ); strategySelector.registerStrategyImplementor( FormatMapper.class, JacksonXmlFormatMapper.SHORT_NAME, diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3JsonFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3JsonFormatMapper.java new file mode 100644 index 000000000000..7e9a0b3cca72 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3JsonFormatMapper.java @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format.jackson; + +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.format.AbstractJsonFormatMapper; +import org.hibernate.type.format.FormatMapperCreationContext; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.json.JsonMapper; + +import java.lang.reflect.Type; +import java.util.List; + +/** + * @author Christian Beikov + * @author Yanming Zhou + * @author Nick Rayburn + */ +public final class Jackson3JsonFormatMapper extends AbstractJsonFormatMapper { + + public static final String SHORT_NAME = "jackson3"; + + private final JsonMapper jsonMapper; + + public Jackson3JsonFormatMapper() { + this( MapperBuilder.findModules( Jackson3JsonFormatMapper.class.getClassLoader() ) ); + } + + public Jackson3JsonFormatMapper(FormatMapperCreationContext creationContext) { + this( JacksonIntegration.loadJackson3Modules( creationContext ) ); + } + + private Jackson3JsonFormatMapper(List modules) { + this( JsonMapper.builderWithJackson2Defaults() + .addModules( modules ) + .build() + ); + } + + public Jackson3JsonFormatMapper(JsonMapper jsonMapper) { + this.jsonMapper = jsonMapper; + } + + @Override + public void writeToTarget(T value, JavaType javaType, Object target, WrapperOptions options) + throws JacksonException { + jsonMapper.writerFor( jsonMapper.constructType( javaType.getJavaType() ) ) + .writeValue( (JsonGenerator) target, value ); + } + + @Override + public T readFromSource(JavaType javaType, Object source, WrapperOptions options) throws JacksonException { + return jsonMapper.readValue( (JsonParser) source, jsonMapper.constructType( javaType.getJavaType() ) ); + } + + @Override + public boolean supportsSourceType(Class sourceType) { + return JsonParser.class.isAssignableFrom( sourceType ); + } + + @Override + public boolean supportsTargetType(Class targetType) { + return JsonGenerator.class.isAssignableFrom( targetType ); + } + + @Override + public T fromString(CharSequence charSequence, Type type) { + try { + return jsonMapper.readValue( charSequence.toString(), jsonMapper.constructType( type ) ); + } + catch (JacksonException e) { + throw new IllegalArgumentException( "Could not deserialize string to java type: " + type, e ); + } + } + + @Override + public String toString(T value, Type type) { + try { + return jsonMapper.writerFor( jsonMapper.constructType( type ) ).writeValueAsString( value ); + } + catch (JacksonException e) { + throw new IllegalArgumentException( "Could not serialize object of java type: " + type, e ); + } + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3XmlFormatMapper.java b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3XmlFormatMapper.java new file mode 100644 index 000000000000..bc2bd1316a45 --- /dev/null +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/Jackson3XmlFormatMapper.java @@ -0,0 +1,314 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.type.format.jackson; + +import com.fasterxml.jackson.annotation.JsonRootName; +import org.hibernate.type.descriptor.WrapperOptions; +import org.hibernate.type.descriptor.java.JavaType; +import org.hibernate.type.descriptor.java.PrimitiveByteArrayJavaType; +import org.hibernate.type.format.FormatMapper; +import org.hibernate.type.format.FormatMapperCreationContext; +import org.hibernate.type.internal.ParameterizedTypeImpl; +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.module.SimpleModule; +import tools.jackson.databind.ser.std.StdSerializer; +import tools.jackson.dataformat.xml.XmlMapper; +import tools.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper; +import tools.jackson.dataformat.xml.annotation.JacksonXmlProperty; + +import java.lang.reflect.Array; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * @author Christian Beikov + * @author Emmanuel Jannetti + * @author Nick Rayburn + */ +public final class Jackson3XmlFormatMapper implements FormatMapper { + + public static final String SHORT_NAME = "jackson3-xml"; + private final boolean legacyFormat; + + private final XmlMapper xmlMapper; + + public Jackson3XmlFormatMapper() { + this( true ); + } + + public Jackson3XmlFormatMapper(boolean legacyFormat) { + this( + createXmlMapper( MapperBuilder.findModules( Jackson3XmlFormatMapper.class.getClassLoader() ), legacyFormat ), + legacyFormat + ); + } + + public Jackson3XmlFormatMapper(FormatMapperCreationContext creationContext) { + this( + createXmlMapper( + JacksonIntegration.loadJackson3Modules( creationContext ), + creationContext.getBootstrapContext() + .getMetadataBuildingOptions() + .isXmlFormatMapperLegacyFormatEnabled() + ), + creationContext.getBootstrapContext() + .getMetadataBuildingOptions() + .isXmlFormatMapperLegacyFormatEnabled() + ); + } + + public Jackson3XmlFormatMapper(XmlMapper xmlMapper) { + this( xmlMapper, false ); + } + + public Jackson3XmlFormatMapper(XmlMapper xmlMapper, boolean legacyFormat) { + this.xmlMapper = xmlMapper; + this.legacyFormat = legacyFormat; + } + + private static XmlMapper createXmlMapper(List modules, boolean legacyFormat) { + final XmlMapper.Builder builder = XmlMapper.builderWithJackson2Defaults().addModules( modules ); + // Workaround for null vs empty string handling inside arrays, + // see: https://github.com/FasterXML/jackson-dataformat-xml/issues/344 + final SimpleModule module = new SimpleModule(); + module.addDeserializer( String[].class, new StringArrayDeserializer() ); + if ( !legacyFormat ) { + module.addDeserializer( byte[].class, new ByteArrayDeserializer() ); + module.addSerializer( byte[].class, new ByteArraySerializer() ); + } + builder.addModule( module ); + return builder.build(); + } + + @Override + public T fromString(CharSequence charSequence, JavaType javaType, WrapperOptions wrapperOptions) { + if ( javaType.getJavaType() == String.class || javaType.getJavaType() == Object.class ) { + return (T) charSequence.toString(); + } + try { + if ( !legacyFormat ) { + if ( Map.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + final Type keyType; + final Type elementType; + if ( javaType.getJavaType() instanceof ParameterizedType parameterizedType ) { + keyType = parameterizedType.getActualTypeArguments()[0]; + elementType = parameterizedType.getActualTypeArguments()[1]; + } + else { + keyType = Object.class; + elementType = Object.class; + } + final MapWrapper collectionWrapper = xmlMapper.readValue( + charSequence.toString(), + xmlMapper.constructType( new ParameterizedTypeImpl( MapWrapper.class, + new Type[] {keyType, elementType}, null ) ) + ); + final Map map = new LinkedHashMap<>( collectionWrapper.entry.size() ); + for ( EntryWrapper entry : collectionWrapper.entry ) { + map.put( entry.key, entry.value ); + } + return javaType.wrap( map, wrapperOptions ); + } + else if ( Collection.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + final Type elementType = + javaType.getJavaType() instanceof ParameterizedType parameterizedType + ? parameterizedType.getActualTypeArguments()[0] + : Object.class; + final CollectionWrapper collectionWrapper = xmlMapper.readValue( + charSequence.toString(), + xmlMapper.constructType( + new ParameterizedTypeImpl( CollectionWrapper.class, new Type[] {elementType}, + null ) ) + ); + return javaType.wrap( collectionWrapper.value, wrapperOptions ); + } + else if ( javaType.getJavaTypeClass().isArray() ) { + final CollectionWrapper collectionWrapper = xmlMapper.readValue( + charSequence.toString(), + xmlMapper.constructType( new ParameterizedTypeImpl( CollectionWrapper.class, + new Type[] {javaType.getJavaTypeClass().getComponentType()}, null ) ) + ); + return javaType.wrap( collectionWrapper.value, wrapperOptions ); + } + } + return xmlMapper.readValue( + charSequence.toString(), + xmlMapper.constructType( javaType.getJavaType() ) + ); + } + catch (JacksonException e) { + throw new IllegalArgumentException( "Could not deserialize string to java type: " + javaType, e ); + } + } + + @Override + public String toString(T value, JavaType javaType, WrapperOptions wrapperOptions) { + if ( javaType.getJavaType() == String.class || javaType.getJavaType() == Object.class ) { + return (String) value; + } + if ( !legacyFormat ) { + if ( Map.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + final Type keyType; + final Type elementType; + if ( javaType.getJavaType() instanceof ParameterizedType parameterizedType ) { + keyType = parameterizedType.getActualTypeArguments()[0]; + elementType = parameterizedType.getActualTypeArguments()[1]; + } + else { + keyType = Object.class; + elementType = Object.class; + } + final MapWrapper mapWrapper = new MapWrapper<>(); + for ( Map.Entry entry : ((Map) value).entrySet() ) { + mapWrapper.entry.add( new EntryWrapper<>( entry.getKey(), entry.getValue() ) ); + } + return writeValueAsString( + mapWrapper, + javaType, + new ParameterizedTypeImpl( MapWrapper.class, new Type[] {keyType, elementType}, null ) + ); + } + else if ( Collection.class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + final Type elementType = + javaType.getJavaType() instanceof ParameterizedType parameterizedType + ? parameterizedType.getActualTypeArguments()[0] + : Object.class; + return writeValueAsString( + new CollectionWrapper<>( (Collection) value ), + javaType, + new ParameterizedTypeImpl( CollectionWrapper.class, new Type[] {elementType}, null ) + ); + } + else if ( javaType.getJavaTypeClass().isArray() ) { + final CollectionWrapper collectionWrapper; + if ( Object[].class.isAssignableFrom( javaType.getJavaTypeClass() ) ) { + collectionWrapper = new CollectionWrapper<>( Arrays.asList( (Object[]) value ) ); + } + else { + // Primitive arrays get a special treatment + final int length = Array.getLength( value ); + final List list = new ArrayList<>( length ); + for ( int i = 0; i < length; i++ ) { + list.add( Array.get( value, i ) ); + } + collectionWrapper = new CollectionWrapper<>( list ); + } + return writeValueAsString( + collectionWrapper, + javaType, + new ParameterizedTypeImpl( CollectionWrapper.class, + new Type[] {javaType.getJavaTypeClass().getComponentType()}, null ) + ); + } + } + return writeValueAsString( value, javaType, javaType.getJavaType() ); + } + + private String writeValueAsString(Object value, JavaType javaType, Type type) { + try { + return xmlMapper.writerFor( xmlMapper.constructType( type ) ).writeValueAsString( value ); + } + catch (JacksonException e) { + throw new IllegalArgumentException( "Could not serialize object of java type: " + javaType, e ); + } + } + + @JsonRootName(value = "Collection") + public static class CollectionWrapper { + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "e") + Collection value; + + public CollectionWrapper() { + this.value = new ArrayList<>(); + } + + public CollectionWrapper(Collection value) { + this.value = value; + } + } + + @JsonRootName(value = "Map") + public static class MapWrapper { + @JacksonXmlElementWrapper(useWrapping = false) + @JacksonXmlProperty(localName = "e") + Collection> entry; + + public MapWrapper() { + this.entry = new ArrayList<>(); + } + + public MapWrapper(Collection> entry) { + this.entry = entry; + } + } + + public static class EntryWrapper { + @JacksonXmlProperty(localName = "k") + K key; + @JacksonXmlProperty(localName = "v") + V value; + + public EntryWrapper() { + } + + public EntryWrapper(K key, V value) { + this.key = key; + this.value = value; + } + } + + private static class StringArrayDeserializer extends ValueDeserializer { + @Override + public String[] deserialize(JsonParser jp, DeserializationContext deserializationContext) throws JacksonException { + final ArrayList result = new ArrayList<>(); + JsonToken token; + while ( ( token = jp.nextValue() ) != JsonToken.END_OBJECT ) { + if ( token.isScalarValue() ) { + result.add( jp.getValueAsString() ); + } + } + return result.toArray( String[]::new ); + } + } + + private static class ByteArrayDeserializer extends ValueDeserializer { + @Override + public byte[] deserialize(JsonParser jp, DeserializationContext deserializationContext) throws JacksonException { + return PrimitiveByteArrayJavaType.INSTANCE.fromString( jp.getValueAsString() ); + } + } + + public static class ByteArraySerializer extends StdSerializer { + + public ByteArraySerializer() { + super( byte[].class ); + } + + @Override + public boolean isEmpty(SerializationContext prov, byte[] value) { + return value.length == 0; + } + + @Override + public void serialize(byte[] value, JsonGenerator g, SerializationContext provider) throws JacksonException { + g.writeString( PrimitiveByteArrayJavaType.INSTANCE.toString( value ) ); + } + } +} diff --git a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java index 11bd31e78915..0d270e464b2b 100644 --- a/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java +++ b/hibernate-core/src/main/java/org/hibernate/type/format/jackson/JacksonIntegration.java @@ -9,6 +9,8 @@ import com.fasterxml.jackson.databind.Module; import com.fasterxml.jackson.databind.ObjectMapper; import org.checkerframework.checker.nullness.qual.Nullable; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.cfg.MapperBuilder; import org.hibernate.type.format.FormatMapper; import org.hibernate.type.format.FormatMapperCreationContext; @@ -21,6 +23,8 @@ public final class JacksonIntegration { private static final boolean JACKSON_JSON_AVAILABLE = ableToLoadJacksonJSONMapper(); private static final boolean JACKSON_OSON_AVAILABLE = ableToLoadJacksonOSONFactory(); + private static final boolean JACKSON3_XML_AVAILABLE = ableToLoadJackson3XMLMapper(); + private static final boolean JACKSON3_JSON_AVAILABLE = ableToLoadJackson3JSONMapper(); private JacksonIntegration() { //To not be instantiated: static helpers only @@ -30,10 +34,18 @@ private static boolean ableToLoadJacksonJSONMapper() { return canLoad( "com.fasterxml.jackson.databind.ObjectMapper" ); } + private static boolean ableToLoadJackson3JSONMapper() { + return canLoad( "tools.jackson.databind.json.JsonMapper" ); + } + private static boolean ableToLoadJacksonXMLMapper() { return canLoad( "com.fasterxml.jackson.dataformat.xml.XmlMapper" ); } + private static boolean ableToLoadJackson3XMLMapper() { + return canLoad( "tools.jackson.dataformat.xml.XmlMapper" ); + } + /** * Checks that Jackson is available and that we have the Oracle OSON extension available * in the classpath. @@ -50,11 +62,22 @@ private static boolean ableToLoadJacksonOSONFactory() { : null; } + public static @Nullable FormatMapper getXMLJackson3FormatMapperOrNull(FormatMapperCreationContext creationContext) { + return JACKSON3_XML_AVAILABLE + ? new Jackson3XmlFormatMapper( creationContext ) + : null; + } + public static @Nullable FormatMapper getJsonJacksonFormatMapperOrNull(FormatMapperCreationContext creationContext) { return JACKSON_JSON_AVAILABLE ? new JacksonJsonFormatMapper( creationContext ) : null; } + public static @Nullable FormatMapper getJsonJackson3FormatMapperOrNull(FormatMapperCreationContext creationContext) { + return JACKSON3_JSON_AVAILABLE + ? new Jackson3JsonFormatMapper( creationContext ) + : null; + } public static @Nullable FormatMapper getOsonJacksonFormatMapperOrNull(FormatMapperCreationContext creationContext) { return JACKSON_OSON_AVAILABLE ? new JacksonOsonFormatMapper( creationContext ) @@ -66,6 +89,11 @@ private static boolean ableToLoadJacksonOSONFactory() { ? new JacksonJsonFormatMapper() : null; } + public static @Nullable FormatMapper getJsonJackson3FormatMapperOrNull() { + return JACKSON3_JSON_AVAILABLE + ? new Jackson3JsonFormatMapper() + : null; + } public static @Nullable FormatMapper getOsonJacksonFormatMapperOrNull() { return JACKSON_OSON_AVAILABLE ? new JacksonOsonFormatMapper() @@ -117,4 +145,27 @@ static List loadModules(FormatMapperCreationContext creationContext) { } return ObjectMapper.findModules( classLoader ); } + + static List loadJackson3Modules(FormatMapperCreationContext creationContext) { + final ClassLoader classLoader = JacksonIntegration.class.getClassLoader(); + final ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader(); + if ( contextClassLoader != null && classLoader != contextClassLoader ) { + try { + // The context class loader represents the application class loader in a Jakarta EE deployment. + // We have to check if the ObjectMapper that is visible to Hibernate ORM is the same that is visible + // to the application class loader. Only if it is, we can use the application class loader or rather + // our AggregatedClassLoader for loading Jackson Module via ServiceLoader, as otherwise the loaded + // Jackson Module instances would have a different class loader, leading to a ClassCastException. + if ( ObjectMapper.class == contextClassLoader.loadClass( "tools.jackson.databind.ObjectMapper" ) ) { + return creationContext.getBootstrapContext() + .getClassLoaderService() + .>workWithClassLoader( MapperBuilder::findModules ); + } + } + catch (ClassNotFoundException | LinkageError e) { + // Ignore if the context/application class loader doesn't know Jackson classes + } + } + return MapperBuilder.findModules( classLoader ); + } } diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonJavaTimeMappingTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonJavaTimeMappingTests.java new file mode 100644 index 000000000000..f87e8de86fcd --- /dev/null +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonJavaTimeMappingTests.java @@ -0,0 +1,223 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * Copyright Red Hat Inc. and Hibernate Authors + */ +package org.hibernate.orm.test.mapping.basic; + +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.cfg.AvailableSettings; +import org.hibernate.community.dialect.AltibaseDialect; +import org.hibernate.community.dialect.DerbyDialect; +import org.hibernate.community.dialect.InformixDialect; +import org.hibernate.dialect.HANADialect; +import org.hibernate.dialect.OracleDialect; +import org.hibernate.dialect.SybaseDialect; +import org.hibernate.metamodel.mapping.internal.BasicAttributeMapping; +import org.hibernate.metamodel.spi.MappingMetamodelImplementor; +import org.hibernate.persister.entity.EntityPersister; +import org.hibernate.testing.orm.junit.DomainModel; +import org.hibernate.testing.orm.junit.ServiceRegistry; +import org.hibernate.testing.orm.junit.SessionFactory; +import org.hibernate.testing.orm.junit.SessionFactoryScope; +import org.hibernate.testing.orm.junit.Setting; +import org.hibernate.testing.orm.junit.SkipForDialect; +import org.hibernate.type.SqlTypes; +import org.hibernate.type.descriptor.jdbc.JdbcType; +import org.hibernate.type.descriptor.jdbc.spi.JdbcTypeRegistry; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.io.Serializable; +import java.nio.charset.StandardCharsets; +import java.sql.Blob; +import java.sql.Clob; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.isA; +import static org.hamcrest.Matchers.isOneOf; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +@DomainModel(annotatedClasses = JsonJavaTimeMappingTests.EntityWithJson.class) +@SessionFactory +public abstract class JsonJavaTimeMappingTests { + + @ServiceRegistry(settings = @Setting(name = AvailableSettings.JSON_FORMAT_MAPPER, value = "jackson")) + public static class Jackson extends JsonJavaTimeMappingTests { + + public Jackson() { + super(); + } + } + + @ServiceRegistry(settings = @Setting(name = AvailableSettings.JSON_FORMAT_MAPPER, value = "jackson3")) + public static class Jackson3 extends JsonJavaTimeMappingTests { + + public Jackson3() { + super(); + } + } + + private final JavaTime javaTime; + private final String json; + + protected JsonJavaTimeMappingTests() { + final var localDate = LocalDate.of( 2025, 12, 1 ); + final var localTime = LocalTime.of( 9, 9, 42 ); + final var localDateTime = LocalDateTime.of( localDate, localTime ); + final var instant = localDateTime.atZone( ZoneId.of( "Europe/Paris" ) ).toInstant(); + final var duration = Duration.ofHours( 3 ).plusMinutes( 14 ); + javaTime = new JavaTime( instant, localDateTime, localDate, localTime, duration ); + this.json = "{\"instant\":1764576582.000000000,\"localDateTime\":[2025,12,1,9,9,42],\"localDate\":[2025,12,1],\"localTime\":[9,9,42],\"duration\":11640.000000000}"; + } + + @BeforeEach + public void setup(SessionFactoryScope scope) { + scope.inTransaction( + (session) -> { + session.persist( new EntityWithJson( 1, javaTime ) ); + } + ); + } + + @AfterEach + public void tearDown(SessionFactoryScope scope) { + scope.getSessionFactory().getSchemaManager().truncate(); + } + + @Test + public void verifyMappings(SessionFactoryScope scope) { + final MappingMetamodelImplementor mappingMetamodel = scope.getSessionFactory() + .getRuntimeMetamodels() + .getMappingMetamodel(); + final EntityPersister entityDescriptor = mappingMetamodel.findEntityDescriptor( EntityWithJson.class ); + final JdbcTypeRegistry jdbcTypeRegistry = mappingMetamodel.getTypeConfiguration().getJdbcTypeRegistry(); + + final BasicAttributeMapping stringMapAttribute = (BasicAttributeMapping) entityDescriptor.findAttributeMapping( + "javaTime" ); + + assertThat( stringMapAttribute.getJavaType().getJavaTypeClass(), equalTo( JavaTime.class ) ); + + final JdbcType jsonType = jdbcTypeRegistry.getDescriptor( SqlTypes.JSON ); + assertThat( stringMapAttribute.getJdbcMapping().getJdbcType(), isA( jsonType.getClass() ) ); + } + + @Test + public void verifyReadWorks(SessionFactoryScope scope) { + scope.inTransaction( + (session) -> { + EntityWithJson entityWithJson = session.find( EntityWithJson.class, 1 ); + assertThat( entityWithJson.javaTime, is( javaTime ) ); + } + ); + } + + @Test + public void verifyMergeWorks(SessionFactoryScope scope) { + scope.inTransaction( + (session) -> session.merge( new EntityWithJson( 2, null ) ) + ); + + scope.inTransaction( + (session) -> { + EntityWithJson entityWithJson = session.find( EntityWithJson.class, 2 ); + assertThat( entityWithJson.javaTime, is( nullValue() ) ); + } + ); + } + + @Test + @SkipForDialect(dialectClass = DerbyDialect.class, + reason = "Derby doesn't support comparing CLOBs with the = operator") + @SkipForDialect(dialectClass = HANADialect.class, + reason = "HANA doesn't support comparing LOBs with the = operator") + @SkipForDialect(dialectClass = SybaseDialect.class, matchSubTypes = true, + reason = "Sybase doesn't support comparing LOBs with the = operator") + @SkipForDialect(dialectClass = OracleDialect.class, + reason = "Oracle doesn't support comparing JSON with the = operator") + @SkipForDialect(dialectClass = AltibaseDialect.class, + reason = "Altibase doesn't support comparing CLOBs with the = operator") + @SkipForDialect(dialectClass = InformixDialect.class, + reason = "Blobs are not allowed in this expression") + public void verifyComparisonWorks(SessionFactoryScope scope) { + scope.inTransaction( + (session) -> { + // PostgreSQL returns the JSON slightly formatted + String alternativePostgreSQLJson = + "{\"instant\": 1764576582.000000000, \"duration\": 11640.000000000, \"localDate\": [2025, 12, 1], \"localTime\": [9, 9, 42], \"localDateTime\": [2025, 12, 1, 9, 9, 42]}"; + // MySQL + String alternativeMySQLJson = + "\"{\\\"instant\\\": 1764576582.0, \\\"duration\\\": 11640.0, \\\"localDate\\\": [2025, 12, 1], \\\"localTime\\\": [9, 9, 42], \\\"localDateTime\\\": [2025, 12, 1, 9, 9, 42]}\""; + EntityWithJson entityWithJson = session.createQuery( + "from EntityWithJson e where e.javaTime = :param", + EntityWithJson.class + ) + .setParameter( "param", javaTime ) + .getSingleResult(); + assertThat( entityWithJson, notNullValue() ); + assertThat( entityWithJson.javaTime, is( javaTime ) ); + Object nativeJson = session.createNativeQuery( + "select javaTime from EntityWithJson", + Object.class + ) + .getResultList() + .get( 0 ); + final String jsonText; + try { + if ( nativeJson instanceof Blob ) { + final Blob blob = (Blob) nativeJson; + jsonText = new String( + blob.getBytes( 1L, (int) blob.length() ), + StandardCharsets.UTF_8 + ); + } + else if ( nativeJson instanceof Clob ) { + final Clob jsonClob = (Clob) nativeJson; + jsonText = jsonClob.getSubString( 1L, (int) jsonClob.length() ); + } + else { + jsonText = (String) nativeJson; + } + } + catch (Exception e) { + throw new RuntimeException( e ); + } + assertThat( jsonText, isOneOf( json, alternativePostgreSQLJson, alternativeMySQLJson ) ); + } + ); + } + + public record JavaTime(Instant instant, LocalDateTime localDateTime, LocalDate localDate, LocalTime localTime, + Duration duration) implements Serializable { + } + + @Entity(name = "EntityWithJson") + @Table(name = "EntityWithJson") + public static class EntityWithJson { + @Id + private Integer id; + + @JdbcTypeCode(SqlTypes.JSON) + private JavaTime javaTime; + + public EntityWithJson() { + } + + public EntityWithJson(Integer id, JavaTime javaTime) { + this.id = id; + this.javaTime = javaTime; + } + } +} diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonMappingTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonMappingTests.java index 7f5ecf8be859..96917288e824 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonMappingTests.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/JsonMappingTests.java @@ -78,6 +78,14 @@ public Jackson() { } } + @ServiceRegistry(settings = @Setting(name = AvailableSettings.JSON_FORMAT_MAPPER, value = "jackson3")) + public static class Jackson3 extends JsonMappingTests { + + public Jackson3() { + super( false ); + } + } + private final Map stringMap; private final Map objectMap; private final List list; @@ -147,6 +155,7 @@ public void verifyReadWorks(SessionFactoryScope scope) { assertThat( entityWithJson.objectMap, is( objectMap ) ); assertThat( entityWithJson.list, is( list ) ); assertThat( entityWithJson.jsonNode, is( nullValue() ) ); + assertThat( entityWithJson.jackson3JsonNode, is( nullValue() ) ); assertThat( entityWithJson.jsonValue, is( nullValue() ) ); } ); @@ -168,6 +177,7 @@ public void verifyMergeWorks(SessionFactoryScope scope) { assertThat( entityWithJson.list, is( nullValue() ) ); assertThat( entityWithJson.jsonString, is( nullValue() ) ); assertThat( entityWithJson.jsonNode, is( nullValue() ) ); + assertThat( entityWithJson.jackson3JsonNode, is( nullValue() ) ); assertThat( entityWithJson.jsonValue, is( nullValue() ) ); assertThat( entityWithJson.complexMap, is( nullValue() ) ); } @@ -301,6 +311,9 @@ public static class EntityWithJson { @JdbcTypeCode( SqlTypes.JSON ) private JsonNode jsonNode; + @JdbcTypeCode( SqlTypes.JSON ) + private tools.jackson.databind.JsonNode jackson3JsonNode; + @JdbcTypeCode( SqlTypes.JSON ) private JsonValue jsonValue; diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/PolymorphicJsonTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/PolymorphicJsonTests.java index 3c7436bdb2c3..12d70c275e43 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/PolymorphicJsonTests.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/PolymorphicJsonTests.java @@ -50,6 +50,13 @@ public Jackson() { } } + @ServiceRegistry(settings = @Setting(name = AvailableSettings.JSON_FORMAT_MAPPER, value = "jackson3")) + public static class Jackson3 extends PolymorphicJsonTests { + + public Jackson3() { + } + } + @BeforeEach public void setup(SessionFactoryScope scope) { scope.inTransaction( diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/XmlMappingTests.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/XmlMappingTests.java index b88fbf140bed..46243bf4d06b 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/XmlMappingTests.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/basic/XmlMappingTests.java @@ -68,6 +68,14 @@ public Jackson() { } } + @ServiceRegistry(settings = @Setting(name = AvailableSettings.XML_FORMAT_MAPPER, value = "jackson3-xml")) + public static class Jackson3 extends XmlMappingTests { + + public Jackson3() { + super( false ); + } + } + private final Map stringMap; private final Map objectMap; diff --git a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/type/format/XmlFormatterTest.java b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/type/format/XmlFormatterTest.java index 82d68b587630..98ddde22998b 100644 --- a/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/type/format/XmlFormatterTest.java +++ b/hibernate-core/src/test/java/org/hibernate/orm/test/mapping/type/format/XmlFormatterTest.java @@ -13,6 +13,7 @@ import org.hibernate.type.descriptor.java.JavaType; import org.hibernate.type.descriptor.java.PrimitiveByteArrayJavaType; import org.hibernate.type.format.FormatMapper; +import org.hibernate.type.format.jackson.Jackson3XmlFormatMapper; import org.hibernate.type.format.jackson.JacksonXmlFormatMapper; import org.hibernate.type.format.jaxb.JaxbXmlFormatMapper; import org.hibernate.type.internal.ParameterizedTypeImpl; @@ -61,7 +62,7 @@ public void injectSessionFactoryScope(SessionFactoryScope scope) { } private static Stream formatMappers() { - return Stream.of( new JaxbXmlFormatMapper( false ), new JacksonXmlFormatMapper( false ) ) + return Stream.of( new JaxbXmlFormatMapper( false ), new JacksonXmlFormatMapper( false ), new Jackson3XmlFormatMapper( false ) ) .map( Arguments::of ); } diff --git a/settings.gradle b/settings.gradle index 03a875daf7bb..3f5bacb04876 100644 --- a/settings.gradle +++ b/settings.gradle @@ -83,6 +83,7 @@ dependencyResolutionManagement { def hibernateModelsVersion = version "hibernateModels", "1.0.1" def jandexVersion = version "jandex", "3.3.2" def jacksonVersion = version "jackson", "2.19.4" + def jackson3Version = version "jackson3", "3.0.3" def jbossLoggingVersion = version "jbossLogging", "3.6.1.Final" def jbossLoggingToolVersion = version "jbossLoggingTool", "3.0.4.Final" @@ -119,6 +120,9 @@ dependencyResolutionManagement { library( "jacksonXml", "com.fasterxml.jackson.dataformat", "jackson-dataformat-xml" ).versionRef( jacksonVersion ) library( "jacksonJsr310", "com.fasterxml.jackson.datatype", "jackson-datatype-jsr310" ).versionRef( jacksonVersion ) + library( "jackson3", "tools.jackson.core", "jackson-databind" ).versionRef( jackson3Version ) + library( "jackson3Xml", "tools.jackson.dataformat", "jackson-dataformat-xml" ).versionRef( jackson3Version ) + library( "agroal", "io.agroal", "agroal-api" ).versionRef( agroalVersion ) library( "agroalPool", "io.agroal", "agroal-pool" ).versionRef( agroalVersion ) library( "c3p0", "com.mchange", "c3p0" ).versionRef( c3poVersion )