diff --git a/libs/resources/RespCommandsDocs.json b/libs/resources/RespCommandsDocs.json index 1c6b34e89f..55cd618133 100644 --- a/libs/resources/RespCommandsDocs.json +++ b/libs/resources/RespCommandsDocs.json @@ -6040,6 +6040,92 @@ } ] }, + { + "Command": "ZRANGESTORE", + "Name": "ZRANGESTORE", + "Summary": "Stores a range of members from sorted set in a key.", + "Group": "SortedSet", + "Complexity": "O(log(N)\u002BM) with N being the number of elements in the sorted set and M the number of elements stored into the destination key.", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "DST", + "DisplayText": "dst", + "Type": "Key", + "KeySpecIndex": 0 + }, + { + "TypeDiscriminator": "RespCommandKeyArgument", + "Name": "SRC", + "DisplayText": "src", + "Type": "Key", + "KeySpecIndex": 1 + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MIN", + "DisplayText": "min", + "Type": "String" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "MAX", + "DisplayText": "max", + "Type": "String" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "SORTBY", + "Type": "OneOf", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "BYSCORE", + "DisplayText": "byscore", + "Type": "PureToken", + "Token": "BYSCORE" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "BYLEX", + "DisplayText": "bylex", + "Type": "PureToken", + "Token": "BYLEX" + } + ] + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "REV", + "DisplayText": "rev", + "Type": "PureToken", + "Token": "REV", + "ArgumentFlags": "Optional" + }, + { + "TypeDiscriminator": "RespCommandContainerArgument", + "Name": "LIMIT", + "Type": "Block", + "Token": "LIMIT", + "ArgumentFlags": "Optional", + "Arguments": [ + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "OFFSET", + "DisplayText": "offset", + "Type": "Integer" + }, + { + "TypeDiscriminator": "RespCommandBasicArgument", + "Name": "COUNT", + "DisplayText": "count", + "Type": "Integer" + } + ] + } + ] + }, { "Command": "ZRANK", "Name": "ZRANK", diff --git a/libs/resources/RespCommandsInfo.json b/libs/resources/RespCommandsInfo.json index ccaa73b7d2..583ae9ec6a 100644 --- a/libs/resources/RespCommandsInfo.json +++ b/libs/resources/RespCommandsInfo.json @@ -4453,6 +4453,44 @@ } ] }, + { + "Command": "ZRANGESTORE", + "Name": "ZRANGESTORE", + "Arity": -5, + "Flags": "DenyOom, Write", + "FirstKey": 1, + "LastKey": 2, + "Step": 1, + "AclCategories": "SortedSet, Slow, Write", + "KeySpecifications": [ + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 1 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "OW, Update" + }, + { + "BeginSearch": { + "TypeDiscriminator": "BeginSearchIndex", + "Index": 2 + }, + "FindKeys": { + "TypeDiscriminator": "FindKeysRange", + "LastKey": 0, + "KeyStep": 1, + "Limit": 0 + }, + "Flags": "RO, Access" + } + ] + }, { "Command": "ZRANK", "Name": "ZRANK", diff --git a/libs/server/API/GarnetApiObjectCommands.cs b/libs/server/API/GarnetApiObjectCommands.cs index 86bc00a04f..927107c3e0 100644 --- a/libs/server/API/GarnetApiObjectCommands.cs +++ b/libs/server/API/GarnetApiObjectCommands.cs @@ -34,6 +34,10 @@ public GarnetStatus SortedSetAdd(ArgSlice key, (ArgSlice score, ArgSlice member) public GarnetStatus SortedSetAdd(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput output) => storageSession.SortedSetAdd(key, ref input, ref output, ref objectContext); + /// + public GarnetStatus SortedSetRangeStore(ArgSlice dstKey, ArgSlice srcKey, ref ObjectInput input, out int result) + => storageSession.SortedSetRangeStore(dstKey, srcKey, ref input, out result, ref objectContext); + /// public GarnetStatus SortedSetRemove(ArgSlice key, ArgSlice member, out int zremCount) => storageSession.SortedSetRemove(key.ToArray(), member, out zremCount, ref objectContext); diff --git a/libs/server/API/IGarnetApi.cs b/libs/server/API/IGarnetApi.cs index b1676d1391..14851c1148 100644 --- a/libs/server/API/IGarnetApi.cs +++ b/libs/server/API/IGarnetApi.cs @@ -355,6 +355,16 @@ public interface IGarnetApi : IGarnetReadApi, IGarnetAdvancedApi /// GarnetStatus SortedSetAdd(byte[] key, ref ObjectInput input, ref GarnetObjectStoreOutput output); + /// + /// Stores a range of sorted set elements in the specified key space. + /// + /// The distribution key for the sorted set. + /// The sub-key for the sorted set. + /// The input object containing the elements to store. + /// The result of the store operation. + /// A indicating the status of the operation. + GarnetStatus SortedSetRangeStore(ArgSlice dstKey, ArgSlice srcKey, ref ObjectInput input, out int result); + /// /// Removes the specified member from the sorted set stored at key. /// diff --git a/libs/server/Objects/SortedSet/SortedSetObject.cs b/libs/server/Objects/SortedSet/SortedSetObject.cs index 4042441287..ab6bf7d4e5 100644 --- a/libs/server/Objects/SortedSet/SortedSetObject.cs +++ b/libs/server/Objects/SortedSet/SortedSetObject.cs @@ -28,6 +28,7 @@ public enum SortedSetOperation : byte ZRANK, ZRANGE, ZRANGEBYSCORE, + ZRANGESTORE, GEOADD, GEOHASH, GEODIST, @@ -257,6 +258,7 @@ public override unsafe bool Operate(ref ObjectInput input, ref SpanByteAndMemory SortedSetRank(ref input, ref output); break; case SortedSetOperation.ZRANGE: + case SortedSetOperation.ZRANGESTORE: SortedSetRange(ref input, ref output); break; case SortedSetOperation.ZRANGEBYSCORE: diff --git a/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs b/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs index 13ab47eeae..b1480f455e 100644 --- a/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs +++ b/libs/server/Objects/SortedSet/SortedSetObjectImpl.cs @@ -435,6 +435,9 @@ private void SortedSetRange(ref ObjectInput input, ref SpanByteAndMemory output) ZRangeOptions options = new(); switch (input.header.SortedSetOp) { + case SortedSetOperation.ZRANGESTORE: + options.WithScores = true; + break; case SortedSetOperation.ZRANGEBYSCORE: options.ByScore = true; break; diff --git a/libs/server/Resp/Objects/SortedSetCommands.cs b/libs/server/Resp/Objects/SortedSetCommands.cs index 09fc435351..4c7c7f60b5 100644 --- a/libs/server/Resp/Objects/SortedSetCommands.cs +++ b/libs/server/Resp/Objects/SortedSetCommands.cs @@ -194,6 +194,38 @@ private unsafe bool SortedSetRange(RespCommand command, ref TGarnetA return true; } + private unsafe bool SortedSetRangeStore(ref TGarnetApi storageApi) + where TGarnetApi : IGarnetApi + { + // ZRANGESTORE dst src min max [BYSCORE | BYLEX] [REV] [LIMIT offset count] + if (parseState.Count is < 4 or > 9) + { + return AbortWithWrongNumberOfArguments(nameof(RespCommand.ZRANGESTORE)); + } + + var dstKey = parseState.GetArgSliceByRef(0); + var srcKey = parseState.GetArgSliceByRef(1); + + var header = new RespInputHeader(GarnetObjectType.SortedSet) { SortedSetOp = SortedSetOperation.ZRANGESTORE }; + var input = new ObjectInput(header, ref parseState, startIdx: 2, arg1: respProtocolVersion); + + var status = storageApi.SortedSetRangeStore(dstKey, srcKey, ref input, out int result); + + switch (status) + { + case GarnetStatus.OK: + while (!RespWriteUtils.WriteInteger(result, ref dcurr, dend)) + SendAndReset(); + break; + case GarnetStatus.WRONGTYPE: + while (!RespWriteUtils.WriteError(CmdStrings.RESP_ERR_WRONG_TYPE, ref dcurr, dend)) + SendAndReset(); + break; + } + + return true; + } + /// /// Returns the score of member in the sorted set at key. /// If member does not exist in the sorted set, or key does not exist, nil is returned. diff --git a/libs/server/Resp/Parser/RespCommand.cs b/libs/server/Resp/Parser/RespCommand.cs index 336c7d821c..c01ce43bae 100644 --- a/libs/server/Resp/Parser/RespCommand.cs +++ b/libs/server/Resp/Parser/RespCommand.cs @@ -162,6 +162,7 @@ public enum RespCommand : ushort ZINCRBY, ZPOPMAX, ZPOPMIN, + ZRANGESTORE, ZREM, ZREMRANGEBYLEX, ZREMRANGEBYRANK, @@ -1418,6 +1419,10 @@ private RespCommand FastParseArrayCommand(ref int count, ref ReadOnlySpan { return RespCommand.INCRBYFLOAT; } + else if (*(ulong*)(ptr + 2) == MemoryMarshal.Read("1\r\nZRANG"u8) && *(ulong*)(ptr + 10) == MemoryMarshal.Read("ESTORE\r\n"u8)) + { + return RespCommand.ZRANGESTORE; + } break; case 12: diff --git a/libs/server/Resp/RespServerSession.cs b/libs/server/Resp/RespServerSession.cs index f025e6cb3c..edccbb070d 100644 --- a/libs/server/Resp/RespServerSession.cs +++ b/libs/server/Resp/RespServerSession.cs @@ -607,6 +607,7 @@ private bool ProcessArrayCommands(RespCommand cmd, ref TGarnetApi st RespCommand.ZINCRBY => SortedSetIncrement(ref storageApi), RespCommand.ZRANK => SortedSetRank(cmd, ref storageApi), RespCommand.ZRANGE => SortedSetRange(cmd, ref storageApi), + RespCommand.ZRANGESTORE => SortedSetRangeStore(ref storageApi), RespCommand.ZRANGEBYSCORE => SortedSetRange(cmd, ref storageApi), RespCommand.ZREVRANK => SortedSetRank(cmd, ref storageApi), RespCommand.ZREMRANGEBYLEX => SortedSetLengthByValue(cmd, ref storageApi), diff --git a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs index eb517ad668..1c6df20744 100644 --- a/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs +++ b/libs/server/Storage/Session/ObjectStore/SortedSetOps.cs @@ -692,6 +692,111 @@ public GarnetStatus SortedSetAdd(byte[] key, ref ObjectInput inp where TObjectContext : ITsavoriteContext => RMWObjectStoreOperationWithOutput(key, ref input, ref objectStoreContext, ref output); + /// + /// ZRANGESTORE - Stores a range of sorted set elements into a destination key. + /// + /// The type of the object context. + /// The destination key where the range will be stored. + /// The source key from which the range will be taken. + /// The input object containing range parameters. + /// The result of the operation, indicating the number of elements stored. + /// The context of the object store. + /// Returns a GarnetStatus indicating the success or failure of the operation. + public unsafe GarnetStatus SortedSetRangeStore(ArgSlice dstKey, ArgSlice srcKey, ref ObjectInput input, out int result, ref TObjectContext objectStoreContext) + where TObjectContext : ITsavoriteContext + { + if (txnManager.ObjectStoreLockableContext.Session is null) + ThrowObjectStoreUninitializedException(); + + result = 0; + + if (dstKey.Length == 0 || srcKey.Length == 0) + return GarnetStatus.OK; + + var createTransaction = false; + + if (txnManager.state != TxnState.Running) + { + Debug.Assert(txnManager.state == TxnState.None); + createTransaction = true; + txnManager.SaveKeyEntryToLock(dstKey, true, LockType.Exclusive); + txnManager.SaveKeyEntryToLock(srcKey, true, LockType.Shared); + _ = txnManager.Run(true); + } + + // SetObject + var objectStoreLockableContext = txnManager.ObjectStoreLockableContext; + + try + { + SpanByteAndMemory rangeOutputMem = default; + var rangeOutput = new GarnetObjectStoreOutput() { spanByteAndMemory = rangeOutputMem }; + var status = SortedSetRange(srcKey.ToArray(), ref input, ref rangeOutput, ref objectStoreLockableContext); + rangeOutputMem = rangeOutput.spanByteAndMemory; + + if (status == GarnetStatus.WRONGTYPE) + { + return GarnetStatus.WRONGTYPE; + } + + if (status == GarnetStatus.NOTFOUND) + { + // Expire/Delete the destination key if the source key is not found + _ = EXPIRE(dstKey, TimeSpan.Zero, out _, StoreType.Object, ExpireOption.None, ref lockableContext, ref objectStoreLockableContext); + return GarnetStatus.OK; + } + + Debug.Assert(!rangeOutputMem.IsSpanByte, "Output should not be in SpanByte format when the status is OK"); + + var rangeOutputHandler = rangeOutputMem.Memory.Memory.Pin(); + try + { + var rangeOutPtr = (byte*)rangeOutputHandler.Pointer; + ref var currOutPtr = ref rangeOutPtr; + var endOutPtr = rangeOutPtr + rangeOutputMem.Length; + + var destinationKey = dstKey.ToArray(); + objectStoreLockableContext.Delete(ref destinationKey); + + RespReadUtils.ReadUnsignedArrayLength(out var arrayLen, ref currOutPtr, endOutPtr); + Debug.Assert(arrayLen % 2 == 0, "Should always contain element and its score"); + result = arrayLen / 2; + + if (result > 0) + { + parseState.Initialize(arrayLen); // 2 elements per pair (result * 2) + + for (int j = 0; j < result; j++) + { + // Read member/element into parse state + parseState.Read((2 * j) + 1, ref currOutPtr, endOutPtr); + // Read score into parse state + parseState.Read(2 * j, ref currOutPtr, endOutPtr); + } + + var zAddInput = new ObjectInput(new RespInputHeader + { + type = GarnetObjectType.SortedSet, + SortedSetOp = SortedSetOperation.ZADD, + }, ref parseState); + + var zAddOutput = new GarnetObjectStoreOutput { spanByteAndMemory = new SpanByteAndMemory(null) }; + RMWObjectStoreOperationWithOutput(destinationKey, ref zAddInput, ref objectStoreLockableContext, ref zAddOutput); + } + } + finally + { + rangeOutputHandler.Dispose(); + } + return status; + } + finally + { + if (createTransaction) + txnManager.Commit(true); + } + } + /// /// Removes the specified members from the sorted set stored at key. /// Non existing members are ignored. diff --git a/playground/CommandInfoUpdater/SupportedCommand.cs b/playground/CommandInfoUpdater/SupportedCommand.cs index f57a69e4d3..ddd6f5172d 100644 --- a/playground/CommandInfoUpdater/SupportedCommand.cs +++ b/playground/CommandInfoUpdater/SupportedCommand.cs @@ -275,6 +275,7 @@ public class SupportedCommand new("ZRANDMEMBER", RespCommand.ZRANDMEMBER), new("ZRANGE", RespCommand.ZRANGE), new("ZRANGEBYSCORE", RespCommand.ZRANGEBYSCORE), + new("ZRANGESTORE", RespCommand.ZRANGESTORE), new("ZRANK", RespCommand.ZRANK), new("ZREM", RespCommand.ZREM), new("ZREMRANGEBYLEX", RespCommand.ZREMRANGEBYLEX), diff --git a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs index 215d6b86ec..b52911236b 100644 --- a/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs +++ b/test/Garnet.test.cluster/RedirectTests/BaseCommand.cs @@ -1997,6 +1997,35 @@ public override ArraySegment[] SetupSingleSlotRequest() } } + internal class ZRANGESTORE : BaseCommand + { + public override bool IsArrayCommand => true; + public override bool ArrayResponse => false; + public override string Command => nameof(ZRANGESTORE); + + public override string[] GetSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + // ZRANGESTORE dst src 0 -1 + return [ssk[0], ssk[1], "0", "-1"]; + } + + public override string[] GetCrossSlotRequest() + { + var csk = GetCrossSlotKeys; + return [csk[0], csk[1], "0", "-1"]; + } + + public override ArraySegment[] SetupSingleSlotRequest() + { + var ssk = GetSingleSlotKeys; + var setup = new ArraySegment[2]; + setup[0] = new(["ZADD", ssk[1], "1", "a", "2", "b", "3", "c"]); + setup[1] = new(["ZADD", ssk[2], "4", "d", "5", "e", "6", "f"]); + return setup; + } + } + #endregion #region HashCommands diff --git a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs index 54bf2a318d..b569a0aa8b 100644 --- a/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs +++ b/test/Garnet.test.cluster/RedirectTests/ClusterSlotVerificationTests.cs @@ -97,6 +97,7 @@ public class ClusterSlotVerificationTests new ZCARD(), new ZRANGE(), new ZREVRANGEBYLEX(), + new ZRANGESTORE(), new ZSCORE(), new ZMSCORE(), new ZPOPMAX(), @@ -279,6 +280,7 @@ public virtual void OneTimeTearDown() [TestCase("ZCARD")] [TestCase("ZRANGE")] [TestCase("ZREVRANGEBYLEX")] + [TestCase("ZRANGESTORE")] [TestCase("ZSCORE")] [TestCase("ZMSCORE")] [TestCase("ZPOPMAX")] @@ -421,6 +423,7 @@ void GarnetClientSessionClusterDown(BaseCommand command) [TestCase("ZCARD")] [TestCase("ZRANGE")] [TestCase("ZREVRANGEBYLEX")] + [TestCase("ZRANGESTORE")] [TestCase("ZSCORE")] [TestCase("ZMSCORE")] [TestCase("ZPOPMAX")] @@ -573,6 +576,7 @@ void GarnetClientSessionOK(BaseCommand command) [TestCase("ZCARD")] [TestCase("ZRANGE")] [TestCase("ZREVRANGEBYLEX")] + [TestCase("ZRANGESTORE")] [TestCase("ZSCORE")] [TestCase("ZMSCORE")] [TestCase("ZPOPMAX")] @@ -717,6 +721,7 @@ void GarnetClientSessionCrossslotTest(BaseCommand command) [TestCase("ZCARD")] [TestCase("ZRANGE")] [TestCase("ZREVRANGEBYLEX")] + [TestCase("ZRANGESTORE")] [TestCase("ZSCORE")] [TestCase("ZMSCORE")] [TestCase("ZPOPMAX")] @@ -868,6 +873,7 @@ void GarnetClientSessionMOVEDTest(BaseCommand command) [TestCase("ZCARD")] [TestCase("ZRANGE")] [TestCase("ZREVRANGEBYLEX")] + [TestCase("ZRANGESTORE")] [TestCase("ZSCORE")] [TestCase("ZMSCORE")] [TestCase("ZPOPMAX")] @@ -1036,6 +1042,7 @@ void GarnetClientSessionASKTest(BaseCommand command) [TestCase("ZCARD")] [TestCase("ZRANGE")] [TestCase("ZREVRANGEBYLEX")] + [TestCase("ZRANGESTORE")] [TestCase("ZSCORE")] [TestCase("ZMSCORE")] [TestCase("ZPOPMAX")] diff --git a/test/Garnet.test/Resp/ACL/RespCommandTests.cs b/test/Garnet.test/Resp/ACL/RespCommandTests.cs index cf8f3a0311..a4f74634ef 100644 --- a/test/Garnet.test/Resp/ACL/RespCommandTests.cs +++ b/test/Garnet.test/Resp/ACL/RespCommandTests.cs @@ -5795,6 +5795,21 @@ static async Task DoZRevRangeByLexAsync(GarnetClient client) } } + [Test] + public async Task ZRangeStoreACLsAsync() + { + await CheckCommandsAsync( + "ZRANGESTORE", + [DoZRangeStoreAsync] + ); + + static async Task DoZRangeStoreAsync(GarnetClient client) + { + var val = await client.ExecuteForLongResultAsync("ZRANGESTORE", ["dkey", "key", "0", "-1"]); + ClassicAssert.AreEqual(0, val); + } + } + [Test] public async Task ZRangeByScoreACLsAsync() { diff --git a/test/Garnet.test/RespSortedSetTests.cs b/test/Garnet.test/RespSortedSetTests.cs index cb70206712..90ef72febc 100644 --- a/test/Garnet.test/RespSortedSetTests.cs +++ b/test/Garnet.test/RespSortedSetTests.cs @@ -1126,6 +1126,130 @@ public void CanDoZRevRangeByLexWithoutLimit(string min, string max, string[] exp ClassicAssert.AreEqual(expected, result); } + [Test] + [TestCase("user1:obj1", "user1:objA", new[] { "Hello", "World" }, new[] { 1.0, 2.0 }, new[] { "Hello", "World" }, new[] { 1.0, 2.0 })] // Normal case + [TestCase("user1:emptySet", "user1:objB", new string[] { }, new double[] { }, new string[] { }, new double[] { })] // Empty set + [TestCase("user1:nonExistingKey", "user1:objC", new string[] { }, new double[] { }, new string[] { }, new double[] { })] // Non-existing key + [TestCase("user1:obj2", "user1:objD", new[] { "Alpha", "Beta", "Gamma" }, new[] { 1.0, 2.0, 3.0 }, new[] { "Beta", "Gamma" }, new[] { 2.0, 3.0 }, -2, -1)] // Negative range + public void CheckSortedSetRangeStoreSE(string key, string destinationKey, string[] elements, double[] scores, string[] expectedElements, double[] expectedScores, int start = 0, int stop = -1) + { + int expectedCount = expectedElements.Length; + + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + var keyValues = elements.Zip(scores, (e, s) => new SortedSetEntry(e, s)).ToArray(); + + // Set up sorted set if elements are provided + if (keyValues.Length > 0) + { + db.SortedSetAdd(key, keyValues); + } + + var actualCount = db.SortedSetRangeAndStore(key, destinationKey, start, stop); + ClassicAssert.AreEqual(expectedCount, actualCount); + + var actualMembers = db.SortedSetRangeByScoreWithScores(destinationKey); + ClassicAssert.AreEqual(expectedCount, actualMembers.Length); + + for (int i = 0; i < expectedCount; i++) + { + ClassicAssert.AreEqual(expectedElements[i], actualMembers[i].Element.ToString()); + ClassicAssert.AreEqual(expectedScores[i], actualMembers[i].Score); + } + } + + [Test] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 2.0, 3.0, 4.0 }, "BYSCORE", "(2", "4", "", 2, new[] { "c", "d" }, new[] { 3.0, 4.0 }, Description = "ZRANGESTORE BYSCORE with exclusive min")] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 2.0, 3.0, 4.0 }, "BYSCORE", "2", "(4", "", 2, new[] { "b", "c" }, new[] { 2.0, 3.0 }, Description = "ZRANGESTORE BYSCORE with exclusive max")] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 2.0, 3.0, 4.0 }, "BYSCORE REV", "4", "1", "", 4, new[] { "a", "b", "c", "d" }, new[] { 1.0, 2.0, 3.0, 4.0 }, Description = "ZRANGESTORE BYSCORE with REV")] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 2.0, 3.0, 4.0 }, "BYSCORE", "2", "4", "LIMIT 1 1", 1, new[] { "c" }, new[] { 3.0 }, Description = "ZRANGESTORE BYSCORE with LIMIT")] + public void CheckSortedSetRangeStoreByScoreSE(string sourceKey, string destKey, string[] sourceElements, double[] sourceScores, string options, string min, string max, string limit, + int expectedCount, string[] expectedElements, double[] expectedScores) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + var entries = sourceElements.Zip(sourceScores, (e, s) => new SortedSetEntry(e, s)).ToArray(); + db.SortedSetAdd(sourceKey, entries); + + var command = $"{destKey} {sourceKey} {min} {max} {options} {limit}".Trim().Split(" "); + var result = db.Execute("ZRANGESTORE", command); + ClassicAssert.AreEqual(expectedCount, int.Parse(result.ToString())); + + var actualMembers = db.SortedSetRangeByScoreWithScores(destKey); + ClassicAssert.AreEqual(expectedElements.Length, actualMembers.Length); + + for (int i = 0; i < expectedElements.Length; i++) + { + ClassicAssert.AreEqual(expectedElements[i], actualMembers[i].Element.ToString()); + ClassicAssert.AreEqual(expectedScores[i], actualMembers[i].Score); + } + } + + [Test] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 1.0, 1.0, 1.0 }, "BYLEX", "[b", "[d", "", 3, new[] { "b", "c", "d" }, Description = "ZRANGESTORE BYLEX with inclusive range")] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 1.0, 1.0, 1.0 }, "BYLEX", "(b", "(d", "", 1, new[] { "c" }, Description = "ZRANGESTORE BYLEX with exclusive range")] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 1.0, 1.0, 1.0 }, "BYLEX REV", "[d", "[b", "", 3, new[] { "b", "c", "d" }, Description = "ZRANGESTORE BYLEX with REV")] + [TestCase("set1", "dest1", new[] { "a", "b", "c", "d" }, new[] { 1.0, 1.0, 1.0, 1.0 }, "BYLEX", "[b", "[d", "LIMIT 1 1", 1, new[] { "c" }, Description = "ZRANGESTORE BYLEX with LIMIT")] + public void CheckSortedSetRangeStoreByLexSE(string sourceKey, string destKey, string[] sourceElements, double[] sourceScores, string options, string min, string max, string limit, + int expectedCount, string[] expectedElements) + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + var entries = sourceElements.Zip(sourceScores, (e, s) => new SortedSetEntry(e, s)).ToArray(); + db.SortedSetAdd(sourceKey, entries); + + var command = $"{destKey} {sourceKey} {min} {max} {options} {limit}".Trim().Split(); + var result = db.Execute("ZRANGESTORE", command); + ClassicAssert.AreEqual(expectedCount, int.Parse(result.ToString())); + + var actualMembers = db.SortedSetRangeByScore(destKey); + ClassicAssert.AreEqual(expectedElements.Length, actualMembers.Length); + + for (int i = 0; i < expectedElements.Length; i++) + { + ClassicAssert.AreEqual(expectedElements[i], actualMembers[i].ToString()); + } + } + + [Test] + public void TestCheckSortedSetRangeStoreWithExistingDestinationKeySE() + { + using var redis = ConnectionMultiplexer.Connect(TestUtils.GetConfig()); + var db = redis.GetDatabase(0); + + var sourceKey = "sourceKey"; + var destinationKey = "destKey"; + + // Set up source sorted set + var sourceElements = new[] { "a", "b", "c", "d" }; + var sourceScores = new[] { 1.0, 2.0, 3.0, 4.0 }; + var sourceEntries = sourceElements.Zip(sourceScores, (e, s) => new SortedSetEntry(e, s)).ToArray(); + db.SortedSetAdd(sourceKey, sourceEntries); + + // Set up existing destination sorted set + db.StringSet(destinationKey, "dummy"); + + // Expected elements after range store + var expectedElements = new[] { "b", "c" }; + var expectedScores = new[] { 2.0, 3.0 }; + + var actualCount = db.SortedSetRangeAndStore(sourceKey, destinationKey, 1, 2); + + Assert.That(actualCount, Is.EqualTo(expectedElements.Length)); + + var actualMembers = db.SortedSetRangeByScoreWithScores(destinationKey); + Assert.That(actualMembers.Length, Is.EqualTo(expectedElements.Length)); + + for (int i = 0; i < expectedElements.Length; i++) + { + Assert.That(actualMembers[i].Element.ToString(), Is.EqualTo(expectedElements[i])); + Assert.That(actualMembers[i].Score, Is.EqualTo(expectedScores[i])); + } + } + #endregion #region LightClientTests @@ -1250,6 +1374,61 @@ public void CanDoZRangeByIndexLC(int bytesSent) ClassicAssert.AreEqual(expectedResponse, actualValue); } + // ZRANGEBSTORE + [Test] + [TestCase("user1:obj1", "user1:objA", new[] { "Hello", "World" }, new[] { 1.0, 2.0 }, new[] { "Hello", "World" }, new[] { 1.0, 2.0 })] // Normal case + [TestCase("user1:emptySet", "user1:objB", new string[] { }, new double[] { }, new string[] { }, new double[] { })] // Empty set + [TestCase("user1:nonExistingKey", "user1:objC", new string[] { }, new double[] { }, new string[] { }, new double[] { })] // Non-existing key + [TestCase("user1:obj2", "user1:objD", new[] { "Alpha", "Beta", "Gamma" }, new[] { 1.0, 2.0, 3.0 }, new[] { "Beta", "Gamma" }, new[] { 2.0, 3.0 }, -2, -1)] // Negative range + public void CheckSortedSetRangeStoreLC(string key, string destinationKey, string[] elements, double[] scores, string[] expectedElements, double[] expectedScores, int start = 0, int stop = -1) + { + using var lightClientRequest = TestUtils.CreateRequest(); + + // Setup initial sorted set if elements exist + if (elements.Length > 0) + { + var addCommand = $"ZADD {key} " + string.Join(" ", elements.Zip(scores, (e, s) => $"{s} {e}")); + var response = lightClientRequest.SendCommand(addCommand); + var expectedResponse = $":{elements.Length}\r\n"; + var actualValue = Encoding.ASCII.GetString(response).Substring(0, expectedResponse.Length); + ClassicAssert.AreEqual(expectedResponse, actualValue); + } + + // Execute ZRANGESTORE + var rangeStoreCommand = $"ZRANGESTORE {destinationKey} {key} {start} {stop}"; + var response2 = lightClientRequest.SendCommand(rangeStoreCommand); + var expectedResponse2 = $":{expectedElements.Length}\r\n"; + var actualValue2 = Encoding.ASCII.GetString(response2).Substring(0, expectedResponse2.Length); + ClassicAssert.AreEqual(expectedResponse2, actualValue2); + + // Verify stored result using ZRANGE + if (expectedElements.Length > 0) + { + var verifyCommand = $"ZRANGE {destinationKey} 0 -1 WITHSCORES"; + var response3 = lightClientRequest.SendCommand(verifyCommand, expectedElements.Length * 2 + 1); + var expectedItems = new List(); + expectedItems.Add($"*{expectedElements.Length * 2}"); + for (int i = 0; i < expectedElements.Length; i++) + { + expectedItems.Add($"${expectedElements[i].Length}"); + expectedItems.Add(expectedElements[i]); + expectedItems.Add($"${expectedScores[i].ToString().Length}"); + expectedItems.Add(expectedScores[i].ToString()); + } + var expectedResponse3 = string.Join("\r\n", expectedItems) + "\r\n"; + var actualValue3 = Encoding.ASCII.GetString(response3).Substring(0, expectedResponse3.Length); + ClassicAssert.AreEqual(expectedResponse3, actualValue3); + } + else + { + var verifyCommand = $"ZRANGE {destinationKey} 0 -1"; + var response3 = lightClientRequest.SendCommand(verifyCommand); + var expectedResponse3 = "*0\r\n"; + var actualValue3 = Encoding.ASCII.GetString(response3).Substring(0, expectedResponse3.Length); + ClassicAssert.AreEqual(expectedResponse3, actualValue3); + } + } + [Test] [TestCase(10)] [TestCase(50)] diff --git a/website/docs/commands/api-compatibility.md b/website/docs/commands/api-compatibility.md index 697d316f67..944a325f46 100644 --- a/website/docs/commands/api-compatibility.md +++ b/website/docs/commands/api-compatibility.md @@ -339,7 +339,7 @@ Note that this list is subject to change as we continue to expand our API comman | | [ZRANGE](data-structures.md#zrange) | ➕ | | | | [ZRANGEBYLEX](data-structures.md#zrangebylex) | ➕ | (Deprecated) | | | [ZRANGEBYSCORE](data-structures.md#zrangebyscore) | ➕ | (Deprecated) | -| | ZRANGESTORE | ➖ | | +| | [ZRANGESTORE](data-structures.md#zrangestore) | ➕ | | | | [ZRANK](data-structures.md#zrank) | ➕ | | | | [ZREM](data-structures.md#zrem) | ➕ | | | | [ZREMRANGEBYLEX](data-structures.md#zremrangebylex) | ➕ | | diff --git a/website/docs/commands/data-structures.md b/website/docs/commands/data-structures.md index 0570a87605..4eeec3aaae 100644 --- a/website/docs/commands/data-structures.md +++ b/website/docs/commands/data-structures.md @@ -1115,6 +1115,18 @@ If member does not exist in the sorted set, or **key** does not exist, nil is re --- +### ZRANGESTORE + +#### Syntax + +```bash + ZRANGESTORE dst src min max [BYSCORE|BYLEX] [REV] [LIMIT offset count] +``` + +Stores the specified range of elements in the sorted set stored at **src** into the sorted set stored at **dst**. + +--- + ## Geospatial indices ### GEOADD