Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5994b05
fix RESP3 mismatches
mgravell May 1, 2026
445b236
dotnet format
mgravell May 1, 2026
f421826
Spoof RESP2 MRANGE results from RESP3
mgravell May 1, 2026
e03ef64
fix double-incr
mgravell May 1, 2026
ccc0cf4
nits
mgravell May 1, 2026
925432a
fix rule parsing
mgravell May 1, 2026
2bb1142
fix info (flag) parsing
mgravell May 1, 2026
352648a
fix JSON APIs
mgravell May 1, 2026
478d0d5
fix spellcheck parsing
mgravell May 1, 2026
4d26ff6
fix content from info result
mgravell May 1, 2026
e16236f
Marc/resp3 (#477)
mgravell May 2, 2026
54ea2cd
Marc/resp3 (#478)
mgravell May 2, 2026
a89240f
Merge branch 'master' into marc/resp3
mgravell May 2, 2026
a1eab4e
- fix more search RESP3 behaviours
mgravell May 5, 2026
a00858d
Merge branch 'master' into marc/resp3
mgravell May 5, 2026
d6f562b
deps
mgravell May 5, 2026
4977a3a
note GHATL pin
mgravell May 5, 2026
0f8605d
help me understand CI fail
mgravell May 5, 2026
a367f14
add some key randomness to TestJsonSetNotExistAsync
mgravell May 5, 2026
684c5d3
add GetKeys helper to tell me what is missing in older servers
mgravell May 5, 2026
9f97a18
handle v7 RESP3 profile layout
mgravell May 5, 2026
df33902
dotnet format
mgravell May 5, 2026
e352242
more v7 fixings
mgravell May 5, 2026
205c4c7
and another
mgravell May 5, 2026
6e65015
Merge branch 'master' into marc/resp3
mgravell May 6, 2026
ab72488
make the bugbot happy
mgravell May 8, 2026
c3bf589
Merge branch 'master' into marc/resp3
mgravell May 8, 2026
b426103
Merge branch 'master' into marc/resp3
mgravell May 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
<PackageVersion Include="coverlet.collector" Version="10.0.0" />
<PackageVersion Include="coverlet.msbuild" Version="10.0.0" />
<PackageVersion Include="dotenv.net" Version="4.0.2" />
<!-- there seem to be bumps in GHATL 3.x making it not work with the test SDK -->
<PackageVersion Include="GitHubActionsTestLogger" Version="2.4.1" />
<PackageVersion Include="Microsoft.Azure.StackExchangeRedis" Version="3.3.1" />
<PackageVersion Include="Microsoft.Bcl.Memory" Version="10.0.7" />
Expand Down
12 changes: 2 additions & 10 deletions src/NRedisStack/Json/JsonCommands.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,15 +131,7 @@ public int SetFromDirectory(RedisValue path, string filesPath, When when = When.
public JsonType[] Type(RedisKey key, string? path = null)
{
RedisResult result = db.Execute(JsonCommandBuilder.Type(key, path));

return result.Resp2Type switch
{
ResultType.Array => ((RedisResult[])result!)
.Select(x => (JsonType)Enum.Parse(typeof(JsonType), x.ToString().ToUpper()))
.ToArray(),
ResultType.BulkString => [(JsonType)Enum.Parse(typeof(JsonType), result.ToString().ToUpper())],
_ => []
};
return ResponseParser.ParseJsonTypeArray(result);
}

public long DebugMemory(string key, string? path = null)
Expand Down Expand Up @@ -250,7 +242,7 @@ public RedisResult[] MGet(RedisKey[] keys, string path)
public double?[] NumIncrby(RedisKey key, string path, double value)
{
var res = db.Execute(JsonCommandBuilder.NumIncrby(key, path, value));
return JsonSerializer.Deserialize<double?[]>(res.ToString())!;
return ResponseParser.ParseJsonDoubleArray(res);
}

/// <inheritdoc/>
Expand Down
15 changes: 2 additions & 13 deletions src/NRedisStack/Json/JsonCommandsAsync.cs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ public async Task<RedisResult[]> MGetAsync(RedisKey[] keys, string path)
public async Task<double?[]> NumIncrbyAsync(RedisKey key, string path, double value)
{
var res = await db.ExecuteAsync(JsonCommandBuilder.NumIncrby(key, path, value));
return JsonSerializer.Deserialize<double?[]>(res.ToString())!;
return ResponseParser.ParseJsonDoubleArray(res);
}

public async Task<IEnumerable<HashSet<string>>> ObjKeysAsync(RedisKey key, string? path = null)
Expand Down Expand Up @@ -231,18 +231,7 @@ public async Task<int> SetFromDirectoryAsync(RedisValue path, string filesPath,
public async Task<JsonType[]> TypeAsync(RedisKey key, string? path = null)
{
RedisResult result = await db.ExecuteAsync(JsonCommandBuilder.Type(key, path));

if (result.Resp2Type == ResultType.Array)
{
return ((RedisResult[])result!).Select(x => (JsonType)Enum.Parse(typeof(JsonType), x.ToString().ToUpper())).ToArray();
}

if (result.Resp2Type == ResultType.BulkString)
{
return [(JsonType)Enum.Parse(typeof(JsonType), result.ToString().ToUpper())];
}

return [];
return ResponseParser.ParseJsonTypeArray(result);
}

public async Task<long> DebugMemoryAsync(string key, string? path = null)
Expand Down
724 changes: 575 additions & 149 deletions src/NRedisStack/ResponseParser.cs

Large diffs are not rendered by default.

82 changes: 60 additions & 22 deletions src/NRedisStack/Search/AggregationResult.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using NRedisStack.Search.Aggregation;
using System.Diagnostics;
using NRedisStack.Search.Aggregation;
using StackExchange.Redis;

namespace NRedisStack.Search;
Expand Down Expand Up @@ -29,34 +30,71 @@ internal WithCursorAggregationResult(string indexName, RedisResult result, long
internal AggregationResult(RedisResult result, long cursorId = -1)
{
var arr = (RedisResult[])result!;

// this statement below is not true as explained in the document https://redis.io/docs/latest/commands/ft.aggregate/#return
// // the first element is always the number of results
// TotalResults = (long)arr[0];

_results = new Dictionary<string, object>[arr.Length - 1];
for (int i = 1; i < arr.Length; i++)
Dictionary<string, object>[]? results = null;
if (result.Resp3Type is ResultType.Map)
{
var raw = (RedisResult[])arr[i]!;
var cur = new Dictionary<string, object>();
for (int j = 0; j < raw.Length;)
results = ResponseParser.ParseSearchResultsMap(result, ParseRecordFromMap, out long totalResults);
// TotalResults = totalResults; // we ignore this in RESP3 mode for consistent API behaviour
}
else
{
// this statement below is not true as explained in the document https://redis.io/docs/latest/commands/ft.aggregate/#return
// // the first element is always the number of results
// TotalResults = (long)arr[0];

results = new Dictionary<string, object>[arr.Length - 1];
for (int i = 1; i < arr.Length; i++)
{
var key = (string)raw[j++]!;
var val = raw[j++];
if (val.Resp2Type == ResultType.Array)
var raw = (RedisResult[])arr[i]!;
var cur = new Dictionary<string, object>();
for (int j = 0; j < raw.Length;)
{
cur.Add(key, ConvertMultiBulkToObject((RedisResult[])val!));
var key = (string)raw[j++]!;
var val = raw[j++];
cur.Add(key, ParseFieldValue(val));
}
else

results[i - 1] = cur;
}
}
TotalResults = results.Length;
CursorId = cursorId;
_results = results ?? []; // if we didn't get results, make an empty array

static object ParseFieldValue(RedisResult val) => val.Resp2Type switch
{
ResultType.Array => ConvertMultiBulkToObject((RedisResult[])val!),
_ => (RedisValue)val
};

static Dictionary<string, object> ParseRecordFromMap(string[] attributes, RedisResult[] map)
{
var record = new Dictionary<string, object>();
for (int i = 0; i + 1 < map.Length; i += 2)
{
var key = (string)map[i]!;
var val = map[i + 1];
switch (key)
{
cur.Add(key, (RedisValue)val);
case "values" when val.Resp3Type is ResultType.Array:
var values = (RedisResult[])val!;
for (int j = 0; j < values.Length && j < attributes.Length; j++)
{
record.Add(attributes[j], ParseFieldValue(values[j]));
}
break;
case "extra_attributes" when val.Resp3Type is ResultType.Map:
var extraAttributes = (RedisResult[])val!;
for (int j = 0; j + 1 < extraAttributes.Length; j += 2)
{
record.Add((string)extraAttributes[j]!, ParseFieldValue(extraAttributes[j + 1]));
}
break;

}
}

_results[i - 1] = cur;
return record;
}
TotalResults = _results.Length;
CursorId = cursorId;
}

/// <summary>
Expand All @@ -68,7 +106,7 @@ internal AggregationResult(RedisResult result, long cursorId = -1)
/// </summary>
/// <param name="multiBulkArray"></param>
/// <returns>object</returns>
private object ConvertMultiBulkToObject(IEnumerable<RedisResult> multiBulkArray)
private static object ConvertMultiBulkToObject(IEnumerable<RedisResult> multiBulkArray)
{
return multiBulkArray.Select(item => item.Resp2Type == ResultType.Array
? ConvertMultiBulkToObject((RedisResult[])item!)
Expand Down
20 changes: 18 additions & 2 deletions src/NRedisStack/Search/DataTypes/InfoResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -109,18 +109,34 @@ private double GetDouble(string key)
{
var dict = new Dictionary<string, RedisResult>();

bool isMap = values[i].Resp3Type is ResultType.Map;
IEnumerable<RedisResult> enumerable = (RedisResult[])values[i]!;
IEnumerator<RedisResult> results = enumerable.GetEnumerator();
while (results.MoveNext())
{
string attribute = (string)results.Current!;
// if its boolean attributes add itself to the dictionary and continue
if (booleanAttributes.Contains(attribute))
if (attribute is "flags" && isMap)
{
results.MoveNext();
if (results.Current.Resp3Type is ResultType.Array)
{
// RESP3 adds the flags (think "boolean attributes") a single array of strings;
// spoof the RESP2 behaviour for consistency
foreach (var flag in (RedisResult[])results.Current!)
{
dict.Add((string)flag!, flag);
}
}
}
else if (!isMap && booleanAttributes.Contains(attribute))
{
// boolean attributes are added as themselves in RESP2
dict.Add(attribute, results.Current);
}
else
{//if its not a boolean attribute, add the next item as value to the dictionary
{
//if its not a flag or boolean attribute, add the next item as value to the dictionary
results.MoveNext(); ;
dict.Add(attribute, results.Current);
}
Expand Down
89 changes: 66 additions & 23 deletions src/NRedisStack/Search/SearchResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,61 @@ public class SearchResult
public List<string> ToJson() => Documents.Select(x => x["json"].ToString())
.Where(x => !string.IsNullOrEmpty(x)).ToList();

internal SearchResult(RedisResult[] resp, bool hasContent, bool hasScores, bool hasPayloads/*, bool shouldExplainScore*/)
internal SearchResult(RedisResult root, bool hasContent, bool hasScores, bool hasPayloads/*, bool shouldExplainScore*/)
{
// Calculate the step distance to walk over the results.
// The order of results is id, score (if withScore), payLoad (if hasPayloads), fields
int stride = 1;
if (hasScores) stride++;
if (hasPayloads) stride++;
if (hasContent) stride++;

// the first element is always the number of results
if (resp is not { Length: > 0 })
if (root.Length <= 0)
{
// unexpected empty case
TotalResults = 0;
Documents = [];
Debug.Assert(false, "Empty result from FT.SEARCH"); // debug only, flag as a problem
Debug.Assert(false, "Empty result from FT.SEARCH"); // debug only, flag as a problem
}
else if (root.Resp3Type is ResultType.Map && root.Length > 0) // RESP3
{
Documents = new(ResponseParser.ParseSearchResultsMap(
root, static (attributes, values) =>
{
string id = "";
double score = 1.0;
byte[]? payload = null;
RedisValue[]? fields = null;
string[]? scoreExplained = null;
for (int i = 0; i + 1 < values.Length; i += 2)
{
var key = values[i].ToString();
var value = values[i + 1];
Comment thread
cursor[bot] marked this conversation as resolved.
switch (key)
{
case "id":
id = value.ToString();
break;
case "payload": // implicit "when hasPayloads"
payload = (byte[]?)value;
break;
case "score": // implicit "when hasScores"
score = ParseScore(value, out scoreExplained);
break;
case "extra_attributes": // implicit "when hasContent"
fields = (RedisValue[]?)value;
break;
}
}
return Document.Load(id, score, payload, fields, scoreExplained);
}, out long totalResults));
TotalResults = totalResults;
}
else
else // RESP2
{
RedisResult[] resp = (RedisResult[])root!;
// Calculate the step distance to walk over the results.
// The order of results is id, score (if withScore), payLoad (if hasPayloads), fields
int stride = 1;
if (hasScores) stride++;
if (hasPayloads) stride++;
if (hasContent) stride++;

// the first element is always the number of results

TotalResults = (long)resp[0];
int count = checked((int)(resp.Length - 1) / stride);
var docs = Documents = new List<Document>(count);
Expand All @@ -51,17 +87,7 @@ internal SearchResult(RedisResult[] resp, bool hasContent, bool hasScores, bool
string[]? scoreExplained = null;
if (hasScores)
{
// if (shouldExplainScore)
// {
// var scoreResult = (RedisResult[])resp[offset++];
// score = (double) scoreResult[0];
// var redisResultsScoreExplained = (RedisResult[]) scoreResult[1];
// scoreExplained = FlatRedisResultArray(redisResultsScoreExplained).ToArray();
// }
//else
//{
score = (double)resp[offset++];
//}
score = ParseScore(resp[offset++], out scoreExplained);
}

if (hasPayloads) // match logic from setup
Expand All @@ -77,5 +103,22 @@ internal SearchResult(RedisResult[] resp, bool hasContent, bool hasScores, bool
docs.Add(Document.Load(id, score, payload, fields, scoreExplained));
}
}
static double ParseScore(RedisResult scoreResult, out string[]? scoreExplained)
{
scoreExplained = null;
double score;
if (scoreResult.Resp2Type is ResultType.Array) // implicit shouldExplainScore
{
var scoreResultArr = (RedisResult[])scoreResult!;
score = (double)scoreResultArr[0];
// var redisResultsScoreExplained = (RedisResult[])scoreResultArr[1];
// scoreExplained = FlatRedisResultArray(redisResultsScoreExplained).ToArray();
}
else
{
score = (double)scoreResult;
}
return score;
}
}
}
36 changes: 33 additions & 3 deletions tests/NRedisStack.Tests/AbstractNRedisStackTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,16 @@ public abstract class AbstractNRedisStackTest : IClassFixture<EndpointsFixture>,
private protected EndpointsFixture EndpointsFixture { get; }
private readonly ITestOutputHelper? log;

protected void Log(string message)
protected void Log(string message, bool demand = true)
{
if (log is null) throw new InvalidOperationException("Log is not initialized");
log.WriteLine(message);
if (log is null)
{
if (demand) throw new InvalidOperationException("Log is not initialized");
}
else
{
log.WriteLine(message);
}
}

protected readonly ConfigurationOptions DefaultConnectionConfig = new()
Expand All @@ -30,6 +36,30 @@ protected internal AbstractNRedisStackTest(EndpointsFixture endpointsFixture, IT
this.log = log;
}

protected void AssertVersion(IDatabase db, [CallerMemberName] string testName = "")
{
// this is used to reapply "Skip" logic after auto-discovery of the server version is possible
var attributes = GetType().GetMethod(testName)?.GetCustomAttributes(true) ?? [];
Version? version = null;
foreach (var attribute in attributes)
{
SkipIfRedisCore? core = attribute switch
{
SkipIfRedisFactAttribute fact => fact.Core,
SkipIfRedisTheoryAttribute theory => theory.Core,
_ => null,
};
if (core is { } defined)
{
// get the actual redis version and use that to recheck
version ??= db.Multiplexer.GetServer((RedisKey)"any key").Version;
Log($"Validating with detected server version: {version}", demand: false);
var skip = defined.GetSkip(version);
Assert.SkipWhen(skip is not null, skip ?? "");
}
}
}

protected ConnectionMultiplexer GetConnection(string endpointId = EndpointsFixture.Env.Standalone, bool shareConnection = true) => EndpointsFixture.GetConnectionById(this.DefaultConnectionConfig, endpointId, shareConnection);

protected ConnectionMultiplexer GetConnection(ConfigurationOptions configurationOptions, string endpointId = EndpointsFixture.Env.Standalone) => EndpointsFixture.GetConnectionById(configurationOptions, endpointId, false);
Expand Down
5 changes: 2 additions & 3 deletions tests/NRedisStack.Tests/CommunityEditionUpdatesTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,9 @@

namespace NRedisStack.Tests;

public class CommunityEditionUpdatesTests : AbstractNRedisStackTest, IDisposable
public class CommunityEditionUpdatesTests(EndpointsFixture endpointsFixture, ITestOutputHelper log)
: AbstractNRedisStackTest(endpointsFixture, log), IDisposable
{
public CommunityEditionUpdatesTests(EndpointsFixture endpointsFixture) : base(endpointsFixture) { }

private IServer getAnyPrimary(IConnectionMultiplexer muxer)
{
foreach (var endpoint in muxer.GetEndPoints())
Expand Down
Loading
Loading