From 462d50ce9f776e6b5bdf8cf4a0412cdafb92ba5d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 24 Mar 2026 22:39:53 +0700 Subject: [PATCH 1/3] fix: Redis hash/list/set/zset/stream views drop non-UTF8 binary values --- .../RedisDriverPlugin/RedisPluginDriver.swift | 64 +-- .../Core/Redis/RedisResultBuildingTests.swift | 516 ++++++++++++++++++ 2 files changed, 549 insertions(+), 31 deletions(-) create mode 100644 TableProTests/Core/Redis/RedisResultBuildingTests.swift diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 3c49a897..6f7721bc 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -1237,12 +1237,12 @@ private extension RedisPluginDriver { return truncatePreview(reply.stringValue) case "hash": - let array: [String] + let array: [RedisReply] if case .array(let scanResult) = reply, scanResult.count == 2, - let items = scanResult[1].stringArrayValue { + let items = scanResult[1].arrayValue { array = items - } else if let items = reply.stringArrayValue, !items.isEmpty { + } else if let items = reply.arrayValue, !items.isEmpty { array = items } else { return "{}" @@ -1251,39 +1251,41 @@ private extension RedisPluginDriver { var pairs: [String] = [] var idx = 0 while idx + 1 < array.count { + let field = redisReplyToString(array[idx]) + let value = redisReplyToString(array[idx + 1]) pairs.append( - "\"\(escapeJsonString(array[idx]))\":\"\(escapeJsonString(array[idx + 1]))\"" + "\"\(escapeJsonString(field))\":\"\(escapeJsonString(value))\"" ) idx += 2 } return truncatePreview("{\(pairs.joined(separator: ","))}") case "list": - guard let items = reply.stringArrayValue else { return "[]" } - let quoted = items.map { "\"\(escapeJsonString($0))\"" } + guard let items = reply.arrayValue else { return "[]" } + let quoted = items.map { "\"\(escapeJsonString(redisReplyToString($0)))\"" } return truncatePreview("[\(quoted.joined(separator: ", "))]") case "set": - let members: [String] + let members: [RedisReply] if case .array(let scanResult) = reply, scanResult.count == 2, - let items = scanResult[1].stringArrayValue { + let items = scanResult[1].arrayValue { members = items - } else if let items = reply.stringArrayValue { + } else if let items = reply.arrayValue { members = items } else { return "[]" } - let quoted = members.map { "\"\(escapeJsonString($0))\"" } + let quoted = members.map { "\"\(escapeJsonString(redisReplyToString($0)))\"" } return truncatePreview("[\(quoted.joined(separator: ", "))]") case "zset": // Parse WITHSCORES result: alternating member, score pairs - guard let items = reply.stringArrayValue, !items.isEmpty else { return "[]" } + guard let items = reply.arrayValue, !items.isEmpty else { return "[]" } var pairs: [String] = [] var i = 0 while i + 1 < items.count { - pairs.append("\(items[i]):\(items[i + 1])") + pairs.append("\(redisReplyToString(items[i])):\(redisReplyToString(items[i + 1]))") i += 2 } return truncatePreview(pairs.joined(separator: ", ")) @@ -1297,13 +1299,13 @@ private extension RedisPluginDriver { for entry in entries { guard let parts = entry.arrayValue, parts.count >= 2, let entryId = parts[0].stringValue, - let fields = parts[1].stringArrayValue else { + let fields = parts[1].arrayValue else { continue } var fieldPairs: [String] = [] var j = 0 while j + 1 < fields.count { - fieldPairs.append("\(fields[j])=\(fields[j + 1])") + fieldPairs.append("\(redisReplyToString(fields[j]))=\(redisReplyToString(fields[j + 1]))") j += 2 } entryStrings.append("\(entryId): \(fieldPairs.joined(separator: ", "))") @@ -1435,7 +1437,7 @@ private extension RedisPluginDriver { } func buildHashResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - guard let array = result.stringArrayValue, !array.isEmpty else { + guard let items = result.arrayValue, !items.isEmpty else { return PluginQueryResult( columns: ["Field", "Value"], columnTypeNames: ["String", "String"], @@ -1447,8 +1449,8 @@ private extension RedisPluginDriver { var rows: [[String?]] = [] var i = 0 - while i + 1 < array.count { - rows.append([array[i], array[i + 1]]) + while i + 1 < items.count { + rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])]) i += 2 } @@ -1462,7 +1464,7 @@ private extension RedisPluginDriver { } func buildListResult(_ result: RedisReply, startOffset: Int = 0, startTime: Date) -> PluginQueryResult { - guard let array = result.stringArrayValue else { + guard let items = result.arrayValue else { return PluginQueryResult( columns: ["Index", "Value"], columnTypeNames: ["Int64", "String"], @@ -1472,8 +1474,8 @@ private extension RedisPluginDriver { ) } - let rows = array.enumerated().map { index, value -> [String?] in - [String(startOffset + index), value] + let rows = items.enumerated().map { index, item -> [String?] in + [String(startOffset + index), redisReplyToString(item)] } return PluginQueryResult( @@ -1486,7 +1488,7 @@ private extension RedisPluginDriver { } func buildSetResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - guard let array = result.stringArrayValue else { + guard let items = result.arrayValue else { return PluginQueryResult( columns: ["Member"], columnTypeNames: ["String"], @@ -1496,7 +1498,7 @@ private extension RedisPluginDriver { ) } - let rows = array.map { [$0] as [String?] } + let rows = items.map { [redisReplyToString($0)] as [String?] } return PluginQueryResult( columns: ["Member"], @@ -1508,7 +1510,7 @@ private extension RedisPluginDriver { } func buildSortedSetResult(_ result: RedisReply, withScores: Bool, startTime: Date) -> PluginQueryResult { - guard let array = result.stringArrayValue else { + guard let items = result.arrayValue else { return PluginQueryResult( columns: withScores ? ["Member", "Score"] : ["Member"], columnTypeNames: withScores ? ["String", "Double"] : ["String"], @@ -1521,8 +1523,8 @@ private extension RedisPluginDriver { if withScores { var rows: [[String?]] = [] var i = 0 - while i + 1 < array.count { - rows.append([array[i], array[i + 1]]) + while i + 1 < items.count { + rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])]) i += 2 } return PluginQueryResult( @@ -1533,7 +1535,7 @@ private extension RedisPluginDriver { executionTime: Date().timeIntervalSince(startTime) ) } else { - let rows = array.map { [$0] as [String?] } + let rows = items.map { [redisReplyToString($0)] as [String?] } return PluginQueryResult( columns: ["Member"], columnTypeNames: ["String"], @@ -1559,14 +1561,14 @@ private extension RedisPluginDriver { for entry in entries { guard let entryParts = entry.arrayValue, entryParts.count >= 2, let entryId = entryParts[0].stringValue, - let fields = entryParts[1].stringArrayValue else { + let fields = entryParts[1].arrayValue else { continue } var fieldPairs: [String] = [] var i = 0 while i + 1 < fields.count { - fieldPairs.append("\(fields[i])=\(fields[i + 1])") + fieldPairs.append("\(redisReplyToString(fields[i]))=\(redisReplyToString(fields[i + 1]))") i += 2 } rows.append([entryId, fieldPairs.joined(separator: ", ")]) @@ -1582,7 +1584,7 @@ private extension RedisPluginDriver { } func buildConfigResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - guard let array = result.stringArrayValue, !array.isEmpty else { + guard let items = result.arrayValue, !items.isEmpty else { return PluginQueryResult( columns: ["Parameter", "Value"], columnTypeNames: ["String", "String"], @@ -1594,8 +1596,8 @@ private extension RedisPluginDriver { var rows: [[String?]] = [] var i = 0 - while i + 1 < array.count { - rows.append([array[i], array[i + 1]]) + while i + 1 < items.count { + rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])]) i += 2 } diff --git a/TableProTests/Core/Redis/RedisResultBuildingTests.swift b/TableProTests/Core/Redis/RedisResultBuildingTests.swift new file mode 100644 index 00000000..bf52d50c --- /dev/null +++ b/TableProTests/Core/Redis/RedisResultBuildingTests.swift @@ -0,0 +1,516 @@ +// +// RedisResultBuildingTests.swift +// TableProTests +// +// Regression tests for the Redis build*Result methods. +// +// The original bug: build methods used `stringArrayValue` (compactMap(\.stringValue)) +// which silently dropped `.data`, `.null`, and `.integer` entries, corrupting +// alternating field/value pairs in hashes and other paired structures. +// The fix switched to `arrayValue` (raw [RedisReply]) + `redisReplyToString()`. +// +// Because RedisPluginDriver lives in a plugin bundle and cannot be @testable +// imported, we replicate the fixed logic here as private helpers. +// + +import Foundation +import Testing + +// MARK: - Private Local Helpers (copied from RedisDriverPlugin) + +private enum TestRedisReply { + case string(String) + case integer(Int64) + case array([TestRedisReply]) + case data(Data) + case status(String) + case error(String) + case null + + var stringValue: String? { + switch self { + case .string(let s), .status(let s): return s + case .data(let d): return String(data: d, encoding: .utf8) + default: return nil + } + } + + var intValue: Int? { + switch self { + case .integer(let i): return Int(i) + case .string(let s): return Int(s) + default: return nil + } + } + + var stringArrayValue: [String]? { + guard case .array(let items) = self else { return nil } + return items.compactMap(\.stringValue) + } + + var arrayValue: [TestRedisReply]? { + guard case .array(let items) = self else { return nil } + return items + } +} + +// MARK: - Fixed Logic Replicas + +/// Matches the fixed `redisReplyToString` in RedisPluginDriver. +private func testRedisReplyToString(_ reply: TestRedisReply) -> String { + switch reply { + case .string(let s), .status(let s), .error(let s): return s + case .integer(let i): return String(i) + case .data(let d): return String(data: d, encoding: .utf8) ?? d.base64EncodedString() + case .array(let items): return "[\(items.map { testRedisReplyToString($0) }.joined(separator: ", "))]" + case .null: return "(nil)" + } +} + +/// Result type mirroring the relevant fields of PluginQueryResult. +private struct TestResult { + let columns: [String] + let rows: [[String?]] +} + +private func buildTestHashResult(_ result: TestRedisReply) -> TestResult { + guard let items = result.arrayValue, !items.isEmpty else { + return TestResult(columns: ["Field", "Value"], rows: []) + } + + var rows: [[String?]] = [] + var i = 0 + while i + 1 < items.count { + rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])]) + i += 2 + } + + return TestResult(columns: ["Field", "Value"], rows: rows) +} + +private func buildTestListResult(_ result: TestRedisReply, startOffset: Int = 0) -> TestResult { + guard let items = result.arrayValue else { + return TestResult(columns: ["Index", "Value"], rows: []) + } + + let rows = items.enumerated().map { index, item -> [String?] in + [String(startOffset + index), testRedisReplyToString(item)] + } + + return TestResult(columns: ["Index", "Value"], rows: rows) +} + +private func buildTestSetResult(_ result: TestRedisReply) -> TestResult { + guard let items = result.arrayValue else { + return TestResult(columns: ["Member"], rows: []) + } + + let rows = items.map { [testRedisReplyToString($0)] as [String?] } + return TestResult(columns: ["Member"], rows: rows) +} + +private func buildTestSortedSetResult(_ result: TestRedisReply, withScores: Bool) -> TestResult { + guard let items = result.arrayValue else { + return TestResult( + columns: withScores ? ["Member", "Score"] : ["Member"], + rows: [] + ) + } + + if withScores { + var rows: [[String?]] = [] + var i = 0 + while i + 1 < items.count { + rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])]) + i += 2 + } + return TestResult(columns: ["Member", "Score"], rows: rows) + } else { + let rows = items.map { [testRedisReplyToString($0)] as [String?] } + return TestResult(columns: ["Member"], rows: rows) + } +} + +private func buildTestConfigResult(_ result: TestRedisReply) -> TestResult { + guard let items = result.arrayValue, !items.isEmpty else { + return TestResult(columns: ["Parameter", "Value"], rows: []) + } + + var rows: [[String?]] = [] + var i = 0 + while i + 1 < items.count { + rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])]) + i += 2 + } + + return TestResult(columns: ["Parameter", "Value"], rows: rows) +} + +// MARK: - redisReplyToString + +@Suite("Redis Result Building - redisReplyToString") +struct RedisReplyToStringTests { + @Test("string returns the string") + func stringCase() { + #expect(testRedisReplyToString(.string("hello")) == "hello") + } + + @Test("integer returns string representation") + func integerCase() { + #expect(testRedisReplyToString(.integer(42)) == "42") + } + + @Test("data with valid UTF-8 returns the decoded string") + func dataValidUtf8() { + let data = "some text".data(using: .utf8)! + #expect(testRedisReplyToString(.data(data)) == "some text") + } + + @Test("data with invalid UTF-8 returns base64") + func dataInvalidUtf8() { + let data = Data([0xFF, 0xFE, 0x80]) + let expected = data.base64EncodedString() + #expect(testRedisReplyToString(.data(data)) == expected) + } + + @Test("null returns (nil)") + func nullCase() { + #expect(testRedisReplyToString(.null) == "(nil)") + } + + @Test("status returns the status string") + func statusCase() { + #expect(testRedisReplyToString(.status("OK")) == "OK") + } + + @Test("error returns the error string") + func errorCase() { + #expect(testRedisReplyToString(.error("ERR unknown")) == "ERR unknown") + } + + @Test("array returns bracketed representation") + func arrayCase() { + let reply = TestRedisReply.array([.string("a"), .integer(1), .null]) + #expect(testRedisReplyToString(reply) == "[a, 1, (nil)]") + } +} + +// MARK: - Hash + +@Suite("Redis Result Building - Hash") +struct RedisHashResultTests { + @Test("hash with all string values") + func allStrings() { + let reply = TestRedisReply.array([ + .string("field1"), .string("value1"), + .string("field2"), .string("value2") + ]) + let result = buildTestHashResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["field1", "value1"]) + #expect(result.rows[1] == ["field2", "value2"]) + } + + @Test("hash with binary data values preserves all pairs") + func binaryDataValues() { + let binaryData = Data([0xFF, 0xFE]) + let reply = TestRedisReply.array([ + .string("field1"), .data(binaryData), + .string("field2"), .string("value2") + ]) + let result = buildTestHashResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["field1", binaryData.base64EncodedString()]) + #expect(result.rows[1] == ["field2", "value2"]) + } + + @Test("hash with null values shows (nil) instead of dropping") + func nullValues() { + let reply = TestRedisReply.array([ + .string("field1"), .null, + .string("field2"), .string("value2") + ]) + let result = buildTestHashResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["field1", "(nil)"]) + #expect(result.rows[1] == ["field2", "value2"]) + } + + @Test("hash with integer values shows string representation") + func integerValues() { + let reply = TestRedisReply.array([ + .string("field1"), .integer(42) + ]) + let result = buildTestHashResult(reply) + #expect(result.rows.count == 1) + #expect(result.rows[0] == ["field1", "42"]) + } + + @Test("hash with empty array returns zero rows") + func emptyArray() { + let reply = TestRedisReply.array([]) + let result = buildTestHashResult(reply) + #expect(result.rows.isEmpty) + } + + @Test("hash with null reply returns zero rows") + func nullReply() { + let result = buildTestHashResult(.null) + #expect(result.rows.isEmpty) + } + + @Test("hash with odd number of elements ignores orphan") + func oddElements() { + let reply = TestRedisReply.array([ + .string("f1"), .string("v1"), + .string("orphan") + ]) + let result = buildTestHashResult(reply) + #expect(result.rows.count == 1) + #expect(result.rows[0] == ["f1", "v1"]) + } + + @Test("regression: stringArrayValue would corrupt hash with binary data") + func regressionStringArrayValueCorruption() { + // This is the core regression scenario. With the old code using stringArrayValue, + // .data(non-UTF8) would be dropped, shifting "field2" into the value position of + // field1, and "value2" would become an orphan key with no value. + let binaryData = Data([0xFF, 0xFE]) + let reply = TestRedisReply.array([ + .string("field1"), .data(binaryData), + .string("field2"), .string("value2") + ]) + + // Old (buggy) behavior: stringArrayValue drops the .data entry + let buggyArray = reply.stringArrayValue! + // Would be ["field1", "field2", "value2"] — only 3 elements, pairs are misaligned + #expect(buggyArray.count == 3) + #expect(buggyArray == ["field1", "field2", "value2"]) + + // Fixed behavior: arrayValue + redisReplyToString preserves all entries + let result = buildTestHashResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0][0] == "field1") + #expect(result.rows[0][1] == binaryData.base64EncodedString()) + #expect(result.rows[1] == ["field2", "value2"]) + } + + @Test("regression: stringArrayValue would corrupt hash with integer values") + func regressionStringArrayValueIntegerDrop() { + let reply = TestRedisReply.array([ + .string("counter"), .integer(100), + .string("name"), .string("test") + ]) + + // Old (buggy) behavior: stringArrayValue drops .integer + let buggyArray = reply.stringArrayValue! + #expect(buggyArray == ["counter", "name", "test"]) + + // Fixed behavior: integer is converted to "100" + let result = buildTestHashResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["counter", "100"]) + #expect(result.rows[1] == ["name", "test"]) + } +} + +// MARK: - List + +@Suite("Redis Result Building - List") +struct RedisListResultTests { + @Test("list with all strings shows correct indices and values") + func allStrings() { + let reply = TestRedisReply.array([.string("a"), .string("b"), .string("c")]) + let result = buildTestListResult(reply) + #expect(result.rows.count == 3) + #expect(result.rows[0] == ["0", "a"]) + #expect(result.rows[1] == ["1", "b"]) + #expect(result.rows[2] == ["2", "c"]) + } + + @Test("list with binary data uses base64 fallback") + func binaryData() { + let data = Data([0xFF, 0xFE]) + let reply = TestRedisReply.array([.string("ok"), .data(data)]) + let result = buildTestListResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["0", "ok"]) + #expect(result.rows[1] == ["1", data.base64EncodedString()]) + } + + @Test("list with null entries shows (nil)") + func nullEntries() { + let reply = TestRedisReply.array([.string("a"), .null, .string("c")]) + let result = buildTestListResult(reply) + #expect(result.rows.count == 3) + #expect(result.rows[1] == ["1", "(nil)"]) + } + + @Test("list with offset starts indices from offset") + func withOffset() { + let reply = TestRedisReply.array([.string("x"), .string("y")]) + let result = buildTestListResult(reply, startOffset: 10) + #expect(result.rows[0] == ["10", "x"]) + #expect(result.rows[1] == ["11", "y"]) + } + + @Test("list with integer entries shows string representation") + func integerEntries() { + let reply = TestRedisReply.array([.integer(1), .integer(2)]) + let result = buildTestListResult(reply) + #expect(result.rows[0] == ["0", "1"]) + #expect(result.rows[1] == ["1", "2"]) + } + + @Test("list with null reply returns zero rows") + func nullReply() { + let result = buildTestListResult(.null) + #expect(result.rows.isEmpty) + } +} + +// MARK: - Set + +@Suite("Redis Result Building - Set") +struct RedisSetResultTests { + @Test("set with all strings shows correct members") + func allStrings() { + let reply = TestRedisReply.array([.string("a"), .string("b"), .string("c")]) + let result = buildTestSetResult(reply) + #expect(result.rows.count == 3) + #expect(result.rows[0] == ["a"]) + #expect(result.rows[1] == ["b"]) + #expect(result.rows[2] == ["c"]) + } + + @Test("set with binary data uses base64 fallback") + func binaryData() { + let data = Data([0x80, 0x81]) + let reply = TestRedisReply.array([.string("ok"), .data(data)]) + let result = buildTestSetResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["ok"]) + #expect(result.rows[1] == [data.base64EncodedString()]) + } + + @Test("set with null and integer entries") + func mixedTypes() { + let reply = TestRedisReply.array([.null, .integer(7)]) + let result = buildTestSetResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["(nil)"]) + #expect(result.rows[1] == ["7"]) + } + + @Test("set with null reply returns zero rows") + func nullReply() { + let result = buildTestSetResult(.null) + #expect(result.rows.isEmpty) + } +} + +// MARK: - Sorted Set + +@Suite("Redis Result Building - Sorted Set") +struct RedisSortedSetResultTests { + @Test("sorted set with scores shows correct member/score pairs") + func withScores() { + let reply = TestRedisReply.array([ + .string("alice"), .string("100"), + .string("bob"), .string("200") + ]) + let result = buildTestSortedSetResult(reply, withScores: true) + #expect(result.columns == ["Member", "Score"]) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["alice", "100"]) + #expect(result.rows[1] == ["bob", "200"]) + } + + @Test("sorted set without scores shows just members") + func withoutScores() { + let reply = TestRedisReply.array([.string("alice"), .string("bob")]) + let result = buildTestSortedSetResult(reply, withScores: false) + #expect(result.columns == ["Member"]) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["alice"]) + #expect(result.rows[1] == ["bob"]) + } + + @Test("sorted set with binary data members uses base64 fallback") + func binaryDataMembers() { + let data = Data([0xFF, 0xFE]) + let reply = TestRedisReply.array([ + .data(data), .string("50") + ]) + let result = buildTestSortedSetResult(reply, withScores: true) + #expect(result.rows.count == 1) + #expect(result.rows[0] == [data.base64EncodedString(), "50"]) + } + + @Test("sorted set with integer scores") + func integerScores() { + let reply = TestRedisReply.array([ + .string("member"), .integer(99) + ]) + let result = buildTestSortedSetResult(reply, withScores: true) + #expect(result.rows.count == 1) + #expect(result.rows[0] == ["member", "99"]) + } + + @Test("sorted set with null reply returns zero rows") + func nullReply() { + let result = buildTestSortedSetResult(.null, withScores: true) + #expect(result.rows.isEmpty) + } + + @Test("sorted set with odd elements and scores ignores orphan") + func oddElementsWithScores() { + let reply = TestRedisReply.array([ + .string("alice"), .string("100"), + .string("orphan") + ]) + let result = buildTestSortedSetResult(reply, withScores: true) + #expect(result.rows.count == 1) + #expect(result.rows[0] == ["alice", "100"]) + } +} + +// MARK: - Config + +@Suite("Redis Result Building - Config") +struct RedisConfigResultTests { + @Test("config with all strings shows correct parameter/value pairs") + func allStrings() { + let reply = TestRedisReply.array([ + .string("maxmemory"), .string("0"), + .string("timeout"), .string("300") + ]) + let result = buildTestConfigResult(reply) + #expect(result.rows.count == 2) + #expect(result.rows[0] == ["maxmemory", "0"]) + #expect(result.rows[1] == ["timeout", "300"]) + } + + @Test("config with empty array returns zero rows") + func emptyArray() { + let reply = TestRedisReply.array([]) + let result = buildTestConfigResult(reply) + #expect(result.rows.isEmpty) + } + + @Test("config with null reply returns zero rows") + func nullReply() { + let result = buildTestConfigResult(.null) + #expect(result.rows.isEmpty) + } + + @Test("config with integer values shows string representation") + func integerValues() { + let reply = TestRedisReply.array([ + .string("hz"), .integer(10) + ]) + let result = buildTestConfigResult(reply) + #expect(result.rows.count == 1) + #expect(result.rows[0] == ["hz", "10"]) + } +} From 988d7b17a4f0fdfe31b0b96261afa5e5d9a875db Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 24 Mar 2026 22:51:36 +0700 Subject: [PATCH 2/3] fix: eliminate all remaining stringArrayValue usages in Redis plugin --- CHANGELOG.md | 3 +++ Plugins/RedisDriverPlugin/RedisPluginDriver.swift | 13 +++++++------ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2eed45b..4876bd5d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Multi-select connections in Welcome window (Cmd+Click, Shift+Click) with bulk delete (⌘⌫), Move to Group, and multi-connect +- Drag-and-drop connections between groups, reorder within groups, and reorder groups - ClickHouse, MSSQL, Redis, XLSX Export, MQL Export, and SQL Import now ship as built-in plugins - Large document safety caps for syntax highlighting (skip >5MB, throttle >50KB) - Lazy-load full values for LONGTEXT/MEDIUMTEXT/CLOB columns in the detail pane sidebar @@ -16,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Detail pane showing truncated values for LONGTEXT/MEDIUMTEXT/CLOB columns, preventing correct editing +- Redis hash/list/set/zset/stream views showing empty or misaligned rows when values contained binary, null, or integer types ## [0.23.2] - 2026-03-24 diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 6f7721bc..7dd35b35 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -109,7 +109,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { case .keys(let pattern): let result = try await conn.executeCommand(["KEYS", pattern]) - return result.stringArrayValue?.count ?? 0 + return result.arrayValue?.count ?? 0 case .dbsize: let result = try await conn.executeCommand(["DBSIZE"]) @@ -200,7 +200,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // Get total database count from CONFIG GET databases let configResult = try await conn.executeCommand(["CONFIG", "GET", "databases"]) var maxDatabases = 16 - if let array = configResult.stringArrayValue, array.count >= 2, let count = Int(array[1]) { + if let array = configResult.arrayValue, array.count >= 2, let count = Int(redisReplyToString(array[1])) { maxDatabases = count } @@ -310,7 +310,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { } let result = try await conn.executeCommand(["CONFIG", "GET", "databases"]) var maxDatabases = 16 - if let array = result.stringArrayValue, array.count >= 2, let count = Int(array[1]) { + if let array = result.arrayValue, array.count >= 2, let count = Int(redisReplyToString(array[1])) { maxDatabases = count } return (0 ..< maxDatabases).map { "db\($0)" } @@ -627,9 +627,10 @@ private extension RedisPluginDriver { case .keys(let pattern): let result = try await conn.executeCommand(["KEYS", pattern]) - guard let keys = result.stringArrayValue else { + guard let items = result.arrayValue else { return buildEmptyKeyResult(startTime: startTime) } + let keys = items.map { redisReplyToString($0) } let capped = Array(keys.prefix(PluginRowLimits.defaultMax)) let keysTruncated = keys.count > PluginRowLimits.defaultMax return try await buildKeyBrowseResult( @@ -1298,10 +1299,10 @@ private extension RedisPluginDriver { var entryStrings: [String] = [] for entry in entries { guard let parts = entry.arrayValue, parts.count >= 2, - let entryId = parts[0].stringValue, let fields = parts[1].arrayValue else { continue } + let entryId = redisReplyToString(parts[0]) var fieldPairs: [String] = [] var j = 0 while j + 1 < fields.count { @@ -1560,10 +1561,10 @@ private extension RedisPluginDriver { var rows: [[String?]] = [] for entry in entries { guard let entryParts = entry.arrayValue, entryParts.count >= 2, - let entryId = entryParts[0].stringValue, let fields = entryParts[1].arrayValue else { continue } + let entryId = redisReplyToString(entryParts[0]) var fieldPairs: [String] = [] var i = 0 From be3f6ccb615d68656a62f4977a3d30da67b6a836 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Tue, 24 Mar 2026 22:54:03 +0700 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94=20?= =?UTF-8?q?string=20preview,=20force=20unwraps=20in=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Plugins/RedisDriverPlugin/RedisPluginDriver.swift | 2 +- TableProTests/Core/Redis/RedisResultBuildingTests.swift | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 7dd35b35..b23ab3a4 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -1235,7 +1235,7 @@ private extension RedisPluginDriver { func formatPreviewReply(_ reply: RedisReply, type: String) -> String? { switch type.lowercased() { case "string": - return truncatePreview(reply.stringValue) + return truncatePreview(redisReplyToString(reply)) case "hash": let array: [RedisReply] diff --git a/TableProTests/Core/Redis/RedisResultBuildingTests.swift b/TableProTests/Core/Redis/RedisResultBuildingTests.swift index bf52d50c..f3dd97e0 100644 --- a/TableProTests/Core/Redis/RedisResultBuildingTests.swift +++ b/TableProTests/Core/Redis/RedisResultBuildingTests.swift @@ -162,7 +162,7 @@ struct RedisReplyToStringTests { @Test("data with valid UTF-8 returns the decoded string") func dataValidUtf8() { - let data = "some text".data(using: .utf8)! + let data = Data("some text".utf8) #expect(testRedisReplyToString(.data(data)) == "some text") } @@ -282,9 +282,9 @@ struct RedisHashResultTests { ]) // Old (buggy) behavior: stringArrayValue drops the .data entry - let buggyArray = reply.stringArrayValue! + let buggyArray = reply.stringArrayValue // Would be ["field1", "field2", "value2"] — only 3 elements, pairs are misaligned - #expect(buggyArray.count == 3) + #expect(buggyArray?.count == 3) #expect(buggyArray == ["field1", "field2", "value2"]) // Fixed behavior: arrayValue + redisReplyToString preserves all entries @@ -303,7 +303,7 @@ struct RedisHashResultTests { ]) // Old (buggy) behavior: stringArrayValue drops .integer - let buggyArray = reply.stringArrayValue! + let buggyArray = reply.stringArrayValue #expect(buggyArray == ["counter", "name", "test"]) // Fixed behavior: integer is converted to "100"