diff --git a/src/NRedisStack/Gears/GearsCommandBuilder.cs b/src/NRedisStack/Gears/GearsCommandBuilder.cs new file mode 100644 index 00000000..31d8c73c --- /dev/null +++ b/src/NRedisStack/Gears/GearsCommandBuilder.cs @@ -0,0 +1,81 @@ +using NRedisStack.RedisStackCommands; +using NRedisStack.Gears.Literals; +namespace NRedisStack +{ + + public static class GearsCommandBuilder + { + public static SerializedCommand TFunctionLoad(string libraryCode, bool replace = false, string? config = null) + { + var args = new List() { GearsArgs.LOAD }; + + if (replace) + { + args.Add(GearsArgs.REPLACE); + } + + if (config != null) + { + args.Add(GearsArgs.CONFIG); + args.Add(config); + } + args.Add(libraryCode); + return new SerializedCommand(RG.TFUNCTION, args); + } + + public static SerializedCommand TFunctionDelete(string libraryName) + { + return new SerializedCommand(RG.TFUNCTION, GearsArgs.DELETE, libraryName); + } + + public static SerializedCommand TFunctionList(bool withCode = false, int verbose = 0, string? libraryName = null) + { + var args = new List() { GearsArgs.LIST }; + + if (withCode) + { + args.Add(GearsArgs.WITHCODE); + } + + if (verbose > 0 && verbose < 4) + { + args.Add(new string('v', verbose)); + } + else if (verbose != 0) // verbose == 0 is the default so we don't need to throw an error + { + throw new ArgumentOutOfRangeException(nameof(verbose), "verbose must be between 1 and 3"); + } + + if (libraryName != null) + { + args.Add(GearsArgs.LIBRARY); + args.Add(libraryName); + } + + return new SerializedCommand(RG.TFUNCTION, args); + } + + public static SerializedCommand TFCall(string libraryName, string functionName, string[]? keys = null, string[]? args = null, bool async = false) + { + string command = async ? RG.TFCALLASYNC : RG.TFCALL; + var commandArgs = new List() {$"{libraryName}.{functionName}"}; + + if (keys != null) + { + commandArgs.Add(keys.Length); + commandArgs.AddRange(keys); + } + else + { + commandArgs.Add(0); + } + + if (args != null) + { + commandArgs.AddRange(args); + } + + return new SerializedCommand(command, commandArgs); + } + } +} diff --git a/src/NRedisStack/Gears/GearsCommands.cs b/src/NRedisStack/Gears/GearsCommands.cs new file mode 100644 index 00000000..9c0b890e --- /dev/null +++ b/src/NRedisStack/Gears/GearsCommands.cs @@ -0,0 +1,66 @@ +using StackExchange.Redis; +namespace NRedisStack +{ + + public static class GearsCommands //: GearsCommandsAsync, IGearsCommands + { + + /// + /// Load a new library to RedisGears. + /// + /// the library code. + /// a string representation of a JSON object + /// that will be provided to the library on load time, + /// for more information refer to + /// + /// library configuration + /// an optional argument, instructs RedisGears to replace the function if its already exists. + /// if everything was done correctly, Error otherwise. + /// //TODO: check this link when it's available + public static bool TFunctionLoad(this IDatabase db, string libraryCode, bool replace = false, string? config = null) + { + return db.Execute(GearsCommandBuilder.TFunctionLoad(libraryCode, replace, config)).OKtoBoolean(); + } + + /// + /// Delete a library from RedisGears. + /// + /// the name of the library to delete. + /// if the library was deleted successfully, Error otherwise. + /// //TODO: check this link when it's available + public static bool TFunctionDelete(this IDatabase db, string libraryName) + { + return db.Execute(GearsCommandBuilder.TFunctionDelete(libraryName)).OKtoBoolean(); + } + + /// + /// List the functions with additional information about each function. + /// + /// Show libraries code. + /// output verbosity level, higher number will increase verbosity level + /// specifying a library name (can be used + /// multiple times to show multiple libraries in a single command) + /// Information about the requested libraries. + /// //TODO: check this link when it's available + public static Dictionary[] TFunctionList(this IDatabase db, bool withCode = false, int verbose = 0, string? libraryName = null) + { + return db.Execute(GearsCommandBuilder.TFunctionList(withCode, verbose, libraryName)).ToDictionarys(); + } + + /// + /// Trigger a sync or async (Coroutine) function. + /// + /// The library name contains the function. + /// The function name to run. + /// keys that will be touched by the function. + /// Additional argument to pass to the function. + /// If true, Invoke an async function (Coroutine). + /// The return value from the sync & async function on error in case of failure. + /// //TODO: check this link when it's available + /// //TODO: check this link when it's available + public static RedisResult TFCall(this IDatabase db, string libraryName, string functionName, string[]? keys = null, string[]? args = null, bool async = false) + { + return db.Execute(GearsCommandBuilder.TFCall(libraryName, functionName, keys, args, async)); + } + } +} diff --git a/src/NRedisStack/Gears/GearsCommandsAsync.cs b/src/NRedisStack/Gears/GearsCommandsAsync.cs new file mode 100644 index 00000000..c0b65912 --- /dev/null +++ b/src/NRedisStack/Gears/GearsCommandsAsync.cs @@ -0,0 +1,64 @@ +using StackExchange.Redis; +namespace NRedisStack +{ + + public static class GearsCommandsAsync //: IGearsCommandsAsync + { + /// + /// Load a new library to RedisGears. + /// + /// the library code. + /// a string representation of a JSON object + /// that will be provided to the library on load time, + /// for more information refer to + /// + /// library configuration + /// an optional argument, instructs RedisGears to replace the function if its already exists. + /// if everything was done correctly, Error otherwise. + /// //TODO: add link to the command when it's available + public static async Task TFunctionLoadAsync(this IDatabase db, string libraryCode, string? config = null, bool replace = false) + { + return (await db.ExecuteAsync(GearsCommandBuilder.TFunctionLoad(libraryCode, replace, config))).OKtoBoolean(); + } + + /// + /// Delete a library from RedisGears. + /// + /// the name of the library to delete. + /// if the library was deleted successfully, Error otherwise. + /// //TODO: add link to the command when it's available + public static async Task TFunctionDeleteAsync(this IDatabase db, string libraryName) + { + return (await db.ExecuteAsync(GearsCommandBuilder.TFunctionDelete(libraryName))).OKtoBoolean(); + } + + /// + /// List the functions with additional information about each function. + /// + /// Show libraries code. + /// output verbosity level, higher number will increase verbosity level + /// specifying a library name (can be used + /// multiple times to show multiple libraries in a single command) + /// Information about the requested libraries. + /// //TODO: add link to the command when it's available + public static async Task[]> TFunctionListAsync(this IDatabase db, bool withCode = false, int verbose = 0, string? libraryName = null) + { + return (await db.ExecuteAsync(GearsCommandBuilder.TFunctionList(withCode, verbose, libraryName))).ToDictionarys(); + } + + /// + /// Invoke a sync or async (Coroutine) function. + /// + /// The library name contains the function. + /// The function name to run. + /// keys that will be touched by the function. + /// Additional argument to pass to the function. + /// If true, Invoke an async function (Coroutine). + /// The return value from the sync & async function on error in case of failure. + /// //TODO: add link to the command when it's available + public static async Task TFCallAsync(this IDatabase db, string libraryName, string functionName, string[]? keys = null, string[]? args = null, bool async = false) + { + return await db.ExecuteAsync(GearsCommandBuilder.TFCall(libraryName, functionName, keys, args, async)); + } + } +} diff --git a/src/NRedisStack/Gears/Literals/CommandArgs.cs b/src/NRedisStack/Gears/Literals/CommandArgs.cs new file mode 100644 index 00000000..4c9a44a9 --- /dev/null +++ b/src/NRedisStack/Gears/Literals/CommandArgs.cs @@ -0,0 +1,13 @@ +namespace NRedisStack.Gears.Literals +{ + internal class GearsArgs + { + public const string CONFIG = "CONFIG"; + public const string REPLACE = "REPLACE"; + public const string LOAD = "LOAD"; + public const string DELETE = "DELETE"; + public const string LIST = "LIST"; + public const string WITHCODE = "WITHCODE"; + public const string LIBRARY = "LIBRARY"; + } +} diff --git a/src/NRedisStack/Gears/Literals/Commands.cs b/src/NRedisStack/Gears/Literals/Commands.cs new file mode 100644 index 00000000..7a34dd7e --- /dev/null +++ b/src/NRedisStack/Gears/Literals/Commands.cs @@ -0,0 +1,12 @@ +namespace NRedisStack.Gears.Literals +{ + /// + /// RedisGears command literals + /// + internal class RG + { + public const string TFUNCTION = "TFUNCTION"; + public const string TFCALL = "TFCALL"; + public const string TFCALLASYNC = "TFCALLASYNC"; + } +} diff --git a/src/NRedisStack/ResponseParser.cs b/src/NRedisStack/ResponseParser.cs index a8b0fd85..7071476b 100644 --- a/src/NRedisStack/ResponseParser.cs +++ b/src/NRedisStack/ResponseParser.cs @@ -613,5 +613,17 @@ public static IEnumerable> ToHashSets(this RedisResult result) return sets; } + + public static Dictionary[] ToDictionarys(this RedisResult result) + { + var resArr = (RedisResult[])result!; + var dicts = new Dictionary[resArr.Length]; + for (int i = 0; i < resArr.Length; i++) + { + dicts[i] = resArr[i].ToDictionary(); + } + + return dicts; + } } } \ No newline at end of file diff --git a/tests/NRedisStack.Tests/Gears/GearsTests.cs b/tests/NRedisStack.Tests/Gears/GearsTests.cs new file mode 100644 index 00000000..79d7f7ae --- /dev/null +++ b/tests/NRedisStack.Tests/Gears/GearsTests.cs @@ -0,0 +1,211 @@ +using Xunit; +using StackExchange.Redis; +using Moq; + +namespace NRedisStack.Tests.Gears; + +public class GearsTests : AbstractNRedisStackTest, IDisposable +{ + Mock _mock = new Mock(); + private readonly string key = "BLOOM_TESTS"; + public GearsTests(RedisFixture redisFixture) : base(redisFixture) { } + + public void Dispose() + { + redisFixture.Redis.GetDatabase().KeyDelete(key); + } + + + [Fact] + [Trait("Category", "edge")] + public void TestTFunctionLoadDelete() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + Assert.True(db.TFunctionLoad(GenerateLibCode("lib"))); + Assert.True(db.TFunctionDelete("lib")); + } + + + [Fact] + [Trait("Category", "edge")] + public async Task TestTFunctionLoadDeleteAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + TryDeleteLib(db, "lib", "lib1", "lib2", "lib3"); + + Assert.True(await db.TFunctionLoadAsync(GenerateLibCode("lib"))); + Assert.True(await db.TFunctionDeleteAsync("lib")); + } + + [Fact] + [Trait("Category", "edge")] + public void TestTFunctionList() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + TryDeleteLib(db, "lib", "lib1", "lib2", "lib3"); + + Assert.True(db.TFunctionLoad(GenerateLibCode("lib1"))); + Assert.True(db.TFunctionLoad(GenerateLibCode("lib2"))); + Assert.True(db.TFunctionLoad(GenerateLibCode("lib3"))); + + // test error throwing: + Assert.Throws(() => db.TFunctionList(verbose: 8)); + var functions = db.TFunctionList(verbose: 1); + Assert.Equal(3, functions.Length); + + HashSet expectedNames = new HashSet { "lib1", "lib2", "lib3" }; + HashSet actualNames = new HashSet{ + functions[0]["name"].ToString()!, + functions[1]["name"].ToString()!, + functions[2]["name"].ToString()! + }; + + Assert.Equal(expectedNames, actualNames); + + + Assert.True(db.TFunctionDelete("lib1")); + Assert.True(db.TFunctionDelete("lib2")); + Assert.True(db.TFunctionDelete("lib3")); + } + + [Fact] + [Trait("Category", "edge")] + public async Task TestTFunctionListAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + TryDeleteLib(db, "lib", "lib1", "lib2", "lib3"); + + Assert.True(await db.TFunctionLoadAsync(GenerateLibCode("lib1"))); + Assert.True(await db.TFunctionLoadAsync(GenerateLibCode("lib2"))); + Assert.True(await db.TFunctionLoadAsync(GenerateLibCode("lib3"))); + + var functions = await db.TFunctionListAsync(verbose: 1); + Assert.Equal(3, functions.Length); + + HashSet expectedNames = new HashSet { "lib1", "lib2", "lib3" }; + HashSet actualNames = new HashSet{ + functions[0]["name"].ToString()!, + functions[1]["name"].ToString()!, + functions[2]["name"].ToString()! + }; + + Assert.Equal(expectedNames, actualNames); + + + Assert.True(await db.TFunctionDeleteAsync("lib1")); + Assert.True(await db.TFunctionDeleteAsync("lib2")); + Assert.True(await db.TFunctionDeleteAsync("lib3")); + } + + [Fact] + [Trait("Category", "edge")] + public void TestTFCall() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + TryDeleteLib(db, "lib", "lib1", "lib2", "lib3"); + + Assert.True(db.TFunctionLoad(GenerateLibCode("lib"))); + Assert.Equal("bar", db.TFCall("lib", "foo", async: false).ToString()); + Assert.Equal("bar", db.TFCall("lib", "foo", async: true).ToString()); + + Assert.True(db.TFunctionDelete("lib")); + } + + [Fact] + [Trait("Category", "edge")] + public async Task TestTFCallAsync() + { + IDatabase db = redisFixture.Redis.GetDatabase(); + db.Execute("FLUSHALL"); + TryDeleteLib(db, "lib", "lib1", "lib2", "lib3"); + + Assert.True(await db.TFunctionLoadAsync(GenerateLibCode("lib"))); + Assert.Equal("bar", (await db.TFCallAsync("lib", "foo", async: false)).ToString()); + Assert.Equal("bar", (await db.TFCallAsync("lib", "foo", async: true)).ToString()); + + Assert.True(await db.TFunctionDeleteAsync("lib")); + } + + [Fact] + [Trait("Category", "edge")] + public void TestGearsCommandBuilder() + { + // TFunctionLoad: + var buildCommand = GearsCommandBuilder + .TFunctionLoad(GenerateLibCode("lib"), + true, "config"); + var expected = new List + { + "LOAD", + "REPLACE", + "CONFIG", + "config", + GenerateLibCode("lib") + }; + Assert.Equal("TFUNCTION", buildCommand.Command); + Assert.Equal(expected, buildCommand.Args); + + // TFunctionDelete: + buildCommand = GearsCommandBuilder.TFunctionDelete("lib"); + expected = new List + { + "DELETE", + "lib" + }; + Assert.Equal("TFUNCTION", buildCommand.Command); + Assert.Equal(expected, buildCommand.Args); + + // TFunctionList: + buildCommand = GearsCommandBuilder.TFunctionList(true, 2, "lib"); + expected = new List + { + "LIST", + "WITHCODE", + "vv", + "LIBRARY", + "lib", + }; + Assert.Equal("TFUNCTION", buildCommand.Command); + Assert.Equal(expected, buildCommand.Args); + + // TFCall: + var buildSync = GearsCommandBuilder.TFCall("libName", "funcName", new string[] { "key1", "key2" }, new string[] { "arg1", "arg2" }, false); + var buildAsync = GearsCommandBuilder.TFCall("libName", "funcName", new string[] { "key1", "key2" }, new string[] { "arg1", "arg2" }, true); + + expected = new List + { + "libName.funcName", + 2, + "key1", + "key2", + "arg1", + "arg2" + }; + + Assert.Equal("TFCALL", buildSync.Command); + Assert.Equal(expected, buildSync.Args); + + Assert.Equal("TFCALLASYNC", buildAsync.Command); + Assert.Equal(expected, buildAsync.Args); + } + + private static void TryDeleteLib(IDatabase db, params string[] libNames) + { + try + { + foreach(var libName in libNames) + db.TFunctionDelete(libName); + } + catch (RedisServerException) { } + } + + private static string GenerateLibCode(string libName) + { + return $"#!js api_version=1.0 name={libName}\n redis.registerFunction('foo', ()=>{{return 'bar'}})"; + } +}