From 4e87786b34b56ad8c2141ced7acd5c3367ef3558 Mon Sep 17 00:00:00 2001 From: Arad Alvand Date: Mon, 9 Nov 2026 23:12:40 +0330 Subject: [PATCH 1/3] Support WITHCOUNT / WITHOUTCOUNT on FT.SEARCH and FT.AGGREGATE `Query` gains a `bool? WithCount` property, set with `SetWithCount()` / `SetWithoutCount()`; `AggregationRequest` gains `WithCount()` / `WithoutCount()`. `WITHOUTCOUNT` turns on the query optimizer, which lets a query sorted by a numeric field stop once it has enough results; `WITHCOUNT` keeps the total exact. The server only accepts the option before the first pipeline step of FT.AGGREGATE, so `AggregationRequest` places it right after the query whatever the call order, and a later call replaces an earlier one. Closes #591. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XUNs4unCWEtK5q25xYbvgz --- .../PublicAPI/PublicAPI.Unshipped.txt | 6 ++ src/NRedisStack/Search/AggregationRequest.cs | 36 +++++++++ .../Search/Literals/CommandArgs.cs | 2 + src/NRedisStack/Search/Query.cs | 33 ++++++++ .../Search/CountOptionTests.cs | 76 +++++++++++++++++++ tests/NRedisStack.Tests/Search/SearchTests.cs | 66 ++++++++++++++++ 6 files changed, 219 insertions(+) create mode 100644 tests/NRedisStack.Tests/Search/CountOptionTests.cs diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index 7dc5c581..e625dff3 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1 +1,7 @@ #nullable enable +NRedisStack.Search.AggregationRequest.WithCount() -> NRedisStack.Search.AggregationRequest! +NRedisStack.Search.AggregationRequest.WithoutCount() -> NRedisStack.Search.AggregationRequest! +NRedisStack.Search.Query.SetWithCount() -> NRedisStack.Search.Query! +NRedisStack.Search.Query.SetWithoutCount() -> NRedisStack.Search.Query! +NRedisStack.Search.Query.WithCount.get -> bool? +NRedisStack.Search.Query.WithCount.set -> void diff --git a/src/NRedisStack/Search/AggregationRequest.cs b/src/NRedisStack/Search/AggregationRequest.cs index bf356761..f40c88c4 100644 --- a/src/NRedisStack/Search/AggregationRequest.cs +++ b/src/NRedisStack/Search/AggregationRequest.cs @@ -7,6 +7,7 @@ public class AggregationRequest : IDialectAwareParam { private readonly List args = []; // Check if Readonly private bool isWithCursor = false; + private bool hasCountOption = false; public int? dialect { get; private set; } = null; @@ -24,6 +25,41 @@ public AggregationRequest Verbatim() return this; } + /// + /// Counts every result (WITHCOUNT), so that the total is exact. + /// + /// + /// The server only accepts the option before the first pipeline step, so it is sent right after the + /// query wherever in the chain this is called; a later replaces it. + /// + public AggregationRequest WithCount() => SetCountOption(SearchArgs.WITHCOUNT); + + /// + /// Does not count every result (WITHOUTCOUNT), which lets an aggregation sorted by a numeric + /// field stop once it has enough results. The total is then not exact. + /// + /// + /// + /// The server only accepts the option before the first pipeline step, so it is sent right after the + /// query wherever in the chain this is called; a later replaces it. + /// + public AggregationRequest WithoutCount() => SetCountOption(SearchArgs.WITHOUTCOUNT); + + private AggregationRequest SetCountOption(string option) + { + // The query is always the first argument, and every other option or step is appended after it. + if (hasCountOption) + { + args[1] = option; + } + else + { + args.Insert(1, option); + hasCountOption = true; + } + return this; + } + /// /// Exposes the full-text score values to the aggregation pipeline via the ADDSCORES option. /// The score of each result is then available as the @__score field, e.g. for use in diff --git a/src/NRedisStack/Search/Literals/CommandArgs.cs b/src/NRedisStack/Search/Literals/CommandArgs.cs index 297d08d5..edc53b30 100644 --- a/src/NRedisStack/Search/Literals/CommandArgs.cs +++ b/src/NRedisStack/Search/Literals/CommandArgs.cs @@ -68,7 +68,9 @@ internal class SearchArgs public const string UNF = "UNF"; public const string VERBATIM = "VERBATIM"; public const string WEIGHT = "WEIGHT"; + public const string WITHCOUNT = "WITHCOUNT"; public const string WITHCURSOR = "WITHCURSOR"; + public const string WITHOUTCOUNT = "WITHOUTCOUNT"; public const string WITHPAYLOADS = "WITHPAYLOADS"; public const string WITHSCORES = "WITHSCORES"; public const string WITHSUFFIXTRIE = "WITHSUFFIXTRIE"; diff --git a/src/NRedisStack/Search/Query.cs b/src/NRedisStack/Search/Query.cs index 73480d77..35cda18f 100644 --- a/src/NRedisStack/Search/Query.cs +++ b/src/NRedisStack/Search/Query.cs @@ -149,6 +149,14 @@ public HighlightTags(string open, string close) /// Set the query to return object payloads, if any were given /// public bool WithPayloads { get; set; } + /// + /// Whether the query counts every result: true sends WITHCOUNT, which returns an exact + /// total; false sends WITHOUTCOUNT, which lets a query sorted by a numeric field stop + /// once it has enough results, at the cost of an exact total; null (the default) sends neither + /// and leaves it to the server, which counts unless the query uses DIALECT 4. + /// + /// + public bool? WithCount { get; set; } /// /// Set the query language, for stemming purposes; see http://redisearch.io for documentation on languages and stemming @@ -237,6 +245,10 @@ internal void SerializeRedisArgs(List args) { args.Add(SearchArgs.WITHPAYLOADS); } + if (WithCount.HasValue) + { + args.Add(WithCount.Value ? SearchArgs.WITHCOUNT : SearchArgs.WITHOUTCOUNT); + } if (Language != null) { args.Add(SearchArgs.LANGUAGE); @@ -491,6 +503,27 @@ public Query SetWithPayloads() return this; } + /// + /// Set the query to count every result (WITHCOUNT), so that the total is exact. + /// + /// the query itself + public Query SetWithCount() + { + WithCount = true; + return this; + } + + /// + /// Set the query not to count every result (WITHOUTCOUNT), which lets a query sorted by a + /// numeric field stop once it has enough results. The total is then not exact. + /// + /// the query itself + public Query SetWithoutCount() + { + WithCount = false; + return this; + } + /// /// Set the query language, for stemming purposes /// diff --git a/tests/NRedisStack.Tests/Search/CountOptionTests.cs b/tests/NRedisStack.Tests/Search/CountOptionTests.cs new file mode 100644 index 00000000..a81a2db5 --- /dev/null +++ b/tests/NRedisStack.Tests/Search/CountOptionTests.cs @@ -0,0 +1,76 @@ +using NRedisStack.Search; +using NRedisStack.Search.Aggregation; +using Xunit; + +namespace NRedisStack.Tests.Search; + +/// +/// Unit tests for the WITHCOUNT / WITHOUTCOUNT options of and +/// — verify where the builders put them. These tests do not require a +/// running server. +/// +public class CountOptionTests +{ + private static string[] SearchArgs(Query query) => + SearchCommandBuilder.Search("idx", query).Args.Select(arg => arg.ToString()!).ToArray(); + + [Fact] + public void QuerySendsNoCountOptionByDefault() + { + var args = SearchArgs(new Query("foo")); + + Assert.DoesNotContain("WITHCOUNT", args); + Assert.DoesNotContain("WITHOUTCOUNT", args); + } + + [Fact] + public void QueryWithCount() + { + var args = SearchArgs(new Query("foo").SetWithCount()); + + Assert.Contains("WITHCOUNT", args); + Assert.DoesNotContain("WITHOUTCOUNT", args); + } + + [Fact] + public void QueryWithoutCount() + { + var args = SearchArgs(new Query("foo").SetWithoutCount()); + + Assert.Contains("WITHOUTCOUNT", args); + Assert.DoesNotContain("WITHCOUNT", args); + } + + [Fact] + public void AggregationCountOptionFollowsQueryWhenSetLast() + { + // The server rejects the option after a pipeline step such as LOAD, so it has to follow the + // query even when it is set last. + var request = new AggregationRequest("@n:[0 10]") + .Load(FieldName.Of("@n")) + .SortBy(10, SortedField.Asc("@n")) + .WithoutCount(); + + object[] expected = + [ + "@n:[0 10]", "WITHOUTCOUNT", + "LOAD", 1, "@n", + "SORTBY", 2, "@n", "ASC", "MAX", 10 + ]; + + Assert.Equal(expected, request.GetArgs()); + } + + [Fact] + public void AggregationLaterCountOptionReplacesEarlierOne() + { + var request = new AggregationRequest("*") + .WithoutCount() + .SortBy(SortedField.Asc("@n")) + .WithCount(); + + object[] expected = ["*", "WITHCOUNT", "SORTBY", 2, "@n", "ASC"]; + + Assert.Equal(expected, request.GetArgs()); + } +} diff --git a/tests/NRedisStack.Tests/Search/SearchTests.cs b/tests/NRedisStack.Tests/Search/SearchTests.cs index ffa37aa9..e16b089e 100644 --- a/tests/NRedisStack.Tests/Search/SearchTests.cs +++ b/tests/NRedisStack.Tests/Search/SearchTests.cs @@ -160,6 +160,72 @@ public void TestAggregationRequestVerbatim(string endpointId) Assert.Equal(0, res.TotalResults); } + [SkipIfRedisTheory(Comparison.LessThan, "7.2.0")] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] + public void TestCountOptionsSortedByNumericField(string endpointId) + { + SkipClusterPre8(endpointId); + IDatabase db = GetCleanDatabase(endpointId); + var ft = db.FT(); + Schema sc = new Schema().AddNumericField("n", sortable: true); + ft.Create(index, FTCreateParams.CreateParams(), sc); + for (int i = 0; i < 100; i++) + { + AddDocument(db, new Document($"doc{i}").Set("n", i)); + } + AssertIndexSize(ft, index, 100); + + Query Page() => new Query("@n:[(50 +inf]").SetSortBy("n", ascending: true).Limit(0, 3).SetNoContent() + .Dialect(2); + var counted = ft.Search(index, Page().SetWithCount()); + Assert.Equal(49, counted.TotalResults); + Assert.Equal(["doc51", "doc52", "doc53"], counted.Documents.Select(doc => doc.Id)); + var uncounted = ft.Search(index, Page().SetWithoutCount()); + Assert.Equal(["doc51", "doc52", "doc53"], uncounted.Documents.Select(doc => doc.Id)); + + // Set after SORTBY and LOAD, where the server would reject it, to show that it is sent right + // after the query regardless. + var aggregation = ft.Aggregate(index, new AggregationRequest("@n:[(50 +inf]") + .SortBy(3, SortedField.Asc("@n")) + .Load(FieldName.Of("@n")) + .WithoutCount() + .Dialect(2)); + Assert.Equal([51L, 52L, 53L], Enumerable.Range(0, 3).Select(i => aggregation.GetRow(i).GetLong("n"))); + } + + [SkipIfRedisTheory(Comparison.LessThan, "7.2.0")] + [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] + public async Task TestCountOptionsSortedByNumericFieldAsync(string endpointId) + { + SkipClusterPre8(endpointId); + IDatabase db = GetCleanDatabase(endpointId); + var ft = db.FT(); + Schema sc = new Schema().AddNumericField("n", sortable: true); + await ft.CreateAsync(index, FTCreateParams.CreateParams(), sc); + for (int i = 0; i < 100; i++) + { + AddDocument(db, new Document($"doc{i}").Set("n", i)); + } + AssertIndexSize(ft, index, 100); + + Query Page() => new Query("@n:[(50 +inf]").SetSortBy("n", ascending: true).Limit(0, 3).SetNoContent() + .Dialect(2); + var counted = await ft.SearchAsync(index, Page().SetWithCount()); + Assert.Equal(49, counted.TotalResults); + Assert.Equal(["doc51", "doc52", "doc53"], counted.Documents.Select(doc => doc.Id)); + var uncounted = await ft.SearchAsync(index, Page().SetWithoutCount()); + Assert.Equal(["doc51", "doc52", "doc53"], uncounted.Documents.Select(doc => doc.Id)); + + // Set after SORTBY and LOAD, where the server would reject it, to show that it is sent right + // after the query regardless. + var aggregation = await ft.AggregateAsync(index, new AggregationRequest("@n:[(50 +inf]") + .SortBy(3, SortedField.Asc("@n")) + .Load(FieldName.Of("@n")) + .WithoutCount() + .Dialect(2)); + Assert.Equal([51L, 52L, 53L], Enumerable.Range(0, 3).Select(i => aggregation.GetRow(i).GetLong("n"))); + } + [SkipIfRedisTheory(Is.Enterprise)] [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] public async Task TestAggregationRequestVerbatimAsync(string endpointId) From 716dfe953b562ec9d35c206265eecf8f649ee6e5 Mon Sep 17 00:00:00 2001 From: Arad Alvand Date: Tue, 10 Nov 2026 17:32:59 +0330 Subject: [PATCH 2/3] Make the count option a tri-state on both request types Address review feedback on #592: - `AggregationRequest` keeps the option in a field and inserts it after the query when serializing, like `dialect`, instead of overwriting `args[1]`. A token a caller inserted through `GetArgs()` is no longer clobbered, and the option can be cleared again. - Both surfaces take one `bool?` setter: `Query.SetWithCount(bool? value = true)`, following the file's `Set(bool value = true)` convention, and `AggregationRequest.WithCount(bool? value = true)`. `false` sends `WITHOUTCOUNT` and `null` sends neither. - The integration tests skip Enterprise like their neighbours. - Every entry point links the command's docs. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XUNs4unCWEtK5q25xYbvgz --- .../PublicAPI/PublicAPI.Unshipped.txt | 6 +-- src/NRedisStack/Search/AggregationRequest.cs | 44 +++++++---------- src/NRedisStack/Search/Query.cs | 19 ++------ .../Search/CountOptionTests.cs | 47 +++++++++++++++++-- tests/NRedisStack.Tests/Search/SearchTests.cs | 12 ++--- 5 files changed, 73 insertions(+), 55 deletions(-) diff --git a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt index e625dff3..abf5f2bd 100644 --- a/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/NRedisStack/PublicAPI/PublicAPI.Unshipped.txt @@ -1,7 +1,5 @@ #nullable enable -NRedisStack.Search.AggregationRequest.WithCount() -> NRedisStack.Search.AggregationRequest! -NRedisStack.Search.AggregationRequest.WithoutCount() -> NRedisStack.Search.AggregationRequest! -NRedisStack.Search.Query.SetWithCount() -> NRedisStack.Search.Query! -NRedisStack.Search.Query.SetWithoutCount() -> NRedisStack.Search.Query! +NRedisStack.Search.AggregationRequest.WithCount(bool? value = true) -> NRedisStack.Search.AggregationRequest! +NRedisStack.Search.Query.SetWithCount(bool? value = true) -> NRedisStack.Search.Query! NRedisStack.Search.Query.WithCount.get -> bool? NRedisStack.Search.Query.WithCount.set -> void diff --git a/src/NRedisStack/Search/AggregationRequest.cs b/src/NRedisStack/Search/AggregationRequest.cs index f40c88c4..7a36dc05 100644 --- a/src/NRedisStack/Search/AggregationRequest.cs +++ b/src/NRedisStack/Search/AggregationRequest.cs @@ -7,7 +7,7 @@ public class AggregationRequest : IDialectAwareParam { private readonly List args = []; // Check if Readonly private bool isWithCursor = false; - private bool hasCountOption = false; + private bool? withCount = null; public int? dialect { get; private set; } = null; @@ -26,37 +26,19 @@ public AggregationRequest Verbatim() } /// - /// Counts every result (WITHCOUNT), so that the total is exact. - /// - /// - /// The server only accepts the option before the first pipeline step, so it is sent right after the - /// query wherever in the chain this is called; a later replaces it. - /// - public AggregationRequest WithCount() => SetCountOption(SearchArgs.WITHCOUNT); - - /// - /// Does not count every result (WITHOUTCOUNT), which lets an aggregation sorted by a numeric - /// field stop once it has enough results. The total is then not exact. + /// Whether the aggregation counts every result: true sends WITHCOUNT, which keeps the total + /// exact; false sends WITHOUTCOUNT, which lets an aggregation sorted by a numeric field stop + /// once it has enough results, at the cost of an exact total; null sends neither and leaves it to + /// the server. /// /// /// /// The server only accepts the option before the first pipeline step, so it is sent right after the - /// query wherever in the chain this is called; a later replaces it. + /// query wherever in the chain this is called; a later call replaces an earlier one. /// - public AggregationRequest WithoutCount() => SetCountOption(SearchArgs.WITHOUTCOUNT); - - private AggregationRequest SetCountOption(string option) + public AggregationRequest WithCount(bool? value = true) { - // The query is always the first argument, and every other option or step is appended after it. - if (hasCountOption) - { - args[1] = option; - } - else - { - args.Insert(1, option); - hasCountOption = true; - } + withCount = value; return this; } @@ -220,6 +202,15 @@ private void Dialect() } } + private void CountOption() + { + if (withCount.HasValue) + { + // The query is always the first argument, and the server rejects the option after a pipeline step. + args.Insert(1, withCount.Value ? SearchArgs.WITHCOUNT : SearchArgs.WITHOUTCOUNT); + } + } + public List GetArgs() { return args; @@ -227,6 +218,7 @@ public List GetArgs() public void SerializeRedisArgs() { + CountOption(); Dialect(); } diff --git a/src/NRedisStack/Search/Query.cs b/src/NRedisStack/Search/Query.cs index 35cda18f..89c3fe7f 100644 --- a/src/NRedisStack/Search/Query.cs +++ b/src/NRedisStack/Search/Query.cs @@ -504,23 +504,14 @@ public Query SetWithPayloads() } /// - /// Set the query to count every result (WITHCOUNT), so that the total is exact. - /// - /// the query itself - public Query SetWithCount() - { - WithCount = true; - return this; - } - - /// - /// Set the query not to count every result (WITHOUTCOUNT), which lets a query sorted by a - /// numeric field stop once it has enough results. The total is then not exact. + /// Set whether the query counts every result; see . /// + /// + /// true for WITHCOUNT, false for WITHOUTCOUNT, null for neither /// the query itself - public Query SetWithoutCount() + public Query SetWithCount(bool? value = true) { - WithCount = false; + WithCount = value; return this; } diff --git a/tests/NRedisStack.Tests/Search/CountOptionTests.cs b/tests/NRedisStack.Tests/Search/CountOptionTests.cs index a81a2db5..09dd4a73 100644 --- a/tests/NRedisStack.Tests/Search/CountOptionTests.cs +++ b/tests/NRedisStack.Tests/Search/CountOptionTests.cs @@ -14,6 +14,9 @@ public class CountOptionTests private static string[] SearchArgs(Query query) => SearchCommandBuilder.Search("idx", query).Args.Select(arg => arg.ToString()!).ToArray(); + private static object[] AggregateArgs(AggregationRequest request) => + SearchCommandBuilder.Aggregate("idx", request).Args.Skip(1).ToArray(); + [Fact] public void QuerySendsNoCountOptionByDefault() { @@ -35,12 +38,21 @@ public void QueryWithCount() [Fact] public void QueryWithoutCount() { - var args = SearchArgs(new Query("foo").SetWithoutCount()); + var args = SearchArgs(new Query("foo").SetWithCount(false)); Assert.Contains("WITHOUTCOUNT", args); Assert.DoesNotContain("WITHCOUNT", args); } + [Fact] + public void QueryCountOptionCanBeCleared() + { + var args = SearchArgs(new Query("foo").SetWithCount(false).SetWithCount(null)); + + Assert.DoesNotContain("WITHCOUNT", args); + Assert.DoesNotContain("WITHOUTCOUNT", args); + } + [Fact] public void AggregationCountOptionFollowsQueryWhenSetLast() { @@ -49,7 +61,7 @@ public void AggregationCountOptionFollowsQueryWhenSetLast() var request = new AggregationRequest("@n:[0 10]") .Load(FieldName.Of("@n")) .SortBy(10, SortedField.Asc("@n")) - .WithoutCount(); + .WithCount(false); object[] expected = [ @@ -58,19 +70,44 @@ public void AggregationCountOptionFollowsQueryWhenSetLast() "SORTBY", 2, "@n", "ASC", "MAX", 10 ]; - Assert.Equal(expected, request.GetArgs()); + Assert.Equal(expected, AggregateArgs(request)); } [Fact] public void AggregationLaterCountOptionReplacesEarlierOne() { var request = new AggregationRequest("*") - .WithoutCount() + .WithCount(false) .SortBy(SortedField.Asc("@n")) .WithCount(); object[] expected = ["*", "WITHCOUNT", "SORTBY", 2, "@n", "ASC"]; - Assert.Equal(expected, request.GetArgs()); + Assert.Equal(expected, AggregateArgs(request)); + } + + [Fact] + public void AggregationCountOptionCanBeCleared() + { + var request = new AggregationRequest("*") + .WithCount(false) + .SortBy(SortedField.Asc("@n")) + .WithCount(null); + + object[] expected = ["*", "SORTBY", 2, "@n", "ASC"]; + + Assert.Equal(expected, AggregateArgs(request)); + } + + [Fact] + public void AggregationCountOptionLeavesCallerInsertedArgsIntact() + { + var request = new AggregationRequest("*").WithCount(); + request.GetArgs().Insert(1, "VERBATIM"); + request.WithCount(false); + + object[] expected = ["*", "WITHOUTCOUNT", "VERBATIM"]; + + Assert.Equal(expected, AggregateArgs(request)); } } diff --git a/tests/NRedisStack.Tests/Search/SearchTests.cs b/tests/NRedisStack.Tests/Search/SearchTests.cs index e16b089e..a0dd385a 100644 --- a/tests/NRedisStack.Tests/Search/SearchTests.cs +++ b/tests/NRedisStack.Tests/Search/SearchTests.cs @@ -160,7 +160,7 @@ public void TestAggregationRequestVerbatim(string endpointId) Assert.Equal(0, res.TotalResults); } - [SkipIfRedisTheory(Comparison.LessThan, "7.2.0")] + [SkipIfRedisTheory(Is.Enterprise, Comparison.LessThan, "7.2.0")] [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] public void TestCountOptionsSortedByNumericField(string endpointId) { @@ -180,7 +180,7 @@ public void TestCountOptionsSortedByNumericField(string endpointId) var counted = ft.Search(index, Page().SetWithCount()); Assert.Equal(49, counted.TotalResults); Assert.Equal(["doc51", "doc52", "doc53"], counted.Documents.Select(doc => doc.Id)); - var uncounted = ft.Search(index, Page().SetWithoutCount()); + var uncounted = ft.Search(index, Page().SetWithCount(false)); Assert.Equal(["doc51", "doc52", "doc53"], uncounted.Documents.Select(doc => doc.Id)); // Set after SORTBY and LOAD, where the server would reject it, to show that it is sent right @@ -188,12 +188,12 @@ public void TestCountOptionsSortedByNumericField(string endpointId) var aggregation = ft.Aggregate(index, new AggregationRequest("@n:[(50 +inf]") .SortBy(3, SortedField.Asc("@n")) .Load(FieldName.Of("@n")) - .WithoutCount() + .WithCount(false) .Dialect(2)); Assert.Equal([51L, 52L, 53L], Enumerable.Range(0, 3).Select(i => aggregation.GetRow(i).GetLong("n"))); } - [SkipIfRedisTheory(Comparison.LessThan, "7.2.0")] + [SkipIfRedisTheory(Is.Enterprise, Comparison.LessThan, "7.2.0")] [MemberData(nameof(EndpointsFixture.Env.AllEnvironments), MemberType = typeof(EndpointsFixture.Env))] public async Task TestCountOptionsSortedByNumericFieldAsync(string endpointId) { @@ -213,7 +213,7 @@ public async Task TestCountOptionsSortedByNumericFieldAsync(string endpointId) var counted = await ft.SearchAsync(index, Page().SetWithCount()); Assert.Equal(49, counted.TotalResults); Assert.Equal(["doc51", "doc52", "doc53"], counted.Documents.Select(doc => doc.Id)); - var uncounted = await ft.SearchAsync(index, Page().SetWithoutCount()); + var uncounted = await ft.SearchAsync(index, Page().SetWithCount(false)); Assert.Equal(["doc51", "doc52", "doc53"], uncounted.Documents.Select(doc => doc.Id)); // Set after SORTBY and LOAD, where the server would reject it, to show that it is sent right @@ -221,7 +221,7 @@ public async Task TestCountOptionsSortedByNumericFieldAsync(string endpointId) var aggregation = await ft.AggregateAsync(index, new AggregationRequest("@n:[(50 +inf]") .SortBy(3, SortedField.Asc("@n")) .Load(FieldName.Of("@n")) - .WithoutCount() + .WithCount(false) .Dialect(2)); Assert.Equal([51L, 52L, 53L], Enumerable.Range(0, 3).Select(i => aggregation.GetRow(i).GetLong("n"))); } From 51df5bad0141087f95931fea3244c438cafb8b46 Mon Sep 17 00:00:00 2001 From: Arad Alvand Date: Tue, 10 Nov 2026 17:47:46 +0330 Subject: [PATCH 3/3] Compose the aggregation count option without mutating the request `SerializeRedisArgs()` inserted the token into the request's own argument list, so building a reused request again kept the earlier token: clearing the option still sent it, and changing it sent both. The command builders now copy the request's arguments with the current option placed after the query, and leave the request untouched. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XUNs4unCWEtK5q25xYbvgz --- src/NRedisStack/Search/AggregationRequest.cs | 13 +++++++++---- src/NRedisStack/Search/SearchCommandBuilder.cs | 4 ++-- .../NRedisStack.Tests/Search/CountOptionTests.cs | 15 +++++++++++++++ 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/src/NRedisStack/Search/AggregationRequest.cs b/src/NRedisStack/Search/AggregationRequest.cs index 7a36dc05..cf20aa17 100644 --- a/src/NRedisStack/Search/AggregationRequest.cs +++ b/src/NRedisStack/Search/AggregationRequest.cs @@ -202,13 +202,19 @@ private void Dialect() } } - private void CountOption() + /// + /// Adds the request's arguments to , with the count option right after the query, + /// since the server rejects it after a pipeline step. The request's own arguments are left as they are, + /// so building the command again reflects the current option. + /// + internal void AddArgsTo(List target) { + target.Add(args[0]); if (withCount.HasValue) { - // The query is always the first argument, and the server rejects the option after a pipeline step. - args.Insert(1, withCount.Value ? SearchArgs.WITHCOUNT : SearchArgs.WITHOUTCOUNT); + target.Add(withCount.Value ? SearchArgs.WITHCOUNT : SearchArgs.WITHOUTCOUNT); } + target.AddRange(args.Skip(1)); } public List GetArgs() @@ -218,7 +224,6 @@ public List GetArgs() public void SerializeRedisArgs() { - CountOption(); Dialect(); } diff --git a/src/NRedisStack/Search/SearchCommandBuilder.cs b/src/NRedisStack/Search/SearchCommandBuilder.cs index 92a9e8df..0e00cebd 100644 --- a/src/NRedisStack/Search/SearchCommandBuilder.cs +++ b/src/NRedisStack/Search/SearchCommandBuilder.cs @@ -15,7 +15,7 @@ public static SerializedCommand Aggregate(string index, AggregationRequest query { List args = [index]; query.SerializeRedisArgs(); - args.AddRange(query.GetArgs()); + query.AddArgsTo(args); return new(AggregateCategory(query), FT.AGGREGATE, args); } @@ -207,7 +207,7 @@ public static SerializedCommand ProfileAggregate(string IndexName, AggregationRe : new List { IndexName, SearchArgs.AGGREGATE, SearchArgs.QUERY }; query.SerializeRedisArgs(); - args.AddRange(query.GetArgs()); + query.AddArgsTo(args); // profiling a cursored aggregate still allocates the cursor, so it carries the same category return new(AggregateCategory(query), FT.PROFILE, args); } diff --git a/tests/NRedisStack.Tests/Search/CountOptionTests.cs b/tests/NRedisStack.Tests/Search/CountOptionTests.cs index 09dd4a73..edfb4444 100644 --- a/tests/NRedisStack.Tests/Search/CountOptionTests.cs +++ b/tests/NRedisStack.Tests/Search/CountOptionTests.cs @@ -99,6 +99,21 @@ public void AggregationCountOptionCanBeCleared() Assert.Equal(expected, AggregateArgs(request)); } + [Fact] + public void AggregationCountOptionChangesTakeEffectWhenRequestIsReused() + { + var request = new AggregationRequest("*").WithCount(false); + AggregateArgs(request); + + request.WithCount(null); + object[] cleared = ["*"]; + Assert.Equal(cleared, AggregateArgs(request)); + + request.WithCount(); + object[] counted = ["*", "WITHCOUNT"]; + Assert.Equal(counted, AggregateArgs(request)); + } + [Fact] public void AggregationCountOptionLeavesCallerInsertedArgsIntact() {