From 9e05b31419bfb54d6ccd19a6a53df72ddb4fee6c Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Fri, 12 Sep 2025 09:17:53 +0000 Subject: [PATCH 1/7] Custom function and script responses Signed-off-by: Adam Fowler --- .../Custom/ScriptingCustomCommands.swift | 75 +++++++ .../Valkey/Commands/ScriptingCommands.swift | 27 +-- .../ValkeyCommandsRender.swift | 6 + .../ClientIntegrationTests.swift | 105 ---------- .../CommandIntegrationTests.swift | 192 ++++++++++++++++++ 5 files changed, 282 insertions(+), 123 deletions(-) create mode 100644 Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift create mode 100644 Tests/IntegrationTests/CommandIntegrationTests.swift diff --git a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift new file mode 100644 index 00000000..0e670f2b --- /dev/null +++ b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift @@ -0,0 +1,75 @@ +// +// 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 + +extension FUNCTION { + public typealias LOADResponse = String +} + +extension FUNCTION.LIST { + public typealias Response = [ResponseElement] + public struct ResponseElement: RESPTokenDecodable, Sendable { + public struct Script: RESPTokenDecodable, Sendable { + let name: String + let description: String? + let flags: [String] + + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let name = map["name"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let description = map["description"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let flags = map["flags"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + self.name = try String(fromRESP: name) + self.description = try String?(fromRESP: description) + self.flags = try [String](fromRESP: flags) + } + } + let libraryName: String + let engine: String + let functions: [Script] + let libraryCode: String? + + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let libraryName = map["library_name"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let engine = map["engine"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let functions = map["functions"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + 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 { + +} + +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/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift b/Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift index afb4c05c..c708fdf2 100644 --- a/Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift +++ b/Sources/_ValkeyCommandsBuilder/ValkeyCommandsRender.swift @@ -16,6 +16,9 @@ private let disableResponseCalculationCommands: Set = [ "CLUSTER MYID", "CLUSTER MYSHARDID", "CLUSTER SHARDS", + "FUNCTION LIST", + "FUNCTION LOAD", + "FUNCTION STATS", "GEODIST", "GEOPOS", "GEOSEARCH", @@ -23,6 +26,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 9a788fe2..14f7fefb 100644 --- a/Tests/IntegrationTests/ClientIntegrationTests.swift +++ b/Tests/IntegrationTests/ClientIntegrationTests.swift @@ -317,83 +317,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 { @@ -543,34 +466,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..00081d3b --- /dev/null +++ b/Tests/IntegrationTests/CommandIntegrationTests.swift @@ -0,0 +1,192 @@ +// +// 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[0].name == "valkey_swift_test_set") + #expect(library.functions[1].name == "valkey_swift_test_get") + } + } +} From 0a1d78c9e0e68f2310c060dd4c47cd4f242138cf Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Fri, 12 Sep 2025 17:02:51 +0000 Subject: [PATCH 2/7] Add testSCRIPTfunctions Signed-off-by: Adam Fowler --- .../CommandIntegrationTests.swift | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/Tests/IntegrationTests/CommandIntegrationTests.swift b/Tests/IntegrationTests/CommandIntegrationTests.swift index 00081d3b..8b3cd95f 100644 --- a/Tests/IntegrationTests/CommandIntegrationTests.swift +++ b/Tests/IntegrationTests/CommandIntegrationTests.swift @@ -187,6 +187,23 @@ struct CommandIntegratedTests { #expect(library.functions.count == 2) #expect(library.functions[0].name == "valkey_swift_test_set") #expect(library.functions[1].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]) } } } From fd17b8008dadd124ef7c3d880f83d805dc8adb8b Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Sat, 13 Sep 2025 11:15:56 +0000 Subject: [PATCH 3/7] Fix testFUNCTIONLIST Signed-off-by: Adam Fowler --- Tests/IntegrationTests/CommandIntegrationTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/IntegrationTests/CommandIntegrationTests.swift b/Tests/IntegrationTests/CommandIntegrationTests.swift index 8b3cd95f..39151ae6 100644 --- a/Tests/IntegrationTests/CommandIntegrationTests.swift +++ b/Tests/IntegrationTests/CommandIntegrationTests.swift @@ -185,8 +185,8 @@ struct CommandIntegratedTests { #expect(library.engine == "LUA") #expect(library.libraryCode?.hasPrefix("#!lua name=_valkey_swift_tests") == true) #expect(library.functions.count == 2) - #expect(library.functions[0].name == "valkey_swift_test_set") - #expect(library.functions[1].name == "valkey_swift_test_get") + #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") } From bdb93142f8776a10e4f0e9c94e7222edd5f8780f Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Sat, 13 Sep 2025 11:53:41 +0000 Subject: [PATCH 4/7] Add response override for FUNCTION STATS Signed-off-by: Adam Fowler --- .../Custom/ScriptingCustomCommands.swift | 55 ++++++++++++++++--- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift index 0e670f2b..8c3d3316 100644 --- a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift +++ b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift @@ -7,6 +7,8 @@ // // This file is autogenerated by ValkeyCommandsBuilder +import NIOCore + extension FUNCTION { public typealias LOADResponse = String } @@ -15,9 +17,9 @@ extension FUNCTION.LIST { public typealias Response = [ResponseElement] public struct ResponseElement: RESPTokenDecodable, Sendable { public struct Script: RESPTokenDecodable, Sendable { - let name: String - let description: String? - let flags: [String] + 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) @@ -29,10 +31,10 @@ extension FUNCTION.LIST { self.flags = try [String](fromRESP: flags) } } - let libraryName: String - let engine: String - let functions: [Script] - let libraryCode: String? + 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) @@ -53,7 +55,46 @@ extension FUNCTION.LOAD { } extension FUNCTION.STATS { + public struct Response: RESPTokenDecodable, Sendable { + + public struct Script: RESPTokenDecodable, Sendable { + public let name: String + public let command: [ByteBuffer] + public let duration: Duration + + public init(fromRESP token: RESPToken) throws { + let map = try [String: RESPToken](fromRESP: token) + guard let name = map["name"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let command = map["command"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let duration = map["duration_ms"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + self.name = try .init(fromRESP: name) + self.command = try .init(fromRESP: command) + self.duration = try .milliseconds(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 RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let functionCount = map["functions_count"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + 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 { + print(token.value.descriptionWith(redact: false)) + let map = try [String: RESPToken](fromRESP: token) + guard let runningScript = map["running_script"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + guard let engines = map["engines"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + self.runningScript = try .init(fromRESP: runningScript) + self.engines = try .init(fromRESP: engines) + } + } } extension SCRIPT { From 66e672a7f9bf6aed472c6720ae1e871e6794327b Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Sat, 13 Sep 2025 14:27:19 +0000 Subject: [PATCH 5/7] Add command tests for scripts Add testCommandEncodesDecodes helper function Signed-off-by: Adam Fowler --- .../Custom/ScriptingCustomCommands.swift | 1 - Tests/ValkeyTests/CommandTests.swift | 268 ++++++++++++------ 2 files changed, 183 insertions(+), 86 deletions(-) diff --git a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift index 8c3d3316..7f6bdab2 100644 --- a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift +++ b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift @@ -87,7 +87,6 @@ extension FUNCTION.STATS { public let runningScript: Script public let engines: [String: Engine] public init(fromRESP token: RESPToken) throws { - print(token.value.descriptionWith(redact: false)) let map = try [String: RESPToken](fromRESP: token) guard let runningScript = map["running_script"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } guard let engines = map["engines"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } diff --git a/Tests/ValkeyTests/CommandTests.swift b/Tests/ValkeyTests/CommandTests.swift index 1ae40ff8..92e0f630 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.duration == .milliseconds(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() + } +} From c8a0abf8af6d4333f161a77d6dca7477d8690878 Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Sat, 11 Oct 2025 08:40:26 +0100 Subject: [PATCH 6/7] Use RESPDecodeError Signed-off-by: Adam Fowler --- .../Custom/ScriptingCustomCommands.swift | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift index 7f6bdab2..0f3b5f95 100644 --- a/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift +++ b/Sources/Valkey/Commands/Custom/ScriptingCustomCommands.swift @@ -23,9 +23,9 @@ extension FUNCTION.LIST { public init(fromRESP token: RESPToken) throws { let map = try [String: RESPToken](fromRESP: token) - guard let name = map["name"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let description = map["description"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let flags = map["flags"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + 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) @@ -38,9 +38,9 @@ extension FUNCTION.LIST { public init(fromRESP token: RESPToken) throws { let map = try [String: RESPToken](fromRESP: token) - guard let libraryName = map["library_name"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let engine = map["engine"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let functions = map["functions"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + 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) @@ -60,16 +60,16 @@ extension FUNCTION.STATS { public struct Script: RESPTokenDecodable, Sendable { public let name: String public let command: [ByteBuffer] - public let duration: Duration + public let durationInMilliseconds: Double public init(fromRESP token: RESPToken) throws { let map = try [String: RESPToken](fromRESP: token) - guard let name = map["name"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let command = map["command"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let duration = map["duration_ms"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + 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.duration = try .milliseconds(Double(fromRESP: duration)) + self.durationInMilliseconds = try Double(fromRESP: duration) } } public struct Engine: RESPTokenDecodable, Sendable { @@ -78,8 +78,8 @@ extension FUNCTION.STATS { public init(fromRESP token: RESPToken) throws { let map = try [String: RESPToken](fromRESP: token) - guard let libraryCount = map["libraries_count"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let functionCount = map["functions_count"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + 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) } @@ -88,8 +88,8 @@ extension FUNCTION.STATS { 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 RESPParsingError(code: .unexpectedType, buffer: token.base) } - guard let engines = map["engines"] else { throw RESPParsingError(code: .unexpectedType, buffer: token.base) } + 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) } From 57b71f399741192bfbd979003e567a76caca258d Mon Sep 17 00:00:00 2001 From: Adam Fowler Date: Sat, 11 Oct 2025 08:47:47 +0100 Subject: [PATCH 7/7] Cleanup RESPToken output Signed-off-by: Adam Fowler --- Sources/Valkey/RESP/RESPToken.swift | 6 +++--- Tests/ValkeyTests/CommandTests.swift | 2 +- Tests/ValkeyTests/RESPTokenTests.swift | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) 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/Tests/ValkeyTests/CommandTests.swift b/Tests/ValkeyTests/CommandTests.swift index 92e0f630..54d65479 100644 --- a/Tests/ValkeyTests/CommandTests.swift +++ b/Tests/ValkeyTests/CommandTests.swift @@ -102,7 +102,7 @@ struct CommandTests { "13299520-9AF5-4FFE-83C2-38C8F801EDAD", ] ) - #expect(stats.runningScript.duration == .milliseconds(5053)) + #expect(stats.runningScript.durationInMilliseconds == 5053) let lua = try #require(stats.engines["LUA"]) #expect(lua.functionCount == 8) #expect(lua.libraryCount == 3) 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