Skip to content

Commit 038f3de

Browse files
ArnoKollmgravell
andauthored
Add Condition.SortedSet[Not]ContainsStarting condition for transactions (#2638)
* New-SortedSetStartsWith-Condition * fix formatting rules * clean up and optimize * tests for sorted-set-starts-with condition IDE noise cleanup * release notes / shipped --------- Co-authored-by: Marc Gravell <marc.gravell@gmail.com>
1 parent cf8b6fb commit 038f3de

File tree

10 files changed

+352
-51
lines changed

10 files changed

+352
-51
lines changed

docs/ReleaseNotes.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Current package versions:
1010

1111
- Add `HGETDEL`, `HGETEX` and `HSETEX` support ([#2863 by atakavci](https://github.com/StackExchange/StackExchange.Redis/pull/2863))
1212
- Fix key-prefix omission in `SetIntersectionLength` and `SortedSet{Combine[WithScores]|IntersectionLength}` ([#2863 by mgravell](https://github.com/StackExchange/StackExchange.Redis/pull/2863))
13+
- Add `Condition.SortedSet[Not]ContainsStarting` condition for transactions ([#2638 by ArnoKoll](https://github.com/StackExchange/StackExchange.Redis/pull/2638))
1314

1415
## 2.8.58
1516

src/StackExchange.Redis/Condition.cs

Lines changed: 98 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,6 +284,20 @@ public static Condition StringNotEqual(RedisKey key, RedisValue value)
284284
/// <param name="member">The member the sorted set must not contain.</param>
285285
public static Condition SortedSetNotContains(RedisKey key, RedisValue member) => new ExistsCondition(key, RedisType.SortedSet, member, false);
286286

287+
/// <summary>
288+
/// Enforces that the given sorted set contains a member that starts with the specified prefix.
289+
/// </summary>
290+
/// <param name="key">The key of the sorted set to check.</param>
291+
/// <param name="prefix">The sorted set must contain at least one member that starts with the specified prefix.</param>
292+
public static Condition SortedSetContainsStarting(RedisKey key, RedisValue prefix) => new StartsWithCondition(key, prefix, true);
293+
294+
/// <summary>
295+
/// Enforces that the given sorted set does not contain a member that starts with the specified prefix.
296+
/// </summary>
297+
/// <param name="key">The key of the sorted set to check.</param>
298+
/// <param name="prefix">The sorted set must not contain at a member that starts with the specified prefix.</param>
299+
public static Condition SortedSetNotContainsStarting(RedisKey key, RedisValue prefix) => new StartsWithCondition(key, prefix, false);
300+
287301
/// <summary>
288302
/// Enforces that the given sorted set member must have the specified score.
289303
/// </summary>
@@ -370,6 +384,9 @@ public static Message CreateMessage(Condition condition, int db, CommandFlags fl
370384
public static Message CreateMessage(Condition condition, int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, in RedisValue value1) =>
371385
new ConditionMessage(condition, db, flags, command, key, value, value1);
372386

387+
public static Message CreateMessage(Condition condition, int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4) =>
388+
new ConditionMessage(condition, db, flags, command, key, value, value1, value2, value3, value4);
389+
373390
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0071:Simplify interpolation", Justification = "Allocations (string.Concat vs. string.Format)")]
374391
protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result)
375392
{
@@ -389,6 +406,9 @@ private sealed class ConditionMessage : Message.CommandKeyBase
389406
public readonly Condition Condition;
390407
private readonly RedisValue value;
391408
private readonly RedisValue value1;
409+
private readonly RedisValue value2;
410+
private readonly RedisValue value3;
411+
private readonly RedisValue value4;
392412

393413
public ConditionMessage(Condition condition, int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value)
394414
: base(db, flags, command, key)
@@ -403,6 +423,15 @@ public ConditionMessage(Condition condition, int db, CommandFlags flags, RedisCo
403423
this.value1 = value1; // note no assert here
404424
}
405425

426+
// Message with 3 or 4 values not used, therefore not implemented
427+
public ConditionMessage(Condition condition, int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, in RedisValue value1, in RedisValue value2, in RedisValue value3, in RedisValue value4)
428+
: this(condition, db, flags, command, key, value, value1)
429+
{
430+
this.value2 = value2; // note no assert here
431+
this.value3 = value3; // note no assert here
432+
this.value4 = value4; // note no assert here
433+
}
434+
406435
protected override void WriteImpl(PhysicalConnection physical)
407436
{
408437
if (value.IsNull)
@@ -412,16 +441,20 @@ protected override void WriteImpl(PhysicalConnection physical)
412441
}
413442
else
414443
{
415-
physical.WriteHeader(command, value1.IsNull ? 2 : 3);
444+
physical.WriteHeader(command, value1.IsNull ? 2 : value2.IsNull ? 3 : value3.IsNull ? 4 : value4.IsNull ? 5 : 6);
416445
physical.Write(Key);
417446
physical.WriteBulkString(value);
418447
if (!value1.IsNull)
419-
{
420448
physical.WriteBulkString(value1);
421-
}
449+
if (!value2.IsNull)
450+
physical.WriteBulkString(value2);
451+
if (!value3.IsNull)
452+
physical.WriteBulkString(value3);
453+
if (!value4.IsNull)
454+
physical.WriteBulkString(value4);
422455
}
423456
}
424-
public override int ArgCount => value.IsNull ? 1 : value1.IsNull ? 2 : 3;
457+
public override int ArgCount => value.IsNull ? 1 : value1.IsNull ? 2 : value2.IsNull ? 3 : value3.IsNull ? 4 : value4.IsNull ? 5 : 6;
425458
}
426459
}
427460

@@ -501,6 +534,67 @@ internal override bool TryValidate(in RawResult result, out bool value)
501534
}
502535
}
503536

537+
internal sealed class StartsWithCondition : Condition
538+
{
539+
/* only usable for RedisType.SortedSet, members of SortedSets are always byte-arrays, expectedStartValue therefore is a byte-array
540+
any Encoding and Conversion for the search-sequence has to be executed in calling application
541+
working with byte arrays should prevent any encoding within this class, that could distort the comparison */
542+
543+
private readonly bool expectedResult;
544+
private readonly RedisValue prefix;
545+
private readonly RedisKey key;
546+
547+
internal override Condition MapKeys(Func<RedisKey, RedisKey> map) =>
548+
new StartsWithCondition(map(key), prefix, expectedResult);
549+
550+
public StartsWithCondition(in RedisKey key, in RedisValue prefix, bool expectedResult)
551+
{
552+
if (key.IsNull) throw new ArgumentNullException(nameof(key));
553+
if (prefix.IsNull) throw new ArgumentNullException(nameof(prefix));
554+
this.key = key;
555+
this.prefix = prefix;
556+
this.expectedResult = expectedResult;
557+
}
558+
559+
public override string ToString() =>
560+
$"{key} {nameof(RedisType.SortedSet)} > {(expectedResult ? " member starting " : " no member starting ")} {prefix} + prefix";
561+
562+
internal override void CheckCommands(CommandMap commandMap) => commandMap.AssertAvailable(RedisCommand.ZRANGEBYLEX);
563+
564+
internal override IEnumerable<Message> CreateMessages(int db, IResultBox? resultBox)
565+
{
566+
yield return Message.Create(db, CommandFlags.None, RedisCommand.WATCH, key);
567+
568+
// prepend '[' to prefix for inclusive search
569+
var startValueWithToken = RedisDatabase.GetLexRange(prefix, Exclude.None, isStart: true, Order.Ascending);
570+
571+
var message = ConditionProcessor.CreateMessage(
572+
this,
573+
db,
574+
CommandFlags.None,
575+
RedisCommand.ZRANGEBYLEX,
576+
key,
577+
startValueWithToken,
578+
RedisLiterals.PlusSymbol,
579+
RedisLiterals.LIMIT,
580+
0,
581+
1);
582+
583+
message.SetSource(ConditionProcessor.Default, resultBox);
584+
yield return message;
585+
}
586+
587+
internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key);
588+
589+
internal override bool TryValidate(in RawResult result, out bool value)
590+
{
591+
value = result.ItemsCount == 1 && result[0].AsRedisValue().StartsWith(prefix);
592+
593+
if (!expectedResult) value = !value;
594+
return true;
595+
}
596+
}
597+
504598
internal sealed class EqualsCondition : Condition
505599
{
506600
internal override Condition MapKeys(Func<RedisKey, RedisKey> map) =>
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
#pragma warning disable SA1403 // single namespace
2+
3+
#if NET5_0_OR_GREATER
4+
// context: https://github.com/StackExchange/StackExchange.Redis/issues/2619
5+
[assembly: System.Runtime.CompilerServices.TypeForwardedTo(typeof(System.Runtime.CompilerServices.IsExternalInit))]
6+
#else
7+
// To support { get; init; } properties
8+
using System.ComponentModel;
9+
using System.Text;
10+
11+
namespace System.Runtime.CompilerServices
12+
{
13+
[EditorBrowsable(EditorBrowsableState.Never)]
14+
internal static class IsExternalInit { }
15+
}
16+
#endif
17+
18+
#if !(NETCOREAPP || NETSTANDARD2_1_OR_GREATER)
19+
20+
namespace System.Text
21+
{
22+
internal static class EncodingExtensions
23+
{
24+
public static unsafe int GetBytes(this Encoding encoding, ReadOnlySpan<char> source, Span<byte> destination)
25+
{
26+
fixed (byte* bPtr = destination)
27+
{
28+
fixed (char* cPtr = source)
29+
{
30+
return encoding.GetBytes(cPtr, source.Length, bPtr, destination.Length);
31+
}
32+
}
33+
}
34+
}
35+
}
36+
#endif
37+
38+
39+
#pragma warning restore SA1403

src/StackExchange.Redis/Hacks.cs

Lines changed: 0 additions & 13 deletions
This file was deleted.

src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1951,4 +1951,8 @@ StackExchange.Redis.IDatabaseAsync.HashFieldSetAndSetExpiryAsync(StackExchange.R
19511951
StackExchange.Redis.IDatabaseAsync.HashFieldSetAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue field, StackExchange.Redis.RedisValue value, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task<StackExchange.Redis.RedisValue>!
19521952
StackExchange.Redis.IDatabaseAsync.HashFieldSetAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.HashEntry[]! hashFields, System.DateTime expiry, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task<StackExchange.Redis.RedisValue>!
19531953
StackExchange.Redis.IDatabaseAsync.HashFieldSetAndSetExpiryAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.HashEntry[]! hashFields, System.TimeSpan? expiry = null, bool keepTtl = false, StackExchange.Redis.When when = StackExchange.Redis.When.Always, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task<StackExchange.Redis.RedisValue>!
1954-
1954+
StackExchange.Redis.RedisValue.CopyTo(System.Span<byte> destination) -> int
1955+
StackExchange.Redis.RedisValue.GetByteCount() -> int
1956+
StackExchange.Redis.RedisValue.GetLongByteCount() -> long
1957+
static StackExchange.Redis.Condition.SortedSetContainsStarting(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue prefix) -> StackExchange.Redis.Condition!
1958+
static StackExchange.Redis.Condition.SortedSetNotContainsStarting(StackExchange.Redis.RedisKey key, StackExchange.Redis.RedisValue prefix) -> StackExchange.Redis.Condition!
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
#nullable enable
1+
#nullable enable

src/StackExchange.Redis/RedisDatabase.cs

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3962,21 +3962,23 @@ private Message GetSortedSetMultiPopMessage(RedisKey[] keys, Order order, long c
39623962
return tran;
39633963
}
39643964

3965-
private static RedisValue GetLexRange(RedisValue value, Exclude exclude, bool isStart, Order order)
3965+
internal static RedisValue GetLexRange(RedisValue value, Exclude exclude, bool isStart, Order order)
39663966
{
3967-
if (value.IsNull)
3967+
if (value.IsNull) // open search
39683968
{
39693969
if (order == Order.Ascending) return isStart ? RedisLiterals.MinusSymbol : RedisLiterals.PlusSymbol;
39703970

3971-
return isStart ? RedisLiterals.PlusSymbol : RedisLiterals.MinusSymbol; // 24.01.2024: when descending order: Plus and Minus have to be reversed
3971+
return isStart ? RedisLiterals.PlusSymbol : RedisLiterals.MinusSymbol; // when descending order: Plus and Minus have to be reversed
39723972
}
39733973

3974-
byte[] orig = value!;
3974+
var srcLength = value.GetByteCount();
3975+
Debug.Assert(srcLength >= 0);
39753976

3976-
byte[] result = new byte[orig.Length + 1];
3977+
byte[] result = new byte[srcLength + 1];
39773978
// no defaults here; must always explicitly specify [ / (
39783979
result[0] = (exclude & (isStart ? Exclude.Start : Exclude.Stop)) == 0 ? (byte)'[' : (byte)'(';
3979-
Buffer.BlockCopy(orig, 0, result, 1, orig.Length);
3980+
int written = value.CopyTo(result.AsSpan(1));
3981+
Debug.Assert(written == srcLength, "predicted/actual length mismatch");
39803982
return result;
39813983
}
39823984

src/StackExchange.Redis/RedisValue.cs

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Buffers;
33
using System.Buffers.Text;
44
using System.ComponentModel;
5+
using System.Diagnostics;
56
using System.IO;
67
using System.Linq;
78
using System.Reflection;
@@ -838,23 +839,78 @@ private static string ToHex(ReadOnlySpan<byte> src)
838839

839840
return value._memory.ToArray();
840841
case StorageType.Int64:
841-
Span<byte> span = stackalloc byte[Format.MaxInt64TextLen + 2];
842-
int len = PhysicalConnection.WriteRaw(span, value.OverlappedValueInt64, false, 0);
843-
arr = new byte[len - 2]; // don't need the CRLF
844-
span.Slice(0, arr.Length).CopyTo(arr);
845-
return arr;
842+
Debug.Assert(Format.MaxInt64TextLen <= 24);
843+
Span<byte> span = stackalloc byte[24];
844+
int len = Format.FormatInt64(value.OverlappedValueInt64, span);
845+
return span.Slice(0, len).ToArray();
846846
case StorageType.UInt64:
847-
// we know it is a huge value - just jump straight to Utf8Formatter
848-
span = stackalloc byte[Format.MaxInt64TextLen];
847+
Debug.Assert(Format.MaxInt64TextLen <= 24);
848+
span = stackalloc byte[24];
849849
len = Format.FormatUInt64(value.OverlappedValueUInt64, span);
850-
arr = new byte[len];
851-
span.Slice(0, len).CopyTo(arr);
852-
return arr;
850+
return span.Slice(0, len).ToArray();
851+
case StorageType.Double:
852+
span = stackalloc byte[128];
853+
len = Format.FormatDouble(value.OverlappedValueDouble, span);
854+
return span.Slice(0, len).ToArray();
855+
case StorageType.String:
856+
return Encoding.UTF8.GetBytes((string)value._objectOrSentinel!);
853857
}
854858
// fallback: stringify and encode
855859
return Encoding.UTF8.GetBytes((string)value!);
856860
}
857861

862+
/// <summary>
863+
/// Gets the length of the value in bytes.
864+
/// </summary>
865+
public int GetByteCount()
866+
{
867+
switch (Type)
868+
{
869+
case StorageType.Null: return 0;
870+
case StorageType.Raw: return _memory.Length;
871+
case StorageType.String: return Encoding.UTF8.GetByteCount((string)_objectOrSentinel!);
872+
case StorageType.Int64: return Format.MeasureInt64(OverlappedValueInt64);
873+
case StorageType.UInt64: return Format.MeasureUInt64(OverlappedValueUInt64);
874+
case StorageType.Double: return Format.MeasureDouble(OverlappedValueDouble);
875+
default: return ThrowUnableToMeasure();
876+
}
877+
}
878+
879+
private int ThrowUnableToMeasure() => throw new InvalidOperationException("Unable to compute length of type: " + Type);
880+
881+
/// <summary>
882+
/// Gets the length of the value in bytes.
883+
/// </summary>
884+
/* right now, we only support int lengths, but adding this now so that
885+
there are no surprises if/when we add support for discontiguous buffers */
886+
public long GetLongByteCount() => GetByteCount();
887+
888+
/// <summary>
889+
/// Copy the value as bytes to the provided <paramref name="destination"/>.
890+
/// </summary>
891+
public int CopyTo(Span<byte> destination)
892+
{
893+
switch (Type)
894+
{
895+
case StorageType.Null:
896+
return 0;
897+
case StorageType.Raw:
898+
var srcBytes = _memory.Span;
899+
srcBytes.CopyTo(destination);
900+
return srcBytes.Length;
901+
case StorageType.String:
902+
return Encoding.UTF8.GetBytes(((string)_objectOrSentinel!).AsSpan(), destination);
903+
case StorageType.Int64:
904+
return Format.FormatInt64(OverlappedValueInt64, destination);
905+
case StorageType.UInt64:
906+
return Format.FormatUInt64(OverlappedValueUInt64, destination);
907+
case StorageType.Double:
908+
return Format.FormatDouble(OverlappedValueDouble, destination);
909+
default:
910+
return ThrowUnableToMeasure();
911+
}
912+
}
913+
858914
/// <summary>
859915
/// Converts a <see cref="RedisValue"/> to a <see cref="ReadOnlyMemory{T}"/>.
860916
/// </summary>

tests/StackExchange.Redis.Tests/LexTests.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,9 @@ public async Task QueryRangeAndLengthByLex()
5151
set = db.SortedSetRangeByValue(key, "e", default(RedisValue));
5252
count = db.SortedSetLengthByValue(key, "e", default(RedisValue));
5353
Equate(set, count, "e", "f", "g");
54+
55+
set = db.SortedSetRangeByValue(key, RedisValue.Null, RedisValue.Null, Exclude.None, Order.Descending, 0, 3); // added to test Null-min- and max-param
56+
Equate(set, set.Length, "g", "f", "e");
5457
}
5558

5659
[Fact]

0 commit comments

Comments
 (0)