diff --git a/glide-core/src/client/value_conversion.rs b/glide-core/src/client/value_conversion.rs index 4a43da7da7..4fcd5830e4 100644 --- a/glide-core/src/client/value_conversion.rs +++ b/glide-core/src/client/value_conversion.rs @@ -22,6 +22,7 @@ pub(crate) enum ExpectedReturnType<'a> { ArrayOfStrings, ArrayOfBools, ArrayOfDoubleOrNull, + FTSearchReturnType, Lolwut, ArrayOfStringAndArrays, ArrayOfArraysOfDoubleOrNull, @@ -891,7 +892,53 @@ pub(crate) fn convert_to_expected_type( format!("(response was {:?})", get_value_type(&value)), ) .into()), - } + }, + ExpectedReturnType::FTSearchReturnType => match value { + /* + Example of the response + 1) (integer) 2 + 2) "json:2" + 3) 1) "__VEC_score" + 2) "11.1100006104" + 3) "$" + 4) "{\"vec\":[1.1,1.2,1.3,1.4,1.5,1.6]}" + 4) "json:0" + 5) 1) "__VEC_score" + 2) "91" + 3) "$" + 4) "{\"vec\":[1,2,3,4,5,6]}" + + Converting response to + 1) (integer) 2 + 2) 1# "json:2" => + 1# "__VEC_score" => "11.1100006104" + 2# "$" => "{\"vec\":[1.1,1.2,1.3,1.4,1.5,1.6]}" + 2# "json:0" => + 1# "__VEC_score" => "91" + 2# "$" => "{\"vec\":[1,2,3,4,5,6]}" + + Response may contain only 1 element, no conversion in that case. + */ + Value::Array(ref array) if array.len() == 1 => Ok(value), + Value::Array(mut array) => { + Ok(Value::Array(vec![ + array[0].clone(), + convert_to_expected_type(Value::Array(array.split_off(1)), Some(ExpectedReturnType::Map { + key_type: &Some(ExpectedReturnType::BulkString), + value_type: &Some(ExpectedReturnType::Map { + key_type: &Some(ExpectedReturnType::BulkString), + value_type: &Some(ExpectedReturnType::BulkString), + }), + }))? + ])) + }, + _ => Err(( + ErrorKind::TypeError, + "Response couldn't be converted to Pair", + format!("(response was {:?})", get_value_type(&value)), + ) + .into()) + }, } } @@ -1256,6 +1303,7 @@ pub(crate) fn expected_type_for_cmd(cmd: &Cmd) -> Option { key_type: &None, value_type: &None, }), + b"FT.SEARCH" => Some(ExpectedReturnType::FTSearchReturnType), _ => None, } } diff --git a/glide-core/src/protobuf/command_request.proto b/glide-core/src/protobuf/command_request.proto index 4d8359b0e3..20d0073860 100644 --- a/glide-core/src/protobuf/command_request.proto +++ b/glide-core/src/protobuf/command_request.proto @@ -259,6 +259,7 @@ enum RequestType { ScriptShow = 218; FtCreate = 2000; + FtSearch = 2001; } message Command { diff --git a/glide-core/src/request_type.rs b/glide-core/src/request_type.rs index 317cc21396..0ddd27a6a6 100644 --- a/glide-core/src/request_type.rs +++ b/glide-core/src/request_type.rs @@ -228,6 +228,7 @@ pub enum RequestType { ScriptKill = 217, ScriptShow = 218, FtCreate = 2000, + FtSearch = 2001, } fn get_two_word_command(first: &str, second: &str) -> Cmd { @@ -459,6 +460,7 @@ impl From<::protobuf::EnumOrUnknown> for RequestType { ProtobufRequestType::ScriptKill => RequestType::ScriptKill, ProtobufRequestType::ScriptShow => RequestType::ScriptShow, ProtobufRequestType::FtCreate => RequestType::FtCreate, + ProtobufRequestType::FtSearch => RequestType::FtSearch, } } } @@ -688,6 +690,7 @@ impl RequestType { RequestType::ScriptFlush => Some(get_two_word_command("SCRIPT", "FLUSH")), RequestType::ScriptKill => Some(get_two_word_command("SCRIPT", "KILL")), RequestType::FtCreate => Some(cmd("FT.CREATE")), + RequestType::FtSearch => Some(cmd("FT.SEARCH")), } } } diff --git a/java/client/build.gradle b/java/client/build.gradle index 46fa8f4cee..364b09ca1e 100644 --- a/java/client/build.gradle +++ b/java/client/build.gradle @@ -165,8 +165,8 @@ jar.dependsOn('copyNativeLib') javadoc.dependsOn('copyNativeLib') copyNativeLib.dependsOn('buildRustRelease') compileTestJava.dependsOn('copyNativeLib') -test.dependsOn('buildRust') -testFfi.dependsOn('buildRust') +test.dependsOn('buildRustRelease') +testFfi.dependsOn('buildRustRelease') test { exclude "glide/ffi/FfiTest.class" diff --git a/java/client/src/main/java/glide/api/BaseClient.java b/java/client/src/main/java/glide/api/BaseClient.java index 6b6945a31d..4b45e0af16 100644 --- a/java/client/src/main/java/glide/api/BaseClient.java +++ b/java/client/src/main/java/glide/api/BaseClient.java @@ -26,6 +26,7 @@ import static command_request.CommandRequestOuterClass.RequestType.FCall; import static command_request.CommandRequestOuterClass.RequestType.FCallReadOnly; import static command_request.CommandRequestOuterClass.RequestType.FtCreate; +import static command_request.CommandRequestOuterClass.RequestType.FtSearch; import static command_request.CommandRequestOuterClass.RequestType.GeoAdd; import static command_request.CommandRequestOuterClass.RequestType.GeoDist; import static command_request.CommandRequestOuterClass.RequestType.GeoHash; @@ -268,6 +269,7 @@ import glide.api.models.commands.stream.StreamTrimOptions; import glide.api.models.commands.vss.FTCreateOptions.FieldInfo; import glide.api.models.commands.vss.FTCreateOptions.IndexType; +import glide.api.models.commands.vss.FTSearchOptions; import glide.api.models.configuration.BaseClientConfiguration; import glide.api.models.configuration.BaseSubscriptionConfiguration; import glide.api.models.exceptions.ConfigurationError; @@ -5167,4 +5169,15 @@ public CompletableFuture ftcreate( return commandManager.submitNewCommand( FtCreate, args.toArray(String[]::new), this::handleStringResponse); } + + @Override + public CompletableFuture ftsearch( + String indexName, String query, FTSearchOptions options) { + var args = + concatenateArrays( + new GlideString[] {gs(indexName), gs(query)}, + options.toArgs(), + new GlideString[] {gs("DIALECT"), gs("2")}); + return commandManager.submitNewCommand(FtSearch, args, this::handleArrayResponseBinary); + } } diff --git a/java/client/src/main/java/glide/api/commands/VectorSearchBaseCommands.java b/java/client/src/main/java/glide/api/commands/VectorSearchBaseCommands.java index e5a72bc7f7..e74f983923 100644 --- a/java/client/src/main/java/glide/api/commands/VectorSearchBaseCommands.java +++ b/java/client/src/main/java/glide/api/commands/VectorSearchBaseCommands.java @@ -3,6 +3,8 @@ import glide.api.models.commands.vss.FTCreateOptions.FieldInfo; import glide.api.models.commands.vss.FTCreateOptions.IndexType; +import glide.api.models.commands.vss.FTSearchOptions; +import glide.api.models.commands.vss.FTSearchOptions.FTSearchOptionsBuilder; import java.util.concurrent.CompletableFuture; public interface VectorSearchBaseCommands { @@ -30,4 +32,34 @@ public interface VectorSearchBaseCommands { */ CompletableFuture ftcreate( String indexName, IndexType indexType, String[] prefixes, FieldInfo[] fields); + + /** + * Uses the provided query expression to locate keys within an index. Once located, the count + * and/or content of indexed fields within those keys can be returned. + * + * @see TODO + * @param indexName The index name to search into. + * @param query The text query to search. + * @param options The search options - see {@link FTSearchOptions}. + * @return A two element array, where first element is count of documents in result set, and the + * second element, which has format + * {@literal Map>} - a mapping between + * document names and map of their attributes.
+ * If {@link FTSearchOptionsBuilder#count()} or {@link FTSearchOptionsBuilder#limit(int, int)} + * with values 0, 0 is set, the command returns array with only one element - the + * count of the documents. + * @example + *
{@code
+     * byte[] vector = new byte[24];
+     * Arrays.fill(vector, (byte) 0);
+     * var result = client.ftsearch("json_idx1", "*=>[KNN 2 @VEC $query_vec]",
+     *         FTSearchOptions.builder().params(Map.of("query_vec", gs(vector))).build())
+     *     .get();
+     * assertArrayEquals(result, new Object[] { 2L, Map.of(
+     *     gs("json:2"), Map.of(gs("__VEC_score"), gs("11.1100006104"), gs("$"), gs("{\"vec\":[1.1,1.2,1.3,1.4,1.5,1.6]}")),
+     *     gs("json:0"), Map.of(gs("__VEC_score"), gs("91"), gs("$"), gs("{\"vec\":[1,2,3,4,5,6]}")))
+     * });
+     * }
+ */ + CompletableFuture ftsearch(String indexName, String query, FTSearchOptions options); } diff --git a/java/client/src/main/java/glide/api/models/commands/vss/FTSearchOptions.java b/java/client/src/main/java/glide/api/models/commands/vss/FTSearchOptions.java new file mode 100644 index 0000000000..e58c96b8b2 --- /dev/null +++ b/java/client/src/main/java/glide/api/models/commands/vss/FTSearchOptions.java @@ -0,0 +1,109 @@ +/** Copyright Valkey GLIDE Project Contributors - SPDX Identifier: Apache-2.0 */ +package glide.api.models.commands.vss; + +import static glide.api.models.GlideString.gs; + +import glide.api.commands.VectorSearchBaseCommands; +import glide.api.models.GlideString; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import lombok.Builder; +import org.apache.commons.lang3.tuple.Pair; + +/** Mandatory parameters for {@link VectorSearchBaseCommands#ftsearch}. */ +@Builder +public class FTSearchOptions { + /** + * Which fields of a key to be returned.
+ * Map keys are field names and their values are aliases. Aliases are optional, use null + * to omit. + */ + @Builder.Default private final Map identifiers = new HashMap<>(); + + /** Query timeout in milliseconds. */ + private final Integer timeout; + + private final Pair limit; + + @Builder.Default private final boolean count = false; + + /** + * Query parameters, which could be referenced in the query by $ sign, followed by + * the parameter name. + */ + @Builder.Default private final Map params = new HashMap<>(); + + // TODO maxstale? + // dialect is no-op + + /** Convert to module API. */ + public GlideString[] toArgs() { + var args = new ArrayList(); + if (!identifiers.isEmpty()) { + args.add(gs("RETURN")); + int tokenCount = 0; + for (var pair : identifiers.entrySet()) { + tokenCount++; + args.add(gs(pair.getKey())); + if (pair.getValue() != null) { + tokenCount += 2; + args.add(gs("AS")); + args.add(gs(pair.getValue())); + } + } + args.add(1, gs(Integer.toString(tokenCount))); + } + if (timeout != null) { + args.add(gs("TIMEOUT")); + args.add(gs(timeout.toString())); + } + if (!params.isEmpty()) { + args.add(gs("PARAMS")); + args.add(gs(Integer.toString(params.size() * 2))); + params.forEach( + (name, value) -> { + args.add(gs(name)); + args.add(value); + }); + } + if (limit != null) { + args.add(gs("LIMIT")); + args.add(gs(Integer.toString(limit.getLeft()))); + args.add(gs(Integer.toString(limit.getRight()))); + } + if (count) { + args.add(gs("COUNT")); + } + return args.toArray(GlideString[]::new); + } + + public static class FTSearchOptionsBuilder { + + // private - hiding this API from user + void limit(Pair limit) {} + + void count(boolean count) {} + + /** + * Configure query pagination. By default only first 10 documents are returned. + * + * @param offset Zero-based offset. + * @param count Number of elements to return. + */ + public FTSearchOptionsBuilder limit(int offset, int count) { + this.limit = Pair.of(offset, count); + return this; + } + + /** + * Once set, the query will return only number of documents in the result set without actually + * returning them. + */ + public FTSearchOptionsBuilder count() { + this.count$value = true; + this.count$set = true; + return this; + } + } +} diff --git a/java/integTest/build.gradle b/java/integTest/build.gradle index a7a1c0b915..e05e1fcaa0 100644 --- a/java/integTest/build.gradle +++ b/java/integTest/build.gradle @@ -131,8 +131,8 @@ test { tasks.register('modulesTest', Test) { doFirst { - systemProperty 'test.server.standalone.ports', 6379 - systemProperty 'test.server.cluster.ports', 7000 + clusterPorts = [7000] + standalonePorts = [6379] } filter { diff --git a/java/integTest/src/test/java/glide/modules/VectorSearchTests.java b/java/integTest/src/test/java/glide/modules/VectorSearchTests.java index 0ac82199ad..f1e0cab811 100644 --- a/java/integTest/src/test/java/glide/modules/VectorSearchTests.java +++ b/java/integTest/src/test/java/glide/modules/VectorSearchTests.java @@ -4,6 +4,8 @@ import static glide.TestUtilities.commonClientConfig; import static glide.TestUtilities.commonClusterClientConfig; import static glide.api.BaseClient.OK; +import static glide.api.models.GlideString.gs; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -20,8 +22,10 @@ import glide.api.models.commands.vss.FTCreateOptions.TextField; import glide.api.models.commands.vss.FTCreateOptions.VectorFieldFlat; import glide.api.models.commands.vss.FTCreateOptions.VectorFieldHnsw; +import glide.api.models.commands.vss.FTSearchOptions; import glide.api.models.exceptions.RequestException; import java.util.List; +import java.util.Map; import java.util.UUID; import java.util.concurrent.ExecutionException; import lombok.Getter; @@ -167,4 +171,112 @@ public void ft_create(BaseClient client) { assertInstanceOf(RequestException.class, exception.getCause()); assertTrue(exception.getMessage().contains("arguments are missing")); } + + @SneakyThrows + @ParameterizedTest(autoCloseArguments = false) + @MethodSource("getClients") + public void ft_search(BaseClient client) { + String index = UUID.randomUUID().toString(); + String prefix = "{" + UUID.randomUUID() + "}:"; + + assertEquals( + OK, + client + .ftcreate( + index, + IndexType.HASH, + new String[] {prefix}, + new FieldInfo[] { + new FieldInfo("vec", "VEC", VectorFieldHnsw.builder(DistanceMetric.L2, 2).build()) + }) + .get()); + + assertEquals( + 1L, + client + .hset( + gs(prefix + 0), + Map.of( + gs("vec"), + gs( + new byte[] { + (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, + (byte) 0 + }))) + .get()); + assertEquals( + 1L, + client + .hset( + gs(prefix + 1), + Map.of( + gs("vec"), + gs( + new byte[] { + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0x80, + (byte) 0xBF + }))) + .get()); + + var ftsearch = + client + .ftsearch( + index, + "*=>[KNN 2 @VEC $query_vec]", + FTSearchOptions.builder() + .params( + Map.of( + "query_vec", + gs( + new byte[] { + (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, (byte) 0, + (byte) 0, (byte) 0 + }))) + .build()) + .get(); + + assertArrayEquals( + new Object[] { + 2L, + Map.of( + gs(prefix + 0), + Map.of(gs("__VEC_score"), gs("0"), gs("vec"), gs("\0\0\0\0\0\0\0\0")), + gs(prefix + 1), + Map.of( + gs("__VEC_score"), + gs("1"), + gs("vec"), + gs( + new byte[] { + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0, + (byte) 0x80, + (byte) 0xBF + }))) + }, + ftsearch); + + // TODO more tests with json index + + // querying non-existing index + var exception = + assertThrows( + ExecutionException.class, + () -> + client + .ftsearch(UUID.randomUUID().toString(), "*", FTSearchOptions.builder().build()) + .get()); + assertInstanceOf(RequestException.class, exception.getCause()); + assertTrue(exception.getMessage().contains("no such index")); + } }