diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 465a8ecf..dda76d2d 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -47,3 +47,8 @@ NRedisStack.Search.SearchResult.Warnings.get -> string![]! NRedisStack.Search.AggregationResult.Warnings.get -> string![]! NRedisStack.Search.HybridSearchResult.Warnings.get -> string![]! NRedisStack.Search.AggregationRequest.AddScores() -> NRedisStack.Search.AggregationRequest! +NRedisStack.ISearchCommands.AliasList(string! index) -> StackExchange.Redis.RedisResult![]! +NRedisStack.ISearchCommandsAsync.AliasListAsync(string! index) -> System.Threading.Tasks.Task! +NRedisStack.SearchCommands.AliasList(string! index) -> StackExchange.Redis.RedisResult![]! +NRedisStack.SearchCommandsAsync.AliasListAsync(string! index) -> System.Threading.Tasks.Task! +static NRedisStack.SearchCommandBuilder.AliasList(string! index) -> NRedisStack.RedisStackCommands.SerializedCommand! diff --git a/src/NRedisStack/Search/ISearchCommands.cs b/src/NRedisStack/Search/ISearchCommands.cs index bc90e6a4..36c6627a 100644 --- a/src/NRedisStack/Search/ISearchCommands.cs +++ b/src/NRedisStack/Search/ISearchCommands.cs @@ -62,6 +62,14 @@ public interface ISearchCommands /// bool AliasUpdate(string alias, string index); + /// + /// List all aliases associated with the given index. + /// + /// The index name. The index must be created using FT.CREATE; an alias name is not accepted. + /// Array with the alias names associated with the index (unordered; empty when the index has no aliases). + /// + RedisResult[] AliasList(string index); + /// /// Add a new attribute to the index /// diff --git a/src/NRedisStack/Search/ISearchCommandsAsync.cs b/src/NRedisStack/Search/ISearchCommandsAsync.cs index 74b0aa19..55aa2c69 100644 --- a/src/NRedisStack/Search/ISearchCommandsAsync.cs +++ b/src/NRedisStack/Search/ISearchCommandsAsync.cs @@ -63,6 +63,14 @@ public interface ISearchCommandsAsync /// Task AliasUpdateAsync(string alias, string index); + /// + /// List all aliases associated with the given index. + /// + /// The index name. The index must be created using FT.CREATE; an alias name is not accepted. + /// Array with the alias names associated with the index (unordered; empty when the index has no aliases). + /// + Task AliasListAsync(string index); + /// /// Add a new attribute to the index /// diff --git a/src/NRedisStack/Search/Literals/Commands.cs b/src/NRedisStack/Search/Literals/Commands.cs index ab024cf2..8ead547d 100644 --- a/src/NRedisStack/Search/Literals/Commands.cs +++ b/src/NRedisStack/Search/Literals/Commands.cs @@ -7,6 +7,7 @@ internal class FT public const string ALIASADD = "FT.ALIASADD"; public const string ALIASDEL = "FT.ALIASDEL"; public const string ALIASUPDATE = "FT.ALIASUPDATE"; + public const string ALIASLIST = "FT.ALIASLIST"; public const string ALTER = "FT.ALTER"; public const string CONFIG = "FT.CONFIG"; public const string CREATE = "FT.CREATE"; diff --git a/src/NRedisStack/Search/SearchCommandBuilder.cs b/src/NRedisStack/Search/SearchCommandBuilder.cs index 78014096..a1fa5242 100644 --- a/src/NRedisStack/Search/SearchCommandBuilder.cs +++ b/src/NRedisStack/Search/SearchCommandBuilder.cs @@ -33,6 +33,11 @@ public static SerializedCommand AliasUpdate(string alias, string index) { return new(FT.ALIASUPDATE, alias, index); } + + public static SerializedCommand AliasList(string index) + { + return new(FT.ALIASLIST, index); + } public static SerializedCommand Alter(string index, Schema schema, bool skipInitialScan = false) { List args = [index]; diff --git a/src/NRedisStack/Search/SearchCommands.cs b/src/NRedisStack/Search/SearchCommands.cs index ae72de9a..1d7f80a7 100644 --- a/src/NRedisStack/Search/SearchCommands.cs +++ b/src/NRedisStack/Search/SearchCommands.cs @@ -87,6 +87,12 @@ public bool AliasUpdate(string alias, string index) return db.Execute(SearchCommandBuilder.AliasUpdate(alias, index)).OKtoBoolean(); } + /// + public RedisResult[] AliasList(string index) + { + return db.Execute(SearchCommandBuilder.AliasList(index)).ToArray(); + } + /// public bool Alter(string index, Schema schema, bool skipInitialScan = false) { diff --git a/src/NRedisStack/Search/SearchCommandsAsync.cs b/src/NRedisStack/Search/SearchCommandsAsync.cs index 32574d04..11c29c30 100644 --- a/src/NRedisStack/Search/SearchCommandsAsync.cs +++ b/src/NRedisStack/Search/SearchCommandsAsync.cs @@ -129,6 +129,12 @@ public async Task AliasUpdateAsync(string alias, string index) return (await _db.ExecuteAsync(SearchCommandBuilder.AliasUpdate(alias, index))).OKtoBoolean(); } + /// + public async Task AliasListAsync(string index) + { + return (await _db.ExecuteAsync(SearchCommandBuilder.AliasList(index))).ToArray(); + } + /// public async Task AlterAsync(string index, Schema schema, bool skipInitialScan = false) { diff --git a/tests/NRedisStack.Tests/Search/SearchTests.cs b/tests/NRedisStack.Tests/Search/SearchTests.cs index 855fa3f8..e0a2ab3d 100644 --- a/tests/NRedisStack.Tests/Search/SearchTests.cs +++ b/tests/NRedisStack.Tests/Search/SearchTests.cs @@ -37,6 +37,34 @@ private static void SkipClusterPre8(string endpointId) && EndpointsFixture.RedisVersion.Minor < 4, "Ignoring cluster tests for FT.SEARCH pre Redis 8.4"); } + // Legacy cluster servers reject alias commands wholesale with CROSSSLOT; the first + // FT.ALIASADD acts as an environment probe, so skip the test when it fails that way. + private static void AliasAddOrSkipOnCrossSlot(SearchCommands ft, string alias, string index) + { + try + { + Assert.True(ft.AliasAdd(alias, index)); + } + catch (RedisServerException rse) + { + Assert.SkipWhen(rse.Message.StartsWith("CROSSSLOT"), "legacy failure"); + throw; + } + } + + private static async Task AliasAddOrSkipOnCrossSlotAsync(SearchCommands ft, string alias, string index) + { + try + { + Assert.True(await ft.AliasAddAsync(alias, index)); + } + catch (RedisServerException rse) + { + Assert.SkipWhen(rse.Message.StartsWith("CROSSSLOT"), "legacy failure"); + throw; + } + } + private void AddDocument(IDatabase db, Document doc) { var hash = doc.GetProperties() @@ -501,15 +529,7 @@ public void TestAlias(string endpointId) doc.Add("field1", "value"); AddDocument(db, "doc1", doc); - try - { - Assert.True(ft.AliasAdd("ALIAS1", index)); - } - catch (RedisServerException rse) - { - Assert.SkipWhen(rse.Message.StartsWith("CROSSSLOT"), "legacy failure"); - throw; - } + AliasAddOrSkipOnCrossSlot(ft, "ALIAS1", index); SearchResult res1 = ft.Search("ALIAS1", new Query("*").ReturnFields("field1")); Assert.Equal(1, res1.TotalResults); @@ -540,15 +560,7 @@ public async Task TestAliasAsync(string endpointId) doc.Add("field1", "value"); AddDocument(db, "doc1", doc); - try - { - Assert.True(await ft.AliasAddAsync("ALIAS1", index)); - } - catch (RedisServerException rse) - { - Assert.SkipWhen(rse.Message.StartsWith("CROSSSLOT"), "legacy failure"); - throw; - } + await AliasAddOrSkipOnCrossSlotAsync(ft, "ALIAS1", index); SearchResult res1 = ft.Search("ALIAS1", new Query("*").ReturnFields("field1")); Assert.Equal(1, res1.TotalResults); @@ -564,6 +576,58 @@ public async Task TestAliasAsync(string endpointId) await Assert.ThrowsAsync(async () => await ft.AliasDelAsync("ALIAS2")); } + [SkipIfRedisTheory(Comparison.LessThan, "8.10.0")] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] + public void TestAliasList(string endpointId) + { + IDatabase db = GetCleanDatabase(endpointId); + var ft = db.FT(); + Assert.True(ft.Create(index, FTCreateParams.CreateParams(), new Schema().AddTextField("field1"))); + + // Ordering is not part of the contract, so compare sorted. + string[] Aliases() => ft.AliasList(index).Select(a => a.ToString()).OrderBy(a => a).ToArray(); + + // An existing index with no aliases returns an empty collection, not an error. + Assert.Empty(ft.AliasList(index)); + + AliasAddOrSkipOnCrossSlot(ft, "ALIAS1", index); + Assert.Equal(new[] { "ALIAS1" }, Aliases()); + + Assert.True(ft.AliasAdd("ALIAS2", index)); + Assert.Equal(new[] { "ALIAS1", "ALIAS2" }, Aliases()); + + Assert.True(ft.AliasDel("ALIAS1")); + Assert.Equal(new[] { "ALIAS2" }, Aliases()); + + // A non-existent index propagates the server error unchanged. + Assert.Throws(() => ft.AliasList("nonexisting-index")); + } + + [SkipIfRedisTheory(Comparison.LessThan, "8.10.0")] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] + public async Task TestAliasListAsync(string endpointId) + { + IDatabase db = GetCleanDatabase(endpointId); + var ft = db.FT(); + Assert.True(ft.Create(index, FTCreateParams.CreateParams(), new Schema().AddTextField("field1"))); + + // Ordering is not part of the contract, so compare sorted. + async Task Aliases() => (await ft.AliasListAsync(index)).Select(a => a.ToString()).OrderBy(a => a).ToArray(); + + Assert.Empty(await ft.AliasListAsync(index)); + + await AliasAddOrSkipOnCrossSlotAsync(ft, "ALIAS1", index); + Assert.Equal(new[] { "ALIAS1" }, await Aliases()); + + Assert.True(await ft.AliasAddAsync("ALIAS2", index)); + Assert.Equal(new[] { "ALIAS1", "ALIAS2" }, await Aliases()); + + Assert.True(await ft.AliasDelAsync("ALIAS1")); + Assert.Equal(new[] { "ALIAS2" }, await Aliases()); + + await Assert.ThrowsAsync(async () => await ft.AliasListAsync("nonexisting-index")); + } + [Theory] [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] public void TestApplyAndFilterAggregations(string endpointId)