From a6781bc8383fa2d7d403bf8854b5f9467046bace Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Tue, 23 Jun 2026 12:35:46 +0300 Subject: [PATCH 1/8] Added connection layer Signed-off-by: Yordan Tsintsov --- .../data/redis/aot/RedisRuntimeHints.java | 2 + .../DefaultStringRedisConnection.java | 5 + .../connection/DefaultedRedisConnection.java | 109 ++++++ .../redis/connection/JsonSetCondition.java | 94 +++++ .../data/redis/connection/RedisCommands.java | 3 +- .../connection/RedisCommandsProvider.java | 9 + .../redis/connection/RedisJsonCommands.java | 329 ++++++++++++++++++ .../jedis/JedisClusterConnection.java | 6 + .../jedis/JedisClusterJsonCommands.java | 32 ++ .../connection/jedis/JedisConnection.java | 6 + .../connection/jedis/JedisConverters.java | 29 +- .../redis/connection/jedis/JedisInvoker.java | 3 +- .../connection/jedis/JedisJsonCommands.java | 207 +++++++++++ .../connection/json/DefaultJsonPath.java | 39 +++ .../connection/json/DefaultJsonValue.java | 64 ++++ .../data/redis/connection/json/JsonPath.java | 52 +++ .../data/redis/connection/json/JsonValue.java | 111 ++++++ .../redis/connection/json/package-info.java | 5 + .../lettuce/LettuceClusterConnection.java | 6 + .../lettuce/LettuceClusterJsonCommands.java | 28 ++ .../connection/lettuce/LettuceConnection.java | 6 + .../connection/lettuce/LettuceConverters.java | 32 +- .../lettuce/LettuceJsonCommands.java | 206 +++++++++++ .../data/redis/core/RedisCommand.java | 17 + .../AbstractConnectionIntegrationTests.java | 253 ++++++++++++++ .../connection/RedisConnectionUnitTests.java | 5 + .../lettuce/LettuceConvertersUnitTests.java | 14 + .../config/redis-master/master.conf | 1 + 28 files changed, 1660 insertions(+), 13 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/connection/JsonSetCondition.java create mode 100644 src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java create mode 100644 src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterJsonCommands.java create mode 100644 src/main/java/org/springframework/data/redis/connection/jedis/JedisJsonCommands.java create mode 100644 src/main/java/org/springframework/data/redis/connection/json/DefaultJsonPath.java create mode 100644 src/main/java/org/springframework/data/redis/connection/json/DefaultJsonValue.java create mode 100644 src/main/java/org/springframework/data/redis/connection/json/JsonPath.java create mode 100644 src/main/java/org/springframework/data/redis/connection/json/JsonValue.java create mode 100644 src/main/java/org/springframework/data/redis/connection/json/package-info.java create mode 100644 src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterJsonCommands.java create mode 100644 src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java diff --git a/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java b/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java index 4111aa2c55..7935c23c6a 100644 --- a/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java +++ b/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java @@ -89,6 +89,7 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) TypeReference.of(RedisKeyCommands.class), TypeReference.of(RedisStringCommands.class), TypeReference.of(RedisListCommands.class), TypeReference.of(RedisSetCommands.class), TypeReference.of(RedisZSetCommands.class), TypeReference.of(RedisHashCommands.class), + TypeReference.of(RedisJsonCommands.class), TypeReference.of(RedisTxCommands.class), TypeReference.of(RedisPubSubCommands.class), TypeReference.of(RedisConnectionCommands.class), TypeReference.of(RedisServerCommands.class), TypeReference.of(RedisStreamCommands.class), TypeReference.of(RedisScriptingCommands.class), @@ -175,6 +176,7 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) registerRedisConnectionProxy(TypeReference.of(RedisStreamCommands.class), hints); registerRedisConnectionProxy(TypeReference.of(RedisStringCommands.class), hints); registerRedisConnectionProxy(TypeReference.of(RedisZSetCommands.class), hints); + registerRedisConnectionProxy(TypeReference.of(RedisJsonCommands.class), hints); } static void boundOperationsProxy(Class type, @Nullable ClassLoader classLoader, RuntimeHints hints) { diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java index f8956a7155..163deff992 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java @@ -249,6 +249,11 @@ public RedisZSetCommands zSetCommands() { return this; } + @Override + public RedisJsonCommands jsonCommands() { + return delegate.jsonCommands(); + } + @Override public Long append(byte[] key, byte[] value) { return convertAndReturn(delegate.append(key, value), Converters.identityConverter()); diff --git a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java index faa30b3b23..cd1191fe54 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultedRedisConnection.java @@ -31,6 +31,8 @@ import org.springframework.data.geo.GeoResults; import org.springframework.data.geo.Metric; import org.springframework.data.geo.Point; +import org.springframework.data.redis.connection.json.JsonPath; +import org.springframework.data.redis.connection.json.JsonValue; import org.springframework.data.redis.connection.stream.ByteRecord; import org.springframework.data.redis.connection.stream.Consumer; import org.springframework.data.redis.connection.stream.MapRecord; @@ -2071,4 +2073,111 @@ default Long zRangeStoreRevByScore(byte[] dstKey, byte[] srcKey, return zSetCommands().zRangeStoreRevByScore(dstKey, srcKey, range, limit); } + // JSON COMMANDS + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonArrAppend(byte @NonNull [] key, @NonNull JsonPath path, JsonValue @NonNull... values) { + return jsonCommands().jsonArrAppend(key, path, values); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonArrIndex(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value) { + return jsonCommands().jsonArrIndex(key, path, value); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonArrInsert(byte @NonNull [] key, @NonNull JsonPath path, int index, JsonValue @NonNull... values) { + return jsonCommands().jsonArrInsert(key, path, index, values); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonArrLen(byte @NonNull [] key, @NonNull JsonPath path) { + return jsonCommands().jsonArrLen(key, path); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonArrTrim(byte @NonNull [] key, @NonNull JsonPath path, int start, int stop) { + return jsonCommands().jsonArrTrim(key, path, start, stop); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default Long jsonClear(byte @NonNull [] key, @NonNull JsonPath path) { + return jsonCommands().jsonClear(key, path); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default Long jsonDel(byte @NonNull [] key, @NonNull JsonPath path) { + return jsonCommands().jsonDel(key, path); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default String jsonGet(byte @NonNull [] key, @NonNull JsonPath @NonNull... paths) { + return jsonCommands().jsonGet(key, paths); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default Boolean jsonMerge(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value) { + return jsonCommands().jsonMerge(key, path, value); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonMGet(@NonNull JsonPath path, byte @NonNull []... keys) { + return jsonCommands().jsonMGet(path, keys); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default Boolean jsonSet(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value, @NonNull JsonSetCondition condition) { + return jsonCommands().jsonSet(key, path, value, condition); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonStrAppend(byte @NonNull [] key, @NonNull JsonPath path, @NonNull String value) { + return jsonCommands().jsonStrAppend(key, path, value); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonStrLen(byte @NonNull [] key, @NonNull JsonPath path) { + return jsonCommands().jsonStrLen(key, path); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonToggle(byte @NonNull [] key, @NonNull JsonPath path) { + return jsonCommands().jsonToggle(key, path); + } + + /** @deprecated in favor of {@link RedisConnection#jsonCommands()}. */ + @Override + @Deprecated + default List jsonType(byte @NonNull [] key, @NonNull JsonPath path) { + return jsonCommands().jsonType(key, path); + } + } diff --git a/src/main/java/org/springframework/data/redis/connection/JsonSetCondition.java b/src/main/java/org/springframework/data/redis/connection/JsonSetCondition.java new file mode 100644 index 0000000000..c1b5e090c8 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/JsonSetCondition.java @@ -0,0 +1,94 @@ +/* + * Copyright 2026-present 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.connection; + +/** + * Condition for {@code JSON.SET} command. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +public class JsonSetCondition { + + private static final JsonSetCondition UPSERT = new JsonSetCondition(PathCondition.UPSERT); + private static final JsonSetCondition IF_PATH_NOT_EXISTS = new JsonSetCondition(PathCondition.IF_PATH_NOT_EXISTS); + private static final JsonSetCondition IF_PATH_EXISTS = new JsonSetCondition(PathCondition.IF_PATH_EXISTS); + + private final PathCondition pathCondition; + + private JsonSetCondition(PathCondition pathCondition) { + this.pathCondition = pathCondition; + } + + /** + * Do not set any additional command argument. + * + * @return {@link JsonSetCondition#UPSERT} + */ + public static JsonSetCondition upsert() { + return UPSERT; + } + + /** + * Perform the {@code JSON.SET} operation only if the path does not exist. + * + * @return {@link JsonSetCondition#IF_PATH_NOT_EXISTS} + */ + public static JsonSetCondition ifPathNotExists() { + return IF_PATH_NOT_EXISTS; + } + + /** + * Perform the {@code JSON.SET} operation only if the path exists. + * + * @return {@link JsonSetCondition#IF_PATH_EXISTS} + */ + public static JsonSetCondition ifPathExists() { + return IF_PATH_EXISTS; + } + + /** + * Get the path condition. + * + * @return the path condition. + */ + public PathCondition getPathCondition() { + return pathCondition; + } + + /** + * Condition for {@code JSON.SET} command path presence. + */ + public enum PathCondition { + + /** + * Do not set any additional command argument. + */ + UPSERT, + + /** + * {@code NX} + */ + IF_PATH_NOT_EXISTS, + + /** + * {@code XX} + */ + IF_PATH_EXISTS + + } + +} diff --git a/src/main/java/org/springframework/data/redis/connection/RedisCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisCommands.java index 33a58a31c2..0cc9b513c6 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisCommands.java @@ -28,7 +28,8 @@ @NullUnmarked public interface RedisCommands extends RedisKeyCommands, RedisStringCommands, RedisListCommands, RedisSetCommands, RedisZSetCommands, RedisHashCommands, RedisTxCommands, RedisPubSubCommands, RedisConnectionCommands, - RedisServerCommands, RedisStreamCommands, RedisScriptingCommands, RedisGeoCommands, RedisHyperLogLogCommands { + RedisServerCommands, RedisStreamCommands, RedisScriptingCommands, RedisGeoCommands, RedisHyperLogLogCommands, + RedisJsonCommands { /** * {@literal Native} or {@literal raw} execution of the given Redis command along with the given arguments. diff --git a/src/main/java/org/springframework/data/redis/connection/RedisCommandsProvider.java b/src/main/java/org/springframework/data/redis/connection/RedisCommandsProvider.java index eb217a1143..8634cc71a4 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisCommandsProvider.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisCommandsProvider.java @@ -118,4 +118,13 @@ public interface RedisCommandsProvider { * @since 2.0 */ RedisZSetCommands zSetCommands(); + + /** + * Get {@link RedisJsonCommands}. + * + * @return never {@literal null}. + * @since 4.2 + */ + RedisJsonCommands jsonCommands(); + } diff --git a/src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java new file mode 100644 index 0000000000..3de70002a4 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java @@ -0,0 +1,329 @@ +/* + * Copyright 2026-present 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.connection; + +import java.util.List; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullUnmarked; +import org.springframework.data.redis.connection.json.JsonPath; +import org.springframework.data.redis.connection.json.JsonValue; + +/** + * JSON commands supported by Redis. + *

+ * Many methods in this interface return a {@link List} rather than a single value. This is because + * JSONPath expressions can match multiple values within a document, and each match produces its own + * result. For example, given the following document stored at key {@code user:1}: + *

{@code
+ * {
+ *   "groups": [
+ *     { "name": "admins",  "roles": ["read", "write", "delete"] },
+ *     { "name": "editors", "roles": ["read", "write"] },
+ *     { "name": "viewers", "roles": ["read"] }
+ *   ]
+ * }
+ * }
+ * Calling {@code JSON.ARRINDEX user:1 $.groups[*].roles "write"} returns: + *
{@code
+ * [1, 1, -1]
+ * }
+ * The path {@code $.groups[*].roles} matches three arrays, so the result contains one entry per + * match: index {@code 1} for the first two arrays (where {@code "write"} appears at position 1) + * and {@code -1} for the third array (where {@code "write"} is not found). + * + * @author Yordan Tsintsov + * @see RedisCommands + * @since 4.2 + */ +@NullUnmarked +public interface RedisJsonCommands { + + /** + * Append the JSON rawJsonValues into the array at path after the last element in it. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @param values must not be {@literal null}. + * @return a list where each element contains the new length of the array or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.ARRAPPEND + * @since 4.2 + */ + List jsonArrAppend(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue @NonNull... values); + + /** + * Search for the first occurrence of a JSON rawJsonValue in an array. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @param value must not be {@literal null}. + * @return a list where each element contains the index of the first occurrence of the rawJsonValue, {@code -1} if not found, + * or {@literal null} if the matched rawJsonValue is not an array. Returns an empty list if the path does not match any rawJsonValue. + * @see Redis Documentation: JSON.ARRINDEX + * @since 4.2 + */ + List jsonArrIndex(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value); + + /** + * Insert the {@code rawJsonValues} into the array at {@code path} before {@code index}. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @param index to insert before. + * @param values must not be {@literal null}. + * @return a list where each element contains the new length of the array after the insertion or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.ARRINSERT + * @since 4.2 + */ + List jsonArrInsert(byte @NonNull [] key, @NonNull JsonPath path, int index, @NonNull JsonValue @NonNull... values); + + /** + * Get the length of the array at the given path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @return a list where each element contains the length of the array or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.ARRLEN + * @since 4.2 + */ + List jsonArrLen(byte @NonNull [] key, @NonNull JsonPath path); + + /** + * Trim an array so that it contains only the specified inclusive range of elements. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @param start index to start trimming from ({@code inclusive}). + * @param stop index to stop trimming at ({@code inclusive}). + * @return a list where each element contains the length of the array after the trim or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.ARRTRIM + * @since 4.2 + */ + List jsonArrTrim(byte @NonNull [] key, @NonNull JsonPath path, int start, int stop); + + /** + * Clear container values (arrays/objects) and set numeric values to 0 at the root path of the given key. + * + * @param key must not be {@literal null}. + * @return the number of paths cleared. + * @see Redis Documentation: JSON.CLEAR + * @since 4.2 + */ + default Long jsonClear(byte @NonNull [] key) { + return jsonClear(key, JsonPath.root()); + } + + /** + * Clear container values (arrays/objects) and set numeric values to 0 at the given key and path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @return the number of paths cleared. + * @see Redis Documentation: JSON.CLEAR + * @since 4.2 + */ + Long jsonClear(byte @NonNull [] key, @NonNull JsonPath path); + + /** + * Delete the JSON value at the root path of the given key. + * + * @param key must not be {@literal null}. + * @return the number of paths deleted. + * @see Redis Documentation: JSON.DEL + * @since 4.2 + */ + default Long jsonDel(byte @NonNull [] key) { + return jsonDel(key, JsonPath.root()); + } + + /** + * Delete the JSON value at the given key and path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @return the number of paths deleted. + * @see Redis Documentation: JSON.DEL + * @since 4.2 + */ + Long jsonDel(byte @NonNull [] key, @NonNull JsonPath path); + + /** + * Get the JSON value at the root path of the given key. + * + * @param key must not be {@literal null}. + * @return a JSON-serialized string. When a single path is given, returns the value at that path. + * When multiple paths are given, returns a JSON object with each path as a key. Returns {@code null} if the key does not exist. + * @see Redis Documentation: JSON.GET + * @since 4.2 + */ + default String jsonGet(byte @NonNull [] key) { + return jsonGet(key, JsonPath.root()); + } + + /** + * Get the JSON values at the given key and paths. + * + * @param key must not be {@literal null}. + * @param paths must not be {@literal null}. + * @return a JSON-serialized string. When a single path is given, returns the value at that path. + * When multiple paths are given, returns a JSON object with each path as a key. Returns {@code null} if the key does not exist. + * @see Redis Documentation: JSON.GET + * @since 4.2 + */ + String jsonGet(byte @NonNull [] key, @NonNull JsonPath @NonNull... paths); + + /** + * Merge the JSON rawJsonValue at the root path of the given {@code key}. + * + * @param key must not be {@literal null}. + * @param value must not be {@literal null}. + * @return {@literal true} if the key was merged, {@literal false} otherwise. + * @see Redis Documentation: JSON.MERGE + * @since 4.2 + */ + default Boolean jsonMerge(byte @NonNull [] key, @NonNull JsonValue value) { + return jsonMerge(key, JsonPath.root(), value); + } + + /** + * Merge the JSON rawJsonValue at the given key and path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @param value must not be {@literal null}. + * @return {@literal true} if the key was merged, {@literal false} otherwise. + * @see Redis Documentation: JSON.MERGE + * @since 4.2 + */ + Boolean jsonMerge(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value); + + /** + * Get the JSON values at the root path of the given keys. + * + * @param keys must not be {@literal null}. + * @return list of root JSON values or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.MGET + * @since 4.2 + */ + default List jsonMGet(byte @NonNull [] @NonNull... keys) { + return jsonMGet(JsonPath.root(), keys); + } + + /** + * Get the JSON values at the given path for the given keys. + * + * @param path must not be {@literal null}. + * @param keys must not be {@literal null}. + * @return list of JSON values or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.MGET + * @since 4.2 + */ + List jsonMGet(@NonNull JsonPath path, byte @NonNull [] @NonNull... keys); + + /** + * Set the JSON rawJsonValue at the root path of the given key. + * + * @param key must not be {@literal null}. + * @param value must not be {@literal null}. + * @return {@literal true} if the key was set, {@literal false} otherwise. + * @see Redis Documentation: JSON.SET + * @since 4.2 + */ + default Boolean jsonSet(byte @NonNull [] key, @NonNull JsonValue value) { + return jsonSet(key, JsonPath.root(), value, JsonSetCondition.upsert()); + } + + /** + * Set the JSON rawJsonValue at the given key and path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @param value must not be {@literal null}. + * @param condition must not be {@literal null}. + * @return {@literal true} if the key was set, {@literal false} otherwise. + * @see Redis Documentation: JSON.SET + * @since 4.2 + */ + Boolean jsonSet(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value, @NonNull JsonSetCondition condition); + + /** + * Append a string value to the JSON string at the given path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @param value must not be {@literal null}. Value must be JSON encoded. + * @return a list where each element is the new string length or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.STRAPPEND + * @since 4.2 + */ + List jsonStrAppend(byte @NonNull [] key, @NonNull JsonPath path, @NonNull String value); + + /** + * Get the length of the JSON string value at the given path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @return a list where each element is the string length or {@literal null} if path does not exist. + * @see Redis Documentation: JSON.STRLEN + * @since 4.2 + */ + List jsonStrLen(byte @NonNull [] key, @NonNull JsonPath path); + + /** + * Toggle boolean values at the given key and path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @return a list where each element is the new boolean value after toggling, or {@literal null} if the path does not exist. + * @see Redis Documentation: JSON.TOGGLE + * @since 4.2 + */ + List jsonToggle(byte @NonNull [] key, @NonNull JsonPath path); + + /** + * Get the JSON type at the root path of the given key. + * + * @param key must not be {@literal null}. + * @return a list where each element is the type at the given path. + * @see Redis Documentation: JSON.TYPE + * @since 4.2 + */ + default List jsonType(byte @NonNull [] key) { + return jsonType(key, JsonPath.root()); + } + + /** + * Get the JSON type at the given key and path. + * + * @param key must not be {@literal null}. + * @param path must not be {@literal null}. + * @return a list where each element is the type at the given path. + * @see Redis Documentation: JSON.TYPE + * @since 4.2 + */ + List jsonType(byte @NonNull [] key, @NonNull JsonPath path); + + enum JsonType { + + STRING, + NUMBER, + BOOLEAN, + OBJECT, + ARRAY + + } + +} diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterConnection.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterConnection.java index 33f6a34c59..e5b6d7519e 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterConnection.java @@ -114,6 +114,7 @@ public class JedisClusterConnection extends JedisConnection implements RedisClus private final JedisClusterStreamCommands streamCommands = new JedisClusterStreamCommands(this); private final JedisClusterStringCommands stringCommands = new JedisClusterStringCommands(this); private final JedisClusterZSetCommands zSetCommands = new JedisClusterZSetCommands(this); + private final JedisClusterJsonCommands jsonCommands = new JedisClusterJsonCommands(this); private boolean closed; @@ -383,6 +384,11 @@ public RedisZSetCommands zSetCommands() { return zSetCommands; } + @Override + public RedisJsonCommands jsonCommands() { + return jsonCommands; + } + @Override public RedisScriptingCommands scriptingCommands() { return new JedisClusterScriptingCommands(this); diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterJsonCommands.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterJsonCommands.java new file mode 100644 index 0000000000..3307240168 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterJsonCommands.java @@ -0,0 +1,32 @@ +/* + * Copyright 2026-present 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.connection.jedis; + +import org.springframework.data.redis.connection.RedisJsonCommands; + +/** + * {@link RedisJsonCommands} implementation for Jedis Cluster. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +class JedisClusterJsonCommands extends JedisJsonCommands { + + JedisClusterJsonCommands(JedisClusterConnection connection) { + super(connection); + } + +} diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnection.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnection.java index 1a6e505f46..318e05fa35 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConnection.java @@ -102,6 +102,7 @@ public class JedisConnection extends AbstractRedisConnection { private final JedisStreamCommands streamCommands = new JedisStreamCommands(this); private final JedisStringCommands stringCommands = new JedisStringCommands(this); private final JedisZSetCommands zSetCommands = new JedisZSetCommands(this); + private final JedisJsonCommands jsonCommands = new JedisJsonCommands(this); private final Log LOGGER = LogFactory.getLog(getClass()); @@ -290,6 +291,11 @@ public RedisZSetCommands zSetCommands() { return zSetCommands; } + @Override + public RedisJsonCommands jsonCommands() { + return jsonCommands; + } + @Override public RedisScriptingCommands scriptingCommands() { return scriptingCommands; diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java index 98a7bf512c..3c520139eb 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java @@ -15,6 +15,7 @@ */ package org.springframework.data.redis.connection.jedis; +import org.springframework.data.redis.connection.*; import redis.clients.jedis.GeoCoordinate; import redis.clients.jedis.HostAndPort; import redis.clients.jedis.Protocol; @@ -22,6 +23,7 @@ import redis.clients.jedis.args.FlushMode; import redis.clients.jedis.args.GeoUnit; import redis.clients.jedis.args.ListPosition; +import redis.clients.jedis.json.JsonSetParams; import redis.clients.jedis.params.GeoRadiusParam; import redis.clients.jedis.params.GeoSearchParam; import redis.clients.jedis.params.GetExParams; @@ -57,28 +59,18 @@ import org.springframework.data.geo.Metric; import org.springframework.data.geo.Metrics; import org.springframework.data.geo.Point; -import org.springframework.data.redis.connection.BitFieldSubCommands; import org.springframework.data.redis.connection.BitFieldSubCommands.BitFieldIncrBy; import org.springframework.data.redis.connection.BitFieldSubCommands.BitFieldSet; import org.springframework.data.redis.connection.BitFieldSubCommands.BitFieldSubCommand; -import org.springframework.data.redis.connection.CompareCondition; -import org.springframework.data.redis.connection.RedisGeoCommands; import org.springframework.data.redis.connection.RedisGeoCommands.DistanceUnit; import org.springframework.data.redis.connection.RedisGeoCommands.GeoLocation; import org.springframework.data.redis.connection.RedisGeoCommands.GeoRadiusCommandArgs; import org.springframework.data.redis.connection.RedisGeoCommands.GeoRadiusCommandArgs.Flag; -import org.springframework.data.redis.connection.RedisHashCommands; import org.springframework.data.redis.connection.RedisListCommands.Position; -import org.springframework.data.redis.connection.RedisNode; -import org.springframework.data.redis.connection.RedisServer; -import org.springframework.data.redis.connection.RedisServerCommands; import org.springframework.data.redis.connection.RedisStringCommands.BitOperation; import org.springframework.data.redis.connection.RedisZSetCommands.ZAddArgs; -import org.springframework.data.redis.connection.SetCondition; -import org.springframework.data.redis.connection.SortParameters; import org.springframework.data.redis.connection.SortParameters.Order; import org.springframework.data.redis.connection.SortParameters.Range; -import org.springframework.data.redis.connection.ValueEncoding; import org.springframework.data.redis.connection.convert.Converters; import org.springframework.data.redis.connection.convert.ListConverter; import org.springframework.data.redis.connection.convert.StringToRedisClientInfoConverter; @@ -876,6 +868,23 @@ public static HostAndPort toHostAndPort(RedisNode node) { return new HostAndPort(node.getRequiredHost(), node.getPortOr(Protocol.DEFAULT_PORT)); } + public static JsonSetParams toJsonSetParams(JsonSetCondition condition) { + return switch (condition.getPathCondition()) { + case UPSERT -> new JsonSetParams(); + case IF_PATH_NOT_EXISTS -> new JsonSetParams().nx(); + case IF_PATH_EXISTS -> new JsonSetParams().xx(); + }; + } + + public static RedisJsonCommands.JsonType fromJsonType(Class clazz) { + if (clazz == String.class) return RedisJsonCommands.JsonType.STRING; + if (clazz == int.class || clazz == float.class) return RedisJsonCommands.JsonType.NUMBER; + if (clazz == boolean.class) return RedisJsonCommands.JsonType.BOOLEAN; + if (clazz == Object.class) return RedisJsonCommands.JsonType.OBJECT; + if (clazz == List.class) return RedisJsonCommands.JsonType.ARRAY; + return null; + } + /** * @author Christoph Strobl * @since 1.8 diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisInvoker.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisInvoker.java index b9c19ad6c8..6102131f0c 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisInvoker.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisInvoker.java @@ -38,6 +38,7 @@ import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.redis.connection.convert.Converters; import org.springframework.util.Assert; +import redis.clients.jedis.json.commands.RedisJsonPipelineCommands; /** * Utility for functional invocation of UnifiedJedis methods. Typically used to express the method call as method @@ -1035,7 +1036,7 @@ Object doInvoke(Function callFunction, Function converter, Supplier nullDefault); } - interface ResponseCommands extends PipelineBinaryCommands, DatabasePipelineCommands, StreamPipelineBinaryCommands { + interface ResponseCommands extends PipelineBinaryCommands, DatabasePipelineCommands, StreamPipelineBinaryCommands, RedisJsonPipelineCommands { Response publish(String channel, String message); } diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisJsonCommands.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisJsonCommands.java new file mode 100644 index 0000000000..9f8f96a3c4 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisJsonCommands.java @@ -0,0 +1,207 @@ +/* + * Copyright 2026-present 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.connection.jedis; + +import java.util.List; +import java.util.stream.Stream; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullUnmarked; +import org.springframework.data.redis.connection.JsonSetCondition; +import org.springframework.data.redis.connection.json.JsonPath; +import org.springframework.data.redis.connection.json.JsonValue; +import redis.clients.jedis.UnifiedJedis; +import redis.clients.jedis.json.Path2; +import redis.clients.jedis.json.commands.RedisJsonPipelineCommands; + +import org.springframework.data.redis.connection.RedisJsonCommands; +import org.springframework.util.Assert; + +/** + * {@link RedisJsonCommands} implementation for Jedis. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +@NullUnmarked +class JedisJsonCommands implements RedisJsonCommands { + + private final JedisConnection connection; + + JedisJsonCommands(JedisConnection connection) { + this.connection = connection; + } + + @Override + public List jsonArrAppend(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue @NonNull... values) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notEmpty(values, "Values must not be empty"); + Assert.noNullElements(values, "Values must not be null"); + + String[] rawJsonValues = Stream.of(values).map(JsonValue::asString).toArray(String[]::new); + + return connection.invoke().just(UnifiedJedis::jsonArrAppend, RedisJsonPipelineCommands::jsonArrAppend, JedisConverters.toString(key), Path2.of(path.asString()), rawJsonValues); + } + + @Override + public List jsonArrIndex(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonArrIndex, RedisJsonPipelineCommands::jsonArrIndex, JedisConverters.toString(key), Path2.of(path.asString()), value.asString()); + } + + @Override + public List jsonArrInsert(byte @NonNull [] key, @NonNull JsonPath path, int index, @NonNull JsonValue @NonNull... values) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notEmpty(values, "Values must not be empty"); + Assert.noNullElements(values, "Values must not be null"); + + String[] rawJsonValues = Stream.of(values).map(JsonValue::asString).toArray(String[]::new); + + return connection.invoke().just(UnifiedJedis::jsonArrInsert, RedisJsonPipelineCommands::jsonArrInsert, JedisConverters.toString(key), Path2.of(path.asString()), index, rawJsonValues); + } + + @Override + public List jsonArrLen(byte @NonNull [] key, @NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonArrLen, RedisJsonPipelineCommands::jsonArrLen, JedisConverters.toString(key), Path2.of(path.asString())); + } + + @Override + public List jsonArrTrim(byte @NonNull [] key, @NonNull JsonPath path, int start, int stop) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonArrTrim, RedisJsonPipelineCommands::jsonArrTrim, JedisConverters.toString(key), Path2.of(path.asString()), start, stop); + } + + @Override + public Long jsonClear(byte @NonNull [] key, @NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonClear, RedisJsonPipelineCommands::jsonClear, JedisConverters.toString(key), Path2.of(path.asString())); + } + + @Override + public Long jsonDel(byte @NonNull [] key, @NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonDel, RedisJsonPipelineCommands::jsonDel, JedisConverters.toString(key), Path2.of(path.asString())); + } + + @Override + public String jsonGet(byte @NonNull [] key, @NonNull JsonPath @NonNull... paths) { + + Assert.notNull(key, "Key must not be null"); + Assert.notEmpty(paths, "Paths must not be empty"); + Assert.noNullElements(paths, "Paths must not be null"); + + Path2[] path2s = Stream.of(paths).map(path -> Path2.of(path.asString())).toArray(Path2[]::new); + + return connection.invoke().from(UnifiedJedis::jsonGet, RedisJsonPipelineCommands::jsonGet, JedisConverters.toString(key), path2s) + .get(Object::toString); + } + + @Override + public Boolean jsonMerge(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + + return connection.invoke().from(UnifiedJedis::jsonMerge, RedisJsonPipelineCommands::jsonMerge, JedisConverters.toString(key), Path2.of(path.asString()), value.asString()) + .get(JedisConverters::stringToBoolean); + } + + @Override + public List jsonMGet(@NonNull JsonPath path, byte @NonNull [] @NonNull... keys) { + + Assert.notNull(path, "Path must not be null"); + Assert.notEmpty(keys, "Keys must not be empty"); + Assert.noNullElements(keys, "Keys must not be null"); + + String[] stringKeys = Stream.of(keys).map(String::new).toArray(String[]::new); + + return connection.invoke().from(UnifiedJedis::jsonMGet, RedisJsonPipelineCommands::jsonMGet, Path2.of(path.asString()), stringKeys) + .get(jsonArrList -> jsonArrList.stream().map(arr -> arr != null ? arr.toString() : null).toList()); + } + + @Override + public Boolean jsonSet(byte @NonNull [] key, @NonNull JsonPath path, @NonNull JsonValue value, @NonNull JsonSetCondition condition) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + Assert.notNull(condition, "Condition must not be null"); + + return connection.invoke().from(UnifiedJedis::jsonSet, RedisJsonPipelineCommands::jsonSet, JedisConverters.toString(key), Path2.of(path.asString()), value.asString(), JedisConverters.toJsonSetParams(condition)) + .get(JedisConverters::stringToBoolean); + } + + @Override + public List jsonStrAppend(byte @NonNull [] key, @NonNull JsonPath path, @NonNull String value) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonStrAppend, RedisJsonPipelineCommands::jsonStrAppend, JedisConverters.toString(key), Path2.of(path.asString()), value); + } + + @Override + public List jsonStrLen(byte @NonNull [] key, @NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonStrLen, RedisJsonPipelineCommands::jsonStrLen, JedisConverters.toString(key), Path2.of(path.asString())); + } + + @Override + public List jsonToggle(byte @NonNull [] key, @NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(UnifiedJedis::jsonToggle, RedisJsonPipelineCommands::jsonToggle, JedisConverters.toString(key), Path2.of(path.asString())); + } + + @Override + public List jsonType(byte @NonNull [] key, @NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().from(UnifiedJedis::jsonType, RedisJsonPipelineCommands::jsonType, JedisConverters.toString(key), Path2.of(path.asString())) + .get(types -> types.stream().map(type -> type != null ? JedisConverters.fromJsonType(type) : null).toList()); + } + +} diff --git a/src/main/java/org/springframework/data/redis/connection/json/DefaultJsonPath.java b/src/main/java/org/springframework/data/redis/connection/json/DefaultJsonPath.java new file mode 100644 index 0000000000..0058ea513f --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/json/DefaultJsonPath.java @@ -0,0 +1,39 @@ +/* + * Copyright 2026-present 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.connection.json; + +import org.springframework.util.Assert; + +/** + * Default implementation of {@link JsonPath}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +record DefaultJsonPath(String path) implements JsonPath { + + static final DefaultJsonPath ROOT = new DefaultJsonPath("$"); + + DefaultJsonPath { + Assert.notNull(path, "Path must not be null"); + } + + @Override + public String asString() { + return path; + } + +} diff --git a/src/main/java/org/springframework/data/redis/connection/json/DefaultJsonValue.java b/src/main/java/org/springframework/data/redis/connection/json/DefaultJsonValue.java new file mode 100644 index 0000000000..27366f9ed8 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/json/DefaultJsonValue.java @@ -0,0 +1,64 @@ +/* + * Copyright 2026-present 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.connection.json; + +import org.springframework.util.Assert; + +/** + * Default implementation of {@link JsonValue}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +record DefaultJsonValue(String value) implements JsonValue { + + static final DefaultJsonValue NULL = new DefaultJsonValue("null"); + + DefaultJsonValue { + Assert.notNull(value, "Value must not be null"); + } + + static String quote(String value) { + Assert.notNull(value, "Value must not be null"); + + StringBuilder sb = new StringBuilder(value.length() + 2); + sb.append('"'); + + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + switch (c) { + case '"' -> sb.append("\\\""); + case '\\' -> sb.append("\\\\"); + default -> { + if (c < 0x20) { + sb.append(String.format("\\u%04x", (int) c)); + } else { + sb.append(c); + } + } + } + } + + sb.append('"'); + return sb.toString(); + } + + @Override + public String asString() { + return value; + } + +} diff --git a/src/main/java/org/springframework/data/redis/connection/json/JsonPath.java b/src/main/java/org/springframework/data/redis/connection/json/JsonPath.java new file mode 100644 index 0000000000..aae5e62393 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/json/JsonPath.java @@ -0,0 +1,52 @@ +/* + * Copyright 2026-present 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.connection.json; + +/** + * JSON path abstraction for JSON commands. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +public interface JsonPath { + + /** + * Root JSON path. + * + * @return {@link JsonPath} representing root JSON path. + */ + static JsonPath root() { + return DefaultJsonPath.ROOT; + } + + /** + * JSON path from a {@code String}. + * + * @param path the JSON path. Must not be {@literal null}. + * @return {@link JsonPath} representing JSON path. + */ + static JsonPath of(String path) { + return new DefaultJsonPath(path); + } + + /** + * Returns the canonical JSON path text of this value. + * + * @return the canonical JSON path text of this value. + */ + String asString(); + +} diff --git a/src/main/java/org/springframework/data/redis/connection/json/JsonValue.java b/src/main/java/org/springframework/data/redis/connection/json/JsonValue.java new file mode 100644 index 0000000000..1b28da9cb3 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/json/JsonValue.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026-present 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.connection.json; + +/** + * Value abstraction for JSON payloads passed to {@code RedisJsonCommands}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +public interface JsonValue { + + /** + * JSON {@code null} value. + * + * @return {@link JsonValue} representing JSON {@code null}. + */ + static JsonValue nullValue() { + return DefaultJsonValue.NULL; + } + + /** + * JSON boolean from a {@code boolean}. + * + * @param value the boolean value. + * @return {@link JsonValue} representing JSON boolean. + */ + static JsonValue of(boolean value) { + return new DefaultJsonValue(Boolean.toString(value)); + } + + /** + * JSON number from a {@link Number}. + * + * @param number must not be {@literal null}. + * @return {@link JsonValue} representing JSON number. + */ + static JsonValue of(Number number) { + return new DefaultJsonValue(number.toString()); + } + + /** + * JSON number from an {@code int}. + * + * @param number the value. + * @return {@link JsonValue} representing JSON number. + */ + static JsonValue of(int number) { + return new DefaultJsonValue(Integer.toString(number)); + } + + /** + * JSON number from a {@code long}. + * + * @param number the value. + * @return {@link JsonValue} representing JSON number. + */ + static JsonValue of(long number) { + return new DefaultJsonValue(Long.toString(number)); + } + + /** + * JSON number from a {@code double}. + * + * @param number the value; must be finite. + * @return {@link JsonValue} representing JSON number. + */ + static JsonValue of(double number) { + return new DefaultJsonValue(Double.toString(number)); + } + + /** + * JSON string from a Java {@link String}. + * + * @param value must not be {@literal null}. + * @return {@link JsonValue} representing JSON string. + */ + static JsonValue of(String value) { + return new DefaultJsonValue(DefaultJsonValue.quote(value)); + } + + /** + * JSON value from a JSON document. The supplied text is forwarded unchanged; no validation or escaping is + * performed. + * + * @param json a valid JSON document. Must not be {@literal null}. + * @return a {@link JsonValue} carrying the JSON. + */ + static JsonValue raw(String json) { + return new DefaultJsonValue(json); + } + + /** + * @return the canonical JSON text of this value. + */ + String asString(); + +} diff --git a/src/main/java/org/springframework/data/redis/connection/json/package-info.java b/src/main/java/org/springframework/data/redis/connection/json/package-info.java new file mode 100644 index 0000000000..93cc9dba57 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/json/package-info.java @@ -0,0 +1,5 @@ +/** + * Data structures and interfaces to interact with Redis JSON. + */ +@org.jspecify.annotations.NullMarked +package org.springframework.data.redis.connection.json; diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnection.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnection.java index 37ddaa166d..54b7ec4a6c 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterConnection.java @@ -88,6 +88,7 @@ public class LettuceClusterConnection extends LettuceConnection private final LettuceClusterStringCommands stringCommands = new LettuceClusterStringCommands(this); private final LettuceClusterSetCommands setCommands = new LettuceClusterSetCommands(this); private final LettuceClusterZSetCommands zSetCommands = new LettuceClusterZSetCommands(this); + private final LettuceClusterJsonCommands jsonCommands = new LettuceClusterJsonCommands(this); private final LettuceClusterServerCommands serverCommands = new LettuceClusterServerCommands(this); /** @@ -269,6 +270,11 @@ public RedisZSetCommands zSetCommands() { return zSetCommands; } + @Override + public RedisJsonCommands jsonCommands() { + return jsonCommands; + } + @Override public String ping() { diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterJsonCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterJsonCommands.java new file mode 100644 index 0000000000..f7219da5ff --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceClusterJsonCommands.java @@ -0,0 +1,28 @@ +/* + * Copyright 2026-present 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.connection.lettuce; + +/** + * @author Yordan Tsintsov + * @since 4.2 + */ +class LettuceClusterJsonCommands extends LettuceJsonCommands { + + LettuceClusterJsonCommands(LettuceClusterConnection connection) { + super(connection); + } + +} diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnection.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnection.java index a9879daa9a..62524f0543 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConnection.java @@ -155,6 +155,7 @@ public LettuceTransactionResultConverter(Queue> txResults, private final LettuceStreamCommands streamCommands = new LettuceStreamCommands(this); private final LettuceStringCommands stringCommands = new LettuceStringCommands(this); private final LettuceZSetCommands zSetCommands = new LettuceZSetCommands(this); + private final LettuceJsonCommands jsonCommands = new LettuceJsonCommands(this); private @Nullable List> ppline; @@ -310,6 +311,11 @@ public RedisZSetCommands zSetCommands() { return this.zSetCommands; } + @Override + public RedisJsonCommands jsonCommands() { + return this.jsonCommands; + } + protected DataAccessException convertLettuceAccessException(Exception cause) { return EXCEPTION_TRANSLATION.translate(cause); } diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java index c64efe4047..437456cf22 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceConverters.java @@ -22,6 +22,11 @@ import io.lettuce.core.CompareCondition; import io.lettuce.core.cluster.models.partitions.Partitions; import io.lettuce.core.cluster.models.partitions.RedisClusterNode.NodeFlag; +import io.lettuce.core.json.DefaultJsonParser; +import io.lettuce.core.json.JsonParser; +import io.lettuce.core.json.JsonType; +import io.lettuce.core.json.JsonValue; +import io.lettuce.core.json.arguments.JsonSetArgs; import java.nio.charset.StandardCharsets; import java.time.Duration; @@ -77,7 +82,7 @@ import org.springframework.util.StringUtils; /** - * Lettuce type converters. This is an internal class not intended to by used outside of the framework. + * Lettuce type converters. This is an internal class not intended to be used outside the framework. * * @author Jennifer Hickey * @author Christoph Strobl @@ -102,6 +107,8 @@ public abstract class LettuceConverters extends Converters { private static final long INDEXED_RANGE_START = 0; private static final long INDEXED_RANGE_END = -1; + private static final JsonParser JSON_PARSER = new DefaultJsonParser(); + static { PLUS_BYTES = toBytes("+"); MINUS_BYTES = toBytes("-"); @@ -1039,6 +1046,29 @@ static FlushMode toFlushMode(RedisServerCommands.@Nullable FlushOption option) { }; } + static JsonValue toJsonValue(String value) { + return JSON_PARSER.fromObject(value); + } + + static JsonSetArgs toJsonSetArgs(JsonSetCondition condition) { + return switch (condition.getPathCondition()) { + case UPSERT -> new JsonSetArgs(); + case IF_PATH_NOT_EXISTS -> new JsonSetArgs().nx(); + case IF_PATH_EXISTS -> new JsonSetArgs().xx(); + }; + } + + static RedisJsonCommands.JsonType fromJsonType(JsonType type) { + return switch (type) { + case STRING -> RedisJsonCommands.JsonType.STRING; + case INTEGER, NUMBER -> RedisJsonCommands.JsonType.NUMBER; + case BOOLEAN -> RedisJsonCommands.JsonType.BOOLEAN; + case OBJECT -> RedisJsonCommands.JsonType.OBJECT; + case ARRAY -> RedisJsonCommands.JsonType.ARRAY; + case UNKNOWN -> null; + }; + } + /** * @author Christoph Strobl * @since 1.8 diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java new file mode 100644 index 0000000000..1d1e11dd5d --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java @@ -0,0 +1,206 @@ +/* + * Copyright 2026-present 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.connection.lettuce; + +import java.util.List; +import java.util.stream.Stream; + +import io.lettuce.core.api.async.RedisJsonAsyncCommands; +import io.lettuce.core.json.JsonPath; +import io.lettuce.core.json.arguments.JsonRangeArgs; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullUnmarked; + +import org.springframework.data.redis.connection.JsonSetCondition; +import org.springframework.data.redis.connection.RedisJsonCommands; +import org.springframework.data.redis.connection.json.JsonValue; +import org.springframework.util.Assert; + +/** + * {@link RedisJsonCommands} implementation for Lettuce. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +@NullUnmarked +class LettuceJsonCommands implements RedisJsonCommands { + + private final LettuceConnection connection; + + LettuceJsonCommands(LettuceConnection connection) { + this.connection = connection; + } + + @Override + public List jsonArrAppend(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path, @NonNull JsonValue @NonNull... values) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notEmpty(values, "Values must not be empty"); + Assert.noNullElements(values, "Values must not be null"); + + String[] rawJsonValues = Stream.of(values).map(JsonValue::asString).toArray(String[]::new); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonArrappend, key, JsonPath.of(path.asString()), rawJsonValues); + } + + @Override + public List jsonArrIndex(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path, @NonNull JsonValue value) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonArrindex, key, JsonPath.of(path.asString()), value.asString()); + } + + @Override + public List jsonArrInsert(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path, int index, @NonNull JsonValue @NonNull... values) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notEmpty(values, "Values must not be empty"); + Assert.noNullElements(values, "Values must not be null"); + + String[] rawJsonValues = Stream.of(values).map(JsonValue::asString).toArray(String[]::new); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonArrinsert, key, JsonPath.of(path.asString()), index, rawJsonValues); + } + + @Override + public List jsonArrLen(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonArrlen, key, JsonPath.of(path.asString())); + } + + @Override + public List jsonArrTrim(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path, int start, int stop) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + JsonRangeArgs args = JsonRangeArgs.Builder.start(start).stop(stop); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonArrtrim, key, JsonPath.of(path.asString()), args); + } + + @Override + public Long jsonClear(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonClear, key, JsonPath.of(path.asString())); + } + + @Override + public Long jsonDel(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonDel, key, JsonPath.of(path.asString())); + } + + @Override + public String jsonGet(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath @NonNull... paths) { + + Assert.notNull(key, "Key must not be null"); + Assert.notEmpty(paths, "Paths must not be empty"); + Assert.noNullElements(paths, "Paths must not be null"); + + JsonPath[] jsonPaths = Stream.of(paths).map(path -> JsonPath.of(path.asString())).toArray(JsonPath[]::new); + + return connection.invoke().from(RedisJsonAsyncCommands::jsonGetRaw, key, jsonPaths) + .get(it -> it.get(0) != null ? it.get(0) : null); + } + + @Override + public Boolean jsonMerge(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path, @NonNull JsonValue value) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + + return connection.invoke().from(RedisJsonAsyncCommands::jsonMerge, key, JsonPath.of(path.asString()), value.asString()) + .get(LettuceConverters::stringToBoolean); + } + + @Override + public List jsonMGet(org.springframework.data.redis.connection.json.@NonNull JsonPath path, byte @NonNull [] @NonNull... keys) { + + Assert.notNull(path, "Path must not be null"); + Assert.notEmpty(keys, "Keys must not be empty"); + Assert.noNullElements(keys, "Keys must not be null"); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonMGetRaw, JsonPath.of(path.asString()), keys); + } + + @Override + public Boolean jsonSet(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path, @NonNull JsonValue value, @NonNull JsonSetCondition condition) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + Assert.notNull(condition, "Option must not be null"); + + return connection.invoke().from(RedisJsonAsyncCommands::jsonSet, key, JsonPath.of(path.asString()), value.asString(), LettuceConverters.toJsonSetArgs(condition)) + .get(LettuceConverters::stringToBoolean); + } + + @Override + public List jsonStrAppend(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path, @NonNull String value) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + Assert.notNull(value, "Value must not be null"); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonStrappend, key, JsonPath.of(path.asString()), "\"" + value + "\""); + } + + @Override + public List jsonStrLen(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().just(RedisJsonAsyncCommands::jsonStrlen, key, JsonPath.of(path.asString())); + } + + @Override + public List jsonToggle(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().from(RedisJsonAsyncCommands::jsonToggle, key, JsonPath.of(path.asString())) + .get(values -> values.stream().map(value -> value != null ? LettuceConverters.toBoolean(value) : null).toList()); + } + + @Override + public List jsonType(byte @NonNull [] key, org.springframework.data.redis.connection.json.@NonNull JsonPath path) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(path, "Path must not be null"); + + return connection.invoke().from(RedisJsonAsyncCommands::jsonType, key, JsonPath.of(path.asString())) + .get(types -> types.stream().map(type -> type != null ? LettuceConverters.fromJsonType(type) : null).toList()); + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/RedisCommand.java b/src/main/java/org/springframework/data/redis/core/RedisCommand.java index 2b1f2b8c97..95e828dc1e 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisCommand.java +++ b/src/main/java/org/springframework/data/redis/core/RedisCommand.java @@ -168,6 +168,23 @@ public enum RedisCommand { INCRBYFLOAT("rw", 2, 2), // INFO("r", 0), // + // -- J + JSON_ARRAPPEND("w", 2), // + JSON_ARRINDEX("r", 3, 5), // + JSON_ARRINSERT("w", 4), // + JSON_ARRLEN("r", 1, 2), // + JSON_ARRTRIM("w", 4, 4), // + JSON_CLEAR("w", 1, 2), // + JSON_DEL("w", 1, 2), // + JSON_GET("r", 1), // + JSON_MERGE("w", 3, 3), // + JSON_MGET("r", 2), // + JSON_SET("w", 3, 4), // + JSON_STRAPPEND("w", 2, 3), // + JSON_STRLEN("r", 1, 2), // + JSON_TOGGLE("w", 2, 2), // + JSON_TYPE("r", 1, 2), // + // -- K KEYS("r", 1), // diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java index dd06bdb42a..2e04cb477e 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java @@ -73,6 +73,8 @@ import org.springframework.data.redis.connection.SortParameters.Order; import org.springframework.data.redis.connection.StringRedisConnection.StringTuple; import org.springframework.data.redis.connection.ValueEncoding.RedisValueEncoding; +import org.springframework.data.redis.connection.json.JsonPath; +import org.springframework.data.redis.connection.json.JsonValue; import org.springframework.data.redis.connection.stream.Consumer; import org.springframework.data.redis.connection.stream.MapRecord; import org.springframework.data.redis.connection.stream.PendingMessages; @@ -5177,6 +5179,257 @@ void zRangeStoreRevByLexStoresKeys() { assertThat((LinkedHashSet) result.get(5)).containsSequence(VALUE_3, VALUE_4); } + @Test // TODO + @EnabledOnCommand("JSON.ARRAPPEND") + void jsonArrAppend() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":[]}"))); + actual.add(connection.jsonCommands().jsonArrAppend(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"), + JsonValue.of(1), JsonValue.of(2), JsonValue.of(3))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(3L); + } + + @Test // TODO + @EnabledOnCommand("JSON.ARRINDEX") + void jsonArrIndex() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("[1,2,3]"))); + actual.add(connection.jsonCommands().jsonArrIndex(jsonKey, JsonPath.root(), JsonValue.of(2))); + actual.add(connection.jsonCommands().jsonArrIndex(jsonKey, JsonPath.root(), JsonValue.of(4))); + actual.add(connection.jsonCommands().jsonArrIndex(jsonKey, JsonPath.of(JsonPath.root().asString() + ".NOT_EXIST"), JsonValue.of(1))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(1L); + assertThat((List) result.get(2)).containsExactly(-1L); + assertThat((List) result.get(3)).isEmpty(); + } + + @Test // TODO + @EnabledOnCommand("JSON.ARRINSERT") + void jsonArrInsert() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("[1,2,3]"))); + actual.add(connection.jsonCommands().jsonArrInsert(jsonKey, JsonPath.root(), 1, JsonValue.of(4), JsonValue.of(5))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(5L); + } + + @Test // TODO + @EnabledOnCommand("JSON.ARRLEN") + void jsonArrLen() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("[1,2,3]"))); + actual.add(connection.jsonCommands().jsonArrLen(jsonKey, JsonPath.root())); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(3L); + } + + @Test // TODO + @EnabledOnCommand("JSON.ARRTRIM") + void jsonArrTrim() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("[1,2,3,4,5]"))); + actual.add(connection.jsonCommands().jsonArrTrim(jsonKey, JsonPath.root(), 1, 3)); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(3L); + } + + @Test // TODO + @EnabledOnCommand("JSON.CLEAR") + void jsonClear() { + + byte[] jsonKey1 = KEY_1.getBytes(); + byte[] jsonKey2 = KEY_2.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey1, JsonValue.raw("[1,2,3]"))); + actual.add(connection.jsonCommands().jsonClear(jsonKey1)); + actual.add(connection.jsonCommands().jsonSet(jsonKey2, JsonValue.raw("{\"a\":1}"))); + actual.add(connection.jsonCommands().jsonClear(jsonKey2, JsonPath.root())); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat(result.get(1)).isEqualTo(1L); + assertThat(result.get(2)).isEqualTo(true); + assertThat(result.get(3)).isEqualTo(1L); + } + + @Test // TODO + @EnabledOnCommand("JSON.DEL") + void jsonDel() { + + byte[] jsonKey1 = KEY_1.getBytes(); + byte[] jsonKey2 = KEY_2.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey1, JsonValue.raw("[1,2,3]"))); + actual.add(connection.jsonCommands().jsonDel(jsonKey1)); + actual.add(connection.jsonCommands().jsonSet(jsonKey2, JsonValue.raw("{\"a\":1}"))); + actual.add(connection.jsonCommands().jsonDel(jsonKey2, JsonPath.of(JsonPath.root().asString() + ".a"))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat(result.get(1)).isEqualTo(1L); + assertThat(result.get(2)).isEqualTo(true); + assertThat(result.get(3)).isEqualTo(1L); + } + + @Test // TODO + @EnabledOnCommand("JSON.GET") + void jsonGet() { + + byte[] jsonKey = KEY_1.getBytes(); + JsonValue json = JsonValue.raw("{\"a\":1}"); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, json)); + actual.add(connection.jsonCommands().jsonGet(jsonKey)); + actual.add(connection.jsonCommands().jsonGet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); + actual.add(connection.jsonCommands().jsonGet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".b"))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat(result.get(1)).isEqualTo("[" + json.asString() + "]"); + assertThat(result.get(2)).isEqualTo("[1]"); + assertThat(result.get(3)).isEqualTo(("[]")); + } + + @Test // TODO + @EnabledOnCommand("JSON.MERGE") + void jsonMerge() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":1}"))); + actual.add(connection.jsonCommands().jsonMerge(jsonKey, JsonValue.raw("{\"b\":2}"))); + actual.add(connection.jsonCommands().jsonGet(jsonKey)); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat(result.get(1)).isEqualTo(true); + assertThat(result.get(2)).isEqualTo("[{\"a\":1,\"b\":2}]"); + } + + @Test // TODO + @EnabledOnCommand("JSON.MGET") + void jsonMGet() { + + byte[] jsonKey1 = KEY_1.getBytes(); + byte[] jsonKey2 = KEY_2.getBytes(); + JsonValue json = JsonValue.raw("{\"a\":1}"); + String rawResponse = "[" + json.asString() + "]"; + + actual.add(connection.jsonCommands().jsonSet(jsonKey1, json)); + actual.add(connection.jsonCommands().jsonSet(jsonKey2, json)); + actual.add(connection.jsonCommands().jsonMGet(jsonKey1, jsonKey2)); + actual.add(connection.jsonCommands().jsonMGet(JsonPath.of(JsonPath.root().asString() + ".a"), jsonKey1, jsonKey2)); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat(result.get(1)).isEqualTo(true); + assertThat((List) result.get(2)).containsSequence(rawResponse, rawResponse); + assertThat((List) result.get(3)).containsSequence("[1]", "[1]"); + } + + @Test // TODO + @EnabledOnCommand("JSON.SET") + void jsonSet() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":1}"))); + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"), JsonValue.of(2), JsonSetCondition.ifPathExists())); + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".b"), JsonValue.raw("{\"b\":3}"), JsonSetCondition.ifPathNotExists())); + actual.add(connection.jsonCommands().jsonGet(jsonKey)); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat(result.get(1)).isEqualTo(true); + assertThat(result.get(2)).isEqualTo(true); + assertThat(result.get(3)).isEqualTo("[{\"a\":2,\"b\":{\"b\":3}}]"); + } + + @Test // TODO + @EnabledOnCommand("JSON.STRAPPEND") + void jsonStrAppend() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":\"foo\"}"))); + actual.add(connection.jsonCommands().jsonStrAppend(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"), "bar")); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(6L); + } + + @Test // TODO + @EnabledOnCommand("JSON.STRLEN") + void jsonStrLen() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":\"foo\"}"))); + actual.add(connection.jsonCommands().jsonStrLen(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(3L); + } + + @Test // TODO + @EnabledOnCommand("JSON.TOGGLE") + void jsonToggle() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":true}"))); + actual.add(connection.jsonCommands().jsonToggle(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(false); + } + + @Test // TODO + @EnabledOnCommand("JSON.TYPE") + void jsonType() { + + byte[] jsonKey = KEY_1.getBytes(); + + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":\"foo\",\"b\":42,\"c\":true,\"d\":null}"))); + actual.add(connection.jsonCommands().jsonType(jsonKey)); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".b"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".c"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".d"))); + + List result = getResults(); + assertThat(result.get(0)).isEqualTo(true); + assertThat((List) result.get(1)).containsExactly(RedisJsonCommands.JsonType.OBJECT); + assertThat((List) result.get(2)).containsExactly(RedisJsonCommands.JsonType.STRING); + assertThat((List) result.get(3)).containsExactly(RedisJsonCommands.JsonType.NUMBER); + assertThat((List) result.get(4)).containsExactly(RedisJsonCommands.JsonType.BOOLEAN); + assertThat((List) result.get(5)).containsExactly((RedisJsonCommands.JsonType) null); + } + protected void verifyResults(List expected) { assertThat(getResults()).isEqualTo(expected); } diff --git a/src/test/java/org/springframework/data/redis/connection/RedisConnectionUnitTests.java b/src/test/java/org/springframework/data/redis/connection/RedisConnectionUnitTests.java index 13adc2151f..50f406066a 100644 --- a/src/test/java/org/springframework/data/redis/connection/RedisConnectionUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/RedisConnectionUnitTests.java @@ -162,6 +162,11 @@ public RedisZSetCommands zSetCommands() { return null; } + @Override + public RedisJsonCommands jsonCommands() { + return null; + } + @Override protected boolean isActive(RedisNode node) { return ObjectUtils.nullSafeEquals(activeNode, node); diff --git a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConvertersUnitTests.java b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConvertersUnitTests.java index e4a33b8620..478c70ffae 100644 --- a/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConvertersUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/lettuce/LettuceConvertersUnitTests.java @@ -51,6 +51,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.springframework.data.redis.connection.JsonSetCondition; import org.springframework.data.redis.connection.RedisClusterNode; import org.springframework.data.redis.connection.RedisClusterNode.Flag; import org.springframework.data.redis.connection.RedisClusterNode.LinkState; @@ -654,6 +655,19 @@ void considersSetCondition() { }); } + @Test // TODO + void convertToJsonSetArgs() { + + verifyArguments(LettuceConverters.toJsonSetArgs(JsonSetCondition.upsert()), + (arguments) -> assertThat(arguments).isEmpty()); + + verifyArguments(LettuceConverters.toJsonSetArgs(JsonSetCondition.ifPathNotExists()), + (arguments) -> assertThat(arguments).containsOnly("NX")); + + verifyArguments(LettuceConverters.toJsonSetArgs(JsonSetCondition.ifPathExists()), + (arguments) -> assertThat(arguments).containsOnly("XX")); + } + static void verifyArguments(CompositeArgument argument, Consumer> assertion) { verifyArguments(argument, (arguments, commandString) -> assertion.accept(arguments)); } diff --git a/src/test/resources/docker-env/config/redis-master/master.conf b/src/test/resources/docker-env/config/redis-master/master.conf index 90dcd7bf70..771919f46f 100644 --- a/src/test/resources/docker-env/config/redis-master/master.conf +++ b/src/test/resources/docker-env/config/redis-master/master.conf @@ -7,3 +7,4 @@ unixsocket /sock/redis-6379.sock unixsocketperm 755 replica-announce-ip 127.0.0.1 replica-announce-port 6379 +loadmodule /usr/local/lib/redis/modules/rejson.so \ No newline at end of file From f71450e635503942ebe9b745984197b75521eecf Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Mon, 29 Jun 2026 17:05:10 +0300 Subject: [PATCH 2/8] First iteration of the template Signed-off-by: Yordan Tsintsov --- .../data/redis/aot/RedisRuntimeHints.java | 1 + .../data/redis/connection/DataType.java | 6 +- .../redis/connection/RedisJsonCommands.java | 3 + .../lettuce/LettuceJsonCommands.java | 2 +- .../redis/core/DefaultJsonOperations.java | 502 ++++++++++++++++ .../data/redis/core/JsonOperations.java | 561 ++++++++++++++++++ .../data/redis/core/RedisJsonTemplate.java | 162 +++++ .../JacksonRedisJsonSerializer.java | 76 +++ .../redis/serializer/RedisJsonSerializer.java | 65 ++ ...DefaultJsonOperationsIntegrationTests.java | 330 +++++++++++ .../core/DefaultJsonResultUnitTests.java | 75 +++ .../core/DefaultJsonResultsUnitTests.java | 91 +++ .../JacksonRedisJsonSerializerUnitTests.java | 111 ++++ 13 files changed, 1983 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java create mode 100644 src/main/java/org/springframework/data/redis/core/JsonOperations.java create mode 100644 src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java create mode 100644 src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java create mode 100644 src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java create mode 100644 src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java create mode 100644 src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java create mode 100644 src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java create mode 100644 src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java diff --git a/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java b/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java index 7935c23c6a..34cac4f71a 100644 --- a/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java +++ b/src/main/java/org/springframework/data/redis/aot/RedisRuntimeHints.java @@ -133,6 +133,7 @@ public void registerHints(RuntimeHints hints, @Nullable ClassLoader classLoader) TypeReference.of("org.springframework.data.redis.core.DefaultStreamOperations"), TypeReference.of("org.springframework.data.redis.core.DefaultValueOperations"), TypeReference.of("org.springframework.data.redis.core.DefaultZSetOperations"), + TypeReference.of("org.springframework.data.redis.core.DefaultJsonOperations"), TypeReference.of(RedisKeyValueAdapter.class), TypeReference.of(RedisKeyValueTemplate.class), diff --git a/src/main/java/org/springframework/data/redis/connection/DataType.java b/src/main/java/org/springframework/data/redis/connection/DataType.java index 756d927d17..afd7f393cc 100644 --- a/src/main/java/org/springframework/data/redis/connection/DataType.java +++ b/src/main/java/org/springframework/data/redis/connection/DataType.java @@ -32,7 +32,11 @@ public enum DataType { /** * @since 2.2 */ - STREAM("stream"); + STREAM("stream"), + /** + * @since 4.2 + */ + JSON("json"); private static final Map codeLookup = new ConcurrentHashMap<>(7); diff --git a/src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java index 3de70002a4..ab13a8bbcd 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisJsonCommands.java @@ -316,6 +316,9 @@ default List jsonType(byte @NonNull [] key) { */ List jsonType(byte @NonNull [] key, @NonNull JsonPath path); + /** + * Enum representation of the JSON types provided by Redis JSON API. + */ enum JsonType { STRING, diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java index 1d1e11dd5d..663fab3cf1 100644 --- a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceJsonCommands.java @@ -171,7 +171,7 @@ public List jsonStrAppend(byte @NonNull [] key, org.springframework.data.r Assert.notNull(path, "Path must not be null"); Assert.notNull(value, "Value must not be null"); - return connection.invoke().just(RedisJsonAsyncCommands::jsonStrappend, key, JsonPath.of(path.asString()), "\"" + value + "\""); + return connection.invoke().just(RedisJsonAsyncCommands::jsonStrappend, key, JsonPath.of(path.asString()), JsonValue.of(value).asString()); } @Override diff --git a/src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java new file mode 100644 index 0000000000..ad1e231867 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java @@ -0,0 +1,502 @@ +/* + * Copyright 2026-present 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.core; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.connection.JsonSetCondition; +import org.springframework.data.redis.connection.RedisJsonCommands; +import org.springframework.data.redis.connection.json.JsonPath; +import org.springframework.data.redis.connection.json.JsonValue; +import org.springframework.data.redis.serializer.RedisJsonSerializer; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.util.Assert; + +/** + * Default implementation of {@link JsonOperations}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +class DefaultJsonOperations implements JsonOperations { + + private final RedisJsonTemplate template; + + DefaultJsonOperations(RedisJsonTemplate template) { + this.template = template; + } + + @Override + public JsonArraySpec array(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonArraySpec(template, jsonSerializer(), rawKey); + } + + @Override + public JsonBooleanSpec bool(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonBooleanSpec(template, jsonSerializer(), rawKey); + } + + @Override + public JsonStringSpec string(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonStringSpec(template, jsonSerializer(), rawKey); + } + + @Override + public JsonAtKeySpec key(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonAtKeySpec(template, jsonSerializer(), rawKey); + } + + @Override + public JsonResult paths(K key, String... paths) { + + Assert.notEmpty(paths, "Paths must not be empty"); + + byte[] rawKey = rawKey(key); + RedisJsonSerializer serializer = jsonSerializer(); + + JsonPath[] jsonPaths = new JsonPath[paths.length]; + for (int i = 0; i < paths.length; i++) { + jsonPaths[i] = JsonPath.of(paths[i]); + } + + String response = template.execute(c -> c.jsonCommands().jsonGet(rawKey, jsonPaths)); + + return new DefaultJsonResult(serializer, response); + } + + @Override + public JsonAtKeysSpec values(Collection keys) { + + Assert.notEmpty(keys, "Keys must not be empty"); + + byte[][] rawKeys = rawKeys(keys); + + return new DefaultJsonMultiGetSpec(template, jsonSerializer(), rawKeys); + } + + private RedisJsonSerializer jsonSerializer() { + RedisJsonSerializer serializer = template.getJsonSerializer(); + Assert.notNull(serializer, "JsonSerializer is not configured"); + return serializer; + } + + private @Nullable RedisSerializer keySerializer() { + return template.getKeySerializer(); + } + + private RedisSerializer requiredKeySerializer() { + + RedisSerializer serializer = keySerializer(); + + if (serializer == null) { + throw new IllegalStateException("No key serializer configured"); + } + + return serializer; + } + + @SuppressWarnings("unchecked") + private byte[] rawKey(Object key) { + + Assert.notNull(key, "non null key required"); + + if (key instanceof byte[] bytes) { + return bytes; + } + + return requiredKeySerializer().serialize((K) key); + } + + private byte[][] rawKeys(Collection keys) { + final byte[][] rawKeys = new byte[keys.size()][]; + + int i = 0; + for (K key : keys) { + rawKeys[i++] = rawKey(key); + } + + return rawKeys; + } + + static class DefaultPathSpec

> implements PathSpec

{ + + String jsonPath = JsonPath.root().asString(); + + @Override + @SuppressWarnings("unchecked") + public P root() { + this.jsonPath = JsonPath.root().asString(); + return (P) this; + } + + @Override + @SuppressWarnings("unchecked") + public P path(String jsonPath) { + Assert.hasText(jsonPath, "JsonPath must not be empty"); + this.jsonPath = jsonPath; + return (P) this; + } + + } + + abstract static class DefaultJsonSpec & JsonSetSupport> + extends DefaultPathSpec implements JsonKeySupport, JsonSetSupport { + + final RedisJsonTemplate template; + final RedisJsonSerializer serializer; + final byte[] key; + private JsonSetCondition condition = JsonSetCondition.upsert(); + + DefaultJsonSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { + this.template = template; + this.serializer = serializer; + this.key = key; + } + + // --- JsonKeySupport --- + + @Override + public @Nullable Long clear() { + return template.execute(c -> c.jsonCommands().jsonClear(key, JsonPath.of(jsonPath))); + } + + @Override + public @Nullable Long delete() { + return template.execute(c -> c.jsonCommands().jsonDel(key, JsonPath.of(jsonPath))); + } + + @Override + public JsonResult get() { + String result = template.execute(c -> c.jsonCommands().jsonGet(key, JsonPath.of(jsonPath))); + return new DefaultJsonResult(serializer, result); + } + + // --- JsonSetSupport --- + + @Override + @SuppressWarnings("unchecked") + public S conditional(Consumer consumer) { + + Assert.notNull(consumer, "Consumer must not be null"); + + DefaultJsonSetSpec spec = new DefaultJsonSetSpec(); + consumer.accept(spec); + this.condition = spec.condition(); + + return (S) this; + } + + @Override + public @Nullable Boolean set(T value) { + JsonValue jsonValue = JsonValue.raw(serializer.serialize(value)); + return template.execute(c -> c.jsonCommands().jsonSet(key, JsonPath.of(jsonPath), jsonValue, condition)); + } + + } + + static class DefaultJsonArraySpec extends DefaultPathSpec implements JsonArraySpec { + + private final RedisJsonTemplate template; + private final RedisJsonSerializer jsonSerializer; + private final byte[] key; + + DefaultJsonArraySpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { + this.template = template; + this.jsonSerializer = serializer; + this.key = key; + } + + @Override + public @Nullable List<@Nullable Long> append(Object... values) { + JsonValue[] jsonValues = Arrays.stream(values).map(it -> JsonValue.raw(jsonSerializer.serialize(it))).toArray(JsonValue[]::new); + return template.execute(c -> c.jsonCommands().jsonArrAppend(key, JsonPath.of(jsonPath), jsonValues)); + } + + @Override + public @Nullable List<@Nullable Long> length() { + return template.execute(c -> c.jsonCommands().jsonArrLen(key, JsonPath.of(jsonPath))); + } + + @Override + public @Nullable List<@Nullable Long> trim(int start, int end) { + return template.execute(c -> c.jsonCommands().jsonArrTrim(key, JsonPath.of(jsonPath), start, end)); + } + + @Override + public @Nullable List indexOf(Object value) { + JsonValue jsonValue = JsonValue.raw(jsonSerializer.serialize(value)); + return template.execute(c -> c.jsonCommands().jsonArrIndex(key, JsonPath.of(jsonPath), jsonValue)); + } + + @Override + public JsonArrayAtIndex index(int index) { + return new DefaultJsonArrayAtIndex(template, jsonSerializer, key, jsonPath, index); + } + + } + + static class DefaultJsonArrayAtIndex implements JsonArrayAtIndex { + + private final RedisJsonTemplate template; + private final RedisJsonSerializer serializer; + private final byte[] key; + private final String jsonPath; + private final int index; + + DefaultJsonArrayAtIndex(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key, String jsonPath, int index) { + this.template = template; + this.serializer = serializer; + this.key = key; + this.jsonPath = jsonPath; + this.index = index; + } + + @Override + public @Nullable List<@Nullable Long> insert(Object... values) { + JsonValue[] jsonValues = Arrays.stream(values).map(it -> JsonValue.raw(serializer.serialize(it))).toArray(JsonValue[]::new); + return template.execute(c -> c.jsonCommands().jsonArrInsert(key, JsonPath.of(jsonPath), index, jsonValues)); + } + + } + + static class DefaultJsonBooleanSpec extends DefaultJsonSpec implements JsonBooleanSpec { + + DefaultJsonBooleanSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { + super(template, serializer, key); + } + + @Override + public @Nullable List<@Nullable Boolean> toggle() { + return template.execute(c -> c.jsonCommands().jsonToggle(key, JsonPath.of(jsonPath))); + } + + } + + static class DefaultJsonStringSpec extends DefaultJsonSpec implements JsonStringSpec { + + DefaultJsonStringSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { + super(template, serializer, key); + } + + @Override + public @Nullable List<@Nullable Long> length() { + return template.execute(c -> c.jsonCommands().jsonStrLen(key, JsonPath.of(jsonPath))); + } + + @Override + public @Nullable List<@Nullable Long> append(String value) { + return template.execute(c -> c.jsonCommands().jsonStrAppend(key, JsonPath.of(jsonPath), value)); + } + + } + + static class DefaultJsonAtKeySpec extends DefaultJsonSpec implements JsonAtKeySpec { + + DefaultJsonAtKeySpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { + super(template, serializer, key); + } + + @Override + public @Nullable Boolean mergeWith(Object value) { + JsonValue jsonValue = JsonValue.raw(serializer.serialize(value)); + return template.execute(c -> c.jsonCommands().jsonMerge(key, JsonPath.of(jsonPath), jsonValue)); + } + + @Override + public @Nullable List getType() { + return template.execute(c -> c.jsonCommands().jsonType(key, JsonPath.of(jsonPath))); + } + + } + + static class DefaultJsonMultiGetSpec extends DefaultPathSpec implements JsonAtKeysSpec { + + private final RedisJsonTemplate template; + private final RedisJsonSerializer serializer; + private final byte[][] keys; + + DefaultJsonMultiGetSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[][] keys) { + this.template = template; + this.serializer = serializer; + this.keys = keys; + } + + @Override + public JsonResults get() { + + List response = template.execute(c -> c.jsonCommands().jsonMGet(JsonPath.of(jsonPath), keys)); + List result = response == null ? null + : response.stream().map(it -> (JsonResult) new DefaultJsonResult(serializer, it)).toList(); + + return new DefaultJsonResults(result); + } + + } + + static class DefaultJsonSetSpec implements JsonSetSpec { + + private JsonSetCondition condition = JsonSetCondition.upsert(); + + @Override + public JsonSetSpec always() { + this.condition = JsonSetCondition.upsert(); + return this; + } + + @Override + public JsonSetSpec ifAbsent() { + this.condition = JsonSetCondition.ifPathNotExists(); + return this; + } + + @Override + public JsonSetSpec ifPresent() { + this.condition = JsonSetCondition.ifPathExists(); + return this; + } + + public JsonSetCondition condition() { + return condition; + } + + } + + static class DefaultJsonResult implements JsonResult { + + private final RedisJsonSerializer serializer; + private final @Nullable String result; + + DefaultJsonResult(RedisJsonSerializer serializer, @Nullable String result) { + this.serializer = serializer; + this.result = result; + } + + @Override + public V as(Class type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return serializer.deserialize(result, type); + } + + @Override + public V as(ParameterizedTypeReference type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return serializer.deserialize(result, type); + } + + @Override + public String asString() { + Assert.notNull(result, "Result must not be null"); + return result; + } + + @Override + public U map(Function mapper) { + Assert.notNull(mapper, "Mapper must not be null"); + return mapper.apply(asBytes()); + } + + @Override + public byte[] asBytes() { + Assert.notNull(result, "Result must not be null"); + return result.getBytes(StandardCharsets.UTF_8); + } + + @Override + public boolean isNull() { + if ("null".equals(result)) { + return true; + } + return result == null; + } + + @Override + public @Nullable String toString() { + return result; + } + + } + + static class DefaultJsonResults implements JsonResults { + + private final @Nullable Collection result; + + DefaultJsonResults(@Nullable Collection result) { + this.result = result; + } + + @Override + public List<@Nullable V> as(Class type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.as(type)).toList(); + } + + @Override + public List<@Nullable V> as(ParameterizedTypeReference type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.as(type)).toList(); + } + + @Override + public Iterator iterator() { + Assert.notNull(result, "Result must not be null"); + return result.iterator(); + } + + @Override + public List<@Nullable String> asString() { + Assert.notNull(result, "Result must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.asString()).toList(); + } + + @Override + public List asBytes() { + Assert.notNull(result, "Result must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.asBytes()).toList(); + } + + @Override + public boolean isNull() { + return result == null || result.isEmpty(); + } + + } + +} diff --git a/src/main/java/org/springframework/data/redis/core/JsonOperations.java b/src/main/java/org/springframework/data/redis/core/JsonOperations.java new file mode 100644 index 0000000000..4dac78c134 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/JsonOperations.java @@ -0,0 +1,561 @@ +/* + * Copyright 2026-present 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.core; + +import java.util.Collection; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + +import org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.connection.RedisJsonCommands; +import org.springframework.data.redis.connection.json.JsonValue; +import org.springframework.data.util.Streamable; + +/** + * Redis operations for JSON values providing a fluent API for flexible JSON result consumption. + *

+ * Typed entry points bound to a key allow configuring the command and running it by calling a terminal method returning + * the command result, for example: + * + *

+ * operations.key("key").set("value");
+ * operations.key("key").path("$..name").setIfAbsent("Doe");
+ * operations.array("key").path("$.names").index(2).insert("John")
+ * Person person = operations.key("key").get().as(Person.class);
+ * 
+ *

+ * JSON path expressions follow the + * RedisJSON path syntax. + * Unless specified otherwise, results are positionally correlated to matching paths: each element in the returned + * {@link List} corresponds to a matching path, and {@literal null} elements indicate paths that did not match or + * pointed to an incompatible JSON type. + * + * @param the Redis key type. + * @author Mark Paluch + * @author Yordan Tsintsov + * @since 4.2 + */ +public interface JsonOperations { + + /** + * Start building a JSON array operation for the given {@code key}. + * + * @param key must not be {@literal null}. + * @return a spec for specifying the array operation. + */ + JsonArraySpec array(K key); + + /** + * Start building a JSON boolean operation for the given {@code key}. + * + * @param key must not be {@literal null}. + * @return a spec for specifying the boolean operation. + */ + JsonBooleanSpec bool(K key); + + /** + * Start building a JSON string operation for the given {@code key}. + * + * @param key must not be {@literal null}. + * @return a spec for specifying the string operation. + */ + JsonStringSpec string(K key); + + /** + * Start building a JSON value operation for the given {@code key}. + * + * @param key must not be {@literal null}. + * @return a spec for specifying the value operation. + */ + JsonAtKeySpec key(K key); + + /** + * Retrieve the JSON value for the given {@code key}. + * + * @param key must not be {@literal null}. + * @return the JSON value for the given key. + */ + default JsonResult get(K key) { + return key(key).get(); + } + + /** + * Set the {@code key} to a JSON {@code value}. + * + * @param key must not be {@literal null}. + * @param value must not be {@literal null}. + * @return {@literal true} if the value was written; {@literal false} otherwise. + */ + default @Nullable Boolean set(K key, Object value) { + return key(key).set(value); + } + + /** + * Read the JSON values at the given {@code paths} for the given {@code key} in a single {@code JSON.GET}. The supplied + * paths form the complete set to retrieve and are returned as one combined JSON document. + * + * @param key must not be {@literal null}. + * @param paths the JSON paths to read; must not be empty. + * @return the combined JSON document containing the requested paths. + * @see Redis Documentation: JSON.GET + */ + JsonResult paths(K key, String... paths); + + /** + * Read the JSON values at the given {@code paths} for the given {@code key} in a single {@code JSON.GET}. The supplied + * paths form the complete set to retrieve and are returned as one combined JSON document. + * + * @param key must not be {@literal null}. + * @param paths the JSON paths to read; must not be empty. + * @return the combined JSON document containing the requested paths. + * @see Redis Documentation: JSON.GET + */ + default JsonResult paths(K key, Collection paths) { + return paths(key, paths.toArray(new String[0])); + } + + /** + * Start building a JSON multi-value operation (i.e. multi-get) for the given {@code keys}. + * + * @param keys must not be {@literal null}. + * @return a spec for specifying the multi-value operation. + * @see Redis Documentation: JSON.MGET + */ + JsonAtKeysSpec values(Collection keys); + + /** + * Specification for JSON array operations bound to a particular {@code key}. + *

+ * All commands invoked through this interface operate on a {@link PathSpec#path(String) JSON path} defaulting to the + * document root ({@code $}). + */ + interface JsonArraySpec extends PathSpec { + + /** + * Append the given {@code values} to the JSON array at the configured path. + * + * @param values values to append. + * @return a list where each element contains the new array length at matching paths, or {@literal null} if the path + * does not exist or is not an array. + * @see Redis Documentation: JSON.ARRAPPEND + */ + @Nullable List<@Nullable Long> append(Object... values); + + /** + * Return the length of the JSON array at the configured path. + * + * @return a list where each element contains the array length at matching paths, or {@literal null} if the path + * does not exist or is not an array. + * @see Redis Documentation: JSON.ARRLEN + */ + @Nullable List<@Nullable Long> length(); + + /** + * Trim the JSON array so that it contains only the specified inclusive range of elements between {@code start} and + * {@code end} (i.e. remove everything outside the given range). + * + * @param start index of the first element to keep (previous elements are trimmed). + * @param end index of the last element to keep (following elements are trimmed), including the last element. + * Negative values are interpreted as starting from the end. + * @return a list where each element contains the new array length at matching paths, or {@literal null} if the path + * does not exist or is not an array. + * @see Redis Documentation: JSON.ARRTRIM + */ + @Nullable List<@Nullable Long> trim(int start, int end); + + /** + * Return the first index of {@code value} within the JSON array at the configured path. + * + * @param value must not be {@literal null}. + * @return a list containing the first zero-based index for each matching path, or {@code -1} if the value is not + * contained in the corresponding array. + * @see Redis Documentation: JSON.ARRINDEX + */ + @Nullable List indexOf(Object value); + + /** + * Select an array element by its {@code index} for subsequent operations. + * + * @param index the array index to operate on. + * @return a spec for index-based array operations. + */ + JsonArrayAtIndex index(int index); + + } + + /** + * Specification for JSON array operations bound to a previously selected array index. + */ + interface JsonArrayAtIndex { + + /** + * Insert {@code values} before the previously selected array index. + * + * @param values must not be {@literal null}. + * @return a list where each element contains the new array length at matching paths, or {@literal null} if the path + * does not exist or is not an array. + * @see Redis Documentation: JSON.ARRINSERT + */ + @Nullable List<@Nullable Long> insert(Object... values); + + } + + /** + * Specification for JSON boolean operations bound to a particular {@code key}. + * + * @see Redis Documentation: JSON.TOGGLE + */ + interface JsonBooleanSpec extends JsonKeySupport, JsonSetSupport { + + /** + * Toggle the boolean values at the configured path. + * + * @return a list containing the updated boolean values for matching paths, or {@literal null} if the matching JSON + * value is not a boolean. + * @see Redis Documentation: JSON.TOGGLE + */ + @Nullable List<@Nullable Boolean> toggle(); + + } + + /** + * Specification for JSON string operations bound to a particular {@code key}. + */ + interface JsonStringSpec extends JsonKeySupport, JsonSetSupport { + + /** + * Return the length of the JSON string values at the configured path. + * + * @return a list containing the string length for each matching path, or {@literal null} if the matching JSON value + * is not a string. + * @see Redis Documentation: JSON.STRLEN + */ + @Nullable List<@Nullable Long> length(); + + /** + * Append {@code value} to the JSON string values at the configured path. + * + * @param value must not be {@literal null}. + * @return a list containing the updated string length for each matching path, or {@literal null} if the matching + * JSON value is not a string. + * @see Redis Documentation: JSON.STRAPPEND + */ + @Nullable List<@Nullable Long> append(String value); + + } + + /** + * Specification for JSON value operations bound to a particular {@code key}. Provides access to type-agnostic + * operations such as {@link #mergeWith(Object) merge} and {@link #getType() type} inspection. + */ + interface JsonAtKeySpec extends JsonKeySupport, JsonSetSupport { + + /** + * Merge {@code value} into the JSON value at the configured path. + * + * @param value must not be {@literal null}. + * @return {@literal true} if the merge was applied; {@literal false} otherwise. + * @see Redis Documentation: JSON.MERGE + */ + @Nullable Boolean mergeWith(Object value); + + /** + * Determine the {@link RedisJsonCommands.JsonType type} of the JSON values at the configured path. + * + * @return a list containing the JSON types for matching paths. + * @see Redis Documentation: JSON.TYPE + */ + @Nullable List getType(); + + } + + /** + * Specification for JSON multi-key operations sharing a common JSON path. + * + * @see Redis Documentation: JSON.MGET + */ + interface JsonAtKeysSpec extends PathSpec, JsonMultiGetSpec { + + } + + /** + * Common support for JSON operations bound to a single key and configurable path. + * + * @param

self-type used for fluent method chaining. + */ + interface JsonKeySupport

> extends PathSpec

{ + + /** + * Clear the JSON values at the configured path. + * + * @return the number of values that were cleared. + * @see Redis Documentation: JSON.CLEAR + */ + @Nullable Long clear(); + + /** + * Delete the JSON values at the configured path. + * + * @return the number of values that were deleted. + * @see Redis Documentation: JSON.DEL + */ + @Nullable Long delete(); + + /** + * Retrieve the JSON value at the configured path. + * + * @return the JSON value wrapper. + * @see Redis Documentation: JSON.GET + */ + JsonResult get(); + + } + + /** + * Common support for setting JSON values at the currently configured path. + * + * @param value type. + * @param self-type used for fluent method chaining. + * @see Redis Documentation: JSON.SET + */ + interface JsonSetSupport> { + + /** + * Apply a condition to the set operation through a {@link JsonSetSpec}. + * + * @param consumer callback to configure the condition, must not be {@literal null}. + * @return this spec for further configuration. + */ + S conditional(Consumer consumer); + + /** + * Set the JSON {@code value} at the configured path. + * + * @param value must not be {@literal null}. + * @return {@literal true} if the value was written; {@literal false} otherwise. + * @see Redis Documentation: JSON.SET + */ + @Nullable Boolean set(T value); + + /** + * Set the JSON {@code value} at the configured path only if the path has one or more matches ({@code XX}). + * + * @param value must not be {@literal null}. + * @return {@literal true} if the value was written; {@literal false} otherwise. + */ + default @Nullable Boolean setIfPresent(T value) { + return conditional(JsonSetSpec::ifPresent).set(value); + } + + /** + * Set the JSON {@code value} at the configured path only if the path has no matches ({@code NX}). + * + * @param value must not be {@literal null}. + * @return {@literal true} if the value was written; {@literal false} otherwise. + */ + default @Nullable Boolean setIfAbsent(T value) { + return conditional(JsonSetSpec::ifAbsent).set(value); + } + + } + + /** + * Terminal step for multi-path or multi-key JSON read operations. + */ + interface JsonMultiGetSpec { + + /** + * Execute the read operation and return the resulting JSON values. + * + * @return the JSON values. + */ + JsonResults get(); + } + + /** + * Common support for selecting the JSON path against which commands operate. + * + * @param

self-type used for fluent method chaining. + */ + interface PathSpec

> { + + /** + * Select the document root path ({@code $}). + * + * @return this builder. + */ + P root(); + + /** + * Select the JSON path to operate on. + * + * @param jsonPath must not be {@literal null}. + * @return this builder. + */ + P path(String jsonPath); + + } + + /** + * A single JSON result value providing accessors to obtain the {@link #asString() raw} or {@link #as(Class) + * deserialized} result of a JSON command. + */ + interface JsonResult extends JsonValue { + + /** + * Decode this JSON value into the given target {@code type}. + * + * @param type must not be {@literal null}. + * @return the decoded value. + * @param target type. + */ + V as(Class type); + + /** + * Decode this JSON value into the given target {@code type}. + * + * @param type must not be {@literal null}. + * @return the decoded value. + * @param target type. + */ + V as(ParameterizedTypeReference type); + + /** + * Return the JSON representation of this value as a UTF-8 encoded {@link String}. + * + * @return the JSON representation. + */ + String asString(); + + /** + * Map the raw JSON bytes of this value through the given {@code mapper}. + * + * @param mapper must not be {@literal null}. + * @return the mapped value. + * @param mapped result type. + */ + U map(Function mapper); + + /** + * Return the JSON representation of this value as raw bytes. + * + * @return the raw JSON bytes. + */ + byte[] asBytes(); + + /** + * Return whether this value is absent or represents JSON {@code null}. An absent value indicates that the path did + * not match or the key did not exist. + * + * @return {@literal true} if this value is absent or represents {@code null}; {@literal false} otherwise. + */ + boolean isNull(); + + /** + * Return the JSON representation of this value as a string, primarily for debugging purposes. Prefer + * {@link #asString()} or {@link #asBytes()} for programmatic access. + * + * @return the JSON representation, or {@literal null} if this value {@link #isNull() is null}. + */ + @Nullable + String toString(); + + } + + /** + * A sequence of JSON result values providing accessors to obtain the {@link #asString() raw} or {@link #as(Class) + * deserialized} results of a JSON command. Elements are positionally correlated to the matching paths or input keys + * of the originating command. + */ + interface JsonResults extends Streamable { + + /** + * Decode all JSON values into the given target {@code type}. + * + * @param type must not be {@literal null}. + * @return the decoded values. + * @param target type. + */ + List<@Nullable V> as(Class type); + + /** + * Decode all JSON values into the given target {@code type}. + * + * @param type must not be {@literal null}. + * @return the decoded value. + * @param target type. + */ + List<@Nullable V> as(ParameterizedTypeReference type); + + /** + * Return the JSON representation of all values as UTF-8 encoded {@link String Strings}. + * + * @return the JSON string representations. + */ + List<@Nullable String> asString(); + + /** + * Return the JSON representation of all values as raw bytes. + * + * @return the raw JSON bytes. + */ + List asBytes(); + + /** + * Return whether this result is absent or represents JSON {@code null}. An absent result indicates that the key did + * not exist or the command yielded no values. + * + * @return {@literal true} if this result is absent or represents {@code null}; {@literal false} otherwise. + */ + boolean isNull(); + + } + + /** + * Steps for configuring a {@code JSON.SET} operation. + * + * @see Redis Documentation: JSON.SET + */ + interface JsonSetSpec { + + /** + * Set the value unconditionally. + * + * @return this builder. + */ + JsonSetSpec always(); + + /** + * Set the value only if the target path does not already exist ({@code NX}). + * + * @return this builder. + */ + JsonSetSpec ifAbsent(); + + /** + * Set the value only if the target path already exists ({@code XX}). + * + * @return this builder. + */ + JsonSetSpec ifPresent(); + + } + +} + diff --git a/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java new file mode 100644 index 0000000000..64b324fdd6 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java @@ -0,0 +1,162 @@ +/* + * Copyright 2026-present 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.core; + +import org.jspecify.annotations.Nullable; + +import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.JacksonRedisJsonSerializer; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.serializer.RedisJsonSerializer; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.util.Assert; +import org.springframework.util.ClassUtils; + +/** + * Helper class that simplifies Redis JSON data access. + *

+ * Keys are serialized with {@link JdkSerializationRedisSerializer} by default, which produces binary (non human-readable) + * key representations. Configure a key serializer through {@link #setKeySerializer(RedisSerializer)}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +public class RedisJsonTemplate extends RedisAccessor implements BeanClassLoaderAware { + + private boolean enableTransactionSupport = false; + private boolean initialized = false; + private @Nullable ClassLoader classLoader; + + private @Nullable RedisSerializer keySerializer; + private @Nullable RedisJsonSerializer jsonSerializer; + + private final JsonOperations jsonOperations = new DefaultJsonOperations<>(this); + + @Override + @SuppressWarnings("unchecked") + public void afterPropertiesSet() { + + super.afterPropertiesSet(); + + if (keySerializer == null) { + keySerializer = (RedisSerializer) new JdkSerializationRedisSerializer(classLoader != null ? classLoader : this.getClass().getClassLoader()); + } + + if (jsonSerializer == null && ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", classLoader)) { + jsonSerializer = JacksonRedisJsonSerializer.createDefault(); + } + + initialized = true; + } + + /** + * Returns whether this template participates in ongoing transactions. + * + * @return {@literal true} if transaction support is enabled; {@literal false} otherwise. + */ + public boolean isEnableTransactionSupport() { + return enableTransactionSupport; + } + + /** + * Sets whether this template participates in ongoing transactions using {@literal MULTI...EXEC|DISCARD} to keep track + * of operations. + * + * @param enableTransactionSupport {@literal true} to participate in ongoing transactions; {@literal false} to not + * track transactions. + * @since 4.2 + */ + public void setEnableTransactionSupport(boolean enableTransactionSupport) { + this.enableTransactionSupport = enableTransactionSupport; + } + + @Override + public void setBeanClassLoader(ClassLoader classLoader) { + this.classLoader = classLoader; + } + + /** + * Returns the key serializer used by this template. + * + * @return the key serializer used by this template. + */ + public @Nullable RedisSerializer getKeySerializer() { + return keySerializer; + } + + /** + * Sets the key serializer to be used by this template. + * + * @param keySerializer the key serializer to be used by this template. + */ + public void setKeySerializer(@Nullable RedisSerializer keySerializer) { + this.keySerializer = keySerializer; + } + + /** + * Returns the JSON serializer used by this template. + * + * @return the JSON serializer used by this template + */ + public @Nullable RedisJsonSerializer getJsonSerializer() { + return jsonSerializer; + } + + /** + * Sets the JSON serializer to be used by this template. + * + * @param jsonSerializer the JSON serializer to be used by this template. + */ + public void setJsonSerializer(@Nullable RedisJsonSerializer jsonSerializer) { + this.jsonSerializer = jsonSerializer; + } + + /** + * Executes the given action within a {@link RedisConnection} obtained from the configured + * {@link RedisConnectionFactory}, releasing the connection once the action completes. + * + * @param return type + * @param action callback object that specifies the Redis action; must not be {@literal null}. + * @return object returned by the action. + * @since 4.2 + */ + T execute(RedisCallback action) { + + Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it"); + Assert.notNull(action, "Callback object must not be null"); + + RedisConnectionFactory factory = getRequiredConnectionFactory(); + RedisConnection connection = RedisConnectionUtils.getConnection(factory, enableTransactionSupport); + + try { + return action.doInRedis(connection); + } finally { + RedisConnectionUtils.releaseConnection(connection, factory); + } + } + + /** + * Returns the operations performed on JSON values. + * + * @return JSON operations + */ + public JsonOperations opsForJson() { + return jsonOperations; + } + +} diff --git a/src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java b/src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java new file mode 100644 index 0000000000..0f9675757c --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026-present 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 org.jspecify.annotations.Nullable; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.util.Assert; + +/** + * Implementation of {@link RedisJsonSerializer} with Jackson 3. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +public class JacksonRedisJsonSerializer implements RedisJsonSerializer { + + private final JsonMapper mapper; + + public JacksonRedisJsonSerializer(JsonMapper mapper) { + Assert.notNull(mapper, "JsonMapper must not be null"); + this.mapper = mapper; + } + + public static JacksonRedisJsonSerializer createDefault() { + return new JacksonRedisJsonSerializer(JsonMapper.shared()); + } + + @Override + public String serialize(@Nullable Object value) throws SerializationException { + + if (value == null) { + return "null"; + } + + try { + return mapper.writeValueAsString(value); + } catch (JacksonException ex) { + throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex); + } + } + + @Override + public T deserialize(String rawJson, Class type) throws SerializationException { + try { + return mapper.readValue(rawJson, type); + } catch (JacksonException ex) { + throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); + } + } + + @Override + public T deserialize(String rawJson, ParameterizedTypeReference typeRef) throws SerializationException { + try { + return mapper.readValue(rawJson, mapper.getTypeFactory().constructType(typeRef.getType())); + } catch (JacksonException ex) { + throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); + } + } + +} diff --git a/src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java b/src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java new file mode 100644 index 0000000000..fa9306ae9b --- /dev/null +++ b/src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java @@ -0,0 +1,65 @@ +/* + * Copyright 2026-present 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 org.jspecify.annotations.Nullable; + +import org.springframework.core.ParameterizedTypeReference; + +/** + * Serializer for converting Objects to and from their JSON {@link String} representation. It is recommended that + * implementations handle {@literal null} objects on serialization (by writing JSON {@code null}) and JSON {@code null} + * on deserialization (by returning {@literal null}). Note that Redis does not accept {@literal null} keys or values but + * can return {@code null} replies for non-existing keys. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +public interface RedisJsonSerializer { + + /** + * Serialize the given {@code value} to its JSON representation. + * + * @param value the object to serialize; may be {@literal null}, in which case JSON {@code null} is written. + * @return the JSON representation, never {@literal null}. + * @throws SerializationException if the value cannot be serialized. + */ + String serialize(@Nullable Object value) throws SerializationException; + + /** + * Deserialize the given {@code rawJson} into an instance of {@code type}. + * + * @param rawJson the JSON representation to read; must not be {@literal null}. + * @param type the target type; must not be {@literal null}. + * @param the target type. + * @return the deserialized object, or {@literal null} if {@code rawJson} represents JSON {@code null}. + * @throws SerializationException if the JSON cannot be deserialized. + */ + T deserialize(String rawJson, Class type) throws SerializationException; + + /** + * Deserialize the given {@code rawJson} into an instance of the type described by {@code typeRef}. Use this variant + * for generic types such as {@code List} that cannot be expressed as a {@link Class}. + * + * @param rawJson the JSON representation to read; must not be {@literal null}. + * @param typeRef reference describing the target type; must not be {@literal null}. + * @param the target type. + * @return the deserialized object, or {@literal null} if {@code rawJson} represents JSON {@code null}. + * @throws SerializationException if the JSON cannot be deserialized. + */ + T deserialize(String rawJson, ParameterizedTypeReference typeRef) throws SerializationException; + +} diff --git a/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java b/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java new file mode 100644 index 0000000000..409575946b --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java @@ -0,0 +1,330 @@ +/* + * Copyright 2026-present 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.core; + +import static org.assertj.core.api.Assertions.*; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedClass; +import org.junit.jupiter.params.provider.MethodSource; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.ObjectFactory; +import org.springframework.data.redis.StringObjectFactory; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisJsonCommands; +import org.springframework.data.redis.connection.jedis.extension.JedisConnectionFactoryExtension; +import org.springframework.data.redis.connection.lettuce.extension.LettuceConnectionFactoryExtension; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.data.redis.test.condition.EnabledOnCommand; +import org.springframework.data.redis.test.extension.RedisStandalone; + +/** + * Integration test of {@link DefaultJsonOperations}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +@ParameterizedClass +@MethodSource("testParams") +class DefaultJsonOperationsIntegrationTests { + + private final RedisJsonTemplate template; + private final ObjectFactory keyFactory; + private final JsonOperations jsonOps; + + public DefaultJsonOperationsIntegrationTests(RedisJsonTemplate template, ObjectFactory keyFactory) { + + this.template = template; + this.keyFactory = keyFactory; + this.jsonOps = template.opsForJson(); + } + + static Collection testParams() { + + List params = new ArrayList<>(); + params.addAll(testParams(LettuceConnectionFactoryExtension.getConnectionFactory(RedisStandalone.class))); + params.addAll(testParams(JedisConnectionFactoryExtension.getConnectionFactory(RedisStandalone.class))); + return params; + } + + static Collection testParams(RedisConnectionFactory connectionFactory) { + + ObjectFactory stringFactory = new StringObjectFactory(); + + RedisJsonTemplate stringTemplate = new RedisJsonTemplate<>(); + stringTemplate.setKeySerializer(StringRedisSerializer.UTF_8); + stringTemplate.setConnectionFactory(connectionFactory); + stringTemplate.afterPropertiesSet(); + + return Arrays.asList(new Object[][] { { stringTemplate, stringFactory } }); + } + + @BeforeEach + void setUp() { + template.execute(connection -> { + connection.serverCommands().flushDb(); + return null; + }); + } + + @Test // + @EnabledOnCommand("JSON.ARRAPPEND") + void testArrayAppend() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.array(key).path("$.forsakenDefeated").append(4, 5, 6)) + .isEqualTo(List.of(6L)); + assertThat(jsonOps.key(key).path("$.forsakenDefeated").get() + .as(new ParameterizedTypeReference>>() {})) + .isEqualTo(List.of(List.of(1L, 2L, 3L, 4L, 5L, 6L))); + } + + @Test // + @EnabledOnCommand("JSON.ARRINDEX") + void testArrayIndex() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.array(key).path("$.forsakenDefeated").indexOf(2L)) + .isEqualTo(List.of(1L)); + assertThat(jsonOps.array(key).path("$.forsakenDefeated").indexOf(Integer.MAX_VALUE)) + .isEqualTo(List.of(-1L)); + } + + @Test // + @EnabledOnCommand("JSON.ARRINSERT") + void testArrayInsert() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.array(key).path("$.forsakenDefeated").index(2).insert(1, 4, 5, 6)) + .isEqualTo(List.of(7L)); + } + + @Test // + @EnabledOnCommand("JSON.ARRLEN") + void testArrayLength() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.array(key).path("$.forsakenDefeated").length()) + .isEqualTo(List.of(3L)); + } + + @Test // + @EnabledOnCommand("JSON.ARRTRIM") + void testArrayTrim() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.array(key).path("$.forsakenDefeated").trim(1, 2)) + .isEqualTo(List.of(2L)); + } + + @Test // + @EnabledOnCommand("JSON.CLEAR") + void testClear() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.key(key).clear()).isEqualTo(1); + } + + @Test // + @EnabledOnCommand("JSON.DEL") + void testDelete() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.key(key).delete()).isEqualTo(1); + } + + @Test // + @EnabledOnCommand("JSON.GET") + void testGet() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.get(key).as(new ParameterizedTypeReference>() {})) + .isEqualTo(List.of(DRAGON_REBORN)); + assertThat(jsonOps.paths(key, "$.name", "$.age").asString()) + .contains("Rand al'Thor") + .contains("34"); + assertThat(jsonOps.paths(key, List.of("$.name")).asString()) + .contains("Rand al'Thor"); + } + + @Test // + @EnabledOnCommand("JSON.MERGE") + void testMerge() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.key(key).mergeWith(Map.of("age", 35))).isTrue(); + } + + @Test // + @EnabledOnCommand("JSON.MGET") + void testMultiGet() { + + K key1 = keyFactory.instance(); + K key2 = keyFactory.instance(); + K missing = keyFactory.instance(); + + jsonOps.set(key1, DRAGON_REBORN); + jsonOps.set(key2, DRAGON_REBORN); + + assertThat(jsonOps.values(List.of(key1)).get().as(new ParameterizedTypeReference>() {})) + .isEqualTo(List.of(List.of(DRAGON_REBORN))); + assertThat(jsonOps.values(List.of(key1, key2)).get().as(new ParameterizedTypeReference>() {})) + .isEqualTo(List.of(List.of(DRAGON_REBORN), List.of(DRAGON_REBORN))); + assertThat(jsonOps.values(List.of(key1, missing, key2)).get() + .as(new ParameterizedTypeReference>() {})) + .containsExactly(List.of(DRAGON_REBORN), null, List.of(DRAGON_REBORN)); + } + + @Test // + @EnabledOnCommand("JSON.SET") + void testSet() { + + K key = keyFactory.instance(); + + assertThat(jsonOps.key(key).setIfPresent(DRAGON_REBORN)).isNotEqualTo(Boolean.TRUE); + assertThat(jsonOps.key(key).setIfAbsent(DRAGON_REBORN)).isTrue(); + assertThat(jsonOps.key(key).setIfAbsent(CALLANDOR)).isNotEqualTo(Boolean.TRUE); + assertThat(jsonOps.key(key).setIfPresent(CALLANDOR)).isTrue(); + assertThat(jsonOps.key(key).conditional(JsonOperations.JsonSetSpec::ifPresent).set(DRAGON_REBORN)).isTrue(); + assertThat(jsonOps.set(key, DRAGON_REBORN)).isTrue(); + } + + @Test // + @EnabledOnCommand("JSON.STRAPPEND") + void testStringAppend() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.string(key).path("$.name").append("foo")) + .isEqualTo(List.of(15L)); + assertThat(jsonOps.string(key).path("$.name").get().as(new ParameterizedTypeReference>() {})) + .isEqualTo(List.of("Rand al'Thorfoo")); + assertThat(jsonOps.string(key).path("$.name").append("\"x\\y")) + .isEqualTo(List.of(19L)); + assertThat(jsonOps.string(key).path("$.name").get().as(new ParameterizedTypeReference>() {})) + .isEqualTo(List.of("Rand al'Thorfoo\"x\\y")); + } + + @Test // + @EnabledOnCommand("JSON.STRLEN") + void testStringLength() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.string(key).path("$.name").length()) + .isEqualTo(List.of(12L)); + assertThat(jsonOps.string(key).path("$.name").set("Lews Therin")) + .isTrue(); + assertThat(jsonOps.string(key).path("$.name").length()) + .isEqualTo(List.of(11L)); + } + + @Test // + @EnabledOnCommand("JSON.TOGGLE") + void testToggle() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.bool(key).path("$.madness").toggle()) + .isEqualTo(List.of(true)); + assertThat(jsonOps.bool(key).path("$.madness").set(false)) + .isTrue(); + assertThat(jsonOps.bool(key).path("$.madness").toggle()) + .isEqualTo(List.of(true)); + } + + @Test // + @EnabledOnCommand("JSON.TYPE") + void testType() { + + K key = keyFactory.instance(); + + jsonOps.set(key, DRAGON_REBORN); + + assertThat(jsonOps.key(key).path("$.name").getType()) + .isEqualTo(List.of(RedisJsonCommands.JsonType.STRING)); + assertThat(jsonOps.key(key).root().getType()) + .isEqualTo(List.of(RedisJsonCommands.JsonType.OBJECT)); + } + + record Callandor( + String name, + double length, + double width + ) {} + + record DragonReborn( + String name, + long age, + boolean madness, + List titles, + List forsakenDefeated, + Callandor callandor + ) {} + + private static final Callandor CALLANDOR = new Callandor("Callandor", 10.0, 1.0); + + private static final DragonReborn DRAGON_REBORN = new DragonReborn( + "Rand al'Thor", + 34, + false, + List.of("Dragon Reborn", "Lord of the Morning"), + List.of(1L, 2L, 3L), + CALLANDOR + ); + +} diff --git a/src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java new file mode 100644 index 0000000000..803c35c067 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java @@ -0,0 +1,75 @@ +/* + * Copyright 2026-present 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.core; + +import static org.assertj.core.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.Person; +import org.springframework.data.redis.PersonObjectFactory; +import org.springframework.data.redis.core.DefaultJsonOperations.DefaultJsonResult; +import org.springframework.data.redis.serializer.JacksonRedisJsonSerializer; +import org.springframework.data.redis.serializer.RedisJsonSerializer; + +/** + * Unit tests for {@link DefaultJsonResult}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +class DefaultJsonResultUnitTests { + + private final RedisJsonSerializer serializer = JacksonRedisJsonSerializer.createDefault(); + + @Test + void testAsClassDeserializesPojo() { + + Person person = new PersonObjectFactory().instance(); + DefaultJsonResult result = new DefaultJsonResult(serializer, serializer.serialize(person)); + + assertThat(result.as(Person.class)).isEqualTo(person); + } + + @Test + void testAsTypeRefDeserializesGenericList() { + + DefaultJsonResult result = new DefaultJsonResult(serializer, "[1,2,3]"); + + assertThat(result.as(new ParameterizedTypeReference>() {})).containsExactly(1L, 2L, 3L); + } + + @Test + void testAsStringReturnsRawPayload() { + + DefaultJsonResult result = new DefaultJsonResult(serializer, "{\"name\":\"rand\"}"); + + assertThat(result.asString()).isEqualTo("{\"name\":\"rand\"}"); + } + + @Test + void testAsBytesReturnsUtf8Encoding() { + + DefaultJsonResult result = new DefaultJsonResult(serializer, "{\"name\":\"rand\"}"); + + assertThat(result.asBytes()).isEqualTo("{\"name\":\"rand\"}".getBytes(StandardCharsets.UTF_8)); + } + +} diff --git a/src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java new file mode 100644 index 0000000000..02f236f3fd --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java @@ -0,0 +1,91 @@ +/* + * Copyright 2026-present 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.core; + +import static org.assertj.core.api.Assertions.*; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.springframework.data.redis.core.DefaultJsonOperations.DefaultJsonResult; +import org.springframework.data.redis.core.DefaultJsonOperations.DefaultJsonResults; +import org.springframework.data.redis.serializer.JacksonRedisJsonSerializer; +import org.springframework.data.redis.serializer.RedisJsonSerializer; + +/** + * Unit tests for {@link DefaultJsonResults}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +class DefaultJsonResultsUnitTests { + + private final RedisJsonSerializer serializer = JacksonRedisJsonSerializer.createDefault(); + + @Test + void testAsClassDeserializesEachEntry() { + + List data = List.of(new DefaultJsonResult(serializer, "1"), + new DefaultJsonResult(serializer, "2"), + new DefaultJsonResult(serializer, "3")); + + DefaultJsonResults result = new DefaultJsonResults(data); + + assertThat(result.as(Long.class)).containsExactly(1L, 2L, 3L); + } + + @Test + void testAsStringReturnsRawEntries() { + + List data = List.of(new DefaultJsonResult(serializer, "{\"a\":1}"), + new DefaultJsonResult(serializer, null), + new DefaultJsonResult(serializer, "{\"a\":2}")); + + assertThat(new DefaultJsonResults(data).asString()).isEqualTo(Arrays.asList("{\"a\":1}", null, "{\"a\":2}")); + } + + @Test + void testAsBytesReturnsUtf8EncodingPerEntry() { + + List data = List.of(new DefaultJsonResult(serializer, "foo"), + new DefaultJsonResult(serializer, null)); + + DefaultJsonResults result = new DefaultJsonResults(data); + + List bytes = result.asBytes(); + + assertThat(bytes).hasSize(2); + assertThat(bytes.get(0)).isEqualTo("foo".getBytes(StandardCharsets.UTF_8)); + assertThat(bytes.get(1)).isNull(); + } + + @Test + void testIsNullReturnValue() { + + DefaultJsonResults emptyResult = new DefaultJsonResults(new ArrayList<>()); + DefaultJsonResults nullResult = new DefaultJsonResults(null); + DefaultJsonResults correctResult = new DefaultJsonResults(List.of(new DefaultJsonResult(serializer, "1"))); + + assertThat(emptyResult.isNull()).isTrue(); + assertThat(nullResult.isNull()).isTrue(); + assertThat(correctResult.isNull()).isFalse(); + } + +} diff --git a/src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java new file mode 100644 index 0000000000..1d5c6c23fd --- /dev/null +++ b/src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026-present the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.serializer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; + +import java.util.List; + +import org.junit.jupiter.api.Test; +import tools.jackson.databind.json.JsonMapper; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.Person; +import org.springframework.data.redis.PersonObjectFactory; + +/** + * Unit tests for {@link JacksonRedisJsonSerializer}. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +class JacksonRedisJsonSerializerUnitTests { + + private final JacksonRedisJsonSerializer serializer = new JacksonRedisJsonSerializer(JsonMapper.shared()); + + @Test + void testToJsonReturnsJsonNullLiteralForNull() { + assertThat(serializer.serialize(null)).isEqualTo("null"); + } + + @Test + void testToJsonConvertsToJsonFromObject() { + + Person person = new PersonObjectFactory().instance(); + + String json = serializer.serialize(person); + + assertThat(serializer.deserialize(json, Person.class)).isEqualTo(person); + } + + @Test + void testToJsonWrapsJacksonExceptions() { + + assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> serializer.serialize(new ThrowingPojo())) + .withMessageStartingWith("Could not write JSON:"); + } + + @Test + void testFromJsonClassDeserializesPojo() { + + Person person = new PersonObjectFactory().instance(); + + assertThat(serializer.deserialize(serializer.serialize(person), Person.class)).isEqualTo(person); + } + + @Test + void testFromJsonClassWrapsInvalidJson() { + + assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> serializer.deserialize("{not json", Person.class)) + .withMessageStartingWith("Could not read JSON:"); + } + + @Test + void testFromJsonTypeRefDeserializesGenericList() { + + List result = serializer.deserialize("[1,2,3]", new ParameterizedTypeReference<>() {}); + + assertThat(result).containsExactly(1L, 2L, 3L); + } + + @Test + void testFromJsonTypeRefDeserializesNestedGenericList() { + + List> result = serializer.deserialize("[[1,2,3,4,5,6]]", + new ParameterizedTypeReference<>() {}); + + assertThat(result).containsExactly(List.of(1L, 2L, 3L, 4L, 5L, 6L)); + } + + @Test + void testFromJsonTypeRefWrapsInvalidJson() { + + assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> serializer.deserialize("{not json", new ParameterizedTypeReference>() {})) + .withMessageStartingWith("Could not read JSON:"); + } + + static class ThrowingPojo { + + public String getValue() { + throw new IllegalStateException("boom"); + } + } + +} From cf934726191ae19b9ce96b6e406c1be888a1228b Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Mon, 29 Jun 2026 18:09:23 +0300 Subject: [PATCH 3/8] Updated test comments to actual PR number Signed-off-by: Yordan Tsintsov --- .../AbstractConnectionIntegrationTests.java | 30 +++++++++---------- ...DefaultJsonOperationsIntegrationTests.java | 30 +++++++++---------- 2 files changed, 30 insertions(+), 30 deletions(-) diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java index 2e04cb477e..7ad1bbebf9 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java @@ -5179,7 +5179,7 @@ void zRangeStoreRevByLexStoresKeys() { assertThat((LinkedHashSet) result.get(5)).containsSequence(VALUE_3, VALUE_4); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.ARRAPPEND") void jsonArrAppend() { @@ -5194,7 +5194,7 @@ void jsonArrAppend() { assertThat((List) result.get(1)).containsExactly(3L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.ARRINDEX") void jsonArrIndex() { @@ -5212,7 +5212,7 @@ void jsonArrIndex() { assertThat((List) result.get(3)).isEmpty(); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.ARRINSERT") void jsonArrInsert() { @@ -5226,7 +5226,7 @@ void jsonArrInsert() { assertThat((List) result.get(1)).containsExactly(5L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.ARRLEN") void jsonArrLen() { @@ -5240,7 +5240,7 @@ void jsonArrLen() { assertThat((List) result.get(1)).containsExactly(3L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.ARRTRIM") void jsonArrTrim() { @@ -5254,7 +5254,7 @@ void jsonArrTrim() { assertThat((List) result.get(1)).containsExactly(3L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.CLEAR") void jsonClear() { @@ -5273,7 +5273,7 @@ void jsonClear() { assertThat(result.get(3)).isEqualTo(1L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.DEL") void jsonDel() { @@ -5292,7 +5292,7 @@ void jsonDel() { assertThat(result.get(3)).isEqualTo(1L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.GET") void jsonGet() { @@ -5311,7 +5311,7 @@ void jsonGet() { assertThat(result.get(3)).isEqualTo(("[]")); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.MERGE") void jsonMerge() { @@ -5327,7 +5327,7 @@ void jsonMerge() { assertThat(result.get(2)).isEqualTo("[{\"a\":1,\"b\":2}]"); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.MGET") void jsonMGet() { @@ -5348,7 +5348,7 @@ void jsonMGet() { assertThat((List) result.get(3)).containsSequence("[1]", "[1]"); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.SET") void jsonSet() { @@ -5366,7 +5366,7 @@ void jsonSet() { assertThat(result.get(3)).isEqualTo("[{\"a\":2,\"b\":{\"b\":3}}]"); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.STRAPPEND") void jsonStrAppend() { @@ -5380,7 +5380,7 @@ void jsonStrAppend() { assertThat((List) result.get(1)).containsExactly(6L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.STRLEN") void jsonStrLen() { @@ -5394,7 +5394,7 @@ void jsonStrLen() { assertThat((List) result.get(1)).containsExactly(3L); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.TOGGLE") void jsonToggle() { @@ -5408,7 +5408,7 @@ void jsonToggle() { assertThat((List) result.get(1)).containsExactly(false); } - @Test // TODO + @Test // GH-3390 @EnabledOnCommand("JSON.TYPE") void jsonType() { diff --git a/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java b/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java index 409575946b..3c7064f327 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java @@ -88,7 +88,7 @@ void setUp() { }); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.ARRAPPEND") void testArrayAppend() { @@ -103,7 +103,7 @@ void testArrayAppend() { .isEqualTo(List.of(List.of(1L, 2L, 3L, 4L, 5L, 6L))); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.ARRINDEX") void testArrayIndex() { @@ -117,7 +117,7 @@ void testArrayIndex() { .isEqualTo(List.of(-1L)); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.ARRINSERT") void testArrayInsert() { @@ -129,7 +129,7 @@ void testArrayInsert() { .isEqualTo(List.of(7L)); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.ARRLEN") void testArrayLength() { @@ -141,7 +141,7 @@ void testArrayLength() { .isEqualTo(List.of(3L)); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.ARRTRIM") void testArrayTrim() { @@ -153,7 +153,7 @@ void testArrayTrim() { .isEqualTo(List.of(2L)); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.CLEAR") void testClear() { @@ -164,7 +164,7 @@ void testClear() { assertThat(jsonOps.key(key).clear()).isEqualTo(1); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.DEL") void testDelete() { @@ -175,7 +175,7 @@ void testDelete() { assertThat(jsonOps.key(key).delete()).isEqualTo(1); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.GET") void testGet() { @@ -192,7 +192,7 @@ void testGet() { .contains("Rand al'Thor"); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.MERGE") void testMerge() { @@ -203,7 +203,7 @@ void testMerge() { assertThat(jsonOps.key(key).mergeWith(Map.of("age", 35))).isTrue(); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.MGET") void testMultiGet() { @@ -223,7 +223,7 @@ void testMultiGet() { .containsExactly(List.of(DRAGON_REBORN), null, List.of(DRAGON_REBORN)); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.SET") void testSet() { @@ -237,7 +237,7 @@ void testSet() { assertThat(jsonOps.set(key, DRAGON_REBORN)).isTrue(); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.STRAPPEND") void testStringAppend() { @@ -255,7 +255,7 @@ void testStringAppend() { .isEqualTo(List.of("Rand al'Thorfoo\"x\\y")); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.STRLEN") void testStringLength() { @@ -271,7 +271,7 @@ void testStringLength() { .isEqualTo(List.of(11L)); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.TOGGLE") void testToggle() { @@ -287,7 +287,7 @@ void testToggle() { .isEqualTo(List.of(true)); } - @Test // + @Test // GH-3390 @EnabledOnCommand("JSON.TYPE") void testType() { From 20a92d1f1ad53797752c44bc89c1469cd376262d Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Tue, 30 Jun 2026 15:49:06 +0300 Subject: [PATCH 4/8] Added helper extension functions for Kotlin Signed-off-by: Yordan Tsintsov --- .../redis/core/JsonOperationsExtensions.kt | 46 +++++++++++ .../core/JsonOperationsExtensionsUnitTests.kt | 81 +++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 src/main/kotlin/org/springframework/data/redis/core/JsonOperationsExtensions.kt create mode 100644 src/test/kotlin/org/springframework/data/redis/core/JsonOperationsExtensionsUnitTests.kt diff --git a/src/main/kotlin/org/springframework/data/redis/core/JsonOperationsExtensions.kt b/src/main/kotlin/org/springframework/data/redis/core/JsonOperationsExtensions.kt new file mode 100644 index 0000000000..e611b3dd5f --- /dev/null +++ b/src/main/kotlin/org/springframework/data/redis/core/JsonOperationsExtensions.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2026-present 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.core + +import org.springframework.core.ParameterizedTypeReference + +/** + * Decode this [JsonOperations.JsonResult] into the target type [T] using reified type information. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +inline fun JsonOperations.JsonResult.asType(): T = + `as`(object : ParameterizedTypeReference() {}) + +/** + * Decode all values of this [JsonOperations.JsonResults] into the target type [T] using reified type information. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +inline fun JsonOperations.JsonResults.asType(): List = + `as`(object : ParameterizedTypeReference() {}) + +/** + * Decode this [JsonOperations.JsonResult] into the target type [T], returning `null` when the result is absent or + * represents JSON `null` instead of decoding it. + * + * @author Yordan Tsintsov + * @since 4.2 + */ +inline fun JsonOperations.JsonResult.asTypeOrNull(): T? = + if (this.isNull) null else asType() diff --git a/src/test/kotlin/org/springframework/data/redis/core/JsonOperationsExtensionsUnitTests.kt b/src/test/kotlin/org/springframework/data/redis/core/JsonOperationsExtensionsUnitTests.kt new file mode 100644 index 0000000000..31c9f1b76b --- /dev/null +++ b/src/test/kotlin/org/springframework/data/redis/core/JsonOperationsExtensionsUnitTests.kt @@ -0,0 +1,81 @@ +/* + * Copyright 2026-present 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.core + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.core.ParameterizedTypeReference + +/** + * Unit tests for `JsonOperationsExtensions`. + * + * @author Yordan Tsintsov + */ +class JsonOperationsExtensionsUnitTests { + + data class Person(val name: String) + + @Test // GH-3390 + fun `asType on JsonResult delegates to as with a ParameterizedTypeReference`() { + + val result = mockk() + val person = Person("Rand al'Thor") + every { result.`as`(any>()) } returns person + + assertThat(result.asType()).isEqualTo(person) + + verify { result.`as`(any>()) } + } + + @Test // GH-3390 + fun `asType on JsonResults delegates to as and preserves null elements`() { + + val results = mockk() + val person = Person("Rand al'Thor") + every { results.`as`(any>()) } returns listOf(person, null) + + assertThat(results.asType()).containsExactly(person, null) + + verify { results.`as`(any>()) } + } + + @Test // GH-3390 + fun `asTypeOrNull returns the decoded value when the result is not null`() { + + val result = mockk() + val person = Person("Rand al'Thor") + every { result.isNull } returns false + every { result.`as`(any>()) } returns person + + assertThat(result.asTypeOrNull()).isEqualTo(person) + + verify { result.`as`(any>()) } + } + + @Test // GH-3390 + fun `asTypeOrNull returns null without decoding when the result is null`() { + + val result = mockk() + every { result.isNull } returns true + + assertThat(result.asTypeOrNull()).isNull() + + verify(exactly = 0) { result.`as`(any>()) } + } +} From d97bb4e278370e4957801f930a6878d17414a006 Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Wed, 8 Jul 2026 16:19:10 +0300 Subject: [PATCH 5/8] Refactored RedisJsonTemplate.java. Signed-off-by: Yordan Tsintsov --- .../connection/jedis/JedisConverters.java | 11 +- .../data/redis/connection/json/JsonPath.java | 2 +- .../redis/core/DefaultJsonOperations.java | 502 ----------------- .../data/redis/core/JsonOperations.java | 12 +- .../data/redis/core/RedisJsonOperations.java | 25 + .../data/redis/core/RedisJsonTemplate.java | 531 +++++++++++++++--- .../GenericJacksonJsonRedisSerializer.java | 41 +- .../JacksonRedisJsonSerializer.java | 76 --- .../redis/serializer/RedisJsonSerializer.java | 8 +- .../AbstractConnectionIntegrationTests.java | 34 +- .../core/DefaultJsonResultUnitTests.java | 8 +- .../core/DefaultJsonResultsUnitTests.java | 8 +- ...=> RedisJsonTemplateIntegrationTests.java} | 120 ++-- .../core/RedisJsonTemplateUnitTests.java | 112 ++++ ...icJacksonJsonRedisSerializerUnitTests.java | 76 +++ .../JacksonRedisJsonSerializerUnitTests.java | 111 ---- 16 files changed, 806 insertions(+), 871 deletions(-) delete mode 100644 src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java create mode 100644 src/main/java/org/springframework/data/redis/core/RedisJsonOperations.java delete mode 100644 src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java rename src/test/java/org/springframework/data/redis/core/{DefaultJsonOperationsIntegrationTests.java => RedisJsonTemplateIntegrationTests.java} (62%) create mode 100644 src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java delete mode 100644 src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java index 3c520139eb..851f695dd5 100644 --- a/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisConverters.java @@ -877,12 +877,13 @@ public static JsonSetParams toJsonSetParams(JsonSetCondition condition) { } public static RedisJsonCommands.JsonType fromJsonType(Class clazz) { - if (clazz == String.class) return RedisJsonCommands.JsonType.STRING; - if (clazz == int.class || clazz == float.class) return RedisJsonCommands.JsonType.NUMBER; - if (clazz == boolean.class) return RedisJsonCommands.JsonType.BOOLEAN; + if (clazz == null) return null; + if (clazz == boolean.class || clazz == Boolean.class) return RedisJsonCommands.JsonType.BOOLEAN; + if (clazz == int.class || clazz == float.class || Number.class.isAssignableFrom(clazz)) return RedisJsonCommands.JsonType.NUMBER; + if (CharSequence.class.isAssignableFrom(clazz)) return RedisJsonCommands.JsonType.STRING; if (clazz == Object.class) return RedisJsonCommands.JsonType.OBJECT; - if (clazz == List.class) return RedisJsonCommands.JsonType.ARRAY; - return null; + if (Collection.class.isAssignableFrom(clazz)) return RedisJsonCommands.JsonType.ARRAY; + throw new IllegalArgumentException("Cannot convert %s to RedisJsonCommands.JsonType".formatted(clazz)); } /** diff --git a/src/main/java/org/springframework/data/redis/connection/json/JsonPath.java b/src/main/java/org/springframework/data/redis/connection/json/JsonPath.java index aae5e62393..7c10118753 100644 --- a/src/main/java/org/springframework/data/redis/connection/json/JsonPath.java +++ b/src/main/java/org/springframework/data/redis/connection/json/JsonPath.java @@ -38,7 +38,7 @@ static JsonPath root() { * @param path the JSON path. Must not be {@literal null}. * @return {@link JsonPath} representing JSON path. */ - static JsonPath of(String path) { + static JsonPath raw(String path) { return new DefaultJsonPath(path); } diff --git a/src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java b/src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java deleted file mode 100644 index ad1e231867..0000000000 --- a/src/main/java/org/springframework/data/redis/core/DefaultJsonOperations.java +++ /dev/null @@ -1,502 +0,0 @@ -/* - * Copyright 2026-present 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.core; - -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Collection; -import java.util.Iterator; -import java.util.List; -import java.util.function.Consumer; -import java.util.function.Function; - -import org.jspecify.annotations.Nullable; - -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.data.redis.connection.JsonSetCondition; -import org.springframework.data.redis.connection.RedisJsonCommands; -import org.springframework.data.redis.connection.json.JsonPath; -import org.springframework.data.redis.connection.json.JsonValue; -import org.springframework.data.redis.serializer.RedisJsonSerializer; -import org.springframework.data.redis.serializer.RedisSerializer; -import org.springframework.util.Assert; - -/** - * Default implementation of {@link JsonOperations}. - * - * @author Yordan Tsintsov - * @since 4.2 - */ -class DefaultJsonOperations implements JsonOperations { - - private final RedisJsonTemplate template; - - DefaultJsonOperations(RedisJsonTemplate template) { - this.template = template; - } - - @Override - public JsonArraySpec array(K key) { - - byte[] rawKey = rawKey(key); - - return new DefaultJsonArraySpec(template, jsonSerializer(), rawKey); - } - - @Override - public JsonBooleanSpec bool(K key) { - - byte[] rawKey = rawKey(key); - - return new DefaultJsonBooleanSpec(template, jsonSerializer(), rawKey); - } - - @Override - public JsonStringSpec string(K key) { - - byte[] rawKey = rawKey(key); - - return new DefaultJsonStringSpec(template, jsonSerializer(), rawKey); - } - - @Override - public JsonAtKeySpec key(K key) { - - byte[] rawKey = rawKey(key); - - return new DefaultJsonAtKeySpec(template, jsonSerializer(), rawKey); - } - - @Override - public JsonResult paths(K key, String... paths) { - - Assert.notEmpty(paths, "Paths must not be empty"); - - byte[] rawKey = rawKey(key); - RedisJsonSerializer serializer = jsonSerializer(); - - JsonPath[] jsonPaths = new JsonPath[paths.length]; - for (int i = 0; i < paths.length; i++) { - jsonPaths[i] = JsonPath.of(paths[i]); - } - - String response = template.execute(c -> c.jsonCommands().jsonGet(rawKey, jsonPaths)); - - return new DefaultJsonResult(serializer, response); - } - - @Override - public JsonAtKeysSpec values(Collection keys) { - - Assert.notEmpty(keys, "Keys must not be empty"); - - byte[][] rawKeys = rawKeys(keys); - - return new DefaultJsonMultiGetSpec(template, jsonSerializer(), rawKeys); - } - - private RedisJsonSerializer jsonSerializer() { - RedisJsonSerializer serializer = template.getJsonSerializer(); - Assert.notNull(serializer, "JsonSerializer is not configured"); - return serializer; - } - - private @Nullable RedisSerializer keySerializer() { - return template.getKeySerializer(); - } - - private RedisSerializer requiredKeySerializer() { - - RedisSerializer serializer = keySerializer(); - - if (serializer == null) { - throw new IllegalStateException("No key serializer configured"); - } - - return serializer; - } - - @SuppressWarnings("unchecked") - private byte[] rawKey(Object key) { - - Assert.notNull(key, "non null key required"); - - if (key instanceof byte[] bytes) { - return bytes; - } - - return requiredKeySerializer().serialize((K) key); - } - - private byte[][] rawKeys(Collection keys) { - final byte[][] rawKeys = new byte[keys.size()][]; - - int i = 0; - for (K key : keys) { - rawKeys[i++] = rawKey(key); - } - - return rawKeys; - } - - static class DefaultPathSpec

> implements PathSpec

{ - - String jsonPath = JsonPath.root().asString(); - - @Override - @SuppressWarnings("unchecked") - public P root() { - this.jsonPath = JsonPath.root().asString(); - return (P) this; - } - - @Override - @SuppressWarnings("unchecked") - public P path(String jsonPath) { - Assert.hasText(jsonPath, "JsonPath must not be empty"); - this.jsonPath = jsonPath; - return (P) this; - } - - } - - abstract static class DefaultJsonSpec & JsonSetSupport> - extends DefaultPathSpec implements JsonKeySupport, JsonSetSupport { - - final RedisJsonTemplate template; - final RedisJsonSerializer serializer; - final byte[] key; - private JsonSetCondition condition = JsonSetCondition.upsert(); - - DefaultJsonSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { - this.template = template; - this.serializer = serializer; - this.key = key; - } - - // --- JsonKeySupport --- - - @Override - public @Nullable Long clear() { - return template.execute(c -> c.jsonCommands().jsonClear(key, JsonPath.of(jsonPath))); - } - - @Override - public @Nullable Long delete() { - return template.execute(c -> c.jsonCommands().jsonDel(key, JsonPath.of(jsonPath))); - } - - @Override - public JsonResult get() { - String result = template.execute(c -> c.jsonCommands().jsonGet(key, JsonPath.of(jsonPath))); - return new DefaultJsonResult(serializer, result); - } - - // --- JsonSetSupport --- - - @Override - @SuppressWarnings("unchecked") - public S conditional(Consumer consumer) { - - Assert.notNull(consumer, "Consumer must not be null"); - - DefaultJsonSetSpec spec = new DefaultJsonSetSpec(); - consumer.accept(spec); - this.condition = spec.condition(); - - return (S) this; - } - - @Override - public @Nullable Boolean set(T value) { - JsonValue jsonValue = JsonValue.raw(serializer.serialize(value)); - return template.execute(c -> c.jsonCommands().jsonSet(key, JsonPath.of(jsonPath), jsonValue, condition)); - } - - } - - static class DefaultJsonArraySpec extends DefaultPathSpec implements JsonArraySpec { - - private final RedisJsonTemplate template; - private final RedisJsonSerializer jsonSerializer; - private final byte[] key; - - DefaultJsonArraySpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { - this.template = template; - this.jsonSerializer = serializer; - this.key = key; - } - - @Override - public @Nullable List<@Nullable Long> append(Object... values) { - JsonValue[] jsonValues = Arrays.stream(values).map(it -> JsonValue.raw(jsonSerializer.serialize(it))).toArray(JsonValue[]::new); - return template.execute(c -> c.jsonCommands().jsonArrAppend(key, JsonPath.of(jsonPath), jsonValues)); - } - - @Override - public @Nullable List<@Nullable Long> length() { - return template.execute(c -> c.jsonCommands().jsonArrLen(key, JsonPath.of(jsonPath))); - } - - @Override - public @Nullable List<@Nullable Long> trim(int start, int end) { - return template.execute(c -> c.jsonCommands().jsonArrTrim(key, JsonPath.of(jsonPath), start, end)); - } - - @Override - public @Nullable List indexOf(Object value) { - JsonValue jsonValue = JsonValue.raw(jsonSerializer.serialize(value)); - return template.execute(c -> c.jsonCommands().jsonArrIndex(key, JsonPath.of(jsonPath), jsonValue)); - } - - @Override - public JsonArrayAtIndex index(int index) { - return new DefaultJsonArrayAtIndex(template, jsonSerializer, key, jsonPath, index); - } - - } - - static class DefaultJsonArrayAtIndex implements JsonArrayAtIndex { - - private final RedisJsonTemplate template; - private final RedisJsonSerializer serializer; - private final byte[] key; - private final String jsonPath; - private final int index; - - DefaultJsonArrayAtIndex(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key, String jsonPath, int index) { - this.template = template; - this.serializer = serializer; - this.key = key; - this.jsonPath = jsonPath; - this.index = index; - } - - @Override - public @Nullable List<@Nullable Long> insert(Object... values) { - JsonValue[] jsonValues = Arrays.stream(values).map(it -> JsonValue.raw(serializer.serialize(it))).toArray(JsonValue[]::new); - return template.execute(c -> c.jsonCommands().jsonArrInsert(key, JsonPath.of(jsonPath), index, jsonValues)); - } - - } - - static class DefaultJsonBooleanSpec extends DefaultJsonSpec implements JsonBooleanSpec { - - DefaultJsonBooleanSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { - super(template, serializer, key); - } - - @Override - public @Nullable List<@Nullable Boolean> toggle() { - return template.execute(c -> c.jsonCommands().jsonToggle(key, JsonPath.of(jsonPath))); - } - - } - - static class DefaultJsonStringSpec extends DefaultJsonSpec implements JsonStringSpec { - - DefaultJsonStringSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { - super(template, serializer, key); - } - - @Override - public @Nullable List<@Nullable Long> length() { - return template.execute(c -> c.jsonCommands().jsonStrLen(key, JsonPath.of(jsonPath))); - } - - @Override - public @Nullable List<@Nullable Long> append(String value) { - return template.execute(c -> c.jsonCommands().jsonStrAppend(key, JsonPath.of(jsonPath), value)); - } - - } - - static class DefaultJsonAtKeySpec extends DefaultJsonSpec implements JsonAtKeySpec { - - DefaultJsonAtKeySpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[] key) { - super(template, serializer, key); - } - - @Override - public @Nullable Boolean mergeWith(Object value) { - JsonValue jsonValue = JsonValue.raw(serializer.serialize(value)); - return template.execute(c -> c.jsonCommands().jsonMerge(key, JsonPath.of(jsonPath), jsonValue)); - } - - @Override - public @Nullable List getType() { - return template.execute(c -> c.jsonCommands().jsonType(key, JsonPath.of(jsonPath))); - } - - } - - static class DefaultJsonMultiGetSpec extends DefaultPathSpec implements JsonAtKeysSpec { - - private final RedisJsonTemplate template; - private final RedisJsonSerializer serializer; - private final byte[][] keys; - - DefaultJsonMultiGetSpec(RedisJsonTemplate template, RedisJsonSerializer serializer, byte[][] keys) { - this.template = template; - this.serializer = serializer; - this.keys = keys; - } - - @Override - public JsonResults get() { - - List response = template.execute(c -> c.jsonCommands().jsonMGet(JsonPath.of(jsonPath), keys)); - List result = response == null ? null - : response.stream().map(it -> (JsonResult) new DefaultJsonResult(serializer, it)).toList(); - - return new DefaultJsonResults(result); - } - - } - - static class DefaultJsonSetSpec implements JsonSetSpec { - - private JsonSetCondition condition = JsonSetCondition.upsert(); - - @Override - public JsonSetSpec always() { - this.condition = JsonSetCondition.upsert(); - return this; - } - - @Override - public JsonSetSpec ifAbsent() { - this.condition = JsonSetCondition.ifPathNotExists(); - return this; - } - - @Override - public JsonSetSpec ifPresent() { - this.condition = JsonSetCondition.ifPathExists(); - return this; - } - - public JsonSetCondition condition() { - return condition; - } - - } - - static class DefaultJsonResult implements JsonResult { - - private final RedisJsonSerializer serializer; - private final @Nullable String result; - - DefaultJsonResult(RedisJsonSerializer serializer, @Nullable String result) { - this.serializer = serializer; - this.result = result; - } - - @Override - public V as(Class type) { - Assert.notNull(result, "Result must not be null"); - Assert.notNull(type, "Type must not be null"); - return serializer.deserialize(result, type); - } - - @Override - public V as(ParameterizedTypeReference type) { - Assert.notNull(result, "Result must not be null"); - Assert.notNull(type, "Type must not be null"); - return serializer.deserialize(result, type); - } - - @Override - public String asString() { - Assert.notNull(result, "Result must not be null"); - return result; - } - - @Override - public U map(Function mapper) { - Assert.notNull(mapper, "Mapper must not be null"); - return mapper.apply(asBytes()); - } - - @Override - public byte[] asBytes() { - Assert.notNull(result, "Result must not be null"); - return result.getBytes(StandardCharsets.UTF_8); - } - - @Override - public boolean isNull() { - if ("null".equals(result)) { - return true; - } - return result == null; - } - - @Override - public @Nullable String toString() { - return result; - } - - } - - static class DefaultJsonResults implements JsonResults { - - private final @Nullable Collection result; - - DefaultJsonResults(@Nullable Collection result) { - this.result = result; - } - - @Override - public List<@Nullable V> as(Class type) { - Assert.notNull(result, "Result must not be null"); - Assert.notNull(type, "Type must not be null"); - return result.stream().map(it -> it.isNull() ? null : it.as(type)).toList(); - } - - @Override - public List<@Nullable V> as(ParameterizedTypeReference type) { - Assert.notNull(result, "Result must not be null"); - Assert.notNull(type, "Type must not be null"); - return result.stream().map(it -> it.isNull() ? null : it.as(type)).toList(); - } - - @Override - public Iterator iterator() { - Assert.notNull(result, "Result must not be null"); - return result.iterator(); - } - - @Override - public List<@Nullable String> asString() { - Assert.notNull(result, "Result must not be null"); - return result.stream().map(it -> it.isNull() ? null : it.asString()).toList(); - } - - @Override - public List asBytes() { - Assert.notNull(result, "Result must not be null"); - return result.stream().map(it -> it.isNull() ? null : it.asBytes()).toList(); - } - - @Override - public boolean isNull() { - return result == null || result.isEmpty(); - } - - } - -} diff --git a/src/main/java/org/springframework/data/redis/core/JsonOperations.java b/src/main/java/org/springframework/data/redis/core/JsonOperations.java index 4dac78c134..a4df9f669e 100644 --- a/src/main/java/org/springframework/data/redis/core/JsonOperations.java +++ b/src/main/java/org/springframework/data/redis/core/JsonOperations.java @@ -34,10 +34,10 @@ * the command result, for example: * *

- * operations.key("key").set("value");
- * operations.key("key").path("$..name").setIfAbsent("Doe");
+ * operations.value("key").set("value");
+ * operations.value("key").path("$..name").setIfAbsent("Doe");
  * operations.array("key").path("$.names").index(2).insert("John")
- * Person person = operations.key("key").get().as(Person.class);
+ * Person person = operations.value("key").get().as(Person.class);
  * 
*

* JSON path expressions follow the @@ -83,7 +83,7 @@ public interface JsonOperations { * @param key must not be {@literal null}. * @return a spec for specifying the value operation. */ - JsonAtKeySpec key(K key); + JsonAtKeySpec value(K key); /** * Retrieve the JSON value for the given {@code key}. @@ -92,7 +92,7 @@ public interface JsonOperations { * @return the JSON value for the given key. */ default JsonResult get(K key) { - return key(key).get(); + return value(key).get(); } /** @@ -103,7 +103,7 @@ default JsonResult get(K key) { * @return {@literal true} if the value was written; {@literal false} otherwise. */ default @Nullable Boolean set(K key, Object value) { - return key(key).set(value); + return value(key).set(value); } /** diff --git a/src/main/java/org/springframework/data/redis/core/RedisJsonOperations.java b/src/main/java/org/springframework/data/redis/core/RedisJsonOperations.java new file mode 100644 index 0000000000..41780113f3 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/RedisJsonOperations.java @@ -0,0 +1,25 @@ +/* + * Copyright 2026-present 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.core; + +/** + * + * + * @author Yordan Tsintsov + * @since 4.2 + */ +public interface RedisJsonOperations extends JsonOperations { +} diff --git a/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java index 64b324fdd6..3a2af53373 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java @@ -15,13 +15,24 @@ */ package org.springframework.data.redis.core; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collection; +import java.util.Iterator; +import java.util.List; +import java.util.function.Consumer; +import java.util.function.Function; + import org.jspecify.annotations.Nullable; -import org.springframework.beans.factory.BeanClassLoaderAware; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.connection.JsonSetCondition; import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.serializer.JacksonRedisJsonSerializer; -import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; +import org.springframework.data.redis.connection.RedisJsonCommands; +import org.springframework.data.redis.connection.json.JsonPath; +import org.springframework.data.redis.connection.json.JsonValue; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.data.redis.serializer.RedisJsonSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.util.Assert; @@ -29,65 +40,37 @@ /** * Helper class that simplifies Redis JSON data access. - *

- * Keys are serialized with {@link JdkSerializationRedisSerializer} by default, which produces binary (non human-readable) - * key representations. Configure a key serializer through {@link #setKeySerializer(RedisSerializer)}. * * @author Yordan Tsintsov * @since 4.2 */ -public class RedisJsonTemplate extends RedisAccessor implements BeanClassLoaderAware { - - private boolean enableTransactionSupport = false; - private boolean initialized = false; - private @Nullable ClassLoader classLoader; +public class RedisJsonTemplate extends RedisAccessor implements RedisJsonOperations { - private @Nullable RedisSerializer keySerializer; - private @Nullable RedisJsonSerializer jsonSerializer; + private final RedisSerializer keySerializer; + private final RedisJsonSerializer jsonSerializer; - private final JsonOperations jsonOperations = new DefaultJsonOperations<>(this); + public RedisJsonTemplate(RedisConnectionFactory connectionFactory) { - @Override - @SuppressWarnings("unchecked") - public void afterPropertiesSet() { - - super.afterPropertiesSet(); - - if (keySerializer == null) { - keySerializer = (RedisSerializer) new JdkSerializationRedisSerializer(classLoader != null ? classLoader : this.getClass().getClassLoader()); - } + Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); - if (jsonSerializer == null && ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", classLoader)) { - jsonSerializer = JacksonRedisJsonSerializer.createDefault(); + setConnectionFactory(connectionFactory); + keySerializer = (RedisSerializer) RedisSerializer.string(); + if (ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", RedisJsonTemplate.class.getClassLoader())) { + jsonSerializer = GenericJacksonJsonRedisSerializer.builder().build(); + } else { + throw new IllegalStateException("No default RedisJsonSerializer available. Add Jackson 3 (tools.jackson) to the classpath, or use the constructor that accepts a RedisJsonSerializer"); } - - initialized = true; } - /** - * Returns whether this template participates in ongoing transactions. - * - * @return {@literal true} if transaction support is enabled; {@literal false} otherwise. - */ - public boolean isEnableTransactionSupport() { - return enableTransactionSupport; - } + public RedisJsonTemplate(RedisConnectionFactory connectionFactory, RedisSerializer keySerializer, RedisJsonSerializer jsonSerializer) { - /** - * Sets whether this template participates in ongoing transactions using {@literal MULTI...EXEC|DISCARD} to keep track - * of operations. - * - * @param enableTransactionSupport {@literal true} to participate in ongoing transactions; {@literal false} to not - * track transactions. - * @since 4.2 - */ - public void setEnableTransactionSupport(boolean enableTransactionSupport) { - this.enableTransactionSupport = enableTransactionSupport; - } + Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); + Assert.notNull(keySerializer, "KeySerializer must not be null"); + Assert.notNull(jsonSerializer, "JsonSerializer must not be null"); - @Override - public void setBeanClassLoader(ClassLoader classLoader) { - this.classLoader = classLoader; + setConnectionFactory(connectionFactory); + this.keySerializer = keySerializer; + this.jsonSerializer = jsonSerializer; } /** @@ -95,37 +78,19 @@ public void setBeanClassLoader(ClassLoader classLoader) { * * @return the key serializer used by this template. */ - public @Nullable RedisSerializer getKeySerializer() { + public RedisSerializer getKeySerializer() { return keySerializer; } - /** - * Sets the key serializer to be used by this template. - * - * @param keySerializer the key serializer to be used by this template. - */ - public void setKeySerializer(@Nullable RedisSerializer keySerializer) { - this.keySerializer = keySerializer; - } - /** * Returns the JSON serializer used by this template. * * @return the JSON serializer used by this template */ - public @Nullable RedisJsonSerializer getJsonSerializer() { + public RedisJsonSerializer getJsonSerializer() { return jsonSerializer; } - /** - * Sets the JSON serializer to be used by this template. - * - * @param jsonSerializer the JSON serializer to be used by this template. - */ - public void setJsonSerializer(@Nullable RedisJsonSerializer jsonSerializer) { - this.jsonSerializer = jsonSerializer; - } - /** * Executes the given action within a {@link RedisConnection} obtained from the configured * {@link RedisConnectionFactory}, releasing the connection once the action completes. @@ -135,13 +100,12 @@ public void setJsonSerializer(@Nullable RedisJsonSerializer jsonSerializer) { * @return object returned by the action. * @since 4.2 */ - T execute(RedisCallback action) { + private T execute(RedisCallback action) { - Assert.isTrue(initialized, "template not initialized; call afterPropertiesSet() before using it"); Assert.notNull(action, "Callback object must not be null"); RedisConnectionFactory factory = getRequiredConnectionFactory(); - RedisConnection connection = RedisConnectionUtils.getConnection(factory, enableTransactionSupport); + RedisConnection connection = RedisConnectionUtils.getConnection(factory); try { return action.doInRedis(connection); @@ -150,13 +114,422 @@ public void setJsonSerializer(@Nullable RedisJsonSerializer jsonSerializer) { } } - /** - * Returns the operations performed on JSON values. - * - * @return JSON operations - */ - public JsonOperations opsForJson() { - return jsonOperations; + @Override + public JsonArraySpec array(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonArraySpec(this, rawKey); + } + + @Override + public JsonBooleanSpec bool(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonBooleanSpec(this, rawKey); + } + + @Override + public JsonStringSpec string(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonStringSpec(this, rawKey); + } + + @Override + public JsonAtKeySpec value(K key) { + + byte[] rawKey = rawKey(key); + + return new DefaultJsonAtKeySpec(this, rawKey); + } + + @Override + public JsonResult paths(K key, String... paths) { + + Assert.notEmpty(paths, "Paths must not be empty"); + + byte[] rawKey = rawKey(key); + + JsonPath[] jsonPaths = new JsonPath[paths.length]; + for (int i = 0; i < paths.length; i++) { + jsonPaths[i] = JsonPath.raw(paths[i]); + } + + String response = this.execute(c -> c.jsonCommands().jsonGet(rawKey, jsonPaths)); + + return new DefaultJsonResult(this.jsonSerializer, response); + } + + @Override + public JsonAtKeysSpec values(Collection keys) { + + Assert.notEmpty(keys, "Keys must not be empty"); + + byte[][] rawKeys = rawKeys(keys); + + return new DefaultJsonMultiGetSpec(this, rawKeys); + } + + static class DefaultPathSpec

> implements PathSpec

{ + + String jsonPath = JsonPath.root().asString(); + + @Override + @SuppressWarnings("unchecked") + public P root() { + this.jsonPath = JsonPath.root().asString(); + return (P) this; + } + + @Override + @SuppressWarnings("unchecked") + public P path(String jsonPath) { + Assert.hasText(jsonPath, "JsonPath must not be empty"); + this.jsonPath = jsonPath; + return (P) this; + } + + } + + @SuppressWarnings("unchecked") + private byte[] rawKey(Object key) { + + Assert.notNull(key, "non null key required"); + + if (key instanceof byte[] bytes) { + return bytes; + } + + return keySerializer.serialize((K) key); + } + + private byte[][] rawKeys(Collection keys) { + final byte[][] rawKeys = new byte[keys.size()][]; + + int i = 0; + for (K key : keys) { + rawKeys[i++] = rawKey(key); + } + + return rawKeys; + } + + abstract static class DefaultJsonSpec & JsonSetSupport> + extends DefaultPathSpec implements JsonKeySupport, JsonSetSupport { + + final RedisJsonTemplate template; + final byte[] key; + private JsonSetCondition condition = JsonSetCondition.upsert(); + + DefaultJsonSpec(RedisJsonTemplate template, byte[] key) { + this.template = template; + this.key = key; + } + + // --- JsonKeySupport --- + + @Override + public @Nullable Long clear() { + return template.execute(c -> c.jsonCommands().jsonClear(key, JsonPath.raw(jsonPath))); + } + + @Override + public @Nullable Long delete() { + return template.execute(c -> c.jsonCommands().jsonDel(key, JsonPath.raw(jsonPath))); + } + + @Override + public JsonResult get() { + String result = template.execute(c -> c.jsonCommands().jsonGet(key, JsonPath.raw(jsonPath))); + return new DefaultJsonResult(template.jsonSerializer, result); + } + + // --- JsonSetSupport --- + + @Override + @SuppressWarnings("unchecked") + public S conditional(Consumer consumer) { + + Assert.notNull(consumer, "Consumer must not be null"); + + DefaultJsonSetSpec spec = new DefaultJsonSetSpec(); + consumer.accept(spec); + this.condition = spec.condition(); + + return (S) this; + } + + @Override + public @Nullable Boolean set(T value) { + JsonValue jsonValue = JsonValue.raw(template.jsonSerializer.serializeAsString(value)); + return template.execute(c -> c.jsonCommands().jsonSet(key, JsonPath.raw(jsonPath), jsonValue, condition)); + } + + } + + static class DefaultJsonArraySpec extends DefaultPathSpec implements JsonArraySpec { + + private final RedisJsonTemplate template; + private final byte[] key; + + DefaultJsonArraySpec(RedisJsonTemplate template, byte[] key) { + this.template = template; + this.key = key; + } + + @Override + public @Nullable List<@Nullable Long> append(Object... values) { + JsonValue[] jsonValues = Arrays.stream(values).map(it -> JsonValue.raw(template.jsonSerializer.serializeAsString(it))).toArray(JsonValue[]::new); + return template.execute(c -> c.jsonCommands().jsonArrAppend(key, JsonPath.raw(jsonPath), jsonValues)); + } + + @Override + public @Nullable List<@Nullable Long> length() { + return template.execute(c -> c.jsonCommands().jsonArrLen(key, JsonPath.raw(jsonPath))); + } + + @Override + public @Nullable List<@Nullable Long> trim(int start, int end) { + return template.execute(c -> c.jsonCommands().jsonArrTrim(key, JsonPath.raw(jsonPath), start, end)); + } + + @Override + public @Nullable List indexOf(Object value) { + JsonValue jsonValue = JsonValue.raw(template.jsonSerializer.serializeAsString(value)); + return template.execute(c -> c.jsonCommands().jsonArrIndex(key, JsonPath.raw(jsonPath), jsonValue)); + } + + @Override + public JsonArrayAtIndex index(int index) { + return new DefaultJsonArrayAtIndex(template, key, jsonPath, index); + } + + } + + static class DefaultJsonArrayAtIndex implements JsonArrayAtIndex { + + private final RedisJsonTemplate template; + private final byte[] key; + private final String jsonPath; + private final int index; + + DefaultJsonArrayAtIndex(RedisJsonTemplate template, byte[] key, String jsonPath, int index) { + this.template = template; + this.key = key; + this.jsonPath = jsonPath; + this.index = index; + } + + @Override + public @Nullable List<@Nullable Long> insert(Object... values) { + JsonValue[] jsonValues = Arrays.stream(values).map(it -> JsonValue.raw(template.jsonSerializer.serializeAsString(it))).toArray(JsonValue[]::new); + return template.execute(c -> c.jsonCommands().jsonArrInsert(key, JsonPath.raw(jsonPath), index, jsonValues)); + } + + } + + static class DefaultJsonBooleanSpec extends DefaultJsonSpec implements JsonBooleanSpec { + + DefaultJsonBooleanSpec(RedisJsonTemplate template, byte[] key) { + super(template, key); + } + + @Override + public @Nullable List<@Nullable Boolean> toggle() { + return template.execute(c -> c.jsonCommands().jsonToggle(key, JsonPath.raw(jsonPath))); + } + + } + + static class DefaultJsonStringSpec extends DefaultJsonSpec implements JsonStringSpec { + + DefaultJsonStringSpec(RedisJsonTemplate template, byte[] key) { + super(template, key); + } + + @Override + public @Nullable List<@Nullable Long> length() { + return template.execute(c -> c.jsonCommands().jsonStrLen(key, JsonPath.raw(jsonPath))); + } + + @Override + public @Nullable List<@Nullable Long> append(String value) { + return template.execute(c -> c.jsonCommands().jsonStrAppend(key, JsonPath.raw(jsonPath), value)); + } + + } + + static class DefaultJsonAtKeySpec extends DefaultJsonSpec implements JsonAtKeySpec { + + DefaultJsonAtKeySpec(RedisJsonTemplate template, byte[] key) { + super(template, key); + } + + @Override + public @Nullable Boolean mergeWith(Object value) { + JsonValue jsonValue = JsonValue.raw(template.jsonSerializer.serializeAsString(value)); + return template.execute(c -> c.jsonCommands().jsonMerge(key, JsonPath.raw(jsonPath), jsonValue)); + } + + @Override + public @Nullable List getType() { + return template.execute(c -> c.jsonCommands().jsonType(key, JsonPath.raw(jsonPath))); + } + + } + + static class DefaultJsonMultiGetSpec extends DefaultPathSpec implements JsonAtKeysSpec { + + private final RedisJsonTemplate template; + private final byte[][] keys; + + DefaultJsonMultiGetSpec(RedisJsonTemplate template, byte[][] keys) { + this.template = template; + this.keys = keys; + } + + @Override + public JsonResults get() { + + List response = template.execute(c -> c.jsonCommands().jsonMGet(JsonPath.raw(jsonPath), keys)); + List result = response == null ? null + : response.stream().map(it -> (JsonResult) new DefaultJsonResult(template.jsonSerializer, it)).toList(); + + return new DefaultJsonResults(result); + } + + } + + static class DefaultJsonSetSpec implements JsonSetSpec { + + private JsonSetCondition condition = JsonSetCondition.upsert(); + + @Override + public JsonSetSpec always() { + this.condition = JsonSetCondition.upsert(); + return this; + } + + @Override + public JsonSetSpec ifAbsent() { + this.condition = JsonSetCondition.ifPathNotExists(); + return this; + } + + @Override + public JsonSetSpec ifPresent() { + this.condition = JsonSetCondition.ifPathExists(); + return this; + } + + public JsonSetCondition condition() { + return condition; + } + + } + + static class DefaultJsonResult implements JsonResult { + + private final RedisJsonSerializer serializer; + private final @Nullable String result; + + DefaultJsonResult(RedisJsonSerializer serializer, @Nullable String result) { + this.serializer = serializer; + this.result = result; + } + + @Override + public V as(Class type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return serializer.deserializeFromString(result, type); + } + + @Override + public V as(ParameterizedTypeReference type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return serializer.deserializeFromString(result, type); + } + + @Override + public String asString() { + Assert.notNull(result, "Result must not be null"); + return result; + } + + @Override + public U map(Function mapper) { + Assert.notNull(mapper, "Mapper must not be null"); + return mapper.apply(asBytes()); + } + + @Override + public byte[] asBytes() { + Assert.notNull(result, "Result must not be null"); + return result.getBytes(StandardCharsets.UTF_8); + } + + @Override + public boolean isNull() { + return result == null | "null".equals(result); + } + + @Override + public @Nullable String toString() { + return result; + } + + } + + static class DefaultJsonResults implements JsonResults { + + private final @Nullable Collection result; + + DefaultJsonResults(@Nullable Collection result) { + this.result = result; + } + + @Override + public List<@Nullable V> as(Class type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.as(type)).toList(); + } + + @Override + public List<@Nullable V> as(ParameterizedTypeReference type) { + Assert.notNull(result, "Result must not be null"); + Assert.notNull(type, "Type must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.as(type)).toList(); + } + + @Override + public Iterator iterator() { + Assert.notNull(result, "Result must not be null"); + return result.iterator(); + } + + @Override + public List<@Nullable String> asString() { + Assert.notNull(result, "Result must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.asString()).toList(); + } + + @Override + public List asBytes() { + Assert.notNull(result, "Result must not be null"); + return result.stream().map(it -> it.isNull() ? null : it.asBytes()).toList(); + } + + @Override + public boolean isNull() { + return result == null || result.isEmpty(); + } + } } diff --git a/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java b/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java index e486a3f7a5..4999ebd631 100644 --- a/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.java @@ -15,6 +15,7 @@ */ package org.springframework.data.redis.serializer; +import org.springframework.core.ParameterizedTypeReference; import tools.jackson.core.JacksonException; import tools.jackson.core.JsonGenerator; import tools.jackson.core.JsonParser; @@ -73,7 +74,7 @@ * @see ObjectMapper * @since 4.0 */ -public class GenericJacksonJsonRedisSerializer implements RedisSerializer { +public class GenericJacksonJsonRedisSerializer implements RedisJsonSerializer { private final JacksonObjectReader reader; @@ -83,6 +84,8 @@ public class GenericJacksonJsonRedisSerializer implements RedisSerializer plainMapper; + private final TypeResolver typeResolver; /** @@ -114,6 +117,7 @@ protected GenericJacksonJsonRedisSerializer(ObjectMapper mapper, JacksonObjectRe this.writer = writer; this.defaultTypingEnabled = Lazy.of(() -> mapper.serializationConfig().getDefaultTyper(null) != null); + this.plainMapper = Lazy.of(() -> this.defaultTypingEnabled.get() ? mapper.rebuild().deactivateDefaultTyping().build() : mapper); Lazy lazyTypeHintPropertyName = newLazyTypeHintPropertyName(mapper, this.defaultTypingEnabled); this.typeResolver = newTypeResolver(mapper, lazyTypeHintPropertyName); @@ -213,6 +217,41 @@ public byte[] serialize(@Nullable Object value) throws SerializationException { } } + @Override + public String serializeAsString(@Nullable Object value) throws SerializationException { + + if (value == null) { + return "null"; + } + + try { + return plainMapper.get().writeValueAsString(value); + } catch (JacksonException ex) { + throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex); + } + } + + @Override + public T deserializeFromString(String rawJson, Class type) throws SerializationException { + try { + return plainMapper.get().readValue(rawJson, type); + } catch (JacksonException ex) { + throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); + } + } + + @Override + public T deserializeFromString(String rawJson, ParameterizedTypeReference typeRef) throws SerializationException { + + ObjectMapper plain = plainMapper.get(); + + try { + return plain.readValue(rawJson, plain.getTypeFactory().constructType(typeRef.getType())); + } catch (JacksonException ex) { + throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); + } + } + protected JavaType resolveType(byte[] source, Class type) throws IOException { if (!type.equals(Object.class) || !defaultTypingEnabled.get()) { diff --git a/src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java b/src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java deleted file mode 100644 index 0f9675757c..0000000000 --- a/src/main/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializer.java +++ /dev/null @@ -1,76 +0,0 @@ -/* - * Copyright 2026-present 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 org.jspecify.annotations.Nullable; -import tools.jackson.core.JacksonException; -import tools.jackson.databind.json.JsonMapper; - -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.util.Assert; - -/** - * Implementation of {@link RedisJsonSerializer} with Jackson 3. - * - * @author Yordan Tsintsov - * @since 4.2 - */ -public class JacksonRedisJsonSerializer implements RedisJsonSerializer { - - private final JsonMapper mapper; - - public JacksonRedisJsonSerializer(JsonMapper mapper) { - Assert.notNull(mapper, "JsonMapper must not be null"); - this.mapper = mapper; - } - - public static JacksonRedisJsonSerializer createDefault() { - return new JacksonRedisJsonSerializer(JsonMapper.shared()); - } - - @Override - public String serialize(@Nullable Object value) throws SerializationException { - - if (value == null) { - return "null"; - } - - try { - return mapper.writeValueAsString(value); - } catch (JacksonException ex) { - throw new SerializationException("Could not write JSON: " + ex.getMessage(), ex); - } - } - - @Override - public T deserialize(String rawJson, Class type) throws SerializationException { - try { - return mapper.readValue(rawJson, type); - } catch (JacksonException ex) { - throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); - } - } - - @Override - public T deserialize(String rawJson, ParameterizedTypeReference typeRef) throws SerializationException { - try { - return mapper.readValue(rawJson, mapper.getTypeFactory().constructType(typeRef.getType())); - } catch (JacksonException ex) { - throw new SerializationException("Could not read JSON: " + ex.getMessage(), ex); - } - } - -} diff --git a/src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java b/src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java index fa9306ae9b..246f598358 100644 --- a/src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java +++ b/src/main/java/org/springframework/data/redis/serializer/RedisJsonSerializer.java @@ -28,7 +28,7 @@ * @author Yordan Tsintsov * @since 4.2 */ -public interface RedisJsonSerializer { +public interface RedisJsonSerializer extends RedisSerializer { /** * Serialize the given {@code value} to its JSON representation. @@ -37,7 +37,7 @@ public interface RedisJsonSerializer { * @return the JSON representation, never {@literal null}. * @throws SerializationException if the value cannot be serialized. */ - String serialize(@Nullable Object value) throws SerializationException; + String serializeAsString(@Nullable Object value) throws SerializationException; /** * Deserialize the given {@code rawJson} into an instance of {@code type}. @@ -48,7 +48,7 @@ public interface RedisJsonSerializer { * @return the deserialized object, or {@literal null} if {@code rawJson} represents JSON {@code null}. * @throws SerializationException if the JSON cannot be deserialized. */ - T deserialize(String rawJson, Class type) throws SerializationException; + T deserializeFromString(String rawJson, Class type) throws SerializationException; /** * Deserialize the given {@code rawJson} into an instance of the type described by {@code typeRef}. Use this variant @@ -60,6 +60,6 @@ public interface RedisJsonSerializer { * @return the deserialized object, or {@literal null} if {@code rawJson} represents JSON {@code null}. * @throws SerializationException if the JSON cannot be deserialized. */ - T deserialize(String rawJson, ParameterizedTypeReference typeRef) throws SerializationException; + T deserializeFromString(String rawJson, ParameterizedTypeReference typeRef) throws SerializationException; } diff --git a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java index 7ad1bbebf9..bfdb13175e 100644 --- a/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/connection/AbstractConnectionIntegrationTests.java @@ -5186,7 +5186,7 @@ void jsonArrAppend() { byte[] jsonKey = KEY_1.getBytes(); actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":[]}"))); - actual.add(connection.jsonCommands().jsonArrAppend(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"), + actual.add(connection.jsonCommands().jsonArrAppend(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".a"), JsonValue.of(1), JsonValue.of(2), JsonValue.of(3))); List result = getResults(); @@ -5203,7 +5203,7 @@ void jsonArrIndex() { actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("[1,2,3]"))); actual.add(connection.jsonCommands().jsonArrIndex(jsonKey, JsonPath.root(), JsonValue.of(2))); actual.add(connection.jsonCommands().jsonArrIndex(jsonKey, JsonPath.root(), JsonValue.of(4))); - actual.add(connection.jsonCommands().jsonArrIndex(jsonKey, JsonPath.of(JsonPath.root().asString() + ".NOT_EXIST"), JsonValue.of(1))); + actual.add(connection.jsonCommands().jsonArrIndex(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".NOT_EXIST"), JsonValue.of(1))); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5283,7 +5283,7 @@ void jsonDel() { actual.add(connection.jsonCommands().jsonSet(jsonKey1, JsonValue.raw("[1,2,3]"))); actual.add(connection.jsonCommands().jsonDel(jsonKey1)); actual.add(connection.jsonCommands().jsonSet(jsonKey2, JsonValue.raw("{\"a\":1}"))); - actual.add(connection.jsonCommands().jsonDel(jsonKey2, JsonPath.of(JsonPath.root().asString() + ".a"))); + actual.add(connection.jsonCommands().jsonDel(jsonKey2, JsonPath.raw(JsonPath.root().asString() + ".a"))); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5301,8 +5301,8 @@ void jsonGet() { actual.add(connection.jsonCommands().jsonSet(jsonKey, json)); actual.add(connection.jsonCommands().jsonGet(jsonKey)); - actual.add(connection.jsonCommands().jsonGet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); - actual.add(connection.jsonCommands().jsonGet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".b"))); + actual.add(connection.jsonCommands().jsonGet(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".a"))); + actual.add(connection.jsonCommands().jsonGet(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".b"))); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5339,7 +5339,7 @@ void jsonMGet() { actual.add(connection.jsonCommands().jsonSet(jsonKey1, json)); actual.add(connection.jsonCommands().jsonSet(jsonKey2, json)); actual.add(connection.jsonCommands().jsonMGet(jsonKey1, jsonKey2)); - actual.add(connection.jsonCommands().jsonMGet(JsonPath.of(JsonPath.root().asString() + ".a"), jsonKey1, jsonKey2)); + actual.add(connection.jsonCommands().jsonMGet(JsonPath.raw(JsonPath.root().asString() + ".a"), jsonKey1, jsonKey2)); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5355,8 +5355,8 @@ void jsonSet() { byte[] jsonKey = KEY_1.getBytes(); actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":1}"))); - actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"), JsonValue.of(2), JsonSetCondition.ifPathExists())); - actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonPath.of(JsonPath.root().asString() + ".b"), JsonValue.raw("{\"b\":3}"), JsonSetCondition.ifPathNotExists())); + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".a"), JsonValue.of(2), JsonSetCondition.ifPathExists())); + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".b"), JsonValue.raw("{\"b\":3}"), JsonSetCondition.ifPathNotExists())); actual.add(connection.jsonCommands().jsonGet(jsonKey)); List result = getResults(); @@ -5373,7 +5373,7 @@ void jsonStrAppend() { byte[] jsonKey = KEY_1.getBytes(); actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":\"foo\"}"))); - actual.add(connection.jsonCommands().jsonStrAppend(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"), "bar")); + actual.add(connection.jsonCommands().jsonStrAppend(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".a"), "bar")); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5387,7 +5387,7 @@ void jsonStrLen() { byte[] jsonKey = KEY_1.getBytes(); actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":\"foo\"}"))); - actual.add(connection.jsonCommands().jsonStrLen(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); + actual.add(connection.jsonCommands().jsonStrLen(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".a"))); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5401,7 +5401,7 @@ void jsonToggle() { byte[] jsonKey = KEY_1.getBytes(); actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":true}"))); - actual.add(connection.jsonCommands().jsonToggle(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); + actual.add(connection.jsonCommands().jsonToggle(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".a"))); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5414,12 +5414,13 @@ void jsonType() { byte[] jsonKey = KEY_1.getBytes(); - actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":\"foo\",\"b\":42,\"c\":true,\"d\":null}"))); + actual.add(connection.jsonCommands().jsonSet(jsonKey, JsonValue.raw("{\"a\":\"foo\",\"b\":42,\"c\":true,\"d\":null,\"e\":[1,2,3]}"))); actual.add(connection.jsonCommands().jsonType(jsonKey)); - actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".a"))); - actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".b"))); - actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".c"))); - actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.of(JsonPath.root().asString() + ".d"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".a"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".b"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".c"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".d"))); + actual.add(connection.jsonCommands().jsonType(jsonKey, JsonPath.raw(JsonPath.root().asString() + ".e"))); List result = getResults(); assertThat(result.get(0)).isEqualTo(true); @@ -5428,6 +5429,7 @@ void jsonType() { assertThat((List) result.get(3)).containsExactly(RedisJsonCommands.JsonType.NUMBER); assertThat((List) result.get(4)).containsExactly(RedisJsonCommands.JsonType.BOOLEAN); assertThat((List) result.get(5)).containsExactly((RedisJsonCommands.JsonType) null); + assertThat((List) result.get(6)).containsExactly(RedisJsonCommands.JsonType.ARRAY); } protected void verifyResults(List expected) { diff --git a/src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java index 803c35c067..a774794a1e 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java +++ b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultUnitTests.java @@ -25,8 +25,8 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.data.redis.Person; import org.springframework.data.redis.PersonObjectFactory; -import org.springframework.data.redis.core.DefaultJsonOperations.DefaultJsonResult; -import org.springframework.data.redis.serializer.JacksonRedisJsonSerializer; +import org.springframework.data.redis.core.RedisJsonTemplate.DefaultJsonResult; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.data.redis.serializer.RedisJsonSerializer; /** @@ -37,13 +37,13 @@ */ class DefaultJsonResultUnitTests { - private final RedisJsonSerializer serializer = JacksonRedisJsonSerializer.createDefault(); + private final RedisJsonSerializer serializer = GenericJacksonJsonRedisSerializer.builder().build(); @Test void testAsClassDeserializesPojo() { Person person = new PersonObjectFactory().instance(); - DefaultJsonResult result = new DefaultJsonResult(serializer, serializer.serialize(person)); + DefaultJsonResult result = new DefaultJsonResult(serializer, serializer.serializeAsString(person)); assertThat(result.as(Person.class)).isEqualTo(person); } diff --git a/src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java index 02f236f3fd..f2022177e8 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java +++ b/src/test/java/org/springframework/data/redis/core/DefaultJsonResultsUnitTests.java @@ -24,9 +24,9 @@ import org.junit.jupiter.api.Test; -import org.springframework.data.redis.core.DefaultJsonOperations.DefaultJsonResult; -import org.springframework.data.redis.core.DefaultJsonOperations.DefaultJsonResults; -import org.springframework.data.redis.serializer.JacksonRedisJsonSerializer; +import org.springframework.data.redis.core.RedisJsonTemplate.DefaultJsonResult; +import org.springframework.data.redis.core.RedisJsonTemplate.DefaultJsonResults; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.data.redis.serializer.RedisJsonSerializer; /** @@ -37,7 +37,7 @@ */ class DefaultJsonResultsUnitTests { - private final RedisJsonSerializer serializer = JacksonRedisJsonSerializer.createDefault(); + private final RedisJsonSerializer serializer = GenericJacksonJsonRedisSerializer.builder().build(); @Test void testAsClassDeserializesEachEntry() { diff --git a/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java b/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateIntegrationTests.java similarity index 62% rename from src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java rename to src/test/java/org/springframework/data/redis/core/RedisJsonTemplateIntegrationTests.java index 3c7064f327..3c8c1143fa 100644 --- a/src/test/java/org/springframework/data/redis/core/DefaultJsonOperationsIntegrationTests.java +++ b/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateIntegrationTests.java @@ -31,33 +31,32 @@ import org.springframework.core.ParameterizedTypeReference; import org.springframework.data.redis.ObjectFactory; import org.springframework.data.redis.StringObjectFactory; +import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisJsonCommands; import org.springframework.data.redis.connection.jedis.extension.JedisConnectionFactoryExtension; import org.springframework.data.redis.connection.lettuce.extension.LettuceConnectionFactoryExtension; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; import org.springframework.data.redis.test.condition.EnabledOnCommand; import org.springframework.data.redis.test.extension.RedisStandalone; /** - * Integration test of {@link DefaultJsonOperations}. + * Integration test of {@link RedisJsonTemplate}. * * @author Yordan Tsintsov * @since 4.2 */ @ParameterizedClass @MethodSource("testParams") -class DefaultJsonOperationsIntegrationTests { +class RedisJsonTemplateIntegrationTests { private final RedisJsonTemplate template; private final ObjectFactory keyFactory; - private final JsonOperations jsonOps; - - public DefaultJsonOperationsIntegrationTests(RedisJsonTemplate template, ObjectFactory keyFactory) { + public RedisJsonTemplateIntegrationTests(RedisJsonTemplate template, ObjectFactory keyFactory) { this.template = template; this.keyFactory = keyFactory; - this.jsonOps = template.opsForJson(); } static Collection testParams() { @@ -72,20 +71,17 @@ static Collection testParams(RedisConnectionFactory connectionFactory) ObjectFactory stringFactory = new StringObjectFactory(); - RedisJsonTemplate stringTemplate = new RedisJsonTemplate<>(); - stringTemplate.setKeySerializer(StringRedisSerializer.UTF_8); - stringTemplate.setConnectionFactory(connectionFactory); - stringTemplate.afterPropertiesSet(); + RedisJsonTemplate stringTemplate = new RedisJsonTemplate<>(connectionFactory, StringRedisSerializer.UTF_8, GenericJacksonJsonRedisSerializer.builder().build()); return Arrays.asList(new Object[][] { { stringTemplate, stringFactory } }); } @BeforeEach void setUp() { - template.execute(connection -> { + RedisConnectionFactory factory = template.getRequiredConnectionFactory(); + try (RedisConnection connection = factory.getConnection()) { connection.serverCommands().flushDb(); - return null; - }); + } } @Test // GH-3390 @@ -94,11 +90,11 @@ void testArrayAppend() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.array(key).path("$.forsakenDefeated").append(4, 5, 6)) + assertThat(template.array(key).path("$.forsakenDefeated").append(4, 5, 6)) .isEqualTo(List.of(6L)); - assertThat(jsonOps.key(key).path("$.forsakenDefeated").get() + assertThat(template.value(key).path("$.forsakenDefeated").get() .as(new ParameterizedTypeReference>>() {})) .isEqualTo(List.of(List.of(1L, 2L, 3L, 4L, 5L, 6L))); } @@ -109,11 +105,11 @@ void testArrayIndex() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.array(key).path("$.forsakenDefeated").indexOf(2L)) + assertThat(template.array(key).path("$.forsakenDefeated").indexOf(2L)) .isEqualTo(List.of(1L)); - assertThat(jsonOps.array(key).path("$.forsakenDefeated").indexOf(Integer.MAX_VALUE)) + assertThat(template.array(key).path("$.forsakenDefeated").indexOf(Integer.MAX_VALUE)) .isEqualTo(List.of(-1L)); } @@ -123,9 +119,9 @@ void testArrayInsert() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.array(key).path("$.forsakenDefeated").index(2).insert(1, 4, 5, 6)) + assertThat(template.array(key).path("$.forsakenDefeated").index(2).insert(1, 4, 5, 6)) .isEqualTo(List.of(7L)); } @@ -135,9 +131,9 @@ void testArrayLength() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.array(key).path("$.forsakenDefeated").length()) + assertThat(template.array(key).path("$.forsakenDefeated").length()) .isEqualTo(List.of(3L)); } @@ -147,9 +143,9 @@ void testArrayTrim() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.array(key).path("$.forsakenDefeated").trim(1, 2)) + assertThat(template.array(key).path("$.forsakenDefeated").trim(1, 2)) .isEqualTo(List.of(2L)); } @@ -159,9 +155,9 @@ void testClear() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.key(key).clear()).isEqualTo(1); + assertThat(template.value(key).clear()).isEqualTo(1); } @Test // GH-3390 @@ -170,9 +166,9 @@ void testDelete() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.key(key).delete()).isEqualTo(1); + assertThat(template.value(key).delete()).isEqualTo(1); } @Test // GH-3390 @@ -181,14 +177,14 @@ void testGet() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.get(key).as(new ParameterizedTypeReference>() {})) + assertThat(template.get(key).as(new ParameterizedTypeReference>() {})) .isEqualTo(List.of(DRAGON_REBORN)); - assertThat(jsonOps.paths(key, "$.name", "$.age").asString()) + assertThat(template.paths(key, "$.name", "$.age").asString()) .contains("Rand al'Thor") .contains("34"); - assertThat(jsonOps.paths(key, List.of("$.name")).asString()) + assertThat(template.paths(key, List.of("$.name")).asString()) .contains("Rand al'Thor"); } @@ -198,9 +194,9 @@ void testMerge() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.key(key).mergeWith(Map.of("age", 35))).isTrue(); + assertThat(template.value(key).mergeWith(Map.of("age", 35))).isTrue(); } @Test // GH-3390 @@ -211,14 +207,14 @@ void testMultiGet() { K key2 = keyFactory.instance(); K missing = keyFactory.instance(); - jsonOps.set(key1, DRAGON_REBORN); - jsonOps.set(key2, DRAGON_REBORN); + template.set(key1, DRAGON_REBORN); + template.set(key2, DRAGON_REBORN); - assertThat(jsonOps.values(List.of(key1)).get().as(new ParameterizedTypeReference>() {})) + assertThat(template.values(List.of(key1)).get().as(new ParameterizedTypeReference>() {})) .isEqualTo(List.of(List.of(DRAGON_REBORN))); - assertThat(jsonOps.values(List.of(key1, key2)).get().as(new ParameterizedTypeReference>() {})) + assertThat(template.values(List.of(key1, key2)).get().as(new ParameterizedTypeReference>() {})) .isEqualTo(List.of(List.of(DRAGON_REBORN), List.of(DRAGON_REBORN))); - assertThat(jsonOps.values(List.of(key1, missing, key2)).get() + assertThat(template.values(List.of(key1, missing, key2)).get() .as(new ParameterizedTypeReference>() {})) .containsExactly(List.of(DRAGON_REBORN), null, List.of(DRAGON_REBORN)); } @@ -229,12 +225,12 @@ void testSet() { K key = keyFactory.instance(); - assertThat(jsonOps.key(key).setIfPresent(DRAGON_REBORN)).isNotEqualTo(Boolean.TRUE); - assertThat(jsonOps.key(key).setIfAbsent(DRAGON_REBORN)).isTrue(); - assertThat(jsonOps.key(key).setIfAbsent(CALLANDOR)).isNotEqualTo(Boolean.TRUE); - assertThat(jsonOps.key(key).setIfPresent(CALLANDOR)).isTrue(); - assertThat(jsonOps.key(key).conditional(JsonOperations.JsonSetSpec::ifPresent).set(DRAGON_REBORN)).isTrue(); - assertThat(jsonOps.set(key, DRAGON_REBORN)).isTrue(); + assertThat(template.value(key).setIfPresent(DRAGON_REBORN)).isNotEqualTo(Boolean.TRUE); + assertThat(template.value(key).setIfAbsent(DRAGON_REBORN)).isTrue(); + assertThat(template.value(key).setIfAbsent(CALLANDOR)).isNotEqualTo(Boolean.TRUE); + assertThat(template.value(key).setIfPresent(CALLANDOR)).isTrue(); + assertThat(template.value(key).conditional(JsonOperations.JsonSetSpec::ifPresent).set(DRAGON_REBORN)).isTrue(); + assertThat(template.set(key, DRAGON_REBORN)).isTrue(); } @Test // GH-3390 @@ -243,15 +239,15 @@ void testStringAppend() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.string(key).path("$.name").append("foo")) + assertThat(template.string(key).path("$.name").append("foo")) .isEqualTo(List.of(15L)); - assertThat(jsonOps.string(key).path("$.name").get().as(new ParameterizedTypeReference>() {})) + assertThat(template.string(key).path("$.name").get().as(new ParameterizedTypeReference>() {})) .isEqualTo(List.of("Rand al'Thorfoo")); - assertThat(jsonOps.string(key).path("$.name").append("\"x\\y")) + assertThat(template.string(key).path("$.name").append("\"x\\y")) .isEqualTo(List.of(19L)); - assertThat(jsonOps.string(key).path("$.name").get().as(new ParameterizedTypeReference>() {})) + assertThat(template.string(key).path("$.name").get().as(new ParameterizedTypeReference>() {})) .isEqualTo(List.of("Rand al'Thorfoo\"x\\y")); } @@ -261,13 +257,13 @@ void testStringLength() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.string(key).path("$.name").length()) + assertThat(template.string(key).path("$.name").length()) .isEqualTo(List.of(12L)); - assertThat(jsonOps.string(key).path("$.name").set("Lews Therin")) + assertThat(template.string(key).path("$.name").set("Lews Therin")) .isTrue(); - assertThat(jsonOps.string(key).path("$.name").length()) + assertThat(template.string(key).path("$.name").length()) .isEqualTo(List.of(11L)); } @@ -277,13 +273,13 @@ void testToggle() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.bool(key).path("$.madness").toggle()) + assertThat(template.bool(key).path("$.madness").toggle()) .isEqualTo(List.of(true)); - assertThat(jsonOps.bool(key).path("$.madness").set(false)) + assertThat(template.bool(key).path("$.madness").set(false)) .isTrue(); - assertThat(jsonOps.bool(key).path("$.madness").toggle()) + assertThat(template.bool(key).path("$.madness").toggle()) .isEqualTo(List.of(true)); } @@ -293,11 +289,11 @@ void testType() { K key = keyFactory.instance(); - jsonOps.set(key, DRAGON_REBORN); + template.set(key, DRAGON_REBORN); - assertThat(jsonOps.key(key).path("$.name").getType()) + assertThat(template.value(key).path("$.name").getType()) .isEqualTo(List.of(RedisJsonCommands.JsonType.STRING)); - assertThat(jsonOps.key(key).root().getType()) + assertThat(template.value(key).root().getType()) .isEqualTo(List.of(RedisJsonCommands.JsonType.OBJECT)); } diff --git a/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java b/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java new file mode 100644 index 0000000000..04b7594a41 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026-present 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.core; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisJsonSerializer; +import org.springframework.data.redis.serializer.RedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +/** + * Unit tests for {@link RedisJsonTemplate} verifying construction, argument validation and command delegation against a + * mocked {@link RedisConnection}, without requiring a running Redis instance. + * + * @author Yordan Tsintsov + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class RedisJsonTemplateUnitTests { + + @Mock + RedisConnectionFactory connectionFactory; + @Mock + RedisSerializer keySerializer; + @Mock + RedisJsonSerializer jsonSerializer; + + private RedisJsonTemplate template; + + @BeforeEach + void setUp() { + this.template = new RedisJsonTemplate<>(connectionFactory, keySerializer, jsonSerializer); + } + + @Test + // GH-3390 + void defaultConstructorUsesStringKeySerializer() { + + RedisJsonTemplate template = new RedisJsonTemplate<>(connectionFactory); + + assertThat(template.getKeySerializer()).isSameAs(StringRedisSerializer.UTF_8); + } + + @Test + // GH-3390 + void defaultConstructorUsesJacksonJsonSerializer() { + + RedisJsonTemplate template = new RedisJsonTemplate<>(connectionFactory); + + assertThat(template.getJsonSerializer()).isInstanceOf(GenericJacksonJsonRedisSerializer.class); + } + + @Test + // GH-3390 + void gettersReturnConfiguredSerializers() { + + assertThat(template.getKeySerializer()).isSameAs(keySerializer); + assertThat(template.getJsonSerializer()).isSameAs(jsonSerializer); + } + + @Test + // GH-3390 + void defaultConstructorRejectsNullConnectionFactory() { + assertThatIllegalArgumentException().isThrownBy(() -> new RedisJsonTemplate<>(null)); + } + + @Test + // GH-3390 + void constructorRejectsNullConnectionFactory() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RedisJsonTemplate<>(null, keySerializer, jsonSerializer)); + } + + @Test + // GH-3390 + void constructorRejectsNullKeySerializer() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RedisJsonTemplate(connectionFactory, null, jsonSerializer)); + } + + @Test + // GH-3390 + void constructorRejectsNullJsonSerializer() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new RedisJsonTemplate<>(connectionFactory, keySerializer, null)); + } + +} diff --git a/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java index a3cec6c3d4..bf779b755c 100644 --- a/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java +++ b/src/test/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializerUnitTests.java @@ -22,6 +22,10 @@ import static org.springframework.test.util.ReflectionTestUtils.*; import static org.springframework.util.ObjectUtils.*; +import org.assertj.core.api.Assertions; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.data.redis.Person; +import org.springframework.data.redis.PersonObjectFactory; import tools.jackson.core.exc.JacksonIOException; import tools.jackson.core.exc.StreamReadException; import tools.jackson.core.json.JsonReadFeature; @@ -41,6 +45,7 @@ import java.nio.charset.StandardCharsets; import java.time.LocalDate; import java.util.Arrays; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.UUID; @@ -403,6 +408,77 @@ void configuresJsonMapper() { assertThat(serializer).isNotNull(); } + @Test + void testToJsonReturnsJsonNullLiteralForNull() { + assertThat(serializer.serializeAsString(null)).isEqualTo("null"); + } + + @Test + void testToJsonConvertsToJsonFromObject() { + + Person person = new PersonObjectFactory().instance(); + + String json = serializer.serializeAsString(person); + + assertThat(serializer.deserializeFromString(json, Person.class)).isEqualTo(person); + } + + @Test + void testToJsonWrapsJacksonExceptions() { + + Assertions.assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> serializer.serializeAsString(new ThrowingPojo())) + .withMessageStartingWith("Could not write JSON:"); + } + + @Test + void testFromJsonClassDeserializesPojo() { + + Person person = new PersonObjectFactory().instance(); + + assertThat(serializer.deserializeFromString(serializer.serializeAsString(person), Person.class)).isEqualTo(person); + } + + @Test + void testFromJsonClassWrapsInvalidJson() { + + Assertions.assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> serializer.deserializeFromString("{not json", Person.class)) + .withMessageStartingWith("Could not read JSON:"); + } + + @Test + void testFromJsonTypeRefDeserializesGenericList() { + + List result = serializer.deserializeFromString("[1,2,3]", new ParameterizedTypeReference<>() {}); + + assertThat(result).containsExactly(1L, 2L, 3L); + } + + @Test + void testFromJsonTypeRefDeserializesNestedGenericList() { + + List> result = serializer.deserializeFromString("[[1,2,3,4,5,6]]", + new ParameterizedTypeReference<>() {}); + + assertThat(result).containsExactly(List.of(1L, 2L, 3L, 4L, 5L, 6L)); + } + + @Test + void testFromJsonTypeRefWrapsInvalidJson() { + + Assertions.assertThatExceptionOfType(SerializationException.class) + .isThrownBy(() -> serializer.deserializeFromString("{not json", new ParameterizedTypeReference>() {})) + .withMessageStartingWith("Could not read JSON:"); + } + + static class ThrowingPojo { + + public String getValue() { + throw new IllegalStateException("boom"); + } + } + private static void serializeAndDeserializeNullValue(GenericJacksonJsonRedisSerializer serializer) { NullValue nv = BeanUtils.instantiateClass(NullValue.class); diff --git a/src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java b/src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java deleted file mode 100644 index 1d5c6c23fd..0000000000 --- a/src/test/java/org/springframework/data/redis/serializer/JacksonRedisJsonSerializerUnitTests.java +++ /dev/null @@ -1,111 +0,0 @@ -/* - * Copyright 2026-present the original author or authors. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.springframework.data.redis.serializer; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatExceptionOfType; - -import java.util.List; - -import org.junit.jupiter.api.Test; -import tools.jackson.databind.json.JsonMapper; - -import org.springframework.core.ParameterizedTypeReference; -import org.springframework.data.redis.Person; -import org.springframework.data.redis.PersonObjectFactory; - -/** - * Unit tests for {@link JacksonRedisJsonSerializer}. - * - * @author Yordan Tsintsov - * @since 4.2 - */ -class JacksonRedisJsonSerializerUnitTests { - - private final JacksonRedisJsonSerializer serializer = new JacksonRedisJsonSerializer(JsonMapper.shared()); - - @Test - void testToJsonReturnsJsonNullLiteralForNull() { - assertThat(serializer.serialize(null)).isEqualTo("null"); - } - - @Test - void testToJsonConvertsToJsonFromObject() { - - Person person = new PersonObjectFactory().instance(); - - String json = serializer.serialize(person); - - assertThat(serializer.deserialize(json, Person.class)).isEqualTo(person); - } - - @Test - void testToJsonWrapsJacksonExceptions() { - - assertThatExceptionOfType(SerializationException.class) - .isThrownBy(() -> serializer.serialize(new ThrowingPojo())) - .withMessageStartingWith("Could not write JSON:"); - } - - @Test - void testFromJsonClassDeserializesPojo() { - - Person person = new PersonObjectFactory().instance(); - - assertThat(serializer.deserialize(serializer.serialize(person), Person.class)).isEqualTo(person); - } - - @Test - void testFromJsonClassWrapsInvalidJson() { - - assertThatExceptionOfType(SerializationException.class) - .isThrownBy(() -> serializer.deserialize("{not json", Person.class)) - .withMessageStartingWith("Could not read JSON:"); - } - - @Test - void testFromJsonTypeRefDeserializesGenericList() { - - List result = serializer.deserialize("[1,2,3]", new ParameterizedTypeReference<>() {}); - - assertThat(result).containsExactly(1L, 2L, 3L); - } - - @Test - void testFromJsonTypeRefDeserializesNestedGenericList() { - - List> result = serializer.deserialize("[[1,2,3,4,5,6]]", - new ParameterizedTypeReference<>() {}); - - assertThat(result).containsExactly(List.of(1L, 2L, 3L, 4L, 5L, 6L)); - } - - @Test - void testFromJsonTypeRefWrapsInvalidJson() { - - assertThatExceptionOfType(SerializationException.class) - .isThrownBy(() -> serializer.deserialize("{not json", new ParameterizedTypeReference>() {})) - .withMessageStartingWith("Could not read JSON:"); - } - - static class ThrowingPojo { - - public String getValue() { - throw new IllegalStateException("boom"); - } - } - -} From d43fde0f113ffae3b23e7ffdecb5db7a5746f20f Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Wed, 8 Jul 2026 16:54:38 +0300 Subject: [PATCH 6/8] Fixed bug in RedisJsonTemplate.java for default constructor. Signed-off-by: Yordan Tsintsov --- .../data/redis/core/RedisJsonTemplate.java | 77 ++++++++++++------- .../redis/core/StringRedisJsonTemplate.java | 56 ++++++++++++++ .../core/RedisJsonTemplateUnitTests.java | 60 ++++++++++++++- 3 files changed, 162 insertions(+), 31 deletions(-) create mode 100644 src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java diff --git a/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java index 3a2af53373..b3b9d7ef51 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java @@ -49,19 +49,29 @@ public class RedisJsonTemplate extends RedisAccessor implements RedisJsonOper private final RedisSerializer keySerializer; private final RedisJsonSerializer jsonSerializer; + /** + * Creates a new {@link RedisJsonTemplate} using the given {@link RedisConnectionFactory} and default serializers. + * + * @param connectionFactory must not be {@literal null}. + * @throws IllegalStateException if no supported JSON library is available on the classpath. + */ + @SuppressWarnings("unchecked") public RedisJsonTemplate(RedisConnectionFactory connectionFactory) { Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); setConnectionFactory(connectionFactory); - keySerializer = (RedisSerializer) RedisSerializer.string(); - if (ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", RedisJsonTemplate.class.getClassLoader())) { - jsonSerializer = GenericJacksonJsonRedisSerializer.builder().build(); - } else { - throw new IllegalStateException("No default RedisJsonSerializer available. Add Jackson 3 (tools.jackson) to the classpath, or use the constructor that accepts a RedisJsonSerializer"); - } + keySerializer = (RedisSerializer) RedisSerializer.java(getClass().getClassLoader()); + jsonSerializer = defaultJsonSerializer(); } + /** + * Creates a new {@link RedisJsonTemplate} using the given {@link RedisConnectionFactory} and serializers. + * + * @param connectionFactory must not be {@literal null}. + * @param keySerializer must not be {@literal null}. + * @param jsonSerializer must not be {@literal null}. + */ public RedisJsonTemplate(RedisConnectionFactory connectionFactory, RedisSerializer keySerializer, RedisJsonSerializer jsonSerializer) { Assert.notNull(connectionFactory, "ConnectionFactory must not be null"); @@ -73,6 +83,15 @@ public RedisJsonTemplate(RedisConnectionFactory connectionFactory, RedisSerializ this.jsonSerializer = jsonSerializer; } + static RedisJsonSerializer defaultJsonSerializer() { + + if (!ClassUtils.isPresent("tools.jackson.databind.ObjectMapper", RedisJsonTemplate.class.getClassLoader())) { + throw new IllegalStateException("No default RedisJsonSerializer available. Add Jackson 3 (tools.jackson) to the classpath, or use the constructor that accepts a RedisJsonSerializer"); + } + + return GenericJacksonJsonRedisSerializer.builder().build(); + } + /** * Returns the key serializer used by this template. * @@ -194,29 +213,6 @@ public P path(String jsonPath) { } - @SuppressWarnings("unchecked") - private byte[] rawKey(Object key) { - - Assert.notNull(key, "non null key required"); - - if (key instanceof byte[] bytes) { - return bytes; - } - - return keySerializer.serialize((K) key); - } - - private byte[][] rawKeys(Collection keys) { - final byte[][] rawKeys = new byte[keys.size()][]; - - int i = 0; - for (K key : keys) { - rawKeys[i++] = rawKey(key); - } - - return rawKeys; - } - abstract static class DefaultJsonSpec & JsonSetSupport> extends DefaultPathSpec implements JsonKeySupport, JsonSetSupport { @@ -532,4 +528,27 @@ public boolean isNull() { } + @SuppressWarnings("unchecked") + private byte[] rawKey(Object key) { + + Assert.notNull(key, "non null key required"); + + if (key instanceof byte[] bytes) { + return bytes; + } + + return keySerializer.serialize((K) key); + } + + private byte[][] rawKeys(Collection keys) { + final byte[][] rawKeys = new byte[keys.size()][]; + + int i = 0; + for (K key : keys) { + rawKeys[i++] = rawKey(key); + } + + return rawKeys; + } + } diff --git a/src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java b/src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java new file mode 100644 index 0000000000..8b556c997a --- /dev/null +++ b/src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java @@ -0,0 +1,56 @@ +/* + * Copyright 2026-present 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.core; + +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.serializer.RedisJsonSerializer; +import org.springframework.data.redis.serializer.RedisSerializer; + +/** + * String-focused extension of {@link RedisJsonTemplate}. Since String works naturally with JSON, this provides users + * with a default String template for JSONs which accepts String keys. + * + * @author Yordan Tsintsov + */ +public class StringRedisJsonTemplate extends RedisJsonTemplate { + + /** + * Creates a new {@link StringRedisJsonTemplate} using the given {@link RedisConnectionFactory} and default + * serializers. + *

+ * Keys are serialized using the {@link RedisSerializer#string() UTF-8 String serializer}, producing human-readable + * keys. The JSON serializer defaults to a Jackson 3-based serializer and therefore requires {@code tools.jackson} on + * the classpath. + * + * @param connectionFactory must not be {@literal null}. + * @throws IllegalStateException if no supported JSON library is available on the classpath. + */ + public StringRedisJsonTemplate(RedisConnectionFactory connectionFactory) { + super(connectionFactory, RedisSerializer.string(), RedisJsonTemplate.defaultJsonSerializer()); + } + + /** + * Creates a new {@link StringRedisJsonTemplate} using the given {@link RedisConnectionFactory} and serializers. + * + * @param connectionFactory must not be {@literal null}. + * @param keySerializer must not be {@literal null}. + * @param jsonSerializer must not be {@literal null}. + */ + public StringRedisJsonTemplate(RedisConnectionFactory connectionFactory, RedisSerializer keySerializer, RedisJsonSerializer jsonSerializer) { + super(connectionFactory, keySerializer, jsonSerializer); + } + +} diff --git a/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java b/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java index 04b7594a41..b40e6dc968 100644 --- a/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java +++ b/src/test/java/org/springframework/data/redis/core/RedisJsonTemplateUnitTests.java @@ -28,6 +28,7 @@ import org.springframework.data.redis.connection.RedisConnection; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer; +import org.springframework.data.redis.serializer.JdkSerializationRedisSerializer; import org.springframework.data.redis.serializer.RedisJsonSerializer; import org.springframework.data.redis.serializer.RedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -58,11 +59,11 @@ void setUp() { @Test // GH-3390 - void defaultConstructorUsesStringKeySerializer() { + void defaultConstructorUsesJdkKeySerializer() { RedisJsonTemplate template = new RedisJsonTemplate<>(connectionFactory); - assertThat(template.getKeySerializer()).isSameAs(StringRedisSerializer.UTF_8); + assertThat(template.getKeySerializer()).isInstanceOf(JdkSerializationRedisSerializer.class); } @Test @@ -109,4 +110,59 @@ void constructorRejectsNullJsonSerializer() { .isThrownBy(() -> new RedisJsonTemplate<>(connectionFactory, keySerializer, null)); } + @Test + // GH-3390 + void stringTemplateDefaultConstructorUsesStringKeySerializer() { + + StringRedisJsonTemplate template = new StringRedisJsonTemplate(connectionFactory); + + assertThat(template.getKeySerializer()).isSameAs(StringRedisSerializer.UTF_8); + } + + @Test + // GH-3390 + void stringTemplateDefaultConstructorUsesJacksonJsonSerializer() { + + StringRedisJsonTemplate template = new StringRedisJsonTemplate(connectionFactory); + + assertThat(template.getJsonSerializer()).isInstanceOf(GenericJacksonJsonRedisSerializer.class); + } + + @Test + // GH-3390 + void stringTemplateConstructorUsesConfiguredSerializers() { + + StringRedisJsonTemplate template = new StringRedisJsonTemplate(connectionFactory, keySerializer, jsonSerializer); + + assertThat(template.getKeySerializer()).isSameAs(keySerializer); + assertThat(template.getJsonSerializer()).isSameAs(jsonSerializer); + } + + @Test + // GH-3390 + void stringTemplateDefaultConstructorRejectsNullConnectionFactory() { + assertThatIllegalArgumentException().isThrownBy(() -> new StringRedisJsonTemplate(null)); + } + + @Test + // GH-3390 + void stringTemplateConstructorRejectsNullConnectionFactory() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new StringRedisJsonTemplate(null, keySerializer, jsonSerializer)); + } + + @Test + // GH-3390 + void stringTemplateConstructorRejectsNullKeySerializer() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new StringRedisJsonTemplate(connectionFactory, null, jsonSerializer)); + } + + @Test + // GH-3390 + void stringTemplateConstructorRejectsNullJsonSerializer() { + assertThatIllegalArgumentException() + .isThrownBy(() -> new StringRedisJsonTemplate(connectionFactory, keySerializer, null)); + } + } From 0a3e10a97170e7a8cc65e71a221c519a7f60ab02 Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Wed, 8 Jul 2026 16:58:47 +0300 Subject: [PATCH 7/8] Bugfix. Signed-off-by: Yordan Tsintsov --- .../org/springframework/data/redis/core/RedisJsonTemplate.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java index b3b9d7ef51..e9365e25f1 100644 --- a/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/RedisJsonTemplate.java @@ -471,7 +471,7 @@ public byte[] asBytes() { @Override public boolean isNull() { - return result == null | "null".equals(result); + return result == null || "null".equals(result); } @Override From e35714beca1c1ac46f0cb8a325ac95b9e6199d0f Mon Sep 17 00:00:00 2001 From: Yordan Tsintsov Date: Wed, 8 Jul 2026 17:12:26 +0300 Subject: [PATCH 8/8] Polishing. Signed-off-by: Yordan Tsintsov --- .../springframework/data/redis/core/StringRedisJsonTemplate.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java b/src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java index 8b556c997a..6521c2d9b3 100644 --- a/src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java +++ b/src/main/java/org/springframework/data/redis/core/StringRedisJsonTemplate.java @@ -24,6 +24,7 @@ * with a default String template for JSONs which accepts String keys. * * @author Yordan Tsintsov + * @since 4.2 */ public class StringRedisJsonTemplate extends RedisJsonTemplate {