> 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 73ca9fe9b6..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,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 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 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);
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 extends Map.Entry> source) {
+
+ Map resultMap = new HashMap<>(source.size());
+ flatten(adapterFactory, "", source, resultMap::put);
+ return resultMap;
+ }
+
+ private static void flatten(FlatEric.JsonNodeAdapterFactory adapterFactory, String propertyPrefix,
+ Collection extends Map.Entry> 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 extends JsonNodeAdapter> 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 extends FlatEric.JsonNodeAdapter> list, BiConsumer sink) {
+
+ Iterator extends FlatEric.JsonNodeAdapter> 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 extends JsonNodeAdapter> 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 6c2aa8181b..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,12 +144,11 @@
* @author Mark Paluch
* @author John Blum
* @since 1.8
+ * @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;
@@ -232,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
@@ -242,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);
@@ -257,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() {
@@ -612,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 extends FlatEric.JsonNodeAdapter> 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
new file mode 100644
index 0000000000..f92bd61065
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/hash/Jackson3HashMapper.java
@@ -0,0 +1,664 @@
+/*
+ * 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 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.*;
+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.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;
+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.Calendar;
+import java.util.Collection;
+import java.util.Date;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Map.Entry;
+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.util.DirectFieldAccessFallbackBeanWrapper;
+import org.springframework.data.util.Lazy;
+import org.springframework.lang.Contract;
+import org.springframework.util.Assert;
+
+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.
+ *
+ * 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;
+ * String lastname;
+ * Address address;
+ * Date date;
+ * LocalDateTime localDateTime;
+ * }
+ *
+ * class Address {
+ * String city;
+ * String country;
+ * }
+ *
+ *
+ * Normal
+ *
+ *
+ * Hash field
+ * Value
+ *
+ *
+ * firstname
+ * Jon
+ *
+ *
+ * lastname
+ * Snow
+ *
+ *
+ * address
+ * { "city" : "Castle Black", "country" : "The North" }
+ *
+ *
+ * date
+ * 1561543964015
+ *
+ *
+ * localDateTime
+ * 2018-01-02T12:13:14
+ *
+ *
+ * Flat
+ *
+ *
+ * Hash field
+ * Value
+ *
+ *
+ * firstname
+ * Jon
+ *
+ *
+ * lastname
+ * Snow
+ *
+ *
+ * address.city
+ * Castle Black
+ *
+ *
+ * address.country
+ * The North
+ *
+ *
+ * date
+ * 1561543964015
+ *
+ *
+ * localDateTime
+ * 2018-01-02T12:13:14
+ *
+ *
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public class Jackson3HashMapper implements HashMapper {
+
+ 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;
+
+ /**
+ * 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}.
+ * @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();
+ }
+
+ /**
+ * 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 extends ObjectMapper, ? extends 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(@Nullable Object source) {
+
+ JsonNode tree = this.typingMapper.valueToTree(source);
+ return this.flatten ? FlatEric.flatten(Jackson3AdapterFactory.INSTANCE, tree.properties())
+ : this.untypedMapper.convertValue(tree, Map.class);
+ }
+
+ @Override
+ @SuppressWarnings("all")
+ public Object fromHash(Map hash) {
+
+ try {
+ if (this.flatten) {
+
+ Map unflattenedHash = FlatEric.unflatten(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);
+ }
+ }
+
+
+ /**
+ * Builder to create a {@link Jackson3HashMapper} instance.
+ *
+ * @param type of the {@link MapperBuilder}.
+ */
+ public static class Jackson3HashMapperBuilder>> {
+
+ private final Supplier builderFactory;
+
+ 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 -> {};
+
+ private Jackson3HashMapperBuilder(Supplier builderFactory) {
+ this.builderFactory = builderFactory;
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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);
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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;
+ }
+
+ /**
+ * 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) {
+
+ Assert.notNull(typeValidator, "Type validator must not be null");
+
+ this.typeValidator = typeValidator;
+ return this;
+ }
+
+ /**
+ * 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) {
+
+ Assert.notNull(mapperBuilderCustomizer, "JSON mapper customizer must not be null");
+
+ this.mapperBuilderCustomizer = mapperBuilderCustomizer;
+ return this;
+ }
+
+ /**
+ * Build a new {@link Jackson3HashMapper} instance with the configured settings.
+ *
+ * @return a new {@link Jackson3HashMapper} instance.
+ */
+ @Contract("-> new")
+ public Jackson3HashMapper build() {
+
+ B mapperBuilder = builderFactory.get();
+
+ preconfigure(mapperBuilder, jackson2CompatibilityMode);
+ mapperBuilder.setDefaultTyping(getDefaultTyping(typeValidator, flatten, "@class"));
+
+ mapperBuilderCustomizer.accept(mapperBuilder);
+
+ return new Jackson3HashMapper(mapperBuilder.build(), flatten);
+ }
+
+ private static TypeResolverBuilder> getDefaultTyping(PolymorphicTypeValidator typeValidator, boolean flatten,
+ String typePropertyName) {
+
+ return new DefaultTypeResolverBuilder(typeValidator, DefaultTyping.NON_FINAL, typePropertyName) {
+
+ @Override
+ public boolean useForType(JavaType type) {
+
+ if (type.isPrimitive()) {
+ return false;
+ }
+
+ if (flatten && (type.isTypeOrSubTypeOf(Number.class) || type.isEnumType())) {
+ return false;
+ }
+
+ 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";
+ }
+
+ @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(useCalendarTimestamps));
+
+ 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");
+
+ public final boolean useTimestamps;
+
+ public UTCCalendarSerializer(boolean useTimestamps) {
+ this.useTimestamps = useTimestamps;
+ }
+
+ @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);
+ }
+
+ protected boolean _asTimestamp(SerializationContext serializers) {
+ return useTimestamps;
+ }
+
+ }
+
+ 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;
+ }
+ }
+
+ 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 extends FlatEric.JsonNodeAdapter> 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 fcf2f9eff0..42fa6422d1 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,10 @@
* @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}
*/
+@SuppressWarnings("removal")
+@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..bcf6a3161e
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializer.java
@@ -0,0 +1,666 @@
+/*
+ * 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.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.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.jsontype.BasicPolymorphicTypeValidator;
+import tools.jackson.databind.jsontype.PolymorphicTypeValidator;
+import tools.jackson.databind.jsontype.TypeDeserializer;
+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.Collections;
+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 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}.
+ *
+ * @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 ObjectMapper mapper;
+
+ private final TypeResolver typeResolver;
+
+ /**
+ * 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}.
+ */
+ protected 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);
+
+ 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) {
+ return SerializationUtils.EMPTY_ARRAY;
+ }
+
+ try {
+ return writer.write(mapper, value);
+ } catch (RuntimeException 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 Jackson3 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 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 GenericJackson3JsonRedisSerializerBuilder>> {
+
+ private final Supplier builderFactory;
+
+ 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;
+ }
+
+ /**
+ * 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 GenericJackson3JsonRedisSerializerBuilder enableSpringCacheNullValueSupport() {
+
+ this.cacheNullValueSupportEnabled = true;
+ 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 {@code this} builder.
+ */
+ @Contract("_ -> this")
+ public GenericJackson3JsonRedisSerializerBuilder enableSpringCacheNullValueSupport(String typePropertyName) {
+
+ typePropertyName(typePropertyName);
+ return enableSpringCacheNullValueSupport();
+ }
+
+ /**
+ * 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 {@code this} builder.
+ * @see https://owasp.org/www-community/vulnerabilities/Deserialization_of_untrusted_data
+ */
+ @Contract("-> this")
+ 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);
+
+ 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 {@code this} builder.
+ */
+ @Contract("_ -> this")
+ public GenericJackson3JsonRedisSerializerBuilder writer(Jackson3ObjectWriter writer) {
+
+ Assert.notNull(writer, "Jackson3ObjectWriter must not be null");
+
+ this.writer = writer;
+ return this;
+ }
+
+ /**
+ * Configures the {@link Jackson3ObjectReader}.
+ *
+ * @param reader must not be {@literal null}.
+ * @return {@code this} builder.
+ */
+ @Contract("_ -> this")
+ public GenericJackson3JsonRedisSerializerBuilder reader(Jackson3ObjectReader reader) {
+
+ Assert.notNull(reader, "Jackson3ObjectReader must not be null");
+
+ this.reader = reader;
+ return this;
+ }
+
+ /**
+ * 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 GenericJackson3JsonRedisSerializerBuilder customize(Consumer mapperBuilderCustomizer) {
+
+ Assert.notNull(mapperBuilderCustomizer, "JSON mapper configurer must not be null");
+
+ this.mapperBuilderCustomizer = mapperBuilderCustomizer;
+ return this;
+ }
+
+ /**
+ * 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);
+ }
+
+ mapperBuilderCustomizer.accept(mapperBuilder);
+
+ return new GenericJackson3JsonRedisSerializer(mapperBuilder.build(), reader, writer);
+ }
+
+ }
+
+ 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
+ */
+ 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();
+ }
+ }
+
+ @Override
+ public void serializeWithType(NullValue value, JsonGenerator gen, SerializationContext ctxt, TypeSerializer typeSer)
+ throws JacksonException {
+ serialize(value, gen, ctxt);
+ }
+ }
+
+ 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) {
+ 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);
+
+ 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 e280d9d8b6..bdd0de0e18 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,10 @@
* @author Thomas Darimont
* @author Mark Paluch
* @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
new file mode 100644
index 0000000000..23c75a9443
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3JsonRedisSerializer.java
@@ -0,0 +1,170 @@
+/*
+ * 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.serializer;
+
+import tools.jackson.databind.JavaType;
+import tools.jackson.databind.ObjectMapper;
+import tools.jackson.databind.json.JsonMapper;
+import tools.jackson.databind.type.TypeFactory;
+
+import org.jspecify.annotations.Nullable;
+
+import org.springframework.util.Assert;
+
+/**
+ * {@link RedisSerializer} that can read and write JSON using
+ * 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 Jackson3ObjectReader} respective
+ * {@link Jackson3ObjectWriter}.
+ *
+ * @author Christoph Strobl
+ * @author Thomas Darimont
+ * @author Mark Paluch
+ * @since 4.0
+ */
+public class Jackson3JsonRedisSerializer implements RedisSerializer {
+
+ private final JavaType javaType;
+
+ private final 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(JsonMapper.shared(), type);
+ }
+
+ /**
+ * Creates a new {@link Jackson3JsonRedisSerializer} for the given target {@link JavaType}.
+ *
+ * @param javaType must not be {@literal null}.
+ */
+ public Jackson3JsonRedisSerializer(JavaType javaType) {
+ this(JsonMapper.shared(), 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}.
+ */
+ 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}.
+ */
+ 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 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");
+
+ this.mapper = mapper;
+ this.reader = reader;
+ this.writer = writer;
+ this.javaType = javaType;
+ }
+
+ @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 (RuntimeException ex) {
+ throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex);
+ }
+ }
+
+ @Nullable
+ @Override
+ @SuppressWarnings("unchecked")
+ 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 (RuntimeException 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..63de096f9b
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectReader.java
@@ -0,0 +1,55 @@
+/*
+ * 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.serializer;
+
+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 tools.jackson.databind.ObjectReader} applying serialization features, date formats, or views.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.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.
+ */
+ Object read(ObjectMapper mapper, byte[] source, JavaType type);
+
+ /**
+ * Create a default {@link Jackson3ObjectReader} delegating to
+ * {@link ObjectMapper#readValue(byte[], int, int, 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..ecf133fbee
--- /dev/null
+++ b/src/main/java/org/springframework/data/redis/serializer/Jackson3ObjectWriter.java
@@ -0,0 +1,52 @@
+/*
+ * 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.serializer;
+
+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 tools.jackson.databind.ObjectWriter} applying serialization features, date formats, or views.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @since 4.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.
+ */
+ byte[] write(ObjectMapper mapper, Object source);
+
+ /**
+ * 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/Jackson3CompatibilityTests.java b/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java
new file mode 100644
index 0000000000..5c4d60a741
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3CompatibilityTests.java
@@ -0,0 +1,50 @@
+/*
+ * 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.*;
+
+import java.util.Map;
+
+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
+ */
+@SuppressWarnings("removal")
+class Jackson3CompatibilityTests extends Jackson3HashMapperUnitTests {
+
+ private final Jackson2HashMapper jackson2HashMapper;
+
+ Jackson3CompatibilityTests() {
+ super(Jackson3HashMapper.builder().jackson2CompatibilityMode().build());
+ this.jackson2HashMapper = new Jackson2HashMapper(false);
+ }
+
+ @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..d759991848
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3FlatteningCompatibilityTests.java
@@ -0,0 +1,50 @@
+/*
+ * 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.*;
+
+import java.util.Map;
+
+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
+ */
+@SuppressWarnings("removal")
+class Jackson3FlatteningCompatibilityTests extends Jackson3HashMapperUnitTests {
+
+ private final Jackson2HashMapper jackson2HashMapper;
+
+ Jackson3FlatteningCompatibilityTests() {
+ super(Jackson3HashMapper.builder().jackson2CompatibilityMode().flatten().build());
+ this.jackson2HashMapper = new Jackson2HashMapper(true);
+ }
+
+ @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..fe23e9f430
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperFlatteningUnitTests.java
@@ -0,0 +1,65 @@
+/*
+ * 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.*;
+
+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
+ */
+class Jackson3HashMapperFlatteningUnitTests extends Jackson3HashMapperUnitTests {
+
+ Jackson3HashMapperFlatteningUnitTests() {
+ super(Jackson3HashMapper.flattening());
+ }
+
+ @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..8b70a3ee83
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperNonFlatteningUnitTests.java
@@ -0,0 +1,65 @@
+/*
+ * 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.*;
+
+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
+ */
+class Jackson3HashMapperNonFlatteningUnitTests extends Jackson3HashMapperUnitTests {
+
+ Jackson3HashMapperNonFlatteningUnitTests() {
+ super(Jackson3HashMapper.hierarchical());
+ }
+
+ @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..db28ec61eb
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/mapping/Jackson3HashMapperUnitTests.java
@@ -0,0 +1,528 @@
+/*
+ * 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.*;
+
+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;
+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.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.Jackson3HashMapper;
+
+/**
+ * Support class for {@link Jackson3HashMapper} unit tests.
+ *
+ * @author Christoph Strobl
+ * @author Mark Paluch
+ * @author John Blum
+ */
+abstract class Jackson3HashMapperUnitTests extends AbstractHashMapperTests {
+
+ private final Jackson3HashMapper mapper;
+
+ Jackson3HashMapperUnitTests(Jackson3HashMapper mapper) {
+
+ this.mapper = mapper;
+ }
+
+ 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
+ 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);
+ }
+
+ @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;
+ 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..4b017ec246
--- /dev/null
+++ b/src/test/java/org/springframework/data/redis/serializer/GenericJackson3JsonRedisSerializerUnitTests.java
@@ -0,0 +1,677 @@
+/*
+ * 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.*;
+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.*;
+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;
+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.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);
+
+ private final GenericJackson3JsonRedisSerializer serializer = GenericJackson3JsonRedisSerializer
+ .create(it -> it.enableSpringCacheNullValueSupport().enableUnsafeDefaultTyping());
+
+ @Test // DATAREDIS-392, GH-2878
+ void shouldUseDefaultTyping() {
+ assertThat(extractTypeResolver(serializer)).isNotNull();
+ }
+
+ @Test // DATAREDIS-392
+ void serializeShouldReturnEmptyByteArrayWhenSourceIsNull() {
+ assertThat(serializer.serialize(null)).isEqualTo(SerializationUtils.EMPTY_ARRAY);
+ }
+
+ @Test // DATAREDIS-392
+ void deserializeShouldReturnNullWhenSouceIsNull() {
+ assertThat(serializer.deserialize(null)).isNull();
+ }
+
+ @Test // DATAREDIS-392
+ void deserializeShouldReturnNullWhenSouceIsEmptyArray() {
+ assertThat(serializer.deserialize(SerializationUtils.EMPTY_ARRAY)).isNull();
+ }
+
+ @Test // DATAREDIS-392
+ void deserializeShouldBeAbleToRestoreSimpleObjectAfterSerialization() {
+
+ GenericJackson3JsonRedisSerializer serializer = this.serializer;
+
+ assertThat((SimpleObject) serializer.deserialize(serializer.serialize(SIMPLE_OBJECT))).isEqualTo(SIMPLE_OBJECT);
+ }
+
+ @Test // DATAREDIS-392
+ void deserializeShouldBeAbleToRestoreComplexObjectAfterSerialization() {
+
+ GenericJackson3JsonRedisSerializer serializer = this.serializer;
+
+ 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 = this.serializer;
+
+ 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
+ void deserializeShouldBeAbleToRestoreFinalObjectAfterSerialization() {
+
+ GenericJackson3JsonRedisSerializer serializer = this.serializer;
+
+ 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 = serializer;
+ 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.enableUnsafeDefaultTyping();
+ 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 = this.serializer;
+ 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 = this.serializer;
+
+ 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 = this.serializer;
+
+ 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 = this.serializer;
+
+ 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 = this.serializer;
+
+ 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 = this.serializer;
+ 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 = this.serializer;
+
+ assertThat(serializer.serialize(EnumType.ONE)).isEqualTo(("\"ONE\"").getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test // GH-2396
+ void deserializesEnumFromBytes() {
+
+ GenericJackson3JsonRedisSerializer serializer = this.serializer;
+
+ assertThat(serializer.deserialize("\"TWO\"".getBytes(StandardCharsets.UTF_8), EnumType.class))
+ .isEqualTo(EnumType.TWO);
+ }
+
+ @Test // GH-2396
+ void serializesJavaTimeIntoBytes() {
+
+ GenericJackson3JsonRedisSerializer serializer = this.serializer;
+
+ 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));
+ }
+
+ @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);
+
+ 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);