From e8b8b34078ce81e388e84aa30caa604098a2a149 Mon Sep 17 00:00:00 2001 From: JavaNo0b <98101954+JavaNo0b@users.noreply.github.com> Date: Tue, 6 May 2025 19:26:47 +0900 Subject: [PATCH 1/5] Add performance warning to RedisOperations#keys() Javadoc Signed-off-by: JavaNo0b <98101954+JavaNo0b@users.noreply.github.com> --- .../data/redis/core/RedisOperations.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/springframework/data/redis/core/RedisOperations.java b/src/main/java/org/springframework/data/redis/core/RedisOperations.java index 73ca9fe9b6..054aeb845c 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisOperations.java +++ b/src/main/java/org/springframework/data/redis/core/RedisOperations.java @@ -264,11 +264,14 @@ public interface RedisOperations { DataType type(@NonNull K key); /** - * Find all keys matching the given {@code pattern}. - * - * @param pattern must not be {@literal null}. - * @return {@literal null} when used in pipeline / transaction. - * @see Redis Documentation: KEYS + * Retrieve keys matching the given pattern via {@code KEYS} command. + *

+ * Note: This command scans the entire keyspace and may cause performance issues + * in production environments. Prefer using {@link #scan(ScanOptions)} for large datasets. + * + * @param pattern key pattern + * @return set of matching keys, or {@literal null} when used in pipeline / transaction + * @see Redis KEYS command */ Set<@NonNull K> keys(@NonNull K pattern); From a7b8896417a8e0ef409fada36b9c7e30489dbbab Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Fri, 30 May 2025 07:59:22 +0200 Subject: [PATCH 2/5] Update javadoc. Original Pull Request: #3142 --- .../data/redis/connection/ReactiveKeyCommands.java | 7 ++++--- .../data/redis/connection/RedisKeyCommands.java | 5 ++++- .../data/redis/connection/StringRedisConnection.java | 5 ++++- .../springframework/data/redis/core/ClusterOperations.java | 5 ++++- .../data/redis/core/ReactiveRedisOperations.java | 7 ++++--- .../springframework/data/redis/core/RedisOperations.java | 6 +++--- 6 files changed, 23 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java b/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java index 69427921d0..64f145afbd 100644 --- a/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java @@ -242,9 +242,10 @@ default Mono touch(Collection keys) { Flux, Long>> touch(Publisher> keys); /** - * Find all keys matching the given {@literal pattern}.
- * It is recommended to use {@link #scan(ScanOptions)} to iterate over the keyspace as {@link #keys(ByteBuffer)} is a - * non-interruptible and expensive Redis operation. + * Retrieve all keys matching the given pattern via {@code KEYS} command. + *

+ * IMPORTANT: This command is non-interruptible and scans the entire keyspace which may cause + * performance issues. Consider {@link #scan(ScanOptions)} for large datasets. * * @param pattern must not be {@literal null}. * @return diff --git a/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java index 77046f4d87..938aea1d4d 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisKeyCommands.java @@ -119,7 +119,10 @@ default Boolean exists(byte @NonNull [] key) { Long touch(byte @NonNull [] @NonNull... keys); /** - * Find all keys matching the given {@code pattern}. + * Retrieve all keys matching the given pattern. + *

+ * IMPORTANT: The {@literal KEYS} command is non-interruptible and scans the entire keyspace which + * may cause performance issues. Consider {@link #scan(ScanOptions)} for large datasets. * * @param pattern must not be {@literal null}. * @return empty {@link Set} if no match found. {@literal null} when used in pipeline / transaction. diff --git a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java index 3eae672f4d..c29b9c1671 100644 --- a/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/StringRedisConnection.java @@ -185,7 +185,10 @@ interface StringTuple extends Tuple { Long touch(@NonNull String @NonNull... keys); /** - * Find all keys matching the given {@code pattern}. + * Retrieve all keys matching the given pattern via {@code KEYS} command. + *

+ * IMPORTANT: This command is non-interruptible and scans the entire keyspace which may cause + * performance issues. Consider {@link #scan(ScanOptions)} for large datasets. * * @param pattern must not be {@literal null}. * @return diff --git a/src/main/java/org/springframework/data/redis/core/ClusterOperations.java b/src/main/java/org/springframework/data/redis/core/ClusterOperations.java index a30d085928..64eb7ef297 100644 --- a/src/main/java/org/springframework/data/redis/core/ClusterOperations.java +++ b/src/main/java/org/springframework/data/redis/core/ClusterOperations.java @@ -41,7 +41,10 @@ public interface ClusterOperations { /** - * Get all keys located at given node. + * Retrieve all keys located at given node matching the given pattern. + *

+ * IMPORTANT: The {@literal KEYS} command is non-interruptible and scans the entire keyspace which + * may cause performance issues. * * @param node must not be {@literal null}. * @param pattern diff --git a/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java b/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java index 686277f0df..55f126b636 100644 --- a/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java +++ b/src/main/java/org/springframework/data/redis/core/ReactiveRedisOperations.java @@ -264,9 +264,10 @@ default Mono>> listenToPatternLater(String... Mono type(K key); /** - * Find all keys matching the given {@code pattern}.
- * IMPORTANT: It is recommended to use {@link #scan()} to iterate over the keyspace as - * {@link #keys(Object)} is a non-interruptible and expensive Redis operation. + * Retrieve all keys matching the given pattern via {@code KEYS} command. + *

+ * IMPORTANT: This command is non-interruptible and scans the entire keyspace which may cause + * performance issues. Consider {@link #scan(ScanOptions)} for large datasets. * * @param pattern must not be {@literal null}. * @return the {@link Flux} emitting matching keys one by one. diff --git a/src/main/java/org/springframework/data/redis/core/RedisOperations.java b/src/main/java/org/springframework/data/redis/core/RedisOperations.java index 054aeb845c..ba274158e7 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisOperations.java +++ b/src/main/java/org/springframework/data/redis/core/RedisOperations.java @@ -264,10 +264,10 @@ public interface RedisOperations { DataType type(@NonNull K key); /** - * Retrieve keys matching the given pattern via {@code KEYS} command. + * Retrieve all keys matching the given pattern via {@code KEYS} command. *

- * Note: This command scans the entire keyspace and may cause performance issues - * in production environments. Prefer using {@link #scan(ScanOptions)} for large datasets. + * IMPORTANT: This command is non-interruptible and scans the entire keyspace which may cause + * performance issues. Consider {@link #scan(ScanOptions)} for large datasets. * * @param pattern key pattern * @return set of matching keys, or {@literal null} when used in pipeline / transaction From d51f80d5cddce3be88b733d4eb01b69a5050ddfd Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Fri, 6 Jun 2025 08:43:39 +0200 Subject: [PATCH 3/5] Prepare issue branch. --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index 6e25f1d07b..ca86beb798 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-redis - 4.0.0-SNAPSHOT + 4.0.x-GH-3154-SNAPSHOT Spring Data Redis Spring Data module for Redis From 03521d45e71bb889dc1387dc43372334b5072274 Mon Sep 17 00:00:00 2001 From: Christoph Strobl Date: Fri, 6 Jun 2025 14:42:41 +0200 Subject: [PATCH 4/5] Add support for Jackson 3-based `HashMapper` and `RedisSerializer`. We now support Jackson 3 through a separate Jackson3HashMapper, Jackson3JsonRedisSerializer, and GenericJackson3JsonRedisSerializer. Jackson 3 uses different defaults than Jackson 2 did therefore, we also provide configuration variants that reflect Jackson 2 behavior for smoother upgrade paths. Original pull request: #3168 Closes #3154 --- pom.xml | 14 + .../ROOT/pages/redis/hash-mappers.adoc | 87 ++- .../redis/redis-repositories/mapping.adoc | 8 +- .../ROOT/pages/redis/redis-streams.adoc | 2 +- .../modules/ROOT/pages/redis/template.adoc | 2 +- .../data/redis/hash/Jackson2HashMapper.java | 2 + .../data/redis/hash/Jackson3HashMapper.java | 577 ++++++++++++++++ .../GenericJackson2JsonRedisSerializer.java | 2 + .../GenericJackson3JsonRedisSerializer.java | 499 +++++++++++++ .../Jackson2JsonRedisSerializer.java | 2 + .../Jackson3JsonRedisSerializer.java | 198 ++++++ .../serializer/Jackson3ObjectReader.java | 57 ++ .../serializer/Jackson3ObjectWriter.java | 54 ++ .../redis/serializer/JacksonObjectReader.java | 2 + .../redis/serializer/JacksonObjectWriter.java | 2 + .../core/AbstractOperationsTestParams.java | 8 + .../core/ReactiveOperationsTestParams.java | 6 + .../mapping/AbstractHashMapperTests.java | 1 + .../mapping/Jackson3CompatibilityTests.java | 59 ++ .../Jackson3FlatteningCompatibilityTests.java | 59 ++ ...Jackson3HashMapperFlatteningUnitTests.java | 63 ++ ...kson3HashMapperNonFlatteningUnitTests.java | 63 ++ .../mapping/Jackson3HashMapperUnitTests.java | 505 ++++++++++++++ ...cJackson3JsonRedisSerializerUnitTests.java | 653 ++++++++++++++++++ .../collections/CollectionTestParams.java | 15 + .../RedisPropertiesIntegrationTests.java | 11 + 26 files changed, 2943 insertions(+), 8 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java create mode 100644 src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java create mode 100644 src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java create mode 100644 src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java create mode 100644 src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java create mode 100644 src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java create mode 100644 src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java create mode 100644 src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java create mode 100644 src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java create mode 100644 src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java create mode 100644 src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java diff --git a/pom.xml b/pom.xml index ca86beb798..9423280078 100644 --- a/pom.xml +++ b/pom.xml @@ -149,6 +149,20 @@ true + + + tools.jackson.core + jackson-databind + true + + + + com.fasterxml.jackson.core + jackson-annotations + 3.0-rc5 + true + + commons-beanutils commons-beanutils diff --git a/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc b/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc index 334a2fd515..6f320d436b 100644 --- a/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc @@ -1,7 +1,7 @@ [[redis.hashmappers.root]] = Hash Mapping -Data can be stored by using various data structures within Redis. javadoc:org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer[] can convert objects in https://en.wikipedia.org/wiki/JSON[JSON] format. Ideally, JSON can be stored as a value by using plain keys. You can achieve a more sophisticated mapping of structured objects by using Redis hashes. Spring Data Redis offers various strategies for mapping data to hashes (depending on the use case): +Data can be stored by using various data structures within Redis. javadoc:org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer[] can convert objects in https://en.wikipedia.org/wiki/JSON[JSON] format. Ideally, JSON can be stored as a value by using plain keys. You can achieve a more sophisticated mapping of structured objects by using Redis hashes. Spring Data Redis offers various strategies for mapping data to hashes (depending on the use case): * Direct mapping, by using javadoc:org.springframework.data.redis.core.HashOperations[] and a xref:redis.adoc#redis:serializer[serializer] * Using xref:repositories.adoc[Redis Repositories] @@ -16,7 +16,8 @@ Multiple implementations are available: * javadoc:org.springframework.data.redis.hash.BeanUtilsHashMapper[] using Spring's {spring-framework-javadoc}/org/springframework/beans/BeanUtils.html[BeanUtils]. * javadoc:org.springframework.data.redis.hash.ObjectHashMapper[] using xref:redis/redis-repositories/mapping.adoc[Object-to-Hash Mapping]. -* <> using https://github.com/FasterXML/jackson[FasterXML Jackson]. +* <> using https://github.com/FasterXML/jackson[FasterXML Jackson 3]. +* <> (deprecated) using https://github.com/FasterXML/jackson[FasterXML Jackson 2]. The following example shows one way to implement hash mapping: @@ -50,9 +51,91 @@ public class HashMapping { } ---- +[[redis.hashmappers.jackson3]] +=== Jackson3HashMapper + +javadoc:org.springframework.data.redis.hash.Jackson3HashMapper[] provides Redis Hash mapping for domain objects by using https://github.com/FasterXML/jackson[FasterXML Jackson 3]. +`Jackson3HashMapper` can map top-level properties as Hash field names and, optionally, flatten the structure. +Simple types map to simple values. Complex types (nested objects, collections, maps, and so on) are represented as nested JSON. + +Flattening creates individual hash entries for all nested properties and resolves complex types into simple types, as far as possible. + +Consider the following class and the data structure it contains: + +[source,java] +---- +public class Person { + String firstname; + String lastname; + Address address; + Date date; + LocalDateTime localDateTime; +} + +public class Address { + String city; + String country; +} +---- + +The following table shows how the data in the preceding class would appear in normal mapping: + +.Normal Mapping +[width="80%",cols="<1,<2",options="header"] +|==== +|Hash Field +|Value + +|firstname +|`Jon` + +|lastname +|`Snow` + +|address +|`{ "city" : "Castle Black", "country" : "The North" }` + +|date +|1561543964015 + +|localDateTime +|`2018-01-02T12:13:14` +|==== + +The following table shows how the data in the preceding class would appear in flat mapping: + +.Flat Mapping +[width="80%",cols="<1,<2",options="header"] +|==== +|Hash Field +|Value + +|firstname +|`Jon` + +|lastname +|`Snow` + +|address.city +|`Castle Black` + +|address.country +|`The North` + +|date +|1561543964015 + +|localDateTime +|`2018-01-02T12:13:14` +|==== + +NOTE: Flattening requires all property names to not interfere with the JSON path. Using dots or brackets in map keys or as property names is not supported when you use flattening. The resulting hash cannot be mapped back into an Object. + [[redis.hashmappers.jackson2]] === Jackson2HashMapper +WARNING: Jackson 2 based implementations have been deprecated and are subject to removal in a subsequent release. + javadoc:org.springframework.data.redis.hash.Jackson2HashMapper[] provides Redis Hash mapping for domain objects by using https://github.com/FasterXML/jackson[FasterXML Jackson]. `Jackson2HashMapper` can map top-level properties as Hash field names and, optionally, flatten the structure. Simple types map to simple values. Complex types (nested objects, collections, maps, and so on) are represented as nested JSON. diff --git a/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc b/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc index fb1a08c0f8..11ea76b302 100644 --- a/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc @@ -92,11 +92,11 @@ The following example shows two sample byte array converters: @WritingConverter public class AddressToBytesConverter implements Converter { - private final Jackson2JsonRedisSerializer

serializer; + private final Jackson3JsonRedisSerializer
serializer; public AddressToBytesConverter() { - serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer = new Jackson3JsonRedisSerializer
(Address.class); serializer.setObjectMapper(new ObjectMapper()); } @@ -109,11 +109,11 @@ public class AddressToBytesConverter implements Converter { @ReadingConverter public class BytesToAddressConverter implements Converter { - private final Jackson2JsonRedisSerializer
serializer; + private final Jackson3JsonRedisSerializer
serializer; public BytesToAddressConverter() { - serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer = new Jackson3JsonRedisSerializer
(Address.class); serializer.setObjectMapper(new ObjectMapper()); } diff --git a/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc b/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc index 25d916f637..0d71ee07f5 100644 --- a/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc @@ -291,7 +291,7 @@ You may provide a `HashMapper` suitable for your requirements when obtaining `St [source,java] ---- redisTemplate() - .opsForStream(new Jackson2HashMapper(true)) + .opsForStream(new Jackson3HashMapper(true)) .add(record); <1> ---- <1> XADD user-logon * "firstname" "night" "@class" "com.example.User" "lastname" "angel" diff --git a/src/main/antora/modules/ROOT/pages/redis/template.adoc b/src/main/antora/modules/ROOT/pages/redis/template.adoc index 05af2e789b..30f89e88e3 100644 --- a/src/main/antora/modules/ROOT/pages/redis/template.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/template.adoc @@ -365,7 +365,7 @@ Multiple implementations are available (including two that have been already men * javadoc:org.springframework.data.redis.serializer.JdkSerializationRedisSerializer[], which is used by default for javadoc:org.springframework.data.redis.cache.RedisCache[] and javadoc:org.springframework.data.redis.core.RedisTemplate[]. * the `StringRedisSerializer`. -However, one can use `OxmSerializer` for Object/XML mapping through Spring {spring-framework-docs}/data-access.html#oxm[OXM] support or javadoc:org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer[] or javadoc:org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer[] for storing data in https://en.wikipedia.org/wiki/JSON[JSON] format. +However, one can use `OxmSerializer` for Object/XML mapping through Spring {spring-framework-docs}/data-access.html#oxm[OXM] support or javadoc:org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer[] or javadoc:org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializer[] for storing data in https://en.wikipedia.org/wiki/JSON[JSON] format. Do note that the storage format is not limited only to values. It can be used for keys, values, or hashes without any restrictions. diff --git a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java index 6c2aa8181b..0ef9314be7 100644 --- a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java +++ b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java @@ -143,7 +143,9 @@ * @author Mark Paluch * @author John Blum * @since 1.8 + * @deprecated since 4.0 */ +@Deprecated(since = "4.0", forRemoval = true) public class Jackson2HashMapper implements HashMapper { private static final boolean SOURCE_VERSION_PRESENT = diff --git a/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java b/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java new file mode 100644 index 0000000000..250564f7b8 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java @@ -0,0 +1,577 @@ +/* + * Copyright 2016-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.hash; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.core.Version; +import tools.jackson.databind.DefaultTyping; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueDeserializer; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.cfg.MapperBuilder; +import tools.jackson.databind.deser.jdk.JavaUtilCalendarDeserializer; +import tools.jackson.databind.deser.jdk.JavaUtilDateDeserializer; +import tools.jackson.databind.deser.jdk.NumberDeserializers.BigDecimalDeserializer; +import tools.jackson.databind.deser.jdk.NumberDeserializers.BigIntegerDeserializer; +import tools.jackson.databind.deser.std.StdDeserializer; +import tools.jackson.databind.exc.MismatchedInputException; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.json.JsonMapper.Builder; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.TypeDeserializer; +import tools.jackson.databind.jsontype.TypeSerializer; +import tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer; +import tools.jackson.databind.module.SimpleDeserializers; +import tools.jackson.databind.module.SimpleSerializers; +import tools.jackson.databind.ser.Serializers; +import tools.jackson.databind.ser.jdk.JavaUtilCalendarSerializer; +import tools.jackson.databind.ser.jdk.JavaUtilDateSerializer; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Collection; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; +import java.util.TimeZone; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.springframework.data.mapping.MappingException; +import org.springframework.data.redis.support.collections.CollectionUtils; +import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; +import org.springframework.util.StringUtils; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +/** + * {@link ObjectMapper} based {@link HashMapper} implementation that allows flattening. Given an entity {@code Person} + * with an {@code Address} like below the flattening will create individual hash entries for all nested properties and + * resolve complex types into simple types, as far as possible. + *

+ * Flattening requires all property names to not interfere with JSON paths. Using dots or brackets in map keys or as + * property names is not supported using flattening. The resulting hash cannot be mapped back into an Object. + *

Example

+ * + *
+ * class Person {
+ * 	String firstname;
+ * 	String lastname;
+ * 	Address address;
+ * 	Date date;
+ * 	LocalDateTime localDateTime;
+ * }
+ *
+ * class Address {
+ * 	String city;
+ * 	String country;
+ * }
+ * 
+ * + *

Normal

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Hash fieldValue
firstnameJon
lastnameSnow
address{ "city" : "Castle Black", "country" : "The North" }
date1561543964015
localDateTime2018-01-02T12:13:14
+ *

Flat

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Hash fieldValue
firstnameJon
lastnameSnow
address.cityCastle Black
address.countryThe North
date1561543964015
localDateTime2018-01-02T12:13:14
+ * + * @author Christoph Strobl + * @since 4.0 + */ +public class Jackson3HashMapper implements HashMapper { + + private static final boolean SOURCE_VERSION_PRESENT = ClassUtils.isPresent("javax.lang.model.SourceVersion", + Jackson3HashMapper.class.getClassLoader()); + + private final ObjectMapper typingMapper; + private final ObjectMapper untypedMapper; + private final boolean flatten; + + public Jackson3HashMapper( + Consumer>> jsonMapperBuilder, + boolean flatten) { + this(((Supplier) () -> { + Builder builder = JsonMapper.builder(); + jsonMapperBuilder.accept(builder); + return builder.build(); + }).get(), flatten); + } + + public static void preconfigure(MapperBuilder> builder) { + builder.findAndAddModules().addModules(new HashMapperModule()) + .activateDefaultTypingAsProperty(BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class) + .allowIfSubType((ctx, clazz) -> true).build(), DefaultTyping.NON_FINAL, "@class") + .configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .changeDefaultPropertyInclusion(value -> value.withValueInclusion(Include.NON_NULL)); + } + + /** + * Creates new {@link Jackson3HashMapper} initialized with a custom Jackson {@link ObjectMapper}. + * + * @param mapper Jackson {@link ObjectMapper} used to de/serialize hashed {@link Object objects}; must not be + * {@literal null}. + * @param flatten boolean used to configure whether JSON de/serialized {@link Object} properties will be un/flattened + * using {@literal dot notation}, or whether to retain the hierarchical node structure created by Jackson. + */ + public Jackson3HashMapper(ObjectMapper mapper, boolean flatten) { + + Assert.notNull(mapper, "Mapper must not be null"); + + this.flatten = flatten; + this.typingMapper = mapper; + this.untypedMapper = JsonMapper.shared(); + } + + @Override + @SuppressWarnings("unchecked") + public Map toHash(Object source) { + + JsonNode tree = this.typingMapper.valueToTree(source); + return this.flatten ? flattenMap(tree.properties()) : this.untypedMapper.convertValue(tree, Map.class); + } + + @Override + @SuppressWarnings("all") + public Object fromHash(Map hash) { + + try { + if (this.flatten) { + + Map unflattenedHash = doUnflatten(hash); + byte[] unflattenedHashedBytes = this.untypedMapper.writeValueAsBytes(unflattenedHash); + Object hashedObject = this.typingMapper.reader().forType(Object.class).readValue(unflattenedHashedBytes); + + return hashedObject; + } + + return this.typingMapper.treeToValue(this.untypedMapper.valueToTree(hash), Object.class); + + } catch (Exception ex) { + throw new MappingException(ex.getMessage(), ex); + } + } + + @SuppressWarnings("unchecked") + private Map doUnflatten(Map source) { + + Map result = org.springframework.util.CollectionUtils.newLinkedHashMap(source.size()); + Set treatSeparate = org.springframework.util.CollectionUtils.newLinkedHashSet(source.size()); + + for (Entry entry : source.entrySet()) { + + String key = entry.getKey(); + String[] keyParts = key.split("\\."); + + if (keyParts.length == 1 && isNotIndexed(keyParts[0])) { + result.put(key, entry.getValue()); + } else if (keyParts.length == 1 && isIndexed(keyParts[0])) { + + String indexedKeyName = keyParts[0]; + String nonIndexedKeyName = stripIndex(indexedKeyName); + + int index = getIndex(indexedKeyName); + + if (result.containsKey(nonIndexedKeyName)) { + addValueToTypedListAtIndex((List) result.get(nonIndexedKeyName), index, entry.getValue()); + } else { + result.put(nonIndexedKeyName, createTypedListWithValue(index, entry.getValue())); + } + } else { + treatSeparate.add(keyParts[0]); + } + } + + for (String partial : treatSeparate) { + + Map newSource = new LinkedHashMap<>(); + + // Copies all nested, dot properties from the source Map to the new Map beginning from + // the next nested (dot) property + for (Entry entry : source.entrySet()) { + String key = entry.getKey(); + if (key.startsWith(partial)) { + String keyAfterDot = key.substring(partial.length() + 1); + newSource.put(keyAfterDot, entry.getValue()); + } + } + + if (isNonNestedIndexed(partial)) { + + String nonIndexPartial = stripIndex(partial); + int index = getIndex(partial); + + if (result.containsKey(nonIndexPartial)) { + addValueToTypedListAtIndex((List) result.get(nonIndexPartial), index, doUnflatten(newSource)); + } else { + result.put(nonIndexPartial, createTypedListWithValue(index, doUnflatten(newSource))); + } + } else { + result.put(partial, doUnflatten(newSource)); + } + } + + return result; + } + + private boolean isIndexed(String value) { + return value.indexOf('[') > -1; + } + + private boolean isNotIndexed(String value) { + return !isIndexed(value); + } + + private boolean isNonNestedIndexed(String value) { + return value.endsWith("]"); + } + + private int getIndex(String indexedValue) { + return Integer.parseInt(indexedValue.substring(indexedValue.indexOf('[') + 1, indexedValue.length() - 1)); + } + + private String stripIndex(String indexedValue) { + + int indexOfLeftBracket = indexedValue.indexOf("["); + + return indexOfLeftBracket > -1 ? indexedValue.substring(0, indexOfLeftBracket) : indexedValue; + } + + private Map flattenMap(Set> source) { + + Map resultMap = new HashMap<>(); + doFlatten("", source, resultMap); + return resultMap; + } + + private void doFlatten(String propertyPrefix, Set> inputMap, Map resultMap) { + + if (StringUtils.hasText(propertyPrefix)) { + propertyPrefix = propertyPrefix + "."; + } + + for (Entry entry : inputMap) { + flattenElement(propertyPrefix + entry.getKey(), entry.getValue(), resultMap); + } + } + + private void flattenElement(String propertyPrefix, Object source, Map resultMap) { + + if (!(source instanceof JsonNode element)) { + resultMap.put(propertyPrefix, source); + return; + } + + if (element.isArray()) { + + Iterator nodes = element.values().iterator(); + + while (nodes.hasNext()) { + + JsonNode currentNode = nodes.next(); + + if (currentNode.isArray()) { + flattenCollection(propertyPrefix, currentNode.values(), resultMap); + } else if (nodes.hasNext() && mightBeJavaType(currentNode)) { + + JsonNode next = nodes.next(); + + if (next.isArray()) { + flattenCollection(propertyPrefix, next.values(), resultMap); + } + if (currentNode.asString().equals("java.util.Date")) { + resultMap.put(propertyPrefix, next.asString()); + break; + } + if (next.isNumber()) { + resultMap.put(propertyPrefix, next.numberValue()); + break; + } + if (next.isString()) { + resultMap.put(propertyPrefix, next.stringValue()); + break; + } + if (next.isBoolean()) { + resultMap.put(propertyPrefix, next.booleanValue()); + break; + } + if (next.isBinary()) { + + try { + resultMap.put(propertyPrefix, next.binaryValue()); + } catch (Exception ex) { + throw new IllegalStateException("Cannot read binary value '%s'".formatted(propertyPrefix), ex); + } + + break; + } + } + } + } else if (element.isObject()) { + doFlatten(propertyPrefix, element.properties(), resultMap); + } else { + + switch (element.getNodeType()) { + case STRING -> resultMap.put(propertyPrefix, element.stringValue()); + case NUMBER -> resultMap.put(propertyPrefix, element.numberValue()); + case BOOLEAN -> resultMap.put(propertyPrefix, element.booleanValue()); + case BINARY -> { + try { + resultMap.put(propertyPrefix, element.binaryValue()); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + default -> + resultMap.put(propertyPrefix, new DirectFieldAccessFallbackBeanWrapper(element).getPropertyValue("_value")); + } + } + } + + private boolean mightBeJavaType(JsonNode node) { + + String textValue = node.asString(); + + if (!SOURCE_VERSION_PRESENT) { + return Arrays.asList("java.util.Date", "java.math.BigInteger", "java.math.BigDecimal").contains(textValue); + } + + return javax.lang.model.SourceVersion.isName(textValue); + } + + private void flattenCollection(String propertyPrefix, Collection list, Map resultMap) { + + Iterator iterator = list.iterator(); + for (int counter = 0; iterator.hasNext(); counter++) { + JsonNode element = iterator.next(); + flattenElement(propertyPrefix + "[" + counter + "]", element, resultMap); + } + } + + @SuppressWarnings("unchecked") + private void addValueToTypedListAtIndex(List listWithTypeHint, int index, Object value) { + + List valueList = (List) listWithTypeHint.get(1); + + if (index >= valueList.size()) { + int initialCapacity = index + 1; + List newValueList = new ArrayList<>(initialCapacity); + Collections.copy(CollectionUtils.initializeList(newValueList, initialCapacity), valueList); + listWithTypeHint.set(1, newValueList); + valueList = newValueList; + } + + valueList.set(index, value); + } + + private List createTypedListWithValue(int index, Object value) { + + int initialCapacity = index + 1; + + List valueList = CollectionUtils.initializeList(new ArrayList<>(initialCapacity), initialCapacity); + valueList.set(index, value); + + List listWithTypeHint = new ArrayList<>(); + listWithTypeHint.add(ArrayList.class.getName()); + listWithTypeHint.add(valueList); + + return listWithTypeHint; + } + + private static class HashMapperModule extends JacksonModule { + + @Override + public String getModuleName() { + return "spring-data-redis-hash-mapper-module"; + } + + @Override + public Version version() { + return new Version(4, 0, 0, null, "org.springframework.data", "spring-data-redis"); + } + + @Override + public void setupModule(SetupContext context) { + + List> valueSerializers = new ArrayList<>(); + valueSerializers.add(new JavaUtilDateSerializer(true, null) { + @Override + public void serializeWithType(Date value, JsonGenerator g, SerializationContext ctxt, TypeSerializer typeSer) + throws JacksonException { + serialize(value, g, ctxt); + } + }); + valueSerializers.add(new UTCCalendarSerializer()); + + Serializers serializers = new SimpleSerializers(valueSerializers); + context.addSerializers(serializers); + + Map, ValueDeserializer> valueDeserializers = new LinkedHashMap<>(); + valueDeserializers.put(java.util.Calendar.class, + new UntypedFallbackDeserializer<>(new UntypedUTCCalendarDeserializer())); + valueDeserializers.put(java.util.Date.class, new UntypedFallbackDeserializer<>(new JavaUtilDateDeserializer())); + valueDeserializers.put(BigInteger.class, new UntypedFallbackDeserializer<>(new BigIntegerDeserializer())); + valueDeserializers.put(BigDecimal.class, new UntypedFallbackDeserializer<>(new BigDecimalDeserializer())); + + context.addDeserializers(new SimpleDeserializers(valueDeserializers)); + } + + } + + static class UntypedFallbackDeserializer extends StdDeserializer { + + private final StdDeserializer delegate; + + protected UntypedFallbackDeserializer(StdDeserializer delegate) { + super(Object.class); + this.delegate = delegate; + } + + @Override + public Object deserializeWithType(JsonParser p, DeserializationContext ctxt, TypeDeserializer typeDeserializer) + throws JacksonException { + + if (!(typeDeserializer instanceof AsPropertyTypeDeserializer asPropertySerializer)) { + return super.deserializeWithType(p, ctxt, typeDeserializer); + } + + try { + return super.deserializeWithType(p, ctxt, typeDeserializer); + } catch (MismatchedInputException e) { + if (!asPropertySerializer.baseType().isTypeOrSuperTypeOf(delegate.handledType())) { + throw e; + } + } + + return deserialize(p, ctxt); + + } + + @Override + @SuppressWarnings("unchecked") + public T deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { + return (T) delegate.deserialize(p, ctxt); + } + } + + static class UTCCalendarSerializer extends JavaUtilCalendarSerializer { + + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + + @Override + public void serialize(Calendar value, JsonGenerator g, SerializationContext provider) throws JacksonException { + + Calendar utc = Calendar.getInstance(); + utc.setTimeInMillis(value.getTimeInMillis()); + utc.setTimeZone(UTC); + super.serialize(utc, g, provider); + } + + @Override + public void serializeWithType(Calendar value, JsonGenerator g, SerializationContext ctxt, TypeSerializer typeSer) + throws JacksonException { + serialize(value, g, ctxt); + } + } + + static class UntypedUTCCalendarDeserializer extends JavaUtilCalendarDeserializer { + + private static final TimeZone UTC = TimeZone.getTimeZone("UTC"); + + @Override + public Calendar deserialize(JsonParser p, DeserializationContext ctxt) throws JacksonException { + + Calendar cal = super.deserialize(p, ctxt); + + Calendar utc = Calendar.getInstance(UTC); + utc.setTimeInMillis(cal.getTimeInMillis()); + utc.setTimeZone(TimeZone.getTimeZone(ZoneId.systemDefault())); + + return utc; + } + } +} diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java index fcf2f9eff0..3daec289bd 100644 --- a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java @@ -72,7 +72,9 @@ * @see org.springframework.data.redis.serializer.JacksonObjectWriter * @see com.fasterxml.jackson.databind.ObjectMapper * @since 1.6 + * @deprecated since 4.0 in favor of {@link GenericJackson3JsonRedisSerializer} */ +@Deprecated(since = "4.0", forRemoval = true) public class GenericJackson2JsonRedisSerializer implements RedisSerializer { private final JacksonObjectReader reader; diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java new file mode 100644 index 0000000000..278032ca95 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java @@ -0,0 +1,499 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import tools.jackson.core.JacksonException; +import tools.jackson.core.JsonGenerator; +import tools.jackson.core.JsonParser; +import tools.jackson.core.JsonToken; +import tools.jackson.core.Version; +import tools.jackson.core.exc.JacksonIOException; +import tools.jackson.databind.DefaultTyping; +import tools.jackson.databind.DeserializationConfig; +import tools.jackson.databind.DeserializationContext; +import tools.jackson.databind.DeserializationFeature; +import tools.jackson.databind.JacksonModule; +import tools.jackson.databind.JavaType; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.SerializationContext; +import tools.jackson.databind.ValueSerializer; +import tools.jackson.databind.deser.jackson.BaseNodeDeserializer; +import tools.jackson.databind.deser.jackson.JsonNodeDeserializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.json.JsonMapper.Builder; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.PolymorphicTypeValidator; +import tools.jackson.databind.jsontype.TypeDeserializer; +import tools.jackson.databind.jsontype.TypeResolverBuilder; +import tools.jackson.databind.jsontype.impl.StdTypeResolverBuilder; +import tools.jackson.databind.module.SimpleSerializers; +import tools.jackson.databind.ser.std.StdSerializer; +import tools.jackson.databind.type.TypeFactory; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Supplier; + +import org.jspecify.annotations.Nullable; +import org.springframework.cache.support.NullValue; +import org.springframework.data.util.Lazy; +import org.springframework.lang.Contract; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +/** + * Generic Jackson 2-based {@link RedisSerializer} that maps {@link Object objects} to and from {@literal JSON}. + *

+ * {@literal JSON} reading and writing can be customized by configuring a {@link Jackson3ObjectReader} and + * {@link Jackson3ObjectWriter}. + * + * @author Christoph Strobl + * @see Jackson3ObjectReader + * @see Jackson3ObjectWriter + * @see ObjectMapper + * @since 4.0 + */ +public class GenericJackson3JsonRedisSerializer implements RedisSerializer { + + private final Jackson3ObjectReader reader; + + private final Jackson3ObjectWriter writer; + + private final Lazy defaultTypingEnabled; + + private final Lazy lazyTypeHintPropertyName; + + private final ObjectMapper mapper; + + private final TypeResolver typeResolver; + + // internal shortcut for testing + GenericJackson3JsonRedisSerializer() { + this(((Supplier) () -> { + Builder builder = JsonMapper.builder(); + new JsonMapperConfigurer(builder).unsafeDefaultTyping().enableSpringCacheNullValueSupport(); + return builder.build(); + }).get()); + } + + /** + * Prepare a new {@link GenericJackson3JsonRedisSerializer} instance. + * + * @param configurer configuration helper callback to apply customizations to the {@link JsonMapper.Builder json + * mapper}. + * @return new instance of {@link GenericJackson3JsonRedisSerializer}. + */ + public static GenericJackson3JsonRedisSerializer create(Consumer configurer) { + + Builder configurationBuilder = JsonMapper.builder(); + JsonMapperConfigurer configHelper = new JsonMapperConfigurer(configurationBuilder); + configurer.accept(configHelper); + return new GenericJackson3JsonRedisSerializer(configurationBuilder.build(), configHelper.getReader(), + configHelper.getWriter()); + } + + /** + * Create a {@link GenericJackson3JsonRedisSerializer} with a custom-configured {@link ObjectMapper}. + * + * @param mapper must not be {@literal null}. + */ + public GenericJackson3JsonRedisSerializer(ObjectMapper mapper) { + this(mapper, Jackson3ObjectReader.create(), Jackson3ObjectWriter.create()); + } + + /** + * Create a {@link GenericJackson3JsonRedisSerializer} with a custom-configured {@link ObjectMapper} considering + * potential Object/{@link Jackson3ObjectReader -reader} and {@link Jackson3ObjectWriter -writer}. + * + * @param mapper must not be {@literal null}. + * @param reader the {@link Jackson3ObjectReader} function to read objects using {@link ObjectMapper}. + * @param writer the {@link Jackson3ObjectWriter} function to write objects using {@link ObjectMapper}. + */ + public GenericJackson3JsonRedisSerializer(ObjectMapper mapper, Jackson3ObjectReader reader, + Jackson3ObjectWriter writer) { + + Assert.notNull(mapper, "ObjectMapper must not be null"); + Assert.notNull(reader, "Reader must not be null"); + Assert.notNull(writer, "Writer must not be null"); + + this.mapper = mapper; + this.reader = reader; + this.writer = writer; + + this.defaultTypingEnabled = Lazy.of(() -> mapper.serializationConfig().getDefaultTyper(null) != null); + this.lazyTypeHintPropertyName = newLazyTypeHintPropertyName(mapper, this.defaultTypingEnabled); + + this.typeResolver = newTypeResolver(mapper, this.lazyTypeHintPropertyName); + } + + @Override + public byte[] serialize(@Nullable Object value) throws SerializationException { + + if (value == null) { + return SerializationUtils.EMPTY_ARRAY; + } + + try { + return writer.write(mapper, value); + } catch (IOException | JacksonIOException ex) { + throw new SerializationException("Could not write JSON: %s".formatted(ex.getMessage()), ex); + } + } + + @Override + @Contract("null -> null") + public @Nullable Object deserialize(byte @Nullable [] source) throws SerializationException { + return deserialize(source, Object.class); + } + + /** + * Deserialized the array of bytes containing {@literal JSON} as an {@link Object} of the given, required {@link Class + * type}. + * + * @param source array of bytes containing the {@literal JSON} to deserialize; can be {@literal null}. + * @param type {@link Class type} of {@link Object} from which the {@literal JSON} will be deserialized; must not be + * {@literal null}. + * @return {@literal null} for an empty source, or an {@link Object} of the given {@link Class type} deserialized from + * the array of bytes containing {@literal JSON}. + * @throws IllegalArgumentException if the given {@link Class type} is {@literal null}. + * @throws SerializationException if the array of bytes cannot be deserialized as an instance of the given + * {@link Class type} + */ + @SuppressWarnings("unchecked") + @Contract("null, _ -> null") + public @Nullable T deserialize(byte @Nullable [] source, Class type) throws SerializationException { + + Assert.notNull(type, "Deserialization type must not be null;" + + " Please provide Object.class to make use of Jackson2 default typing."); + + if (SerializationUtils.isEmpty(source)) { + return null; + } + + try { + return (T) reader.read(mapper, source, resolveType(source, type)); + } catch (Exception ex) { + throw new SerializationException("Could not read JSON:%s ".formatted(ex.getMessage()), ex); + } + } + + protected JavaType resolveType(byte[] source, Class type) throws IOException { + + if (!type.equals(Object.class) || !defaultTypingEnabled.get()) { + return typeResolver.constructType(type); + } + + return typeResolver.resolveType(source, type); + } + + private static TypeResolver newTypeResolver(ObjectMapper mapper, Lazy typeHintPropertyName) { + + Lazy lazyTypeFactory = Lazy.of(mapper::getTypeFactory); + return new TypeResolver(mapper, lazyTypeFactory, typeHintPropertyName); + } + + private static Lazy newLazyTypeHintPropertyName(ObjectMapper mapper, Lazy defaultTypingEnabled) { + + Lazy configuredTypeDeserializationPropertyName = getConfiguredTypeDeserializationPropertyName(mapper); + + Lazy resolvedLazyTypeHintPropertyName = Lazy + .of(() -> defaultTypingEnabled.get() ? configuredTypeDeserializationPropertyName.get() : null); + + return resolvedLazyTypeHintPropertyName.or("@class"); + } + + private static Lazy getConfiguredTypeDeserializationPropertyName(ObjectMapper mapper) { + + return Lazy.of(() -> { + + DeserializationConfig deserializationConfig = mapper.deserializationConfig(); + + JavaType objectType = mapper.getTypeFactory().constructType(Object.class); + + TypeDeserializer typeDeserializer = deserializationConfig.getDefaultTyper(null) + .buildTypeDeserializer(mapper._deserializationContext(), objectType, Collections.emptyList()); + + return typeDeserializer.getPropertyName(); + }); + } + + /** + * {@link JsonMapperConfigurer} wraps around a {@link JsonMapper.Builder} providing dedicated methods to configure + * aspects like {@link NullValue} serialization strategy for the resulting {@link ObjectMapper} to be used with + * {@link GenericJackson3JsonRedisSerializer} as well as potential Object/{@link Jackson3ObjectReader -reader} and + * {@link Jackson3ObjectWriter -writer} settings. + * + * @since 4.0 + */ + public static class JsonMapperConfigurer { + + private final JsonMapper.Builder builder; + private @Nullable Jackson3ObjectWriter writer; + private @Nullable Jackson3ObjectReader reader; + + public JsonMapperConfigurer(Builder builder) { + this.builder = builder; + } + + /** + * Registers a {@link StdSerializer} capable of serializing Spring Cache {@link NullValue} using the mappers default + * type property. Please make sure to active + * {@link JsonMapper.Builder#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) + * default typing} accordingly. + * + * @return this. + */ + @Contract("-> this") + public JsonMapperConfigurer enableSpringCacheNullValueSupport() { + + builder.addModules(new GenericJackson3RedisSerializerModule(() -> { + TypeResolverBuilder defaultTyper = builder.baseSettings().getDefaultTyper(); + if (defaultTyper instanceof StdTypeResolverBuilder stdTypeResolverBuilder) { + return stdTypeResolverBuilder.getTypeProperty(); + } + return "@class"; + })); + return this; + } + + /** + * Registers a {@link StdSerializer} capable of serializing Spring Cache {@link NullValue} using the given type + * property name. Please make sure to active + * {@link JsonMapper.Builder#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) + * default typing} accordingly. + * + * @return this. + */ + @Contract("_ -> this") + public JsonMapperConfigurer enableSpringCacheNullValueSupport(String typePropertyName) { + + builder.addModules(new GenericJackson3RedisSerializerModule(() -> typePropertyName)); + return this; + } + + /** + * Enables + * {@link JsonMapper.Builder#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) + * default typing} without any type validation constraints. + *

+ * WARNING: without restrictions of the {@link PolymorphicTypeValidator} deserialization is + * vulnerable to arbitrary code execution when reading from untrusted sources. + * + * @return this. + * @see https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data + */ + @Contract("-> this") + public JsonMapperConfigurer unsafeDefaultTyping() { + + builder.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .activateDefaultTypingAsProperty(BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class) + .allowIfSubType((ctx, clazz) -> true).build(), DefaultTyping.NON_FINAL, "@class"); + return this; + } + + /** + * Configures the {@link Jackson3ObjectWriter}. + * + * @param writer must not be {@literal null}. + * @return this. + */ + @Contract("_ -> this") + public JsonMapperConfigurer writer(Jackson3ObjectWriter writer) { + this.writer = writer; + return this; + } + + /** + * Configures the {@link Jackson3ObjectReader}. + * + * @param reader must not be {@literal null}. + * @return this. + */ + @Contract("_ -> this") + public JsonMapperConfigurer reader(Jackson3ObjectReader reader) { + + this.reader = reader; + return this; + } + + /** + * Callback hook to interact with the raw {@link Builder}. + * + * @param builderCustomizer + * @return this. + */ + @Contract("_ -> this") + public JsonMapperConfigurer customize(Consumer builderCustomizer) { + + builderCustomizer.accept(builder); + return this; + } + + Jackson3ObjectReader getReader() { + return reader != null ? reader : Jackson3ObjectReader.create(); + } + + Jackson3ObjectWriter getWriter() { + return writer != null ? writer : Jackson3ObjectWriter.create(); + } + } + + /** + * @since 4.0 + */ + static class TypeResolver { + + private final ObjectMapper mapper; + private final Supplier typeFactory; + private final Supplier hintName; + + TypeResolver(ObjectMapper mapper, Supplier typeFactory, Supplier hintName) { + + this.mapper = mapper; + this.typeFactory = typeFactory; + this.hintName = hintName; + } + + protected JavaType constructType(Class type) { + return typeFactory.get().constructType(type); + } + + protected JavaType resolveType(byte[] source, Class type) throws IOException { + + JsonNode root = readTree(source); + JsonNode jsonNode = root.get(hintName.get()); + + if (jsonNode.isString() && jsonNode.asString() != null) { + return typeFactory.get().constructFromCanonical(jsonNode.asString()); + } + + return constructType(type); + } + + /** + * Lenient variant of ObjectMapper._readTreeAndClose using a strict {@link JsonNodeDeserializer}. + */ + private JsonNode readTree(byte[] source) throws IOException { + + BaseNodeDeserializer deserializer = JsonNodeDeserializer.getDeserializer(JsonNode.class); + DeserializationConfig cfg = mapper.deserializationConfig(); + + try (JsonParser parser = createParser(source)) { + + JsonToken t = parser.currentToken(); + if (t == null) { + t = parser.nextToken(); + if (t == null) { + return cfg.getNodeFactory().missingNode(); + } + } + + /* + * Hokey pokey! Oh my. + */ + DeserializationContext ctxt = mapper._deserializationContext(); + + if (t == JsonToken.VALUE_NULL) { + return cfg.getNodeFactory().nullNode(); + } else { + return deserializer.deserialize(parser, ctxt); + } + } + } + + private JsonParser createParser(byte[] source) throws IOException { + return mapper.createParser(source); + } + } + + /** + * {@link StdSerializer} adding class information required by default typing. This allows de-/serialization of + * {@link NullValue}. + * + * @author Christoph Strobl + * @since 4.0 + */ + private static class NullValueSerializer extends StdSerializer { + + private final Lazy classIdentifier; + + /** + * @param classIdentifier can be {@literal null} and will be defaulted to {@code @class}. + */ + NullValueSerializer(Supplier classIdentifier) { + + super(NullValue.class); + this.classIdentifier = Lazy.of(() -> { + String identifier = classIdentifier.get(); + return StringUtils.hasText(identifier) ? identifier : "@class"; + }); + } + + @Override + public void serialize(NullValue value, JsonGenerator gen, SerializationContext provider) throws JacksonException { + + if (gen.canWriteTypeId()) { + + gen.writeTypeId(classIdentifier.get()); + gen.writeString(NullValue.class.getName()); + } else if (StringUtils.hasText(classIdentifier.get())) { + + gen.writeStartObject(); + gen.writeName(classIdentifier.get()); + gen.writeString(NullValue.class.getName()); + gen.writeEndObject(); + } else { + gen.writeNull(); + } + } + } + + /** + * @since 4.0 + */ + private static class GenericJackson3RedisSerializerModule extends JacksonModule { + + private final Supplier classIdentifier; + + GenericJackson3RedisSerializerModule(Supplier classIdentifier) { + this.classIdentifier = classIdentifier; + } + + @Override + public String getModuleName() { + return "spring-data-redis-generic-serializer-module"; + } + + @Override + public Version version() { + return new Version(4, 0, 0, null, "org.springframework.data", "spring-data-redis"); + } + + @Override + public void setupModule(SetupContext context) { + + List> valueSerializers = new ArrayList<>(); + valueSerializers.add(new NullValueSerializer(classIdentifier)); + context.addSerializers(new SimpleSerializers(valueSerializers)); + } + } +} diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java index e280d9d8b6..10941b1072 100644 --- a/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java @@ -41,7 +41,9 @@ * @author Thomas Darimont * @author Mark Paluch * @since 1.2 + * @deprecated since 4.0 in favor of {@link Jackson3JsonRedisSerializer}. */ +@Deprecated(since = "4.0", forRemoval = true) public class Jackson2JsonRedisSerializer implements RedisSerializer { /** diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java new file mode 100644 index 0000000000..15cd981176 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java @@ -0,0 +1,198 @@ +/* + * Copyright 2011-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.ser.SerializerFactory; +import tools.jackson.databind.type.TypeFactory; + +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; + +import org.springframework.lang.Nullable; +import org.springframework.util.Assert; + +/** + * {@link RedisSerializer} that can read and write JSON using + * Jackson's and + * Jackson Databind {@link ObjectMapper}. + *

+ * This serializer can be used to bind to typed beans, or untyped {@link java.util.HashMap HashMap} instances. + * Note:Null objects are serialized as empty arrays and vice versa. + *

+ * JSON reading and writing can be customized by configuring {@link JacksonObjectReader} respective + * {@link JacksonObjectWriter}. + * + * @author Thomas Darimont + * @author Mark Paluch + * @since 1.2 + */ +public class Jackson3JsonRedisSerializer implements RedisSerializer { + + /** + * @deprecated since 3.0 for removal. + */ + @Deprecated(since = "3.0", forRemoval = true) // + public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + + private final JavaType javaType; + + private ObjectMapper mapper; + + private final Jackson3ObjectReader reader; + + private final Jackson3ObjectWriter writer; + + /** + * Creates a new {@link Jackson3JsonRedisSerializer} for the given target {@link Class}. + * + * @param type must not be {@literal null}. + */ + public Jackson3JsonRedisSerializer(Class type) { + this(new ObjectMapper(), type); + } + + /** + * Creates a new {@link Jackson3JsonRedisSerializer} for the given target {@link JavaType}. + * + * @param javaType must not be {@literal null}. + */ + public Jackson3JsonRedisSerializer(JavaType javaType) { + this(new ObjectMapper(), javaType); + } + + /** + * Creates a new {@link Jackson3JsonRedisSerializer} for the given target {@link Class}. + * + * @param mapper must not be {@literal null}. + * @param type must not be {@literal null}. + * @since 3.0 + */ + public Jackson3JsonRedisSerializer(ObjectMapper mapper, Class type) { + + Assert.notNull(mapper, "ObjectMapper must not be null"); + Assert.notNull(type, "Java type must not be null"); + + this.javaType = getJavaType(type); + this.mapper = mapper; + this.reader = Jackson3ObjectReader.create(); + this.writer = Jackson3ObjectWriter.create(); + } + + /** + * Creates a new {@link Jackson3JsonRedisSerializer} for the given target {@link JavaType}. + * + * @param mapper must not be {@literal null}. + * @param javaType must not be {@literal null}. + * @since 3.0 + */ + public Jackson3JsonRedisSerializer(ObjectMapper mapper, JavaType javaType) { + this(mapper, javaType, Jackson3ObjectReader.create(), Jackson3ObjectWriter.create()); + } + + /** + * Creates a new {@link Jackson3JsonRedisSerializer} for the given target {@link JavaType}. + * + * @param mapper must not be {@literal null}. + * @param javaType must not be {@literal null}. + * @param reader the {@link JacksonObjectReader} function to read objects using {@link ObjectMapper}. + * @param writer the {@link JacksonObjectWriter} function to write objects using {@link ObjectMapper}. + * @since 3.0 + */ + public Jackson3JsonRedisSerializer(ObjectMapper mapper, JavaType javaType, Jackson3ObjectReader reader, + Jackson3ObjectWriter writer) { + + Assert.notNull(mapper, "ObjectMapper must not be null!"); + Assert.notNull(reader, "Reader must not be null!"); + Assert.notNull(writer, "Writer must not be null!"); + + this.mapper = mapper; + this.reader = reader; + this.writer = writer; + this.javaType = javaType; + } + + /** + * Sets the {@code ObjectMapper} for this view. If not set, a default {@link ObjectMapper#ObjectMapper() ObjectMapper} + * is used. + *

+ * Setting a custom-configured {@code ObjectMapper} is one way to take further control of the JSON serialization + * process. For example, an extended {@link SerializerFactory} can be configured that provides custom serializers for + * specific types. The other option for refining the serialization process is to use Jackson's provided annotations on + * the types to be serialized, in which case a custom-configured ObjectMapper is unnecessary. + * + * @deprecated since 3.0, use {@link #Jackson3JsonRedisSerializer(ObjectMapper, Class) constructor creation} to + * configure the object mapper. + */ + @Deprecated(since = "3.0", forRemoval = true) + public void setObjectMapper(ObjectMapper mapper) { + + Assert.notNull(mapper, "'objectMapper' must not be null"); + this.mapper = mapper; + } + + @Override + public byte[] serialize(@Nullable T value) throws SerializationException { + + if (value == null) { + return SerializationUtils.EMPTY_ARRAY; + } + try { + return this.writer.write(this.mapper, value); + } catch (Exception ex) { + throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex); + } + } + + @Nullable + @Override + @SuppressWarnings("unchecked") + public T deserialize(@Nullable byte[] bytes) throws SerializationException { + + if (SerializationUtils.isEmpty(bytes)) { + return null; + } + try { + return (T) this.reader.read(this.mapper, bytes, javaType); + } catch (Exception ex) { + throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); + } + } + + /** + * Returns the Jackson {@link JavaType} for the specific class. + *

+ * Default implementation returns {@link TypeFactory#constructType(java.lang.reflect.Type)}, but this can be + * overridden in subclasses, to allow for custom generic collection handling. For instance: + * + *

+	 * protected JavaType getJavaType(Class<?> clazz) {
+	 * 	if (List.class.isAssignableFrom(clazz)) {
+	 * 		return TypeFactory.defaultInstance().constructCollectionType(ArrayList.class, MyBean.class);
+	 * 	} else {
+	 * 		return super.getJavaType(clazz);
+	 * 	}
+	 * }
+	 * 
+ * + * @param clazz the class to return the java type for + * @return the java type + */ + protected JavaType getJavaType(Class clazz) { + return TypeFactory.unsafeSimpleType(clazz); + } +} diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java new file mode 100644 index 0000000000..ee8b880194 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java @@ -0,0 +1,57 @@ +/* + * Copyright 2022-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import java.io.IOException; +import java.io.InputStream; + +import tools.jackson.databind.JavaType; +import tools.jackson.databind.ObjectMapper; + +/** + * Defines the contract for Object Mapping readers. Implementations of this interface can deserialize a given byte array + * holding JSON to an Object considering the target type. + *

+ * Reader functions can customize how the actual JSON is being deserialized by e.g. obtaining a customized + * {@link com.fasterxml.jackson.databind.ObjectReader} applying serialization features, date formats, or views. + * + * @author Mark Paluch + * @since 3.0 + */ +@FunctionalInterface +public interface Jackson3ObjectReader { + + /** + * Read an object graph from the given root JSON into a Java object considering the {@link JavaType}. + * + * @param mapper the object mapper to use. + * @param source the JSON to deserialize. + * @param type the Java target type + * @return the deserialized Java object. + * @throws IOException if an I/O error or JSON deserialization error occurs. + */ + Object read(ObjectMapper mapper, byte[] source, JavaType type) throws IOException; + + /** + * Create a default {@link Jackson3ObjectReader} delegating to {@link ObjectMapper#readValue(InputStream, JavaType)}. + * + * @return the default {@link Jackson3ObjectReader}. + */ + static Jackson3ObjectReader create() { + return (mapper, source, type) -> mapper.readValue(source, 0, source.length, type); + } + +} diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java new file mode 100644 index 0000000000..f995bcc123 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java @@ -0,0 +1,54 @@ +/* + * Copyright 2022-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import java.io.IOException; + +import tools.jackson.databind.ObjectMapper; + +/** + * Defines the contract for Object Mapping writers. Implementations of this interface can serialize a given Object to a + * {@code byte[]} containing JSON. + *

+ * Writer functions can customize how the actual JSON is being written by e.g. obtaining a customized + * {@link com.fasterxml.jackson.databind.ObjectWriter} applying serialization features, date formats, or views. + * + * @author Mark Paluch + * @since 3.0 + */ +@FunctionalInterface +public interface Jackson3ObjectWriter { + + /** + * Write the object graph with the given root {@code source} as byte array. + * + * @param mapper the object mapper to use. + * @param source the root of the object graph to marshal. + * @return a byte array containing the serialized object graph. + * @throws IOException if an I/O error or JSON serialization error occurs. + */ + byte[] write(ObjectMapper mapper, Object source) throws IOException; + + /** + * Create a default {@link Jackson3ObjectWriter} delegating to {@link ObjectMapper#writeValueAsBytes(Object)}. + * + * @return the default {@link Jackson3ObjectWriter}. + */ + static Jackson3ObjectWriter create() { + return ObjectMapper::writeValueAsBytes; + } + +} diff --git a/src/main/java/org/springframework/data/redis/serializer/JacksonObjectReader.java b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectReader.java index e2c1d943ec..9da953f8dd 100644 --- a/src/main/java/org/springframework/data/redis/serializer/JacksonObjectReader.java +++ b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectReader.java @@ -30,8 +30,10 @@ * * @author Mark Paluch * @since 3.0 + * @deprecated since 4.0 in favor of {@link Jackson3ObjectReader}. */ @FunctionalInterface +@Deprecated(since = "4.0", forRemoval = true) public interface JacksonObjectReader { /** diff --git a/src/main/java/org/springframework/data/redis/serializer/JacksonObjectWriter.java b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectWriter.java index 88db313130..5d13b67b66 100644 --- a/src/main/java/org/springframework/data/redis/serializer/JacksonObjectWriter.java +++ b/src/main/java/org/springframework/data/redis/serializer/JacksonObjectWriter.java @@ -28,8 +28,10 @@ * * @author Mark Paluch * @since 3.0 + * @deprecated since 4.0 in favor of {@link Jackson3ObjectWriter}. */ @FunctionalInterface +@Deprecated(since = "4.0", forRemoval = true) public interface JacksonObjectWriter { /** diff --git a/src/test/java/org/springframework/data/redis/core/AbstractOperationsTestParams.java b/src/test/java/org/springframework/data/redis/core/AbstractOperationsTestParams.java index 7bbb9fc73a..5726527114 100644 --- a/src/test/java/org/springframework/data/redis/core/AbstractOperationsTestParams.java +++ b/src/test/java/org/springframework/data/redis/core/AbstractOperationsTestParams.java @@ -33,6 +33,7 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer; import org.springframework.data.redis.serializer.OxmSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.test.XstreamOxmSerializerSingleton; @@ -108,6 +109,12 @@ public static Collection testParams(RedisConnectionFactory connectionF jackson2JsonPersonTemplate.setValueSerializer(jackson2JsonSerializer); jackson2JsonPersonTemplate.afterPropertiesSet(); + Jackson3JsonRedisSerializer jackson3JsonSerializer = new Jackson3JsonRedisSerializer<>(Person.class); + RedisTemplate jackson3JsonPersonTemplate = new RedisTemplate<>(); + jackson3JsonPersonTemplate.setConnectionFactory(connectionFactory); + jackson3JsonPersonTemplate.setValueSerializer(jackson3JsonSerializer); + jackson3JsonPersonTemplate.afterPropertiesSet(); + GenericJackson2JsonRedisSerializer genericJackson2JsonSerializer = new GenericJackson2JsonRedisSerializer(); RedisTemplate genericJackson2JsonPersonTemplate = new RedisTemplate<>(); genericJackson2JsonPersonTemplate.setConnectionFactory(connectionFactory); @@ -123,6 +130,7 @@ public static Collection testParams(RedisConnectionFactory connectionF { xstreamStringTemplate, stringFactory, stringFactory }, // { xstreamPersonTemplate, stringFactory, personFactory }, // { jackson2JsonPersonTemplate, stringFactory, personFactory }, // + { jackson3JsonPersonTemplate, stringFactory, personFactory }, // { genericJackson2JsonPersonTemplate, stringFactory, personFactory } }); } } diff --git a/src/test/java/org/springframework/data/redis/core/ReactiveOperationsTestParams.java b/src/test/java/org/springframework/data/redis/core/ReactiveOperationsTestParams.java index ed2e4dc069..0df1458646 100644 --- a/src/test/java/org/springframework/data/redis/core/ReactiveOperationsTestParams.java +++ b/src/test/java/org/springframework/data/redis/core/ReactiveOperationsTestParams.java @@ -35,6 +35,7 @@ import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.GenericToStringSerializer; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer; import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.OxmSerializer; import org.springframework.data.redis.serializer.RedisSerializationContext; @@ -100,6 +101,10 @@ RedisSerializationContext. newSerializationContext(jdkSerializat ReactiveRedisTemplate jackson2JsonPersonTemplate = new ReactiveRedisTemplate( lettuceConnectionFactory, RedisSerializationContext.fromSerializer(jackson2JsonSerializer)); + Jackson3JsonRedisSerializer jackson3JsonSerializer = new Jackson3JsonRedisSerializer<>(Person.class); + ReactiveRedisTemplate jackson3JsonPersonTemplate = new ReactiveRedisTemplate( + lettuceConnectionFactory, RedisSerializationContext.fromSerializer(jackson3JsonSerializer)); + GenericJackson2JsonRedisSerializer genericJackson2JsonSerializer = new GenericJackson2JsonRedisSerializer(); ReactiveRedisTemplate genericJackson2JsonPersonTemplate = new ReactiveRedisTemplate( lettuceConnectionFactory, RedisSerializationContext.fromSerializer(genericJackson2JsonSerializer)); @@ -115,6 +120,7 @@ RedisSerializationContext. newSerializationContext(jdkSerializat new Fixture<>(xstreamStringTemplate, stringFactory, stringFactory, oxmSerializer, "String/OXM"), // new Fixture<>(xstreamPersonTemplate, stringFactory, personFactory, oxmSerializer, "String/Person/OXM"), // new Fixture<>(jackson2JsonPersonTemplate, stringFactory, personFactory, jackson2JsonSerializer, "Jackson2"), // + new Fixture<>(jackson3JsonPersonTemplate, stringFactory, personFactory, jackson2JsonSerializer, "Jackson3"), // new Fixture<>(genericJackson2JsonPersonTemplate, stringFactory, personFactory, genericJackson2JsonSerializer, "Generic Jackson 2")); diff --git a/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java b/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java index 3807d13680..1d3fa8e256 100644 --- a/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java @@ -39,6 +39,7 @@ protected void assertBackAndForwardMapping(Object o) { HashMapper mapper = mapperFor(o.getClass()); Map hash = mapper.toHash(o); + System.out.println("hash: " + hash); assertThat(mapper.fromHash(hash)).isEqualTo(o); } diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java new file mode 100644 index 0000000000..e67e802081 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.mapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Disabled; +import org.springframework.data.redis.hash.Jackson2HashMapper; +import org.springframework.data.redis.hash.Jackson3HashMapper; + +/** + * @author Christoph Strobl + */ +public class Jackson3CompatibilityTests extends Jackson3HashMapperUnitTests { + + private final Jackson2HashMapper jackson2HashMapper; + + public Jackson3CompatibilityTests() { + super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure, false)); + this.jackson2HashMapper = new Jackson2HashMapper(false); + } + + @Override + @Disabled("with jackson 2 this used to render the timestamp as string. Now its a long and in line with calendar timestamp") + void dateValueShouldBeTreatedCorrectly() { + super.dateValueShouldBeTreatedCorrectly(); + } + + @Override + @Disabled("with jackson 2 used to render the enum and its type hint in an array. Now its just the enum value") + void enumsShouldBeTreatedCorrectly() { + super.enumsShouldBeTreatedCorrectly(); + } + + @Override + protected void assertBackAndForwardMapping(Object o) { + + Map hash3 = getMapper().toHash(o); + Map hash2 = jackson2HashMapper.toHash(o); + + assertThat(hash3).containsAllEntriesOf(hash2); + assertThat(getMapper().fromHash(hash2)).isEqualTo(o); + } +} diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java new file mode 100644 index 0000000000..2c9d337abe --- /dev/null +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.mapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import org.junit.jupiter.api.Disabled; +import org.springframework.data.redis.hash.Jackson2HashMapper; +import org.springframework.data.redis.hash.Jackson3HashMapper; + +/** + * @author Christoph Strobl + */ +public class Jackson3FlatteningCompatibilityTests extends Jackson3HashMapperUnitTests { + + private final Jackson2HashMapper jackson2HashMapper; + + public Jackson3FlatteningCompatibilityTests() { + super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure, true)); + this.jackson2HashMapper = new Jackson2HashMapper(true); + } + + @Override + @Disabled("with jackson 2 this used to render the timestamp as string. Now its a long and in line with calendar timestamp") + void dateValueShouldBeTreatedCorrectly() { + super.dateValueShouldBeTreatedCorrectly(); + } + + @Override + @Disabled("with jackson 2 used to render the enum and its type hint in an array. Now its just the enum value") + void enumsShouldBeTreatedCorrectly() { + super.enumsShouldBeTreatedCorrectly(); + } + + @Override + protected void assertBackAndForwardMapping(Object o) { + + Map hash3 = getMapper().toHash(o); + Map hash2 = jackson2HashMapper.toHash(o); + + assertThat(hash3).containsAllEntriesOf(hash2); + assertThat(getMapper().fromHash(hash2)).isEqualTo(o); + } +} diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java new file mode 100644 index 0000000000..5e3e4b8835 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.mapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.Month; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.hash.Jackson3HashMapper; + +/** + * @author Christoph Strobl + * @author John Blum + * @since 2023/06 + */ +public class Jackson3HashMapperFlatteningUnitTests extends Jackson3HashMapperUnitTests { + + Jackson3HashMapperFlatteningUnitTests() { + super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure,true)); + } + + @Test // GH-2593 + void timestampHandledCorrectly() { + + Map hash = Map.of("@class", Session.class.getName(), "lastAccessed", "2023-06-05T18:36:30"); + + // Map hash = Map.of("lastAccessed", "2023-06-05T18:36:30"); + + Session session = (Session) getMapper().fromHash(hash); + + assertThat(session).isNotNull(); + assertThat(session.lastAccessed).isEqualTo(LocalDateTime.of(2023, Month.JUNE, 5, 18, 36, 30)); + } + + private static class Session { + + private LocalDateTime lastAccessed; + + public LocalDateTime getLastAccessed() { + return lastAccessed; + } + + public void setLastAccessed(LocalDateTime lastAccessed) { + this.lastAccessed = lastAccessed; + } + } +} diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java new file mode 100644 index 0000000000..af19ff6a26 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java @@ -0,0 +1,63 @@ +/* + * Copyright 2023-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.mapping; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.time.Month; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.hash.Jackson3HashMapper; + +/** + * @author Christoph Strobl + * @author John Blum + * @since 2023/06 + */ +public class Jackson3HashMapperNonFlatteningUnitTests extends Jackson3HashMapperUnitTests { + + Jackson3HashMapperNonFlatteningUnitTests() { + super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure, false)); + } + + @Test // GH-2593 + void timestampHandledCorrectly() { + + Session source = new Session(); + source.lastAccessed = LocalDateTime.of(2023, Month.JUNE, 5, 18, 36, 30); + + Map hash = getMapper().toHash(source); + Session session = (Session) getMapper().fromHash(hash); + + assertThat(session).isNotNull(); + assertThat(session.lastAccessed).isEqualTo(LocalDateTime.of(2023, Month.JUNE, 5, 18, 36, 30)); + } + + private static class Session { + + private LocalDateTime lastAccessed; + + public LocalDateTime getLastAccessed() { + return lastAccessed; + } + + public void setLastAccessed(LocalDateTime lastAccessed) { + this.lastAccessed = lastAccessed; + } + } +} diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java new file mode 100644 index 0000000000..72631fdb64 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java @@ -0,0 +1,505 @@ +/* + * Copyright 2016-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.mapping; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Calendar; +import java.util.Date; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.data.redis.Address; +import org.springframework.data.redis.Person; +import org.springframework.data.redis.hash.HashMapper; +import org.springframework.data.redis.hash.Jackson2HashMapper; +import org.springframework.data.redis.hash.Jackson3HashMapper; + +/** + * Unit tests for {@link Jackson2HashMapper}. + * + * @author Christoph Strobl + * @author Mark Paluch + * @author John Blum + */ +public abstract class Jackson3HashMapperUnitTests extends AbstractHashMapperTests { + + private final Jackson3HashMapper mapper; + + public Jackson3HashMapperUnitTests(Jackson3HashMapper mapper) { + + this.mapper = mapper; + } + + protected Jackson3HashMapper getMapper() { + return this.mapper; + } + + @Override + @SuppressWarnings("rawtypes") + protected HashMapper mapperFor(Class t) { + return getMapper(); + } + + @Test // DATAREDIS-423 + void shouldMapTypedListOfSimpleType() { + + WithList source = new WithList(); + source.strings = Arrays.asList("spring", "data", "redis"); + assertBackAndForwardMapping(source); + } + + @Test // DATAREDIS-423 + void shouldMapTypedListOfComplexType() { + + WithList source = new WithList(); + + source.persons = Arrays.asList(new Person("jon", "snow", 19), new Person("tyrion", "lannister", 27)); + assertBackAndForwardMapping(source); + } + + @Test // DATAREDIS-423 + void shouldMapTypedListOfComplexObjectWihtNestedElements() { + + WithList source = new WithList(); + + Person jon = new Person("jon", "snow", 19); + Address adr = new Address(); + adr.setStreet("the wall"); + adr.setNumber(100); + jon.setAddress(adr); + + source.persons = Arrays.asList(jon, new Person("tyrion", "lannister", 27)); + assertBackAndForwardMapping(source); + } + + @Test // DATAREDIS-423 + void shouldMapNestedObject() { + + Person jon = new Person("jon", "snow", 19); + Address adr = new Address(); + adr.setStreet("the wall"); + adr.setNumber(100); + jon.setAddress(adr); + + assertBackAndForwardMapping(jon); + } + + @Test // DATAREDIS-423 + void shouldMapUntypedList() { + + WithList source = new WithList(); + source.objects = Arrays.asList(100, "foo", new Person("jon", "snow", 19)); + assertBackAndForwardMapping(source); + } + + @Test // DATAREDIS-423 + void shouldMapTypedMapOfSimpleTypes() { + + WithMap source = new WithMap(); + source.strings = new LinkedHashMap<>(); + source.strings.put("1", "spring"); + source.strings.put("2", "data"); + source.strings.put("3", "redis"); + assertBackAndForwardMapping(source); + } + + @Test // DATAREDIS-423 + void shouldMapTypedMapOfComplexTypes() { + + WithMap source = new WithMap(); + source.persons = new LinkedHashMap<>(); + source.persons.put("1", new Person("jon", "snow", 19)); + source.persons.put("2", new Person("tyrion", "lannister", 19)); + assertBackAndForwardMapping(source); + } + + @Test // DATAREDIS-423 + void shouldMapUntypedMap() { + + WithMap source = new WithMap(); + source.objects = new LinkedHashMap<>(); + source.objects.put("1", "spring"); + source.objects.put("2", 100); + source.objects.put("3", "redis"); + assertBackAndForwardMapping(source); + } + + @Test // DATAREDIS-423 + void nestedStuff() { + + WithList nestedList = new WithList(); + nestedList.objects = new ArrayList<>(); + + WithMap deepNestedMap = new WithMap(); + deepNestedMap.persons = new LinkedHashMap<>(); + deepNestedMap.persons.put("jon", new Person("jon", "snow", 24)); + + nestedList.objects.add(deepNestedMap); + + WithMap outer = new WithMap(); + outer.objects = new LinkedHashMap<>(); + outer.objects.put("1", nestedList); + + assertBackAndForwardMapping(outer); + } + + @Test // DATAREDIS-1001 + void dateValueShouldBeTreatedCorrectly() { + + WithDates source = new WithDates(); + source.string = "id-1"; + source.date = new Date(1561543964015L); + source.calendar = Calendar.getInstance(); + source.localDate = LocalDate.parse("2018-01-02"); + source.localDateTime = LocalDateTime.parse("2018-01-02T12:13:14"); + + assertBackAndForwardMapping(source); + } + + @Test // GH-1566 + @Disabled("Jackson removed default typing for final types") + void mapFinalClass() { + + MeFinal source = new MeFinal(); + source.value = "id-1"; + + assertBackAndForwardMapping(source); + } + + @Test // GH-2365 + void bigIntegerShouldBeTreatedCorrectly() { + + WithBigWhatever source = new WithBigWhatever(); + source.bigI = BigInteger.TEN; + + assertBackAndForwardMapping(source); + } + + @Test // GH-2365 + void bigDecimalShouldBeTreatedCorrectly() { + + WithBigWhatever source = new WithBigWhatever(); + source.bigD = BigDecimal.ONE; + + assertBackAndForwardMapping(source); + } + + @Test // GH-2979 + void enumsShouldBeTreatedCorrectly() { + + WithEnumValue source = new WithEnumValue(); + source.value = SpringDataEnum.REDIS; + + assertBackAndForwardMapping(source); + } + + public static class WithList { + + List strings; + List objects; + List persons; + + public List getStrings() { + return this.strings; + } + + public void setStrings(List strings) { + this.strings = strings; + } + + public List getObjects() { + return this.objects; + } + + public void setObjects(List objects) { + this.objects = objects; + } + + public List getPersons() { + return this.persons; + } + + public void setPersons(List persons) { + this.persons = persons; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof WithList that)) { + return false; + } + + return Objects.equals(this.getObjects(), that.getObjects()) + && Objects.equals(this.getPersons(), that.getPersons()) + && Objects.equals(this.getStrings(), that.getStrings()); + } + + @Override + public int hashCode() { + return Objects.hash(getObjects(), getPersons(), getStrings()); + } + } + + public static class WithMap { + + Map strings; + Map objects; + Map persons; + + public Map getStrings() { + return this.strings; + } + + public void setStrings(Map strings) { + this.strings = strings; + } + + public Map getObjects() { + return this.objects; + } + + public void setObjects(Map objects) { + this.objects = objects; + } + + public Map getPersons() { + return this.persons; + } + + public void setPersons(Map persons) { + this.persons = persons; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof WithMap that)) { + return false; + } + + return Objects.equals(this.getObjects(), that.getObjects()) + && Objects.equals(this.getPersons(), that.getPersons()) + && Objects.equals(this.getStrings(), that.getStrings()); + } + + @Override + public int hashCode() { + return Objects.hash(getObjects(), getPersons(), getStrings()); + } + } + + private static class WithDates { + + private String string; + private Date date; + private Calendar calendar; + private LocalDate localDate; + private LocalDateTime localDateTime; + + public String getString() { + return this.string; + } + + public void setString(String string) { + this.string = string; + } + + public Date getDate() { + return this.date; + } + + public void setDate(Date date) { + this.date = date; + } + + public Calendar getCalendar() { + return this.calendar; + } + + public void setCalendar(Calendar calendar) { + this.calendar = calendar; + } + + public LocalDate getLocalDate() { + return this.localDate; + } + + public void setLocalDate(LocalDate localDate) { + this.localDate = localDate; + } + + public LocalDateTime getLocalDateTime() { + return this.localDateTime; + } + + public void setLocalDateTime(LocalDateTime localDateTime) { + this.localDateTime = localDateTime; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof WithDates that)) { + return false; + } + + return Objects.equals(this.getString(), that.getString()) + && Objects.equals(this.getCalendar(), that.getCalendar()) && Objects.equals(this.getDate(), that.getDate()) + && Objects.equals(this.getLocalDate(), that.getLocalDate()) + && Objects.equals(this.getLocalDateTime(), that.getLocalDateTime()); + } + + @Override + public int hashCode() { + return Objects.hash(getString(), getCalendar(), getDate(), getLocalDate(), getLocalDateTime()); + } + + @Override + public String toString() { + return "WithDates{" + "string='" + string + '\'' + ", date=" + date + ", calendar=" + calendar + ", localDate=" + + localDate + ", localDateTime=" + localDateTime + '}'; + } + } + + private static class WithBigWhatever { + + private BigDecimal bigD; + private BigInteger bigI; + + public BigDecimal getBigD() { + return this.bigD; + } + + public void setBigD(BigDecimal bigD) { + this.bigD = bigD; + } + + public BigInteger getBigI() { + return this.bigI; + } + + public void setBigI(BigInteger bigI) { + this.bigI = bigI; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof WithBigWhatever that)) { + return false; + } + + return Objects.equals(this.getBigD(), that.getBigD()) && Objects.equals(this.getBigI(), that.getBigI()); + } + + @Override + public int hashCode() { + return Objects.hash(getBigD(), getBigI()); + } + } + + public static final class MeFinal { + + private String value; + + public String getValue() { + return this.value; + } + + public void setValue(String value) { + this.value = value; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof MeFinal that)) { + return false; + } + + return Objects.equals(this.getValue(), that.getValue()); + } + + @Override + public int hashCode() { + return Objects.hash(getValue()); + } + } + + enum SpringDataEnum { + COMMONS, REDIS + } + + static class WithEnumValue { + + SpringDataEnum value; + + public SpringDataEnum getValue() { + return value; + } + + public void setValue(SpringDataEnum value) { + this.value = value; + } + + @Override + public boolean equals(Object o) { + if (o == this) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + WithEnumValue that = (WithEnumValue) o; + return value == that.value; + } + + @Override + public int hashCode() { + return Objects.hash(value); + } + } +} diff --git a/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java new file mode 100644 index 0000000000..d41dd170ad --- /dev/null +++ b/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java @@ -0,0 +1,653 @@ +/* + * Copyright 2015-2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.springframework.test.util.ReflectionTestUtils.getField; +import static org.springframework.util.ObjectUtils.nullSafeEquals; +import static org.springframework.util.ObjectUtils.nullSafeHashCode; + +import tools.jackson.core.exc.JacksonIOException; +import tools.jackson.core.exc.StreamReadException; +import tools.jackson.databind.DefaultTyping; +import tools.jackson.databind.ObjectMapper; +import tools.jackson.databind.annotation.JsonDeserialize; +import tools.jackson.databind.annotation.JsonSerialize; +import tools.jackson.databind.ext.javatime.deser.LocalDateDeserializer; +import tools.jackson.databind.ext.javatime.ser.LocalDateSerializer; +import tools.jackson.databind.json.JsonMapper; +import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import tools.jackson.databind.jsontype.TypeResolverBuilder; +import tools.jackson.databind.type.TypeFactory; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.time.LocalDate; +import java.util.Arrays; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.BeanUtils; +import org.springframework.cache.support.NullValue; + +import com.fasterxml.jackson.annotation.JsonView; + +/** + * Unit tests for {@link GenericJackson3JsonRedisSerializer}. + * + * @author Christoph Strobl + * @author Mark Paluch + * @author John Blum + */ +class GenericJackson3JsonRedisSerializerUnitTests { + + private static final SimpleObject SIMPLE_OBJECT = new SimpleObject(1L); + private static final ComplexObject COMPLEX_OBJECT = new ComplexObject("steelheart", SIMPLE_OBJECT); + + @Test // DATAREDIS-392, GH-2878 + void shouldUseDefaultTyping() { + assertThat(extractTypeResolver(new GenericJackson3JsonRedisSerializer())).isNotNull(); + } + + @Test // DATAREDIS-392 + void serializeShouldReturnEmptyByteArrayWhenSourceIsNull() { + assertThat(new GenericJackson3JsonRedisSerializer().serialize(null)).isEqualTo(SerializationUtils.EMPTY_ARRAY); + } + + @Test // DATAREDIS-392 + void deserializeShouldReturnNullWhenSouceIsNull() { + assertThat(new GenericJackson3JsonRedisSerializer().deserialize(null)).isNull(); + } + + @Test // DATAREDIS-392 + void deserializeShouldReturnNullWhenSouceIsEmptyArray() { + assertThat(new GenericJackson3JsonRedisSerializer().deserialize(SerializationUtils.EMPTY_ARRAY)).isNull(); + } + + @Test // DATAREDIS-392 + void deserializeShouldBeAbleToRestoreSimpleObjectAfterSerialization() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + assertThat((SimpleObject) serializer.deserialize(serializer.serialize(SIMPLE_OBJECT))).isEqualTo(SIMPLE_OBJECT); + } + + @Test // DATAREDIS-392 + void deserializeShouldBeAbleToRestoreComplexObjectAfterSerialization() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + assertThat((ComplexObject) serializer.deserialize(serializer.serialize(COMPLEX_OBJECT))).isEqualTo(COMPLEX_OBJECT); + } + + @Test // DATAREDIS-392 + void serializeShouldThrowSerializationExceptionProcessingError() { + + ObjectMapper objectMapperMock = mock(ObjectMapper.class); + when(objectMapperMock.writeValueAsBytes(any())) + .thenThrow(JacksonIOException.construct(new IOException("doesn't work"))); + + assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> new GenericJackson3JsonRedisSerializer(objectMapperMock).serialize(SIMPLE_OBJECT)); + } + + @Test // DATAREDIS-392 + void deserializeShouldThrowSerializationExceptionProcessingError() throws IOException { + + ObjectMapper objectMapperMock = mock(ObjectMapper.class); + when(objectMapperMock.readValue(any(byte[].class), any(Class.class))).thenThrow(new StreamReadException("conflux")); + + assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> new GenericJackson3JsonRedisSerializer(objectMapperMock).deserialize(new byte[] { 1 })); + } + + @Test // DATAREDIS-553, DATAREDIS-865 + void shouldSerializeNullValueSoThatItCanBeDeserializedWithDefaultTypingEnabled() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + serializeAndDeserializeNullValue(serializer); + } + + @Test // DATAREDIS-865 + void shouldSerializeNullValueWithCustomObjectMapper() { + + GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer.create(configHelper -> { + configHelper.enableSpringCacheNullValueSupport() // + .customize(mapperBuilder -> { + mapperBuilder.activateDefaultTypingAsProperty(BasicPolymorphicTypeValidator.builder().build(), + DefaultTyping.NON_FINAL, "_woot"); + }); + }); + + serializeAndDeserializeNullValue(serializer); + } + + @Test // GH-1566 + @Disabled("cannot serialize final types") + void deserializeShouldBeAbleToRestoreFinalObjectAfterSerialization() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + FinalObject source = new FinalObject(); + source.longValue = 1L; + source.myArray = new int[] { 1, 2, 3 }; + source.simpleObject = new SimpleObject(2L); + + assertThat(serializer.deserialize(serializer.serialize(source))).isEqualTo(source); + assertThat(serializer.deserialize( + ("{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$FinalObject\",\"longValue\":1,\"myArray\":[1,2,3],\n" + + "\"simpleObject\":{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$SimpleObject\",\"longValue\":2}}") + .getBytes())) + .isEqualTo(source); + } + + @Test // GH-2361 + void shouldDeserializePrimitiveArrayWithoutTypeHint() { + + GenericJackson3JsonRedisSerializer gs = new GenericJackson3JsonRedisSerializer(); + CountAndArray result = (CountAndArray) gs.deserialize( + ("{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$CountAndArray\", \"count\":1, \"available\":[0,1]}") + .getBytes()); + + assertThat(result.getCount()).isEqualTo(1); + assertThat(result.getAvailable()).containsExactly(0, 1); + } + + @Test // GH-2322 + void readsToMapForNonDefaultTyping() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(JsonMapper.shared()); + + User user = new User(); + user.email = "walter@heisenberg.com"; + user.id = 42; + user.name = "Walter White"; + + byte[] serializedValue = serializer.serialize(user); + + Object deserializedValue = serializer.deserialize(serializedValue, Object.class); + assertThat(deserializedValue).isInstanceOf(Map.class); + } + + @Test // GH-2322 + void shouldConsiderWriter() { + + User user = new User(); + user.email = "walter@heisenberg.com"; + user.id = 42; + user.name = "Walter White"; + + GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer.create(configHelper -> { + configHelper.writer((mapper, source) -> mapper.writerWithView(Views.Basic.class).writeValueAsBytes(source)); + }); + + byte[] result = serializer.serialize(user); + + assertThat(new String(result)).contains("id").contains("name").doesNotContain("email"); + } + + @Test // GH-2322 + void shouldConsiderReader() { + + User user = new User(); + user.email = "walter@heisenberg.com"; + user.id = 42; + user.name = "Walter White"; + + GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer.create(configHelper -> { + + configHelper.unsafeDefaultTyping(); + configHelper.reader((mapper, source, type) -> { + if (type.getRawClass() == User.class) { + return mapper.readerWithView(Views.Basic.class).forType(type).readValue(source); + } + return mapper.readValue(source, type); + }); + }); + + byte[] serializedValue = serializer.serialize(user); + + Object result = serializer.deserialize(serializedValue); + assertThat(result).isInstanceOf(User.class).satisfies(it -> { + User u = (User) it; + assertThat(u.id).isEqualTo(user.id); + assertThat(u.name).isEqualTo(user.name); + assertThat(u.email).isNull(); + assertThat(u.mobile).isNull(); + }); + } + + @Test // GH-2361 + void shouldDeserializePrimitiveWrapperArrayWithoutTypeHint() { + + GenericJackson3JsonRedisSerializer gs = new GenericJackson3JsonRedisSerializer(); + CountAndArray result = (CountAndArray) gs.deserialize( + ("{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$CountAndArray\", \"count\":1, \"arrayOfPrimitiveWrapper\":[0,1]}") + .getBytes()); + + assertThat(result.getCount()).isEqualTo(1); + assertThat(result.getArrayOfPrimitiveWrapper()).containsExactly(0L, 1L); + } + + @Test // GH-2361 + void doesNotIncludeTypingForPrimitiveArrayWrappers() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + WithWrapperTypes source = new WithWrapperTypes(); + source.primitiveWrapper = new AtomicReference<>(); + source.primitiveArrayWrapper = new AtomicReference<>(new Integer[] { 200, 300 }); + source.simpleObjectWrapper = new AtomicReference<>(); + + byte[] serializedValue = serializer.serialize(source); + + assertThat(new String(serializedValue)) // + .contains("\"primitiveArrayWrapper\":[200,300]") // + .doesNotContain("\"[Ljava.lang.Integer;\""); + + assertThat(serializer.deserialize(serializedValue)) // + .isInstanceOf(WithWrapperTypes.class) // + .satisfies(it -> { + WithWrapperTypes deserialized = (WithWrapperTypes) it; + assertThat(deserialized.primitiveArrayWrapper).hasValue(source.primitiveArrayWrapper.get()); + }); + } + + @Test // GH-2361 + void doesNotIncludeTypingForPrimitiveWrappers() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + WithWrapperTypes source = new WithWrapperTypes(); + source.primitiveWrapper = new AtomicReference<>(123L); + + byte[] serializedValue = serializer.serialize(source); + + assertThat(new String(serializedValue)) // + .contains("\"primitiveWrapper\":123") // + .doesNotContain("\"Ljava.lang.Long;\""); + + assertThat(serializer.deserialize(serializedValue)) // + .isInstanceOf(WithWrapperTypes.class) // + .satisfies(it -> { + WithWrapperTypes deserialized = (WithWrapperTypes) it; + assertThat(deserialized.primitiveWrapper).hasValue(source.primitiveWrapper.get()); + }); + } + + @Test // GH-2361 + void includesTypingForWrappedObjectTypes() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + SimpleObject simpleObject = new SimpleObject(100L); + WithWrapperTypes source = new WithWrapperTypes(); + source.simpleObjectWrapper = new AtomicReference<>(simpleObject); + + byte[] serializedValue = serializer.serialize(source); + + assertThat(new String(serializedValue)) // + .contains( + "\"simpleObjectWrapper\":{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$SimpleObject\",\"longValue\":100}"); + + assertThat(serializer.deserialize(serializedValue)) // + .isInstanceOf(WithWrapperTypes.class) // + .satisfies(it -> { + WithWrapperTypes deserialized = (WithWrapperTypes) it; + assertThat(deserialized.simpleObjectWrapper).hasValue(source.simpleObjectWrapper.get()); + }); + } + + @Test // GH-2396 + void verifySerializeUUIDIntoBytes() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + UUID source = UUID.fromString("730145fe-324d-4fb1-b12f-60b89a045730"); + assertThat(serializer.serialize(source)).isEqualTo(("\"" + source + "\"").getBytes(StandardCharsets.UTF_8)); + } + + @Test // GH-2396 + void deserializesUUIDFromBytes() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + UUID deserializedUuid = serializer + .deserialize("\"730145fe-324d-4fb1-b12f-60b89a045730\"".getBytes(StandardCharsets.UTF_8), UUID.class); + + assertThat(deserializedUuid).isEqualTo(UUID.fromString("730145fe-324d-4fb1-b12f-60b89a045730")); + } + + @Test // GH-2396 + void serializesEnumIntoBytes() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + assertThat(serializer.serialize(EnumType.ONE)).isEqualTo(("\"ONE\"").getBytes(StandardCharsets.UTF_8)); + } + + @Test // GH-2396 + void deserializesEnumFromBytes() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + assertThat(serializer.deserialize("\"TWO\"".getBytes(StandardCharsets.UTF_8), EnumType.class)) + .isEqualTo(EnumType.TWO); + } + + @Test // GH-2396 + void serializesJavaTimeIntoBytes() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + + WithJsr310 source = new WithJsr310(); + source.myDate = LocalDate.of(2022, 9, 2); + + byte[] serialized = serializer.serialize(source); + assertThat(serialized).isEqualTo( + ("{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$WithJsr310\",\"myDate\":\"2022-09-02\"}") + .getBytes(StandardCharsets.UTF_8)); + } + + @Test // GH-2396 + void deserializesJavaTimeFromBytes() { + + GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(JsonMapper.shared()); + + byte[] source = "{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$WithJsr310\",\"myDate\":\"2022-09-02\"}" + .getBytes(StandardCharsets.UTF_8); + assertThat(serializer.deserialize(source, WithJsr310.class).myDate).isEqualTo(LocalDate.of(2022, 9, 2)); + } + + private static void serializeAndDeserializeNullValue(GenericJackson3JsonRedisSerializer serializer) { + + NullValue nv = BeanUtils.instantiateClass(NullValue.class); + + byte[] serializedValue = serializer.serialize(nv); + assertThat(serializedValue).isNotNull(); + + Object deserializedValue = serializer.deserialize(serializedValue); + assertThat(deserializedValue).isInstanceOf(NullValue.class); + } + + private @Nullable TypeResolverBuilder extractTypeResolver(GenericJackson3JsonRedisSerializer serializer) { + + ObjectMapper mapper = (ObjectMapper) getField(serializer, "mapper"); + return mapper.serializationConfig() + .getDefaultTyper(TypeFactory.createDefaultInstance().constructType(Object.class)); + } + + static class ComplexObject { + + public String stringValue; + public SimpleObject simpleObject; + + public ComplexObject() {} + + public ComplexObject(String stringValue, SimpleObject simpleObject) { + this.stringValue = stringValue; + this.simpleObject = simpleObject; + } + + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof ComplexObject that)) { + return false; + } + + return Objects.equals(this.simpleObject, that.simpleObject) && Objects.equals(this.stringValue, that.stringValue); + } + + @Override + public int hashCode() { + return Objects.hash(this.simpleObject, this.stringValue); + } + } + + static final class FinalObject { + + public Long longValue; + public int[] myArray; + SimpleObject simpleObject; + + public Long getLongValue() { + return this.longValue; + } + + public void setLongValue(Long longValue) { + this.longValue = longValue; + } + + public int[] getMyArray() { + return this.myArray; + } + + public void setMyArray(int[] myArray) { + this.myArray = myArray; + } + + public SimpleObject getSimpleObject() { + return this.simpleObject; + } + + public void setSimpleObject(SimpleObject simpleObject) { + this.simpleObject = simpleObject; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof FinalObject that)) { + return false; + } + + return Objects.equals(this.getLongValue(), that.getLongValue()) + && Arrays.equals(this.getMyArray(), that.getMyArray()) + && Objects.equals(this.getSimpleObject(), that.getSimpleObject()); + } + + @Override + public int hashCode() { + return Objects.hash(getLongValue(), getMyArray(), getSimpleObject()); + } + } + + static class SimpleObject { + + public Long longValue; + + public SimpleObject() {} + + public SimpleObject(Long longValue) { + this.longValue = longValue; + } + + @Override + public int hashCode() { + return nullSafeHashCode(this.longValue); + } + + @Override + public boolean equals(@Nullable Object obj) { + + if (this == obj) { + return true; + } + if (obj == null) { + return false; + } + if (!(obj instanceof SimpleObject other)) { + return false; + } + return nullSafeEquals(this.longValue, other.longValue); + } + } + + static class User { + + @JsonView(Views.Basic.class) public int id; + @JsonView(Views.Basic.class) public String name; + @JsonView(Views.Detailed.class) public String email; + @JsonView(Views.Detailed.class) public String mobile; + + @Override + public String toString() { + + return "User{" + "id=" + id + ", name='" + name + '\'' + ", email='" + email + '\'' + ", mobile='" + mobile + '\'' + + '}'; + } + } + + static class Views { + + interface Basic {} + + interface Detailed {} + } + + static class CountAndArray { + + private int count; + private int[] available; + private Long[] arrayOfPrimitiveWrapper; + + public int getCount() { + return this.count; + } + + public void setCount(int count) { + this.count = count; + } + + public int[] getAvailable() { + return this.available; + } + + public void setAvailable(int[] available) { + this.available = available; + } + + public Long[] getArrayOfPrimitiveWrapper() { + return this.arrayOfPrimitiveWrapper; + } + + public void setArrayOfPrimitiveWrapper(Long[] arrayOfPrimitiveWrapper) { + this.arrayOfPrimitiveWrapper = arrayOfPrimitiveWrapper; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof CountAndArray that)) { + return false; + } + + return Objects.equals(this.getCount(), that.getCount()) + && Objects.equals(this.getAvailable(), that.getAvailable()) + && Objects.equals(this.getArrayOfPrimitiveWrapper(), that.getArrayOfPrimitiveWrapper()); + } + + @Override + public int hashCode() { + return Objects.hash(getCount(), getAvailable(), getArrayOfPrimitiveWrapper()); + } + } + + static class WithWrapperTypes { + + AtomicReference primitiveWrapper; + AtomicReference primitiveArrayWrapper; + AtomicReference simpleObjectWrapper; + + public AtomicReference getPrimitiveWrapper() { + return this.primitiveWrapper; + } + + public void setPrimitiveWrapper(AtomicReference primitiveWrapper) { + this.primitiveWrapper = primitiveWrapper; + } + + public AtomicReference getPrimitiveArrayWrapper() { + return this.primitiveArrayWrapper; + } + + public void setPrimitiveArrayWrapper(AtomicReference primitiveArrayWrapper) { + this.primitiveArrayWrapper = primitiveArrayWrapper; + } + + public AtomicReference getSimpleObjectWrapper() { + return this.simpleObjectWrapper; + } + + public void setSimpleObjectWrapper(AtomicReference simpleObjectWrapper) { + this.simpleObjectWrapper = simpleObjectWrapper; + } + + @Override + public boolean equals(Object obj) { + + if (this == obj) { + return true; + } + + if (!(obj instanceof WithWrapperTypes that)) { + return false; + } + + return Objects.equals(this.getPrimitiveWrapper(), that.getPrimitiveWrapper()) + && Objects.equals(this.getPrimitiveArrayWrapper(), that.getPrimitiveArrayWrapper()) + && Objects.equals(this.getSimpleObjectWrapper(), that.getSimpleObjectWrapper()); + } + + @Override + public int hashCode() { + return Objects.hash(getPrimitiveWrapper(), getPrimitiveArrayWrapper(), getSimpleObjectWrapper()); + } + + } + + enum EnumType { + ONE, TWO + } + + static class WithJsr310 { + + @JsonSerialize(using = LocalDateSerializer.class) + @JsonDeserialize(using = LocalDateDeserializer.class) private LocalDate myDate; + } +} diff --git a/src/test/java/org/springframework/data/redis/support/collections/CollectionTestParams.java b/src/test/java/org/springframework/data/redis/support/collections/CollectionTestParams.java index 858587c33a..fcf42c0f07 100644 --- a/src/test/java/org/springframework/data/redis/support/collections/CollectionTestParams.java +++ b/src/test/java/org/springframework/data/redis/support/collections/CollectionTestParams.java @@ -31,6 +31,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer; import org.springframework.data.redis.serializer.OxmSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.test.XstreamOxmSerializerSingleton; @@ -48,6 +49,7 @@ public static Collection testParams() { OxmSerializer serializer = XstreamOxmSerializerSingleton.getInstance(); Jackson2JsonRedisSerializer jackson2JsonSerializer = new Jackson2JsonRedisSerializer<>(Person.class); + Jackson3JsonRedisSerializer jackson3JsonSerializer = new Jackson3JsonRedisSerializer<>(Person.class); StringRedisSerializer stringSerializer = StringRedisSerializer.UTF_8; // create Jedis Factory @@ -86,6 +88,12 @@ public static Collection testParams() { rawTemplate.setKeySerializer(stringSerializer); rawTemplate.afterPropertiesSet(); + // jackson3 + RedisTemplate jackson3JsonPersonTemplate = new RedisTemplate<>(); + jackson3JsonPersonTemplate.setConnectionFactory(jedisConnFactory); + jackson3JsonPersonTemplate.setValueSerializer(jackson3JsonSerializer); + jackson3JsonPersonTemplate.afterPropertiesSet(); + // Lettuce LettuceConnectionFactory lettuceConnFactory = LettuceConnectionFactoryExtension .getConnectionFactory(RedisStandalone.class); @@ -110,6 +118,11 @@ public static Collection testParams() { jackson2JsonPersonTemplateLtc.setConnectionFactory(lettuceConnFactory); jackson2JsonPersonTemplateLtc.afterPropertiesSet(); + RedisTemplate jackson3JsonPersonTemplateLtc = new RedisTemplate<>(); + jackson3JsonPersonTemplateLtc.setValueSerializer(jackson3JsonSerializer); + jackson3JsonPersonTemplateLtc.setConnectionFactory(lettuceConnFactory); + jackson3JsonPersonTemplateLtc.afterPropertiesSet(); + RedisTemplate rawTemplateLtc = new RedisTemplate<>(); rawTemplateLtc.setConnectionFactory(lettuceConnFactory); rawTemplateLtc.setEnableDefaultSerializer(false); @@ -122,6 +135,7 @@ public static Collection testParams() { { stringFactory, xstreamStringTemplate }, // { personFactory, xstreamPersonTemplate }, // { personFactory, jackson2JsonPersonTemplate }, // + { personFactory, jackson3JsonPersonTemplate }, // { rawFactory, rawTemplate }, // lettuce @@ -132,6 +146,7 @@ public static Collection testParams() { { stringFactory, xstreamStringTemplateLtc }, // { personFactory, xstreamPersonTemplateLtc }, // { personFactory, jackson2JsonPersonTemplateLtc }, // + { personFactory, jackson3JsonPersonTemplateLtc }, // { rawFactory, rawTemplateLtc } }); } } diff --git a/src/test/java/org/springframework/data/redis/support/collections/RedisPropertiesIntegrationTests.java b/src/test/java/org/springframework/data/redis/support/collections/RedisPropertiesIntegrationTests.java index 9d9f9b277d..993a888006 100644 --- a/src/test/java/org/springframework/data/redis/support/collections/RedisPropertiesIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/support/collections/RedisPropertiesIntegrationTests.java @@ -42,6 +42,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer; import org.springframework.data.redis.serializer.OxmSerializer; import org.springframework.data.redis.test.XstreamOxmSerializerSingleton; import org.springframework.data.redis.test.extension.RedisStandalone; @@ -195,6 +196,9 @@ public static Collection testParams() { Jackson2JsonRedisSerializer jackson2JsonSerializer = new Jackson2JsonRedisSerializer<>(Person.class); Jackson2JsonRedisSerializer jackson2JsonStringSerializer = new Jackson2JsonRedisSerializer<>( String.class); + Jackson3JsonRedisSerializer jackson3JsonSerializer = new Jackson3JsonRedisSerializer<>(Person.class); + Jackson3JsonRedisSerializer jackson3JsonStringSerializer = new Jackson3JsonRedisSerializer<>( + String.class); // create Jedis Factory ObjectFactory stringFactory = new StringObjectFactory(); @@ -218,6 +222,13 @@ public static Collection testParams() { jackson2JsonPersonTemplate.setHashValueSerializer(jackson2JsonStringSerializer); jackson2JsonPersonTemplate.afterPropertiesSet(); + RedisTemplate jackson3JsonPersonTemplate = new RedisTemplate<>(); + jackson3JsonPersonTemplate.setConnectionFactory(jedisConnFactory); + jackson3JsonPersonTemplate.setDefaultSerializer(jackson3JsonSerializer); + jackson3JsonPersonTemplate.setHashKeySerializer(jackson3JsonSerializer); + jackson3JsonPersonTemplate.setHashValueSerializer(jackson3JsonStringSerializer); + jackson3JsonPersonTemplate.afterPropertiesSet(); + // Lettuce LettuceConnectionFactory lettuceConnFactory = LettuceConnectionFactoryExtension .getConnectionFactory(RedisStandalone.class, false); From 469bbde0ec032ae867dcc4407713c76ec934d3d7 Mon Sep 17 00:00:00 2001 From: Mark Paluch Date: Wed, 9 Jul 2025 12:12:08 +0200 Subject: [PATCH 5/5] Polishing. Update since tags and copyright years. Add compatibility configuration for Jackson 2 interop when using Jackson 3-based serializers. Introduce builders for configuration. Extract flattening code and introduce adapters to avoid code duplications. Re-enabled compatibility tests. Original pull request: #3168 See #3154 --- pom.xml | 11 +- .../data/redis/hash/FlatEric.java | 386 ++++++++++++ .../data/redis/hash/Jackson2HashMapper.java | 350 ++++------- .../data/redis/hash/Jackson3HashMapper.java | 561 ++++++++++-------- .../GenericJackson2JsonRedisSerializer.java | 1 + .../GenericJackson3JsonRedisSerializer.java | 357 ++++++++--- .../Jackson2JsonRedisSerializer.java | 1 + .../Jackson3JsonRedisSerializer.java | 70 +-- .../serializer/Jackson3ObjectReader.java | 16 +- .../serializer/Jackson3ObjectWriter.java | 12 +- .../mapping/AbstractHashMapperTests.java | 1 - .../mapping/Jackson3CompatibilityTests.java | 25 +- .../Jackson3FlatteningCompatibilityTests.java | 25 +- ...Jackson3HashMapperFlatteningUnitTests.java | 12 +- ...kson3HashMapperNonFlatteningUnitTests.java | 12 +- .../mapping/Jackson3HashMapperUnitTests.java | 39 +- ...cJackson3JsonRedisSerializerUnitTests.java | 78 ++- 17 files changed, 1235 insertions(+), 722 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/hash/FlatEric.java diff --git a/pom.xml b/pom.xml index 9423280078..aa93a7d888 100644 --- a/pom.xml +++ b/pom.xml @@ -91,7 +91,7 @@ spring-context-support - + redis.clients @@ -121,7 +121,8 @@ test - + + io.projectreactor reactor-core @@ -139,7 +140,6 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 - ${jackson} true @@ -149,7 +149,8 @@ true - + + tools.jackson.core jackson-databind @@ -159,7 +160,6 @@ com.fasterxml.jackson.core jackson-annotations - 3.0-rc5 true @@ -241,6 +241,7 @@ + org.jetbrains.kotlin kotlin-stdlib diff --git a/src/main/java/org/springframework/data/redis/hash/FlatEric.java b/src/main/java/org/springframework/data/redis/hash/FlatEric.java new file mode 100644 index 0000000000..83b967c90d --- /dev/null +++ b/src/main/java/org/springframework/data/redis/hash/FlatEric.java @@ -0,0 +1,386 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.hash; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.BiConsumer; + +import org.springframework.util.CollectionUtils; +import org.springframework.util.StringUtils; + +/** + * Mr. Oizo calling. + * + *
+ * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-++++++++-+#####
+ * +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---++++++++++#####
+ * -+++++++++++++++++++++++++++++++-+++++++++++++-+++-------+++++++++++++++++++++++++---+---++++++######
+ * ++++++++++-+++++++++++++++++++++++++++++++++++-----------+++++++++++++++++++++++++---++++-++--+######
+ * -+++++++++++++++++++++++++++++++++++++++++++++------------++++++++++++++++++++++++------++---++#####+
+ * +++++++++++++++++++++++++++++++++++++++++++-------------------++++++++++++++++++++++-----++---+#####+
+ * +++++++++++++++++++++++++++++++++++++++++++---+#+-------+#------++++++++++++++++++++----------+#####+
+ * +++++++++++++++++++++++++++++++++++++++++++----+---------+------++++++++++++++++++++----------+####++
+ * ++++++++++++++++++++++++++++###########+++----------------------+++++++++++++++++++++---------+#####+
+ * +++++++++++++++++++++++++++##############++----------------------++++++++++++++++++++---------+####++
+ * ++++++++++++++++++++++++++##############+++----------------------++++++++++++++++++++++-------######+
+ * ++++++++++++++++++++++++++###############+-----------------------++++++++++++++++++++--------+######+
+ * ++++++++++++++++++++++++++##############+++--------###+----------++++++++++++++++++++--------+######+
+ * ++++++#+++++++++++++++++++###############++++-------##-----------+++++++++++++++++++++-------+######+
+ * ++++++#++++++++++++++++++++##############+++++++-----------------++++++++++++++++++++++++++++########
+ * ++++++#++++++++++++++++++++##############++++----+++------------++++++++++++++++++++++++++++#########
+ * +++++++++++++++++++++++++++############+++-+++++-----------------+++++++++++++++++++++++++###########
+ * +++++++#+++++++++++++++++++###########+++++++-+-----+-------------++++++++++++++++++++++#############
+ * +++++++#+++++++++++++++++#########++++++++---++--------------------++++++++++++++++++################
+ * ++++++++#+++++++++++++++########+---+++++----++--------+------------++++++++++++++###################
+ * ++++++++#++++++++++++++#######+----++++++--------+---------------------++++++########################
+ * ++++++++#++++++++++++#######+-----+++++++----+--+----------------------++############################
+ * ++++++++#++++++++++########-------+++++++--------------------------------############################
+ * +++++++++#++++++++########+-----+###++++++--------------------------------###########################
+ * +++++++++#++++++##########------+###++----+--------------------------------##########################
+ * +++++++++#++++++#########+------###++++-+---------------------------+-------#########################
+ * +++++++++##+++###########+------##+++++-----------------------------++------+########################
+ * +++++++++###+############+------###+++++-----------------------------+++-----+#######################
+ * ++++++++++###############+-----+##++++++-----------------------------+#+-----+#######################
+ * ++++++++++###############+------##+++++------------------------------+#+------#######################
+ * +++++++++################+-------+#++++-----------------------------+##++-----+######################
+ * +++++++++#################+-------+#++++---------------------------++##++++---+######################
+ * +++++++++##################+-------+#++++------------------------+++####++----+######################
+ * ++++++++####################++-------++++++++++------+---++-+++++++###++------+######################
+ * ++++++++#####################+++-------++++++++++++++++++++++++++####+--------#######################
+ * +++++++########################+---------####++++++++++++++++++++------------########################
+ * ++++++#########################++---------++#######++###+------------------+#########################
+ * ++++############################++---------++++######++++++---------------+##########################
+ * ++##################################++---+-++++###+------++---------------###########################
+ * ####################################++++++++++++-----------++-+-----------+##########################
+ * ####################################++++++++----------------+##+----#+----###########################
+ * #####################################+++----------------++###########+---+###########################
+ * ###################################++---------------+++------+++#######+++###########################
+ * ##############################+++----------------+++++++-++--------++################################
+ * #########################++------------------+++++++++++++++-+-------+####+#+++######################
+ * ######################+--------------------+#####+++++++++++++--------++#++#+++++++#+################
+ * ####################+--------------------##############++++++----------+++++#+++++++#+###############
+ * ####################-------------------+##################++++---------++++++++++++++++++############
+ * ####################-----------------+++++++++++++##########++--------+++++++++++++++++++++++########
+ * #############+######+-------------++++++++++++++++++++#######+-------+++++++++++++++++++++++++++#####
+ * #########+###+#++++#++---------++++++++++++++++++++++++########+++-+++++++++++++++++++++++++++++++###
+ * #######++++++++++#++##++-----+++++++++++++++++++++++++++++++###++++++++++++++++++++++++++++++++++++++
+ * #####+++##++#+++#+++##+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
+ * 
+ */ +class FlatEric { + + /** + * Un-Flatten a Map with dot notation keys into a Map with nested Maps and Lists. + * + * @param source source map. + * @return + */ + @SuppressWarnings("unchecked") + static Map unflatten(Map source) { + + Map result = CollectionUtils.newLinkedHashMap(source.size()); + Set treatSeparate = CollectionUtils.newLinkedHashSet(source.size()); + + for (Map.Entry entry : source.entrySet()) { + + String key = entry.getKey(); + String[] keyParts = key.split("\\."); + + if (keyParts.length == 1 && isNotIndexed(keyParts[0])) { + result.put(key, entry.getValue()); + } else if (keyParts.length == 1 && isIndexed(keyParts[0])) { + + String indexedKeyName = keyParts[0]; + String nonIndexedKeyName = stripIndex(indexedKeyName); + + int index = getIndex(indexedKeyName); + + if (result.containsKey(nonIndexedKeyName)) { + addValueToTypedListAtIndex((List) result.get(nonIndexedKeyName), index, entry.getValue()); + } else { + result.put(nonIndexedKeyName, createTypedListWithValue(index, entry.getValue())); + } + } else { + treatSeparate.add(keyParts[0]); + } + } + + for (String partial : treatSeparate) { + + Map newSource = new LinkedHashMap<>(); + + // Copies all nested, dot properties from the source Map to the new Map beginning from + // the next nested (dot) property + for (Map.Entry entry : source.entrySet()) { + String key = entry.getKey(); + if (key.startsWith(partial)) { + String keyAfterDot = key.substring(partial.length() + 1); + newSource.put(keyAfterDot, entry.getValue()); + } + } + + if (isNonNestedIndexed(partial)) { + + String nonIndexPartial = stripIndex(partial); + int index = getIndex(partial); + + if (result.containsKey(nonIndexPartial)) { + addValueToTypedListAtIndex((List) result.get(nonIndexPartial), index, unflatten(newSource)); + } else { + result.put(nonIndexPartial, createTypedListWithValue(index, unflatten(newSource))); + } + } else { + result.put(partial, unflatten(newSource)); + } + } + + return result; + } + + @SuppressWarnings("unchecked") + private static void addValueToTypedListAtIndex(List listWithTypeHint, int index, Object value) { + + List valueList = (List) listWithTypeHint.get(1); + + if (index >= valueList.size()) { + int initialCapacity = index + 1; + List newValueList = new ArrayList<>(initialCapacity); + Collections.copy(org.springframework.data.redis.support.collections.CollectionUtils.initializeList(newValueList, + initialCapacity), valueList); + listWithTypeHint.set(1, newValueList); + valueList = newValueList; + } + + valueList.set(index, value); + } + + private static List createTypedListWithValue(int index, Object value) { + + int initialCapacity = index + 1; + + List valueList = org.springframework.data.redis.support.collections.CollectionUtils + .initializeList(new ArrayList<>(initialCapacity), initialCapacity); + valueList.set(index, value); + + List listWithTypeHint = new ArrayList<>(); + listWithTypeHint.add(ArrayList.class.getName()); + listWithTypeHint.add(valueList); + + return listWithTypeHint; + } + + private static boolean isIndexed(String value) { + return value.indexOf('[') > -1; + } + + private static boolean isNotIndexed(String value) { + return !isIndexed(value); + } + + private static boolean isNonNestedIndexed(String value) { + return value.endsWith("]"); + } + + private static int getIndex(String indexedValue) { + return Integer.parseInt(indexedValue.substring(indexedValue.indexOf('[') + 1, indexedValue.length() - 1)); + } + + private static String stripIndex(String indexedValue) { + + int indexOfLeftBracket = indexedValue.indexOf("["); + + return indexOfLeftBracket > -1 ? indexedValue.substring(0, indexOfLeftBracket) : indexedValue; + } + + /** + * Flatten a {@link Map} containing nested values, Maps and Lists into a map with property path (dot-notation) keys. + * + * @param adapterFactory + * @param source + * @return + * @param + */ + static Map flatten(FlatEric.JsonNodeAdapterFactory adapterFactory, + Collection> source) { + + Map resultMap = new HashMap<>(source.size()); + flatten(adapterFactory, "", source, resultMap::put); + return resultMap; + } + + private static void flatten(FlatEric.JsonNodeAdapterFactory adapterFactory, String propertyPrefix, + Collection> inputMap, BiConsumer sink) { + + if (StringUtils.hasText(propertyPrefix)) { + propertyPrefix = propertyPrefix + "."; + } + + for (Map.Entry entry : inputMap) { + flattenElement(adapterFactory, propertyPrefix + entry.getKey(), entry.getValue(), sink); + } + } + + private static void flattenElement(FlatEric.JsonNodeAdapterFactory adapterFactory, String propertyPrefix, + Object source, BiConsumer sink) { + + if (!adapterFactory.isJsonNode(source)) { + sink.accept(propertyPrefix, source); + return; + } + + FlatEric.JsonNodeAdapter adapter = adapterFactory.adapt(source); + + if (adapter.isArray()) { + + Iterator nodes = adapter.values().iterator(); + + while (nodes.hasNext()) { + + FlatEric.JsonNodeAdapter currentNode = nodes.next(); + + if (currentNode.isArray()) { + flattenCollection(adapterFactory, propertyPrefix, currentNode.values(), sink); + } else if (nodes.hasNext() && mightBeJavaType(currentNode)) { + + FlatEric.JsonNodeAdapter next = nodes.next(); + + if (next.isArray()) { + flattenCollection(adapterFactory, propertyPrefix, next.values(), sink); + } + if (currentNode.asString().equals("java.util.Date")) { + sink.accept(propertyPrefix, next.asString()); + break; + } + if (next.isNumber()) { + sink.accept(propertyPrefix, next.numberValue()); + break; + } + if (next.isString()) { + sink.accept(propertyPrefix, next.stringValue()); + break; + } + if (next.isBoolean()) { + sink.accept(propertyPrefix, next.booleanValue()); + break; + } + if (next.isBinary()) { + + try { + sink.accept(propertyPrefix, next.binaryValue()); + } catch (Exception ex) { + throw new IllegalStateException("Cannot read binary value '%s'".formatted(propertyPrefix), ex); + } + + break; + } + } + } + } else if (adapter.isObject()) { + flatten(adapterFactory, propertyPrefix, adapter.properties(), sink); + } else { + + switch (adapter.getNodeType()) { + case STRING -> sink.accept(propertyPrefix, adapter.stringValue()); + case NUMBER -> sink.accept(propertyPrefix, adapter.numberValue()); + case BOOLEAN -> sink.accept(propertyPrefix, adapter.booleanValue()); + case BINARY -> { + try { + sink.accept(propertyPrefix, adapter.binaryValue()); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + default -> sink.accept(propertyPrefix, adapter.getDirectValue()); + } + } + } + + private static boolean mightBeJavaType(FlatEric.JsonNodeAdapter node) { + + String textValue = node.asString(); + return javax.lang.model.SourceVersion.isName(textValue); + } + + private static void flattenCollection(FlatEric.JsonNodeAdapterFactory adapterFactory, String propertyPrefix, + Collection list, BiConsumer sink) { + + Iterator iterator = list.iterator(); + + for (int counter = 0; iterator.hasNext(); counter++) { + FlatEric.JsonNodeAdapter element = iterator.next(); + flattenElement(adapterFactory, propertyPrefix + "[" + counter + "]", element, sink); + } + } + + /** + * Factory to create a {@link JsonNodeAdapter} from a given node type. + */ + public interface JsonNodeAdapterFactory { + + JsonNodeAdapter adapt(Object node); + + boolean isJsonNode(Object value); + } + + public enum JsonNodeType { + ARRAY, BINARY, BOOLEAN, MISSING, NULL, NUMBER, OBJECT, POJO, STRING + } + + /** + * Wrapper around a JSON node to adapt between Jackson 2 and 3 JSON nodes. + */ + public interface JsonNodeAdapter { + + JsonNodeType getNodeType(); + + boolean isArray(); + + Collection values(); + + String asString(); + + boolean isNumber(); + + Number numberValue(); + + boolean isString(); + + String stringValue(); + + boolean isBoolean(); + + boolean booleanValue(); + + boolean isBinary(); + + byte[] binaryValue(); + + boolean isObject(); + + Collection> properties(); + + Object getDirectValue(); + } + +} diff --git a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java index 0ef9314be7..cd2ebc6b7f 100644 --- a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java +++ b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java @@ -19,18 +19,18 @@ import java.io.IOException; import java.text.ParseException; -import java.util.*; +import java.util.Calendar; +import java.util.Collection; +import java.util.Date; +import java.util.Map; import java.util.Map.Entry; import org.jspecify.annotations.Nullable; import org.springframework.data.mapping.MappingException; -import org.springframework.data.redis.support.collections.CollectionUtils; import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper; import org.springframework.util.Assert; -import org.springframework.util.ClassUtils; import org.springframework.util.NumberUtils; -import org.springframework.util.StringUtils; import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; @@ -65,6 +65,7 @@ * Flattening requires all property names to not interfere with JSON paths. Using dots or brackets in map keys or as * property names is not supported using flattening. The resulting hash cannot be mapped back into an Object. *

Example

+ * *
  * class Person {
  * 	String firstname;
@@ -143,14 +144,11 @@
  * @author Mark Paluch
  * @author John Blum
  * @since 1.8
- * @deprecated since 4.0
+ * @deprecated since 4.0 in favor of {@link org.springframework.data.redis.hash.Jackson3HashMapper}.
  */
 @Deprecated(since = "4.0", forRemoval = true)
 public class Jackson2HashMapper implements HashMapper {
 
-	private static final boolean SOURCE_VERSION_PRESENT =
-			ClassUtils.isPresent("javax.lang.model.SourceVersion", Jackson2HashMapper.class.getClassLoader());
-
 	private final ObjectMapper typingMapper;
 	private final ObjectMapper untypedMapper;
 	private final boolean flatten;
@@ -234,7 +232,8 @@ public Map toHash(@Nullable Object source) {
 
 		JsonNode tree = this.typingMapper.valueToTree(source);
 
-		return this.flatten ? flattenMap(tree.properties().iterator()) : this.untypedMapper.convertValue(tree, Map.class);
+		return this.flatten ? FlatEric.flatten(Jackson2AdapterFactory.INSTANCE, tree.properties())
+				: this.untypedMapper.convertValue(tree, Map.class);
 	}
 
 	@Override
@@ -244,7 +243,7 @@ public Map toHash(@Nullable Object source) {
 		try {
 			if (this.flatten) {
 
-				Map unflattenedHash = doUnflatten(hash);
+				Map unflattenedHash = FlatEric.unflatten(hash);
 				byte[] unflattenedHashedBytes = this.untypedMapper.writeValueAsBytes(unflattenedHash);
 				Object hashedObject = this.typingMapper.reader().forType(Object.class)
 						.readValue(unflattenedHashedBytes);
@@ -259,236 +258,6 @@ public Map toHash(@Nullable Object source) {
 		}
 	}
 
-	@SuppressWarnings("unchecked")
-	private Map doUnflatten(Map source) {
-
-		Map result = new LinkedHashMap<>();
-		Set treatSeparate = new LinkedHashSet<>();
-
-		for (Entry entry : source.entrySet()) {
-
-			String key = entry.getKey();
-			String[] keyParts = key.split("\\.");
-
-			if (keyParts.length == 1 && isNotIndexed(keyParts[0])) {
-				result.put(key, entry.getValue());
-			} else if (keyParts.length == 1 && isIndexed(keyParts[0])) {
-
-				String indexedKeyName = keyParts[0];
-				String nonIndexedKeyName = stripIndex(indexedKeyName);
-
-				int index = getIndex(indexedKeyName);
-
-				if (result.containsKey(nonIndexedKeyName)) {
-					addValueToTypedListAtIndex((List) result.get(nonIndexedKeyName), index, entry.getValue());
-				}
-				else {
-					result.put(nonIndexedKeyName, createTypedListWithValue(index, entry.getValue()));
-				}
-			} else {
-				treatSeparate.add(keyParts[0]);
-			}
-		}
-
-		for (String partial : treatSeparate) {
-
-			Map newSource = new LinkedHashMap<>();
-
-			// Copies all nested, dot properties from the source Map to the new Map beginning from
-			// the next nested (dot) property
-			for (Entry entry : source.entrySet()) {
-				String key = entry.getKey();
-				if (key.startsWith(partial)) {
-					String keyAfterDot = key.substring(partial.length() + 1);
-					newSource.put(keyAfterDot, entry.getValue());
-				}
-			}
-
-			if (isNonNestedIndexed(partial)) {
-
-				String nonIndexPartial = stripIndex(partial);
-				int index = getIndex(partial);
-
-				if (result.containsKey(nonIndexPartial)) {
-					addValueToTypedListAtIndex((List) result.get(nonIndexPartial), index, doUnflatten(newSource));
-				} else {
-					result.put(nonIndexPartial, createTypedListWithValue(index, doUnflatten(newSource)));
-				}
-			} else {
-				result.put(partial, doUnflatten(newSource));
-			}
-		}
-
-		return result;
-	}
-
-	private boolean isIndexed( String value) {
-		return value.indexOf('[') > -1;
-	}
-
-	private boolean isNotIndexed( String value) {
-		return !isIndexed(value);
-	}
-
-	private boolean isNonNestedIndexed( String value) {
-		return value.endsWith("]");
-	}
-
-	private int getIndex( String indexedValue) {
-		return Integer.parseInt(indexedValue.substring(indexedValue.indexOf('[') + 1, indexedValue.length() - 1));
-	}
-
-	private  String stripIndex( String indexedValue) {
-
-		int indexOfLeftBracket = indexedValue.indexOf("[");
-
-		return indexOfLeftBracket > -1
-			? indexedValue.substring(0, indexOfLeftBracket)
-			: indexedValue;
-	}
-
-	private Map flattenMap(Iterator> source) {
-
-		Map resultMap = new HashMap<>();
-		doFlatten("", source, resultMap);
-		return resultMap;
-	}
-
-	private void doFlatten(String propertyPrefix, Iterator> inputMap,
-			Map resultMap) {
-
-		if (StringUtils.hasText(propertyPrefix)) {
-			propertyPrefix = propertyPrefix + ".";
-		}
-
-		while (inputMap.hasNext()) {
-			Entry entry = inputMap.next();
-			flattenElement(propertyPrefix + entry.getKey(), entry.getValue(), resultMap);
-		}
-	}
-
-	private void flattenElement(String propertyPrefix, Object source, Map resultMap) {
-
-		if (!(source instanceof JsonNode element)) {
-			resultMap.put(propertyPrefix, source);
-			return;
-		}
-
-		if (element.isArray()) {
-
-			Iterator nodes = element.elements();
-
-			while (nodes.hasNext()) {
-
-				JsonNode currentNode = nodes.next();
-
-				if (currentNode.isArray()) {
-					flattenCollection(propertyPrefix, currentNode.elements(), resultMap);
-				} else if (nodes.hasNext() && mightBeJavaType(currentNode)) {
-
-					JsonNode next = nodes.next();
-
-					if (next.isArray()) {
-						flattenCollection(propertyPrefix, next.elements(), resultMap);
-					}
-					if (currentNode.asText().equals("java.util.Date")) {
-						resultMap.put(propertyPrefix, next.asText());
-						break;
-					}
-					if (next.isNumber()) {
-						resultMap.put(propertyPrefix, next.numberValue());
-						break;
-					}
-					if (next.isTextual()) {
-						resultMap.put(propertyPrefix, next.textValue());
-						break;
-					}
-					if (next.isBoolean()) {
-						resultMap.put(propertyPrefix, next.booleanValue());
-						break;
-					}
-					if (next.isBinary()) {
-
-						try {
-							resultMap.put(propertyPrefix, next.binaryValue());
-						}
-						catch (IOException ex) {
-							throw new IllegalStateException("Cannot read binary value '%s'".formatted(propertyPrefix), ex);
-						}
-
-						break;
-					}
-				}
-			}
-		} else if (element.isContainerNode()) {
-			doFlatten(propertyPrefix, element.properties().iterator(), resultMap);
-		} else {
-
-			switch (element.getNodeType()) {
-				case STRING -> resultMap.put(propertyPrefix, element.textValue());
-				case NUMBER -> resultMap.put(propertyPrefix, element.numberValue());
-				case BOOLEAN -> resultMap.put(propertyPrefix, element.booleanValue());
-				case BINARY -> {
-					try {
-						resultMap.put(propertyPrefix, element.binaryValue());
-					} catch (IOException e) {
-						throw new IllegalStateException(e);
-					}
-				}
-				default -> resultMap.put(propertyPrefix, new DirectFieldAccessFallbackBeanWrapper(element).getPropertyValue("_value"));
-			}
-		}
-	}
-
-	private boolean mightBeJavaType(JsonNode node) {
-
-		String textValue = node.asText();
-
-		if (!SOURCE_VERSION_PRESENT) {
-			return Arrays.asList("java.util.Date", "java.math.BigInteger", "java.math.BigDecimal").contains(textValue);
-		}
-
-		return javax.lang.model.SourceVersion.isName(textValue);
-	}
-
-	private void flattenCollection(String propertyPrefix, Iterator list, Map resultMap) {
-
-		for (int counter = 0; list.hasNext(); counter++) {
-			JsonNode element = list.next();
-			flattenElement(propertyPrefix + "[" + counter + "]", element, resultMap);
-		}
-	}
-
-	@SuppressWarnings("unchecked")
-	private void addValueToTypedListAtIndex(List listWithTypeHint, int index, Object value) {
-
-		List valueList = (List) listWithTypeHint.get(1);
-
-		if (index >= valueList.size()) {
-			int initialCapacity = index + 1;
-			List newValueList = new ArrayList<>(initialCapacity);
-			Collections.copy(CollectionUtils.initializeList(newValueList, initialCapacity), valueList);
-			listWithTypeHint.set(1, newValueList);
-			valueList = newValueList;
-		}
-
-		valueList.set(index, value);
-	}
-
-	private List createTypedListWithValue(int index, Object value) {
-
-		int initialCapacity = index + 1;
-
-		List valueList = CollectionUtils.initializeList(new ArrayList<>(initialCapacity), initialCapacity);
-		valueList.set(index, value);
-
-		List listWithTypeHint = new ArrayList<>();
-		listWithTypeHint.add(ArrayList.class.getName());
-		listWithTypeHint.add(valueList);
-
-		return listWithTypeHint;
-	}
-
 	private static class HashMapperModule extends SimpleModule {
 
 		HashMapperModule() {
@@ -614,4 +383,105 @@ protected boolean _asTimestamp(SerializerProvider serializers) {
 
 	}
 
+	private enum Jackson2AdapterFactory implements FlatEric.JsonNodeAdapterFactory {
+
+		INSTANCE;
+
+		@Override
+		public FlatEric.JsonNodeAdapter adapt(Object node) {
+			return node instanceof FlatEric.JsonNodeAdapter na ? na : new Jackson2JsonNodeAdapter((JsonNode) node);
+		}
+
+		@Override
+		public boolean isJsonNode(Object value) {
+			return value instanceof JsonNode || value instanceof FlatEric.JsonNodeAdapter;
+		}
+	}
+
+	private record Jackson2JsonNodeAdapter(JsonNode node) implements FlatEric.JsonNodeAdapter {
+
+		@Override
+		public FlatEric.JsonNodeType getNodeType() {
+			return FlatEric.JsonNodeType.valueOf(node().getNodeType().name());
+		}
+
+		@Override
+		public boolean isArray() {
+			return node().isArray();
+		}
+
+		@Override
+		public Collection values() {
+			return node().valueStream().map(Jackson2JsonNodeAdapter::new).toList();
+		}
+
+		@Override
+		public String asString() {
+			return node().asText();
+		}
+
+		@Override
+		public boolean isNumber() {
+			return node().isNumber();
+		}
+
+		@Override
+		public Number numberValue() {
+			return node().numberValue();
+		}
+
+		@Override
+		public boolean isString() {
+			return node().isTextual();
+		}
+
+		@Override
+		public String stringValue() {
+			return node().asText();
+		}
+
+		@Override
+		public boolean isBoolean() {
+			return node().isBoolean();
+		}
+
+		@Override
+		public boolean booleanValue() {
+			return node().booleanValue();
+		}
+
+		@Override
+		public boolean isBinary() {
+			return node().isBinary();
+		}
+
+		@Override
+		public byte[] binaryValue() {
+
+			try {
+				return node().binaryValue();
+			} catch (IOException e) {
+				throw new IllegalStateException(e);
+			}
+		}
+
+		@Override
+		public boolean isObject() {
+			return node().isObject();
+		}
+
+		@Override
+		public Collection> properties() {
+			return node().propertyStream()
+					.map(it -> Map.entry(it.getKey(), (FlatEric.JsonNodeAdapter) new Jackson2JsonNodeAdapter(it.getValue())))
+					.toList();
+		}
+
+		@Override
+		public Object getDirectValue() {
+			return new DirectFieldAccessFallbackBeanWrapper(node()).getPropertyValue("_value");
+		}
+
+	}
+
 }
diff --git a/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java b/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java
index 250564f7b8..f92bd61065 100644
--- a/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java
+++ b/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2016-2025 the original author or authors.
+ * Copyright 2025 the original author or authors.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
@@ -18,16 +18,9 @@
 import tools.jackson.core.JacksonException;
 import tools.jackson.core.JsonGenerator;
 import tools.jackson.core.JsonParser;
+import tools.jackson.core.TreeNode;
 import tools.jackson.core.Version;
-import tools.jackson.databind.DefaultTyping;
-import tools.jackson.databind.DeserializationContext;
-import tools.jackson.databind.DeserializationFeature;
-import tools.jackson.databind.JacksonModule;
-import tools.jackson.databind.JsonNode;
-import tools.jackson.databind.ObjectMapper;
-import tools.jackson.databind.SerializationContext;
-import tools.jackson.databind.ValueDeserializer;
-import tools.jackson.databind.ValueSerializer;
+import tools.jackson.databind.*;
 import tools.jackson.databind.cfg.MapperBuilder;
 import tools.jackson.databind.deser.jdk.JavaUtilCalendarDeserializer;
 import tools.jackson.databind.deser.jdk.JavaUtilDateDeserializer;
@@ -36,11 +29,13 @@
 import tools.jackson.databind.deser.std.StdDeserializer;
 import tools.jackson.databind.exc.MismatchedInputException;
 import tools.jackson.databind.json.JsonMapper;
-import tools.jackson.databind.json.JsonMapper.Builder;
 import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
+import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
 import tools.jackson.databind.jsontype.TypeDeserializer;
+import tools.jackson.databind.jsontype.TypeResolverBuilder;
 import tools.jackson.databind.jsontype.TypeSerializer;
 import tools.jackson.databind.jsontype.impl.AsPropertyTypeDeserializer;
+import tools.jackson.databind.jsontype.impl.DefaultTypeResolverBuilder;
 import tools.jackson.databind.module.SimpleDeserializers;
 import tools.jackson.databind.module.SimpleSerializers;
 import tools.jackson.databind.ser.Serializers;
@@ -51,28 +46,24 @@
 import java.math.BigInteger;
 import java.time.ZoneId;
 import java.util.ArrayList;
-import java.util.Arrays;
 import java.util.Calendar;
 import java.util.Collection;
-import java.util.Collections;
 import java.util.Date;
-import java.util.HashMap;
-import java.util.Iterator;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.Map.Entry;
-import java.util.Set;
 import java.util.TimeZone;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 
+import org.jspecify.annotations.Nullable;
+
 import org.springframework.data.mapping.MappingException;
-import org.springframework.data.redis.support.collections.CollectionUtils;
 import org.springframework.data.util.DirectFieldAccessFallbackBeanWrapper;
+import org.springframework.data.util.Lazy;
+import org.springframework.lang.Contract;
 import org.springframework.util.Assert;
-import org.springframework.util.ClassUtils;
-import org.springframework.util.StringUtils;
 
 import com.fasterxml.jackson.annotation.JsonInclude.Include;
 
@@ -81,10 +72,16 @@
  * with an {@code Address} like below the flattening will create individual hash entries for all nested properties and
  * resolve complex types into simple types, as far as possible.
  * 

+ * Creation can be configured using {@link #builder()} to enable Jackson 2 compatibility mode (when migrating existing + * data from Jackson 2) or to attach a custom {@link MapperBuilder} configurer. + *

+ * By default, JSON mapping uses default typing. Make sure to configure an appropriate {@link PolymorphicTypeValidator} + * to prevent instantiation of unwanted types. + *

* Flattening requires all property names to not interfere with JSON paths. Using dots or brackets in map keys or as * property names is not supported using flattening. The resulting hash cannot be mapped back into an Object. *

Example

- * + * *
  * class Person {
  * 	String firstname;
@@ -160,38 +157,22 @@
  * 
  *
  * @author Christoph Strobl
+ * @author Mark Paluch
  * @since 4.0
  */
 public class Jackson3HashMapper implements HashMapper {
 
-	private static final boolean SOURCE_VERSION_PRESENT = ClassUtils.isPresent("javax.lang.model.SourceVersion",
-			Jackson3HashMapper.class.getClassLoader());
+	private static final Lazy sharedFlattening = Lazy
+			.of(() -> create(Jackson3HashMapperBuilder::flatten));
+	private static final Lazy sharedHierarchical = Lazy
+			.of(() -> create(Jackson3HashMapperBuilder::hierarchical));
 
 	private final ObjectMapper typingMapper;
 	private final ObjectMapper untypedMapper;
 	private final boolean flatten;
 
-	public Jackson3HashMapper(
-			Consumer>> jsonMapperBuilder,
-			boolean flatten) {
-		this(((Supplier) () -> {
-			Builder builder = JsonMapper.builder();
-			jsonMapperBuilder.accept(builder);
-			return builder.build();
-		}).get(), flatten);
-	}
-
-	public static void preconfigure(MapperBuilder> builder) {
-		builder.findAndAddModules().addModules(new HashMapperModule())
-				.activateDefaultTypingAsProperty(BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class)
-						.allowIfSubType((ctx, clazz) -> true).build(), DefaultTyping.NON_FINAL, "@class")
-				.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false)
-				.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
-				.changeDefaultPropertyInclusion(value -> value.withValueInclusion(Include.NON_NULL));
-	}
-
 	/**
-	 * Creates new {@link Jackson3HashMapper} initialized with a custom Jackson {@link ObjectMapper}.
+	 * Creates a new {@link Jackson3HashMapper} initialized with a custom Jackson {@link ObjectMapper}.
 	 *
 	 * @param mapper Jackson {@link ObjectMapper} used to de/serialize hashed {@link Object objects}; must not be
 	 *          {@literal null}.
@@ -207,12 +188,92 @@ public Jackson3HashMapper(ObjectMapper mapper, boolean flatten) {
 		this.untypedMapper = JsonMapper.shared();
 	}
 
+	/**
+	 * Returns a flattening {@link Jackson3HashMapper} using {@literal dot notation} for properties.
+	 *
+	 * @return a flattening {@link Jackson3HashMapper} instance.
+	 */
+	public static Jackson3HashMapper flattening() {
+		return sharedFlattening.get();
+	}
+
+	/**
+	 * Returns a {@link Jackson3HashMapper} retain the hierarchical node structure created by Jackson.
+	 *
+	 * @return a hierarchical {@link Jackson3HashMapper} instance.
+	 */
+	public static Jackson3HashMapper hierarchical() {
+		return sharedHierarchical.get();
+	}
+
+	/**
+	 * Creates a new {@link Jackson3HashMapper} allowing further configuration through {@code configurer}.
+	 *
+	 * @param configurer the configurer for {@link Jackson3HashMapperBuilder}.
+	 * @return a new {@link Jackson3HashMapper} instance.
+	 */
+	public static Jackson3HashMapper create(
+			Consumer> configurer) {
+
+		Assert.notNull(configurer, "Builder configurer must not be null");
+
+		Jackson3HashMapperBuilder builder = builder();
+		configurer.accept(builder);
+
+		return builder.build();
+	}
+
+	/**
+	 * Creates a {@link Jackson3HashMapperBuilder} to build a {@link Jackson3HashMapper} instance using
+	 * {@link JsonMapper}.
+	 *
+	 * @return a {@link Jackson3HashMapperBuilder} to build a {@link Jackson3HashMapper} instance.
+	 */
+	public static Jackson3HashMapperBuilder builder() {
+		return builder(JsonMapper::builder);
+	}
+
+	/**
+	 * Creates a new {@link Jackson3HashMapperBuilder} to configure and build a {@link Jackson3HashMapper}.
+	 *
+	 * @param builderFactory factory to create a {@link MapperBuilder} for the {@link ObjectMapper}.
+	 * @param  type of the {@link MapperBuilder} to use.
+	 * @return a new {@link Jackson3HashMapperBuilder}.
+	 */
+	public static >> Jackson3HashMapperBuilder builder(
+			Supplier builderFactory) {
+
+		Assert.notNull(builderFactory, "MapperBuilder Factory must not be null");
+
+		return new Jackson3HashMapperBuilder<>(builderFactory);
+	}
+
+	/**
+	 * Preconfigures the given {@link MapperBuilder} to create a Jackson {@link ObjectMapper} that is suitable for
+	 * HashMapper use.
+	 *
+	 * @param builder the {@link MapperBuilder} to preconfigure.
+	 * @param jackson2Compatibility whether to apply Jackson 2.x compatibility settings to read values written by
+	 *          {@link Jackson2HashMapper}.
+	 */
+	public static void preconfigure(MapperBuilder> builder,
+			boolean jackson2Compatibility) {
+
+		builder.findAndAddModules() //
+				.addModules(new HashMapperModule(jackson2Compatibility)) //
+				.disable(MapperFeature.REQUIRE_TYPE_ID_FOR_SUBTYPES) //
+				.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false)
+				.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
+				.changeDefaultPropertyInclusion(value -> value.withValueInclusion(Include.NON_NULL));
+	}
+
 	@Override
 	@SuppressWarnings("unchecked")
-	public Map toHash(Object source) {
+	public Map toHash(@Nullable Object source) {
 
 		JsonNode tree = this.typingMapper.valueToTree(source);
-		return this.flatten ? flattenMap(tree.properties()) : this.untypedMapper.convertValue(tree, Map.class);
+		return this.flatten ? FlatEric.flatten(Jackson3AdapterFactory.INSTANCE, tree.properties())
+				: this.untypedMapper.convertValue(tree, Map.class);
 	}
 
 	@Override
@@ -222,7 +283,7 @@ public Object fromHash(Map hash) {
 		try {
 			if (this.flatten) {
 
-				Map unflattenedHash = doUnflatten(hash);
+				Map unflattenedHash = FlatEric.unflatten(hash);
 				byte[] unflattenedHashedBytes = this.untypedMapper.writeValueAsBytes(unflattenedHash);
 				Object hashedObject = this.typingMapper.reader().forType(Object.class).readValue(unflattenedHashedBytes);
 
@@ -236,234 +297,151 @@ public Object fromHash(Map hash) {
 		}
 	}
 
-	@SuppressWarnings("unchecked")
-	private Map doUnflatten(Map source) {
-
-		Map result = org.springframework.util.CollectionUtils.newLinkedHashMap(source.size());
-		Set treatSeparate = org.springframework.util.CollectionUtils.newLinkedHashSet(source.size());
-
-		for (Entry entry : source.entrySet()) {
 
-			String key = entry.getKey();
-			String[] keyParts = key.split("\\.");
-
-			if (keyParts.length == 1 && isNotIndexed(keyParts[0])) {
-				result.put(key, entry.getValue());
-			} else if (keyParts.length == 1 && isIndexed(keyParts[0])) {
+	/**
+	 * Builder to create a {@link Jackson3HashMapper} instance.
+	 *
+	 * @param  type of the {@link MapperBuilder}.
+	 */
+	public static class Jackson3HashMapperBuilder>> {
 
-				String indexedKeyName = keyParts[0];
-				String nonIndexedKeyName = stripIndex(indexedKeyName);
+		private final Supplier builderFactory;
 
-				int index = getIndex(indexedKeyName);
+		private PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder()
+				.allowIfBaseType(Object.class).allowIfSubType((ctx, clazz) -> true).build();
+		private boolean jackson2CompatibilityMode = false;
+		private boolean flatten = false;
+		private Consumer mapperBuilderCustomizer = builder -> {};
 
-				if (result.containsKey(nonIndexedKeyName)) {
-					addValueToTypedListAtIndex((List) result.get(nonIndexedKeyName), index, entry.getValue());
-				} else {
-					result.put(nonIndexedKeyName, createTypedListWithValue(index, entry.getValue()));
-				}
-			} else {
-				treatSeparate.add(keyParts[0]);
-			}
+		private Jackson3HashMapperBuilder(Supplier builderFactory) {
+			this.builderFactory = builderFactory;
 		}
 
-		for (String partial : treatSeparate) {
-
-			Map newSource = new LinkedHashMap<>();
-
-			// Copies all nested, dot properties from the source Map to the new Map beginning from
-			// the next nested (dot) property
-			for (Entry entry : source.entrySet()) {
-				String key = entry.getKey();
-				if (key.startsWith(partial)) {
-					String keyAfterDot = key.substring(partial.length() + 1);
-					newSource.put(keyAfterDot, entry.getValue());
-				}
-			}
-
-			if (isNonNestedIndexed(partial)) {
-
-				String nonIndexPartial = stripIndex(partial);
-				int index = getIndex(partial);
-
-				if (result.containsKey(nonIndexPartial)) {
-					addValueToTypedListAtIndex((List) result.get(nonIndexPartial), index, doUnflatten(newSource));
-				} else {
-					result.put(nonIndexPartial, createTypedListWithValue(index, doUnflatten(newSource)));
-				}
-			} else {
-				result.put(partial, doUnflatten(newSource));
-			}
+		/**
+		 * Use a flattened representation using {@literal dot notation}. The default is to use {@link #hierarchical()}.
+		 *
+		 * @return {@code this} builder.
+		 */
+		@Contract("-> this")
+		public Jackson3HashMapperBuilder flatten() {
+			return flatten(true);
 		}
 
-		return result;
-	}
-
-	private boolean isIndexed(String value) {
-		return value.indexOf('[') > -1;
-	}
-
-	private boolean isNotIndexed(String value) {
-		return !isIndexed(value);
-	}
-
-	private boolean isNonNestedIndexed(String value) {
-		return value.endsWith("]");
-	}
-
-	private int getIndex(String indexedValue) {
-		return Integer.parseInt(indexedValue.substring(indexedValue.indexOf('[') + 1, indexedValue.length() - 1));
-	}
-
-	private String stripIndex(String indexedValue) {
-
-		int indexOfLeftBracket = indexedValue.indexOf("[");
-
-		return indexOfLeftBracket > -1 ? indexedValue.substring(0, indexOfLeftBracket) : indexedValue;
-	}
-
-	private Map flattenMap(Set> source) {
-
-		Map resultMap = new HashMap<>();
-		doFlatten("", source, resultMap);
-		return resultMap;
-	}
-
-	private void doFlatten(String propertyPrefix, Set> inputMap, Map resultMap) {
-
-		if (StringUtils.hasText(propertyPrefix)) {
-			propertyPrefix = propertyPrefix + ".";
+		/**
+		 * Use a hierarchical node structure as created by Jackson. This is the default behavior.
+		 *
+		 * @return {@code this} builder.
+		 */
+		@Contract("-> this")
+		public Jackson3HashMapperBuilder hierarchical() {
+			return flatten(false);
 		}
 
-		for (Entry entry : inputMap) {
-			flattenElement(propertyPrefix + entry.getKey(), entry.getValue(), resultMap);
+		/**
+		 * Configure whether to flatten the resulting hash using {@literal dot notation} for properties. The default is to
+		 * use {@link #hierarchical()}.
+		 *
+		 * @param flatten boolean used to configure whether JSON de/serialized {@link Object} properties will be
+		 *          un/flattened using {@literal dot notation}, or whether to retain the hierarchical node structure created
+		 *          by Jackson.
+		 * @return {@code this} builder.
+		 */
+		@Contract("_ -> this")
+		public Jackson3HashMapperBuilder flatten(boolean flatten) {
+			this.flatten = flatten;
+			return this;
 		}
-	}
-
-	private void flattenElement(String propertyPrefix, Object source, Map resultMap) {
 
-		if (!(source instanceof JsonNode element)) {
-			resultMap.put(propertyPrefix, source);
-			return;
+		/**
+		 * Enable Jackson 2 compatibility mode. This enables reading values written by {@link Jackson2HashMapper} and
+		 * writing values that can be read by {@link Jackson2HashMapper}.
+		 *
+		 * @return {@code this} builder.
+		 */
+		@Contract("-> this")
+		public Jackson3HashMapperBuilder jackson2CompatibilityMode() {
+			this.jackson2CompatibilityMode = true;
+			return this;
 		}
 
-		if (element.isArray()) {
+		/**
+		 * Provide a {@link PolymorphicTypeValidator} to validate polymorphic types during deserialization.
+		 *
+		 * @param typeValidator the validator to use, defaults to a permissive validator that allows all types.
+		 * @return {@code this} builder.
+		 */
+		@Contract("_ -> this")
+		public Jackson3HashMapperBuilder typeValidator(PolymorphicTypeValidator typeValidator) {
 
-			Iterator nodes = element.values().iterator();
+			Assert.notNull(typeValidator, "Type validator must not be null");
 
-			while (nodes.hasNext()) {
-
-				JsonNode currentNode = nodes.next();
-
-				if (currentNode.isArray()) {
-					flattenCollection(propertyPrefix, currentNode.values(), resultMap);
-				} else if (nodes.hasNext() && mightBeJavaType(currentNode)) {
-
-					JsonNode next = nodes.next();
-
-					if (next.isArray()) {
-						flattenCollection(propertyPrefix, next.values(), resultMap);
-					}
-					if (currentNode.asString().equals("java.util.Date")) {
-						resultMap.put(propertyPrefix, next.asString());
-						break;
-					}
-					if (next.isNumber()) {
-						resultMap.put(propertyPrefix, next.numberValue());
-						break;
-					}
-					if (next.isString()) {
-						resultMap.put(propertyPrefix, next.stringValue());
-						break;
-					}
-					if (next.isBoolean()) {
-						resultMap.put(propertyPrefix, next.booleanValue());
-						break;
-					}
-					if (next.isBinary()) {
-
-						try {
-							resultMap.put(propertyPrefix, next.binaryValue());
-						} catch (Exception ex) {
-							throw new IllegalStateException("Cannot read binary value '%s'".formatted(propertyPrefix), ex);
-						}
-
-						break;
-					}
-				}
-			}
-		} else if (element.isObject()) {
-			doFlatten(propertyPrefix, element.properties(), resultMap);
-		} else {
-
-			switch (element.getNodeType()) {
-				case STRING -> resultMap.put(propertyPrefix, element.stringValue());
-				case NUMBER -> resultMap.put(propertyPrefix, element.numberValue());
-				case BOOLEAN -> resultMap.put(propertyPrefix, element.booleanValue());
-				case BINARY -> {
-					try {
-						resultMap.put(propertyPrefix, element.binaryValue());
-					} catch (Exception e) {
-						throw new IllegalStateException(e);
-					}
-				}
-				default ->
-					resultMap.put(propertyPrefix, new DirectFieldAccessFallbackBeanWrapper(element).getPropertyValue("_value"));
-			}
+			this.typeValidator = typeValidator;
+			return this;
 		}
-	}
 
-	private boolean mightBeJavaType(JsonNode node) {
+		/**
+		 * Provide a {@link Consumer customizer} to configure the {@link ObjectMapper} through its {@link MapperBuilder}.
+		 *
+		 * @param mapperBuilderCustomizer the configurer to apply to the {@link ObjectMapper} builder.
+		 * @return {@code this} builder.
+		 */
+		@Contract("_ -> this")
+		public Jackson3HashMapperBuilder customize(Consumer mapperBuilderCustomizer) {
 
-		String textValue = node.asString();
+			Assert.notNull(mapperBuilderCustomizer, "JSON mapper customizer must not be null");
 
-		if (!SOURCE_VERSION_PRESENT) {
-			return Arrays.asList("java.util.Date", "java.math.BigInteger", "java.math.BigDecimal").contains(textValue);
+			this.mapperBuilderCustomizer = mapperBuilderCustomizer;
+			return this;
 		}
 
-		return javax.lang.model.SourceVersion.isName(textValue);
-	}
-
-	private void flattenCollection(String propertyPrefix, Collection list, Map resultMap) {
+		/**
+		 * Build a new {@link Jackson3HashMapper} instance with the configured settings.
+		 *
+		 * @return a new {@link Jackson3HashMapper} instance.
+		 */
+		@Contract("-> new")
+		public Jackson3HashMapper build() {
 
-		Iterator iterator = list.iterator();
-		for (int counter = 0; iterator.hasNext(); counter++) {
-			JsonNode element = iterator.next();
-			flattenElement(propertyPrefix + "[" + counter + "]", element, resultMap);
-		}
-	}
+			B mapperBuilder = builderFactory.get();
 
-	@SuppressWarnings("unchecked")
-	private void addValueToTypedListAtIndex(List listWithTypeHint, int index, Object value) {
+			preconfigure(mapperBuilder, jackson2CompatibilityMode);
+			mapperBuilder.setDefaultTyping(getDefaultTyping(typeValidator, flatten, "@class"));
 
-		List valueList = (List) listWithTypeHint.get(1);
+			mapperBuilderCustomizer.accept(mapperBuilder);
 
-		if (index >= valueList.size()) {
-			int initialCapacity = index + 1;
-			List newValueList = new ArrayList<>(initialCapacity);
-			Collections.copy(CollectionUtils.initializeList(newValueList, initialCapacity), valueList);
-			listWithTypeHint.set(1, newValueList);
-			valueList = newValueList;
+			return new Jackson3HashMapper(mapperBuilder.build(), flatten);
 		}
 
-		valueList.set(index, value);
-	}
+		private static TypeResolverBuilder getDefaultTyping(PolymorphicTypeValidator typeValidator, boolean flatten,
+				String typePropertyName) {
 
-	private List createTypedListWithValue(int index, Object value) {
+			return new DefaultTypeResolverBuilder(typeValidator, DefaultTyping.NON_FINAL, typePropertyName) {
 
-		int initialCapacity = index + 1;
+				@Override
+				public boolean useForType(JavaType type) {
 
-		List valueList = CollectionUtils.initializeList(new ArrayList<>(initialCapacity), initialCapacity);
-		valueList.set(index, value);
+					if (type.isPrimitive()) {
+						return false;
+					}
 
-		List listWithTypeHint = new ArrayList<>();
-		listWithTypeHint.add(ArrayList.class.getName());
-		listWithTypeHint.add(valueList);
+					if (flatten && (type.isTypeOrSubTypeOf(Number.class) || type.isEnumType())) {
+						return false;
+					}
 
-		return listWithTypeHint;
+					return !TreeNode.class.isAssignableFrom(type.getRawClass());
+				}
+			};
+		}
 	}
 
 	private static class HashMapperModule extends JacksonModule {
 
+		private final boolean useCalendarTimestamps;
+
+		private HashMapperModule(boolean useCalendarTimestamps) {
+			this.useCalendarTimestamps = useCalendarTimestamps;
+		}
+
 		@Override
 		public String getModuleName() {
 			return "spring-data-redis-hash-mapper-module";
@@ -485,7 +463,7 @@ public void serializeWithType(Date value, JsonGenerator g, SerializationContext
 					serialize(value, g, ctxt);
 				}
 			});
-			valueSerializers.add(new UTCCalendarSerializer());
+			valueSerializers.add(new UTCCalendarSerializer(useCalendarTimestamps));
 
 			Serializers serializers = new SimpleSerializers(valueSerializers);
 			context.addSerializers(serializers);
@@ -542,6 +520,12 @@ static class UTCCalendarSerializer extends JavaUtilCalendarSerializer {
 
 		private static final TimeZone UTC = TimeZone.getTimeZone("UTC");
 
+		public final boolean useTimestamps;
+
+		public UTCCalendarSerializer(boolean useTimestamps) {
+			this.useTimestamps = useTimestamps;
+		}
+
 		@Override
 		public void serialize(Calendar value, JsonGenerator g, SerializationContext provider) throws JacksonException {
 
@@ -556,6 +540,11 @@ public void serializeWithType(Calendar value, JsonGenerator g, SerializationCont
 				throws JacksonException {
 			serialize(value, g, ctxt);
 		}
+
+		protected boolean _asTimestamp(SerializationContext serializers) {
+			return useTimestamps;
+		}
+
 	}
 
 	static class UntypedUTCCalendarDeserializer extends JavaUtilCalendarDeserializer {
@@ -574,4 +563,102 @@ public Calendar deserialize(JsonParser p, DeserializationContext ctxt) throws Ja
 			return utc;
 		}
 	}
+
+	private enum Jackson3AdapterFactory implements FlatEric.JsonNodeAdapterFactory {
+
+		INSTANCE;
+
+
+		@Override
+		public FlatEric.JsonNodeAdapter adapt(Object node) {
+			return node instanceof FlatEric.JsonNodeAdapter na ? na : new Jackson3JsonNodeAdapter((JsonNode) node);
+		}
+
+		@Override
+		public boolean isJsonNode(Object value) {
+			return value instanceof JsonNode || value instanceof FlatEric.JsonNodeAdapter;
+		}
+	}
+
+	private record Jackson3JsonNodeAdapter(JsonNode node) implements FlatEric.JsonNodeAdapter {
+
+		@Override
+		public FlatEric.JsonNodeType getNodeType() {
+			return FlatEric.JsonNodeType.valueOf(node().getNodeType().name());
+		}
+
+		@Override
+		public boolean isArray() {
+			return node().isArray();
+		}
+
+		@Override
+		public Collection values() {
+			return node().valueStream().map(Jackson3JsonNodeAdapter::new).toList();
+		}
+
+		@Override
+		public String asString() {
+			return node().asString();
+		}
+
+		@Override
+		public boolean isNumber() {
+			return node().isNumber();
+		}
+
+		@Override
+		public Number numberValue() {
+			return node().numberValue();
+		}
+
+		@Override
+		public boolean isString() {
+			return node().isString();
+		}
+
+		@Override
+		public String stringValue() {
+			return node().stringValue();
+		}
+
+		@Override
+		public boolean isBoolean() {
+			return node().isBoolean();
+		}
+
+		@Override
+		public boolean booleanValue() {
+			return node().booleanValue();
+		}
+
+		@Override
+		public boolean isBinary() {
+			return node().isBinary();
+		}
+
+		@Override
+		public byte[] binaryValue() {
+			return node().binaryValue();
+		}
+
+		@Override
+		public boolean isObject() {
+			return node().isObject();
+		}
+
+		@Override
+		public Collection> properties() {
+			return node().propertyStream()
+					.map(it -> Map.entry(it.getKey(), (FlatEric.JsonNodeAdapter) new Jackson3JsonNodeAdapter(it.getValue())))
+					.toList();
+		}
+
+		@Override
+		public Object getDirectValue() {
+			return new DirectFieldAccessFallbackBeanWrapper(node()).getPropertyValue("_value");
+		}
+
+	}
+
 }
diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java
index 3daec289bd..42fa6422d1 100644
--- a/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java
+++ b/src/main/java/org/springframework/data/redis/serializer/GenericJackson2JsonRedisSerializer.java
@@ -74,6 +74,7 @@
  * @since 1.6
  * @deprecated since 4.0 in favor of {@link GenericJackson3JsonRedisSerializer}
  */
+@SuppressWarnings("removal")
 @Deprecated(since = "4.0", forRemoval = true)
 public class GenericJackson2JsonRedisSerializer implements RedisSerializer {
 
diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java
index 278032ca95..bcf6a3161e 100644
--- a/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java
+++ b/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java
@@ -20,7 +20,6 @@
 import tools.jackson.core.JsonParser;
 import tools.jackson.core.JsonToken;
 import tools.jackson.core.Version;
-import tools.jackson.core.exc.JacksonIOException;
 import tools.jackson.databind.DefaultTyping;
 import tools.jackson.databind.DeserializationConfig;
 import tools.jackson.databind.DeserializationContext;
@@ -30,36 +29,40 @@
 import tools.jackson.databind.JsonNode;
 import tools.jackson.databind.ObjectMapper;
 import tools.jackson.databind.SerializationContext;
-import tools.jackson.databind.ValueSerializer;
+import tools.jackson.databind.cfg.MapperBuilder;
 import tools.jackson.databind.deser.jackson.BaseNodeDeserializer;
 import tools.jackson.databind.deser.jackson.JsonNodeDeserializer;
 import tools.jackson.databind.json.JsonMapper;
-import tools.jackson.databind.json.JsonMapper.Builder;
 import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
 import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
 import tools.jackson.databind.jsontype.TypeDeserializer;
-import tools.jackson.databind.jsontype.TypeResolverBuilder;
+import tools.jackson.databind.jsontype.TypeSerializer;
+import tools.jackson.databind.jsontype.impl.DefaultTypeResolverBuilder;
 import tools.jackson.databind.jsontype.impl.StdTypeResolverBuilder;
 import tools.jackson.databind.module.SimpleSerializers;
 import tools.jackson.databind.ser.std.StdSerializer;
 import tools.jackson.databind.type.TypeFactory;
 
 import java.io.IOException;
-import java.util.ArrayList;
 import java.util.Collections;
-import java.util.List;
 import java.util.function.Consumer;
 import java.util.function.Supplier;
 
 import org.jspecify.annotations.Nullable;
+
 import org.springframework.cache.support.NullValue;
+import org.springframework.core.KotlinDetector;
 import org.springframework.data.util.Lazy;
 import org.springframework.lang.Contract;
 import org.springframework.util.Assert;
+import org.springframework.util.ClassUtils;
 import org.springframework.util.StringUtils;
 
+import com.fasterxml.jackson.annotation.JsonTypeInfo;
+import com.fasterxml.jackson.core.TreeNode;
+
 /**
- * Generic Jackson 2-based {@link RedisSerializer} that maps {@link Object objects} to and from {@literal JSON}.
+ * Generic Jackson 3-based {@link RedisSerializer} that maps {@link Object objects} to and from {@literal JSON}.
  * 

* {@literal JSON} reading and writing can be customized by configuring a {@link Jackson3ObjectReader} and * {@link Jackson3ObjectWriter}. @@ -78,37 +81,10 @@ public class GenericJackson3JsonRedisSerializer implements RedisSerializer defaultTypingEnabled; - private final Lazy lazyTypeHintPropertyName; - private final ObjectMapper mapper; private final TypeResolver typeResolver; - // internal shortcut for testing - GenericJackson3JsonRedisSerializer() { - this(((Supplier) () -> { - Builder builder = JsonMapper.builder(); - new JsonMapperConfigurer(builder).unsafeDefaultTyping().enableSpringCacheNullValueSupport(); - return builder.build(); - }).get()); - } - - /** - * Prepare a new {@link GenericJackson3JsonRedisSerializer} instance. - * - * @param configurer configuration helper callback to apply customizations to the {@link JsonMapper.Builder json - * mapper}. - * @return new instance of {@link GenericJackson3JsonRedisSerializer}. - */ - public static GenericJackson3JsonRedisSerializer create(Consumer configurer) { - - Builder configurationBuilder = JsonMapper.builder(); - JsonMapperConfigurer configHelper = new JsonMapperConfigurer(configurationBuilder); - configurer.accept(configHelper); - return new GenericJackson3JsonRedisSerializer(configurationBuilder.build(), configHelper.getReader(), - configHelper.getWriter()); - } - /** * Create a {@link GenericJackson3JsonRedisSerializer} with a custom-configured {@link ObjectMapper}. * @@ -126,7 +102,7 @@ public GenericJackson3JsonRedisSerializer(ObjectMapper mapper) { * @param reader the {@link Jackson3ObjectReader} function to read objects using {@link ObjectMapper}. * @param writer the {@link Jackson3ObjectWriter} function to write objects using {@link ObjectMapper}. */ - public GenericJackson3JsonRedisSerializer(ObjectMapper mapper, Jackson3ObjectReader reader, + protected GenericJackson3JsonRedisSerializer(ObjectMapper mapper, Jackson3ObjectReader reader, Jackson3ObjectWriter writer) { Assert.notNull(mapper, "ObjectMapper must not be null"); @@ -138,12 +114,55 @@ public GenericJackson3JsonRedisSerializer(ObjectMapper mapper, Jackson3ObjectRea this.writer = writer; this.defaultTypingEnabled = Lazy.of(() -> mapper.serializationConfig().getDefaultTyper(null) != null); - this.lazyTypeHintPropertyName = newLazyTypeHintPropertyName(mapper, this.defaultTypingEnabled); - this.typeResolver = newTypeResolver(mapper, this.lazyTypeHintPropertyName); + Lazy lazyTypeHintPropertyName = newLazyTypeHintPropertyName(mapper, this.defaultTypingEnabled); + this.typeResolver = newTypeResolver(mapper, lazyTypeHintPropertyName); + } + + /** + * Prepare a new {@link GenericJackson3JsonRedisSerializer} instance. + * + * @param configurer the configurer for {@link GenericJackson3JsonRedisSerializerBuilder}. + * @return new instance of {@link GenericJackson3JsonRedisSerializer}. + */ + public static GenericJackson3JsonRedisSerializer create( + Consumer> configurer) { + + Assert.notNull(configurer, "Builder configurer must not be null"); + + GenericJackson3JsonRedisSerializerBuilder builder = builder(); + configurer.accept(builder); + return builder.build(); + } + + /** + * Creates a new {@link GenericJackson3JsonRedisSerializerBuilder} to configure and build a + * {@link GenericJackson3JsonRedisSerializer} using {@link JsonMapper}. + * + * @return a new {@link GenericJackson3JsonRedisSerializerBuilder}. + */ + public static GenericJackson3JsonRedisSerializerBuilder builder() { + return builder(JsonMapper::builder); + } + + /** + * Creates a new {@link GenericJackson3JsonRedisSerializerBuilder} to configure and build a + * {@link GenericJackson3JsonRedisSerializer}. + * + * @param builderFactory factory to create a {@link MapperBuilder} for the {@link ObjectMapper}. + * @param type of the {@link MapperBuilder} to use. + * @return a new {@link GenericJackson3JsonRedisSerializerBuilder}. + */ + public static >> GenericJackson3JsonRedisSerializerBuilder builder( + Supplier builderFactory) { + + Assert.notNull(builderFactory, "MapperBuilder Factory must not be null"); + + return new GenericJackson3JsonRedisSerializerBuilder<>(builderFactory); } @Override + @Contract("_ -> !null") public byte[] serialize(@Nullable Object value) throws SerializationException { if (value == null) { @@ -152,7 +171,7 @@ public byte[] serialize(@Nullable Object value) throws SerializationException { try { return writer.write(mapper, value); - } catch (IOException | JacksonIOException ex) { + } catch (RuntimeException ex) { throw new SerializationException("Could not write JSON: %s".formatted(ex.getMessage()), ex); } } @@ -181,7 +200,7 @@ public byte[] serialize(@Nullable Object value) throws SerializationException { public @Nullable T deserialize(byte @Nullable [] source, Class type) throws SerializationException { Assert.notNull(type, "Deserialization type must not be null;" - + " Please provide Object.class to make use of Jackson2 default typing."); + + " Please provide Object.class to make use of Jackson3 default typing."); if (SerializationUtils.isEmpty(source)) { return null; @@ -235,21 +254,28 @@ private static Lazy getConfiguredTypeDeserializationPropertyName(ObjectM } /** - * {@link JsonMapperConfigurer} wraps around a {@link JsonMapper.Builder} providing dedicated methods to configure - * aspects like {@link NullValue} serialization strategy for the resulting {@link ObjectMapper} to be used with - * {@link GenericJackson3JsonRedisSerializer} as well as potential Object/{@link Jackson3ObjectReader -reader} and - * {@link Jackson3ObjectWriter -writer} settings. - * - * @since 4.0 + * {@link GenericJackson3JsonRedisSerializerBuilder} wraps around a {@link JsonMapper.Builder} providing dedicated + * methods to configure aspects like {@link NullValue} serialization strategy for the resulting {@link ObjectMapper} + * to be used with {@link GenericJackson3JsonRedisSerializer} as well as potential Object/{@link Jackson3ObjectReader + * -reader} and {@link Jackson3ObjectWriter -writer} settings. + * + * @param type of the {@link MapperBuilder}. */ - public static class JsonMapperConfigurer { + public static class GenericJackson3JsonRedisSerializerBuilder>> { - private final JsonMapper.Builder builder; - private @Nullable Jackson3ObjectWriter writer; - private @Nullable Jackson3ObjectReader reader; + private final Supplier builderFactory; - public JsonMapperConfigurer(Builder builder) { - this.builder = builder; + private boolean cacheNullValueSupportEnabled = false; + private boolean defaultTyping = false; + private @Nullable String typePropertyName; + private PolymorphicTypeValidator typeValidator = BasicPolymorphicTypeValidator.builder() + .allowIfBaseType(Object.class).allowIfSubType((ctx, clazz) -> true).build(); + private Consumer mapperBuilderCustomizer = (b) -> {}; + private Jackson3ObjectWriter writer = Jackson3ObjectWriter.create(); + private Jackson3ObjectReader reader = Jackson3ObjectReader.create(); + + private GenericJackson3JsonRedisSerializerBuilder(Supplier builderFactory) { + this.builderFactory = builderFactory; } /** @@ -257,19 +283,13 @@ public JsonMapperConfigurer(Builder builder) { * type property. Please make sure to active * {@link JsonMapper.Builder#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) * default typing} accordingly. - * + * * @return this. */ @Contract("-> this") - public JsonMapperConfigurer enableSpringCacheNullValueSupport() { + public GenericJackson3JsonRedisSerializerBuilder enableSpringCacheNullValueSupport() { - builder.addModules(new GenericJackson3RedisSerializerModule(() -> { - TypeResolverBuilder defaultTyper = builder.baseSettings().getDefaultTyper(); - if (defaultTyper instanceof StdTypeResolverBuilder stdTypeResolverBuilder) { - return stdTypeResolverBuilder.getTypeProperty(); - } - return "@class"; - })); + this.cacheNullValueSupportEnabled = true; return this; } @@ -279,13 +299,13 @@ public JsonMapperConfigurer enableSpringCacheNullValueSupport() { * {@link JsonMapper.Builder#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) * default typing} accordingly. * - * @return this. + * @return {@code this} builder. */ @Contract("_ -> this") - public JsonMapperConfigurer enableSpringCacheNullValueSupport(String typePropertyName) { + public GenericJackson3JsonRedisSerializerBuilder enableSpringCacheNullValueSupport(String typePropertyName) { - builder.addModules(new GenericJackson3RedisSerializerModule(() -> typePropertyName)); - return this; + typePropertyName(typePropertyName); + return enableSpringCacheNullValueSupport(); } /** @@ -296,28 +316,73 @@ public JsonMapperConfigurer enableSpringCacheNullValueSupport(String typePropert * WARNING: without restrictions of the {@link PolymorphicTypeValidator} deserialization is * vulnerable to arbitrary code execution when reading from untrusted sources. * - * @return this. + * @return {@code this} builder. * @see https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data */ @Contract("-> this") - public JsonMapperConfigurer unsafeDefaultTyping() { + public GenericJackson3JsonRedisSerializerBuilder enableUnsafeDefaultTyping() { + + this.defaultTyping = true; + return this; + } + + /** + * Enables + * {@link JsonMapper.Builder#activateDefaultTypingAsProperty(PolymorphicTypeValidator, DefaultTyping, String) + * default typing} using the given {@link PolymorphicTypeValidator}. + * + * @return {@code this} builder. + */ + @Contract("_ -> this") + public GenericJackson3JsonRedisSerializerBuilder enableDefaultTyping(PolymorphicTypeValidator typeValidator) { + + typeValidator(typeValidator); - builder.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false) - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) - .activateDefaultTypingAsProperty(BasicPolymorphicTypeValidator.builder().allowIfBaseType(Object.class) - .allowIfSubType((ctx, clazz) -> true).build(), DefaultTyping.NON_FINAL, "@class"); + this.defaultTyping = true; + return this; + } + + /** + * Provide a {@link PolymorphicTypeValidator} to validate polymorphic types during deserialization. + * + * @param typeValidator the validator to use, defaults to a permissive validator that allows all types. + * @return {@code this} builder. + */ + @Contract("_ -> this") + public GenericJackson3JsonRedisSerializerBuilder typeValidator(PolymorphicTypeValidator typeValidator) { + + Assert.notNull(typeValidator, "Type validator must not be null"); + + this.typeValidator = typeValidator; + return this; + } + + /** + * Configure the type property name used for default typing and {@link #enableSpringCacheNullValueSupport()}. + * + * @return {@code this} builder. + */ + @Contract("_ -> this") + public GenericJackson3JsonRedisSerializerBuilder typePropertyName(String typePropertyName) { + + Assert.hasText(typePropertyName, "Property name must not be null or empty"); + + this.typePropertyName = typePropertyName; return this; } /** * Configures the {@link Jackson3ObjectWriter}. - * + * * @param writer must not be {@literal null}. - * @return this. + * @return {@code this} builder. */ @Contract("_ -> this") - public JsonMapperConfigurer writer(Jackson3ObjectWriter writer) { + public GenericJackson3JsonRedisSerializerBuilder writer(Jackson3ObjectWriter writer) { + + Assert.notNull(writer, "Jackson3ObjectWriter must not be null"); + this.writer = writer; return this; } @@ -326,40 +391,72 @@ public JsonMapperConfigurer writer(Jackson3ObjectWriter writer) { * Configures the {@link Jackson3ObjectReader}. * * @param reader must not be {@literal null}. - * @return this. + * @return {@code this} builder. */ @Contract("_ -> this") - public JsonMapperConfigurer reader(Jackson3ObjectReader reader) { + public GenericJackson3JsonRedisSerializerBuilder reader(Jackson3ObjectReader reader) { + + Assert.notNull(reader, "Jackson3ObjectReader must not be null"); this.reader = reader; return this; } /** - * Callback hook to interact with the raw {@link Builder}. + * Provide a {@link Consumer customizer} to configure the {@link ObjectMapper} through its {@link MapperBuilder}. * - * @param builderCustomizer - * @return this. + * @param mapperBuilderCustomizer the configurer to apply to the {@link ObjectMapper} builder. + * @return {@code this} builder. */ @Contract("_ -> this") - public JsonMapperConfigurer customize(Consumer builderCustomizer) { + public GenericJackson3JsonRedisSerializerBuilder customize(Consumer mapperBuilderCustomizer) { + + Assert.notNull(mapperBuilderCustomizer, "JSON mapper configurer must not be null"); - builderCustomizer.accept(builder); + this.mapperBuilderCustomizer = mapperBuilderCustomizer; return this; } - Jackson3ObjectReader getReader() { - return reader != null ? reader : Jackson3ObjectReader.create(); - } + /** + * Build a new {@link GenericJackson3JsonRedisSerializer} instance using the configured settings. + * + * @return a new {@link GenericJackson3JsonRedisSerializer} instance. + */ + @Contract("-> new") + public GenericJackson3JsonRedisSerializer build() { + + B mapperBuilder = builderFactory.get(); + + if (cacheNullValueSupportEnabled) { + + String typePropertyName = StringUtils.hasText(this.typePropertyName) ? this.typePropertyName : "@class"; + mapperBuilder.addModules(new GenericJackson3RedisSerializerModule(() -> { + tools.jackson.databind.jsontype.TypeResolverBuilder defaultTyper = mapperBuilder.baseSettings() + .getDefaultTyper(); + if (defaultTyper instanceof StdTypeResolverBuilder stdTypeResolverBuilder) { + return stdTypeResolverBuilder.getTypeProperty(); + } + return typePropertyName; + })); + } + + if (defaultTyping) { + + GenericJackson3JsonRedisSerializer.TypeResolverBuilder resolver = new GenericJackson3JsonRedisSerializer.TypeResolverBuilder( + typeValidator, DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY, JsonTypeInfo.Id.CLASS, typePropertyName); + + mapperBuilder.configure(DeserializationFeature.FAIL_ON_MISSING_EXTERNAL_TYPE_ID_PROPERTY, false) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .setDefaultTyping(resolver); + } - Jackson3ObjectWriter getWriter() { - return writer != null ? writer : Jackson3ObjectWriter.create(); + mapperBuilderCustomizer.accept(mapperBuilder); + + return new GenericJackson3JsonRedisSerializer(mapperBuilder.build(), reader, writer); } + } - /** - * @since 4.0 - */ static class TypeResolver { private final ObjectMapper mapper; @@ -430,7 +527,6 @@ private JsonParser createParser(byte[] source) throws IOException { * {@link NullValue}. * * @author Christoph Strobl - * @since 4.0 */ private static class NullValueSerializer extends StdSerializer { @@ -442,6 +538,7 @@ private static class NullValueSerializer extends StdSerializer { NullValueSerializer(Supplier classIdentifier) { super(NullValue.class); + this.classIdentifier = Lazy.of(() -> { String identifier = classIdentifier.get(); return StringUtils.hasText(identifier) ? identifier : "@class"; @@ -465,11 +562,14 @@ public void serialize(NullValue value, JsonGenerator gen, SerializationContext p gen.writeNull(); } } + + @Override + public void serializeWithType(NullValue value, JsonGenerator gen, SerializationContext ctxt, TypeSerializer typeSer) + throws JacksonException { + serialize(value, gen, ctxt); + } } - /** - * @since 4.0 - */ private static class GenericJackson3RedisSerializerModule extends JacksonModule { private final Supplier classIdentifier; @@ -490,10 +590,77 @@ public Version version() { @Override public void setupModule(SetupContext context) { + context.addSerializers(new SimpleSerializers().addSerializer(new NullValueSerializer(classIdentifier))); + } + + } + + private static class TypeResolverBuilder extends DefaultTypeResolverBuilder { + + public TypeResolverBuilder(PolymorphicTypeValidator subtypeValidator, DefaultTyping t, JsonTypeInfo.As includeAs) { + super(subtypeValidator, t, includeAs); + } + + public TypeResolverBuilder(PolymorphicTypeValidator subtypeValidator, DefaultTyping t, String propertyName) { + super(subtypeValidator, t, propertyName); + } + + public TypeResolverBuilder(PolymorphicTypeValidator subtypeValidator, DefaultTyping t, JsonTypeInfo.As includeAs, + JsonTypeInfo.Id idType, @Nullable String propertyName) { + super(subtypeValidator, t, includeAs, idType, propertyName); + } + + @Override + public DefaultTypeResolverBuilder withDefaultImpl(Class defaultImpl) { + return this; + } + + /** + * Method called to check if the default type handler should be used for given type. Note: "natural types" (String, + * Boolean, Integer, Double) will never use typing; that is both due to them being concrete and final, and since + * actual serializers and deserializers will also ignore any attempts to enforce typing. + */ + @Override + public boolean useForType(JavaType javaType) { + + if (javaType.isJavaLangObject()) { + return true; + } + + javaType = resolveArrayOrWrapper(javaType); - List> valueSerializers = new ArrayList<>(); - valueSerializers.add(new NullValueSerializer(classIdentifier)); - context.addSerializers(new SimpleSerializers(valueSerializers)); + if (javaType.isEnumType() || ClassUtils.isPrimitiveOrWrapper(javaType.getRawClass())) { + return false; + } + + if (javaType.isFinal() && !KotlinDetector.isKotlinType(javaType.getRawClass()) + && javaType.getRawClass().getPackageName().startsWith("java")) { + return false; + } + + // [databind#88] Should not apply to JSON tree models: + return !TreeNode.class.isAssignableFrom(javaType.getRawClass()); } + + private JavaType resolveArrayOrWrapper(JavaType type) { + + while (type.isArrayType()) { + type = type.getContentType(); + if (type.isReferenceType()) { + type = resolveArrayOrWrapper(type); + } + } + + while (type.isReferenceType()) { + type = type.getReferencedType(); + if (type.isArrayType()) { + type = resolveArrayOrWrapper(type); + } + } + + return type; + } + } + } diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java index 10941b1072..bdd0de0e18 100644 --- a/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson2JsonRedisSerializer.java @@ -43,6 +43,7 @@ * @since 1.2 * @deprecated since 4.0 in favor of {@link Jackson3JsonRedisSerializer}. */ +@SuppressWarnings("removal") @Deprecated(since = "4.0", forRemoval = true) public class Jackson2JsonRedisSerializer implements RedisSerializer { diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java index 15cd981176..23c75a9443 100644 --- a/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java @@ -1,5 +1,5 @@ /* - * Copyright 2011-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,41 +17,34 @@ import tools.jackson.databind.JavaType; import tools.jackson.databind.ObjectMapper; -import tools.jackson.databind.ser.SerializerFactory; +import tools.jackson.databind.json.JsonMapper; import tools.jackson.databind.type.TypeFactory; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; +import org.jspecify.annotations.Nullable; -import org.springframework.lang.Nullable; import org.springframework.util.Assert; /** * {@link RedisSerializer} that can read and write JSON using - * Jackson's and - * Jackson Databind {@link ObjectMapper}. + * Jackson 3 and + * Jackson 3 Databind {@link ObjectMapper}. *

* This serializer can be used to bind to typed beans, or untyped {@link java.util.HashMap HashMap} instances. * Note:Null objects are serialized as empty arrays and vice versa. *

- * JSON reading and writing can be customized by configuring {@link JacksonObjectReader} respective - * {@link JacksonObjectWriter}. + * JSON reading and writing can be customized by configuring {@link Jackson3ObjectReader} respective + * {@link Jackson3ObjectWriter}. * + * @author Christoph Strobl * @author Thomas Darimont * @author Mark Paluch - * @since 1.2 + * @since 4.0 */ public class Jackson3JsonRedisSerializer implements RedisSerializer { - /** - * @deprecated since 3.0 for removal. - */ - @Deprecated(since = "3.0", forRemoval = true) // - public static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; - private final JavaType javaType; - private ObjectMapper mapper; + private final ObjectMapper mapper; private final Jackson3ObjectReader reader; @@ -63,7 +56,7 @@ public class Jackson3JsonRedisSerializer implements RedisSerializer { * @param type must not be {@literal null}. */ public Jackson3JsonRedisSerializer(Class type) { - this(new ObjectMapper(), type); + this(JsonMapper.shared(), type); } /** @@ -72,7 +65,7 @@ public Jackson3JsonRedisSerializer(Class type) { * @param javaType must not be {@literal null}. */ public Jackson3JsonRedisSerializer(JavaType javaType) { - this(new ObjectMapper(), javaType); + this(JsonMapper.shared(), javaType); } /** @@ -80,7 +73,6 @@ public Jackson3JsonRedisSerializer(JavaType javaType) { * * @param mapper must not be {@literal null}. * @param type must not be {@literal null}. - * @since 3.0 */ public Jackson3JsonRedisSerializer(ObjectMapper mapper, Class type) { @@ -98,7 +90,6 @@ public Jackson3JsonRedisSerializer(ObjectMapper mapper, Class type) { * * @param mapper must not be {@literal null}. * @param javaType must not be {@literal null}. - * @since 3.0 */ public Jackson3JsonRedisSerializer(ObjectMapper mapper, JavaType javaType) { this(mapper, javaType, Jackson3ObjectReader.create(), Jackson3ObjectWriter.create()); @@ -109,16 +100,15 @@ public Jackson3JsonRedisSerializer(ObjectMapper mapper, JavaType javaType) { * * @param mapper must not be {@literal null}. * @param javaType must not be {@literal null}. - * @param reader the {@link JacksonObjectReader} function to read objects using {@link ObjectMapper}. - * @param writer the {@link JacksonObjectWriter} function to write objects using {@link ObjectMapper}. - * @since 3.0 + * @param reader the {@link Jackson3ObjectReader} function to read objects using {@link ObjectMapper}. + * @param writer the {@link Jackson3ObjectWriter} function to write objects using {@link ObjectMapper}. */ public Jackson3JsonRedisSerializer(ObjectMapper mapper, JavaType javaType, Jackson3ObjectReader reader, Jackson3ObjectWriter writer) { - Assert.notNull(mapper, "ObjectMapper must not be null!"); - Assert.notNull(reader, "Reader must not be null!"); - Assert.notNull(writer, "Writer must not be null!"); + Assert.notNull(mapper, "ObjectMapper must not be null"); + Assert.notNull(reader, "Reader must not be null"); + Assert.notNull(writer, "Writer must not be null"); this.mapper = mapper; this.reader = reader; @@ -126,25 +116,6 @@ public Jackson3JsonRedisSerializer(ObjectMapper mapper, JavaType javaType, Jacks this.javaType = javaType; } - /** - * Sets the {@code ObjectMapper} for this view. If not set, a default {@link ObjectMapper#ObjectMapper() ObjectMapper} - * is used. - *

- * Setting a custom-configured {@code ObjectMapper} is one way to take further control of the JSON serialization - * process. For example, an extended {@link SerializerFactory} can be configured that provides custom serializers for - * specific types. The other option for refining the serialization process is to use Jackson's provided annotations on - * the types to be serialized, in which case a custom-configured ObjectMapper is unnecessary. - * - * @deprecated since 3.0, use {@link #Jackson3JsonRedisSerializer(ObjectMapper, Class) constructor creation} to - * configure the object mapper. - */ - @Deprecated(since = "3.0", forRemoval = true) - public void setObjectMapper(ObjectMapper mapper) { - - Assert.notNull(mapper, "'objectMapper' must not be null"); - this.mapper = mapper; - } - @Override public byte[] serialize(@Nullable T value) throws SerializationException { @@ -153,7 +124,7 @@ public byte[] serialize(@Nullable T value) throws SerializationException { } try { return this.writer.write(this.mapper, value); - } catch (Exception ex) { + } catch (RuntimeException ex) { throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex); } } @@ -161,14 +132,14 @@ public byte[] serialize(@Nullable T value) throws SerializationException { @Nullable @Override @SuppressWarnings("unchecked") - public T deserialize(@Nullable byte[] bytes) throws SerializationException { + public T deserialize(byte @Nullable [] bytes) throws SerializationException { if (SerializationUtils.isEmpty(bytes)) { return null; } try { return (T) this.reader.read(this.mapper, bytes, javaType); - } catch (Exception ex) { + } catch (RuntimeException ex) { throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); } } @@ -195,4 +166,5 @@ public T deserialize(@Nullable byte[] bytes) throws SerializationException { protected JavaType getJavaType(Class clazz) { return TypeFactory.unsafeSimpleType(clazz); } + } diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java index ee8b880194..63de096f9b 100644 --- a/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,9 +15,6 @@ */ package org.springframework.data.redis.serializer; -import java.io.IOException; -import java.io.InputStream; - import tools.jackson.databind.JavaType; import tools.jackson.databind.ObjectMapper; @@ -26,10 +23,11 @@ * holding JSON to an Object considering the target type. *

* Reader functions can customize how the actual JSON is being deserialized by e.g. obtaining a customized - * {@link com.fasterxml.jackson.databind.ObjectReader} applying serialization features, date formats, or views. + * {@link tools.jackson.databind.ObjectReader} applying serialization features, date formats, or views. * + * @author Christoph Strobl * @author Mark Paluch - * @since 3.0 + * @since 4.0 */ @FunctionalInterface public interface Jackson3ObjectReader { @@ -41,12 +39,12 @@ public interface Jackson3ObjectReader { * @param source the JSON to deserialize. * @param type the Java target type * @return the deserialized Java object. - * @throws IOException if an I/O error or JSON deserialization error occurs. */ - Object read(ObjectMapper mapper, byte[] source, JavaType type) throws IOException; + Object read(ObjectMapper mapper, byte[] source, JavaType type); /** - * Create a default {@link Jackson3ObjectReader} delegating to {@link ObjectMapper#readValue(InputStream, JavaType)}. + * Create a default {@link Jackson3ObjectReader} delegating to + * {@link ObjectMapper#readValue(byte[], int, int, JavaType)}. * * @return the default {@link Jackson3ObjectReader}. */ diff --git a/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java index f995bcc123..ecf133fbee 100644 --- a/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java +++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java @@ -1,5 +1,5 @@ /* - * Copyright 2022-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,8 +15,6 @@ */ package org.springframework.data.redis.serializer; -import java.io.IOException; - import tools.jackson.databind.ObjectMapper; /** @@ -24,10 +22,11 @@ * {@code byte[]} containing JSON. *

* Writer functions can customize how the actual JSON is being written by e.g. obtaining a customized - * {@link com.fasterxml.jackson.databind.ObjectWriter} applying serialization features, date formats, or views. + * {@link tools.jackson.databind.ObjectWriter} applying serialization features, date formats, or views. * + * @author Christoph Strobl * @author Mark Paluch - * @since 3.0 + * @since 4.0 */ @FunctionalInterface public interface Jackson3ObjectWriter { @@ -38,9 +37,8 @@ public interface Jackson3ObjectWriter { * @param mapper the object mapper to use. * @param source the root of the object graph to marshal. * @return a byte array containing the serialized object graph. - * @throws IOException if an I/O error or JSON serialization error occurs. */ - byte[] write(ObjectMapper mapper, Object source) throws IOException; + byte[] write(ObjectMapper mapper, Object source); /** * Create a default {@link Jackson3ObjectWriter} delegating to {@link ObjectMapper#writeValueAsBytes(Object)}. diff --git a/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java b/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java index 1d3fa8e256..3807d13680 100644 --- a/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/AbstractHashMapperTests.java @@ -39,7 +39,6 @@ protected void assertBackAndForwardMapping(Object o) { HashMapper mapper = mapperFor(o.getClass()); Map hash = mapper.toHash(o); - System.out.println("hash: " + hash); assertThat(mapper.fromHash(hash)).isEqualTo(o); } diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java index e67e802081..5c4d60a741 100644 --- a/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java @@ -15,38 +15,29 @@ */ package org.springframework.data.redis.mapping; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import java.util.Map; -import org.junit.jupiter.api.Disabled; import org.springframework.data.redis.hash.Jackson2HashMapper; import org.springframework.data.redis.hash.Jackson3HashMapper; /** + * TCK-style tests to assert {@link Jackson3HashMapper} interoperability with {@link Jackson2HashMapper} in hierarchical + * mode. + * * @author Christoph Strobl */ -public class Jackson3CompatibilityTests extends Jackson3HashMapperUnitTests { +@SuppressWarnings("removal") +class Jackson3CompatibilityTests extends Jackson3HashMapperUnitTests { private final Jackson2HashMapper jackson2HashMapper; - public Jackson3CompatibilityTests() { - super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure, false)); + Jackson3CompatibilityTests() { + super(Jackson3HashMapper.builder().jackson2CompatibilityMode().build()); this.jackson2HashMapper = new Jackson2HashMapper(false); } - @Override - @Disabled("with jackson 2 this used to render the timestamp as string. Now its a long and in line with calendar timestamp") - void dateValueShouldBeTreatedCorrectly() { - super.dateValueShouldBeTreatedCorrectly(); - } - - @Override - @Disabled("with jackson 2 used to render the enum and its type hint in an array. Now its just the enum value") - void enumsShouldBeTreatedCorrectly() { - super.enumsShouldBeTreatedCorrectly(); - } - @Override protected void assertBackAndForwardMapping(Object o) { diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java index 2c9d337abe..d759991848 100644 --- a/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java @@ -15,38 +15,29 @@ */ package org.springframework.data.redis.mapping; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import java.util.Map; -import org.junit.jupiter.api.Disabled; import org.springframework.data.redis.hash.Jackson2HashMapper; import org.springframework.data.redis.hash.Jackson3HashMapper; /** + * TCK-style tests to assert {@link Jackson3HashMapper} interoperability with {@link Jackson2HashMapper} in flattening + * mode. + * * @author Christoph Strobl */ -public class Jackson3FlatteningCompatibilityTests extends Jackson3HashMapperUnitTests { +@SuppressWarnings("removal") +class Jackson3FlatteningCompatibilityTests extends Jackson3HashMapperUnitTests { private final Jackson2HashMapper jackson2HashMapper; - public Jackson3FlatteningCompatibilityTests() { - super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure, true)); + Jackson3FlatteningCompatibilityTests() { + super(Jackson3HashMapper.builder().jackson2CompatibilityMode().flatten().build()); this.jackson2HashMapper = new Jackson2HashMapper(true); } - @Override - @Disabled("with jackson 2 this used to render the timestamp as string. Now its a long and in line with calendar timestamp") - void dateValueShouldBeTreatedCorrectly() { - super.dateValueShouldBeTreatedCorrectly(); - } - - @Override - @Disabled("with jackson 2 used to render the enum and its type hint in an array. Now its just the enum value") - void enumsShouldBeTreatedCorrectly() { - super.enumsShouldBeTreatedCorrectly(); - } - @Override protected void assertBackAndForwardMapping(Object o) { diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java index 5e3e4b8835..fe23e9f430 100644 --- a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,24 +15,26 @@ */ package org.springframework.data.redis.mapping; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import java.time.LocalDateTime; import java.time.Month; import java.util.Map; import org.junit.jupiter.api.Test; + import org.springframework.data.redis.hash.Jackson3HashMapper; /** + * Unit tests for {@link Jackson3HashMapper} using flattening mode. + * * @author Christoph Strobl * @author John Blum - * @since 2023/06 */ -public class Jackson3HashMapperFlatteningUnitTests extends Jackson3HashMapperUnitTests { +class Jackson3HashMapperFlatteningUnitTests extends Jackson3HashMapperUnitTests { Jackson3HashMapperFlatteningUnitTests() { - super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure,true)); + super(Jackson3HashMapper.flattening()); } @Test // GH-2593 diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java index af19ff6a26..8b70a3ee83 100644 --- a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,24 +15,26 @@ */ package org.springframework.data.redis.mapping; -import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.*; import java.time.LocalDateTime; import java.time.Month; import java.util.Map; import org.junit.jupiter.api.Test; + import org.springframework.data.redis.hash.Jackson3HashMapper; /** + * Unit tests for {@link Jackson3HashMapper} using hierarchical mode. + * * @author Christoph Strobl * @author John Blum - * @since 2023/06 */ -public class Jackson3HashMapperNonFlatteningUnitTests extends Jackson3HashMapperUnitTests { +class Jackson3HashMapperNonFlatteningUnitTests extends Jackson3HashMapperUnitTests { Jackson3HashMapperNonFlatteningUnitTests() { - super(new Jackson3HashMapper(Jackson3HashMapper::preconfigure, false)); + super(Jackson3HashMapper.hierarchical()); } @Test // GH-2593 diff --git a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java index 72631fdb64..db28ec61eb 100644 --- a/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java +++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java @@ -1,5 +1,5 @@ /* - * Copyright 2016-2025 the original author or authors. + * Copyright 2025 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,12 @@ */ package org.springframework.data.redis.mapping; +import static org.assertj.core.api.Assertions.*; + +import tools.jackson.core.json.JsonReadFeature; +import tools.jackson.databind.MapperFeature; +import tools.jackson.databind.ObjectMapper; + import java.math.BigDecimal; import java.math.BigInteger; import java.time.LocalDate; @@ -28,31 +34,30 @@ import java.util.Map; import java.util.Objects; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; + import org.springframework.data.redis.Address; import org.springframework.data.redis.Person; import org.springframework.data.redis.hash.HashMapper; -import org.springframework.data.redis.hash.Jackson2HashMapper; import org.springframework.data.redis.hash.Jackson3HashMapper; /** - * Unit tests for {@link Jackson2HashMapper}. + * Support class for {@link Jackson3HashMapper} unit tests. * * @author Christoph Strobl * @author Mark Paluch * @author John Blum */ -public abstract class Jackson3HashMapperUnitTests extends AbstractHashMapperTests { +abstract class Jackson3HashMapperUnitTests extends AbstractHashMapperTests { private final Jackson3HashMapper mapper; - public Jackson3HashMapperUnitTests(Jackson3HashMapper mapper) { + Jackson3HashMapperUnitTests(Jackson3HashMapper mapper) { this.mapper = mapper; } - protected Jackson3HashMapper getMapper() { + Jackson3HashMapper getMapper() { return this.mapper; } @@ -179,7 +184,6 @@ void dateValueShouldBeTreatedCorrectly() { } @Test // GH-1566 - @Disabled("Jackson removed default typing for final types") void mapFinalClass() { MeFinal source = new MeFinal(); @@ -215,6 +219,25 @@ void enumsShouldBeTreatedCorrectly() { assertBackAndForwardMapping(source); } + @Test // GH-3292 + void configuresObjectMapper() { + + Jackson3HashMapper serializer = Jackson3HashMapper.builder(() -> new ObjectMapper().rebuild()) + .customize(mb -> mb.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)).build(); + + assertThat(serializer).isNotNull(); + } + + @Test // GH-3292 + void configuresJsonMapper() { + + Jackson3HashMapper serializer = Jackson3HashMapper.create(b -> { + b.customize(mb -> mb.enable(JsonReadFeature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER)); + }); + + assertThat(serializer).isNotNull(); + } + public static class WithList { List strings; diff --git a/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java index d41dd170ad..4b017ec246 100644 --- a/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java +++ b/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java @@ -15,18 +15,20 @@ */ package org.springframework.data.redis.serializer; +import static org.assertj.core.api.Assertions.*; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.AssertionsForClassTypes.*; import static org.assertj.core.api.AssertionsForClassTypes.assertThatExceptionOfType; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; -import static org.springframework.test.util.ReflectionTestUtils.getField; -import static org.springframework.util.ObjectUtils.nullSafeEquals; -import static org.springframework.util.ObjectUtils.nullSafeHashCode; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.util.ReflectionTestUtils.*; +import static org.springframework.util.ObjectUtils.*; import tools.jackson.core.exc.JacksonIOException; import tools.jackson.core.exc.StreamReadException; +import tools.jackson.core.json.JsonReadFeature; import tools.jackson.databind.DefaultTyping; +import tools.jackson.databind.MapperFeature; import tools.jackson.databind.ObjectMapper; import tools.jackson.databind.annotation.JsonDeserialize; import tools.jackson.databind.annotation.JsonSerialize; @@ -47,8 +49,8 @@ import java.util.concurrent.atomic.AtomicReference; import org.jspecify.annotations.Nullable; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; + import org.springframework.beans.BeanUtils; import org.springframework.cache.support.NullValue; @@ -66,30 +68,33 @@ class GenericJackson3JsonRedisSerializerUnitTests { private static final SimpleObject SIMPLE_OBJECT = new SimpleObject(1L); private static final ComplexObject COMPLEX_OBJECT = new ComplexObject("steelheart", SIMPLE_OBJECT); + private final GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer + .create(it -> it.enableSpringCacheNullValueSupport().enableUnsafeDefaultTyping()); + @Test // DATAREDIS-392, GH-2878 void shouldUseDefaultTyping() { - assertThat(extractTypeResolver(new GenericJackson3JsonRedisSerializer())).isNotNull(); + assertThat(extractTypeResolver(serializer)).isNotNull(); } @Test // DATAREDIS-392 void serializeShouldReturnEmptyByteArrayWhenSourceIsNull() { - assertThat(new GenericJackson3JsonRedisSerializer().serialize(null)).isEqualTo(SerializationUtils.EMPTY_ARRAY); + assertThat(serializer.serialize(null)).isEqualTo(SerializationUtils.EMPTY_ARRAY); } @Test // DATAREDIS-392 void deserializeShouldReturnNullWhenSouceIsNull() { - assertThat(new GenericJackson3JsonRedisSerializer().deserialize(null)).isNull(); + assertThat(serializer.deserialize(null)).isNull(); } @Test // DATAREDIS-392 void deserializeShouldReturnNullWhenSouceIsEmptyArray() { - assertThat(new GenericJackson3JsonRedisSerializer().deserialize(SerializationUtils.EMPTY_ARRAY)).isNull(); + assertThat(serializer.deserialize(SerializationUtils.EMPTY_ARRAY)).isNull(); } @Test // DATAREDIS-392 void deserializeShouldBeAbleToRestoreSimpleObjectAfterSerialization() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; assertThat((SimpleObject) serializer.deserialize(serializer.serialize(SIMPLE_OBJECT))).isEqualTo(SIMPLE_OBJECT); } @@ -97,7 +102,7 @@ void deserializeShouldBeAbleToRestoreSimpleObjectAfterSerialization() { @Test // DATAREDIS-392 void deserializeShouldBeAbleToRestoreComplexObjectAfterSerialization() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; assertThat((ComplexObject) serializer.deserialize(serializer.serialize(COMPLEX_OBJECT))).isEqualTo(COMPLEX_OBJECT); } @@ -126,7 +131,7 @@ void deserializeShouldThrowSerializationExceptionProcessingError() throws IOExce @Test // DATAREDIS-553, DATAREDIS-865 void shouldSerializeNullValueSoThatItCanBeDeserializedWithDefaultTypingEnabled() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; serializeAndDeserializeNullValue(serializer); } @@ -146,10 +151,9 @@ void shouldSerializeNullValueWithCustomObjectMapper() { } @Test // GH-1566 - @Disabled("cannot serialize final types") void deserializeShouldBeAbleToRestoreFinalObjectAfterSerialization() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; FinalObject source = new FinalObject(); source.longValue = 1L; @@ -167,7 +171,7 @@ void deserializeShouldBeAbleToRestoreFinalObjectAfterSerialization() { @Test // GH-2361 void shouldDeserializePrimitiveArrayWithoutTypeHint() { - GenericJackson3JsonRedisSerializer gs = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer gs = serializer; CountAndArray result = (CountAndArray) gs.deserialize( ("{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$CountAndArray\", \"count\":1, \"available\":[0,1]}") .getBytes()); @@ -219,7 +223,7 @@ void shouldConsiderReader() { GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer.create(configHelper -> { - configHelper.unsafeDefaultTyping(); + configHelper.enableUnsafeDefaultTyping(); configHelper.reader((mapper, source, type) -> { if (type.getRawClass() == User.class) { return mapper.readerWithView(Views.Basic.class).forType(type).readValue(source); @@ -243,7 +247,7 @@ void shouldConsiderReader() { @Test // GH-2361 void shouldDeserializePrimitiveWrapperArrayWithoutTypeHint() { - GenericJackson3JsonRedisSerializer gs = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer gs = this.serializer; CountAndArray result = (CountAndArray) gs.deserialize( ("{\"@class\":\"org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializerUnitTests$CountAndArray\", \"count\":1, \"arrayOfPrimitiveWrapper\":[0,1]}") .getBytes()); @@ -255,7 +259,7 @@ void shouldDeserializePrimitiveWrapperArrayWithoutTypeHint() { @Test // GH-2361 void doesNotIncludeTypingForPrimitiveArrayWrappers() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; WithWrapperTypes source = new WithWrapperTypes(); source.primitiveWrapper = new AtomicReference<>(); @@ -279,7 +283,7 @@ void doesNotIncludeTypingForPrimitiveArrayWrappers() { @Test // GH-2361 void doesNotIncludeTypingForPrimitiveWrappers() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; WithWrapperTypes source = new WithWrapperTypes(); source.primitiveWrapper = new AtomicReference<>(123L); @@ -301,7 +305,7 @@ void doesNotIncludeTypingForPrimitiveWrappers() { @Test // GH-2361 void includesTypingForWrappedObjectTypes() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; SimpleObject simpleObject = new SimpleObject(100L); WithWrapperTypes source = new WithWrapperTypes(); @@ -324,7 +328,7 @@ void includesTypingForWrappedObjectTypes() { @Test // GH-2396 void verifySerializeUUIDIntoBytes() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; UUID source = UUID.fromString("730145fe-324d-4fb1-b12f-60b89a045730"); assertThat(serializer.serialize(source)).isEqualTo(("\"" + source + "\"").getBytes(StandardCharsets.UTF_8)); @@ -333,7 +337,7 @@ void verifySerializeUUIDIntoBytes() { @Test // GH-2396 void deserializesUUIDFromBytes() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; UUID deserializedUuid = serializer .deserialize("\"730145fe-324d-4fb1-b12f-60b89a045730\"".getBytes(StandardCharsets.UTF_8), UUID.class); @@ -343,7 +347,7 @@ void deserializesUUIDFromBytes() { @Test // GH-2396 void serializesEnumIntoBytes() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; assertThat(serializer.serialize(EnumType.ONE)).isEqualTo(("\"ONE\"").getBytes(StandardCharsets.UTF_8)); } @@ -351,7 +355,7 @@ void serializesEnumIntoBytes() { @Test // GH-2396 void deserializesEnumFromBytes() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; assertThat(serializer.deserialize("\"TWO\"".getBytes(StandardCharsets.UTF_8), EnumType.class)) .isEqualTo(EnumType.TWO); @@ -360,7 +364,7 @@ void deserializesEnumFromBytes() { @Test // GH-2396 void serializesJavaTimeIntoBytes() { - GenericJackson3JsonRedisSerializer serializer = new GenericJackson3JsonRedisSerializer(); + GenericJackson3JsonRedisSerializer serializer = this.serializer; WithJsr310 source = new WithJsr310(); source.myDate = LocalDate.of(2022, 9, 2); @@ -381,6 +385,26 @@ void deserializesJavaTimeFromBytes() { assertThat(serializer.deserialize(source, WithJsr310.class).myDate).isEqualTo(LocalDate.of(2022, 9, 2)); } + @Test // GH-3292 + void configuresObjectMapper() { + + GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer + .builder(() -> new ObjectMapper().rebuild()) + .customize(mb -> mb.enable(MapperFeature.ACCEPT_CASE_INSENSITIVE_ENUMS)).build(); + + assertThat(serializer).isNotNull(); + } + + @Test // GH-3292 + void configuresJsonMapper() { + + GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer.create(b -> { + b.customize(mb -> mb.enable(JsonReadFeature.ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER)); + }); + + assertThat(serializer).isNotNull(); + } + private static void serializeAndDeserializeNullValue(GenericJackson3JsonRedisSerializer serializer) { NullValue nv = BeanUtils.instantiateClass(NullValue.class);