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() {