diff --git a/CHANGELOG.md b/CHANGELOG.md index e9224d1245..668ce554f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ * Java: Added `JSON.OBJLEN` and `JSON.OBJKEYS` ([#2492](https://github.com/valkey-io/valkey-glide/pull/2492)) * Java: Added `JSON.DEL` and `JSON.FORGET` ([#2490](https://github.com/valkey-io/valkey-glide/pull/2490)) * Java: Added `FT.ALIASADD`, `FT.ALIASDEL`, `FT.ALIASUPDATE` ([#2442](https://github.com/valkey-io/valkey-glide/pull/2442)) +* Java: Added `FT.EXPLAIN`, `FT.EXPLAINCLI` ([#2515](https://github.com/valkey-io/valkey-glide/pull/2515)) * Core: Update routing for commands from server modules ([#2461](https://github.com/valkey-io/valkey-glide/pull/2461)) * Node: Added `JSON.SET` and `JSON.GET` ([#2427](https://github.com/valkey-io/valkey-glide/pull/2427)) * Java: Added `JSON.ARRAPPEND` ([#2489](https://github.com/valkey-io/valkey-glide/pull/2489)) diff --git a/java/client/src/main/java/glide/api/commands/servermodules/FT.java b/java/client/src/main/java/glide/api/commands/servermodules/FT.java index 0250865648..2bf2b80278 100644 --- a/java/client/src/main/java/glide/api/commands/servermodules/FT.java +++ b/java/client/src/main/java/glide/api/commands/servermodules/FT.java @@ -703,6 +703,116 @@ public static CompletableFuture aliasupdate( return executeCommand(client, args, false); } + /** + * Parse a query and return information about how that query was parsed. + * + * @param client The client to execute the command. + * @param indexName The index name to search into. + * @param query The text query to search. It is the same as the query passed as an argument to + * {@link FT#search(BaseClient, String, String)}. + * @return A String representing the execution plan. + * @example + *
{@code
+     * FT.explain(client, "myIndex", "@price:[0 10]").get();
+     * // the result can look like (the result is a string):
+     * //  Field {
+     * //    price
+     * //    0
+     * //    10
+     * // }
+     * }
+ */ + public static CompletableFuture explain( + @NonNull BaseClient client, @NonNull String indexName, @NonNull String query) { + var args = concatenateArrays(new GlideString[] {gs("FT.EXPLAIN"), gs(indexName), gs(query)}); + return executeCommand(client, args, false) + .thenApply(result -> ((GlideString) result).toString()); + } + + /** + * Parse a query and return information about how that query was parsed. + * + * @param client The client to execute the command. + * @param indexName The index name to search into. + * @param query The text query to search. It is the same as the query passed as an argument to + * {@link FT#search(BaseClient, String, String)}. + * @return A GlideString representing the execution plan. + * @example + *
{@code
+     * FT.explain(client, "myIndex", "@price:[0 10]").get();
+     * // the result can look like (the result is a string):
+     * //  Field {
+     * //    price
+     * //    0
+     * //    10
+     * // }
+     * }
+ */ + public static CompletableFuture explain( + @NonNull BaseClient client, @NonNull GlideString indexName, @NonNull GlideString query) { + var args = concatenateArrays(new GlideString[] {gs("FT.EXPLAIN"), indexName, query}); + return executeCommand(client, args, false); + } + + /** + * Same as the {@link FT#explain(BaseClient, String, String)} except that the results are + * displayed in a different format. More useful with cli. + * + * @param client The client to execute the command. + * @param indexName The index name to search into. + * @param query The text query to search. It is the same as the query passed as an argument to + * {@link FT#search(BaseClient, String, String)}. + * @return A String[[ representing the execution plan. + * @example + *
{@code
+     * FT.explaincli(client, "myIndex",  "@price:[0 10]").get();
+     * // the output can look like this (the result is an array)
+     * //  Field {
+     * //    price
+     * //    0
+     * //    10
+     * // }
+     * }
+ */ + public static CompletableFuture explaincli( + @NonNull BaseClient client, @NonNull String indexName, @NonNull String query) { + CompletableFuture result = explaincli(client, gs(indexName), gs(query)); + return result.thenApply( + ret -> Arrays.stream(ret).map(e -> e.getString()).toArray(String[]::new)); + } + + /** + * Same as the {@link FT#explain(BaseClient, String, String)} except that the results are + * displayed in a different format. More useful with cli. + * + * @param client The client to execute the command. + * @param indexName The index name to search into. + * @param query The text query to search. It is the same as the query passed as an argument to + * {@link FT#search(BaseClient, String, String)}. + * @return A GlideString[] representing the execution plan. + * @example + *
{@code
+     * FT.explaincli(client, "myIndex",  "@price:[0 10]").get();
+     * // the output can look like this (the result is an array)
+     * //  Field {
+     * //    price
+     * //    0
+     * //    10
+     * // }
+     * }
+ */ + public static CompletableFuture explaincli( + @NonNull BaseClient client, @NonNull GlideString indexName, @NonNull GlideString query) { + var args = concatenateArrays(new GlideString[] {gs("FT.EXPLAINCLI"), indexName, query}); + CompletableFuture result = + ((GlideClusterClient) client) + .customCommand(args) + .thenApply(ClusterValue::getSingleValue) + .thenApply(ret -> (Object[]) ret) + .thenApply(ret -> castArray(ret, GlideString.class)); + return result; + } + /** * A wrapper for custom command API. * diff --git a/java/integTest/src/test/java/glide/modules/VectorSearchTests.java b/java/integTest/src/test/java/glide/modules/VectorSearchTests.java index 09cf22cabf..e2a1336034 100644 --- a/java/integTest/src/test/java/glide/modules/VectorSearchTests.java +++ b/java/integTest/src/test/java/glide/modules/VectorSearchTests.java @@ -26,6 +26,7 @@ import glide.api.models.commands.FT.FTAggregateOptions.SortBy.SortOrder; import glide.api.models.commands.FT.FTAggregateOptions.SortBy.SortProperty; import glide.api.models.commands.FT.FTCreateOptions; +import glide.api.models.commands.FT.FTCreateOptions.DataType; import glide.api.models.commands.FT.FTCreateOptions.DistanceMetric; import glide.api.models.commands.FT.FTCreateOptions.FieldInfo; import glide.api.models.commands.FT.FTCreateOptions.NumericField; @@ -37,7 +38,10 @@ import glide.api.models.commands.FlushMode; import glide.api.models.commands.InfoOptions.Section; import glide.api.models.exceptions.RequestException; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -825,4 +829,111 @@ public void ft_aliasadd_aliasdel_aliasupdate() { assertInstanceOf(RequestException.class, exception.getCause()); assertTrue(exception.getMessage().contains("Index does not exist")); } + + @SneakyThrows + @Test + public void ft_explain() { + + String indexName = UUID.randomUUID().toString(); + createIndexHelper(indexName); + + // search query containing numeric field. + String query = "@price:[0 10]"; + String result = FT.explain(client, indexName, query).get(); + assertTrue(result.contains("price")); + assertTrue(result.contains("0")); + assertTrue(result.contains("10")); + + GlideString resultGS = FT.explain(client, gs(indexName), gs(query)).get(); + assertTrue((resultGS).toString().contains("price")); + assertTrue((resultGS).toString().contains("0")); + assertTrue((resultGS).toString().contains("10")); + + // search query that returns all data. + resultGS = FT.explain(client, gs(indexName), gs("*")).get(); + result = resultGS.toString(); + assertTrue(result.contains("*")); + + assertEquals(OK, FT.dropindex(client, indexName).get()); + + // missing index throws an error. + var exception = + assertThrows( + ExecutionException.class, + () -> FT.explain(client, UUID.randomUUID().toString(), "*").get()); + assertInstanceOf(RequestException.class, exception.getCause()); + assertTrue(exception.getMessage().contains("Index not found")); + } + + @SneakyThrows + @Test + public void ft_explaincli() { + + String indexName = UUID.randomUUID().toString(); + createIndexHelper(indexName); + + // search query containing numeric field. + String query = "@price:[0 10]"; + String[] result = FT.explaincli(client, indexName, query).get(); + List resultList = new ArrayList<>(); + + for (String r : result) { + resultList.add(r.trim()); // trim to remove any excess white space + } + + assertTrue(resultList.contains("price")); + assertTrue(resultList.contains("0")); + assertTrue(resultList.contains("10")); + + GlideString[] resultGS = FT.explaincli(client, gs(indexName), gs(query)).get(); + List resultListGS = new ArrayList<>(); + for (GlideString r : resultGS) { + resultListGS.add(r.toString().trim()); // trim to remove any excess white space + } + + assertTrue((resultListGS).contains("price")); + assertTrue((resultListGS).contains("0")); + assertTrue((resultListGS).contains("10")); + + List resultListGS2 = new ArrayList<>(); + + // search query that returns all data. + resultGS = FT.explaincli(client, gs(indexName), gs("*")).get(); + for (GlideString r : resultGS) { + resultListGS2.add(r.toString().trim()); // trim to remove any excess white space + } + assertTrue((resultListGS2).contains("*")); + + assertEquals(OK, FT.dropindex(client, indexName).get()); + + // missing index throws an error. + var exception = + assertThrows( + ExecutionException.class, + () -> FT.explaincli(client, UUID.randomUUID().toString(), "*").get()); + assertInstanceOf(RequestException.class, exception.getCause()); + assertTrue(exception.getMessage().contains("Index not found")); + } + + @SneakyThrows + public void createIndexHelper(String indexName) { + FieldInfo numericField = new FieldInfo("price", new NumericField()); + FieldInfo textField = new FieldInfo("title", new TextField()); + + FieldInfo[] fields = new FieldInfo[] {numericField, textField}; + + String prefix = "{hash-search-" + UUID.randomUUID().toString() + "}:"; + + assertEquals( + OK, + FT.create( + client, + indexName, + fields, + FTCreateOptions.builder() + .dataType(DataType.HASH) + .prefixes(new String[] {prefix}) + .build()) + .get()); + } }