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 ad35115b73..51356494c9 100644 --- a/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java +++ b/src/main/java/org/springframework/data/redis/connection/DefaultStringRedisConnection.java @@ -84,6 +84,7 @@ * @author Shyngys Sapraliyev * @author Jeonggyu Choi * @author Mingi Lee + * @author Anne Lee */ @NullUnmarked @SuppressWarnings({ "ConstantConditions", "deprecation" }) @@ -248,7 +249,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..08a598e6d7 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/RedisVectorSetCommands.java @@ -0,0 +1,178 @@ +/* + * 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 org.springframework.data.domain.Vector; + +import java.util.Objects; + +/** + * 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 vector 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 [] vector, byte @NonNull [] element, @Nullable VAddOptions options); + + /** + * Add a vector to a vector set using Vector. + * + * @param key the key + * @param vector the vector + * @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, @NonNull Vector vector, byte @NonNull [] element, VAddOptions options); + + /** + * Add a vector to a vector set using double array. + * + * @param key the key + * @param vector 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 + */ + default Boolean vAdd(byte @NonNull [] key, double @NonNull [] vector, byte @NonNull [] element, VAddOptions options) { + return vAdd(key, Vector.unsafe(vector), element, options); + } + + /** + * Options for the VADD command. + * + * Note on attributes: + * - Attributes should be provided as a JSON string + * - The caller is responsible for JSON serialization + */ + class VAddOptions { + + private static final VAddOptions DEFAULT = new VAddOptions(null, false, QuantizationType.Q8, null, null, null); + + private final @Nullable Integer reduceDim; + private final boolean cas; + private final QuantizationType quantization; + private final @Nullable Integer efBuildFactor; + private final @Nullable String attributes; + private final @Nullable Integer maxConnections; + + public VAddOptions(@Nullable Integer reduceDim, boolean cas, QuantizationType quantization, + @Nullable Integer efBuildFactor, @Nullable String attributes, + @Nullable Integer maxConnections) { + this.reduceDim = reduceDim; + this.cas = cas; + this.quantization = quantization; + this.efBuildFactor = efBuildFactor; + this.attributes = attributes; + this.maxConnections = maxConnections; + } + + public static VAddOptions defaults() { + return DEFAULT; + } + + public static VAddOptions reduceDim(@Nullable Integer reduceDim) { + return new VAddOptions(reduceDim, false, QuantizationType.Q8, null, null, null); + } + + public static VAddOptions cas(boolean cas) { + return new VAddOptions(null, cas, QuantizationType.Q8, null, null, null); + } + + public static VAddOptions quantization(QuantizationType quantization) { + return new VAddOptions(null, false, quantization, null, null, null); + } + + public static VAddOptions efBuildFactor(@Nullable Integer efBuildFactor) { + return new VAddOptions(null, false, QuantizationType.Q8, efBuildFactor, null, null); + } + + public static VAddOptions attributes(@Nullable String attributes) { + return new VAddOptions(null, false, QuantizationType.Q8, null, attributes, null); + } + + public static VAddOptions maxConnections(@Nullable Integer maxConnections) { + return new VAddOptions(null, false, QuantizationType.Q8, null, null, maxConnections); + } + + public static VAddOptions casWithQuantization(boolean cas, QuantizationType quantization) { + return new VAddOptions(null, cas, quantization, null, null, null); + } + + public static VAddOptions reduceDimWithQuantization(Integer reduceDim, QuantizationType quantization) { + return new VAddOptions(reduceDim, false, quantization, null, null, null); + } + + public @Nullable Integer getReduceDim() { + return reduceDim; + } + + public boolean isCas() { + return cas; + } + + public QuantizationType getQuantization() { + return quantization; + } + + public @Nullable Integer getEfBuildFactor() { + return efBuildFactor; + } + + public @Nullable String getAttributes() { + return attributes; + } + + public @Nullable Integer getMaxConnections() { + return maxConnections; + } + + @Override + public boolean equals(Object o) { + if (!(o instanceof VAddOptions that)) {return false;} + return cas == that.cas && Objects.equals(reduceDim, that.reduceDim) + && quantization == that.quantization && Objects.equals(efBuildFactor, that.efBuildFactor) + && Objects.equals(attributes, that.attributes) && Objects.equals(maxConnections, + that.maxConnections); + } + + @Override + public int hashCode() { + return Objects.hash(reduceDim, cas, quantization, efBuildFactor, attributes, maxConnections); + } + + 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..01e4997ba5 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisClusterVSetCommands.java @@ -0,0 +1,97 @@ +/* + * 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.domain.Vector; +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 [] vector, byte @NonNull [] element, + VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(vector, "Vector must not be null"); + Assert.notNull(element, "Element must not be null"); + + try { + if (options == null) { + return connection.getCluster().vaddFP32(key, vector, element); + } + + VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return connection.getCluster().vaddFP32(key, vector, element, options.getReduceDim(), params); + } + + return connection.getCluster().vaddFP32(key, vector, element, params); + } catch (Exception ex) { + throw convertJedisAccessException(ex); + } + } + + @Override + public Boolean vAdd(byte @NonNull [] key, @NonNull Vector vector, byte @NonNull [] element, + VAddOptions options) { + + Assert.notNull(key, "Key must not be null"); + Assert.notNull(vector, "Vector must not be null"); + Assert.notNull(element, "Element must not be null"); + + try { + if (options == null) { + return connection.getCluster().vadd(key, vector.toFloatArray(), element); + } + + VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return connection.getCluster().vadd(key, vector.toFloatArray(), element, options.getReduceDim(), params); + } + + return connection.getCluster().vadd(key, vector.toFloatArray(), 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 98ba6a5525..8c268aad03 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 @@ -30,10 +30,12 @@ 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 java.nio.ByteBuffer; import java.util.ArrayList; import java.util.Collection; @@ -73,6 +75,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; @@ -109,6 +112,7 @@ * @author Guy Korland * @author dengliming * @author John Blum + * @author Anne Lee */ @SuppressWarnings("ConstantConditions") abstract class JedisConverters extends Converters { @@ -682,6 +686,60 @@ static ZAddParams toZAddParams(ZAddArgs source) { return target; } + /** + * 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) { + params.setAttr(source.getAttributes()); + } + + // 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..dc45050056 --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommands.java @@ -0,0 +1,89 @@ +/* + * 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.domain.Vector; +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 [] vector, byte @NonNull [] element, VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(vector, "Vector must not be null"); + Assert.notNull(element, "Element must not be null"); + + if (options == null) { + return jedisConnection.invoke() + .just(Jedis::vaddFP32, PipelineBinaryCommands::vaddFP32, key, vector, element); + } + + VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return jedisConnection.invoke() + .just(Jedis::vaddFP32, PipelineBinaryCommands::vaddFP32, key, vector, element, options.getReduceDim(), params); + } + + return jedisConnection.invoke() + .just(Jedis::vaddFP32, PipelineBinaryCommands::vaddFP32, key, vector, element, params); + } + + @Override + public Boolean vAdd(byte @NonNull [] key, @NonNull Vector vector, byte @NonNull [] element, + VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(vector, "Vector must not be null"); + Assert.notNull(element, "Element must not be null"); + + if (options == null) { + return jedisConnection.invoke() + .just(Jedis::vadd, PipelineBinaryCommands::vadd, key, vector.toFloatArray(), element); + } + + VAddParams params = JedisConverters.toVAddParams(options); + + if (options.getReduceDim() != null) { + // With REDUCE dimension + return jedisConnection.invoke() + .just(Jedis::vadd, PipelineBinaryCommands::vadd, key, vector.toFloatArray(), element, options.getReduceDim(), params); + } + + return jedisConnection.invoke() + .just(Jedis::vadd, PipelineBinaryCommands::vadd, key, vector.toFloatArray(), 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 1103b5400d..f56c15fb70 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..6c931885fc --- /dev/null +++ b/src/main/java/org/springframework/data/redis/connection/lettuce/LettuceVectorSetCommands.java @@ -0,0 +1,58 @@ +/* + * 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.NonNull; +import org.jspecify.annotations.NullUnmarked; +import org.springframework.data.domain.Vector; +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[] vector, byte[] element, VAddOptions options) { + Assert.notNull(key, "Key must not be null"); + Assert.notNull(vector, "Vector 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 @NonNull [] key, @NonNull Vector vector, byte @NonNull [] element, + VAddOptions options) { + // 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"); + } +} 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 754f17e889..a3ed03de0d 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..963e185c0e --- /dev/null +++ b/src/test/java/org/springframework/data/redis/connection/jedis/JedisVectorSetCommandsUnitTests.java @@ -0,0 +1,288 @@ +/* + * 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 = new VAddOptions( + null, + true, + QuantizationType.Q8, + 200, + null, + 20 + ); + + 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 = new VAddOptions(128, false, QuantizationType.NOQUANT, null, null, null); + + 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 = new VAddOptions(null, false, QuantizationType.BIN, 100, null, null); + + 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 = new VAddOptions(64, false, QuantizationType.Q8, null, null, null); + + 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.attributes(attributes.toString()); + + 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 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("Vector must not be null"); + + // When & Then - Double values + assertThatThrownBy(() -> commands.vAdd(KEY, (double[]) null, ELEMENT, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("double vector 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.quantization(QuantizationType.NOQUANT); + + 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.quantization(QuantizationType.Q8); + + commands.vAdd(KEY, FP32_VALUES, ELEMENT, q8Options); + + // Test BIN + VAddOptions binOptions = VAddOptions.quantization(QuantizationType.BIN); + + 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)); + } +}