diff --git a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift new file mode 100644 index 00000000..0f3b5f95 --- /dev/null +++ b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift @@ -0,0 +1,115 @@ +// +// This source file is part of the valkey-swift project +// Copyright (c) 2025 the valkey-swift project authors +// +// See LICENSE.txt for license information +// SPDX-License-Identifier: Apache-2.0 +// +// This file is autogenerated by ValkeyCommandsBuilder + +import NIOCore + +extension FUNCTION { + public typealias LOADResponse = String +} + +extension FUNCTION.LIST { + public typealias Response = [ResponseElement] + public struct ResponseElement: RESPTokenDecodable, Sendable { + public struct Script: RESPTokenDecodable, Sendable { + public let name: String + public let description: String? + public let flags: [String] + + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let name = map["name"] else { throw RESPDecodeError.missingToken(key: "name", token: token) } + guard let description = map["description"] else { throw RESPDecodeError.missingToken(key: "description", token: token) } + guard let flags = map["flags"] else { throw RESPDecodeError.missingToken(key: "flags", token: token) } + self.name = try String(fromRESP: name) + self.description = try String?(fromRESP: description) + self.flags = try [String](fromRESP: flags) + } + } + public let libraryName: String + public let engine: String + public let functions: [Script] + public let libraryCode: String? + + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let libraryName = map["library_name"] else { throw RESPDecodeError.missingToken(key: "library_name", token: token) } + guard let engine = map["engine"] else { throw RESPDecodeError.missingToken(key: "engine", token: token) } + guard let functions = map["functions"] else { throw RESPDecodeError.missingToken(key: "functions", token: token) } + let libraryCode = map["library_code"] + self.libraryName = try String(fromRESP: libraryName) + self.engine = try String(fromRESP: engine) + self.functions = try [Script](fromRESP: functions) + self.libraryCode = try libraryCode.map { try String(fromRESP: $0) } + } + } +} + +extension FUNCTION.LOAD { + public typealias Response = FUNCTION.LOADResponse +} + +extension FUNCTION.STATS { + public struct Response: RESPTokenDecodable, Sendable { + + public struct Script: RESPTokenDecodable, Sendable { + public let name: String + public let command: [ByteBuffer] + public let durationInMilliseconds: Double + + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let name = map["name"] else { throw RESPDecodeError.missingToken(key: "name", token: token) } + guard let command = map["command"] else { throw RESPDecodeError.missingToken(key: "command", token: token) } + guard let duration = map["duration_ms"] else { throw RESPDecodeError.missingToken(key: "duration_ms", token: token) } + self.name = try .init(fromRESP: name) + self.command = try .init(fromRESP: command) + self.durationInMilliseconds = try Double(fromRESP: duration) + } + } + public struct Engine: RESPTokenDecodable, Sendable { + public let libraryCount: Int + public let functionCount: Int + + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let libraryCount = map["libraries_count"] else { throw RESPDecodeError.missingToken(key: "libraries_count", token: token) } + guard let functionCount = map["functions_count"] else { throw RESPDecodeError.missingToken(key: "functions_count", token: token) } + self.libraryCount = try .init(fromRESP: libraryCount) + self.functionCount = try .init(fromRESP: functionCount) + } + } + public let runningScript: Script + public let engines: [String: Engine] + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let runningScript = map["running_script"] else { throw RESPDecodeError.missingToken(key: "running_script", token: token) } + guard let engines = map["engines"] else { throw RESPDecodeError.missingToken(key: "engines", token: token) } + self.runningScript = try .init(fromRESP: runningScript) + self.engines = try .init(fromRESP: engines) + } + } +} + +extension SCRIPT { + public typealias LOADResponse = String + public typealias EXISTSResponse = [Int] + public typealias SHOWResponse = String +} + +extension SCRIPT.LOAD { + public typealias Response = SCRIPT.LOADResponse +} + +extension SCRIPT.EXISTS { + public typealias Response = SCRIPT.EXISTSResponse +} + +extension SCRIPT.SHOW { + public typealias Response = SCRIPT.SHOWResponse +} diff --git a/Sources/Valkey/Commands/ScriptingCommands.swift b/Sources/Valkey/Commands/ScriptingCommands.swift index 8baa553c..f08b3def 100644 --- a/Sources/Valkey/Commands/ScriptingCommands.swift +++ b/Sources/Valkey/Commands/ScriptingCommands.swift @@ -111,8 +111,6 @@ public enum FUNCTION { /// Returns information about all libraries. @_documentation(visibility: internal) public struct LIST: ValkeyCommand { - public typealias Response = RESPToken.Array - @inlinable public static var name: String { "FUNCTION LIST" } public var libraryNamePattern: String? @@ -131,8 +129,6 @@ public enum FUNCTION { /// Creates a library. @_documentation(visibility: internal) public struct LOAD: ValkeyCommand { - public typealias Response = ByteBuffer - @inlinable public static var name: String { "FUNCTION LOAD" } public var replace: Bool @@ -186,8 +182,6 @@ public enum FUNCTION { /// Returns information about a function during execution. @_documentation(visibility: internal) public struct STATS: ValkeyCommand { - public typealias Response = RESPToken.Map - @inlinable public static var name: String { "FUNCTION STATS" } @inlinable public init() { @@ -239,8 +233,6 @@ public enum SCRIPT { /// Determines whether server-side Lua scripts exist in the script cache. @_documentation(visibility: internal) public struct EXISTS: ValkeyCommand { - public typealias Response = RESPToken.Array - @inlinable public static var name: String { "SCRIPT EXISTS" } public var sha1s: [Sha1] @@ -316,8 +308,6 @@ public enum SCRIPT { /// Loads a server-side Lua script to the script cache. @_documentation(visibility: internal) public struct LOAD: ValkeyCommand { - public typealias Response = ByteBuffer - @inlinable public static var name: String { "SCRIPT LOAD" } public var script: Script @@ -334,8 +324,6 @@ public enum SCRIPT { /// Show server-side Lua script in the script cache. @_documentation(visibility: internal) public struct SHOW: ValkeyCommand { - public typealias Response = ByteBuffer - @inlinable public static var name: String { "SCRIPT SHOW" } public var sha1: Sha1 @@ -621,7 +609,7 @@ extension ValkeyClientProtocol { /// - Complexity: O(N) where N is the number of functions @inlinable @discardableResult - public func functionList(libraryNamePattern: String? = nil, withcode: Bool = false) async throws -> RESPToken.Array { + public func functionList(libraryNamePattern: String? = nil, withcode: Bool = false) async throws -> FUNCTION.LIST.Response { try await execute(FUNCTION.LIST(libraryNamePattern: libraryNamePattern, withcode: withcode)) } @@ -633,7 +621,10 @@ extension ValkeyClientProtocol { /// - Response: [String]: The library name that was loaded @inlinable @discardableResult - public func functionLoad(replace: Bool = false, functionCode: FunctionCode) async throws -> ByteBuffer { + public func functionLoad( + replace: Bool = false, + functionCode: FunctionCode + ) async throws -> FUNCTION.LOADResponse { try await execute(FUNCTION.LOAD(replace: replace, functionCode: functionCode)) } @@ -657,7 +648,7 @@ extension ValkeyClientProtocol { /// - Complexity: O(1) @inlinable @discardableResult - public func functionStats() async throws -> RESPToken.Map { + public func functionStats() async throws -> FUNCTION.STATS.Response { try await execute(FUNCTION.STATS()) } @@ -679,7 +670,7 @@ extension ValkeyClientProtocol { /// - Response: [Array]: An array of integers that correspond to the specified SHA1 digest arguments. @inlinable @discardableResult - public func scriptExists(sha1s: [Sha1]) async throws -> RESPToken.Array { + public func scriptExists(sha1s: [Sha1]) async throws -> SCRIPT.EXISTSResponse { try await execute(SCRIPT.EXISTS(sha1s: sha1s)) } @@ -725,7 +716,7 @@ extension ValkeyClientProtocol { /// - Response: [String]: The SHA1 digest of the script added into the script cache @inlinable @discardableResult - public func scriptLoad(script: Script) async throws -> ByteBuffer { + public func scriptLoad(script: Script) async throws -> SCRIPT.LOADResponse { try await execute(SCRIPT.LOAD(script: script)) } @@ -737,7 +728,7 @@ extension ValkeyClientProtocol { /// - Response: [String]: Lua script if sha1 hash exists in script cache. @inlinable @discardableResult - public func scriptShow(sha1: Sha1) async throws -> ByteBuffer { + public func scriptShow(sha1: Sha1) async throws -> SCRIPT.SHOWResponse { try await execute(SCRIPT.SHOW(sha1: sha1)) } diff --git a/Sources/Valkey/RESP/RESPToken.swift b/Sources/Valkey/RESP/RESPToken.swift index 3836052f..c14a3e70 100644 --- a/Sources/Valkey/RESP/RESPToken.swift +++ b/Sources/Valkey/RESP/RESPToken.swift @@ -613,10 +613,10 @@ extension RESPToken.Value: CustomDebugStringConvertible { func descriptionWith(indent tab: String = "", childIndent childTab: String = "", redact: Bool = true) -> String { switch self { - case .simpleString(let buffer): "\(tab).simpleString(\(redact ? "\"***\"" : "\"\(String(buffer: buffer))\""))" - case .simpleError(let buffer): "\(tab).simpleError(\("\"\(String(buffer: buffer))\""))" + case .simpleString(let buffer): "\(tab).simpleString(\"\(String(buffer: buffer))\")" + case .simpleError(let buffer): "\(tab).simpleError(\"\(String(buffer: buffer))\")" case .bulkString(let buffer): "\(tab).bulkString(\(redact ? "\"***\"" : "\"\(String(buffer: buffer))\""))" - case .bulkError(let buffer): "\(tab).bulkError(\("\"\(String(buffer: buffer))\""))" + case .bulkError(let buffer): "\(tab).bulkError(\"\(String(buffer: buffer))\")" case .verbatimString(let buffer): "\(tab).verbatimString(\(redact ? "\"txt:***\"" : "\"\(String(buffer: buffer))\""))" case .number(let integer): "\(tab).number(\(integer))" case .double(let double): "\(tab).double(\(double))" diff --git a/Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift b/Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift index 1ff1665f..b3175a6d 100644 --- a/Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift +++ b/Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift @@ -18,6 +18,9 @@ private let disableResponseCalculationCommands: Set = [ "CLUSTER MYID", "CLUSTER MYSHARDID", "CLUSTER SHARDS", + "FUNCTION LIST", + "FUNCTION LOAD", + "FUNCTION STATS", "GEODIST", "GEOPOS", "GEOSEARCH", @@ -25,6 +28,9 @@ private let disableResponseCalculationCommands: Set = [ "LMOVE", "LMPOP", "SSCAN", + "SCRIPT EXISTS", + "SCRIPT LOAD", + "SCRIPT SHOW", "XAUTOCLAIM", "XCLAIM", "XPENDING", diff --git a/Tests/IntegrationTests/ClientIntegrationTests.swift b/Tests/IntegrationTests/ClientIntegrationTests.swift index 36791d56..f0555d7e 100644 --- a/Tests/IntegrationTests/ClientIntegrationTests.swift +++ b/Tests/IntegrationTests/ClientIntegrationTests.swift @@ -369,83 +369,6 @@ struct ClientIntegratedTests { } } - @Test - @available(valkeySwift 1.0, *) - func testRole() async throws { - var logger = Logger(label: "Valkey") - logger.logLevel = .debug - try await withValkeyConnection(.hostname(valkeyHostname, port: 6379), logger: logger) { connection in - let role = try await connection.role() - switch role { - case .primary: - break - case .replica, .sentinel: - Issue.record() - } - } - } - - @available(valkeySwift 1.0, *) - @Test("Array with count using LMPOP") - func testArrayWithCount() async throws { - var logger = Logger(label: "Valkey") - logger.logLevel = .trace - try await withValkeyConnection(.hostname(valkeyHostname, port: 6379), logger: logger) { connection in - try await withKey(connection: connection) { key in - try await withKey(connection: connection) { key2 in - try await connection.lpush(key, elements: ["a"]) - try await connection.lpush(key2, elements: ["b"]) - try await connection.lpush(key2, elements: ["c"]) - try await connection.lpush(key2, elements: ["d"]) - let rt1 = try await connection.lmpop(keys: [key, key2], where: .right) - let (element) = try rt1?.values.decodeElements(as: (String).self) - #expect(rt1?.key == key) - #expect(element == "a") - let rt2 = try await connection.lmpop(keys: [key, key2], where: .right) - let elements2 = try rt2?.values.decode(as: [String].self) - #expect(rt2?.key == key2) - #expect(elements2 == ["b"]) - let rt3 = try await connection.lmpop(keys: [key, key2], where: .right, count: 2) - let elements3 = try rt3?.values.decode(as: [String].self) - #expect(rt3?.key == key2) - #expect(elements3 == ["c", "d"]) - } - } - } - } - - @available(valkeySwift 1.0, *) - @Test - func testLMOVE() async throws { - var logger = Logger(label: "Valkey") - logger.logLevel = .trace - try await withValkeyConnection(.hostname(valkeyHostname, port: 6379), logger: logger) { connection in - try await withKey(connection: connection) { key in - try await withKey(connection: connection) { key2 in - let rtEmpty = try await connection.lmove(source: key, destination: key2, wherefrom: .right, whereto: .left) - #expect(rtEmpty == nil) - try await connection.lpush(key, elements: ["a"]) - try await connection.lpush(key, elements: ["b"]) - try await connection.lpush(key, elements: ["c"]) - try await connection.lpush(key, elements: ["d"]) - let list1Before = try await connection.lrange(key, start: 0, stop: -1).decode(as: [String].self) - #expect(list1Before == ["d", "c", "b", "a"]) - let list2Before = try await connection.lrange(key2, start: 0, stop: -1).decode(as: [String].self) - #expect(list2Before == []) - for expectedValue in ["a", "b", "c", "d"] { - var rt = try #require(try await connection.lmove(source: key, destination: key2, wherefrom: .right, whereto: .left)) - let value = rt.readString(length: 1) - #expect(value == expectedValue) - } - let list1After = try await connection.lrange(key, start: 0, stop: -1).decode(as: [String].self) - #expect(list1After == []) - let list2After = try await connection.lrange(key2, start: 0, stop: -1).decode(as: [String].self) - #expect(list2After == ["d", "c", "b", "a"]) - } - } - } - } - @available(valkeySwift 1.0, *) @Test("Test command error is thrown") func testCommandError() async throws { @@ -595,34 +518,6 @@ struct ClientIntegratedTests { } } - @available(valkeySwift 1.0, *) - @Test - func testGEOPOS() async throws { - var logger = Logger(label: "Valkey") - logger.logLevel = .trace - try await withValkeyConnection(.hostname(valkeyHostname, port: 6379), logger: logger) { connection in - try await withKey(connection: connection) { key in - let count = try await connection.geoadd( - key, - data: [.init(longitude: 1.0, latitude: 53.0, member: "Edinburgh"), .init(longitude: 1.4, latitude: 53.5, member: "Glasgow")] - ) - #expect(count == 2) - let search = try await connection.geosearch( - key, - from: .fromlonlat(.init(longitude: 0.0, latitude: 53.0)), - by: .circle(.init(radius: 10000, unit: .mi)), - withcoord: true, - withdist: true, - withhash: true - ) - print(search.map { $0.member }) - try print(search.map { try $0.attributes[0].decode(as: Double.self) }) - try print(search.map { try $0.attributes[1].decode(as: String.self) }) - try print(search.map { try $0.attributes[2].decode(as: GeoCoordinates.self) }) - } - } - } - @available(valkeySwift 1.0, *) @Test func testClientInfo() async throws { diff --git a/Tests/IntegrationTests/CommandIntegrationTests.swift b/Tests/IntegrationTests/CommandIntegrationTests.swift new file mode 100644 index 00000000..39151ae6 --- /dev/null +++ b/Tests/IntegrationTests/CommandIntegrationTests.swift @@ -0,0 +1,209 @@ +// +// This source file is part of the valkey-swift project +// Copyright (c) 2025 the valkey-swift project authors +// +// See LICENSE.txt for license information +// SPDX-License-Identifier: Apache-2.0 +// +import Foundation +import Logging +import NIOCore +import Testing +import Valkey + +@testable import Valkey + +@Suite("Command Integration Tests") +struct CommandIntegratedTests { + let valkeyHostname = ProcessInfo.processInfo.environment["VALKEY_HOSTNAME"] ?? "localhost" + + @available(valkeySwift 1.0, *) + func withKey(connection: some ValkeyClientProtocol, _ operation: (ValkeyKey) async throws -> Value) async throws -> Value { + let key = ValkeyKey(UUID().uuidString) + let value: Value + do { + value = try await operation(key) + } catch { + _ = try? await connection.del(keys: [key]) + throw error + } + _ = try await connection.del(keys: [key]) + return value + } + + @available(valkeySwift 1.0, *) + func withValkeyClient( + _ address: ValkeyServerAddress, + configuration: ValkeyClientConfiguration = .init(), + logger: Logger, + operation: @escaping @Sendable (ValkeyClient) async throws -> Void + ) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + let client = ValkeyClient(address, configuration: configuration, logger: logger) + group.addTask { + await client.run() + } + group.addTask { + try await operation(client) + } + try await group.next() + group.cancelAll() + } + } + + @Test + @available(valkeySwift 1.0, *) + func testRole() async throws { + var logger = Logger(label: "Valkey") + logger.logLevel = .debug + try await withValkeyClient(.hostname(valkeyHostname, port: 6379), logger: logger) { client in + let role = try await client.role() + switch role { + case .primary: + break + case .replica, .sentinel: + Issue.record() + } + } + } + + @available(valkeySwift 1.0, *) + @Test("Array with count using LMPOP") + func testArrayWithCount() async throws { + var logger = Logger(label: "Valkey") + logger.logLevel = .trace + try await withValkeyClient(.hostname(valkeyHostname, port: 6379), logger: logger) { client in + try await withKey(connection: client) { key in + try await withKey(connection: client) { key2 in + try await client.lpush(key, elements: ["a"]) + try await client.lpush(key2, elements: ["b"]) + try await client.lpush(key2, elements: ["c"]) + try await client.lpush(key2, elements: ["d"]) + let rt1 = try await client.lmpop(keys: [key, key2], where: .right) + let (element) = try rt1?.values.decodeElements(as: (String).self) + #expect(rt1?.key == key) + #expect(element == "a") + let rt2 = try await client.lmpop(keys: [key, key2], where: .right) + let elements2 = try rt2?.values.decode(as: [String].self) + #expect(rt2?.key == key2) + #expect(elements2 == ["b"]) + let rt3 = try await client.lmpop(keys: [key, key2], where: .right, count: 2) + let elements3 = try rt3?.values.decode(as: [String].self) + #expect(rt3?.key == key2) + #expect(elements3 == ["c", "d"]) + } + } + } + } + + @available(valkeySwift 1.0, *) + @Test + func testLMOVE() async throws { + var logger = Logger(label: "Valkey") + logger.logLevel = .trace + try await withValkeyClient(.hostname(valkeyHostname, port: 6379), logger: logger) { client in + try await withKey(connection: client) { key in + try await withKey(connection: client) { key2 in + let rtEmpty = try await client.lmove(source: key, destination: key2, wherefrom: .right, whereto: .left) + #expect(rtEmpty == nil) + try await client.lpush(key, elements: ["a"]) + try await client.lpush(key, elements: ["b"]) + try await client.lpush(key, elements: ["c"]) + try await client.lpush(key, elements: ["d"]) + let list1Before = try await client.lrange(key, start: 0, stop: -1).decode(as: [String].self) + #expect(list1Before == ["d", "c", "b", "a"]) + let list2Before = try await client.lrange(key2, start: 0, stop: -1).decode(as: [String].self) + #expect(list2Before == []) + for expectedValue in ["a", "b", "c", "d"] { + var rt = try #require(try await client.lmove(source: key, destination: key2, wherefrom: .right, whereto: .left)) + let value = rt.readString(length: 1) + #expect(value == expectedValue) + } + let list1After = try await client.lrange(key, start: 0, stop: -1).decode(as: [String].self) + #expect(list1After == []) + let list2After = try await client.lrange(key2, start: 0, stop: -1).decode(as: [String].self) + #expect(list2After == ["d", "c", "b", "a"]) + } + } + } + } + + @available(valkeySwift 1.0, *) + @Test + func testGEOPOS() async throws { + var logger = Logger(label: "Valkey") + logger.logLevel = .trace + try await withValkeyClient(.hostname(valkeyHostname, port: 6379), logger: logger) { client in + try await withKey(connection: client) { key in + let count = try await client.geoadd( + key, + data: [.init(longitude: 1.0, latitude: 53.0, member: "Edinburgh"), .init(longitude: 1.4, latitude: 53.5, member: "Glasgow")] + ) + #expect(count == 2) + let search = try await client.geosearch( + key, + from: .fromlonlat(.init(longitude: 0.0, latitude: 53.0)), + by: .circle(.init(radius: 10000, unit: .mi)), + withcoord: true, + withdist: true, + withhash: true + ) + print(search.map { $0.member }) + try print(search.map { try $0.attributes[0].decode(as: Double.self) }) + try print(search.map { try $0.attributes[1].decode(as: String.self) }) + try print(search.map { try $0.attributes[2].decode(as: GeoCoordinates.self) }) + } + } + } + + @available(valkeySwift 1.0, *) + @Test + func testFUNCTIONLIST() async throws { + var logger = Logger(label: "Valkey") + logger.logLevel = .trace + try await withValkeyClient(.hostname(valkeyHostname, port: 6379), logger: logger) { client in + try await client.functionLoad( + replace: true, + functionCode: """ + #!lua name=_valkey_swift_tests + + local function test_get(keys, args) + return redis.call("GET", keys[1]) + end + + local function test_set(keys, args) + return redis.call("SET", keys[1], args[1]) + end + + server.register_function('valkey_swift_test_set', test_set) + server.register_function('valkey_swift_test_get', test_get) + """ + ) + let list = try await client.functionList(libraryNamePattern: "_valkey_swift_tests", withcode: true) + let library = try #require(list.first) + #expect(library.libraryName == "_valkey_swift_tests") + #expect(library.engine == "LUA") + #expect(library.libraryCode?.hasPrefix("#!lua name=_valkey_swift_tests") == true) + #expect(library.functions.count == 2) + #expect(library.functions.contains { $0.name == "valkey_swift_test_set" }) + #expect(library.functions.contains { $0.name == "valkey_swift_test_get" }) + + try await client.functionDelete(libraryName: "_valkey_swift_tests") + } + } + + @available(valkeySwift 1.0, *) + @Test + func testSCRIPTfunctions() async throws { + var logger = Logger(label: "Valkey") + logger.logLevel = .trace + try await withValkeyClient(.hostname(valkeyHostname, port: 6379), logger: logger) { client in + let sha1 = try await client.scriptLoad( + script: "return redis.call(\"GET\", KEYS[1])" + ) + let script = try await client.scriptShow(sha1: sha1) + #expect(script == "return redis.call(\"GET\", KEYS[1])") + _ = try await client.scriptExists(sha1s: [sha1]) + } + } +} diff --git a/Tests/ValkeyTests/CommandTests.swift b/Tests/ValkeyTests/CommandTests.swift index 1ae40ff8..54d65479 100644 --- a/Tests/ValkeyTests/CommandTests.swift +++ b/Tests/ValkeyTests/CommandTests.swift @@ -15,105 +15,177 @@ import Valkey /// /// Generally the commands being tested here are ones we have written custom responses for struct CommandTests { + struct ScriptCommands { + @Test + @available(valkeySwift 1.0, *) + func functionList() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FUNCTION", "LIST", "LIBRARYNAME", "_valkey_swift_tests", "WITHCODE"]), + response: .map([ + .bulkString("library_name"): .bulkString("_valkey_swift_tests"), + .bulkString("engine"): .bulkString("LUA"), + .bulkString("functions"): .array([ + .map([ + .bulkString("name"): .bulkString("valkey_swift_test_get"), + .bulkString("description"): .null, + .bulkString("flags"): .set([]), + ]), + .map([ + .bulkString("name"): .bulkString("valkey_swift_test_set"), + .bulkString("description"): .null, + .bulkString("flags"): .set([]), + ]), + ]), + .bulkString("library_code"): .bulkString( + """ + #!lua name=_valkey_swift_tests + local function test_get(keys, args) + return redis.call("GET", keys[1]) + end + local function test_set(keys, args) + return redis.call("SET", keys[1], args[1]) + end + server.register_function('valkey_swift_test_set', test_set) + server.register_function('valkey_swift_test_get', test_get)") + """ + ), + ]) + ) + ) { connection in + let list = try await connection.functionList(libraryNamePattern: "_valkey_swift_tests", withcode: true) + let library = try #require(list.first) + #expect(library.libraryName == "_valkey_swift_tests") + #expect(library.engine == "LUA") + #expect(library.libraryCode?.hasPrefix("#!lua name=_valkey_swift_tests") == true) + #expect(library.functions.count == 2) + #expect(library.functions.contains { $0.name == "valkey_swift_test_set" }) + #expect(library.functions.contains { $0.name == "valkey_swift_test_get" }) + } + } + + @Test + @available(valkeySwift 1.0, *) + func functionStats() async throws { + try await testCommandEncodesDecodes( + ( + request: .command(["FUNCTION", "STATS"]), + response: .map([ + .bulkString("running_script"): .map([ + .bulkString("name"): .bulkString("valkey_swift_infinite_loop"), + .bulkString("command"): .array([ + .bulkString("FCALL"), + .bulkString("valkey_swift_infinite_loop"), + .bulkString("2"), + .bulkString("30549BCC-6128-4C57-ACE4-ED7AC3ACFE3A"), + .bulkString("13299520-9AF5-4FFE-83C2-38C8F801EDAD"), + ]), + .bulkString("duration_ms"): .number(5053), + ]), + .bulkString("engines"): .map([ + .bulkString("LUA"): .map([ + .bulkString("libraries_count"): .number(3), + .bulkString("functions_count"): .number(8), + ]) + ]), + ]) + ) + ) { connection in + let stats = try await connection.functionStats() + #expect(stats.runningScript.name == "valkey_swift_infinite_loop") + #expect( + stats.runningScript.command.map { String(buffer: $0) } == [ + "FCALL", + "valkey_swift_infinite_loop", + "2", + "30549BCC-6128-4C57-ACE4-ED7AC3ACFE3A", + "13299520-9AF5-4FFE-83C2-38C8F801EDAD", + ] + ) + #expect(stats.runningScript.durationInMilliseconds == 5053) + let lua = try #require(stats.engines["LUA"]) + #expect(lua.functionCount == 8) + #expect(lua.libraryCount == 3) + } + } + } + struct ServerCommands { @Test @available(valkeySwift 1.0, *) func role() async throws { - let channel = NIOAsyncTestingChannel() - let logger = Logger(label: "test") - let connection = try await ValkeyConnection.setupChannelAndConnect(channel, configuration: .init(), logger: logger) - try await channel.processHello() - - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - var role = try await connection.role() - guard case .primary(let primary) = role else { - Issue.record() - return - } - #expect(primary.replicationOffset == 10) - #expect(primary.replicas.count == 2) - #expect(primary.replicas[0].ip == "127.0.0.1") - #expect(primary.replicas[0].port == 9001) - #expect(primary.replicas[0].replicationOffset == 1) - #expect(primary.replicas[1].ip == "127.0.0.1") - #expect(primary.replicas[1].port == 9002) - #expect(primary.replicas[1].replicationOffset == 6) - - role = try await connection.role() - guard case .replica(let replica) = role else { - Issue.record() - return - } - #expect(replica.primaryIP == "127.0.0.1") - #expect(replica.primaryPort == 9000) - #expect(replica.state == .connected) - #expect(replica.replicationOffset == 6) - } - group.addTask { - var outbound = try await channel.waitForOutboundWrite(as: ByteBuffer.self) - #expect(outbound == RESPToken(.command(["ROLE"])).base) - try await channel.writeInbound( - RESPToken( + try await testCommandEncodesDecodes( + ( + request: .command(["ROLE"]), + response: .array([ + .bulkString("master"), + .number(10), + .array([ .array([ - .bulkString("master"), - .number(10), - .array([ - .array([ - .bulkString("127.0.0.1"), - .bulkString("9001"), - .bulkString("1"), - ]), - .array([ - .bulkString("127.0.0.1"), - .bulkString("9002"), - .bulkString("6"), - ]), - ]), - ]) - ).base - ) - outbound = try await channel.waitForOutboundWrite(as: ByteBuffer.self) - #expect(outbound == RESPToken(.command(["ROLE"])).base) - try await channel.writeInbound( - RESPToken( + .bulkString("127.0.0.1"), + .bulkString("9001"), + .bulkString("1"), + ]), .array([ - .bulkString("slave"), .bulkString("127.0.0.1"), - .number(9000), - .bulkString("connected"), - .number(6), - ]) - ).base - ) + .bulkString("9002"), + .bulkString("6"), + ]), + ]), + ]) + ), + ( + request: .command(["ROLE"]), + response: .array([ + .bulkString("slave"), + .bulkString("127.0.0.1"), + .number(9000), + .bulkString("connected"), + .number(6), + ]) + ) + ) { connection in + var role = try await connection.role() + guard case .primary(let primary) = role else { + Issue.record() + return } - try await group.waitForAll() + #expect(primary.replicationOffset == 10) + #expect(primary.replicas.count == 2) + #expect(primary.replicas[0].ip == "127.0.0.1") + #expect(primary.replicas[0].port == 9001) + #expect(primary.replicas[0].replicationOffset == 1) + #expect(primary.replicas[1].ip == "127.0.0.1") + #expect(primary.replicas[1].port == 9002) + #expect(primary.replicas[1].replicationOffset == 6) + + role = try await connection.role() + guard case .replica(let replica) = role else { + Issue.record() + return + } + #expect(replica.primaryIP == "127.0.0.1") + #expect(replica.primaryPort == 9000) + #expect(replica.state == .connected) + #expect(replica.replicationOffset == 6) } } /// Test non-optional tokens render correctly @Test @available(valkeySwift 1.0, *) func replicaof() async throws { - let channel = NIOAsyncTestingChannel() - let logger = Logger(label: "test") - let connection = try await ValkeyConnection.setupChannelAndConnect(channel, configuration: .init(), logger: logger) - try await channel.processHello() - - try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await connection.replicaof(args: .hostPort(.init(host: "127.0.0.1", port: 18000))) - try await connection.replicaof(args: .noOne) - } - group.addTask { - var outbound = try await channel.waitForOutboundWrite(as: ByteBuffer.self) - #expect(outbound == RESPToken(.command(["REPLICAOF", "127.0.0.1", "18000"])).base) - try await channel.writeInbound(RESPToken(.simpleString("Ok")).base) - - outbound = try await channel.waitForOutboundWrite(as: ByteBuffer.self) - #expect(outbound == RESPToken(.command(["REPLICAOF", "NO", "ONE"])).base) - try await channel.writeInbound(RESPToken(.simpleString("Ok")).base) - } - try await group.waitForAll() + try await testCommandEncodesDecodes( + ( + request: .command(["REPLICAOF", "127.0.0.1", "18000"]), + response: .simpleString("Ok") + ), + ( + request: .command(["REPLICAOF", "NO", "ONE"]), + response: .simpleString("Ok") + ) + ) { connection in + try await connection.replicaof(args: .hostPort(.init(host: "127.0.0.1", port: 18000))) + try await connection.replicaof(args: .noOne) } } } @@ -831,3 +903,29 @@ struct CommandTests { } } } + +@available(valkeySwift 1.0, *) +func testCommandEncodesDecodes( + _ respValues: (request: RESP3Value, response: RESP3Value)..., + sourceLocation: SourceLocation = #_sourceLocation, + operation: @escaping @Sendable (ValkeyConnection) async throws -> Void +) async throws { + let channel = NIOAsyncTestingChannel() + let logger = Logger(label: "test") + let connection = try await ValkeyConnection.setupChannelAndConnect(channel, configuration: .init(), logger: logger) + try await channel.processHello() + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await operation(connection) + } + group.addTask { + for (request, response) in respValues { + let outbound = try await channel.waitForOutboundWrite(as: ByteBuffer.self) + #expect(outbound == RESPToken(request).base, sourceLocation: sourceLocation) + try await channel.writeInbound(RESPToken(response).base) + } + } + try await group.waitForAll() + } +} diff --git a/Tests/ValkeyTests/RESPTokenTests.swift b/Tests/ValkeyTests/RESPTokenTests.swift index 160c8280..bd418f6a 100644 --- a/Tests/ValkeyTests/RESPTokenTests.swift +++ b/Tests/ValkeyTests/RESPTokenTests.swift @@ -485,8 +485,8 @@ struct DebugDescription { @Test func testSimpleString() { - let token = RESPToken(.simpleString("test")) - #expect(token.value.debugDescription == ".simpleString(\"***\")") + let token = RESPToken(.simpleString("TEST")) + #expect(token.value.debugDescription == ".simpleString(\"TEST\")") } @Test