diff --git a/pom.xml b/pom.xml index 6e25f1d07b..aa93a7d888 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ org.springframework.data spring-data-redis - 4.0.0-SNAPSHOT + 4.0.x-GH-3154-SNAPSHOT Spring Data Redis Spring Data module for Redis @@ -91,7 +91,7 @@ spring-context-support - + redis.clients @@ -121,7 +121,8 @@ test - + + io.projectreactor reactor-core @@ -139,7 +140,6 @@ com.fasterxml.jackson.datatype jackson-datatype-jsr310 - ${jackson} true @@ -149,6 +149,20 @@ true + + + + tools.jackson.core + jackson-databind + true + + + + com.fasterxml.jackson.core + jackson-annotations + true + + commons-beanutils commons-beanutils @@ -227,6 +241,7 @@ + org.jetbrains.kotlin kotlin-stdlib diff --git a/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc b/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc index 334a2fd515..6f320d436b 100644 --- a/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/hash-mappers.adoc @@ -1,7 +1,7 @@ [[redis.hashmappers.root]] = Hash Mapping -Data can be stored by using various data structures within Redis. javadoc:org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer[] can convert objects in https://en.wikipedia.org/wiki/JSON[JSON] format. Ideally, JSON can be stored as a value by using plain keys. You can achieve a more sophisticated mapping of structured objects by using Redis hashes. Spring Data Redis offers various strategies for mapping data to hashes (depending on the use case): +Data can be stored by using various data structures within Redis. javadoc:org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer[] can convert objects in https://en.wikipedia.org/wiki/JSON[JSON] format. Ideally, JSON can be stored as a value by using plain keys. You can achieve a more sophisticated mapping of structured objects by using Redis hashes. Spring Data Redis offers various strategies for mapping data to hashes (depending on the use case): * Direct mapping, by using javadoc:org.springframework.data.redis.core.HashOperations[] and a xref:redis.adoc#redis:serializer[serializer] * Using xref:repositories.adoc[Redis Repositories] @@ -16,7 +16,8 @@ Multiple implementations are available: * javadoc:org.springframework.data.redis.hash.BeanUtilsHashMapper[] using Spring's {spring-framework-javadoc}/org/springframework/beans/BeanUtils.html[BeanUtils]. * javadoc:org.springframework.data.redis.hash.ObjectHashMapper[] using xref:redis/redis-repositories/mapping.adoc[Object-to-Hash Mapping]. -* <> using https://github.com/FasterXML/jackson[FasterXML Jackson]. +* <> using https://github.com/FasterXML/jackson[FasterXML Jackson 3]. +* <> (deprecated) using https://github.com/FasterXML/jackson[FasterXML Jackson 2]. The following example shows one way to implement hash mapping: @@ -50,9 +51,91 @@ public class HashMapping { } ---- +[[redis.hashmappers.jackson3]] +=== Jackson3HashMapper + +javadoc:org.springframework.data.redis.hash.Jackson3HashMapper[] provides Redis Hash mapping for domain objects by using https://github.com/FasterXML/jackson[FasterXML Jackson 3]. +`Jackson3HashMapper` can map top-level properties as Hash field names and, optionally, flatten the structure. +Simple types map to simple values. Complex types (nested objects, collections, maps, and so on) are represented as nested JSON. + +Flattening creates individual hash entries for all nested properties and resolves complex types into simple types, as far as possible. + +Consider the following class and the data structure it contains: + +[source,java] +---- +public class Person { + String firstname; + String lastname; + Address address; + Date date; + LocalDateTime localDateTime; +} + +public class Address { + String city; + String country; +} +---- + +The following table shows how the data in the preceding class would appear in normal mapping: + +.Normal Mapping +[width="80%",cols="<1,<2",options="header"] +|==== +|Hash Field +|Value + +|firstname +|`Jon` + +|lastname +|`Snow` + +|address +|`{ "city" : "Castle Black", "country" : "The North" }` + +|date +|1561543964015 + +|localDateTime +|`2018-01-02T12:13:14` +|==== + +The following table shows how the data in the preceding class would appear in flat mapping: + +.Flat Mapping +[width="80%",cols="<1,<2",options="header"] +|==== +|Hash Field +|Value + +|firstname +|`Jon` + +|lastname +|`Snow` + +|address.city +|`Castle Black` + +|address.country +|`The North` + +|date +|1561543964015 + +|localDateTime +|`2018-01-02T12:13:14` +|==== + +NOTE: Flattening requires all property names to not interfere with the JSON path. Using dots or brackets in map keys or as property names is not supported when you use flattening. The resulting hash cannot be mapped back into an Object. + [[redis.hashmappers.jackson2]] === Jackson2HashMapper +WARNING: Jackson 2 based implementations have been deprecated and are subject to removal in a subsequent release. + javadoc:org.springframework.data.redis.hash.Jackson2HashMapper[] provides Redis Hash mapping for domain objects by using https://github.com/FasterXML/jackson[FasterXML Jackson]. `Jackson2HashMapper` can map top-level properties as Hash field names and, optionally, flatten the structure. Simple types map to simple values. Complex types (nested objects, collections, maps, and so on) are represented as nested JSON. diff --git a/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc b/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc index fb1a08c0f8..11ea76b302 100644 --- a/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/redis-repositories/mapping.adoc @@ -92,11 +92,11 @@ The following example shows two sample byte array converters: @WritingConverter public class AddressToBytesConverter implements Converter { - private final Jackson2JsonRedisSerializer
serializer; + private final Jackson3JsonRedisSerializer
serializer; public AddressToBytesConverter() { - serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer = new Jackson3JsonRedisSerializer
(Address.class); serializer.setObjectMapper(new ObjectMapper()); } @@ -109,11 +109,11 @@ public class AddressToBytesConverter implements Converter { @ReadingConverter public class BytesToAddressConverter implements Converter { - private final Jackson2JsonRedisSerializer
serializer; + private final Jackson3JsonRedisSerializer
serializer; public BytesToAddressConverter() { - serializer = new Jackson2JsonRedisSerializer
(Address.class); + serializer = new Jackson3JsonRedisSerializer
(Address.class); serializer.setObjectMapper(new ObjectMapper()); } diff --git a/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc b/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc index 25d916f637..0d71ee07f5 100644 --- a/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/redis-streams.adoc @@ -291,7 +291,7 @@ You may provide a `HashMapper` suitable for your requirements when obtaining `St [source,java] ---- redisTemplate() - .opsForStream(new Jackson2HashMapper(true)) + .opsForStream(new Jackson3HashMapper(true)) .add(record); <1> ---- <1> XADD user-logon * "firstname" "night" "@class" "com.example.User" "lastname" "angel" diff --git a/src/main/antora/modules/ROOT/pages/redis/template.adoc b/src/main/antora/modules/ROOT/pages/redis/template.adoc index 05af2e789b..30f89e88e3 100644 --- a/src/main/antora/modules/ROOT/pages/redis/template.adoc +++ b/src/main/antora/modules/ROOT/pages/redis/template.adoc @@ -365,7 +365,7 @@ Multiple implementations are available (including two that have been already men * javadoc:org.springframework.data.redis.serializer.JdkSerializationRedisSerializer[], which is used by default for javadoc:org.springframework.data.redis.cache.RedisCache[] and javadoc:org.springframework.data.redis.core.RedisTemplate[]. * the `StringRedisSerializer`. -However, one can use `OxmSerializer` for Object/XML mapping through Spring {spring-framework-docs}/data-access.html#oxm[OXM] support or javadoc:org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer[] or javadoc:org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer[] for storing data in https://en.wikipedia.org/wiki/JSON[JSON] format. +However, one can use `OxmSerializer` for Object/XML mapping through Spring {spring-framework-docs}/data-access.html#oxm[OXM] support or javadoc:org.springframework.data.redis.serializer.Jackson3JsonRedisSerializer[] or javadoc:org.springframework.data.redis.serializer.GenericJackson3JsonRedisSerializer[] for storing data in https://en.wikipedia.org/wiki/JSON[JSON] format. Do note that the storage format is not limited only to values. It can be used for keys, values, or hashes without any restrictions. diff --git a/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java b/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java index 69427921d0..64f145afbd 100644 --- a/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/ReactiveKeyCommands.java @@ -242,9 +242,10 @@ default Mono touch(Collection keys) { Flux, Long>> touch(Publisher> keys); /** - * Find all keys matching the given {@literal pattern}.
- * It is recommended to use {@link #scan(ScanOptions)} to iterate over the keyspace as {@link #keys(ByteBuffer)} is a - * non-interruptible and expensive Redis operation. + * Retrieve all keys matching the given pattern via {@code KEYS} command. + *

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

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

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

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

+ * IMPORTANT: This command is non-interruptible and scans the entire keyspace which may cause + * performance issues. Consider {@link #scan(ScanOptions)} for large datasets. * * @param pattern must not be {@literal null}. * @return the {@link Flux} emitting matching keys one by one. diff --git a/src/main/java/org/springframework/data/redis/core/RedisOperations.java b/src/main/java/org/springframework/data/redis/core/RedisOperations.java index 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> source) { + + Map resultMap = new HashMap<>(source.size()); + flatten(adapterFactory, "", source, resultMap::put); + return resultMap; + } + + private static void flatten(FlatEric.JsonNodeAdapterFactory adapterFactory, String propertyPrefix, + Collection> inputMap, BiConsumer sink) { + + if (StringUtils.hasText(propertyPrefix)) { + propertyPrefix = propertyPrefix + "."; + } + + for (Map.Entry entry : inputMap) { + flattenElement(adapterFactory, propertyPrefix + entry.getKey(), entry.getValue(), sink); + } + } + + private static void flattenElement(FlatEric.JsonNodeAdapterFactory adapterFactory, String propertyPrefix, + Object source, BiConsumer sink) { + + if (!adapterFactory.isJsonNode(source)) { + sink.accept(propertyPrefix, source); + return; + } + + FlatEric.JsonNodeAdapter adapter = adapterFactory.adapt(source); + + if (adapter.isArray()) { + + Iterator nodes = adapter.values().iterator(); + + while (nodes.hasNext()) { + + FlatEric.JsonNodeAdapter currentNode = nodes.next(); + + if (currentNode.isArray()) { + flattenCollection(adapterFactory, propertyPrefix, currentNode.values(), sink); + } else if (nodes.hasNext() && mightBeJavaType(currentNode)) { + + FlatEric.JsonNodeAdapter next = nodes.next(); + + if (next.isArray()) { + flattenCollection(adapterFactory, propertyPrefix, next.values(), sink); + } + if (currentNode.asString().equals("java.util.Date")) { + sink.accept(propertyPrefix, next.asString()); + break; + } + if (next.isNumber()) { + sink.accept(propertyPrefix, next.numberValue()); + break; + } + if (next.isString()) { + sink.accept(propertyPrefix, next.stringValue()); + break; + } + if (next.isBoolean()) { + sink.accept(propertyPrefix, next.booleanValue()); + break; + } + if (next.isBinary()) { + + try { + sink.accept(propertyPrefix, next.binaryValue()); + } catch (Exception ex) { + throw new IllegalStateException("Cannot read binary value '%s'".formatted(propertyPrefix), ex); + } + + break; + } + } + } + } else if (adapter.isObject()) { + flatten(adapterFactory, propertyPrefix, adapter.properties(), sink); + } else { + + switch (adapter.getNodeType()) { + case STRING -> sink.accept(propertyPrefix, adapter.stringValue()); + case NUMBER -> sink.accept(propertyPrefix, adapter.numberValue()); + case BOOLEAN -> sink.accept(propertyPrefix, adapter.booleanValue()); + case BINARY -> { + try { + sink.accept(propertyPrefix, adapter.binaryValue()); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + default -> sink.accept(propertyPrefix, adapter.getDirectValue()); + } + } + } + + private static boolean mightBeJavaType(FlatEric.JsonNodeAdapter node) { + + String textValue = node.asString(); + return javax.lang.model.SourceVersion.isName(textValue); + } + + private static void flattenCollection(FlatEric.JsonNodeAdapterFactory adapterFactory, String propertyPrefix, + Collection list, BiConsumer sink) { + + Iterator iterator = list.iterator(); + + for (int counter = 0; iterator.hasNext(); counter++) { + FlatEric.JsonNodeAdapter element = iterator.next(); + flattenElement(adapterFactory, propertyPrefix + "[" + counter + "]", element, sink); + } + } + + /** + * Factory to create a {@link JsonNodeAdapter} from a given node type. + */ + public interface JsonNodeAdapterFactory { + + JsonNodeAdapter adapt(Object node); + + boolean isJsonNode(Object value); + } + + public enum JsonNodeType { + ARRAY, BINARY, BOOLEAN, MISSING, NULL, NUMBER, OBJECT, POJO, STRING + } + + /** + * Wrapper around a JSON node to adapt between Jackson 2 and 3 JSON nodes. + */ + public interface JsonNodeAdapter { + + JsonNodeType getNodeType(); + + boolean isArray(); + + Collection values(); + + String asString(); + + boolean isNumber(); + + Number numberValue(); + + boolean isString(); + + String stringValue(); + + boolean isBoolean(); + + boolean booleanValue(); + + boolean isBinary(); + + byte[] binaryValue(); + + boolean isObject(); + + Collection> properties(); + + Object getDirectValue(); + } + +} diff --git a/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java b/src/main/java/org/springframework/data/redis/hash/Jackson2HashMapper.java index 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 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 fieldValue
firstnameJon
lastnameSnow
address{ "city" : "Castle Black", "country" : "The North" }
date1561543964015
localDateTime2018-01-02T12:13:14
+ *

Flat

+ * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + * + *
Hash fieldValue
firstnameJon
lastnameSnow
address.cityCastle Black
address.countryThe North
date1561543964015
localDateTime2018-01-02T12:13:14
+ * + * @author Christoph Strobl + * @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> 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 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);