diff --git a/.github/workflows/nuget-release.yml b/.github/workflows/nuget-release.yml index 5a9b5d23..3a0c3e09 100644 --- a/.github/workflows/nuget-release.yml +++ b/.github/workflows/nuget-release.yml @@ -19,6 +19,25 @@ jobs: run: dotnet --list-sdks - name: Check .NET runtimes run: dotnet --list-runtimes + + - name: get version from tag + id: get_version + run: | + realversion="${GITHUB_REF/refs\/tags\//}" + realversion="${realversion//v/}" + echo "VERSION=$realversion" >> $GITHUB_OUTPUT + + - name: Update version in csproj + run: | + VERSION=${{ steps.get_version.outputs.VERSION }} + echo "Setting version to $VERSION" + + # Update the version in the NRedisStack.csproj file + sed -i "s|.*|$VERSION|" ./src/NRedisStack/NRedisStack.csproj + sed -i "s|.*|$VERSION|" ./src/NRedisStack/NRedisStack.csproj + sed -i "s|.*|$VERSION|" ./src/NRedisStack/NRedisStack.csproj + cat ./src/NRedisStack/NRedisStack.csproj + - name: Build run: dotnet pack -c Release --output . - name: Publish diff --git a/.github/workflows/reusable.yml b/.github/workflows/reusable.yml index 755153b1..5f41716b 100644 --- a/.github/workflows/reusable.yml +++ b/.github/workflows/reusable.yml @@ -37,7 +37,10 @@ jobs: - name: .NET Core 8 uses: actions/setup-dotnet@v3 with: - dotnet-version: 8 + dotnet-version: | + 6 + 7 + 8 - name: Run redis-stack-server docker working-directory: .github diff --git a/src/NRedisStack/Pipeline.cs b/src/NRedisStack/Pipeline.cs index 67f23ff8..3035576b 100644 --- a/src/NRedisStack/Pipeline.cs +++ b/src/NRedisStack/Pipeline.cs @@ -6,6 +6,7 @@ public class Pipeline { public Pipeline(IDatabase db) { + db.SetInfoInPipeline(); _batch = db.CreateBatch(); } diff --git a/src/NRedisStack/Search/DataTypes/InfoResult.cs b/src/NRedisStack/Search/DataTypes/InfoResult.cs index b676ca24..f931df92 100644 --- a/src/NRedisStack/Search/DataTypes/InfoResult.cs +++ b/src/NRedisStack/Search/DataTypes/InfoResult.cs @@ -1,14 +1,20 @@ -using StackExchange.Redis; +using System.Reflection.Emit; +using StackExchange.Redis; namespace NRedisStack.Search.DataTypes; public class InfoResult { private readonly Dictionary _all = new(); - private static readonly string[] booleanAttributes = { "SORTABLE", "UNF", "NOSTEM", "NOINDEX", "CASESENSITIVE", "WITHSUFFIXTRIE" }; + private Dictionary[] _attributes; + private Dictionary _indexOption; + private Dictionary _gcStats; + private Dictionary _cursorStats; + + private static readonly string[] booleanAttributes = { "SORTABLE", "UNF", "NOSTEM", "NOINDEX", "CASESENSITIVE", "WITHSUFFIXTRIE", "INDEXEMPTY", "INDEXMISSING" }; public string IndexName => GetString("index_name")!; - public Dictionary IndexOption => GetRedisResultDictionary("index_options")!; - public Dictionary[] Attributes => GetRedisResultDictionaryArray("attributes")!; + public Dictionary IndexOption => _indexOption = _indexOption ?? GetRedisResultDictionary("index_options")!; + public Dictionary[] Attributes => _attributes = _attributes ?? GetAttributesAsDictionaryArray()!; public long NumDocs => GetLong("num_docs"); public string MaxDocId => GetString("max_doc_id")!; public long NumTerms => GetLong("num_terms"); @@ -48,9 +54,9 @@ public class InfoResult public long NumberOfUses => GetLong("number_of_uses"); - public Dictionary GcStats => GetRedisResultDictionary("gc_stats")!; + public Dictionary GcStats => _gcStats = _gcStats ?? GetRedisResultDictionary("gc_stats")!; - public Dictionary CursorStats => GetRedisResultDictionary("cursor_stats")!; + public Dictionary CursorStats => _cursorStats = _cursorStats ?? GetRedisResultDictionary("cursor_stats")!; public InfoResult(RedisResult result) { @@ -94,24 +100,29 @@ private double GetDouble(string key) return result; } - private Dictionary[]? GetRedisResultDictionaryArray(string key) + private Dictionary[]? GetAttributesAsDictionaryArray() { - if (!_all.TryGetValue(key, out var value)) return default; + if (!_all.TryGetValue("attributes", out var value)) return default; var values = (RedisResult[])value!; var result = new Dictionary[values.Length]; for (int i = 0; i < values.Length; i++) { - var fv = (RedisResult[])values[i]!; var dict = new Dictionary(); - for (int j = 0; j < fv.Length; j += 2) + + IEnumerable enumerable = (RedisResult[])values[i]!; + IEnumerator results = enumerable.GetEnumerator(); + while (results.MoveNext()) { - if (booleanAttributes.Contains((string)fv[j]!)) + string attribute = (string)results.Current; + // if its boolean attributes add itself to the dictionary and continue + if (booleanAttributes.Contains(attribute)) { - dict.Add((string)fv[j]!, fv[j--]); + dict.Add(attribute, results.Current); } else - { - dict.Add((string)fv[j]!, fv[j + 1]); + {//if its not a boolean attribute, add the next item as value to the dictionary + results.MoveNext(); ; + dict.Add(attribute, results.Current); } } result[i] = dict; diff --git a/src/NRedisStack/Search/Document.cs b/src/NRedisStack/Search/Document.cs index 3223054f..c8f9dec5 100644 --- a/src/NRedisStack/Search/Document.cs +++ b/src/NRedisStack/Search/Document.cs @@ -31,6 +31,10 @@ public static Document Load(string id, double score, byte[]? payload, RedisValue { Document ret = new Document(id, score, payload); if (fields == null) return ret; + if (fields.Length == 1 && fields[0].IsNull) + { + return ret; + } for (int i = 0; i < fields.Length; i += 2) { string fieldName = fields[i]!; diff --git a/src/NRedisStack/Search/FieldName.cs b/src/NRedisStack/Search/FieldName.cs index b07d23f4..eee3ca7e 100644 --- a/src/NRedisStack/Search/FieldName.cs +++ b/src/NRedisStack/Search/FieldName.cs @@ -2,27 +2,27 @@ namespace NRedisStack.Search { public class FieldName { - private readonly string fieldName; - private string? alias; + public string Name { get; } + public string? Alias { get; private set; } public FieldName(string name) : this(name, null) { } public FieldName(string name, string? attribute) { - this.fieldName = name; - this.alias = attribute; + this.Name = name; + this.Alias = attribute; } public int AddCommandArguments(List args) { - args.Add(fieldName); - if (alias == null) + args.Add(Name); + if (Alias is null) { return 1; } args.Add("AS"); - args.Add(alias); + args.Add(Alias); return 3; } @@ -33,7 +33,7 @@ public static FieldName Of(string name) public FieldName As(string attribute) { - this.alias = attribute; + this.Alias = attribute; return this; } } diff --git a/src/NRedisStack/Search/Schema.cs b/src/NRedisStack/Search/Schema.cs index 9822673e..96bb920b 100644 --- a/src/NRedisStack/Search/Schema.cs +++ b/src/NRedisStack/Search/Schema.cs @@ -97,11 +97,10 @@ internal override void AddFieldTypeArgs(List args) AddPhonetic(args); AddWeight(args); if (WithSuffixTrie) args.Add(SearchArgs.WITHSUFFIXTRIE); - if (Sortable) args.Add(FieldOptions.SORTABLE); if (Unf) args.Add(SearchArgs.UNF); if (MissingIndex) args.Add(FieldOptions.INDEXMISSING); if (EmptyIndex) args.Add(FieldOptions.INDEXEMPTY); - + if (Sortable) args.Add(FieldOptions.SORTABLE); } private void AddWeight(List args) @@ -165,10 +164,10 @@ internal override void AddFieldTypeArgs(List args) args.Add(Separator); } if (CaseSensitive) args.Add(SearchArgs.CASESENSITIVE); - if (Sortable) args.Add(FieldOptions.SORTABLE); if (Unf) args.Add(SearchArgs.UNF); if (MissingIndex) args.Add(FieldOptions.INDEXMISSING); if (EmptyIndex) args.Add(FieldOptions.INDEXEMPTY); + if (Sortable) args.Add(FieldOptions.SORTABLE); } } @@ -192,10 +191,9 @@ internal GeoField(string name, bool sortable = false, bool noIndex = false, bool internal override void AddFieldTypeArgs(List args) { if (NoIndex) args.Add(SearchArgs.NOINDEX); - if (Sortable) args.Add(FieldOptions.SORTABLE); if (MissingIndex) args.Add(FieldOptions.INDEXMISSING); + if (Sortable) args.Add(FieldOptions.SORTABLE); } - } public class GeoShapeField : Field @@ -252,10 +250,9 @@ internal NumericField(string name, bool sortable = false, bool noIndex = false, internal override void AddFieldTypeArgs(List args) { if (NoIndex) args.Add(SearchArgs.NOINDEX); - if (Sortable) args.Add(FieldOptions.SORTABLE); if (MissingIndex) args.Add(FieldOptions.INDEXMISSING); + if (Sortable) args.Add(FieldOptions.SORTABLE); } - } public class VectorField : Field @@ -322,6 +319,10 @@ public Schema AddField(Field field) /// Set this to true to prevent the indexer from sorting on the normalized form. /// Normalied form is the field sent to lower case with all diaretics removed /// Keeps a suffix trie with all terms which match the suffix. + /// 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. + /// allows you to index and search for empty strings. By default, empty strings are not indexed. /// The object. public Schema AddTextField(string name, double weight = 1.0, bool sortable = false, bool unf = false, bool noStem = false, string? phonetic = null, bool noIndex = false, bool withSuffixTrie = false, bool missingIndex = false, bool emptyIndex = false) @@ -342,6 +343,10 @@ public Schema AddTextField(string name, double weight = 1.0, bool sortable = fal /// Set this to true to prevent the indexer from sorting on the normalized form. /// Normalied form is the field sent to lower case with all diaretics removed /// Keeps a suffix trie with all terms which match the suffix. + /// 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. + /// allows you to index and search for empty strings. By default, empty strings are not indexed. /// The object. public Schema AddTextField(FieldName name, double weight = 1.0, bool sortable = false, bool unf = false, bool noStem = false, string? phonetic = null, bool noIndex = false, bool withSuffixTrie = false, bool missingIndex = false, bool emptyIndex = false) @@ -355,6 +360,9 @@ public Schema AddTextField(FieldName name, double weight = 1.0, bool sortable = /// /// The field's name. /// The coordinate system to use. + /// 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. /// The object. public Schema AddGeoShapeField(string name, CoordinateSystem system, bool missingIndex = false) { @@ -367,6 +375,9 @@ public Schema AddGeoShapeField(string name, CoordinateSystem system, bool missin /// /// The field's name. /// The coordinate system to use. + /// 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. /// The object. public Schema AddGeoShapeField(FieldName name, CoordinateSystem system, bool missingIndex = false) { @@ -380,6 +391,9 @@ public Schema AddGeoShapeField(FieldName name, CoordinateSystem system, bool mis /// The field's name. /// If true, the text field can be sorted. /// Attributes can have the NOINDEX option, which means they will not be indexed. + /// 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. /// The object. public Schema AddGeoField(FieldName name, bool sortable = false, bool noIndex = false, bool missingIndex = false) { @@ -393,6 +407,9 @@ public Schema AddGeoField(FieldName name, bool sortable = false, bool noIndex = /// The field's name. /// If true, the text field can be sorted. /// Attributes can have the NOINDEX option, which means they will not be indexed. + /// 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. /// The object. public Schema AddGeoField(string name, bool sortable = false, bool noIndex = false, bool missingIndex = false) { @@ -406,6 +423,9 @@ public Schema AddGeoField(string name, bool sortable = false, bool noIndex = fal /// The field's name. /// If true, the text field can be sorted. /// Attributes can have the NOINDEX option, which means they will not be indexed. + /// 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. /// The object. public Schema AddNumericField(FieldName name, bool sortable = false, bool noIndex = false, bool missingIndex = false) { @@ -419,6 +439,9 @@ public Schema AddNumericField(FieldName name, bool sortable = false, bool noInde /// The field's name. /// If true, the text field can be sorted. /// Attributes can have the NOINDEX option, which means they will not be indexed. + /// 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. /// The object. public Schema AddNumericField(string name, bool sortable = false, bool noIndex = false, bool missingIndex = false) { @@ -437,6 +460,10 @@ public Schema AddNumericField(string name, bool sortable = false, bool noIndex = /// If true, Keeps the original letter cases of the tags. /// Normalied form is the field sent to lower case with all diaretics removed /// Keeps a suffix trie with all terms which match the suffix. + /// 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. + /// allows you to index and search for empty strings. By default, empty strings are not indexed. /// The object. public Schema AddTagField(FieldName name, bool sortable = false, bool unf = false, bool noIndex = false, string separator = ",", @@ -457,6 +484,10 @@ public Schema AddTagField(FieldName name, bool sortable = false, bool unf = fals /// If true, Keeps the original letter cases of the tags. /// Normalied form is the field sent to lower case with all diaretics removed /// Keeps a suffix trie with all terms which match the suffix. + /// 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. + /// allows you to index and search for empty strings. By default, empty strings are not indexed. /// The object. public Schema AddTagField(string name, bool sortable = false, bool unf = false, bool noIndex = false, string separator = ",", @@ -471,7 +502,10 @@ public Schema AddTagField(string name, bool sortable = false, bool unf = false, /// /// The field's name. /// The vector similarity algorithm to use. - /// The algorithm attributes for the creation of the vector index. + /// The algorithm attributes for the creation of the vector index. + /// 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. /// The object. public Schema AddVectorField(FieldName name, VectorAlgo algorithm, Dictionary? attributes = null, bool missingIndex = false) { @@ -484,7 +518,10 @@ public Schema AddVectorField(FieldName name, VectorAlgo algorithm, Dictionary /// The field's name. /// The vector similarity algorithm to use. - /// The algorithm attributes for the creation of the vector index. + /// The algorithm attributes for the creation of the vector index. + /// 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. /// The object. public Schema AddVectorField(string name, VectorAlgo algorithm, Dictionary? attributes = null, bool missingIndex = false) { diff --git a/tests/NRedisStack.Tests/NRedisStack.Tests.csproj b/tests/NRedisStack.Tests/NRedisStack.Tests.csproj index 7aceede5..cb522fcc 100644 --- a/tests/NRedisStack.Tests/NRedisStack.Tests.csproj +++ b/tests/NRedisStack.Tests/NRedisStack.Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/tests/NRedisStack.Tests/PipelineTests.cs b/tests/NRedisStack.Tests/PipelineTests.cs index cf4826fc..52ec7540 100644 --- a/tests/NRedisStack.Tests/PipelineTests.cs +++ b/tests/NRedisStack.Tests/PipelineTests.cs @@ -157,4 +157,20 @@ public void TestJsonPipeline() Assert.True(setResponse.Result); Assert.Equal("{\"Name\":\"Shachar\",\"Age\":23}", getResponse.Result.ToString()); } + + [SkipIfRedis(Is.OSSCluster)] + public async void Issue401_TestPipelineAsInitialCommand() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + + Auxiliary.ResetInfoDefaults(); // demonstrate first connection + var pipeline = new Pipeline(db); + + var setTask = pipeline.Json.SetAsync("json-key", "$", "{}"); + _ = pipeline.Db.KeyExpireAsync(key, TimeSpan.FromSeconds(10)); + + pipeline.Execute(); + + Assert.True(await setTask); + } } \ No newline at end of file diff --git a/tests/NRedisStack.Tests/RedisFixture.cs b/tests/NRedisStack.Tests/RedisFixture.cs index 0a145e85..9dfdf2eb 100644 --- a/tests/NRedisStack.Tests/RedisFixture.cs +++ b/tests/NRedisStack.Tests/RedisFixture.cs @@ -52,9 +52,12 @@ public class RedisFixture : IDisposable public bool isEnterprise = Environment.GetEnvironmentVariable("IS_ENTERPRISE") == "true"; public bool isOSSCluster; + private ConnectionMultiplexer redis; + private ConfigurationOptions defaultConfig; + public RedisFixture() { - ConfigurationOptions clusterConfig = new ConfigurationOptions + defaultConfig = new ConfigurationOptions { AsyncTimeout = 10000, SyncTimeout = 10000 @@ -93,8 +96,6 @@ public RedisFixture() isOSSCluster = true; } } - - Redis = GetConnectionById(clusterConfig, defaultEndpointId); } public void Dispose() @@ -102,7 +103,14 @@ public void Dispose() Redis.Close(); } - public ConnectionMultiplexer Redis { get; } + public ConnectionMultiplexer Redis + { + get + { + redis = redis ?? GetConnectionById(defaultConfig, defaultEndpointId); + return redis; + } + } public ConnectionMultiplexer GetConnectionById(ConfigurationOptions configurationOptions, string id) { diff --git a/tests/NRedisStack.Tests/Search/IndexCreationTests.cs b/tests/NRedisStack.Tests/Search/IndexCreationTests.cs index c3d73122..f0e72f68 100644 --- a/tests/NRedisStack.Tests/Search/IndexCreationTests.cs +++ b/tests/NRedisStack.Tests/Search/IndexCreationTests.cs @@ -11,6 +11,7 @@ public class IndexCreationTests : AbstractNRedisStackTest, IDisposable private readonly string index = "MISSING_EMPTY_INDEX"; private static readonly string INDEXMISSING = "INDEXMISSING"; private static readonly string INDEXEMPTY = "INDEXEMPTY"; + private static readonly string SORTABLE = "SORTABLE"; public IndexCreationTests(RedisFixture redisFixture) : base(redisFixture) { } @@ -163,4 +164,82 @@ public void TestCreateFloat16VectorField() res = ft.Search("idx", q.AddParam("vec", vec2ToBytes)); Assert.Equal(2, res.TotalResults); } + + [Fact] + public void TestMissingSortableFieldCommandArgs() + { + string idx = "MISSING_EMPTY_SORTABLE_INDEX"; + Schema sc = new Schema() + .AddTextField("text1", 1.0, missingIndex: true, emptyIndex: true, sortable: true) + .AddTagField("tag1", missingIndex: true, emptyIndex: true, sortable: true) + .AddNumericField("numeric1", missingIndex: true, sortable: true) + .AddGeoField("geo1", missingIndex: true, sortable: true); + + var ftCreateParams = FTCreateParams.CreateParams(); + + var cmd = SearchCommandBuilder.Create(idx, ftCreateParams, sc); + var expectedArgs = new object[] { idx, "SCHEMA", + "text1","TEXT",INDEXMISSING,INDEXEMPTY,SORTABLE, + "tag1","TAG", INDEXMISSING,INDEXEMPTY,SORTABLE, + "numeric1","NUMERIC", INDEXMISSING,SORTABLE, + "geo1","GEO", INDEXMISSING, SORTABLE}; + Assert.Equal(expectedArgs, cmd.Args); + } + + [SkipIfRedis(Is.OSSCluster, Comparison.LessThan, "7.3.240")] + public void TestCombiningMissingEmptySortableFields() + { + string idx = "MISSING_EMPTY_SORTABLE_INDEX"; + IDatabase db = redisFixture.Redis.GetDatabase(); + var ft = db.FT(2); + var vectorAttrs = new Dictionary() + { + ["TYPE"] = "FLOAT32", + ["DIM"] = "2", + ["DISTANCE_METRIC"] = "L2", + }; + Schema sc = new Schema() + .AddTextField("text1", 1.0, missingIndex: true, emptyIndex: true, sortable: true) + .AddTagField("tag1", missingIndex: true, emptyIndex: true, sortable: true) + .AddNumericField("numeric1", missingIndex: true, sortable: true) + .AddGeoField("geo1", missingIndex: true, sortable: true) + .AddGeoShapeField("geoshape1", Schema.GeoShapeField.CoordinateSystem.FLAT, missingIndex: true) + .AddVectorField("vector1", Schema.VectorField.VectorAlgo.FLAT, vectorAttrs, missingIndex: true); + + var ftCreateParams = FTCreateParams.CreateParams(); + Assert.True(ft.Create(idx, ftCreateParams, sc)); + + var sampleHash = new HashEntry[] { new("field1", "value1"), new("field2", "value2") }; + db.HashSet("hashWithMissingFields", sampleHash); + + Polygon polygon = new GeometryFactory().CreatePolygon(new Coordinate[] { new Coordinate(1, 1), new Coordinate(10, 10), new Coordinate(100, 100), new Coordinate(1, 1), }); + + var hashWithAllFields = new HashEntry[] { new("text1", "value1"), new("tag1", "value2"), new("numeric1", "3.141"), new("geo1", "-0.441,51.458"), new("geoshape1", polygon.ToString()), new("vector1", "aaaaaaaa") }; + db.HashSet("hashWithAllFields", hashWithAllFields); + + var result = ft.Search(idx, new Query("ismissing(@text1)")); + Assert.Equal(1, result.TotalResults); + Assert.Equal("hashWithMissingFields", result.Documents[0].Id); + + result = ft.Search(idx, new Query("ismissing(@tag1)")); + Assert.Equal(1, result.TotalResults); + Assert.Equal("hashWithMissingFields", result.Documents[0].Id); + + result = ft.Search(idx, new Query("ismissing(@numeric1)")); + Assert.Equal(1, result.TotalResults); + Assert.Equal("hashWithMissingFields", result.Documents[0].Id); + + result = ft.Search(idx, new Query("ismissing(@geo1)")); + Assert.Equal(1, result.TotalResults); + Assert.Equal("hashWithMissingFields", result.Documents[0].Id); + + result = ft.Search(idx, new Query("ismissing(@geoshape1)")); + Assert.Equal(1, result.TotalResults); + Assert.Equal("hashWithMissingFields", result.Documents[0].Id); + + result = ft.Search(idx, new Query("ismissing(@vector1)")); + Assert.Equal(1, result.TotalResults); + Assert.Equal("hashWithMissingFields", result.Documents[0].Id); + } + } \ No newline at end of file diff --git a/tests/NRedisStack.Tests/Search/SearchTests.cs b/tests/NRedisStack.Tests/Search/SearchTests.cs index 2ed10466..3f23675c 100644 --- a/tests/NRedisStack.Tests/Search/SearchTests.cs +++ b/tests/NRedisStack.Tests/Search/SearchTests.cs @@ -11,7 +11,6 @@ namespace NRedisStack.Tests.Search; - public class SearchTests : AbstractNRedisStackTest, IDisposable { // private readonly string key = "SEARCH_TESTS"; @@ -887,6 +886,40 @@ public void AlterAddSortable() Assert.Equal(4, info.CursorStats.Count); } + [SkipIfRedis(Comparison.LessThan, "7.3.0")] + public void InfoWithIndexEmptyAndIndexMissing() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(2); + var vectorAttrs = new Dictionary() + { + ["TYPE"] = "FLOAT32", + ["DIM"] = "2", + ["DISTANCE_METRIC"] = "L2", + }; + + Schema sc = new Schema() + .AddTextField("text1", 1.0, emptyIndex: true, missingIndex: true) + .AddTagField("tag1", emptyIndex: true, missingIndex: true) + .AddNumericField("numeric1", missingIndex: true) + .AddGeoField("geo1", missingIndex: true) + .AddGeoShapeField("geoshape1", Schema.GeoShapeField.CoordinateSystem.FLAT, missingIndex: true) + .AddVectorField("vector1", Schema.VectorField.VectorAlgo.FLAT, vectorAttrs, missingIndex: true); + Assert.True(ft.Create(index, FTCreateParams.CreateParams(), sc)); + + var info = ft.Info(index); + var attributes = info.Attributes; + foreach (var attribute in attributes) + { + Assert.True(attribute.ContainsKey("INDEXMISSING")); + if (attribute["attribute"].ToString() == "text1" || attribute["attribute"].ToString() == "tag1") + { + Assert.True(attribute.ContainsKey("INDEXEMPTY")); + } + } + } + [SkipIfRedis(Is.OSSCluster, Is.Enterprise)] public async Task AlterAddSortableAsync() { @@ -2096,8 +2129,8 @@ public void TestFieldsCommandBuilder() "PHONETIC", "dm:en", "WITHSUFFIXTRIE", - "SORTABLE", "UNF", + "SORTABLE", "num", "NUMERIC", "NOINDEX", @@ -2113,8 +2146,8 @@ public void TestFieldsCommandBuilder() "SEPARATOR", ";", "CASESENSITIVE", - "SORTABLE", "UNF", + "SORTABLE", "vec", "VECTOR", "FLAT", @@ -3279,4 +3312,53 @@ public void TestNumericLogicalOperatorsInDialect4() Assert.Equal(1, ft.Search(index, new Query("@version:[123 123] | @id:[456 7890]")).TotalResults); Assert.Equal(1, ft.Search(index, new Query("@version==123 @id==456").Dialect(4)).TotalResults); } + + [Fact] + public void TestDocumentLoad_Issue352() + { + Document d = Document.Load("1", 0.5, null, new RedisValue[] { RedisValue.Null }); + Assert.Empty(d.GetProperties().ToList()); + } + + [SkipIfRedis(Is.OSSCluster)] + public void TestDocumentLoadWithDB_Issue352() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + var ft = db.FT(); + + Schema sc = new Schema().AddTextField("first", 1.0).AddTextField("last", 1.0).AddNumericField("age"); + Assert.True(ft.Create(index, FTCreateParams.CreateParams(), sc)); + + Document droppedDocument = null; + int numberOfAttempts = 0; + do + { + db.HashSet("student:1111", new HashEntry[] { new("first", "Joe"), new("last", "Dod"), new("age", 18) }); + + Assert.True(db.KeyExpire("student:1111", TimeSpan.FromMilliseconds(500))); + + Boolean cancelled = false; + Task searchTask = Task.Run(() => + { + for (int i = 0; i < 100000; i++) + { + SearchResult result = ft.Search(index, new Query()); + List docs = result.Documents; + if (docs.Count == 0 || cancelled) + { + break; + } + else if (docs[0].GetProperties().ToList().Count == 0) + { + droppedDocument = docs[0]; + } + } + }); + Task.WhenAny(searchTask, Task.Delay(1000)).GetAwaiter().GetResult(); + Assert.True(searchTask.IsCompleted); + Assert.Null(searchTask.Exception); + cancelled = true; + } while (droppedDocument == null && numberOfAttempts++ < 3); + } } diff --git a/tests/NRedisStack.Tests/SkipIfRedisAttribute.cs b/tests/NRedisStack.Tests/SkipIfRedisAttribute.cs index b62dc17a..eae76c4d 100644 --- a/tests/NRedisStack.Tests/SkipIfRedisAttribute.cs +++ b/tests/NRedisStack.Tests/SkipIfRedisAttribute.cs @@ -21,6 +21,8 @@ public class SkipIfRedisAttribute : FactAttribute private readonly Comparison _comparison; private readonly List _environments = new List(); + private static Version serverVersion = null; + public SkipIfRedisAttribute( Is environment, Comparison comparison = Comparison.LessThan, @@ -95,7 +97,7 @@ public override string? Skip } // Version check (if Is.Standalone/Is.OSSCluster is set then ) - var serverVersion = redisFixture.Redis.GetServer(redisFixture.Redis.GetEndPoints()[0]).Version; + serverVersion = serverVersion ?? redisFixture.Redis.GetServer(redisFixture.Redis.GetEndPoints()[0]).Version; var targetVersion = new Version(_targetVersion); int comparisonResult = serverVersion.CompareTo(targetVersion); diff --git a/tests/NRedisStack.Tests/TargetEnvironmentAttribute.cs b/tests/NRedisStack.Tests/TargetEnvironmentAttribute.cs new file mode 100644 index 00000000..4497aef0 --- /dev/null +++ b/tests/NRedisStack.Tests/TargetEnvironmentAttribute.cs @@ -0,0 +1,36 @@ +using Xunit; + +namespace NRedisStack.Tests; +[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)] +public class TargetEnvironmentAttribute : SkipIfRedisAttribute +{ + private string targetEnv; + public TargetEnvironmentAttribute(string targetEnv) : base(Comparison.LessThan, "0.0.0") + { + this.targetEnv = targetEnv; + } + + public TargetEnvironmentAttribute(string targetEnv, Is environment, Comparison comparison = Comparison.LessThan, + string targetVersion = "0.0.0") : base(environment, comparison, targetVersion) + { + this.targetEnv = targetEnv; + } + + public TargetEnvironmentAttribute(string targetEnv, Is environment1, Is environment2, Comparison comparison = Comparison.LessThan, + string targetVersion = "0.0.0") : base(environment1, environment2, comparison, targetVersion) + { + this.targetEnv = targetEnv; + } + + public override string? Skip + { + get + { + if (!new RedisFixture().IsTargetConnectionExist(targetEnv)) + { + return "Test skipped, because: target environment not found."; + } + return base.Skip; + } + } +} \ No newline at end of file diff --git a/tests/NRedisStack.Tests/TokenBasedAuthentication/AuthenticationTests.cs b/tests/NRedisStack.Tests/TokenBasedAuthentication/AuthenticationTests.cs new file mode 100644 index 00000000..db8842c2 --- /dev/null +++ b/tests/NRedisStack.Tests/TokenBasedAuthentication/AuthenticationTests.cs @@ -0,0 +1,53 @@ +using Xunit; +using StackExchange.Redis; +using Azure.Identity; +using NRedisStack.RedisStackCommands; +using NRedisStack.Search; + +namespace NRedisStack.Tests.TokenBasedAuthentication +{ + public class AuthenticationTests : AbstractNRedisStackTest + { + static readonly string key = "myKey"; + static readonly string value = "myValue"; + static readonly string index = "myIndex"; + static readonly string field = "myField"; + static readonly string alias = "myAlias"; + public AuthenticationTests(RedisFixture redisFixture) : base(redisFixture) { } + + [TargetEnvironment("standalone-entraid-acl")] + public void TestTokenBasedAuthentication() + { + + var configurationOptions = new ConfigurationOptions().ConfigureForAzureWithTokenCredentialAsync(new DefaultAzureCredential()).Result!; + configurationOptions.Ssl = false; + configurationOptions.AbortOnConnectFail = true; // Fail fast for the purposes of this sample. In production code, this should remain false to retry connections on startup + + ConnectionMultiplexer? connectionMultiplexer = redisFixture.GetConnectionById(configurationOptions, "standalone-entraid-acl"); + + IDatabase db = connectionMultiplexer.GetDatabase(); + + db.KeyDelete(key); + try + { + db.FT().DropIndex(index); + } + catch { } + + db.StringSet(key, value); + string result = db.StringGet(key); + Assert.Equal(value, result); + + var ft = db.FT(); + Schema sc = new Schema().AddTextField(field); + Assert.True(ft.Create(index, FTCreateParams.CreateParams(), sc)); + + db.HashSet(index, new HashEntry[] { new HashEntry(field, value) }); + + Assert.True(ft.AliasAdd(alias, index)); + SearchResult res1 = ft.Search(alias, new Query("*").ReturnFields(field)); + Assert.Equal(1, res1.TotalResults); + Assert.Equal(value, res1.Documents[0][field]); + } + } +} \ No newline at end of file diff --git a/tests/NRedisStack.Tests/TokenBasedAuthentication/FaultInjectorClient.cs b/tests/NRedisStack.Tests/TokenBasedAuthentication/FaultInjectorClient.cs new file mode 100644 index 00000000..cf319cf1 --- /dev/null +++ b/tests/NRedisStack.Tests/TokenBasedAuthentication/FaultInjectorClient.cs @@ -0,0 +1,113 @@ +using System.Text; +using System.Text.Json; +using System.Net.Http; + +public class FaultInjectorClient +{ + private static readonly string BASE_URL; + + static FaultInjectorClient() + { + BASE_URL = Environment.GetEnvironmentVariable("FAULT_INJECTION_API_URL") ?? "http://127.0.0.1:20324"; + } + + public class TriggerActionResponse + { + public string ActionId { get; } + private DateTime? LastRequestTime { get; set; } + private DateTime? CompletedAt { get; set; } + private DateTime? FirstRequestAt { get; set; } + + public TriggerActionResponse(string actionId) + { + ActionId = actionId; + } + + public async Task IsCompletedAsync(TimeSpan checkInterval, TimeSpan delayAfter, TimeSpan timeout) + { + if (CompletedAt.HasValue) + { + return DateTime.UtcNow - CompletedAt.Value >= delayAfter; + } + + if (FirstRequestAt.HasValue && DateTime.UtcNow - FirstRequestAt.Value >= timeout) + { + throw new TimeoutException("Timeout"); + } + + if (!LastRequestTime.HasValue || DateTime.UtcNow - LastRequestTime.Value >= checkInterval) + { + LastRequestTime = DateTime.UtcNow; + + if (!FirstRequestAt.HasValue) + { + FirstRequestAt = LastRequestTime; + } + + using var httpClient = GetHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Get, $"{BASE_URL}/action/{ActionId}"); + + try + { + var response = await httpClient.SendAsync(request); + var result = await response.Content.ReadAsStringAsync(); + + + if (result.Contains("success")) + { + CompletedAt = DateTime.UtcNow; + return DateTime.UtcNow - CompletedAt.Value >= delayAfter; + } + } + catch (HttpRequestException e) + { + throw new Exception("Fault injection proxy error", e); + } + } + return false; + } + } + + private static HttpClient GetHttpClient() + { + var httpClient = new HttpClient + { + Timeout = TimeSpan.FromMilliseconds(5000) + }; + return httpClient; + } + + public async Task TriggerActionAsync(string actionType, Dictionary parameters) + { + var payload = new Dictionary + { + { "type", actionType }, + { "parameters", parameters } + }; + + var jsonString = JsonSerializer.Serialize(payload, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + + using var httpClient = GetHttpClient(); + var request = new HttpRequestMessage(HttpMethod.Post, $"{BASE_URL}/action") + { + Content = new StringContent(jsonString, Encoding.UTF8, "application/json") + }; + + try + { + var response = await httpClient.SendAsync(request); + var result = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(result, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }); + } + catch (HttpRequestException e) + { + throw; + } + } +}