diff --git a/docs/ReleaseNotes.md b/docs/ReleaseNotes.md
index 59ac059c9..4b7a77c9e 100644
--- a/docs/ReleaseNotes.md
+++ b/docs/ReleaseNotes.md
@@ -7,6 +7,7 @@
- Fixes a few internal edge cases that will now throw proper errors (rather than a downstream null reference)
- Fixes inconsistencies with `null` vs. empty array returns (preferring an not-null empty array in those edge cases)
- Note: does *not* increment a major version (as these are warnings to consumers), because: they're warnings (errors are opt-in), removing obsolete types with a 3.0 rev _would_ be binary breaking (this isn't), and reving to 3.0 would cause binding redirect pain for consumers. Bumping from 2.5 to 2.6 only for this change.
+- Adds: Support for `COPY` ([#2064 by Avital-Fine](https://github.com/StackExchange/StackExchange.Redis/pull/2064))
## 2.5.61
diff --git a/src/StackExchange.Redis/Enums/RedisCommand.cs b/src/StackExchange.Redis/Enums/RedisCommand.cs
index 30a93ccf4..09d067184 100644
--- a/src/StackExchange.Redis/Enums/RedisCommand.cs
+++ b/src/StackExchange.Redis/Enums/RedisCommand.cs
@@ -20,6 +20,7 @@ internal enum RedisCommand
CLIENT,
CLUSTER,
CONFIG,
+ COPY,
DBSIZE,
DEBUG,
diff --git a/src/StackExchange.Redis/Interfaces/IDatabase.cs b/src/StackExchange.Redis/Interfaces/IDatabase.cs
index 213a7daf7..0c4c4acce 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabase.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabase.cs
@@ -472,6 +472,18 @@ public interface IDatabase : IRedis, IDatabaseAsync
/// The endpoint serving the key.
EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None);
+ ///
+ /// Copies the value from the to the specified .
+ ///
+ /// The key of the source value to copy.
+ /// The destination key to copy the source to.
+ /// The database ID to store in. If default (-1), current database is used.
+ /// Whether to overwrite an existing values at . If and the key exists, the copy will not succeed.
+ /// The flags to use for this operation.
+ /// if key was copied. if key was not copied.
+ /// https://redis.io/commands/copy
+ bool KeyCopy(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None);
+
///
/// Removes the specified key. A key is ignored if it does not exist.
/// If UNLINK is available (Redis 4.0+), it will be used.
diff --git a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
index 0a9eb3ace..81d4d55af 100644
--- a/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
+++ b/src/StackExchange.Redis/Interfaces/IDatabaseAsync.cs
@@ -448,6 +448,18 @@ public interface IDatabaseAsync : IRedisAsync
/// The endpoint serving the key.
Task IdentifyEndpointAsync(RedisKey key = default, CommandFlags flags = CommandFlags.None);
+ ///
+ /// Copies the value from the to the specified .
+ ///
+ /// The key of the source value to copy.
+ /// The destination key to copy the source to.
+ /// The database ID to store in. If default (-1), current database is used.
+ /// Whether to overwrite an existing values at . If and the key exists, the copy will not succeed.
+ /// The flags to use for this operation.
+ /// if key was copied. if key was not copied.
+ /// https://redis.io/commands/copy
+ Task KeyCopyAsync(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None);
+
///
/// Removes the specified key. A key is ignored if it does not exist.
/// If UNLINK is available (Redis 4.0+), it will be used.
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/DatabaseWrapper.cs b/src/StackExchange.Redis/KeyspaceIsolation/DatabaseWrapper.cs
index 411c86af0..e755c2d34 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/DatabaseWrapper.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/DatabaseWrapper.cs
@@ -126,6 +126,9 @@ public void HyperLogLogMerge(RedisKey destination, RedisKey first, RedisKey seco
public EndPoint? IdentifyEndpoint(RedisKey key = default, CommandFlags flags = CommandFlags.None) =>
Inner.IdentifyEndpoint(ToInner(key), flags);
+ public bool KeyCopy(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None) =>
+ Inner.KeyCopy(ToInner(sourceKey), ToInner(destinationKey), destinationDatabase, replace, flags);
+
public long KeyDelete(RedisKey[] keys, CommandFlags flags = CommandFlags.None) =>
Inner.KeyDelete(ToInner(keys), flags);
diff --git a/src/StackExchange.Redis/KeyspaceIsolation/WrapperBase.cs b/src/StackExchange.Redis/KeyspaceIsolation/WrapperBase.cs
index 97d0abd6f..2da36d504 100644
--- a/src/StackExchange.Redis/KeyspaceIsolation/WrapperBase.cs
+++ b/src/StackExchange.Redis/KeyspaceIsolation/WrapperBase.cs
@@ -135,6 +135,9 @@ public Task HyperLogLogMergeAsync(RedisKey destination, RedisKey first, RedisKey
public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None) =>
Inner.IsConnected(ToInner(key), flags);
+ public Task KeyCopyAsync(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None) =>
+ Inner.KeyCopyAsync(ToInner(sourceKey), ToInner(destinationKey), destinationDatabase, replace, flags);
+
public Task KeyDeleteAsync(RedisKey[] keys, CommandFlags flags = CommandFlags.None) =>
Inner.KeyDeleteAsync(ToInner(keys), flags);
diff --git a/src/StackExchange.Redis/PublicAPI.Shipped.txt b/src/StackExchange.Redis/PublicAPI.Shipped.txt
index 991430e81..008027d61 100644
--- a/src/StackExchange.Redis/PublicAPI.Shipped.txt
+++ b/src/StackExchange.Redis/PublicAPI.Shipped.txt
@@ -515,6 +515,7 @@ StackExchange.Redis.IDatabase.HyperLogLogLength(StackExchange.Redis.RedisKey[]!
StackExchange.Redis.IDatabase.HyperLogLogMerge(StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisKey first, StackExchange.Redis.RedisKey second, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void
StackExchange.Redis.IDatabase.HyperLogLogMerge(StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisKey[]! sourceKeys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> void
StackExchange.Redis.IDatabase.IdentifyEndpoint(StackExchange.Redis.RedisKey key = default(StackExchange.Redis.RedisKey), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Net.EndPoint?
+StackExchange.Redis.IDatabase.KeyCopy(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool
StackExchange.Redis.IDatabase.KeyDelete(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool
StackExchange.Redis.IDatabase.KeyDelete(StackExchange.Redis.RedisKey[]! keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> long
StackExchange.Redis.IDatabase.KeyDump(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> byte[]?
@@ -703,6 +704,7 @@ StackExchange.Redis.IDatabaseAsync.HyperLogLogMergeAsync(StackExchange.Redis.Red
StackExchange.Redis.IDatabaseAsync.HyperLogLogMergeAsync(StackExchange.Redis.RedisKey destination, StackExchange.Redis.RedisKey[]! sourceKeys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.IdentifyEndpointAsync(StackExchange.Redis.RedisKey key = default(StackExchange.Redis.RedisKey), StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.IsConnected(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> bool
+StackExchange.Redis.IDatabaseAsync.KeyCopyAsync(StackExchange.Redis.RedisKey sourceKey, StackExchange.Redis.RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.KeyDeleteAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.KeyDeleteAsync(StackExchange.Redis.RedisKey[]! keys, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
StackExchange.Redis.IDatabaseAsync.KeyDumpAsync(StackExchange.Redis.RedisKey key, StackExchange.Redis.CommandFlags flags = StackExchange.Redis.CommandFlags.None) -> System.Threading.Tasks.Task!
diff --git a/src/StackExchange.Redis/RedisDatabase.cs b/src/StackExchange.Redis/RedisDatabase.cs
index 3820ed747..6eb4ed0bf 100644
--- a/src/StackExchange.Redis/RedisDatabase.cs
+++ b/src/StackExchange.Redis/RedisDatabase.cs
@@ -601,6 +601,18 @@ public bool IsConnected(RedisKey key, CommandFlags flags = CommandFlags.None)
return server?.IsConnected == true;
}
+ public bool KeyCopy(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = GetCopyMessage(sourceKey, destinationKey, destinationDatabase, replace, flags);
+ return ExecuteSync(msg, ResultProcessor.Boolean);
+ }
+
+ public Task KeyCopyAsync(RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase = -1, bool replace = false, CommandFlags flags = CommandFlags.None)
+ {
+ var msg = GetCopyMessage(sourceKey, destinationKey, destinationDatabase, replace, flags);
+ return ExecuteAsync(msg, ResultProcessor.Boolean);
+ }
+
public bool KeyDelete(RedisKey key, CommandFlags flags = CommandFlags.None)
{
var cmd = GetDeleteCommand(key, flags, out var server);
@@ -2733,6 +2745,16 @@ public Task StringSetRangeAsync(RedisKey key, long offset, RedisValu
_ => throw new ArgumentException("Expiry time must be either Utc or Local", nameof(when)),
};
+ private Message GetCopyMessage(in RedisKey sourceKey, RedisKey destinationKey, int destinationDatabase, bool replace, CommandFlags flags) =>
+ destinationDatabase switch
+ {
+ < -1 => throw new ArgumentOutOfRangeException(nameof(destinationDatabase)),
+ -1 when replace => Message.Create(Database, flags, RedisCommand.COPY, sourceKey, destinationKey, RedisLiterals.REPLACE),
+ -1 => Message.Create(Database, flags, RedisCommand.COPY, sourceKey, destinationKey),
+ _ when replace => Message.Create(Database, flags, RedisCommand.COPY, sourceKey, destinationKey, RedisLiterals.DB, destinationDatabase, RedisLiterals.REPLACE),
+ _ => Message.Create(Database, flags, RedisCommand.COPY, sourceKey, destinationKey, RedisLiterals.DB, destinationDatabase),
+ };
+
private Message GetExpiryMessage(in RedisKey key, CommandFlags flags, TimeSpan? expiry, out ServerEndPoint? server)
{
TimeSpan duration;
diff --git a/src/StackExchange.Redis/RedisFeatures.cs b/src/StackExchange.Redis/RedisFeatures.cs
index e44c43660..81dc29d00 100644
--- a/src/StackExchange.Redis/RedisFeatures.cs
+++ b/src/StackExchange.Redis/RedisFeatures.cs
@@ -76,11 +76,6 @@ public RedisFeatures(Version version)
///
public bool GetDelete => Version >= v6_2_0;
- ///
- /// Does GETEX exist?
- ///
- internal bool GetEx => Version >= v6_2_0;
-
///
/// Is HSTRLEN available?
///
diff --git a/src/StackExchange.Redis/RedisLiterals.cs b/src/StackExchange.Redis/RedisLiterals.cs
index 4dc2b6657..a31fbdf25 100644
--- a/src/StackExchange.Redis/RedisLiterals.cs
+++ b/src/StackExchange.Redis/RedisLiterals.cs
@@ -1,5 +1,4 @@
using System;
-using System.Text;
namespace StackExchange.Redis
{
@@ -54,6 +53,7 @@ public static readonly RedisValue
CHANNELS = "CHANNELS",
COPY = "COPY",
COUNT = "COUNT",
+ DB = "DB",
DESC = "DESC",
DOCTOR = "DOCTOR",
EX = "EX",
diff --git a/tests/StackExchange.Redis.Tests/Copy.cs b/tests/StackExchange.Redis.Tests/Copy.cs
new file mode 100644
index 000000000..1d7c05b62
--- /dev/null
+++ b/tests/StackExchange.Redis.Tests/Copy.cs
@@ -0,0 +1,73 @@
+using System;
+using System.Threading.Tasks;
+using Xunit;
+using Xunit.Abstractions;
+
+namespace StackExchange.Redis.Tests
+{
+ [Collection(SharedConnectionFixture.Key)]
+ public class Copy : TestBase
+ {
+ public Copy(ITestOutputHelper output, SharedConnectionFixture fixture) : base (output, fixture) { }
+
+ [Fact]
+ public async Task Basic()
+ {
+ using var muxer = Create();
+ Skip.IfBelow(muxer, RedisFeatures.v6_2_0);
+
+ var db = muxer.GetDatabase();
+ var src = Me();
+ var dest = Me() + "2";
+ _ = db.KeyDelete(dest);
+
+ _ = db.StringSetAsync(src, "Heyyyyy");
+ var ke1 = db.KeyCopyAsync(src, dest).ForAwait();
+ var ku1 = db.StringGet(dest);
+ Assert.True(await ke1);
+ Assert.True(ku1.Equals("Heyyyyy"));
+ }
+
+ [Fact]
+ public async Task CrossDB()
+ {
+ using var muxer = Create();
+ Skip.IfBelow(muxer, RedisFeatures.v6_2_0);
+
+ var db = muxer.GetDatabase();
+ var dbDestId = TestConfig.GetDedicatedDB(muxer);
+ var dbDest = muxer.GetDatabase(dbDestId);
+
+ var src = Me();
+ var dest = Me() + "2";
+ dbDest.KeyDelete(dest);
+
+ _ = db.StringSetAsync(src, "Heyyyyy");
+ var ke1 = db.KeyCopyAsync(src, dest, dbDestId).ForAwait();
+ var ku1 = dbDest.StringGet(dest);
+ Assert.True(await ke1);
+ Assert.True(ku1.Equals("Heyyyyy"));
+
+ await Assert.ThrowsAsync(() => db.KeyCopyAsync(src, dest, destinationDatabase: -10));
+ }
+
+ [Fact]
+ public async Task WithReplace()
+ {
+ using var muxer = Create();
+ Skip.IfBelow(muxer, RedisFeatures.v6_2_0);
+
+ var db = muxer.GetDatabase();
+ var src = Me();
+ var dest = Me() + "2";
+ _ = db.StringSetAsync(src, "foo1");
+ _ = db.StringSetAsync(dest, "foo2");
+ var ke1 = db.KeyCopyAsync(src, dest).ForAwait();
+ var ke2 = db.KeyCopyAsync(src, dest, replace: true).ForAwait();
+ var ku1 = db.StringGet(dest);
+ Assert.False(await ke1); // Should fail when not using replace and destination key exist
+ Assert.True(await ke2);
+ Assert.True(ku1.Equals("foo1"));
+ }
+ }
+}
diff --git a/tests/StackExchange.Redis.Tests/DatabaseWrapperTests.cs b/tests/StackExchange.Redis.Tests/DatabaseWrapperTests.cs
index 061e782a9..0ddd9981b 100644
--- a/tests/StackExchange.Redis.Tests/DatabaseWrapperTests.cs
+++ b/tests/StackExchange.Redis.Tests/DatabaseWrapperTests.cs
@@ -236,6 +236,13 @@ public void IdentifyEndpoint()
mock.Verify(_ => _.IdentifyEndpoint("prefix:key", CommandFlags.None));
}
+ [Fact]
+ public void KeyCopy()
+ {
+ wrapper.KeyCopy("key", "destination", flags: CommandFlags.None);
+ mock.Verify(_ => _.KeyCopy("prefix:key", "prefix:destination", -1, false, CommandFlags.None));
+ }
+
[Fact]
public void KeyDelete_1()
{
diff --git a/tests/StackExchange.Redis.Tests/Helpers/Skip.cs b/tests/StackExchange.Redis.Tests/Helpers/Skip.cs
index 2a39c985f..b1cdffa30 100644
--- a/tests/StackExchange.Redis.Tests/Helpers/Skip.cs
+++ b/tests/StackExchange.Redis.Tests/Helpers/Skip.cs
@@ -24,6 +24,18 @@ public static void IfNoConfig(string prop, [NotNull] List? values)
}
}
+ public static void IfBelow(IConnectionMultiplexer conn, Version minVersion)
+ {
+ var serverVersion = conn.GetServer(conn.GetEndPoints()[0]).Version;
+ if (minVersion > serverVersion)
+ {
+ throw new SkipTestException($"Requires server version {minVersion}, but server is only {serverVersion}.")
+ {
+ MissingFeatures = $"Server version >= {minVersion}."
+ };
+ }
+ }
+
public static void IfMissingFeature(IConnectionMultiplexer conn, string feature, Func check)
{
var features = conn.GetServer(conn.GetEndPoints()[0]).Features;
diff --git a/tests/StackExchange.Redis.Tests/Strings.cs b/tests/StackExchange.Redis.Tests/Strings.cs
index 713402912..7ca5648c2 100644
--- a/tests/StackExchange.Redis.Tests/Strings.cs
+++ b/tests/StackExchange.Redis.Tests/Strings.cs
@@ -72,7 +72,7 @@ public async Task Set()
public async Task StringGetSetExpiryNoValue()
{
using var muxer = Create();
- Skip.IfMissingFeature(muxer, nameof(RedisFeatures.GetEx), r => r.GetEx);
+ Skip.IfBelow(muxer, RedisFeatures.v6_2_0);
var conn = muxer.GetDatabase();
var key = Me();
@@ -87,7 +87,7 @@ public async Task StringGetSetExpiryNoValue()
public async Task StringGetSetExpiryRelative()
{
using var muxer = Create();
- Skip.IfMissingFeature(muxer, nameof(RedisFeatures.GetEx), r => r.GetEx);
+ Skip.IfBelow(muxer, RedisFeatures.v6_2_0);
var conn = muxer.GetDatabase();
var key = Me();
@@ -107,7 +107,8 @@ public async Task StringGetSetExpiryRelative()
public async Task StringGetSetExpiryAbsolute()
{
using var muxer = Create();
- Skip.IfMissingFeature(muxer, nameof(RedisFeatures.GetEx), r => r.GetEx);
+ Skip.IfBelow(muxer, RedisFeatures.v6_2_0);
+
var conn = muxer.GetDatabase();
var key = Me();
conn.KeyDelete(key, CommandFlags.FireAndForget);
@@ -131,7 +132,7 @@ public async Task StringGetSetExpiryAbsolute()
public async Task StringGetSetExpiryPersist()
{
using var muxer = Create();
- Skip.IfMissingFeature(muxer, nameof(RedisFeatures.GetEx), r => r.GetEx);
+ Skip.IfBelow(muxer, RedisFeatures.v6_2_0);
var conn = muxer.GetDatabase();
var key = Me();
diff --git a/tests/StackExchange.Redis.Tests/WrapperBaseTests.cs b/tests/StackExchange.Redis.Tests/WrapperBaseTests.cs
index c747d403f..13b5fd8cd 100644
--- a/tests/StackExchange.Redis.Tests/WrapperBaseTests.cs
+++ b/tests/StackExchange.Redis.Tests/WrapperBaseTests.cs
@@ -196,6 +196,13 @@ public void IsConnected()
mock.Verify(_ => _.IsConnected("prefix:key", CommandFlags.None));
}
+ [Fact]
+ public void KeyCopyAsync()
+ {
+ wrapper.KeyCopyAsync("key", "destination", flags: CommandFlags.None);
+ mock.Verify(_ => _.KeyCopyAsync("prefix:key", "prefix:destination", -1, false, CommandFlags.None));
+ }
+
[Fact]
public void KeyDeleteAsync_1()
{