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 754c2b8b29..a33343a17c 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java @@ -82,6 +82,7 @@ * @author Dennis Neufeld * @author Shyngys Sapraliyev * @author Jeonggyu Choi + * @author Anne Lee */ @NullUnmarked @SuppressWarnings({ "ConstantConditions", "deprecation" }) @@ -246,7 +247,12 @@ public RedisZSetCommands zSetCommands() { return this; } - @Override + @Override + public RedisVectorSetCommands vectorSetCommands() { + return delegate.vectorSetCommands(); + } + + @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/RedisCommandsProvider.java b/src/main/java/org/springframework/data/redis/connection/RedisCommandsProvider.java index 24cfc387f9..77f98d6825 100644 --- a/src/main/java/org/springframework/data/redis/connection/RedisCommandsProvider.java +++ b/src/main/java/org/springframework/data/redis/connection/RedisCommandsProvider.java @@ -19,6 +19,7 @@ * Provides access to {@link RedisCommands} and the segregated command interfaces. * * @author Mark Paluch + * @author Anne Lee * @since 3.0 */ public interface RedisCommandsProvider { @@ -118,4 +119,12 @@ public interface RedisCommandsProvider { * @since 2.0 */ RedisZSetCommands zSetCommands(); + + /** + * Get {@link RedisVectorSetCommands}. + * + * @return never {@literal null}. + * @since 3.5 + */ + RedisVectorSetCommands vectorSetCommands(); } diff --git a/src/main/java/org/springframework/data/redis/connection/RedisVectorSetCommands.java b/src/main/java/org/springframework/data/redis/connection/RedisVectorSetCommands.java new file mode 100644 index 0000000000..7eb70fdfd1 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/RedisVectorSetCommands.java @@ -0,0 +1,170 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullUnmarked; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +/** + * Vector Set-specific commands supported by Redis. + * + * @author Anne Lee + * @see RedisCommands + */ +@NullUnmarked +public interface RedisVectorSetCommands { + + /** + * Add a vector to a vector set using FP32 binary format. + * + * @param key the key + * @param values the vector as FP32 binary blob + * @param element the element name + * @param options the options for the command + * @return true if the element was added, false if it already existed + */ + Boolean vAdd(byte @NonNull [] key, byte @NonNull [] values, byte @NonNull [] element, VAddOptions options); + + /** + * Add a vector to a vector set using double array. + * + * @param key the key + * @param values the vector as double array + * @param element the element name + * @param options the options for the command + * @return true if the element was added, false if it already existed + */ + Boolean vAdd(byte @NonNull [] key, double @NonNull [] values, byte @NonNull [] element, VAddOptions options); + + /** + * Options for the VADD command. + * + * Note on attributes: + * - Attributes are serialized to JSON and must be JavaScript/JSON compatible types + * - Supported types: String, Number (Integer, Long, Double, Float), Boolean, null + * - Collections (List, Map) are supported for nested structures + * - Custom objects require proper JSON serialization support + * - Date/Time objects should be converted to String or timestamp before use + */ + class VAddOptions { + private final @Nullable Integer reduceDim; + private final boolean cas; + private final QuantizationType quantization; + private final @Nullable Integer efBuildFactor; + private final @Nullable Map attributes; + private final @Nullable Integer maxConnections; + + private VAddOptions(Builder builder) { + this.reduceDim = builder.reduceDim; + this.cas = builder.cas; + this.quantization = builder.quantization; + this.efBuildFactor = builder.efBuildFactor; + this.attributes = builder.attributes; + this.maxConnections = builder.maxConnections; + } + + public static Builder builder() { + return new Builder(); + } + + public @Nullable Integer getReduceDim() { + return reduceDim; + } + + public boolean isCas() { + return cas; + } + + public QuantizationType getQuantization() { + return quantization; + } + + public @Nullable Integer getEfBuildFactor() { + return efBuildFactor; + } + + public @Nullable Map getAttributes() { + return attributes; + } + + public @Nullable Integer getMaxConnections() { + return maxConnections; + } + + public static class Builder { + private @Nullable Integer reduceDim; + private boolean cas = false; + private QuantizationType quantization = QuantizationType.Q8; + private @Nullable Integer efBuildFactor; + private @Nullable Map attributes; + private @Nullable Integer maxConnections; + + private Builder() {} + + public Builder reduceDim(@Nullable Integer reduceDim) { + this.reduceDim = reduceDim; + return this; + } + + public Builder cas(boolean cas) { + this.cas = cas; + return this; + } + + public Builder quantization(QuantizationType quantization) { + this.quantization = quantization; + return this; + } + + public Builder efBuildFactor(@Nullable Integer efBuildFactor) { + this.efBuildFactor = efBuildFactor; + return this; + } + + public Builder attributes(@Nullable Map attributes) { + this.attributes = attributes; + return this; + } + + public Builder attribute(String key, Object value) { + if (this.attributes == null) { + this.attributes = new HashMap<>(); + } + this.attributes.put(key, value); + return this; + } + + public Builder maxConnections(@Nullable Integer maxConnections) { + this.maxConnections = maxConnections; + return this; + } + + public VAddOptions build() { + return new VAddOptions(this); + } + } + + public enum QuantizationType { + NOQUANT, + Q8, + BIN, + } + } +} 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 9e8813294e..c3ca0ff6de 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 @@ -76,6 +76,7 @@ * @author Pavel Khokhlov * @author Liming Deng * @author John Blum + * @author Anne Lee * @since 1.7 */ @NullUnmarked @@ -97,6 +98,7 @@ public class JedisClusterConnection implements RedisClusterConnection { private final JedisClusterStreamCommands streamCommands = new JedisClusterStreamCommands(this); private final JedisClusterStringCommands stringCommands = new JedisClusterStringCommands(this); private final JedisClusterZSetCommands zSetCommands = new JedisClusterZSetCommands(this); + private final JedisClusterVSetCommands vSetCommands = new JedisClusterVSetCommands(this); private boolean closed; @@ -309,7 +311,10 @@ public RedisZSetCommands zSetCommands() { return zSetCommands; } - @Override + @Override + public RedisVectorSetCommands vectorSetCommands() { return vSetCommands; } + + @Override public RedisScriptingCommands scriptingCommands() { return new JedisClusterScriptingCommands(this); } diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterVSetCommands.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterVSetCommands.java new file mode 100644 index 0000000000..eccf242176 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterVSetCommands.java @@ -0,0 +1,101 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection.jedis; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullUnmarked; +import org.springframework.dao.DataAccessException; +import org.springframework.data.redis.connection.RedisVectorSetCommands; +import org.springframework.util.Assert; + +import redis.clients.jedis.params.VAddParams; + +/** + * Cluster {@link RedisVectorSetCommands} implementation for Jedis. + * + * @author Anne Lee + * @since 3.5 + */ +@NullUnmarked +class JedisClusterVSetCommands implements RedisVectorSetCommands { + + + private final JedisClusterConnection connection; + + JedisClusterVSetCommands(@NonNull JedisClusterConnection connection) { + this.connection = connection; + } + + @Override + public Boolean vAdd(byte @NonNull [] key, byte @NonNull [] values, byte @NonNull [] element, + VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(values, "Values must not be null"); + Assert.notNull(element, "Element must not be null"); + + try { + if (options == null) { + return connection.getCluster().vaddFP32(key, values, element); + } + + VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return connection.getCluster().vaddFP32(key, values, element, options.getReduceDim(), params); + } + + return connection.getCluster().vaddFP32(key, values, element, params); + } catch (Exception ex) { + throw convertJedisAccessException(ex); + } + } + + @Override + public Boolean vAdd(byte @NonNull [] key, double @NonNull [] values, byte @NonNull [] element, + VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(values, "Values must not be null"); + Assert.notNull(element, "Element must not be null"); + + // Convert double[] to float[] since Jedis uses float[] + float[] floatValues = new float[values.length]; + for (int i = 0; i < values.length; i++) { + floatValues[i] = (float) values[i]; + } + + try { + if (options == null) { + return connection.getCluster().vadd(key, floatValues, element); + } + + redis.clients.jedis.params.VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return connection.getCluster().vadd(key, floatValues, element, options.getReduceDim(), params); + } + + return connection.getCluster().vadd(key, floatValues, element, params); + } catch (Exception ex) { + throw convertJedisAccessException(ex); + } + } + + private DataAccessException convertJedisAccessException(Exception ex) { + return connection.convertJedisAccessException(ex); + } +} \ No newline at end of file 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 8e07453dcb..c008559563 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 @@ -76,6 +76,7 @@ * @author Guy Korland * @author Dengliming * @author John Blum + * @author Anne Lee * @see redis.clients.jedis.Jedis */ @NullUnmarked @@ -109,6 +110,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 JedisVectorSetCommands vectorSetCommands = new JedisVectorSetCommands(this); private final Log LOGGER = LogFactory.getLog(getClass()); @@ -284,6 +286,11 @@ public RedisServerCommands serverCommands() { return serverCommands; } + @Override + public RedisVectorSetCommands vectorSetCommands() { + return vectorSetCommands; + } + @Override public Object execute(@NonNull String command, byte @NonNull []... args) { 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 d9aad4571a..8978f4267e 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 @@ -28,10 +28,14 @@ import redis.clients.jedis.params.ScanParams; import redis.clients.jedis.params.SetParams; import redis.clients.jedis.params.SortingParams; +import redis.clients.jedis.params.VAddParams; import redis.clients.jedis.params.ZAddParams; import redis.clients.jedis.resps.GeoRadiusResponse; import redis.clients.jedis.util.SafeEncoder; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + import java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collection; @@ -46,6 +50,7 @@ import org.jspecify.annotations.Nullable; import org.springframework.core.convert.converter.Converter; +import org.springframework.dao.InvalidDataAccessApiUsageException; import org.springframework.data.domain.Sort; import org.springframework.data.geo.Distance; import org.springframework.data.geo.GeoResult; @@ -68,6 +73,7 @@ import org.springframework.data.redis.connection.RedisServerCommands; import org.springframework.data.redis.connection.RedisStringCommands.BitOperation; import org.springframework.data.redis.connection.RedisStringCommands.SetOption; +import org.springframework.data.redis.connection.RedisVectorSetCommands.VAddOptions; import org.springframework.data.redis.connection.RedisZSetCommands.ZAddArgs; import org.springframework.data.redis.connection.SortParameters; import org.springframework.data.redis.connection.SortParameters.Order; @@ -104,6 +110,7 @@ * @author Guy Korland * @author dengliming * @author John Blum + * @author Anne Lee */ @SuppressWarnings("ConstantConditions") abstract class JedisConverters extends Converters { @@ -595,6 +602,67 @@ static ZAddParams toZAddParams(ZAddArgs source) { return target; } + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + /** + * Convert {@link VAddOptions} into {@link VAddParams}. + * + * @param source can be {@literal null}. + * @return new instance of {@link VAddParams} or {@literal null} if source is {@literal null}. + * @since 3.5 + */ + @Nullable + static VAddParams toVAddParams(@Nullable VAddOptions source) { + + if (source == null) { + return null; + } + + VAddParams params = new VAddParams(); + + // CAS option + if (source.isCas()) { + params.cas(); + } + + // Quantization type + if (source.getQuantization() != null) { + switch (source.getQuantization()) { + case NOQUANT: + params.noQuant(); + break; + case Q8: + params.q8(); + break; + case BIN: + params.bin(); + break; + } + } + + // EF build-exploration-factor + if (source.getEfBuildFactor() != null) { + params.ef(source.getEfBuildFactor()); + } + + // Attributes as JSON + if (source.getAttributes() != null) { + try { + String jsonAttributes = OBJECT_MAPPER.writeValueAsString(source.getAttributes()); + params.setAttr(jsonAttributes); + } catch (JsonProcessingException e) { + throw new InvalidDataAccessApiUsageException("Failed to serialize attributes to JSON", e); + } + } + + // M numlinks + if (source.getMaxConnections() != null) { + params.m(source.getMaxConnections()); + } + + return params; + } + /** * Convert {@link GeoRadiusCommandArgs} into {@link GeoRadiusParam}. * diff --git a/src/main/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommands.java b/src/main/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommands.java new file mode 100644 index 0000000000..e3f1f56f1f --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommands.java @@ -0,0 +1,93 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection.jedis; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullUnmarked; +import org.springframework.data.redis.connection.RedisVectorSetCommands; +import org.springframework.util.Assert; +import redis.clients.jedis.Jedis; +import redis.clients.jedis.commands.PipelineBinaryCommands; +import redis.clients.jedis.params.VAddParams; + +/** + * {@link RedisVectorSetCommands} implementation for Jedis. + * + * @author Anne Lee + * @since 3.5 + */ +@NullUnmarked +class JedisVectorSetCommands implements RedisVectorSetCommands { + + private final JedisConnection jedisConnection; + + JedisVectorSetCommands(@NonNull JedisConnection jedisConnection) { + this.jedisConnection = jedisConnection; + } + + @Override + public Boolean vAdd(byte @NonNull [] key, byte @NonNull [] values, byte @NonNull [] element, VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(values, "Values must not be null"); + Assert.notNull(element, "Element must not be null"); + + if (options == null) { + return jedisConnection.invoke() + .just(Jedis::vaddFP32, PipelineBinaryCommands::vaddFP32, key, values, element); + } + + VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return jedisConnection.invoke() + .just(Jedis::vaddFP32, PipelineBinaryCommands::vaddFP32, key, values, element, options.getReduceDim(), params); + } + + return jedisConnection.invoke() + .just(Jedis::vaddFP32, PipelineBinaryCommands::vaddFP32, key, values, element, params); + } + + @Override + public Boolean vAdd(byte @NonNull [] key, double @NonNull [] values, byte @NonNull [] element, VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(values, "Values must not be null"); + Assert.notNull(element, "Element must not be null"); + + // Convert double[] to float[] since Jedis uses float[] + float[] floatValues = new float[values.length]; + for (int i = 0; i < values.length; i++) { + floatValues[i] = (float) values[i]; + } + + if (options == null) { + return jedisConnection.invoke() + .just(Jedis::vadd, PipelineBinaryCommands::vadd, key, floatValues, element); + } + + VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return jedisConnection.invoke() + .just(Jedis::vadd, PipelineBinaryCommands::vadd, key, floatValues, element, options.getReduceDim(), params); + } + + return jedisConnection.invoke() + .just(Jedis::vadd, PipelineBinaryCommands::vadd, key, floatValues, element, params); + } + +} 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 e8cb00bb34..6e124fb0bf 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 @@ -103,6 +103,7 @@ * @author Tamil Selvan * @author ihaohong * @author John Blum + * @author Anne Lee */ @NullUnmarked public class LettuceConnection extends AbstractRedisConnection { @@ -154,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 LettuceVectorSetCommands vSetCommands = new LettuceVectorSetCommands(this); private @Nullable List> ppline; @@ -309,7 +311,10 @@ public RedisZSetCommands zSetCommands() { return this.zSetCommands; } - protected DataAccessException convertLettuceAccessException(Exception cause) { + @Override + public RedisVectorSetCommands vectorSetCommands() { return this.vSetCommands; } + + protected DataAccessException convertLettuceAccessException(Exception cause) { return EXCEPTION_TRANSLATION.translate(cause); } diff --git a/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceVectorSetCommands.java b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceVectorSetCommands.java new file mode 100644 index 0000000000..e67858bb72 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceVectorSetCommands.java @@ -0,0 +1,59 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection.lettuce; + + +import org.jspecify.annotations.NullUnmarked; +import org.springframework.data.redis.connection.RedisVectorSetCommands; +import org.springframework.util.Assert; + +/** + {@link RedisVectorSetCommands} implementation for {@literal Lettuce}. + * + * @author Anne Lee + * @since 3.5 + */ +@NullUnmarked +class LettuceVectorSetCommands implements RedisVectorSetCommands { + + private final LettuceConnection connection; + + LettuceVectorSetCommands(LettuceConnection connection) { + this.connection = connection; + } + + @Override + public Boolean vAdd(byte[] key, byte[] values, byte[] element, VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(values, "Values must not be null"); + Assert.notNull(element, "Element must not be null"); + + // TODO: Implement when Lettuce adds native support for V.ADD + // For now, we need to use custom command execution + throw new UnsupportedOperationException("V.ADD is not yet supported in Lettuce"); + } + + @Override + public Boolean vAdd(byte[] key, double[] values, byte[] element, VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(values, "Values must not be null"); + Assert.notNull(element, "Element must not be null"); + + // TODO: Implement when Lettuce adds native support for V.ADD + // Convert double array to FP32 binary format and call the byte[] version + throw new UnsupportedOperationException("V.ADD is not yet supported in Lettuce"); + } +} 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 068e87444a..55e5437bcd 100644 --- a/src/test/java/org/springframework/data/redis/connection/RedisConnectionUnitTests.java +++ b/src/test/java/org/springframework/data/redis/connection/RedisConnectionUnitTests.java @@ -51,6 +51,7 @@ * @author Ninad Divadkar * @author Mark Paluch * @author Dennis Neufeld + * @author Anne Lee */ class RedisConnectionUnitTests { @@ -162,7 +163,12 @@ public RedisZSetCommands zSetCommands() { return null; } - @Override + @Override + public RedisVectorSetCommands vectorSetCommands() { + return null; + } + + @Override protected boolean isActive(RedisNode node) { return ObjectUtils.nullSafeEquals(activeNode, node); } @@ -267,6 +273,14 @@ public Boolean zAdd(byte[] key, double score, byte[] value) { return delegate.zAdd(key, score, value); } + public Boolean vAdd(byte[] key, byte[] values, byte[] element, RedisVectorSetCommands.VAddOptions options) { + return delegate.vectorSetCommands().vAdd(key, values, element, options); + } + + public Boolean vAdd(byte[] key, double[] values, byte[] element, RedisVectorSetCommands.VAddOptions options) { + return delegate.vectorSetCommands().vAdd(key, values, element, options); + } + public Long publish(byte[] channel, byte[] message) { return delegate.publish(channel, message); } diff --git a/src/test/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommandsUnitTests.java b/src/test/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommandsUnitTests.java new file mode 100644 index 0000000000..34f3e9be87 --- /dev/null +++ b/src/test/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommandsUnitTests.java @@ -0,0 +1,320 @@ +/* + * Copyright 2025 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.springframework.data.redis.connection.jedis; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisVectorSetCommands.VAddOptions; +import org.springframework.data.redis.connection.RedisVectorSetCommands.VAddOptions.QuantizationType; + +import redis.clients.jedis.Jedis; +import redis.clients.jedis.Pipeline; +import redis.clients.jedis.Transaction; +import redis.clients.jedis.params.VAddParams; + +/** + * Unit tests for {@link JedisVectorSetCommands}. + * + * @author Anne Lee + */ +@ExtendWith(MockitoExtension.class) +class JedisVectorSetCommandsUnitTests { + + @Mock + private JedisConnection jedisConnection; + + @Mock + private Jedis jedis; + + @Mock + private Pipeline pipeline; + + @Mock + private Transaction transaction; + + @Mock + private JedisInvoker jedisInvoker; + + @Mock + private JedisInvoker.SingleInvocationSpec singleInvocationSpec; + + private JedisVectorSetCommands commands; + + private static final byte[] KEY = "test-key".getBytes(); + private static final byte[] ELEMENT = "test-element".getBytes(); + private static final byte[] FP32_VALUES = new byte[]{0, 0, -128, 63, 0, 0, 0, 64}; // Float values in FP32 format + private static final double[] DOUBLE_VALUES = new double[]{1.5, 2.5, 3.5}; + + @BeforeEach + void setUp() { + lenient().when(jedisConnection.invoke()).thenReturn(jedisInvoker); + commands = new JedisVectorSetCommands(jedisConnection); + } + + @Test + void vAddWithFP32ValuesAndNoOptions() { + // Given + when(jedisInvoker.just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT))) + .thenReturn(true); + + // When + Boolean result = commands.vAdd(KEY, FP32_VALUES, ELEMENT, null); + + // Then + assertThat(result).isTrue(); + verify(jedisInvoker).just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT)); + } + + @Test + void vAddWithFP32ValuesAndOptions() { + // Given + VAddOptions options = VAddOptions.builder() + .cas(true) + .quantization(QuantizationType.Q8) + .efBuildFactor(200) + .maxConnections(16) + .build(); + + when(jedisInvoker.just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT), any(VAddParams.class))) + .thenReturn(true); + + // When + Boolean result = commands.vAdd(KEY, FP32_VALUES, ELEMENT, options); + + // Then + assertThat(result).isTrue(); + + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(VAddParams.class); + verify(jedisInvoker).just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT), paramsCaptor.capture()); + + VAddParams capturedParams = paramsCaptor.getValue(); + assertThat(capturedParams).isNotNull(); + } + + @Test + void vAddWithFP32ValuesAndReduceDimOption() { + // Given + VAddOptions options = VAddOptions.builder() + .reduceDim(128) + .quantization(QuantizationType.NOQUANT) + .build(); + + when(jedisInvoker.just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT), eq(128), any(VAddParams.class))) + .thenReturn(true); + + // When + Boolean result = commands.vAdd(KEY, FP32_VALUES, ELEMENT, options); + + // Then + assertThat(result).isTrue(); + verify(jedisInvoker).just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT), eq(128), any(VAddParams.class)); + } + + @Test + void vAddWithDoubleValuesAndNoOptions() { + // Given + float[] expectedFloatValues = new float[]{1.5f, 2.5f, 3.5f}; + + when(jedisInvoker.just(any(), any(), eq(KEY), any(float[].class), eq(ELEMENT))) + .thenReturn(true); + + // When + Boolean result = commands.vAdd(KEY, DOUBLE_VALUES, ELEMENT, null); + + // Then + assertThat(result).isTrue(); + + ArgumentCaptor floatCaptor = ArgumentCaptor.forClass(float[].class); + verify(jedisInvoker).just(any(), any(), eq(KEY), floatCaptor.capture(), eq(ELEMENT)); + + float[] capturedFloats = floatCaptor.getValue(); + assertThat(capturedFloats).containsExactly(expectedFloatValues); + } + + @Test + void vAddWithDoubleValuesAndOptions() { + // Given + VAddOptions options = VAddOptions.builder() + .cas(false) + .quantization(QuantizationType.BIN) + .efBuildFactor(100) + .build(); + + when(jedisInvoker.just(any(), any(), eq(KEY), any(float[].class), eq(ELEMENT), any(VAddParams.class))) + .thenReturn(false); + + // When + Boolean result = commands.vAdd(KEY, DOUBLE_VALUES, ELEMENT, options); + + // Then + assertThat(result).isFalse(); + verify(jedisInvoker).just(any(), any(), eq(KEY), any(float[].class), eq(ELEMENT), any(VAddParams.class)); + } + + @Test + void vAddWithDoubleValuesAndReduceDimOption() { + // Given + VAddOptions options = VAddOptions.builder() + .reduceDim(64) + .build(); + + when(jedisInvoker.just(any(), any(), eq(KEY), any(float[].class), eq(ELEMENT), eq(64), any(VAddParams.class))) + .thenReturn(true); + + // When + Boolean result = commands.vAdd(KEY, DOUBLE_VALUES, ELEMENT, options); + + // Then + assertThat(result).isTrue(); + verify(jedisInvoker).just(any(), any(), eq(KEY), any(float[].class), eq(ELEMENT), eq(64), any(VAddParams.class)); + } + + @Test + void vAddWithAttributesInOptions() { + // Given + Map attributes = new HashMap<>(); + attributes.put("type", "fruit"); + attributes.put("color", "red"); + attributes.put("price", 2.5); + + VAddOptions options = VAddOptions.builder() + .attributes(attributes) + .build(); + + when(jedisInvoker.just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT), any(VAddParams.class))) + .thenReturn(true); + + // When + Boolean result = commands.vAdd(KEY, FP32_VALUES, ELEMENT, options); + + // Then + assertThat(result).isTrue(); + + ArgumentCaptor paramsCaptor = ArgumentCaptor.forClass(VAddParams.class); + verify(jedisInvoker).just(any(), any(), eq(KEY), eq(FP32_VALUES), eq(ELEMENT), paramsCaptor.capture()); + + // VAddParams should contain the JSON serialized attributes + VAddParams capturedParams = paramsCaptor.getValue(); + assertThat(capturedParams).isNotNull(); + } + + @Test + void vAddThrowsExceptionForInvalidAttributesSerialization() { + // Given + + Map attributes = new HashMap<>(); + attributes.put("circular", attributes); // Circular reference causes serialization failure + + VAddOptions options = VAddOptions.builder() + .attributes(attributes) + .build(); + + // When & Then + assertThatThrownBy(() -> commands.vAdd(KEY, FP32_VALUES, ELEMENT, options)) + .isInstanceOf(Exception.class) + .hasMessageContaining("Failed to serialize attributes to JSON"); + } + + @Test + void shouldHandleNullKeyProperly() { + // When & Then + assertThatThrownBy(() -> commands.vAdd(null, FP32_VALUES, ELEMENT, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Key must not be null"); + } + + @Test + void shouldHandleNullValuesProperly() { + // When & Then - FP32 values + assertThatThrownBy(() -> commands.vAdd(KEY, (byte[]) null, ELEMENT, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Values must not be null"); + + // When & Then - Double values + assertThatThrownBy(() -> commands.vAdd(KEY, (double[]) null, ELEMENT, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Values must not be null"); + } + + @Test + void shouldHandleNullElementProperly() { + // When & Then + assertThatThrownBy(() -> commands.vAdd(KEY, FP32_VALUES, null, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Element must not be null"); + } + + @Test + void shouldConvertDoubleToFloatCorrectly() { + // Given + double[] doubleValues = new double[]{Double.MAX_VALUE, Double.MIN_VALUE, 0.0, -1.0, 1.0}; + float[] expectedFloatValues = new float[]{Float.POSITIVE_INFINITY, 0.0f, 0.0f, -1.0f, 1.0f}; + + when(jedisInvoker.just(any(), any(), eq(KEY), any(float[].class), eq(ELEMENT))) + .thenReturn(true); + + // When + commands.vAdd(KEY, doubleValues, ELEMENT, null); + + // Then + ArgumentCaptor floatCaptor = ArgumentCaptor.forClass(float[].class); + verify(jedisInvoker).just(any(), any(), eq(KEY), floatCaptor.capture(), eq(ELEMENT)); + + float[] capturedFloats = floatCaptor.getValue(); + assertThat(capturedFloats).containsExactly(expectedFloatValues); + } + + @Test + void shouldHandleAllQuantizationTypes() { + // Test NOQUANT + VAddOptions noquantOptions = VAddOptions.builder() + .quantization(QuantizationType.NOQUANT) + .build(); + + when(jedisInvoker.just(any(), any(), any(), any(), any(), any(VAddParams.class))) + .thenReturn(true); + + commands.vAdd(KEY, FP32_VALUES, ELEMENT, noquantOptions); + + // Test Q8 + VAddOptions q8Options = VAddOptions.builder() + .quantization(QuantizationType.Q8) + .build(); + + commands.vAdd(KEY, FP32_VALUES, ELEMENT, q8Options); + + // Test BIN + VAddOptions binOptions = VAddOptions.builder() + .quantization(QuantizationType.BIN) + .build(); + + commands.vAdd(KEY, FP32_VALUES, ELEMENT, binOptions); + + // Verify all three calls were made + verify(jedisInvoker, times(3)).just(any(), any(), any(), any(), any(), any(VAddParams.class)); + } +}