diff --git a/eng/StackExchange.Redis.Build/AsciiHash.md b/eng/StackExchange.Redis.Build/AsciiHash.md index 4a76ded62..ceaf5575e 100644 --- a/eng/StackExchange.Redis.Build/AsciiHash.md +++ b/eng/StackExchange.Redis.Build/AsciiHash.md @@ -117,7 +117,9 @@ public static partial bool TryParse(ReadOnlySpan value, out SomeEnum value Individual enum members can also be marked with `[AsciiHash("token value")]` to override the token payload. If an enum member declares an empty explicit value (i.e. `[AsciiHash("")]`), then that member is ignored by the tool; this is useful for marking "unknown" or "invalid" enum values (commonly the first enum, which by -convention has the value `0`): +convention has the value `0`). Note that this is about the *explicit* empty token: a member with no attribute +(or a bare `[AsciiHash]`) still infers its token from the member name, so a member that must not be matched +needs the explicit `[AsciiHash("")]`: ``` c# public enum SomeEnum diff --git a/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs index b00675c40..b41e329ef 100644 --- a/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs +++ b/eng/StackExchange.Redis.Build/AsciiHashGenerator.cs @@ -183,12 +183,34 @@ private static string GetRawValue(string name, AttributeData? asciiHashAttribute } if (string.IsNullOrWhiteSpace(value)) { - value = InferPayload(name); // if nothing explicit: infer from name + // an explicit empty token means "pretend you don't know about this" (usually the zero + // member, i.e. Unknown/None); anything else: infer from the name + value = HasExplicitToken(asciiHashAttribute) ? "" : InferPayload(name); } return value; } + // the attribute's token parameter is optional, so a bare [AsciiHash] and [AsciiHash("")] are + // indistinguishable in ConstructorArguments (both report ""); the syntax tells them apart + private static bool HasExplicitToken(AttributeData? asciiHashAttribute) + { + if (asciiHashAttribute?.ApplicationSyntaxReference?.GetSyntax() + is not AttributeSyntax { ArgumentList.Arguments: { Count: > 0 } arguments }) + { + return false; + } + + foreach (var argument in arguments) + { + // NameEquals is a property assignment (i.e. CaseSensitive = false); anything else is + // the token, whether positional or named (i.e. token: "") + if (argument.NameEquals is null) return true; + } + + return false; + } + private static string InferPayload(string name) => name.Replace("_", "-"); private (string Namespace, string ParentType, Accessibility Accessibility, string Name, diff --git a/src/RESPite/Shared/AsciiHash.cs b/src/RESPite/Shared/AsciiHash.cs index f0f134872..15164c4fa 100644 --- a/src/RESPite/Shared/AsciiHash.cs +++ b/src/RESPite/Shared/AsciiHash.cs @@ -25,6 +25,9 @@ public sealed partial class AsciiHashAttribute(string token = "") : Attribute /// The token expected when parsing data, if different from the implied value. The implied /// value is the name, replacing underscores for hyphens, so: 'a_b' becomes 'a-b'. /// + /// An explicit empty token (i.e. [AsciiHash("")]) means that the member is + /// excluded, and cannot be parsed or formatted; this is for client-side values such as + /// Unknown. This is distinct from omitting the token, which infers it from the name. public string Token => token; /// diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs index 55769105f..d3e3d44a9 100644 --- a/src/StackExchange.Redis/Enums/RedisCommand.cs +++ b/src/StackExchange.Redis/Enums/RedisCommand.cs @@ -6,7 +6,8 @@ namespace StackExchange.Redis; // ReSharper disable InconsistentNaming internal enum RedisCommand { - NONE, // must be first for "zero reasons" + [AsciiHash("")] // not a command; must be first for "zero reasons" + NONE, APPEND, ASKING, @@ -317,9 +318,6 @@ internal static partial class RedisCommandMetadata [AsciiHash(CaseSensitive = false)] public static partial bool TryParseCI(ReadOnlySpan command, out RedisCommand value); - - [AsciiHash] - public static partial bool TryFormat(RedisCommand command, out string format); } // ReSharper restore InconsistentNaming diff --git a/src/StackExchange.Redis/Enums/RedisType.cs b/src/StackExchange.Redis/Enums/RedisType.cs index 43efcf86b..3cfd747d3 100644 --- a/src/StackExchange.Redis/Enums/RedisType.cs +++ b/src/StackExchange.Redis/Enums/RedisType.cs @@ -13,7 +13,9 @@ public enum RedisType /// /// The specified key does not exist. /// - [AsciiHash("")] + /// none is the literal reply from TYPE for a key that does not exist, + /// so this member has a real token (unlike ). + [AsciiHash("none")] None, /// @@ -75,6 +77,8 @@ public enum RedisType /// /// The data-type was not recognised by the client library. /// + /// This is a client-side value, not a server token, so it is not parsable. + [AsciiHash("")] Unknown, /// @@ -96,7 +100,8 @@ public enum RedisType /// internal static partial class RedisTypeMetadata { - [AsciiHash] + // TYPE replies are lower-case, but this is not a hot path, and v2 parsed case-insensitively + [AsciiHash(CaseSensitive = false)] internal static partial bool TryParse(ReadOnlySpan value, out RedisType redisType); } } diff --git a/tests/StackExchange.Redis.Tests/KeyTests.cs b/tests/StackExchange.Redis.Tests/KeyTests.cs index 37479a069..322cbf00b 100644 --- a/tests/StackExchange.Redis.Tests/KeyTests.cs +++ b/tests/StackExchange.Redis.Tests/KeyTests.cs @@ -54,6 +54,27 @@ public async Task FlushFetchRandomKey() Assert.Equal(prefix + "abc", Encoding.UTF8.GetString(keyBytes)); } + [Fact] + public async Task KeyTypeOfMissingKeyIsNone() // see #3156 + { + await using var conn = Create(); + + var db = conn.GetDatabase(); + var key = Me(); + db.KeyDelete(key, CommandFlags.FireAndForget); + + Assert.Equal(RedisType.None, db.KeyType(key)); + Assert.Equal(RedisType.None, await db.KeyTypeAsync(key)); + + db.StringSet(key, "abc", flags: CommandFlags.FireAndForget); + Assert.Equal(RedisType.String, db.KeyType(key)); + Assert.Equal(RedisType.String, await db.KeyTypeAsync(key)); + + db.KeyDelete(key, CommandFlags.FireAndForget); + Assert.Equal(RedisType.None, db.KeyType(key)); + Assert.Equal(RedisType.None, await db.KeyTypeAsync(key)); + } + [Fact] public async Task Zeros() { diff --git a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs index 090b223f3..5f6f91ce4 100644 --- a/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs +++ b/tests/StackExchange.Redis.Tests/ResultProcessorUnitTests/BasicScalars.cs @@ -157,6 +157,10 @@ public class BasicScalars(ITestOutputHelper log) : ResultProcessorUnitTest(log) [InlineData("+set\r\n", Redis.RedisType.Set)] [InlineData("+list\r\n", Redis.RedisType.List)] [InlineData("+stream\r\n", Redis.RedisType.Stream)] + [InlineData("+vectorset\r\n", Redis.RedisType.VectorSet)] + [InlineData("+array\r\n", Redis.RedisType.Array)] + [InlineData("+none\r\n", Redis.RedisType.None)] // TYPE reply for a key that does not exist; see #3156 + [InlineData("$4\r\nnone\r\n", Redis.RedisType.None)] [InlineData("+blah\r\n", Redis.RedisType.Unknown)] [InlineData("$-1\r\n", Redis.RedisType.None)] [InlineData("_\r\n", Redis.RedisType.None)]