From b8094fbe48da2aaf2ca06790c9188fe12179dd40 Mon Sep 17 00:00:00 2001 From: Tihomir Mateev Date: Tue, 4 Feb 2025 14:13:40 +0100 Subject: [PATCH] Revert "Revert "Add support for FT.CREATE #2717 (#3150)" (#3160)" This reverts commit 319e31540393d60412e57cb456c3a0862ff82b41. --- .../core/AbstractRedisAsyncCommands.java | 13 +- .../core/AbstractRedisReactiveCommands.java | 22 +- .../core/RediSearchCommandBuilder.java | 63 ++ .../api/async/RediSearchAsyncCommands.java | 38 ++ .../core/api/async/RedisAsyncCommands.java | 2 +- .../reactive/RediSearchReactiveCommands.java | 38 ++ .../api/reactive/RedisReactiveCommands.java | 13 +- .../core/api/sync/RediSearchCommands.java | 37 ++ .../lettuce/core/api/sync/RedisCommands.java | 2 +- .../api/async/RediSearchAsyncCommands.java | 37 ++ .../cluster/api/sync/RediSearchCommands.java | 37 ++ .../api/sync/RedisClusterCommands.java | 1 + .../lettuce/core/protocol/CommandKeyword.java | 6 +- .../io/lettuce/core/protocol/CommandType.java | 3 + .../lettuce/core/search/DocumentLanguage.java | 144 +++++ .../java/io/lettuce/core/search/Field.java | 522 +++++++++++++++++ .../core/search/arguments/CreateArgs.java | 537 ++++++++++++++++++ .../io/lettuce/core/search/package-info.java | 10 + .../RediSearchCoroutinesCommands.kt | 41 ++ .../lettuce/core/api/RediSearchCommands.java | 37 ++ .../io/lettuce/apigenerator/Constants.java | 2 +- .../RediSearchCommandBuilderUnitTests.java | 92 +++ .../core/json/RediSearchIntegrationTests.java | 84 +++ 23 files changed, 1764 insertions(+), 17 deletions(-) create mode 100644 src/main/java/io/lettuce/core/RediSearchCommandBuilder.java create mode 100644 src/main/java/io/lettuce/core/api/async/RediSearchAsyncCommands.java create mode 100644 src/main/java/io/lettuce/core/api/reactive/RediSearchReactiveCommands.java create mode 100644 src/main/java/io/lettuce/core/api/sync/RediSearchCommands.java create mode 100644 src/main/java/io/lettuce/core/cluster/api/async/RediSearchAsyncCommands.java create mode 100644 src/main/java/io/lettuce/core/cluster/api/sync/RediSearchCommands.java create mode 100644 src/main/java/io/lettuce/core/search/DocumentLanguage.java create mode 100644 src/main/java/io/lettuce/core/search/Field.java create mode 100644 src/main/java/io/lettuce/core/search/arguments/CreateArgs.java create mode 100644 src/main/java/io/lettuce/core/search/package-info.java create mode 100644 src/main/kotlin/io/lettuce/core/api/coroutines/RediSearchCoroutinesCommands.kt create mode 100644 src/main/templates/io/lettuce/core/api/RediSearchCommands.java create mode 100644 src/test/java/io/lettuce/core/RediSearchCommandBuilderUnitTests.java create mode 100644 src/test/java/io/lettuce/core/json/RediSearchIntegrationTests.java diff --git a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java index de095893fa..1a8994d28d 100644 --- a/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java +++ b/src/main/java/io/lettuce/core/AbstractRedisAsyncCommands.java @@ -48,6 +48,8 @@ import io.lettuce.core.protocol.CommandType; import io.lettuce.core.protocol.ProtocolKeyword; import io.lettuce.core.protocol.RedisCommand; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; import reactor.core.publisher.Mono; import java.time.Duration; @@ -79,7 +81,8 @@ public abstract class AbstractRedisAsyncCommands implements RedisAclAsyncC RedisKeyAsyncCommands, RedisStringAsyncCommands, RedisListAsyncCommands, RedisSetAsyncCommands, RedisSortedSetAsyncCommands, RedisScriptingAsyncCommands, RedisServerAsyncCommands, RedisHLLAsyncCommands, BaseRedisAsyncCommands, RedisTransactionalAsyncCommands, - RedisGeoAsyncCommands, RedisClusterAsyncCommands, RedisJsonAsyncCommands { + RedisGeoAsyncCommands, RedisClusterAsyncCommands, RedisJsonAsyncCommands, + RediSearchAsyncCommands { private final StatefulConnection connection; @@ -87,6 +90,8 @@ public abstract class AbstractRedisAsyncCommands implements RedisAclAsyncC private final RedisJsonCommandBuilder jsonCommandBuilder; + private final RediSearchCommandBuilder searchCommandBuilder; + private final Mono parser; /** @@ -101,6 +106,7 @@ public AbstractRedisAsyncCommands(StatefulConnection connection, RedisCode this.connection = connection; this.commandBuilder = new RedisCommandBuilder<>(codec); this.jsonCommandBuilder = new RedisJsonCommandBuilder<>(codec, parser); + this.searchCommandBuilder = new RediSearchCommandBuilder<>(codec); } /** @@ -1478,6 +1484,11 @@ public boolean isOpen() { return connection.isOpen(); } + @Override + public RedisFuture ftCreate(K index, CreateArgs options, List> fields) { + return dispatch(searchCommandBuilder.ftCreate(index, options, fields)); + } + @Override public RedisFuture> jsonArrappend(K key, JsonPath jsonPath, JsonValue... values) { return dispatch(jsonCommandBuilder.jsonArrappend(key, jsonPath, values)); diff --git a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java index 1e9365821f..02f37afd4d 100644 --- a/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java +++ b/src/main/java/io/lettuce/core/AbstractRedisReactiveCommands.java @@ -49,6 +49,8 @@ import io.lettuce.core.protocol.RedisCommand; import io.lettuce.core.protocol.TracedCommand; import io.lettuce.core.resource.ClientResources; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; import io.lettuce.core.tracing.TraceContext; import io.lettuce.core.tracing.TraceContextProvider; import io.lettuce.core.tracing.Tracing; @@ -84,12 +86,12 @@ * @author Tihomir Mateev * @since 4.0 */ -public abstract class AbstractRedisReactiveCommands - implements RedisAclReactiveCommands, RedisHashReactiveCommands, RedisKeyReactiveCommands, - RedisStringReactiveCommands, RedisListReactiveCommands, RedisSetReactiveCommands, - RedisSortedSetReactiveCommands, RedisScriptingReactiveCommands, RedisServerReactiveCommands, - RedisHLLReactiveCommands, BaseRedisReactiveCommands, RedisTransactionalReactiveCommands, - RedisGeoReactiveCommands, RedisClusterReactiveCommands, RedisJsonReactiveCommands { +public abstract class AbstractRedisReactiveCommands implements RedisAclReactiveCommands, + RedisHashReactiveCommands, RedisKeyReactiveCommands, RedisStringReactiveCommands, + RedisListReactiveCommands, RedisSetReactiveCommands, RedisSortedSetReactiveCommands, + RedisScriptingReactiveCommands, RedisServerReactiveCommands, RedisHLLReactiveCommands, + BaseRedisReactiveCommands, RedisTransactionalReactiveCommands, RedisGeoReactiveCommands, + RedisClusterReactiveCommands, RedisJsonReactiveCommands, RediSearchReactiveCommands { private final StatefulConnection connection; @@ -97,6 +99,8 @@ public abstract class AbstractRedisReactiveCommands private final RedisJsonCommandBuilder jsonCommandBuilder; + private final RediSearchCommandBuilder searchCommandBuilder; + private final Mono parser; private final ClientResources clientResources; @@ -117,6 +121,7 @@ public AbstractRedisReactiveCommands(StatefulConnection connection, RedisC this.parser = parser; this.commandBuilder = new RedisCommandBuilder<>(codec); this.jsonCommandBuilder = new RedisJsonCommandBuilder<>(codec, parser); + this.searchCommandBuilder = new RediSearchCommandBuilder<>(codec); this.clientResources = connection.getResources(); this.tracingEnabled = clientResources.tracing().isEnabled(); } @@ -1543,6 +1548,11 @@ public boolean isOpen() { return connection.isOpen(); } + @Override + public Mono ftCreate(K index, CreateArgs options, List> fields) { + return createMono(() -> searchCommandBuilder.ftCreate(index, options, fields)); + } + @Override public Flux jsonArrappend(K key, JsonPath jsonPath, JsonValue... values) { return createDissolvingFlux(() -> jsonCommandBuilder.jsonArrappend(key, jsonPath, values)); diff --git a/src/main/java/io/lettuce/core/RediSearchCommandBuilder.java b/src/main/java/io/lettuce/core/RediSearchCommandBuilder.java new file mode 100644 index 0000000000..1f8f25d303 --- /dev/null +++ b/src/main/java/io/lettuce/core/RediSearchCommandBuilder.java @@ -0,0 +1,63 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core; + +import io.lettuce.core.codec.RedisCodec; +import io.lettuce.core.output.StatusOutput; +import io.lettuce.core.protocol.BaseRedisCommandBuilder; +import io.lettuce.core.protocol.Command; +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.protocol.CommandKeyword; +import io.lettuce.core.search.arguments.CreateArgs; +import io.lettuce.core.search.Field; + +import java.util.List; + +import static io.lettuce.core.protocol.CommandType.*; + +/** + * Command builder for RediSearch commands. + * + * @param Key type. + * @param Value type. + * @since 6.6 + */ +class RediSearchCommandBuilder extends BaseRedisCommandBuilder { + + RediSearchCommandBuilder(RedisCodec codec) { + super(codec); + } + + /** + * Create a new index with the given name, index options and fields. + * + * @param index the index name + * @param createArgs the index options + * @param fields the fields + * @return the result of the create command + */ + public Command ftCreate(K index, CreateArgs createArgs, List> fields) { + notNullKey(index); + notEmpty(fields.toArray()); + + CommandArgs args = new CommandArgs<>(codec).addKey(index); + + if (createArgs != null) { + createArgs.build(args); + } + + args.add(CommandKeyword.SCHEMA); + + for (Field field : fields) { + field.build(args); + } + + return createCommand(FT_CREATE, new StatusOutput<>(codec), args); + + } + +} diff --git a/src/main/java/io/lettuce/core/api/async/RediSearchAsyncCommands.java b/src/main/java/io/lettuce/core/api/async/RediSearchAsyncCommands.java new file mode 100644 index 0000000000..7b549160cb --- /dev/null +++ b/src/main/java/io/lettuce/core/api/async/RediSearchAsyncCommands.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.api.async; + +import java.util.List; +import io.lettuce.core.RedisFuture; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; + +/** + * Asynchronous executed commands for RediSearch functionality + * + * @param Key type. + * @param Value type. + * @author Tihomir Mateev + * @see RediSearch + * @since 6.6 + * @generated by io.lettuce.apigenerator.CreateAsyncApi + */ +public interface RediSearchAsyncCommands { + + /** + * Create a new index with the given name, index options, and fields. + * + * @param index the index name, as a key + * @param options the index {@link CreateArgs} + * @param fields the {@link Field}s of the index + * @return the result of the create command + * @since 6.6 + * @see FT.CREATE + */ + RedisFuture ftCreate(K index, CreateArgs options, List> fields); + +} diff --git a/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java b/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java index 6ff3ef9ad1..5689de96f5 100644 --- a/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java +++ b/src/main/java/io/lettuce/core/api/async/RedisAsyncCommands.java @@ -37,7 +37,7 @@ public interface RedisAsyncCommands extends BaseRedisAsyncCommands, RedisHashAsyncCommands, RedisHLLAsyncCommands, RedisKeyAsyncCommands, RedisListAsyncCommands, RedisScriptingAsyncCommands, RedisServerAsyncCommands, RedisSetAsyncCommands, RedisSortedSetAsyncCommands, RedisStreamAsyncCommands, RedisStringAsyncCommands, - RedisTransactionalAsyncCommands, RedisJsonAsyncCommands { + RedisTransactionalAsyncCommands, RedisJsonAsyncCommands, RediSearchAsyncCommands { /** * Authenticate to the server. diff --git a/src/main/java/io/lettuce/core/api/reactive/RediSearchReactiveCommands.java b/src/main/java/io/lettuce/core/api/reactive/RediSearchReactiveCommands.java new file mode 100644 index 0000000000..ba2268cca3 --- /dev/null +++ b/src/main/java/io/lettuce/core/api/reactive/RediSearchReactiveCommands.java @@ -0,0 +1,38 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.api.reactive; + +import java.util.List; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; +import reactor.core.publisher.Mono; + +/** + * Reactive executed commands for RediSearch functionality + * + * @param Key type. + * @param Value type. + * @author Tihomir Mateev + * @see RediSearch + * @since 6.6 + * @generated by io.lettuce.apigenerator.CreateReactiveApi + */ +public interface RediSearchReactiveCommands { + + /** + * Create a new index with the given name, index options, and fields. + * + * @param index the index name, as a key + * @param options the index {@link CreateArgs} + * @param fields the {@link Field}s of the index + * @return the result of the create command + * @since 6.6 + * @see FT.CREATE + */ + Mono ftCreate(K index, CreateArgs options, List> fields); + +} diff --git a/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java b/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java index 2f75efcc92..76d24ddf10 100644 --- a/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java +++ b/src/main/java/io/lettuce/core/api/reactive/RedisReactiveCommands.java @@ -31,12 +31,13 @@ * @author Mark Paluch * @since 5.0 */ -public interface RedisReactiveCommands extends BaseRedisReactiveCommands, RedisAclReactiveCommands, - RedisClusterReactiveCommands, RedisFunctionReactiveCommands, RedisGeoReactiveCommands, - RedisHashReactiveCommands, RedisHLLReactiveCommands, RedisKeyReactiveCommands, - RedisListReactiveCommands, RedisScriptingReactiveCommands, RedisServerReactiveCommands, - RedisSetReactiveCommands, RedisSortedSetReactiveCommands, RedisStreamReactiveCommands, - RedisStringReactiveCommands, RedisTransactionalReactiveCommands, RedisJsonReactiveCommands { +public interface RedisReactiveCommands + extends BaseRedisReactiveCommands, RedisAclReactiveCommands, RedisClusterReactiveCommands, + RedisFunctionReactiveCommands, RedisGeoReactiveCommands, RedisHashReactiveCommands, + RedisHLLReactiveCommands, RedisKeyReactiveCommands, RedisListReactiveCommands, + RedisScriptingReactiveCommands, RedisServerReactiveCommands, RedisSetReactiveCommands, + RedisSortedSetReactiveCommands, RedisStreamReactiveCommands, RedisStringReactiveCommands, + RedisTransactionalReactiveCommands, RedisJsonReactiveCommands, RediSearchReactiveCommands { /** * Authenticate to the server. diff --git a/src/main/java/io/lettuce/core/api/sync/RediSearchCommands.java b/src/main/java/io/lettuce/core/api/sync/RediSearchCommands.java new file mode 100644 index 0000000000..c76f9867e6 --- /dev/null +++ b/src/main/java/io/lettuce/core/api/sync/RediSearchCommands.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.api.sync; + +import java.util.List; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; + +/** + * Synchronous executed commands for RediSearch functionality + * + * @param Key type. + * @param Value type. + * @author Tihomir Mateev + * @see RediSearch + * @since 6.6 + * @generated by io.lettuce.apigenerator.CreateSyncApi + */ +public interface RediSearchCommands { + + /** + * Create a new index with the given name, index options, and fields. + * + * @param index the index name, as a key + * @param options the index {@link CreateArgs} + * @param fields the {@link Field}s of the index + * @return the result of the create command + * @since 6.6 + * @see FT.CREATE + */ + String ftCreate(K index, CreateArgs options, List> fields); + +} diff --git a/src/main/java/io/lettuce/core/api/sync/RedisCommands.java b/src/main/java/io/lettuce/core/api/sync/RedisCommands.java index 98f21b4cb2..e7f74d5378 100644 --- a/src/main/java/io/lettuce/core/api/sync/RedisCommands.java +++ b/src/main/java/io/lettuce/core/api/sync/RedisCommands.java @@ -36,7 +36,7 @@ public interface RedisCommands extends BaseRedisCommands, RedisAclCo RedisFunctionCommands, RedisGeoCommands, RedisHashCommands, RedisHLLCommands, RedisKeyCommands, RedisListCommands, RedisScriptingCommands, RedisServerCommands, RedisSetCommands, RedisSortedSetCommands, RedisStreamCommands, RedisStringCommands, - RedisTransactionalCommands, RedisJsonCommands { + RedisTransactionalCommands, RedisJsonCommands, RediSearchCommands { /** * Authenticate to the server. diff --git a/src/main/java/io/lettuce/core/cluster/api/async/RediSearchAsyncCommands.java b/src/main/java/io/lettuce/core/cluster/api/async/RediSearchAsyncCommands.java new file mode 100644 index 0000000000..d9fb189253 --- /dev/null +++ b/src/main/java/io/lettuce/core/cluster/api/async/RediSearchAsyncCommands.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.cluster.api.async; + +import java.util.List; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; + +/** + * Asynchronous executed commands on a node selection for RediSearch functionality + * + * @param Key type. + * @param Value type. + * @author Tihomir Mateev + * @see RediSearch + * @since 6.6 + * @generated by io.lettuce.apigenerator.CreateAsyncNodeSelectionClusterApi + */ +public interface RediSearchAsyncCommands { + + /** + * Create a new index with the given name, index options, and fields. + * + * @param index the index name, as a key + * @param options the index {@link CreateArgs} + * @param fields the {@link Field}s of the index + * @return the result of the create command + * @since 6.6 + * @see FT.CREATE + */ + AsyncExecutions ftCreate(K index, CreateArgs options, List> fields); + +} diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/RediSearchCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/RediSearchCommands.java new file mode 100644 index 0000000000..00cbc7b8bc --- /dev/null +++ b/src/main/java/io/lettuce/core/cluster/api/sync/RediSearchCommands.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.cluster.api.sync; + +import java.util.List; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; + +/** + * Synchronous executed commands on a node selection for RediSearch functionality + * + * @param Key type. + * @param Value type. + * @author Tihomir Mateev + * @see RediSearch + * @since 6.6 + * @generated by io.lettuce.apigenerator.CreateSyncNodeSelectionClusterApi + */ +public interface RediSearchCommands { + + /** + * Create a new index with the given name, index options, and fields. + * + * @param index the index name, as a key + * @param options the index {@link CreateArgs} + * @param fields the {@link Field}s of the index + * @return the result of the create command + * @since 6.6 + * @see FT.CREATE + */ + Executions ftCreate(K index, CreateArgs options, List> fields); + +} diff --git a/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java b/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java index 988975740c..d66093759f 100644 --- a/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java +++ b/src/main/java/io/lettuce/core/cluster/api/sync/RedisClusterCommands.java @@ -34,6 +34,7 @@ * @param Value type. * @author Mark Paluch * @author dengliming + * @author Tihomir Mateev * @since 4.0 */ public interface RedisClusterCommands diff --git a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java index c9d782afd8..62c6e84cd0 100644 --- a/src/main/java/io/lettuce/core/protocol/CommandKeyword.java +++ b/src/main/java/io/lettuce/core/protocol/CommandKeyword.java @@ -49,7 +49,11 @@ public enum CommandKeyword implements ProtocolKeyword { MIGRATING, IMPORTING, SAVE, SKIPME, SLAVES, STREAM, STORE, SUM, SEGFAULT, SETUSER, TAKEOVER, TRACKING, TRACKINGINFO, TYPE, UNBLOCK, USERS, USAGE, WEIGHTS, WHOAMI, - WITHMATCHLEN, WITHSCORE, WITHSCORES, WITHVALUES, XOR, XX, YES, INDENT, NEWLINE, SPACE, GT, LT; + WITHMATCHLEN, WITHSCORE, WITHSCORES, WITHVALUES, XOR, XX, YES, INDENT, NEWLINE, SPACE, GT, LT, + + MAXTEXTFIELDS, PREFIX, FILTER, LANGUAGE, LANGUAGE_FIELD, SCORE, SCORE_FIELD, PAYLOAD_FIELD, TEMPORARY, NOOFFSETS, NOHL, NOFIELDS, NOFREQS, SKIPINITIALSCAN, STOPWORDS, AS, SORTABLE, SCHEMA, UNF, NOINDEX, + + NOSTEM, PHONETIC, WEIGHT, SEPARATOR, CASESENSITIVE, WITHSUFFIXTRIE, INDEXEMPTY, INDEXMISSING; public final byte[] bytes; diff --git a/src/main/java/io/lettuce/core/protocol/CommandType.java b/src/main/java/io/lettuce/core/protocol/CommandType.java index 9a2fcf83f6..3d5b9f4e9d 100644 --- a/src/main/java/io/lettuce/core/protocol/CommandType.java +++ b/src/main/java/io/lettuce/core/protocol/CommandType.java @@ -112,6 +112,9 @@ public enum CommandType implements ProtocolKeyword { "JSON.OBJLEN"), JSON_SET("JSON.SET"), JSON_STRAPPEND("JSON.STRAPPEND"), JSON_STRLEN( "JSON.STRLEN"), JSON_TOGGLE("JSON.TOGGLE"), JSON_TYPE("JSON.TYPE"), + // RediSearch + FT_CREATE("FT.CREATE"), + // Others TIME, WAIT, diff --git a/src/main/java/io/lettuce/core/search/DocumentLanguage.java b/src/main/java/io/lettuce/core/search/DocumentLanguage.java new file mode 100644 index 0000000000..ba3dd1d161 --- /dev/null +++ b/src/main/java/io/lettuce/core/search/DocumentLanguage.java @@ -0,0 +1,144 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.search; + +import java.util.Locale; + +/** + * Supported document languages. + * + * @since 6.6 + * @author Tihomir Mateev + * @see Stemming + */ +public enum DocumentLanguage { + + /** + * Arabic + */ + ARABIC("arabic", new Locale("ar")), + /** + * Armenian + */ + ARMENIAN("armenian", new Locale("hy")), + /** + * Danish + */ + DANISH("danish", new Locale("da")), + /** + * Dutch + */ + DUTCH("dutch", new Locale("nl")), + /** + * English + */ + ENGLISH("english", Locale.ENGLISH), + /** + * Finnish + */ + FINNISH("finnish", new Locale("fi")), + /** + * French + */ + FRENCH("french", Locale.FRENCH), + /** + * German + */ + GERMAN("german", Locale.GERMAN), + /** + * Hungarian + */ + HUNGARIAN("hungarian", new Locale("hu")), + /** + * Italian + */ + ITALIAN("italian", Locale.ITALIAN), + /** + * Norwegian + */ + NORWEGIAN("norwegian", new Locale("no")), + /** + * Portuguese + */ + PORTUGUESE("portuguese", new Locale("pt")), + /** + * Romanian + */ + ROMANIAN("romanian", new Locale("ro")), + /** + * Russian + */ + RUSSIAN("russian", new Locale("ru")), + /** + * Serbian + */ + SERBIAN("serbian", new Locale("sr")), + /** + * Spanish + */ + SPANISH("spanish", new Locale("es")), + /** + * Swedish + */ + SWEDISH("swedish", new Locale("sv")), + /** + * Tamil + */ + TAMIL("tamil", new Locale("ta")), + /** + * Turkish + */ + TURKISH("turkish", new Locale("tr")), + /** + * Yiddish + */ + YIDDISH("yiddish", new Locale("yi")), + /** + * Chinese + * + * @see Chinese + * support + */ + CHINESE("chinese", Locale.CHINESE); + + private final String language; + + private final Locale locale; + + DocumentLanguage(String language, Locale locale) { + this.language = language; + this.locale = locale; + } + + @Override + public String toString() { + return language; + } + + /** + * @return the {@link DocumentLanguage} as a {@link Locale} + */ + public Locale getLocale() { + return locale; + } + + /** + * Retrieve the {@link DocumentLanguage} for a given {@link Locale}. + * + * @param locale the locale + * @return the {@link DocumentLanguage} + */ + public static DocumentLanguage getLanguage(Locale locale) { + for (DocumentLanguage language : DocumentLanguage.values()) { + if (language.getLocale().getLanguage().equals(locale.getLanguage())) { + return language; + } + } + throw new UnsupportedOperationException("No language found for locale: " + locale); + } + +} diff --git a/src/main/java/io/lettuce/core/search/Field.java b/src/main/java/io/lettuce/core/search/Field.java new file mode 100644 index 0000000000..51049936d0 --- /dev/null +++ b/src/main/java/io/lettuce/core/search/Field.java @@ -0,0 +1,522 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.search; + +import io.lettuce.core.protocol.CommandArgs; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static io.lettuce.core.protocol.CommandKeyword.*; + +/** + * Representation of a field in a RediSearch index. + * + * @param Key type + * @see Field + * and type options + * @since 6.6 + * @author Tihomir Mateev + */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +public class Field { + + /** + * Field types + * + * @see Field and + * type options + */ + public enum Type { + /** + * Allows full-text search queries against the value in this attribute. + */ + TEXT, + /** + * Allows exact-match queries, 1 as categories or primary keys, against the value in this attribute. + * + * @see Tag Fields + */ + TAG, + /** + * Allows numeric range queries against the value in this attribute. See query syntax docs for details on how to use + * numeric ranges. + */ + NUMERIC, + /** + * Allows radius range queries against the value (point) in this attribute. The value of the attribute must be a string + * containing a longitude (first) and latitude separated by a comma. + */ + GEO, + /** + * Allows vector queries against the value in this attribute. Requires query dialect 2 or above (introduced in + * RediSearch v2.4). + * + * @see Vector + * Fields + * @see Query + * Dialect v2 + */ + VECTOR, + /** + * Allows polygon queries against the value in this attribute. The value of the attribute must follow a + * WKT notation list of 2D points + * representing the polygon edges POLYGON((x1 y1, x2 y2, ...) separated by a comma. + *

