From 9a2cd5dbc819be74f4f15b9b6140c066758b93aa Mon Sep 17 00:00:00 2001 From: shacharPash <93581407+shacharPash@users.noreply.github.com> Date: Wed, 5 Jul 2023 16:35:08 +0300 Subject: [PATCH] Add support for extended search commands (#151) * start working on Adding Support for FT.SPELLCHECK * tests * Add tests * Add Bounds tests * Chagne to RedisServerException * fixes * add comands to interface * using SearchArgs * Commands implement * delete unused literals * TestAddAndGetSuggestion * Add SugGetWithScoresAsync * assync tests * Support FT.PROFILE * fix parser * fix command builder * test + fixes * add tests * add TestProfileCommandBuilder * space * Try make Tran&pipe instances not interface type * Add ft.create without FTCreateParams * tests * fix } missing * Delete 'I' from ICommands intances in the examples * change to Uppercase * fixex after vlad's review --- Examples/AdvancedJsonExamples.md | 2 +- Examples/AdvancedQueryOperations.md | 8 +- Examples/BasicJsonExamples.md | 2 +- Examples/BasicQueryOperations.md | 4 +- README.md | 24 +- src/NRedisStack/Pipeline.cs | 18 +- src/NRedisStack/ResponseParser.cs | 89 +++ src/NRedisStack/Search/FTSpellCheckParams.cs | 96 ++++ src/NRedisStack/Search/ISearchCommands.cs | 84 ++- .../Search/ISearchCommandsAsync.cs | 87 ++- .../Search/Literals/CommandArgs.cs | 38 ++ src/NRedisStack/Search/Literals/Commands.cs | 9 +- src/NRedisStack/Search/Query.cs | 58 +- .../Search/SearchCommandBuilder.cs | 67 ++- src/NRedisStack/Search/SearchCommands.cs | 78 ++- src/NRedisStack/Search/SearchCommandsAsync.cs | 64 ++- src/NRedisStack/Transactions.cs | 18 +- tests/NRedisStack.Tests/Search/SearchTests.cs | 527 +++++++++++++++++- 18 files changed, 1149 insertions(+), 124 deletions(-) create mode 100644 src/NRedisStack/Search/FTSpellCheckParams.cs diff --git a/Examples/AdvancedJsonExamples.md b/Examples/AdvancedJsonExamples.md index 53ac2f72..aa597d74 100644 --- a/Examples/AdvancedJsonExamples.md +++ b/Examples/AdvancedJsonExamples.md @@ -50,7 +50,7 @@ The ability to query within a JSON object unlocks further value to the underlyin ``` ## Data Loading ```c# -IJsonCommands json = db.JSON(); +JsonCommands json = db.JSON(); json.Set("warehouse:1", "$", new { city = "Boston", location = "42.361145, -71.057083", diff --git a/Examples/AdvancedQueryOperations.md b/Examples/AdvancedQueryOperations.md index 7f2fd88c..4573e60c 100644 --- a/Examples/AdvancedQueryOperations.md +++ b/Examples/AdvancedQueryOperations.md @@ -7,7 +7,7 @@ Aggregation and other more complex RediSearch queries 1. [Data Load](#vss_dataload) 2. [Index Creation](#vss_index) 3. [Search](#vss_search) - 4. [Hybrid query Search](#vss_hybrid_query_search) + 4. [Hybrid Query Search](#vss_hybrid_query_search) 4. [Advanced Search Queries](#adv_search) 1. [Data Set](#advs_dataset) 2. [Data Load](#advs_dataload) @@ -66,7 +66,7 @@ db.HashSet("vec:4", new HashEntry[] ### Index Creation #### Command ```c# -ISearchCommands ft = db.FT(); +SearchCommands ft = db.FT(); try {ft.DropIndex("vss_idx");} catch {}; Console.WriteLine(ft.Create("vss_idx", new FTCreateParams().On(IndexDataType.HASH).Prefix("vec:"), new Schema() @@ -193,7 +193,7 @@ vec:3 is not returned because it has tag B ### Data Load ```c# -IJsonCommands json = db.JSON(); +JsonCommands json = db.JSON(); json.Set("warehouse:1", "$", new { city = "Boston", location = "-71.057083, 42.361145", @@ -253,7 +253,7 @@ json.Set("warehouse:2", "$", new { ### Index Creation #### Command ```c# -ISearchCommands ft = db.FT(); +SearchCommands ft = db.FT(); try {ft.DropIndex("wh_idx");} catch {}; Console.WriteLine(ft.Create("wh_idx", new FTCreateParams() .On(IndexDataType.JSON) diff --git a/Examples/BasicJsonExamples.md b/Examples/BasicJsonExamples.md index f64d924a..bebb2c90 100644 --- a/Examples/BasicJsonExamples.md +++ b/Examples/BasicJsonExamples.md @@ -38,7 +38,7 @@ Document stores are a NoSQL database type that provide flexible schemas and acce Insert a simple KVP as a JSON object. #### Command ```c# -IJsonCommands json = db.JSON(); +JsonCommands json = db.JSON(); Console.WriteLine(json.Set("ex1:1", "$", "\"val\"")); ``` #### Result diff --git a/Examples/BasicQueryOperations.md b/Examples/BasicQueryOperations.md index dac955e6..11e11ac6 100644 --- a/Examples/BasicQueryOperations.md +++ b/Examples/BasicQueryOperations.md @@ -65,7 +65,7 @@ using NRedisStack.Search.Literals.Enums; ``` ## Data Loading ```c# -IJsonCommands json = db.JSON(); +JsonCommands json = db.JSON(); json.Set("product:15970", "$", new { id = 15970, gender = "Men", @@ -100,7 +100,7 @@ json.Set("product:46885", "$", new { #### Command ```c# -ISearchCommands ft = db.FT(); +SearchCommands ft = db.FT(); try {ft.DropIndex("idx1");} catch {}; ft.Create("idx1", new FTCreateParams().On(IndexDataType.JSON) .Prefix("product:"), diff --git a/README.md b/README.md index a67c008c..4df53d5c 100644 --- a/README.md +++ b/README.md @@ -60,15 +60,15 @@ IDatabase db = redis.GetDatabase(); ``` Now you can create a variable from any type of module in the following way: ```csharp -IBloomCommands bf = db.BF(); -ICuckooCommands cf = db.CF(); -ICmsCommands cms = db.CMS(); -IGraphCommands graph = db.GRAPH(); -ITopKCommands topk = db.TOPK(); -ITdigestCommands tdigest = db.TDIGEST(); -ISearchCommands ft = db.FT(); -IJsonCommands json = db.JSON(); -ITimeSeriesCommands ts = db.TS(); +BloomCommands bf = db.BF(); +CuckooCommands cf = db.CF(); +CmsCommands cms = db.CMS(); +GraphCommands graph = db.GRAPH(); +TopKCommands topk = db.TOPK(); +TdigestCommands tdigest = db.TDIGEST(); +SearchCommands ft = db.FT(); +JsonCommands json = db.JSON(); +TimeSeriesCommands ts = db.TS(); ``` Then, that variable will allow you to call all the commands of that module. @@ -82,7 +82,7 @@ To store a json object in Redis: ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("localhost"); IDatabase db = redis.GetDatabase(); -IJsonCommands json = db.JSON(); +JsonCommands json = db.JSON(); var key = "myKey"; json.Set(key, "$", new { Age = 35, Name = "Alice" }); ``` @@ -99,8 +99,8 @@ using NRedisStack.Search.Literals.Enums; ConnectionMultiplexer redis = ConnectionMultiplexer.Connect("localhost"); IDatabase db = redis.GetDatabase(); -ISearchCommands ft = db.FT(); -IJsonCommands json = db.JSON(); +SearchCommands ft = db.FT(); +JsonCommands json = db.JSON(); ``` Create an index with fields and weights: diff --git a/src/NRedisStack/Pipeline.cs b/src/NRedisStack/Pipeline.cs index bf0fa594..182a73c2 100644 --- a/src/NRedisStack/Pipeline.cs +++ b/src/NRedisStack/Pipeline.cs @@ -14,15 +14,15 @@ public Pipeline(IDatabase db) public void Execute() => _batch.Execute(); - public IBloomCommandsAsync Bf => new BloomCommandsAsync(_batch); - public ICmsCommandsAsync Cms => new CmsCommandsAsync(_batch); - public ICuckooCommandsAsync Cf => new CuckooCommandsAsync(_batch); - public IGraphCommandsAsync Graph => new GraphCommandsAsync(_batch); - public IJsonCommandsAsync Json => new JsonCommandsAsync(_batch); - public ISearchCommandsAsync Ft => new SearchCommandsAsync(_batch); - public ITdigestCommandsAsync Tdigest => new TdigestCommandsAsync(_batch); - public ITimeSeriesCommandsAsync Ts => new TimeSeriesCommandsAsync(_batch); - public ITopKCommandsAsync TopK => new TopKCommandsAsync(_batch); + public BloomCommandsAsync Bf => new BloomCommandsAsync(_batch); + public CmsCommandsAsync Cms => new CmsCommandsAsync(_batch); + public CuckooCommandsAsync Cf => new CuckooCommandsAsync(_batch); + public GraphCommandsAsync Graph => new GraphCommandsAsync(_batch); + public JsonCommandsAsync Json => new JsonCommandsAsync(_batch); + public SearchCommandsAsync Ft => new SearchCommandsAsync(_batch); + public TdigestCommandsAsync Tdigest => new TdigestCommandsAsync(_batch); + public TimeSeriesCommandsAsync Ts => new TimeSeriesCommandsAsync(_batch); + public TopKCommandsAsync TopK => new TopKCommandsAsync(_batch); public IDatabaseAsync Db => _batch; } \ No newline at end of file diff --git a/src/NRedisStack/ResponseParser.cs b/src/NRedisStack/ResponseParser.cs index 7071476b..2865531e 100644 --- a/src/NRedisStack/ResponseParser.cs +++ b/src/NRedisStack/ResponseParser.cs @@ -7,6 +7,8 @@ using NRedisStack.CountMinSketch.DataTypes; using NRedisStack.TopK.DataTypes; using NRedisStack.Tdigest.DataTypes; +using NRedisStack.Search; +using NRedisStack.Search.Aggregation; namespace NRedisStack { @@ -614,6 +616,92 @@ public static IEnumerable> ToHashSets(this RedisResult result) return sets; } + public static Dictionary> ToFtSpellCheckResult(this RedisResult result) + { + var rawTerms = (RedisResult[])result!; + var returnTerms = new Dictionary>(rawTerms.Length); + foreach (var term in rawTerms) + { + var rawElements = (RedisResult[])term!; + + string termValue = rawElements[1].ToString()!; + + var list = (RedisResult[]) rawElements[2]!; + Dictionary entries = new Dictionary(list.Length); + foreach (var entry in list) + { + var entryElements = (RedisResult[])entry!; + string suggestion = entryElements[1].ToString()!; + double score = (double)entryElements[0]; + entries.Add(suggestion, score); + } + returnTerms.Add(termValue, entries); + } + + return returnTerms; + } + + public static List> ToStringDoubleTupleList(this RedisResult result) // TODO: consider create class Suggestion instead of List> + { + var results = (RedisResult[])result!; + var list = new List>(results.Length / 2); + for (int i = 0; i < results.Length; i += 2) + { + var suggestion = results[i].ToString()!; + var score = (double)results[i + 1]; + list.Add(new Tuple(suggestion, score)); + } + return list; + } + + public static Dictionary ToStringRedisResultDictionary(this RedisResult value) + { + var res = (RedisResult[])value!; + var dict = new Dictionary(); + foreach (var pair in res) + { + var arr = (RedisResult[])pair!; + dict.Add(arr[0].ToString(), arr[1]); + } + return dict; + } + + public static Tuple> ToProfileSearchResult(this RedisResult result, Query q) + { + var results = (RedisResult[])result!; + + var searchResult = results[0].ToSearchResult(q); + var profile = results[1].ToStringRedisResultDictionary(); + return new Tuple>(searchResult, profile); + } + + public static SearchResult ToSearchResult(this RedisResult result, Query q) + { + return new SearchResult((RedisResult[])result!, !q.NoContent, q.WithScores, q.WithPayloads/*, q.ExplainScore*/); + } + + public static Tuple> ToProfileAggregateResult(this RedisResult result, AggregationRequest q) + { + var results = (RedisResult[])result!; + var aggregateResult = results[0].ToAggregationResult(q); + var profile = results[1].ToStringRedisResultDictionary(); + return new Tuple>(aggregateResult, profile); + } + + public static AggregationResult ToAggregationResult(this RedisResult result, AggregationRequest query) + { + if (query.IsWithCursor()) + { + var results = (RedisResult[])result!; + + return new AggregationResult(results[0], (long)results[1]); + } + else + { + return new AggregationResult(result); + } + } + public static Dictionary[] ToDictionarys(this RedisResult result) { var resArr = (RedisResult[])result!; @@ -624,6 +712,7 @@ public static Dictionary[] ToDictionarys(this RedisResult r } return dicts; + } } } \ No newline at end of file diff --git a/src/NRedisStack/Search/FTSpellCheckParams.cs b/src/NRedisStack/Search/FTSpellCheckParams.cs new file mode 100644 index 00000000..97242604 --- /dev/null +++ b/src/NRedisStack/Search/FTSpellCheckParams.cs @@ -0,0 +1,96 @@ +using NRedisStack.Search.Literals; +namespace NRedisStack.Search +{ + public class FTSpellCheckParams + { + List args = new List(); + private List> terms = new List>(); + private int? distance = null; + private int? dialect = null; + + public FTSpellCheckParams() { } + + /// + /// Specifies an inclusion (INCLUDE) of a custom dictionary. + /// + public FTSpellCheckParams IncludeTerm(string dict) + { + return AddTerm(dict, SearchArgs.INCLUDE); + } + + /// + /// Specifies an inclusion (EXCLUDE) of a custom dictionary. + /// + public FTSpellCheckParams ExcludeTerm(string dict) + { + return AddTerm(dict, SearchArgs.EXCLUDE); + } + + /// + /// Specifies an inclusion (INCLUDE) or exclusion (EXCLUDE) of a custom dictionary. + /// + private FTSpellCheckParams AddTerm(string dict, string type) + { + terms.Add(new KeyValuePair(dict, type)); + return this; + } + + /// + /// Maximum Levenshtein distance for spelling suggestions (default: 1, max: 4). + /// + public FTSpellCheckParams Distance(int distance) + { + this.distance = distance; + return this; + } + + /// + /// Selects the dialect version under which to execute the query. + /// + public FTSpellCheckParams Dialect(int dialect) + { + this.dialect = dialect; + return this; + } + + public List GetArgs() + { + return args; + } + + public void SerializeRedisArgs() + { + Distance(); + Terms(); + Dialect(); + } + + private void Dialect() + { + if (dialect != null) + { + args.Add(SearchArgs.DIALECT); + args.Add(dialect); + } + } + + private void Terms() + { + foreach (var term in terms) + { + args.Add(SearchArgs.TERMS); + args.Add(term.Value); + args.Add(term.Key); + } + } + + private void Distance() + { + if (distance != null) + { + args.Add(SearchArgs.DISTANCE); + args.Add(distance); + } + } + } +} diff --git a/src/NRedisStack/Search/ISearchCommands.cs b/src/NRedisStack/Search/ISearchCommands.cs index 33e746ff..45843dbe 100644 --- a/src/NRedisStack/Search/ISearchCommands.cs +++ b/src/NRedisStack/Search/ISearchCommands.cs @@ -170,7 +170,23 @@ public interface ISearchCommands /// InfoResult Info(RedisValue index); - // TODO: FT.PROFILE (jedis doesn't have it) + /// + /// Apply FT.SEARCH command to collect performance details. + /// + /// The index name, created using FT.CREATE. + /// The query string. + /// Removes details of reader iterator. + /// + Tuple> ProfileSearch(string indexName, Query q, bool limited = false); + + /// + /// Apply FT.AGGREGATE command to collect performance details. + /// + /// The index name, created using FT.CREATE. + /// The query string. + /// Removes details of reader iterator. + /// + Tuple> ProfileAggregate(string indexName, AggregationRequest query, bool limited = false); /// /// Search the index @@ -189,7 +205,71 @@ public interface ISearchCommands /// Dictionary> SynDump(string indexName); - // TODO: FT.SPELLCHECK (jedis doesn't have it) + /// + /// Perform spelling correction on a query, returning suggestions for misspelled terms. + /// + /// is index with the indexed terms. + /// is search query. + /// Optional Spellcheck Parameters + /// An array reply, in which each element represents a misspelled term from the query. + /// + Dictionary> SpellCheck(string indexName, string query, FTSpellCheckParams? spellCheckParams = null); + + /// + /// Add a suggestion string to an auto-complete suggestion dictionary + /// + /// is suggestion dictionary key. + /// is suggestion string to index. + /// is floating point number of the suggestion string's weight. + /// increments the existing entry of the suggestion by the given score, + /// instead of replacing the score. + /// saves an extra payload with the suggestion, that can be fetched by adding the WITHPAYLOADS argument to FT.SUGGET. + /// The current size of the suggestion dictionary. + /// + long SugAdd(string key, string str, double score, bool increment = false, string? payload = null); + + /// + /// Delete a string from a suggestion index. + /// + /// is suggestion dictionary key. + /// is suggestion string to index. + /// if the string was found and deleted, otherwise. + /// + bool SugDel(string key, string str); + + /// + /// Get completion suggestions for a prefix. + /// + /// is suggestion dictionary key. + /// is prefix to complete on. + /// performs a fuzzy prefix search, + /// including prefixes at Levenshtein distance of 1 from the prefix sent. + /// returns optional payloads saved along with the suggestions. + /// limits the results to a maximum of num (default: 5). + /// List of the top suggestions matching the prefix. + /// + List SugGet(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null); + + /// + /// Get completion suggestions for a prefix with the score of each suggestion. + /// + /// is suggestion dictionary key. + /// is prefix to complete on. + /// performs a fuzzy prefix search, + /// including prefixes at Levenshtein distance of 1 from the prefix sent. + /// returns optional payloads saved along with the suggestions. + /// limits the results to a maximum of num (default: 5). + /// List of the top suggestions matching the prefix. + /// + List> SugGetWithScores(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null); + + /// + /// Get the size of an auto-complete suggestion dictionary. + /// + /// is suggestion dictionary key. + /// The current size of the suggestion dictionary. + /// + long SugLen(string key); /// /// Update a synonym group. diff --git a/src/NRedisStack/Search/ISearchCommandsAsync.cs b/src/NRedisStack/Search/ISearchCommandsAsync.cs index e335c10e..b42013f8 100644 --- a/src/NRedisStack/Search/ISearchCommandsAsync.cs +++ b/src/NRedisStack/Search/ISearchCommandsAsync.cs @@ -169,7 +169,25 @@ public interface ISearchCommandsAsync /// Task InfoAsync(RedisValue index); - // TODO: FT.PROFILE (jedis doesn't have it) + + /// + /// Apply FT.SEARCH command to collect performance details. + /// + /// The index name, created using FT.CREATE. + /// The query string. + /// Removes details of reader iterator. + /// + Task>> ProfileSearchAsync(string indexName, Query q, bool limited = false); + + + /// + /// Apply FT.AGGREGATE command to collect performance details. + /// + /// The index name, created using FT.CREATE. + /// The query string. + /// Removes details of reader iterator. + /// + Task>> ProfileAggregateAsync(string indexName, AggregationRequest query, bool limited = false); /// /// Search the index @@ -180,7 +198,72 @@ public interface ISearchCommandsAsync /// Task SearchAsync(string indexName, Query q); - // TODO: FT.SPELLCHECK (jedis doesn't have it) + /// + /// Perform spelling correction on a query, returning suggestions for misspelled terms. + /// + /// is index with the indexed terms. + /// is search query. + /// Optional Spellcheck Parameters + /// An array reply, in which each element represents a misspelled term from the query. + /// + /// + Task>> SpellCheckAsync(string indexName, string query, FTSpellCheckParams? spellCheckParams = null); + + /// + /// Add a suggestion string to an auto-complete suggestion dictionary + /// + /// is suggestion dictionary key. + /// is suggestion string to index. + /// is floating point number of the suggestion string's weight. + /// increments the existing entry of the suggestion by the given score, + /// instead of replacing the score. + /// saves an extra payload with the suggestion, that can be fetched by adding the WITHPAYLOADS argument to FT.SUGGET. + /// The current size of the suggestion dictionary. + /// + Task SugAddAsync(string key, string str, double score, bool increment = false, string? payload = null); + + /// + /// Delete a string from a suggestion index. + /// + /// is suggestion dictionary key. + /// is suggestion string to index. + /// if the string was found and deleted, otherwise. + /// + Task SugDelAsync(string key, string str); + + /// + /// Get completion suggestions for a prefix. + /// + /// is suggestion dictionary key. + /// is prefix to complete on. + /// performs a fuzzy prefix search, + /// including prefixes at Levenshtein distance of 1 from the prefix sent. + /// returns optional payloads saved along with the suggestions. + /// limits the results to a maximum of num (default: 5). + /// List of the top suggestions matching the prefix. + /// + Task> SugGetAsync(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null); + + /// + /// Get completion suggestions for a prefix with the score of each suggestion. + /// + /// is suggestion dictionary key. + /// is prefix to complete on. + /// performs a fuzzy prefix search, + /// including prefixes at Levenshtein distance of 1 from the prefix sent. + /// returns optional payloads saved along with the suggestions. + /// limits the results to a maximum of num (default: 5). + /// List of the top suggestions matching the prefix. + /// + Task>> SugGetWithScoresAsync(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null); + + /// + /// Get the size of an auto-complete suggestion dictionary. + /// + /// is suggestion dictionary key. + /// The current size of the suggestion dictionary. + /// + Task SugLenAsync(string key); /// /// Dump the contents of a synonym group. diff --git a/src/NRedisStack/Search/Literals/CommandArgs.cs b/src/NRedisStack/Search/Literals/CommandArgs.cs index cb38ee97..fb957db7 100644 --- a/src/NRedisStack/Search/Literals/CommandArgs.cs +++ b/src/NRedisStack/Search/Literals/CommandArgs.cs @@ -19,5 +19,43 @@ internal class SearchArgs public const string NOFREQS = "NOFREQS"; public const string STOPWORDS = "STOPWORDS"; public const string SKIPINITIALSCAN = "SKIPINITIALSCAN"; + public const string INCLUDE = "INCLUDE"; + public const string EXCLUDE = "EXCLUDE"; + public const string DIALECT = "DIALECT"; + public const string TERMS = "TERMS"; + public const string DISTANCE = "DISTANCE"; + public const string INCR = "INCR"; + public const string PAYLOAD = "PAYLOAD"; + public const string FUZZY = "FUZZY"; + public const string WITHSCORES = "WITHSCORES"; + public const string WITHPAYLOADS = "WITHPAYLOADS"; + public const string MAX = "MAX"; + public const string VERBATIM = "VERBATIM"; + public const string NOCONTENT = "NOCONTENT"; + public const string NOSTOPWORDS = "NOSTOPWORDS"; + public const string SCORER = "SCORER"; + public const string INFIELDS = "INFIELDS"; + public const string SORTBY = "SORTBY"; + public const string ASC = "ASC"; + public const string DESC = "DESC"; + public const string LIMIT = "LIMIT"; + public const string HIGHLIGHT = "HIGHLIGHT"; + public const string FIELDS = "FIELDS"; + public const string TAGS = "TAGS"; + public const string SUMMARIZE = "SUMMARIZE"; + public const string FRAGS = "FRAGS"; + public const string LEN = "LEN"; + public const string SEPARATOR = "SEPARATOR"; + public const string INKEYS = "INKEYS"; + public const string RETURN = "RETURN"; + public const string PARAMS = "PARAMS"; + public const string SLOP = "SLOP"; + public const string TIMEOUT = "TIMEOUT"; + public const string INORDER = "INORDER"; + public const string EXPANDER = "EXPANDER"; + public const string SEARCH = "SEARCH"; + public const string AGGREGATE = "AGGREGATE"; + public const string LIMITED = "LIMITED"; + public const string QUERY = "QUERY"; } } \ No newline at end of file diff --git a/src/NRedisStack/Search/Literals/Commands.cs b/src/NRedisStack/Search/Literals/Commands.cs index 4db79412..8d94b306 100644 --- a/src/NRedisStack/Search/Literals/Commands.cs +++ b/src/NRedisStack/Search/Literals/Commands.cs @@ -9,13 +9,8 @@ internal class FT public const string ALIASUPDATE = "FT.ALIASUPDATE"; public const string ALTER = "FT.ALTER"; public const string CONFIG = "FT.CONFIG"; - public const string CONFIG_GET = "FT.CONFIG GET"; - public const string CONFIG_HELP = "FT.CONFIG HELP"; - public const string CONFIG_SET = "FT.CONFIG SET"; public const string CREATE = "FT.CREATE"; public const string CURSOR = "FT.CURSOR"; - public const string CURSOR_DEL = "FT.CURSOR DEL"; - public const string CURSOR_READ = "FT.CURSOR READ"; public const string DICTADD = "FT.DICTADD"; public const string DICTDEL = "FT.DICTDEL"; public const string DICTDUMP = "FT.DICTDUMP"; @@ -26,6 +21,10 @@ internal class FT public const string PROFILE = "FT.PROFILE"; public const string SEARCH = "FT.SEARCH"; public const string SPELLCHECK = "FT.SPELLCHECK"; + public const string SUGADD = "FT.SUGADD"; + public const string SUGDEL = "FT.SUGDEL"; + public const string SUGGET = "FT.SUGGET"; + public const string SUGLEN = "FT.SUGLEN"; public const string SYNDUMP = "FT.SYNDUMP"; public const string SYNUPDATE = "FT.SYNUPDATE"; public const string TAGVALS = "FT.TAGVALS"; diff --git a/src/NRedisStack/Search/Query.cs b/src/NRedisStack/Search/Query.cs index ce97017f..4b2bad54 100644 --- a/src/NRedisStack/Search/Query.cs +++ b/src/NRedisStack/Search/Query.cs @@ -215,19 +215,19 @@ internal void SerializeRedisArgs(List args) if (Verbatim) { - args.Add("VERBATIM"); + args.Add(SearchArgs.VERBATIM); } if (NoContent) { - args.Add("NOCONTENT"); + args.Add(SearchArgs.NOCONTENT); } if (NoStopwords) { - args.Add("NOSTOPWORDS"); + args.Add(SearchArgs.NOSTOPWORDS); } if (WithScores) { - args.Add("WITHSCORES"); + args.Add(SearchArgs.WITHSCORES); // if (ExplainScore) // { // args.Add("EXPLAINSCORE"); // TODO: Check Why Jedis doesn't have it @@ -235,43 +235,43 @@ internal void SerializeRedisArgs(List args) } if (WithPayloads) { - args.Add("WITHPAYLOADS"); + args.Add(SearchArgs.WITHPAYLOADS); } if (Language != null) { - args.Add("LANGUAGE"); + args.Add(SearchArgs.LANGUAGE); args.Add(Language); } if (Scorer != null) { - args.Add("SCORER"); + args.Add(SearchArgs.SCORER); args.Add(Scorer); } if (_fields?.Length > 0) { - args.Add("INFIELDS"); + args.Add(SearchArgs.INFIELDS); args.Add(_fields.Length); args.AddRange(_fields); } if (SortBy != null) { - args.Add("SORTBY"); + args.Add(SearchArgs.SORTBY); args.Add(SortBy); if (SortAscending != null) - args.Add(((bool)SortAscending ? "ASC" : "DESC")); + args.Add(((bool)SortAscending ? SearchArgs.ASC : SearchArgs.DESC)); } if (Payload != null) { - args.Add("PAYLOAD"); + args.Add(SearchArgs.PAYLOAD); args.Add(Payload); } if (_paging.Offset != 0 || _paging.Count != 10) { - args.Add("LIMIT"); + args.Add(SearchArgs.LIMIT); args.Add(_paging.Offset); args.Add(_paging.Count); } @@ -286,10 +286,10 @@ internal void SerializeRedisArgs(List args) if (_wantsHighlight) { - args.Add("HIGHLIGHT"); + args.Add(SearchArgs.HIGHLIGHT); if (_highlightFields != null) { - args.Add("FIELDS"); + args.Add(SearchArgs.FIELDS); args.Add(_highlightFields.Length); foreach (var s in _highlightFields) { @@ -298,7 +298,7 @@ internal void SerializeRedisArgs(List args) } if (_highlightTags != null) { - args.Add("TAGS"); + args.Add(SearchArgs.TAGS); var tags = _highlightTags.GetValueOrDefault(); args.Add(tags.Open); args.Add(tags.Close); @@ -306,10 +306,10 @@ internal void SerializeRedisArgs(List args) } if (_wantsSummarize) { - args.Add("SUMMARIZE"); + args.Add(SearchArgs.SUMMARIZE); if (_summarizeFields != null) { - args.Add("FIELDS"); + args.Add(SearchArgs.FIELDS); args.Add(_summarizeFields.Length); foreach (var s in _summarizeFields) { @@ -318,24 +318,24 @@ internal void SerializeRedisArgs(List args) } if (_summarizeNumFragments != -1) { - args.Add("FRAGS"); + args.Add(SearchArgs.FRAGS); args.Add(_summarizeNumFragments); } if (_summarizeFragmentLen != -1) { - args.Add("LEN"); + args.Add(SearchArgs.LEN); args.Add(_summarizeFragmentLen); } if (_summarizeSeparator != null) { - args.Add("SEPARATOR"); + args.Add(SearchArgs.SEPARATOR); args.Add(_summarizeSeparator); } } if (_keys != null && _keys.Length > 0) { - args.Add("INKEYS"); + args.Add(SearchArgs.INKEYS); args.Add(_keys.Length); foreach (var key in _keys) @@ -346,14 +346,14 @@ internal void SerializeRedisArgs(List args) if (_returnFields?.Length > 0) { - args.Add("RETURN"); + args.Add(SearchArgs.RETURN); args.Add(_returnFields.Length); args.AddRange(_returnFields); } else if (_returnFieldsNames?.Length > 0) { - args.Add("RETURN"); + args.Add(SearchArgs.RETURN); int returnCountIndex = args.Count; int returnCount = 0; foreach (FieldName fn in _returnFieldsNames) @@ -365,7 +365,7 @@ internal void SerializeRedisArgs(List args) } if (_params != null && _params.Count > 0) { - args.Add("PARAMS"); + args.Add(SearchArgs.PARAMS); args.Add(_params.Count * 2); foreach (var entry in _params) { @@ -376,30 +376,30 @@ internal void SerializeRedisArgs(List args) if (dialect >= 1) { - args.Add("DIALECT"); + args.Add(SearchArgs.DIALECT); args.Add(dialect); } if (_slop >= 0) { - args.Add("SLOP"); + args.Add(SearchArgs.SLOP); args.Add(_slop); } if (_timeout >= 0) { - args.Add("TIMEOUT"); + args.Add(SearchArgs.TIMEOUT); args.Add(_timeout); } if (_inOrder) { - args.Add("INORDER"); + args.Add(SearchArgs.INORDER); } if (_expander != null) { - args.Add("EXPANDER"); + args.Add(SearchArgs.EXPANDER); args.Add(_expander); } } diff --git a/src/NRedisStack/Search/SearchCommandBuilder.cs b/src/NRedisStack/Search/SearchCommandBuilder.cs index 35d82136..7da5b388 100644 --- a/src/NRedisStack/Search/SearchCommandBuilder.cs +++ b/src/NRedisStack/Search/SearchCommandBuilder.cs @@ -149,14 +149,77 @@ public static SerializedCommand ExplainCli(string indexName, string query, int? } public static SerializedCommand Info(RedisValue index) => - new SerializedCommand("FT.INFO", index); + new SerializedCommand(FT.INFO, index); public static SerializedCommand Search(string indexName, Query q) { var args = new List { indexName }; q.SerializeRedisArgs(args); - return new SerializedCommand("FT.SEARCH", args); + return new SerializedCommand(FT.SEARCH, args); + } + + public static SerializedCommand ProfileSearch(string IndexName, Query q, bool limited = false) + { + var args = + (limited) + ? new List(){IndexName, SearchArgs.SEARCH, SearchArgs.LIMITED, SearchArgs.QUERY} + : new List(){IndexName, SearchArgs.SEARCH, SearchArgs.QUERY}; + + q.SerializeRedisArgs(args); + return new SerializedCommand(FT.PROFILE, args); + } + + public static SerializedCommand ProfileAggregate(string IndexName, AggregationRequest query, bool limited = false) + { + var args = (limited) + ? new List{IndexName, SearchArgs.AGGREGATE, SearchArgs.LIMITED, SearchArgs.QUERY} + : new List{IndexName, SearchArgs.AGGREGATE, SearchArgs.QUERY}; + + query.SerializeRedisArgs(); + args.AddRange(query.GetArgs()); + return new SerializedCommand(FT.PROFILE, args); + } + + public static SerializedCommand SpellCheck(string indexName, string query, FTSpellCheckParams? spellCheckParams = null) + { + if (spellCheckParams != null) + { + spellCheckParams.SerializeRedisArgs(); + var args = new List(spellCheckParams!.GetArgs().Count + 2) { indexName, query }; // TODO: check if this improves performance (create a list with exact size) + args.AddRange(spellCheckParams.GetArgs()); + return new SerializedCommand(FT.SPELLCHECK, args); + } + + return new SerializedCommand(FT.SPELLCHECK, indexName, query); + } + + public static SerializedCommand SugAdd(string key, string str, double score, bool increment = false, string? payload = null) + { + var args = new List { key, str, score }; + if (increment) { args.Add(SearchArgs.INCR); } + if (payload != null) { args.Add(SearchArgs.PAYLOAD); args.Add(payload); } + return new SerializedCommand(FT.SUGADD, args); + } + + public static SerializedCommand SugDel(string key, string str) + { + return new SerializedCommand(FT.SUGDEL, key, str); + } + + public static SerializedCommand SugGet(string key, string prefix, bool fuzzy = false, bool withScores = false, bool withPayloads = false, int? max = null) + { + var args = new List { key, prefix }; + if (fuzzy) { args.Add(SearchArgs.FUZZY); } + if (withScores) { args.Add(SearchArgs.WITHSCORES); } + if (withPayloads) { args.Add(SearchArgs.WITHPAYLOADS); } + if (max != null) { args.Add(SearchArgs.MAX); args.Add(max); } + return new SerializedCommand(FT.SUGGET, args); + } + + public static SerializedCommand SugLen(string key) + { + return new SerializedCommand(FT.SUGLEN, key); } public static SerializedCommand SynDump(string indexName) diff --git a/src/NRedisStack/Search/SearchCommands.cs b/src/NRedisStack/Search/SearchCommands.cs index ff9ed40a..a53772d8 100644 --- a/src/NRedisStack/Search/SearchCommands.cs +++ b/src/NRedisStack/Search/SearchCommands.cs @@ -32,22 +32,13 @@ public RedisResult[] _List() /// public AggregationResult Aggregate(string index, AggregationRequest query) { - if(query.dialect == null && defaultDialect != null) + if (query.dialect == null && defaultDialect != null) { query.Dialect((int)defaultDialect); } var result = _db.Execute(SearchCommandBuilder.Aggregate(index, query)); - if (query.IsWithCursor()) - { - var results = (RedisResult[])result; - - return new AggregationResult(results[0], (long)results[1]); - } - else - { - return new AggregationResult(result); - } + return result.ToAggregationResult(query); } /// @@ -86,12 +77,19 @@ public bool ConfigSet(string option, string value) return _db.Execute(SearchCommandBuilder.ConfigSet(option, value)).OKtoBoolean(); } + // TODO: Add an ability to add fildes like that: TextField.Of("name") /// public bool Create(string indexName, FTCreateParams parameters, Schema schema) { return _db.Execute(SearchCommandBuilder.Create(indexName, parameters, schema)).OKtoBoolean(); } + /// + public bool Create(string indexName, Schema schema) + { + return Create(indexName, new FTCreateParams(), schema); + } + /// public bool CursorDel(string indexName, long cursorId) { @@ -153,8 +151,18 @@ public RedisResult[] ExplainCli(string indexName, string query, int? dialect = n public InfoResult Info(RedisValue index) => new InfoResult(_db.Execute(SearchCommandBuilder.Info(index))); - // TODO: FT.PROFILE (jedis doesn't have it) - + /// + public Tuple> ProfileSearch(string indexName, Query q, bool limited = false) + { + return _db.Execute(SearchCommandBuilder.ProfileSearch(indexName, q, limited)) + .ToProfileSearchResult(q); + } + /// + public Tuple> ProfileAggregate(string indexName, AggregationRequest query, bool limited = false) + { + return _db.Execute(SearchCommandBuilder.ProfileAggregate(indexName, query, limited)) + .ToProfileAggregateResult(query); + } /// public SearchResult Search(string indexName, Query q) { @@ -162,10 +170,49 @@ public SearchResult Search(string indexName, Query q) { q.Dialect((int)defaultDialect); } - var resp = _db.Execute(SearchCommandBuilder.Search(indexName, q)).ToArray(); - return new SearchResult(resp, !q.NoContent, q.WithScores, q.WithPayloads/*, q.ExplainScore*/); + return _db.Execute(SearchCommandBuilder.Search(indexName, q)).ToSearchResult(q); + } + + /// + public Dictionary> SpellCheck(string indexName, string query, FTSpellCheckParams? spellCheckParams = null) + { + return _db.Execute(SearchCommandBuilder.SpellCheck(indexName, query, spellCheckParams)).ToFtSpellCheckResult(); + } + + /// + public long SugAdd(string key, string str, double score, bool increment = false, string? payload = null) + { + return _db.Execute(SearchCommandBuilder.SugAdd(key, str, score, increment, payload)).ToLong(); + } + + + /// + public bool SugDel(string key, string str) + { + return _db.Execute(SearchCommandBuilder.SugDel(key, str)).ToString() == "1"; + } + + + /// + public List SugGet(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null) + { + return _db.Execute(SearchCommandBuilder.SugGet(key, prefix, fuzzy, false, withPayloads, max)).ToStringList(); + } + + /// + public List> SugGetWithScores(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null) + { + return _db.Execute(SearchCommandBuilder.SugGet(key, prefix, fuzzy, true, withPayloads, max)).ToStringDoubleTupleList(); } + + /// + public long SugLen(string key) + { + return _db.Execute(SearchCommandBuilder.SugLen(key)).ToLong(); + } + + /// public Dictionary> SynDump(string indexName) { @@ -180,7 +227,6 @@ public Dictionary> SynDump(string indexName) return result; } - // TODO: FT.SPELLCHECK (jedis doesn't have it) /// public bool SynUpdate(string indexName, string synonymGroupId, bool skipInitialScan = false, params string[] terms) diff --git a/src/NRedisStack/Search/SearchCommandsAsync.cs b/src/NRedisStack/Search/SearchCommandsAsync.cs index d0de86b0..96994a73 100644 --- a/src/NRedisStack/Search/SearchCommandsAsync.cs +++ b/src/NRedisStack/Search/SearchCommandsAsync.cs @@ -83,6 +83,12 @@ public async Task CreateAsync(string indexName, FTCreateParams parameters, return (await _db.ExecuteAsync(SearchCommandBuilder.Create(indexName, parameters, schema))).OKtoBoolean(); } + /// + public async Task CreateAsync(string indexName, Schema schema) + { + return (await CreateAsync(indexName, new FTCreateParams(), schema)); + } + /// public async Task CursorDelAsync(string indexName, long cursorId) { @@ -146,7 +152,18 @@ public async Task ExplainCliAsync(string indexName, string query, public async Task InfoAsync(RedisValue index) => new InfoResult(await _db.ExecuteAsync(SearchCommandBuilder.Info(index))); - // TODO: FT.PROFILE (jedis doesn't have it) + /// + public async Task>> ProfileSearchAsync(string indexName, Query q, bool limited = false) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.ProfileSearch(indexName, q, limited))) + .ToProfileSearchResult(q); + } + /// + public async Task>> ProfileAggregateAsync(string indexName, AggregationRequest query, bool limited = false) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.ProfileAggregate(indexName, query, limited))) + .ToProfileAggregateResult(query); + } /// public async Task SearchAsync(string indexName, Query q) @@ -156,11 +173,50 @@ public async Task SearchAsync(string indexName, Query q) q.Dialect((int)defaultDialect); } - var resp = (await _db.ExecuteAsync(SearchCommandBuilder.Search(indexName, q))).ToArray(); - return new SearchResult(resp, !q.NoContent, q.WithScores, q.WithPayloads/*, q.ExplainScore*/); + return (await _db.ExecuteAsync(SearchCommandBuilder.Search(indexName, q))).ToSearchResult(q); } - // TODO: FT.SPELLCHECK (jedis doesn't have it) + /// + public async Task>> SpellCheckAsync(string indexName, string query, FTSpellCheckParams? spellCheckParams = null) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.SpellCheck(indexName, query, spellCheckParams))).ToFtSpellCheckResult(); + } + + /// + public async Task SugAddAsync(string key, string str, double score, bool increment = false, string? payload = null) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.SugAdd(key, str, score, increment, payload))).ToLong(); + } + + + /// + public async Task SugDelAsync(string key, string str) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.SugDel(key, str))).ToString() == "1"; + + } + + + /// + public async Task> SugGetAsync(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.SugGet(key, prefix, fuzzy, withScores: false, withPayloads, max))).ToStringList(); + + } + + /// + public async Task>> SugGetWithScoresAsync(string key, string prefix, bool fuzzy = false, bool withPayloads = false, int? max = null) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.SugGet(key, prefix, fuzzy, withScores: true, withPayloads, max))).ToStringDoubleTupleList(); + } + + + /// + public async Task SugLenAsync(string key) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.SugLen(key))).ToLong(); + + } /// public async Task>> SynDumpAsync(string indexName) diff --git a/src/NRedisStack/Transactions.cs b/src/NRedisStack/Transactions.cs index 289c61d1..2399090d 100644 --- a/src/NRedisStack/Transactions.cs +++ b/src/NRedisStack/Transactions.cs @@ -18,14 +18,14 @@ public Transaction(IDatabase db) public Task ExecuteAsync(CommandFlags flags = CommandFlags.None) => _transaction.ExecuteAsync(flags); - public IBloomCommandsAsync Bf => new BloomCommandsAsync(_transaction); - public ICmsCommandsAsync Cms => new CmsCommandsAsync(_transaction); - public ICuckooCommandsAsync Cf => new CuckooCommandsAsync(_transaction); - public IGraphCommandsAsync Graph => new GraphCommandsAsync(_transaction); - public IJsonCommandsAsync Json => new JsonCommandsAsync(_transaction); - public ISearchCommandsAsync Ft => new SearchCommandsAsync(_transaction); - public ITdigestCommandsAsync Tdigest => new TdigestCommandsAsync(_transaction); - public ITimeSeriesCommandsAsync Ts => new TimeSeriesCommandsAsync(_transaction); - public ITopKCommandsAsync TopK => new TopKCommandsAsync(_transaction); + public BloomCommandsAsync Bf => new BloomCommandsAsync(_transaction); + public CmsCommandsAsync Cms => new CmsCommandsAsync(_transaction); + public CuckooCommandsAsync Cf => new CuckooCommandsAsync(_transaction); + public GraphCommandsAsync Graph => new GraphCommandsAsync(_transaction); + public JsonCommandsAsync Json => new JsonCommandsAsync(_transaction); + public SearchCommandsAsync Ft => new SearchCommandsAsync(_transaction); + public TdigestCommandsAsync Tdigest => new TdigestCommandsAsync(_transaction); + public TimeSeriesCommandsAsync Ts => new TimeSeriesCommandsAsync(_transaction); + public TopKCommandsAsync TopK => new TopKCommandsAsync(_transaction); } } diff --git a/tests/NRedisStack.Tests/Search/SearchTests.cs b/tests/NRedisStack.Tests/Search/SearchTests.cs index 34bce220..8b4b2320 100644 --- a/tests/NRedisStack.Tests/Search/SearchTests.cs +++ b/tests/NRedisStack.Tests/Search/SearchTests.cs @@ -24,22 +24,22 @@ public void Dispose() private void AddDocument(IDatabase db, Document doc) { - string key = doc.Id; - var properties = doc.GetProperties(); - // HashEntry[] hash = new HashEntry[properties.Count()]; - // for(int i = 0; i < properties.Count(); i++) - // { - // var property = properties.ElementAt(i); - // hash[i] = new HashEntry(property.Key, property.Value); - // } - // db.HashSet(key, hash); - var nameValue = new List() { key }; - foreach (var item in properties) - { - nameValue.Add(item.Key); - nameValue.Add(item.Value); - } - db.Execute("HSET", nameValue); + string key = doc.Id; + var properties = doc.GetProperties(); + // HashEntry[] hash = new HashEntry[properties.Count()]; + // for(int i = 0; i < properties.Count(); i++) + // { + // var property = properties.ElementAt(i); + // hash[i] = new HashEntry(property.Key, property.Value); + // } + // db.HashSet(key, hash); + var nameValue = new List() { key }; + foreach (var item in properties) + { + nameValue.Add(item.Key); + nameValue.Add(item.Value); + } + db.Execute("HSET", nameValue); } private void AddDocument(IDatabase db, string key, Dictionary objDictionary) @@ -1936,7 +1936,7 @@ public void TestQueryCommandBuilderScore() db.Execute("FLUSHALL"); var ft = db.FT(); - db.Execute("JSON.SET", "doc:1", "$", "[{\"arr\": [1, 2, 3]}, {\"val\": \"hello\"}, {\"val\": \"world\"}]"); + db.Execute("JSON.SET", "doc:1", "$", "[{\"arr\": [1, 2, 3]}, {\"val\": \"hello\"}, {\"val\": \"world\"}]"); db.Execute("FT.CREATE", "idx", "ON", "JSON", "PREFIX", "1", "doc:", "SCHEMA", "$..arr", "AS", "arr", "NUMERIC", "$..val", "AS", "val", "TEXT"); // sleep: Thread.Sleep(2000); @@ -2011,16 +2011,16 @@ public void TestLimit() var ft = db.FT(); ft.Create("idx", new FTCreateParams(), new Schema().AddTextField("t1").AddTextField("t2")); - Document doc1 = new Document("doc1", new Dictionary {{"t1", "a"}, {"t2", "b"}}); - Document doc2 = new Document("doc2", new Dictionary {{"t1", "b"}, {"t2", "a"}}); + Document doc1 = new Document("doc1", new Dictionary { { "t1", "a" }, { "t2", "b" } }); + Document doc2 = new Document("doc2", new Dictionary { { "t1", "b" }, { "t2", "a" } }); AddDocument(db, doc1); AddDocument(db, doc2); var req = new AggregationRequest("*").SortBy("@t1").Limit(1); var res = ft.Aggregate("idx", req); - Assert.Equal( res.GetResults().Count, 1); - Assert.Equal( res.GetResults()[0]["t1"].ToString(), "a"); + Assert.Equal(res.GetResults().Count, 1); + Assert.Equal(res.GetResults()[0]["t1"].ToString(), "a"); } [Fact] @@ -2031,16 +2031,16 @@ public async Task TestLimitAsync() var ft = db.FT(); ft.Create("idx", new FTCreateParams(), new Schema().AddTextField("t1").AddTextField("t2")); - Document doc1 = new Document("doc1", new Dictionary {{"t1", "a"}, {"t2", "b"}}); - Document doc2 = new Document("doc2", new Dictionary {{"t1", "b"}, {"t2", "a"}}); + Document doc1 = new Document("doc1", new Dictionary { { "t1", "a" }, { "t2", "b" } }); + Document doc2 = new Document("doc2", new Dictionary { { "t1", "b" }, { "t2", "a" } }); AddDocument(db, doc1); AddDocument(db, doc2); var req = new AggregationRequest("*").SortBy("@t1").Limit(1, 1); var res = await ft.AggregateAsync("idx", req); - Assert.Equal( res.GetResults().Count, 1); - Assert.Equal( res.GetResults()[0]["t1"].ToString(), "b"); + Assert.Equal(res.GetResults().Count, 1); + Assert.Equal(res.GetResults()[0]["t1"].ToString(), "b"); } [Fact] @@ -2134,7 +2134,7 @@ public void VectorSimilaritySearch() Assert.Equal(0, res.Documents[0]["__vector_score"]); - var jsonRes = res.ToJson(); + var jsonRes = res.ToJson(); Assert.Equal("{\"vector\":[2,2,2,2]}", jsonRes![0]); } @@ -2252,6 +2252,142 @@ public void TestQueryParamsWithParams_DefaultDialect() Assert.Equal(2, res.TotalResults); } + [Fact] + public void TestBasicSpellCheck() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("name").AddTextField("body")); + + db.HashSet("doc1", new HashEntry[] { new HashEntry("name", "name1"), new HashEntry("body", "body1") }); + db.HashSet("doc1", new HashEntry[] { new HashEntry("name", "name2"), new HashEntry("body", "body2") }); + db.HashSet("doc1", new HashEntry[] { new HashEntry("name", "name2"), new HashEntry("body", "name2") }); + + var reply = ft.SpellCheck(index, "name"); + Assert.Equal(1, reply.Keys.Count); + Assert.Equal("name", reply.Keys.First()); + Assert.Equal(1, reply["name"]["name1"]); + Assert.Equal(2, reply["name"]["name2"]); + } + + [Fact] + public async Task TestBasicSpellCheckAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("name").AddTextField("body")); + + db.HashSet("doc1", new HashEntry[] { new HashEntry("name", "name1"), new HashEntry("body", "body1") }); + db.HashSet("doc1", new HashEntry[] { new HashEntry("name", "name2"), new HashEntry("body", "body2") }); + db.HashSet("doc1", new HashEntry[] { new HashEntry("name", "name2"), new HashEntry("body", "name2") }); + + var reply = await ft.SpellCheckAsync(index, "name"); + Assert.Equal(1, reply.Keys.Count); + Assert.Equal("name", reply.Keys.First()); + Assert.Equal(1, reply["name"]["name1"]); + Assert.Equal(2, reply["name"]["name2"]); + } + + [Fact] + public void TestCrossTermDictionary() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("name").AddTextField("body")); + ft.DictAdd("slang", "timmies", "toque", "toonie", "serviette", "kerfuffle", "chesterfield"); + var expected = new Dictionary>() + { + ["tooni"] = new Dictionary() + { + ["toonie"] = 0d + } + }; + + Assert.Equal(expected, ft.SpellCheck(index, + "Tooni toque kerfuffle", + new FTSpellCheckParams() + .IncludeTerm("slang") + .ExcludeTerm("slang"))); + } + + [Fact] + public async Task TestCrossTermDictionaryAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("name").AddTextField("body")); + ft.DictAdd("slang", "timmies", "toque", "toonie", "serviette", "kerfuffle", "chesterfield"); + var expected = new Dictionary>() + { + ["tooni"] = new Dictionary() + { + ["toonie"] = 0d + } + }; + + Assert.Equal(expected, await ft.SpellCheckAsync(index, + "Tooni toque kerfuffle", + new FTSpellCheckParams() + .IncludeTerm("slang") + .ExcludeTerm("slang"))); + } + + [Fact] + public void TestDistanceBound() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("name").AddTextField("body")); + // distance suppose to be between 1 and 4 + Assert.Throws(() => ft.SpellCheck(index, "name", new FTSpellCheckParams().Distance(0))); + } + + [Fact] + public async Task TestDistanceBoundAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("name").AddTextField("body")); + // distance suppose to be between 1 and 4 + await Assert.ThrowsAsync(async () => await ft.SpellCheckAsync(index, "name", new FTSpellCheckParams().Distance(0))); + } + + [Fact] + public void TestDialectBound() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("t")); + // dialect 0 is not valid + Assert.Throws(() => ft.SpellCheck(index, "name", new FTSpellCheckParams().Dialect(0))); + } + + [Fact] + public async Task TestDialectBoundAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new FTCreateParams(), new Schema().AddTextField("t")); + // dialect 0 is not valid + await Assert.ThrowsAsync(async () => await ft.SpellCheckAsync(index, "name", new FTSpellCheckParams().Dialect(0))); + } + [Fact] public async Task TestQueryParamsWithParams_DefaultDialectAsync() { @@ -2280,6 +2416,345 @@ public async Task TestQueryParamsWithParams_DefaultDialectAsync() Assert.Equal(2, res.TotalResults); } + string key = "SugTestKey"; + + [Fact] + public void TestAddAndGetSuggestion() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + string suggestion = "ANOTHER_WORD"; + string noMatch = "_WORD MISSED"; + + Assert.True(ft.SugAdd(key, suggestion, 1d) > 0); + Assert.True(ft.SugAdd(key, noMatch, 1d) > 0); + + // test that with a partial part of that string will have the entire word returned + Assert.Equal(1, ft.SugGet(key, suggestion.Substring(0, 3), true, max: 5).Count); + + // turn off fuzzy start at second word no hit + Assert.Equal(0, ft.SugGet(key, noMatch.Substring(1, 6), false, max: 5).Count); + + // my attempt to trigger the fuzzy by 1 character + Assert.Equal(1, ft.SugGet(key, noMatch.Substring(1, 6), true, max: 5).Count); + } + + [Fact] + public async Task TestAddAndGetSuggestionAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + string suggestion = "ANOTHER_WORD"; + string noMatch = "_WORD MISSED"; + + Assert.True(await ft.SugAddAsync(key, suggestion, 1d) > 0); + Assert.True(await ft.SugAddAsync(key, noMatch, 1d) > 0); + + // test that with a partial part of that string will have the entire word returned + Assert.Equal(1, (await ft.SugGetAsync(key, suggestion.Substring(0, 3), true, max: 5)).Count); + + // turn off fuzzy start at second word no hit + Assert.Equal(0, (await ft.SugGetAsync(key, noMatch.Substring(1, 6), false, max: 5)).Count); + + // my attempt to trigger the fuzzy by 1 character + Assert.Equal(1, (await ft.SugGetAsync(key, noMatch.Substring(1, 6), true, max: 5)).Count); + } + + [Fact] + public void AddSuggestionIncrAndGetSuggestionFuzzy() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + string suggestion = "TOPIC OF WORDS"; + + // test can add a suggestion string + Assert.True(ft.SugAdd(key, suggestion, 1d, increment: true) > 0); + + // test that the partial part of that string will be returned using fuzzy + Assert.Equal(suggestion, ft.SugGet(key, suggestion.Substring(0, 3))[0]); + } + + [Fact] + public async Task AddSuggestionIncrAndGetSuggestionFuzzyAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + string suggestion = "TOPIC OF WORDS"; + + // test can add a suggestion string + Assert.True(await ft.SugAddAsync(key, suggestion, 1d, increment: true) > 0); + + // test that the partial part of that string will be returned using fuzzy + Assert.Equal(suggestion, (await ft.SugGetAsync(key, suggestion.Substring(0, 3)))[0]); + } + + [Fact] + public void getSuggestionScores() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + ft.SugAdd(key, "COUNT_ME TOO", 1); + ft.SugAdd(key, "COUNT", 1); + ft.SugAdd(key, "COUNT_ANOTHER", 1); + + string noScoreOrPayload = "COUNT NO PAYLOAD OR COUNT"; + Assert.True(ft.SugAdd(key, noScoreOrPayload, 1, increment: true) > 1); + + var result = ft.SugGetWithScores(key, "COU"); + Assert.Equal(4, result.Count); + foreach (var tuple in result) + { + Assert.True(tuple.Item2 < .999); + } + } + + [Fact] + public async Task getSuggestionScoresAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + await ft.SugAddAsync(key, "COUNT_ME TOO", 1); + await ft.SugAddAsync(key, "COUNT", 1); + await ft.SugAddAsync(key, "COUNT_ANOTHER", 1); + + string noScoreOrPayload = "COUNT NO PAYLOAD OR COUNT"; + Assert.True(await ft.SugAddAsync(key, noScoreOrPayload, 1, increment: true) > 1); + + var result = await ft.SugGetWithScoresAsync(key, "COU"); + Assert.Equal(4, result.Count); + foreach (var tuple in result) + { + Assert.True(tuple.Item2 < .999); + } + } + + [Fact] + public void getSuggestionMax() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + ft.SugAdd(key, "COUNT_ME TOO", 1); + ft.SugAdd(key, "COUNT", 1); + ft.SugAdd(key, "COUNTNO PAYLOAD OR COUNT", 1); + + // test that with a partial part of that string will have the entire word returned + Assert.Equal(3, ft.SugGetWithScores(key, "COU", true, max: 10).Count); + Assert.Equal(2, ft.SugGetWithScores(key, "COU", true, max: 2).Count); + } + + [Fact] + public async Task getSuggestionMaxAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + await ft.SugAddAsync(key, "COUNT_ME TOO", 1); + await ft.SugAddAsync(key, "COUNT", 1); + await ft.SugAddAsync(key, "COUNTNO PAYLOAD OR COUNT", 1); + + // test that with a partial part of that string will have the entire word returned + Assert.Equal(3, (await ft.SugGetWithScoresAsync(key, "COU", true, max: 10)).Count); + Assert.Equal(2, (await ft.SugGetWithScoresAsync(key, "COU", true, max: 2)).Count); + } + + [Fact] + public void getSuggestionNoHit() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + ft.SugAdd(key, "NO WORD", 0.4); + + Assert.Equal(0, ft.SugGetWithScores(key, "DIF").Count); + Assert.Equal(0, ft.SugGet(key, "DIF").Count); + } + + [Fact] + public async Task getSuggestionNoHitAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + await ft.SugAddAsync(key, "NO WORD", 0.4); + + Assert.Equal(0, (await ft.SugGetWithScoresAsync(key, "DIF")).Count); + Assert.Equal(0, (await ft.SugGetAsync(key, "DIF")).Count); + } + + [Fact] + public void getSuggestionLengthAndDeleteSuggestion() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + ft.SugAdd(key, "TOPIC OF WORDS", 1, increment: true); + ft.SugAdd(key, "ANOTHER ENTRY", 1, increment: true); + Assert.Equal(2L, ft.SugLen(key)); + + Assert.True(ft.SugDel(key, "ANOTHER ENTRY")); + Assert.Equal(1L, ft.SugLen(key)); + + Assert.False(ft.SugDel(key, "ANOTHER ENTRY")); + Assert.Equal(1L, ft.SugLen(key)); + + Assert.False(ft.SugDel(key, "ANOTHER ENTRY THAT IS NOT PRESENT")); + Assert.Equal(1L, ft.SugLen(key)); + + ft.SugAdd(key, "LAST ENTRY", 1); + Assert.Equal(2L, ft.SugLen(key)); + } + + [Fact] + public async Task getSuggestionLengthAndDeleteSuggestionAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + await ft.SugAddAsync(key, "TOPIC OF WORDS", 1, increment: true); + await ft.SugAddAsync(key, "ANOTHER ENTRY", 1, increment: true); + Assert.Equal(2L, await ft.SugLenAsync(key)); + + Assert.True(await ft.SugDelAsync(key, "ANOTHER ENTRY")); + Assert.Equal(1L, await ft.SugLenAsync(key)); + + Assert.False(await ft.SugDelAsync(key, "ANOTHER ENTRY")); + Assert.Equal(1L, await ft.SugLenAsync(key)); + + Assert.False(await ft.SugDelAsync(key, "ANOTHER ENTRY THAT IS NOT PRESENT")); + Assert.Equal(1L, await ft.SugLenAsync(key)); + + ft.SugAdd(key, "LAST ENTRY", 1); + Assert.Equal(2L, await ft.SugLenAsync(key)); + } + + [Fact] + public void TestProfileSearch() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + Schema sc = new Schema().AddTextField("t1", 1.0).AddTextField("t2", 1.0); + Assert.True(ft.Create(index, new FTCreateParams(), sc)); + + db.HashSet("doc1", new HashEntry[] { + new HashEntry("t1", "foo"), + new HashEntry("t2", "bar")}); + + var profile = ft.ProfileSearch(index, new Query("foo")); + // Iterators profile={Type=TEXT, Time=0.0, Term=foo, Counter=1, Size=1} + profile.Item2["Iterators profile"].ToDictionary(); + var iteratorsProfile = profile.Item2["Iterators profile"].ToDictionary(); + Assert.Equal("TEXT", iteratorsProfile["Type"].ToString()); + Assert.Equal("foo", iteratorsProfile["Term"].ToString()); + Assert.Equal("1", iteratorsProfile["Counter"].ToString()); + Assert.Equal("1", iteratorsProfile["Size"].ToString()); + } + + [Fact] + public async Task TestProfileSearchAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + Schema sc = new Schema().AddTextField("t1", 1.0).AddTextField("t2", 1.0); + Assert.True(ft.Create(index, new FTCreateParams(), sc)); + + db.HashSet("doc1", new HashEntry[] { + new HashEntry("t1", "foo"), + new HashEntry("t2", "bar")}); + + var profile = await ft.ProfileSearchAsync(index, new Query("foo")); + // Iterators profile={Type=TEXT, Time=0.0, Term=foo, Counter=1, Size=1} + profile.Item2["Iterators profile"].ToDictionary(); + var iteratorsProfile = profile.Item2["Iterators profile"].ToDictionary(); + Assert.Equal("TEXT", iteratorsProfile["Type"].ToString()); + Assert.Equal("foo", iteratorsProfile["Term"].ToString()); + Assert.Equal("1", iteratorsProfile["Counter"].ToString()); + Assert.Equal("1", iteratorsProfile["Size"].ToString()); + } + + + [Fact] + public void TestProfile() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + ft.Create(index, new Schema().AddTextField("t")); // Calling FT.CREATR without FTCreateParams + db.HashSet("1", "t", "hello"); + db.HashSet("2", "t", "world"); + + // check using Query + var q = new Query("hello|world").SetNoContent(); + var profileSearch = ft.ProfileSearch(index, q); + var searchRes = profileSearch.Item1; + var searchDet = profileSearch.Item2; + + Assert.Equal(searchDet.Count , 5); + Assert.Equal(searchRes.Documents.Count , 2); + + // check using AggregationRequest + var aggReq = new AggregationRequest("*").Load(FieldName.Of("t")).Apply("startswith(@t, 'hel')", "prefix"); + var profileAggregate = ft.ProfileAggregate(index, aggReq); + var aggregateRes  = profileAggregate.Item1; + var aggregateDet = profileAggregate.Item2; + Assert.Equal(aggregateDet.Count , 5); + Assert.Equal(aggregateRes.TotalResults, 1); + } + + [Fact] + public async Task TestProfileAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + await ft.CreateAsync(index, new Schema().AddTextField("t")); // Calling FT.CREATR without FTCreateParams + db.HashSet("1", "t", "hello"); + db.HashSet("2", "t", "world"); + + // check using Query + var q = new Query("hello|world").SetNoContent(); + var profileSearch = await ft.ProfileSearchAsync(index, q); + var searchRes = profileSearch.Item1; + var searchDet = profileSearch.Item2; + + Assert.Equal(searchDet.Count , 5); + Assert.Equal(searchRes.Documents.Count , 2); + + // check using AggregationRequest + var aggReq = new AggregationRequest("*").Load(FieldName.Of("t")).Apply("startswith(@t, 'hel')", "prefix"); + var profileAggregate = await ft.ProfileAggregateAsync(index, aggReq); + var aggregateRes  = profileAggregate.Item1; + var aggregateDet = profileAggregate.Item2; + Assert.Equal(aggregateDet.Count , 5); + Assert.Equal(aggregateRes.TotalResults, 1); + } + + [Fact] + public void TestProfileCommandBuilder() + { + var search = SearchCommandBuilder.ProfileSearch("index", new Query(), true); + var aggregate = SearchCommandBuilder.ProfileAggregate("index", new AggregationRequest(), true); + + Assert.Equal("FT.PROFILE", search.Command); + Assert.Equal("FT.PROFILE", aggregate.Command); + Assert.Equal(new object[] { "index", "SEARCH", "LIMITED", "QUERY", "*" }, search.Args); + Assert.Equal(new object[] { "index", "AGGREGATE", "LIMITED", "QUERY", "*" }, aggregate.Args); + } + [Fact] public void TestModulePrefixs1() {