From 3b314391568c95aa86dd02eb47d76705786a5426 Mon Sep 17 00:00:00 2001 From: Arno Koll Date: Wed, 31 Jan 2024 14:52:48 +0100 Subject: [PATCH] New-SortedSetStartsWith-Condition --- src/StackExchange.Redis/Condition.cs | 127 +++++++++++++++++- .../PublicAPI/PublicAPI.Shipped.txt | 4 +- tests/StackExchange.Redis.Tests/LexTests.cs | 4 + .../TransactionTests.cs | 43 ++++++ 4 files changed, 173 insertions(+), 5 deletions(-) diff --git a/src/StackExchange.Redis/Condition.cs b/src/StackExchange.Redis/Condition.cs index 0dcccf59c..b60a14837 100644 --- a/src/StackExchange.Redis/Condition.cs +++ b/src/StackExchange.Redis/Condition.cs @@ -284,6 +284,20 @@ public static Condition StringNotEqual(RedisKey key, RedisValue value) /// The member the sorted set must not contain. public static Condition SortedSetNotContains(RedisKey key, RedisValue member) => new ExistsCondition(key, RedisType.SortedSet, member, false); + /// + /// Enforces that the given sorted set contains a member that ist starting with the start-sequence + /// + /// The key of the sorted set to check. + /// a byte array: the set must contain at least one member, that starts with the byte-sequence. + public static Condition SortedSetStartsWith(RedisKey key, byte[] memberStartSequence) => new StartsWithCondition(key, memberStartSequence, true); + + /// + /// Enforces that the given sorted set does not contain a member that ist starting with the start-sequence + /// + /// The key of the sorted set to check. + /// a byte array: the set must not contain any members, that start with the byte-sequence. + public static Condition SortedSetNotStartsWith(RedisKey key, byte[] memberStartSequence) => new StartsWithCondition(key, memberStartSequence, false); + /// /// Enforces that the given sorted set member must have the specified score. /// @@ -370,6 +384,9 @@ public static Message CreateMessage(Condition condition, int db, CommandFlags fl public static Message CreateMessage(Condition condition, int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value, in RedisValue value1) => new ConditionMessage(condition, db, flags, command, key, value, value1); + 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) => + new ConditionMessage(condition, db, flags, command, key, value, value1, value2, value3, value4); + [System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0071:Simplify interpolation", Justification = "Allocations (string.Concat vs. string.Format)")] protected override bool SetResultCore(PhysicalConnection connection, Message message, in RawResult result) { @@ -389,6 +406,9 @@ private class ConditionMessage : Message.CommandKeyBase public readonly Condition Condition; private readonly RedisValue value; private readonly RedisValue value1; + private readonly RedisValue value2; + private readonly RedisValue value3; + private readonly RedisValue value4; public ConditionMessage(Condition condition, int db, CommandFlags flags, RedisCommand command, in RedisKey key, in RedisValue value) : base(db, flags, command, key) @@ -403,6 +423,15 @@ public ConditionMessage(Condition condition, int db, CommandFlags flags, RedisCo this.value1 = value1; // note no assert here } + // Message with 3 or 4 values not used, therefore not implemented + 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) + : this(condition, db, flags, command, key, value, value1) + { + this.value2 = value2; // note no assert here + this.value3 = value3; // note no assert here + this.value4 = value4; // note no assert here + } + protected override void WriteImpl(PhysicalConnection physical) { if (value.IsNull) @@ -412,19 +441,25 @@ protected override void WriteImpl(PhysicalConnection physical) } else { - physical.WriteHeader(command, value1.IsNull ? 2 : 3); + physical.WriteHeader(command, value1.IsNull? 2 : value2.IsNull? 3 : value3.IsNull? 4 : value4.IsNull? 5 : 6); physical.Write(Key); physical.WriteBulkString(value); if (!value1.IsNull) - { physical.WriteBulkString(value1); - } + if (!value2.IsNull) + physical.WriteBulkString(value2); + if (!value3.IsNull) + physical.WriteBulkString(value3); + if (!value4.IsNull) + physical.WriteBulkString(value4); } } - public override int ArgCount => value.IsNull ? 1 : value1.IsNull ? 2 : 3; + public override int ArgCount => value.IsNull ? 1 : value1.IsNull ? 2 : value2.IsNull ? 3 : value3.IsNull ? 4 : value4.IsNull ? 5 : 6; } } + + internal class ExistsCondition : Condition { private readonly bool expectedResult; @@ -501,6 +536,90 @@ internal override bool TryValidate(in RawResult result, out bool value) } } + internal class StartsWithCondition : Condition + { + // only usable for RedisType.SortedSet, members of SortedSets are always byte-arrays, expectedStartValue therefore is a byte-array + // any Encoding and Conversion for the search-sequence has to be executed in calling application + // working with byte arrays should prevent any encoding within this class, that could distort the comparison + + private readonly bool expectedResult; + private readonly RedisValue expectedStartValue; + private readonly RedisKey key; + + internal override Condition MapKeys(Func map) => + new StartsWithCondition(map(key), expectedStartValue, expectedResult); + + public StartsWithCondition(in RedisKey key, in RedisValue expectedStartValue, bool expectedResult) + { + if (key.IsNull) throw new ArgumentNullException(nameof(key)); + if (expectedStartValue.IsNull) throw new ArgumentNullException(nameof(expectedStartValue)); + this.key = key; + this.expectedStartValue = expectedStartValue; // array with length 0 returns true condition + this.expectedResult = expectedResult; + } + + public override string ToString() => + (expectedStartValue.IsNull ? key.ToString() : ((string?)key) + " " + RedisType.SortedSet + " > " + expectedStartValue) + + (expectedResult ? " starts with" : " does not start with"); + + internal override void CheckCommands(CommandMap commandMap) => commandMap.AssertAvailable(RedisCommand.ZRANGEBYLEX); + + internal override IEnumerable CreateMessages(int db, IResultBox? resultBox) + { + yield return Message.Create(db, CommandFlags.None, RedisCommand.WATCH, key); + +#pragma warning disable CS8600, CS8604 // expectedStartValue is checked to be not null in Constructor and must be a byte[] because of API-parameters + var message = ConditionProcessor.CreateMessage(this, db, CommandFlags.None, RedisCommand.ZRANGEBYLEX, key, + CombineBytes(91, (byte[])expectedStartValue.Box()), "+", "LIMIT", "0", "1");// prepends '[' to startValue for inclusive search in CombineBytes +#pragma warning disable CS8600, CS8604 + message.SetSource(ConditionProcessor.Default, resultBox); + yield return message; + } + + internal override int GetHashSlot(ServerSelectionStrategy serverSelectionStrategy) => serverSelectionStrategy.HashSlot(key); + + internal override bool TryValidate(in RawResult result, out bool value) + { + RedisValue[]? r = result.GetItemsAsValues(); + if (result.ItemsCount == 0) value = false;// false, if empty list -> read after end of memberlist / itemsCout > 1 is impossible due to 'LIMIT 0 1' +#pragma warning disable CS8600, CS8604 // warnings on StartsWith can be ignored because of ItemsCount-check in then preceding command!! + else value = r != null && r.Length > 0 && StartsWith((byte[])r[0].Box(), expectedStartValue); +#pragma warning disable CS8600, CS8604 + +#pragma warning disable CS8602 // warning for r[0] can be ignored because of null-check in then same command-line !! + if (!expectedResult) value = !value; + ConnectionMultiplexer.TraceWithoutContext("actual: " + r == null ? "null" : r.Length == 0 ? "empty" : r[0].ToString() + + "; expected: " + expectedStartValue.ToString() + + "; wanted: " + (expectedResult ? "StartsWith" : "NotStartWith") + + "; voting: " + value); +#pragma warning restore CS8602 + return true; + } + + private static byte[] CombineBytes(byte b1, byte[] a1) // combines b1 and a1 to new array + { + byte[] newArray = new byte[a1.Length + 1]; + newArray[0] = b1; + System.Buffer.BlockCopy(a1, 0, newArray, 1, a1.Length); + return newArray; + } + + internal bool StartsWith(byte[] result, byte[] searchfor) + { + if (searchfor.Length > result.Length) return false; + + for (int i = 0; i < searchfor.Length; i++) + { + if (result[i] != searchfor[i]) return false; + } + + return true; + } + + + } + + internal class EqualsCondition : Condition { internal override Condition MapKeys(Func map) => diff --git a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt index cded72738..c5c452a9d 100644 --- a/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt +++ b/src/StackExchange.Redis/PublicAPI/PublicAPI.Shipped.txt @@ -1842,4 +1842,6 @@ StackExchange.Redis.ResultType.VerbatimString = 12 -> StackExchange.Redis.Result static StackExchange.Redis.RedisResult.Create(StackExchange.Redis.RedisResult![]! values, StackExchange.Redis.ResultType resultType) -> StackExchange.Redis.RedisResult! static StackExchange.Redis.RedisResult.Create(StackExchange.Redis.RedisValue[]! values, StackExchange.Redis.ResultType resultType) -> StackExchange.Redis.RedisResult! virtual StackExchange.Redis.RedisResult.Length.get -> int -virtual StackExchange.Redis.RedisResult.this[int index].get -> StackExchange.Redis.RedisResult! \ No newline at end of file +virtual StackExchange.Redis.RedisResult.this[int index].get -> StackExchange.Redis.RedisResult! +static StackExchange.Redis.Condition.SortedSetStartsWith(StackExchange.Redis.RedisKey key, byte[]! memberStartSequence) -> StackExchange.Redis.Condition! +static StackExchange.Redis.Condition.SortedSetNotStartsWith(StackExchange.Redis.RedisKey key, byte[]! memberStartSequence) -> StackExchange.Redis.Condition! diff --git a/tests/StackExchange.Redis.Tests/LexTests.cs b/tests/StackExchange.Redis.Tests/LexTests.cs index ace821ca6..d47eedf7e 100644 --- a/tests/StackExchange.Redis.Tests/LexTests.cs +++ b/tests/StackExchange.Redis.Tests/LexTests.cs @@ -47,12 +47,16 @@ public void QueryRangeAndLengthByLex() set = db.SortedSetRangeByValue(key, "aaa", "g", Exclude.Stop, Order.Descending, 1, 3); Equate(set, set.Length, "e", "d", "c"); + set = db.SortedSetRangeByValue(key, "g", "aaa", Exclude.Start, Order.Descending, 1, 3); Equate(set, set.Length, "e", "d", "c"); set = db.SortedSetRangeByValue(key, "e", default(RedisValue)); count = db.SortedSetLengthByValue(key, "e", default(RedisValue)); Equate(set, count, "e", "f", "g"); + + set = db.SortedSetRangeByValue(key, RedisValue.Null, RedisValue.Null, Exclude.None, Order.Descending, 0, 3); // added to test Null-min- and max-param + Equate(set, set.Length, "g", "f", "e"); } [Fact] diff --git a/tests/StackExchange.Redis.Tests/TransactionTests.cs b/tests/StackExchange.Redis.Tests/TransactionTests.cs index ac67961be..e5e1927f6 100644 --- a/tests/StackExchange.Redis.Tests/TransactionTests.cs +++ b/tests/StackExchange.Redis.Tests/TransactionTests.cs @@ -816,6 +816,49 @@ public async Task BasicTranWithSortedSetContainsCondition(bool demandKeyExists, } } + + [Theory] + [InlineData(false, false, true)] + [InlineData(false, true, false)] + [InlineData(true, false, false)] + [InlineData(true, true, true)] + public async Task BasicTranWithSortedSetStartsWithCondition(bool demandKeyExists, bool keyExists, bool expectTranResult) + { + using var conn = Create(disabledCommands: new[] { "info", "config" }); + + RedisKey key = Me(), key2 = Me() + "2"; + var db = conn.GetDatabase(); + db.KeyDelete(key, CommandFlags.FireAndForget); + db.KeyDelete(key2, CommandFlags.FireAndForget); + RedisValue member = "value"; + byte[] startWith = new byte[] { 118, 97, 108 }; // = "val" + if (keyExists) db.SortedSetAdd(key2, member, 0.0, flags: CommandFlags.FireAndForget); + Assert.False(db.KeyExists(key)); + Assert.Equal(keyExists, db.SortedSetScore(key2, member).HasValue); + + var tran = db.CreateTransaction(); + var cond = tran.AddCondition(demandKeyExists ? Condition.SortedSetStartsWith(key2, startWith) : Condition.SortedSetNotStartsWith(key2, startWith)); + var incr = tran.StringIncrementAsync(key); + var exec = tran.ExecuteAsync(); + var get = db.StringGet(key); + + Assert.Equal(expectTranResult, await exec); + if (demandKeyExists == keyExists) + { + Assert.True(await exec, "eq: exec"); + Assert.True(cond.WasSatisfied, "eq: was satisfied"); + Assert.Equal(1, await incr); // eq: incr + Assert.Equal(1, (long)get); // eq: get + } + else + { + Assert.False(await exec, "neq: exec"); + Assert.False(cond.WasSatisfied, "neq: was satisfied"); + Assert.Equal(TaskStatus.Canceled, SafeStatus(incr)); // neq: incr + Assert.Equal(0, (long)get); // neq: get + } + } + [Theory] [InlineData(4D, 4D, true, true)] [InlineData(4D, 5D, true, false)]