+ * A GEOSHAPE field type can be followed by one of the following coordinate systems: + *

    + *
  • SPHERICAL for Geographic longitude and latitude coordinates
  • + *
  • FLAT for Cartesian X Y coordinates
  • + *
  • The default coordinate system is SPHERICAL.
  • + *
+ * + * Currently GEOSHAPE doesn't support JSON multi-value and SORTABLE option. + */ + GEOSHAPE + } + + /** + * Phonetic matchers + * + * @see Phonetic + * Matching + */ + public enum PhoneticMatcher { + + ENGLISH("dm:en"), FRENCH("dm:fr"), PORTUGUESE("dm:pt"), SPANISH("dm:es"); + + PhoneticMatcher(String matcher) { + this.matcher = matcher; + } + + private final String matcher; + + /** + * @return the {@link String} representation of the matcher + */ + public String getMatcher() { + return matcher; + } + + } + + private K name; + + private Optional as = Optional.empty(); + + private Type type; + + private boolean sortable; + + private boolean unNormalizedForm; + + private boolean noStemming; + + private boolean noIndex; + + private Optional phonetic = Optional.empty();; + + private boolean caseSensitive; + + private boolean withSuffixTrie; + + private boolean indexEmpty; + + private boolean indexMissing; + + private Optional weight = Optional.empty();; + + private Optional separator = Optional.empty();; + + private Field() { + } + + /** + * Create a new {@link Field} using the builder pattern. + *

+ * One needs to call {@link Builder#build()} to build a single {@link Field} or {@link Builder#buildFields()} to build a + * {@link java.util.List} of {@link Field}s. + * + * @param Key type + * @return a new {@link Builder} + */ + public static Builder builder() { + return new Builder<>(); + } + + /** + * Builder for {@link Field}. + * + * @param Key type + */ + public static class Builder { + + private final Field instance = new Field<>(); + + /** + * The name of the field in a hash the index is going to be based on. + * + * @param name the name of the field + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder name(K name) { + instance.name = name; + return this; + } + + // TODO handling JsonPath + // public Builder name(JsonPath path) { + // instance.name = path.toString(); + // return this; + // } + + /** + * The type of the field. + * + * @param type the type of the field + * @return the instance of the {@link Builder} for the purpose of method chaining + * @see Type + */ + public Builder type(Type type) { + instance.type = type; + return this; + } + + /** + * Defines the attribute associated to the identifier. For example, you can use this feature to alias a complex JSONPath + * expression with more memorable (and easier to type) name. + * + * @param as the field name to be used in queries + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder as(K as) { + instance.as = Optional.of(as); + return this; + } + + /** + * {@link Type#NUMERIC}, {@link Type#TAG}, {@link Type#TEXT}, or {@link Type#GEO} attributes can have an optional + * SORTABLE argument. As the user sorts the results by the value of this attribute, the results are available with very + * low latency. Default is false (not sortable). + *

+ * Note that this adds memory overhead, so consider not declaring it on large text attributes. You can sort an attribute + * without the SORTABLE option, but the latency is not as good as with SORTABLE. + * + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder sortable() { + instance.sortable = true; + return this; + } + + /** + * By default, for hashes (not with JSON) SORTABLE applies normalization to the indexed value (characters set to + * lowercase, removal of diacritics). When using the unnormalized form (UNF), you can disable the normalization and keep + * the original form of the value. With JSON, UNF is implicit with SORTABLE (normalization is disabled). + *

+ * Default is false (normalized form). + * + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder unNormalizedForm() { + instance.sortable = true; + instance.unNormalizedForm = true; + return this; + } + + /** + * By default, the index applies stemming to {@link Type#TEXT} fields. If you don't want to apply stemming to the field, + * you can use the NOSTEM argument. This may be ideal for things like proper names. + * + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder noStemming() { + instance.noStemming = true; + return this; + } + + /** + * Attributes can have the NOINDEX option, which means they will not be indexed. This is useful in conjunction with + * {@link Builder#sortable()}, to create attributes whose update using PARTIAL will not cause full reindexing of the + * document. If an attribute has NOINDEX and doesn't have SORTABLE, it will just be ignored by the index. + * + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder noIndex() { + instance.noIndex = true; + return this; + } + + /** + * Phonetic matching is a feature that allows you to search for similar-sounding words. For example, a search for + * "Smith" will also return results for "Smyth". Phonetic matching is language-specific, and you can specify the + * language using the PHONETIC argument. + *

+ * The following languages are supported: + *

    + *
  • ENGLISH
  • + *
  • FRENCH
  • + *
  • PORTUGUESE
  • x + *
  • SPANISH
  • + *
+ * + * @see Phonetic + * Matching + * @param matcher the phonetic matcher + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder phonetic(PhoneticMatcher matcher) { + instance.phonetic = Optional.of(matcher); + return this; + } + + /** + * The weight of the field. Works with {@link Type#TEXT} attributes, declares the importance of this attribute when + * calculating result accuracy. This is a multiplication factor. The default weight is 1. + * + * @param weight the weight of the field + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder weight(long weight) { + instance.weight = Optional.of(weight); + return this; + } + + /** + * The separator for {@link Type#TAG} attributes. The default separator is a comma. + * + * @param separator the separator for tag fields + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder separator(String separator) { + instance.separator = Optional.of(separator); + return this; + } + + /** + * Keeps the original letter cases of the tags. If not specified, the characters are converted to lowercase. Works with + * {@link Type#TAG} attributes. + * + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder caseSensitive() { + instance.caseSensitive = true; + return this; + } + + /** + * For {@link Type#TEXT} and {@link Type#TAG} attributes, keeps a suffix trie with all terms which match the suffix. It + * is used to optimize contains (foo) and suffix (*foo) queries. Otherwise, a brute-force search on the trie is + * performed. If the suffix trie exists for some fields, these queries will be disabled for other fields. + * + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder withSuffixTrie() { + instance.withSuffixTrie = true; + return this; + } + + /** + * For {@link Type#TEXT} and {@link Type#TAG} attributes, introduced in v2.10, allows you to index and search for empty + * strings. By default, empty strings are not indexed. + * + * @return the instance of the {@link Builder} for the purpose of method chaining + */ + public Builder indexEmpty() { + instance.indexEmpty = true; + return this; + } + + /** + * For all field types, introduced in v2.10, allows you to search for missing values, that is, documents that do not + * contain a specific field. Note the difference between a field with an empty value and a document with a missing + * value. By default, missing values are not indexed. + */ + public Builder indexMissing() { + instance.indexMissing = true; + return this; + } + + /** + * Build a single {@link Field}. + * + * @return the instance of the {@link Field} + */ + public Field build() { + return instance; + } + + /** + * Build a {@link java.util.List} of {@link Field}s, containing the current {@link Field} as the only element of the + * list. + * + * @return the instance of the {@link Field} + */ + public List> buildFields() { + List> fields = new ArrayList<>(); + fields.add(instance); + return fields; + } + + } + + /** + * @return the type of the field + * @see Builder#type(Type) + */ + public Type getType() { + return type; + } + + /** + * @return the name of the field + * @see Builder#name(Object) + */ + public K getName() { + return name; + } + + /** + * @return the alias of the field + * @see Builder#as(Object) + */ + public Optional getAs() { + return as; + } + + /** + * @return if the field should be sortable + * @see Builder#sortable() + */ + public boolean isSortable() { + return sortable; + } + + /** + * @return if the field should be in unnormalized form + * @see Builder#unNormalizedForm() + */ + public boolean isUnNormalizedForm() { + return unNormalizedForm; + } + + /** + * @return if the field should not be indexed + * @see Builder#noIndex() + */ + public boolean isNoIndex() { + return noIndex; + } + + /** + * @return if the field should not be stemmed + * @see Builder#noStemming() + */ + public boolean isNoStemming() { + return noStemming; + } + + /** + * @return the setting for phonetic matching + * @see Builder#phonetic(PhoneticMatcher) + */ + public Optional isPhonetic() { + return phonetic; + } + + /** + * @return if the field should be case sensitive + * @see Builder#caseSensitive() + */ + public boolean isCaseSensitive() { + return caseSensitive; + } + + /** + * @return if the field should have a suffix trie + * @see Builder#withSuffixTrie() + */ + public boolean isWithSuffixTrie() { + return withSuffixTrie; + } + + /** + * @return if the field should index empty values + * @see Builder#indexEmpty() + */ + public boolean isIndexEmpty() { + return indexEmpty; + } + + /** + * @return if the field should index missing values + * @see Builder#indexMissing() + */ + public boolean isIndexMissing() { + return indexMissing; + } + + /** + * @return the weight of the field + * @see Builder#weight(long) + */ + public Optional getWeight() { + return weight; + } + + /** + * @return the separator for tag fields + * @see Builder#separator(String) + */ + public Optional getSeparator() { + return separator; + } + + /** + * Add all configured arguments to the final command + * + * @param args the command arguments to modify + */ + public void build(CommandArgs args) { + args.addKey(name); + as.ifPresent(a -> args.add(AS).addKey(a)); + args.add(type.toString()); + if (sortable) { + args.add(SORTABLE); + if (unNormalizedForm) { + args.add(UNF); + } + } + if (noStemming) { + args.add(NOSTEM); + } + if (noIndex) { + args.add(NOINDEX); + } + phonetic.ifPresent(p -> args.add(PHONETIC).add(p.getMatcher())); + weight.ifPresent(w -> args.add(WEIGHT).add(w)); + separator.ifPresent(s -> args.add(SEPARATOR).add(s)); + if (caseSensitive) { + args.add(CASESENSITIVE); + } + if (withSuffixTrie) { + args.add(WITHSUFFIXTRIE); + } + if (indexEmpty) { + args.add(INDEXEMPTY); + } + if (indexMissing) { + args.add(INDEXMISSING); + } + } + +} diff --git a/src/main/java/io/lettuce/core/search/arguments/CreateArgs.java b/src/main/java/io/lettuce/core/search/arguments/CreateArgs.java new file mode 100644 index 0000000000..2e61affba3 --- /dev/null +++ b/src/main/java/io/lettuce/core/search/arguments/CreateArgs.java @@ -0,0 +1,537 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.search.arguments; + +import io.lettuce.core.protocol.CommandArgs; +import io.lettuce.core.search.DocumentLanguage; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.OptionalDouble; +import java.util.OptionalLong; + +import static io.lettuce.core.protocol.CommandKeyword.*; + +/** + * Argument list builder for {@code FT.CREATE}. + * + * @param Key type. + * @param Value type. + * @see FT.CREATE + * @since 6.6 + * @author Tihomir Mateev + */ +@SuppressWarnings("OptionalUsedAsFieldOrParameterType") +public class CreateArgs { + + /** + * Possible target types for the index. + */ + public enum TargetType { + HASH, JSON + } + + private Optional on = Optional.of(TargetType.HASH); + + private final List prefixes = new ArrayList<>(); + + private Optional filter = Optional.empty(); + + private Optional defaultLanguage = Optional.empty(); + + private Optional languageField = Optional.empty(); + + private OptionalDouble defaultScore = OptionalDouble.empty(); + + private Optional scoreField = Optional.empty(); + + private Optional payloadField = Optional.empty(); + + private boolean maxTextFields; + + private OptionalLong temporary = OptionalLong.empty(); + + private boolean noOffsets; + + private boolean noHighlight; + + private boolean noFields; + + private boolean noFrequency; + + private boolean skipInitialScan; + + private Optional> stopWords = Optional.empty(); + + /** + * Used to build a new instance of the {@link CreateArgs}. + * + * @return a {@link Builder} that provides the option to build up a new instance of the {@link CreateArgs} + * @param the key type + * @param the value type + */ + public static Builder builder() { + return new Builder<>(); + } + + /** + * Builder for {@link CreateArgs}. + *

+ * As a final step the {@link Builder#build()} method needs to be executed to create the final {@link CreateArgs} instance. + * + * @param the key type + * @param the value type + * @see FT.CREATE + */ + public static class Builder { + + private final CreateArgs instance = new CreateArgs<>(); + + /** + * Set the {@link TargetType} type for the index. Defaults to {@link TargetType#HASH}. + * + * @param targetType the target type + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder on(TargetType targetType) { + instance.on = Optional.of(targetType); + return this; + } + + /** + * Add a prefix to the index. You can add several prefixes to index. Default setting is * (all keys). + * + * @param prefix the prefix + * @return the instance of the current {@link Builder} for the purpose of method chaining + * @see {@link Builder#addPrefixes(List)} + */ + public Builder addPrefix(K prefix) { + instance.prefixes.add(prefix); + return this; + } + + /** + * Add a list of prefixes to the index. You can add several prefixes to index. Default setting is * (all keys). + * + * @param prefixes a {@link List} of prefixes + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder addPrefixes(List prefixes) { + instance.prefixes.addAll(prefixes); + return this; + } + + /** + * Set a filter for the index. Default setting is to have no filter. + *

+ * It is possible to use @__key to access the key that was just added/changed. A field can be used to set field name by + * passing 'FILTER @indexName=="myindexname"'. + * + * @param filter a filter expression with the full RediSearch aggregation expression language + * @return the instance of the current {@link Builder} for the purpose of method chaining + * @see RediSearch Query + */ + public Builder filter(V filter) { + instance.filter = Optional.of(filter); + return this; + } + + /** + * Set the default language for the documents in the index. The default setting is English. + * + * @param language the default language + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder defaultLanguage(DocumentLanguage language) { + instance.defaultLanguage = Optional.of(language); + return this; + } + + /** + * Set the field that contains the language setting for the documents in the index. The default setting is to have no + * language field. + * + * @param field the language field + * @return the instance of the current {@link Builder} for the purpose of method chaining + * @see Stemming + */ + public Builder languageField(K field) { + instance.languageField = Optional.of(field); + return this; + } + + /** + * Set the default score for the documents in the index. The default setting is 1.0. + * + * @param score the default score + * @return the instance of the current {@link Builder} for the purpose of method chaining + * @see Scoring + */ + public Builder defaultScore(double score) { + instance.defaultScore = OptionalDouble.of(score); + return this; + } + + /** + * Set the field that contains the score setting for the documents in the index. The default setting is a score of 1.0. + * + * @param field the score field + * @return the instance of the current {@link Builder} for the purpose of method chaining + * @see Scoring + */ + public Builder scoreField(K field) { + instance.scoreField = Optional.of(field); + return this; + } + + /** + * Set the field that contains the payload setting for the documents in the index. The default setting is to have no + * payload field. + *

+ * This should be a document attribute that you use as a binary safe payload string to the document that can be + * evaluated at query time by a custom scoring function or retrieved to the client + * + * @param field the payload field + * @return the instance of the current {@link Builder} for the purpose of method chaining + * @see Scoring + */ + public Builder payloadField(K field) { + instance.payloadField = Optional.of(field); + return this; + } + + /** + * Set the maximum number of text fields in the index. The default setting is to have no limit. + *

+ * Forces RediSearch to encode indexes as if there were more than 32 text attributes, which allows you to add additional + * attributes (beyond 32) using FT.ALTER. For efficiency, RediSearch encodes indexes differently if they are created + * with less than 32 text attributes. + * + * @param maxTextFields the maximum number of text fields + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder maxTextFields(boolean maxTextFields) { + instance.maxTextFields = maxTextFields; + return this; + } + + /** + * Set the temporary index expiration time in seconds. The default setting is to have no expiration time. + *

+ * Creates a lightweight temporary index that expires after a specified period of inactivity, in seconds. The internal + * idle timer is reset whenever the index is searched or added to. Because such indexes are lightweight, you can create + * thousands of such indexes without negative performance implications and, therefore, you should consider using + * {@link Builder#skipInitialScan(boolean)} to avoid costly scanning. + *

+ * Warning: When temporary indexes expire, they drop all the records associated with them. FT.DROPINDEX was introduced + * with a default of not deleting docs and a DD flag that enforced deletion. However, for temporary indexes, documents + * are deleted along with the index. Historically, RediSearch used an FT.ADD command, which made a connection between + * the document and the index. Then, FT.DROP, also a hystoric command, deleted documents by default. In version 2.x, + * RediSearch indexes hashes and JSONs, and the dependency between the index and documents no longer exists. + * + * @param seconds the temporary index expiration time in seconds + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder temporary(long seconds) { + instance.temporary = OptionalLong.of(seconds); + return this; + } + + /** + * Set the no offsets flag. The default setting is to have offsets. + *

+ * It saves memory, but does not allow exact searches or highlighting. It implies + * {@link Builder#noHighlighting(boolean)} is set to true. + * + * @param noOffsets the no offsets flag + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder noOffsets(boolean noOffsets) { + instance.noOffsets = noOffsets; + return this; + } + + /** + * Set the no highlighting flag. The default setting is to have highlighting. + *

+ * Conserves storage space and memory by disabling highlighting support. If set, the corresponding byte offsets for term + * positions are not stored. NOHL is also implied by NOOFFSETS. + * + * @param noHL the no highlighting flag + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder noHighlighting(boolean noHL) { + instance.noHighlight = noHL; + return this; + } + + /** + * Set the no fields flag. The default setting is to have fields. + *

+ * Does not store attribute bits for each term. It saves memory, but it does not allow filtering by specific attributes. + * + * @param noFields the no fields flag + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder noFields(boolean noFields) { + instance.noFields = noFields; + return this; + } + + /** + * Set the no frequency flag. The default setting is to have frequencies. + *

+ * Does not store the frequency of each term. It saves memory, but it does not allow sorting by frequency of a given + * term. + * + * @param noFreqs the no frequency flag + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder noFrequency(boolean noFreqs) { + instance.noFrequency = noFreqs; + return this; + } + + /** + * Set the skip initial scan flag. The default setting is to scan initially. + * + * @param skipInitialScan the skip initial scan flag + * @return the instance of the current {@link Builder} for the purpose of method chaining + */ + public Builder skipInitialScan(boolean skipInitialScan) { + instance.skipInitialScan = skipInitialScan; + return this; + } + + /** + * Set the index with a custom stopword list, to be ignored during indexing and search time. + *

+ * If not set, FT.CREATE takes the default list of stopwords. If {count} is set to 0, the index does not have stopwords. + * + * @param stopWords a list of stop words + * @return the instance of the current {@link Builder} for the purpose of method chaining + * @see Stop + * words + */ + public Builder stopWords(List stopWords) { + instance.stopWords = Optional.of(stopWords); + return this; + } + + public CreateArgs build() { + return instance; + } + + } + + /** + * Get the target type for the index. + * + * @return the target type + * @see TargetType + * @see Builder#on(TargetType) + */ + public Optional getOn() { + return on; + } + + /** + * Get the prefixes for the index. + * + * @return the prefixes + * @see Builder#addPrefix(Object) + * @see Builder#addPrefixes(List) + */ + public List getPrefixes() { + return prefixes; + } + + /** + * Get the filter for the index. + * + * @return the filter + * @see Builder#filter(Object) + */ + public Optional getFilter() { + return filter; + } + + /** + * Get the default language for the documents in the index. + * + * @return the default language + * @see Builder#defaultLanguage(DocumentLanguage) + */ + public Optional getDefaultLanguage() { + return defaultLanguage; + } + + /** + * Get the field that contains the language setting for the documents in the index. + * + * @return the language field + * @see Builder#languageField(Object) + */ + public Optional getLanguageField() { + return languageField; + } + + /** + * Get the default score for the documents in the index. + * + * @return the default score + * @see Builder#defaultScore(double) + */ + public OptionalDouble getDefaultScore() { + return defaultScore; + } + + /** + * Get the field that contains the score setting for the documents in the index. + * + * @return the score field + * @see Builder#scoreField(Object) + */ + public Optional getScoreField() { + return scoreField; + } + + /** + * Get the field that contains the payload setting for the documents in the index. + * + * @return the payload field + * @see Builder#payloadField(Object) + */ + public Optional getPayloadField() { + return payloadField; + } + + /** + * Get the maximum number of text fields in the index. + * + * @return the maximum number of text fields + * @see Builder#maxTextFields(boolean) + */ + public boolean isMaxTextFields() { + return maxTextFields; + } + + /** + * Get the temporary index expiration time in seconds. + * + * @return the temporary index expiration time in seconds + * @see Builder#temporary(long) + */ + public OptionalLong getTemporary() { + return temporary; + } + + /** + * Get the no offsets flag. + * + * @return the no offsets flag + * @see Builder#noOffsets(boolean) + */ + public boolean isNoOffsets() { + return noOffsets; + } + + /** + * Get the no highlighting flag. + * + * @return the no highlighting flag + * @see Builder#noHighlighting(boolean) + */ + public boolean isNoHighlight() { + return noHighlight; + } + + /** + * Get the no fields flag. + * + * @return the no fields flag + * @see Builder#noFields(boolean) + */ + public boolean isNoFields() { + return noFields; + } + + /** + * Get the no frequency flag. + * + * @return the no frequency flag + * @see Builder#noFrequency(boolean) + */ + public boolean isNoFrequency() { + return noFrequency; + } + + /** + * Get the skip initial scan flag. + * + * @return the skip initial scan flag + * @see Builder#skipInitialScan(boolean) + */ + public boolean isSkipInitialScan() { + return skipInitialScan; + } + + /** + * Get the stop words for the index. + * + * @return the stop words + * @see Builder#stopWords(List) + */ + public Optional> getStopWords() { + return stopWords; + } + + /** + * Build a {@link CommandArgs} object that contains all the arguments. + * + * @param args the {@link CommandArgs} object + */ + public void build(CommandArgs args) { + on.ifPresent(targetType -> args.add(ON).add(targetType.name())); + if (!prefixes.isEmpty()) { + args.add(PREFIX).add(prefixes.size()); + prefixes.forEach(args::addKey); + } + filter.ifPresent(filter -> args.add(FILTER).addValue(filter)); + defaultLanguage.ifPresent(language -> args.add(LANGUAGE).add(language.toString())); + languageField.ifPresent(field -> args.add(LANGUAGE_FIELD).addKey(field)); + defaultScore.ifPresent(score -> args.add(SCORE).add(score)); + scoreField.ifPresent(field -> args.add(SCORE_FIELD).addKey(field)); + payloadField.ifPresent(field -> args.add(PAYLOAD_FIELD).addKey(field)); + if (maxTextFields) { + args.add(MAXTEXTFIELDS); + } + temporary.ifPresent(seconds -> args.add(TEMPORARY).add(seconds)); + if (noOffsets) { + args.add(NOOFFSETS); + } + if (noHighlight) { + args.add(NOHL); + } + if (noFields) { + args.add(NOFIELDS); + } + if (noFrequency) { + args.add(NOFREQS); + } + if (skipInitialScan) { + args.add(SKIPINITIALSCAN); + } + stopWords.ifPresent(words -> { + args.add(STOPWORDS).add(words.size()); + words.forEach(args::addValue); + }); + } + +} diff --git a/src/main/java/io/lettuce/core/search/package-info.java b/src/main/java/io/lettuce/core/search/package-info.java new file mode 100644 index 0000000000..0d4f7f5cdd --- /dev/null +++ b/src/main/java/io/lettuce/core/search/package-info.java @@ -0,0 +1,10 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +/** + * Support for the RediSearch features. + */ +package io.lettuce.core.search; diff --git a/src/main/kotlin/io/lettuce/core/api/coroutines/RediSearchCoroutinesCommands.kt b/src/main/kotlin/io/lettuce/core/api/coroutines/RediSearchCoroutinesCommands.kt new file mode 100644 index 0000000000..f6a24da5a0 --- /dev/null +++ b/src/main/kotlin/io/lettuce/core/api/coroutines/RediSearchCoroutinesCommands.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.api.coroutines + +import io.lettuce.core.ExperimentalLettuceCoroutinesApi +import kotlinx.coroutines.flow.Flow +import io.lettuce.core.search.Field +import io.lettuce.core.search.arguments.CreateArgs + +/** + * Coroutine executed commands for RediSearch functionality + * + * @param Key type. + * @param Value type. + * @author Tihomir Mateev + * @see RediSearch + * @since 6.6 + * @generated by io.lettuce.apigenerator.CreateKotlinCoroutinesApi + */ +@ExperimentalLettuceCoroutinesApi +interface RediSearchCoroutinesCommands { + + /** + * Create a new index with the given name, index options, and fields. + * + * @param index the index name, as a key + * @param options the index [CreateArgs] + * @param fields the [Field]s of the index + * @return the result of the create command + * @since 6.6 + * @see FT.CREATE + */ + suspend fun ftCreate(index: K, options: CreateArgs, fields: List>): String? + +} + diff --git a/src/main/templates/io/lettuce/core/api/RediSearchCommands.java b/src/main/templates/io/lettuce/core/api/RediSearchCommands.java new file mode 100644 index 0000000000..9c348db9c9 --- /dev/null +++ b/src/main/templates/io/lettuce/core/api/RediSearchCommands.java @@ -0,0 +1,37 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +package io.lettuce.core.api; + +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; + +import java.util.List; + +/** + * ${intent} for RediSearch functionality + * + * @param Key type. + * @param Value type. + * @author Tihomir Mateev + * @see RediSearch + * @since 6.6 + */ +public interface RediSearchCommands { + + /** + * Create a new index with the given name, index options, and fields. + * + * @param index the index name, as a key + * @param options the index {@link CreateArgs} + * @param fields the {@link Field}s of the index + * @return the result of the create command + * @since 6.6 + * @see FT.CREATE + */ + String ftCreate(K index, CreateArgs options, List> fields); + +} diff --git a/src/test/java/io/lettuce/apigenerator/Constants.java b/src/test/java/io/lettuce/apigenerator/Constants.java index 896b939951..4f28a33d95 100644 --- a/src/test/java/io/lettuce/apigenerator/Constants.java +++ b/src/test/java/io/lettuce/apigenerator/Constants.java @@ -30,7 +30,7 @@ class Constants { "RedisGeoCommands", "RedisHashCommands", "RedisHLLCommands", "RedisKeyCommands", "RedisListCommands", "RedisScriptingCommands", "RedisSentinelCommands", "RedisServerCommands", "RedisSetCommands", "RedisSortedSetCommands", "RedisStreamCommands", "RedisStringCommands", "RedisTransactionalCommands", - "RedisJsonCommands" }; + "RedisJsonCommands", "RediSearchCommands" }; public static final File TEMPLATES = new File("src/main/templates"); diff --git a/src/test/java/io/lettuce/core/RediSearchCommandBuilderUnitTests.java b/src/test/java/io/lettuce/core/RediSearchCommandBuilderUnitTests.java new file mode 100644 index 0000000000..c84e232ebf --- /dev/null +++ b/src/test/java/io/lettuce/core/RediSearchCommandBuilderUnitTests.java @@ -0,0 +1,92 @@ +package io.lettuce.core; + +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ +import io.lettuce.core.codec.StringCodec; +import io.lettuce.core.protocol.Command; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; +import io.netty.buffer.ByteBuf; +import io.netty.buffer.Unpooled; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static io.lettuce.TestTags.UNIT_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Unit tests for {@link RediSearchCommandBuilder}. + * + * @author Tihomir Mateev + */ +@Tag(UNIT_TEST) +class RediSearchCommandBuilderUnitTests { + + private static final String MY_KEY = "idx"; + + private static final String FIELD1_NAME = "title"; + + private static final String FIELD2_NAME = "published_at"; + + private static final String FIELD3_NAME = "category"; + + private static final String FIELD4_NAME = "sku"; + + private static final String FIELD4_ALIAS1 = "sku_text"; + + private static final String FIELD4_ALIAS2 = "sku_tag"; + + private static final String PREFIX = "blog:post:"; + + RediSearchCommandBuilder builder = new RediSearchCommandBuilder<>(StringCodec.UTF8); + + // FT.CREATE idx ON HASH PREFIX 1 blog:post: SCHEMA title TEXT SORTABLE published_at NUMERIC SORTABLE category TAG SORTABLE + @Test + void shouldCorrectlyConstructFtCreateCommandScenario1() { + Field field1 = Field. builder().name(FIELD1_NAME).type(Field.Type.TEXT).sortable().build(); + Field field2 = Field. builder().name(FIELD2_NAME).type(Field.Type.NUMERIC).sortable().build(); + Field field3 = Field. builder().name(FIELD3_NAME).type(Field.Type.TAG).sortable().build(); + CreateArgs createArgs = CreateArgs. builder().addPrefix(PREFIX) + .on(CreateArgs.TargetType.HASH).build(); + Command command = builder.ftCreate(MY_KEY, createArgs, Arrays.asList(field1, field2, field3)); + ByteBuf buf = Unpooled.directBuffer(); + command.encode(buf); + + String result = "*17\r\n" + "$9\r\n" + "FT.CREATE\r\n" + "$3\r\n" + MY_KEY + "\r\n" + "$2\r\n" + "ON\r\n" + "$4\r\n" + + "HASH\r\n" + "$6\r\n" + "PREFIX\r\n" + "$1\r\n" + "1\r\n" + "$10\r\n" + PREFIX + "\r\n" + "$6\r\n" + + "SCHEMA\r\n" + "$5\r\n" + FIELD1_NAME + "\r\n" + "$4\r\n" + "TEXT\r\n" + "$8\r\n" + "SORTABLE\r\n" + "$12\r\n" + + FIELD2_NAME + "\r\n" + "$7\r\n" + "NUMERIC\r\n" + "$8\r\n" + "SORTABLE\r\n" + "$8\r\n" + FIELD3_NAME + "\r\n" + + "$3\r\n" + "TAG\r\n" + "$8\r\n" + "SORTABLE\r\n"; + + assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo(result); + } + + // FT.CREATE idx ON HASH PREFIX 1 blog:post: SCHEMA sku AS sku_text TEXT sku AS sku_tag TAG SORTABLE + @Test + void shouldCorrectlyConstructFtCreateCommandScenario2() { + Field field1 = Field. builder().name(FIELD4_NAME).as(FIELD4_ALIAS1).type(Field.Type.TEXT).build(); + Field field2 = Field. builder().name(FIELD4_NAME).as(FIELD4_ALIAS2).type(Field.Type.TAG).sortable() + .build(); + CreateArgs createArgs = CreateArgs. builder().addPrefix(PREFIX) + .on(CreateArgs.TargetType.HASH).build(); + Command command = builder.ftCreate(MY_KEY, createArgs, Arrays.asList(field1, field2)); + ByteBuf buf = Unpooled.directBuffer(); + command.encode(buf); + + String result = "*17\r\n" + "$9\r\n" + "FT.CREATE\r\n" + "$3\r\n" + MY_KEY + "\r\n" + "$2\r\n" + "ON\r\n" + "$4\r\n" + + "HASH\r\n" + "$6\r\n" + "PREFIX\r\n" + "$1\r\n" + "1\r\n" + "$10\r\n" + PREFIX + "\r\n" + "$6\r\n" + + "SCHEMA\r\n" + "$3\r\n" + FIELD4_NAME + "\r\n" + "$2\r\n" + "AS\r\n" + "$8\r\n" + FIELD4_ALIAS1 + "\r\n" + + "$4\r\n" + "TEXT\r\n" + "$3\r\n" + FIELD4_NAME + "\r\n" + "$2\r\n" + "AS\r\n" + "$7\r\n" + FIELD4_ALIAS2 + + "\r\n" + "$3\r\n" + "TAG\r\n" + "$8\r\n" + "SORTABLE\r\n"; + + assertThat(buf.toString(StandardCharsets.UTF_8)).isEqualTo(result); + } + +} diff --git a/src/test/java/io/lettuce/core/json/RediSearchIntegrationTests.java b/src/test/java/io/lettuce/core/json/RediSearchIntegrationTests.java new file mode 100644 index 0000000000..c87472bc64 --- /dev/null +++ b/src/test/java/io/lettuce/core/json/RediSearchIntegrationTests.java @@ -0,0 +1,84 @@ +/* + * Copyright 2025, Redis Ltd. and Contributors + * All rights reserved. + * + * Licensed under the MIT License. + */ + +package io.lettuce.core.json; + +import io.lettuce.core.RedisClient; +import io.lettuce.core.RedisContainerIntegrationTests; +import io.lettuce.core.RedisURI; +import io.lettuce.core.api.sync.RedisCommands; +import io.lettuce.core.search.Field; +import io.lettuce.core.search.arguments.CreateArgs; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; + +import static io.lettuce.TestTags.INTEGRATION_TEST; +import static org.assertj.core.api.Assertions.assertThat; + +@Tag(INTEGRATION_TEST) +public class RediSearchIntegrationTests extends RedisContainerIntegrationTests { + + private static final String GENERIC_INDEX = "idx"; + + private static final String FIELD1_NAME = "title"; + + private static final String FIELD2_NAME = "published_at"; + + private static final String FIELD3_NAME = "category"; + + private static final String PREFIX = "blog:post:"; + + protected static RedisClient client; + + protected static RedisCommands redis; + + public RediSearchIntegrationTests() { + RedisURI redisURI = RedisURI.Builder.redis("127.0.0.1").withPort(16379).build(); + + client = RedisClient.create(redisURI); + redis = client.connect().sync(); + } + + @BeforeEach + public void prepare() throws IOException { + redis.flushall(); + + Path path = Paths.get("src/test/resources/bike-inventory.json"); + String read = String.join("", Files.readAllLines(path)); + JsonValue value = redis.getJsonParser().createJsonValue(read); + + redis.jsonSet("bikes:inventory", JsonPath.ROOT_PATH, value); + } + + @AfterAll + static void teardown() { + if (client != null) { + client.shutdown(); + } + } + + @Test + void ftCreateScenario1() { + Field field1 = Field. builder().name(FIELD1_NAME).type(Field.Type.TEXT).sortable().build(); + Field field2 = Field. builder().name(FIELD2_NAME).type(Field.Type.NUMERIC).sortable().build(); + Field field3 = Field. builder().name(FIELD3_NAME).type(Field.Type.TAG).sortable().build(); + CreateArgs createArgs = CreateArgs. builder().addPrefix(PREFIX) + .on(CreateArgs.TargetType.HASH).build(); + + String result = redis.ftCreate(GENERIC_INDEX, createArgs, Arrays.asList(field1, field2, field3)); + assertThat(result).isEqualTo("OK"); + } + +}