diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a339f3d7..a3697fcf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Count exactly** next to an estimated row total, to replace the estimate with a real count when you want it. - Reorder filter rows by dragging the grip on the left of each row, or with **Move Up** and **Move Down** in the row's right-click menu. - An **On Update** column in the Structure tab for MySQL and MariaDB, so a timestamp column can be set to update itself without writing the SQL by hand. (#2005) +- A **Length** column in the Redis key grid, showing the size Redis reports: bytes for a string, element count for a hash, list, set, sorted set, or stream. It tells you how much a preview leaves out. + +### Changed + +- The Redis key tree in the sidebar lists keys with `SCAN` instead of `KEYS`, and no longer reads every key's value to draw the tree. `KEYS` blocks the server for the whole scan. +- Redis command arguments now follow the same quoting rules as `redis-cli`, so `\xHH` writes a raw byte and a command you paste from `redis-cli` behaves the same way in TablePro. A command with unbalanced quotes is rejected instead of being guessed at. ### Fixed +- A Redis string key now shows its whole value in the grid. Values were cut at 1,000 characters with `...` on the end, and because that was the only copy the app held, the same cut value reached the JSON tab, Copy JSON, the row inspector, and exports. Editing one of those cells and saving wrote the cut value back to Redis and lost the rest. +- Hash, list, set, sorted set, and stream previews are now valid JSON. They were cut mid-token, so a preview could end in the middle of a key or an escape sequence. +- A Redis key that expires between listing and reading now shows an empty cell instead of the text `(nil)`. +- A Redis value that is not text, such as a gzip or MessagePack payload, is no longer shown as base64 and rewritten as base64 when you save. It now displays as binary, opens in the hex editor, and is written back byte for byte. This applies to the query editor too, so `SET`, `HSET`, `LPUSH`, `SADD`, and `ZADD` keep binary arguments intact. - Opening a SQL Server table or view that lives outside the default schema no longer fails with "Invalid object name". A table now keeps the schema it was listed under, and switching database no longer leaves the sidebar and the query on different schemas. (#2004) - Editing a MySQL or MariaDB column no longer drops its `ON UPDATE CURRENT_TIMESTAMP`. Saving a change to any other part of the column, even just its comment, silently removed the clause. (#2005) - A MySQL or MariaDB timestamp column that keeps fractional seconds now saves. Its default was written as text instead of an expression, so the change was rejected. (#2005) diff --git a/Plugins/RedisDriverPlugin/RedisArgumentCodec.swift b/Plugins/RedisDriverPlugin/RedisArgumentCodec.swift new file mode 100644 index 000000000..7363d4950 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisArgumentCodec.swift @@ -0,0 +1,223 @@ +// +// RedisArgumentCodec.swift +// RedisDriverPlugin +// + +import Foundation + +struct RedisArgument { + let bytes: Data + + var text: String { String(decoding: bytes, as: UTF8.self) } + + init(_ bytes: Data) { + self.bytes = bytes + } + + init(_ text: String) { + bytes = Data(text.utf8) + } +} + +extension String { + var redisArgument: Data { + Data(utf8) + } +} + +extension Array where Element == String { + var asRedisArguments: [Data] { + map { Data($0.utf8) } + } +} + +extension Array where Element == RedisArgument { + var asRedisArguments: [Data] { + map(\.bytes) + } +} + +enum RedisArgumentCodec { + private static let space = UInt8(ascii: " ") + private static let tab = UInt8(ascii: "\t") + private static let newline = UInt8(ascii: "\n") + private static let carriageReturn = UInt8(ascii: "\r") + private static let backslash = UInt8(ascii: "\\") + private static let doubleQuote = UInt8(ascii: "\"") + private static let singleQuote = UInt8(ascii: "'") + private static let hexMarker = UInt8(ascii: "x") + + static func split(_ input: String) -> [Data]? { + let bytes = Array(input.utf8) + var arguments: [Data] = [] + var index = 0 + + while index < bytes.count { + while index < bytes.count, isBlank(bytes[index]) { + index += 1 + } + guard index < bytes.count else { break } + + var current = Data() + var inDoubleQuote = false + var inSingleQuote = false + var closed = false + + while index < bytes.count { + let byte = bytes[index] + + if inDoubleQuote { + if byte == backslash, + index + 3 < bytes.count, + bytes[index + 1] == hexMarker, + let high = hexValue(bytes[index + 2]), + let low = hexValue(bytes[index + 3]) { + current.append(high << 4 | low) + index += 4 + continue + } + if byte == backslash, index + 1 < bytes.count { + current.append(unescaped(bytes[index + 1])) + index += 2 + continue + } + if byte == doubleQuote { + guard index + 1 >= bytes.count || isBlank(bytes[index + 1]) else { return nil } + index += 1 + closed = true + break + } + current.append(byte) + index += 1 + continue + } + + if inSingleQuote { + if byte == backslash, index + 1 < bytes.count, bytes[index + 1] == singleQuote { + current.append(singleQuote) + index += 2 + continue + } + if byte == singleQuote { + guard index + 1 >= bytes.count || isBlank(bytes[index + 1]) else { return nil } + index += 1 + closed = true + break + } + current.append(byte) + index += 1 + continue + } + + if isBlank(byte) { + closed = true + break + } + if byte == doubleQuote { + inDoubleQuote = true + index += 1 + continue + } + if byte == singleQuote { + inSingleQuote = true + index += 1 + continue + } + current.append(byte) + index += 1 + } + + if !closed, inDoubleQuote || inSingleQuote { return nil } + arguments.append(current) + } + + return arguments + } + + static func quote(_ bytes: Data) -> String { + if let text = String(data: bytes, encoding: .utf8) { + return isBare(text) ? text : quotedText(text) + } + return quotedBytes(bytes) + } + + static func quote(_ text: String) -> String { + isBare(text) ? text : quotedText(text) + } + + private static func isBare(_ text: String) -> Bool { + guard !text.isEmpty else { return false } + return !text.unicodeScalars.contains { scalar in + scalar.value < 0x21 || scalar.value == 0x7F + || scalar == "\"" || scalar == "'" || scalar == "\\" + } + } + + private static func quotedText(_ text: String) -> String { + var result = "\"" + for scalar in text.unicodeScalars { + switch scalar { + case "\\": result += "\\\\" + case "\"": result += "\\\"" + case "\n": result += "\\n" + case "\r": result += "\\r" + case "\t": result += "\\t" + case "\u{08}": result += "\\b" + case "\u{07}": result += "\\a" + default: + if scalar.value < 0x20 || scalar.value == 0x7F { + result += String(format: "\\x%02x", scalar.value) + } else { + result.unicodeScalars.append(scalar) + } + } + } + return result + "\"" + } + + private static func quotedBytes(_ bytes: Data) -> String { + var result = "\"" + for byte in bytes { + switch byte { + case backslash: result += "\\\\" + case doubleQuote: result += "\\\"" + case newline: result += "\\n" + case carriageReturn: result += "\\r" + case tab: result += "\\t" + case 0x08: result += "\\b" + case 0x07: result += "\\a" + default: + if byte >= 0x20, byte < 0x7F { + result.unicodeScalars.append(UnicodeScalar(byte)) + } else { + result += String(format: "\\x%02x", byte) + } + } + } + return result + "\"" + } + + private static func isBlank(_ byte: UInt8) -> Bool { + byte == space || byte == tab || byte == newline || byte == carriageReturn + } + + private static func unescaped(_ byte: UInt8) -> UInt8 { + switch byte { + case UInt8(ascii: "n"): return newline + case UInt8(ascii: "r"): return carriageReturn + case UInt8(ascii: "t"): return tab + case UInt8(ascii: "b"): return 0x08 + case UInt8(ascii: "a"): return 0x07 + default: return byte + } + } + + private static func hexValue(_ byte: UInt8) -> UInt8? { + switch byte { + case UInt8(ascii: "0") ... UInt8(ascii: "9"): return byte - UInt8(ascii: "0") + case UInt8(ascii: "a") ... UInt8(ascii: "f"): return byte - UInt8(ascii: "a") + 10 + case UInt8(ascii: "A") ... UInt8(ascii: "F"): return byte - UInt8(ascii: "A") + 10 + default: return nil + } + } +} diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 90ab71257..b6dd5de3c 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -13,11 +13,12 @@ import TableProPluginKit /// A parsed Redis command ready for execution enum RedisOperation { case get(key: String) - case set(key: String, value: String, options: RedisSetOptions?) + case set(key: String, value: Data, options: RedisSetOptions?) case del(keys: [String]) case keys(pattern: String) case scan(cursor: Int, pattern: String?, count: Int?) case keyBrowse(pattern: String?, typeScope: String?, limit: Int, offset: Int) + case keyTree(pattern: String?, limit: Int) case type(key: String) case ttl(key: String) case pttl(key: String) @@ -28,26 +29,26 @@ enum RedisOperation { // Hash case hget(key: String, field: String) - case hset(key: String, fieldValues: [(String, String)]) + case hset(key: String, fieldValues: [(String, Data)]) case hgetall(key: String) case hdel(key: String, fields: [String]) // List case lrange(key: String, start: Int, stop: Int) - case lpush(key: String, values: [String]) - case rpush(key: String, values: [String]) + case lpush(key: String, values: [Data]) + case rpush(key: String, values: [Data]) case llen(key: String) // Set case smembers(key: String) - case sadd(key: String, members: [String]) - case srem(key: String, members: [String]) + case sadd(key: String, members: [Data]) + case srem(key: String, members: [Data]) case scard(key: String) // Sorted set case zrange(key: String, start: String, stop: String, flags: [String]) - case zadd(key: String, flags: [String], scoreMembers: [(Double, String)]) - case zrem(key: String, members: [String]) + case zadd(key: String, flags: [String], scoreMembers: [(Double, Data)]) + case zrem(key: String, members: [Data]) case zcard(key: String) // Stream @@ -62,7 +63,7 @@ enum RedisOperation { case select(database: Int) case configGet(parameter: String) case configSet(parameter: String, value: String) - case command(args: [String]) + case command(args: [RedisArgument]) // Multi case multi @@ -107,10 +108,13 @@ struct RedisCommandParser { let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { throw RedisParseError.emptySyntax } - let tokens = tokenize(trimmed) + guard let split = RedisArgumentCodec.split(trimmed) else { + throw RedisParseError.invalidArgument(String(localized: "unbalanced quotes")) + } + let tokens = split.map { RedisArgument($0) } guard let first = tokens.first else { throw RedisParseError.emptySyntax } - let command = first.uppercased() + let command = first.text.uppercased() let args = Array(tokens.dropFirst()) switch command { @@ -155,36 +159,39 @@ struct RedisCommandParser { case "KEYBROWSE": return parseKeyBrowse(args) + case "KEYTREE": + return parseKeyTree(args) + default: return .command(args: tokens) } } - private static func parseKeyBrowse(_ args: [String]) -> RedisOperation { + private static func parseKeyBrowse(_ args: [RedisArgument]) -> RedisOperation { var pattern: String? var typeScope: String? var limit = 200 var offset = 0 var i = 0 while i < args.count { - switch args[i].uppercased() { + switch args[i].text.uppercased() { case "MATCH": if i + 1 < args.count { - pattern = args[i + 1] + pattern = args[i + 1].text i += 1 } case "TYPE": if i + 1 < args.count { - typeScope = args[i + 1] + typeScope = args[i + 1].text i += 1 } case "LIMIT": - if i + 1 < args.count, let value = Int(args[i + 1]) { + if i + 1 < args.count, let value = Int(args[i + 1].text) { limit = value i += 1 } case "OFFSET": - if i + 1 < args.count, let value = Int(args[i + 1]) { + if i + 1 < args.count, let value = Int(args[i + 1].text) { offset = value i += 1 } @@ -196,31 +203,55 @@ struct RedisCommandParser { return .keyBrowse(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset) } + private static func parseKeyTree(_ args: [RedisArgument]) -> RedisOperation { + var pattern: String? + var limit = PluginRowLimits.emergencyMax + var i = 0 + while i < args.count { + switch args[i].text.uppercased() { + case "MATCH": + if i + 1 < args.count { + pattern = args[i + 1].text + i += 1 + } + case "LIMIT": + if i + 1 < args.count, let value = Int(args[i + 1].text) { + limit = value + i += 1 + } + default: + break + } + i += 1 + } + return .keyTree(pattern: pattern, limit: limit) + } + // MARK: - Key Commands private static func parseKeyCommand( - _ command: String, args: [String], tokens: [String] + _ command: String, args: [RedisArgument], tokens: [RedisArgument] ) throws -> RedisOperation { switch command { case "GET": guard args.count >= 1 else { throw RedisParseError.missingArgument("GET requires a key") } - return .get(key: args[0]) + return .get(key: args[0].text) case "SET": guard args.count >= 2 else { throw RedisParseError.missingArgument("SET requires key and value") } let options = try parseSetOptions(Array(args.dropFirst(2))) - return .set(key: args[0], value: args[1], options: options) + return .set(key: args[0].text, value: args[1].bytes, options: options) case "DEL": guard !args.isEmpty else { throw RedisParseError.missingArgument("DEL requires at least one key") } - return .del(keys: args) + return .del(keys: args.map(\.text)) case "KEYS": guard args.count >= 1 else { throw RedisParseError.missingArgument("KEYS requires a pattern") } - return .keys(pattern: args[0]) + return .keys(pattern: args[0].text) case "SCAN": - guard args.count >= 1, let cursor = Int(args[0]) else { + guard args.count >= 1, let cursor = Int(args[0].text) else { throw RedisParseError.missingArgument("SCAN requires a cursor (integer)") } let (pattern, count) = try parseScanOptions(Array(args.dropFirst())) @@ -228,32 +259,32 @@ struct RedisCommandParser { case "TYPE": guard args.count >= 1 else { throw RedisParseError.missingArgument("TYPE requires a key") } - return .type(key: args[0]) + return .type(key: args[0].text) case "TTL": guard args.count >= 1 else { throw RedisParseError.missingArgument("TTL requires a key") } - return .ttl(key: args[0]) + return .ttl(key: args[0].text) case "PTTL": guard args.count >= 1 else { throw RedisParseError.missingArgument("PTTL requires a key") } - return .pttl(key: args[0]) + return .pttl(key: args[0].text) case "EXPIRE": guard args.count >= 2 else { throw RedisParseError.missingArgument("EXPIRE requires key and seconds") } - guard let seconds = Int(args[1]) else { + guard let seconds = Int(args[1].text) else { throw RedisParseError.invalidArgument("EXPIRE seconds must be an integer") } // Redis 7.0+ supports optional NX|XX|GT|LT flags; pass through as raw command if args.count > 2 { return .command(args: tokens) } - return .expire(key: args[0], seconds: seconds) + return .expire(key: args[0].text, seconds: seconds) case "PEXPIRE": guard args.count >= 2 else { throw RedisParseError.missingArgument("PEXPIRE requires key and milliseconds") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("PEXPIRE milliseconds must be an integer") } return .command(args: tokens) @@ -262,7 +293,7 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("EXPIREAT requires key and timestamp") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("EXPIREAT timestamp must be an integer") } return .command(args: tokens) @@ -271,22 +302,22 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("PEXPIREAT requires key and milliseconds-timestamp") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("PEXPIREAT milliseconds-timestamp must be an integer") } return .command(args: tokens) case "PERSIST": guard args.count >= 1 else { throw RedisParseError.missingArgument("PERSIST requires a key") } - return .persist(key: args[0]) + return .persist(key: args[0].text) case "RENAME": guard args.count >= 2 else { throw RedisParseError.missingArgument("RENAME requires key and newKey") } - return .rename(key: args[0], newKey: args[1]) + return .rename(key: args[0].text, newKey: args[1].text) case "EXISTS": guard !args.isEmpty else { throw RedisParseError.missingArgument("EXISTS requires at least one key") } - return .exists(keys: args) + return .exists(keys: args.map(\.text)) case "GETSET": guard args.count >= 2 else { throw RedisParseError.missingArgument("GETSET requires key and value") } @@ -320,14 +351,14 @@ struct RedisCommandParser { case "INCRBY": guard args.count >= 2 else { throw RedisParseError.missingArgument("INCRBY requires key and increment") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("INCRBY increment must be an integer") } return .command(args: tokens) case "DECRBY": guard args.count >= 2 else { throw RedisParseError.missingArgument("DECRBY requires key and decrement") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("DECRBY decrement must be an integer") } return .command(args: tokens) @@ -336,7 +367,7 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("INCRBYFLOAT requires key and increment") } - guard Double(args[1]) != nil else { + guard Double(args[1].text) != nil else { throw RedisParseError.invalidArgument("INCRBYFLOAT increment must be a number") } return .command(args: tokens) @@ -353,40 +384,40 @@ struct RedisCommandParser { // MARK: - Hash Commands private static func parseHashCommand( - _ command: String, args: [String], tokens: [String] + _ command: String, args: [RedisArgument], tokens: [RedisArgument] ) throws -> RedisOperation { switch command { case "HGET": guard args.count >= 2 else { throw RedisParseError.missingArgument("HGET requires key and field") } - return .hget(key: args[0], field: args[1]) + return .hget(key: args[0].text, field: args[1].text) case "HSET": guard args.count >= 3, args.count % 2 == 1 else { throw RedisParseError.missingArgument("HSET requires key followed by field value pairs") } - var fieldValues: [(String, String)] = [] + var fieldValues: [(String, Data)] = [] var i = 1 while i + 1 < args.count { - fieldValues.append((args[i], args[i + 1])) + fieldValues.append((args[i].text, args[i + 1].bytes)) i += 2 } - return .hset(key: args[0], fieldValues: fieldValues) + return .hset(key: args[0].text, fieldValues: fieldValues) case "HGETALL": guard args.count >= 1 else { throw RedisParseError.missingArgument("HGETALL requires a key") } - return .hgetall(key: args[0]) + return .hgetall(key: args[0].text) case "HDEL": guard args.count >= 2 else { throw RedisParseError.missingArgument("HDEL requires key and at least one field") } - return .hdel(key: args[0], fields: Array(args.dropFirst())) + return .hdel(key: args[0].text, fields: args.dropFirst().map(\.text)) case "HSCAN": guard args.count >= 2 else { throw RedisParseError.missingArgument("HSCAN requires key and cursor") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("HSCAN cursor must be an integer") } return .command(args: tokens) @@ -399,38 +430,38 @@ struct RedisCommandParser { // MARK: - List Commands private static func parseListCommand( - _ command: String, args: [String], tokens: [String] + _ command: String, args: [RedisArgument], tokens: [RedisArgument] ) throws -> RedisOperation { switch command { case "LRANGE": guard args.count >= 3 else { throw RedisParseError.missingArgument("LRANGE requires key, start, and stop") } - guard let start = Int(args[1]), let stop = Int(args[2]) else { + guard let start = Int(args[1].text), let stop = Int(args[2].text) else { throw RedisParseError.invalidArgument("LRANGE start and stop must be integers") } - return .lrange(key: args[0], start: start, stop: stop) + return .lrange(key: args[0].text, start: start, stop: stop) case "LPUSH": guard args.count >= 2 else { throw RedisParseError.missingArgument("LPUSH requires key and at least one value") } - return .lpush(key: args[0], values: Array(args.dropFirst())) + return .lpush(key: args[0].text, values: args.dropFirst().map(\.bytes)) case "RPUSH": guard args.count >= 2 else { throw RedisParseError.missingArgument("RPUSH requires key and at least one value") } - return .rpush(key: args[0], values: Array(args.dropFirst())) + return .rpush(key: args[0].text, values: args.dropFirst().map(\.bytes)) case "LLEN": guard args.count >= 1 else { throw RedisParseError.missingArgument("LLEN requires a key") } - return .llen(key: args[0]) + return .llen(key: args[0].text) case "LPOP": guard args.count >= 1 else { throw RedisParseError.missingArgument("LPOP requires a key") } if args.count >= 2 { - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("LPOP count must be an integer") } } @@ -439,7 +470,7 @@ struct RedisCommandParser { case "RPOP": guard args.count >= 1 else { throw RedisParseError.missingArgument("RPOP requires a key") } if args.count >= 2 { - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("RPOP count must be an integer") } } @@ -449,7 +480,7 @@ struct RedisCommandParser { guard args.count >= 3 else { throw RedisParseError.missingArgument("LSET requires key, index, and element") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("LSET index must be an integer") } return .command(args: tokens) @@ -458,7 +489,7 @@ struct RedisCommandParser { guard args.count >= 4 else { throw RedisParseError.missingArgument("LINSERT requires key, BEFORE|AFTER, pivot, and element") } - let position = args[1].uppercased() + let position = args[1].text.uppercased() guard position == "BEFORE" || position == "AFTER" else { throw RedisParseError.invalidArgument("LINSERT position must be BEFORE or AFTER") } @@ -468,7 +499,7 @@ struct RedisCommandParser { guard args.count >= 3 else { throw RedisParseError.missingArgument("LREM requires key, count, and element") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("LREM count must be an integer") } return .command(args: tokens) @@ -483,8 +514,8 @@ struct RedisCommandParser { guard args.count >= 4 else { throw RedisParseError.missingArgument("LMOVE requires source, destination, LEFT|RIGHT, LEFT|RIGHT") } - let dir1 = args[2].uppercased() - let dir2 = args[3].uppercased() + let dir1 = args[2].text.uppercased() + let dir2 = args[3].text.uppercased() guard (dir1 == "LEFT" || dir1 == "RIGHT") && (dir2 == "LEFT" || dir2 == "RIGHT") else { throw RedisParseError.invalidArgument("LMOVE directions must be LEFT or RIGHT") } @@ -498,33 +529,33 @@ struct RedisCommandParser { // MARK: - Set Commands private static func parseSetCommand( - _ command: String, args: [String], tokens: [String] + _ command: String, args: [RedisArgument], tokens: [RedisArgument] ) throws -> RedisOperation { switch command { case "SMEMBERS": guard args.count >= 1 else { throw RedisParseError.missingArgument("SMEMBERS requires a key") } - return .smembers(key: args[0]) + return .smembers(key: args[0].text) case "SADD": guard args.count >= 2 else { throw RedisParseError.missingArgument("SADD requires key and at least one member") } - return .sadd(key: args[0], members: Array(args.dropFirst())) + return .sadd(key: args[0].text, members: args.dropFirst().map(\.bytes)) case "SREM": guard args.count >= 2 else { throw RedisParseError.missingArgument("SREM requires key and at least one member") } - return .srem(key: args[0], members: Array(args.dropFirst())) + return .srem(key: args[0].text, members: args.dropFirst().map(\.bytes)) case "SCARD": guard args.count >= 1 else { throw RedisParseError.missingArgument("SCARD requires a key") } - return .scard(key: args[0]) + return .scard(key: args[0].text) case "SPOP": guard args.count >= 1 else { throw RedisParseError.missingArgument("SPOP requires a key") } if args.count >= 2 { - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("SPOP count must be an integer") } } @@ -533,7 +564,7 @@ struct RedisCommandParser { case "SRANDMEMBER": guard args.count >= 1 else { throw RedisParseError.missingArgument("SRANDMEMBER requires a key") } if args.count >= 2 { - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("SRANDMEMBER count must be an integer") } } @@ -579,7 +610,7 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("SSCAN requires key and cursor") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("SSCAN cursor must be an integer") } return .command(args: tokens) @@ -592,33 +623,33 @@ struct RedisCommandParser { // MARK: - Sorted Set Commands private static func parseSortedSetCommand( - _ command: String, args: [String], tokens: [String] + _ command: String, args: [RedisArgument], tokens: [RedisArgument] ) throws -> RedisOperation { switch command { case "ZRANGE": guard args.count >= 3 else { throw RedisParseError.missingArgument("ZRANGE requires key, start, and stop") } - let start = args[1] - let stop = args[2] + let start = args[1].text + let stop = args[2].text // Parse optional trailing flags: BYSCORE, BYLEX, REV, WITHSCORES, LIMIT offset count let knownFlags: Set = ["BYSCORE", "BYLEX", "REV", "WITHSCORES", "LIMIT"] var flags: [String] = [] var i = 3 while i < args.count { - let upper = args[i].uppercased() + let upper = args[i].text.uppercased() if knownFlags.contains(upper) { flags.append(upper) if upper == "LIMIT" { guard i + 2 < args.count else { throw RedisParseError.missingArgument("LIMIT requires offset and count") } - flags.append(args[i + 1]) - flags.append(args[i + 2]) + flags.append(args[i + 1].text) + flags.append(args[i + 2].text) i += 2 } } i += 1 } - return .zrange(key: args[0], start: start, stop: stop, flags: flags) + return .zrange(key: args[0].text, start: start, stop: stop, flags: flags) case "ZADD": guard args.count >= 2 else { @@ -628,34 +659,34 @@ struct RedisCommandParser { let zaddFlags: Set = ["NX", "XX", "GT", "LT", "CH", "INCR"] var collectedFlags: [String] = [] var i = 1 - while i < args.count, zaddFlags.contains(args[i].uppercased()) { - collectedFlags.append(args[i].uppercased()) + while i < args.count, zaddFlags.contains(args[i].text.uppercased()) { + collectedFlags.append(args[i].text.uppercased()) i += 1 } let remaining = Array(args[i...]) guard !remaining.isEmpty, remaining.count % 2 == 0 else { throw RedisParseError.missingArgument("ZADD requires score member pairs after flags") } - var scoreMembers: [(Double, String)] = [] + var scoreMembers: [(Double, Data)] = [] var j = 0 while j + 1 < remaining.count { - guard let score = Double(remaining[j]) else { - throw RedisParseError.invalidArgument("ZADD score must be a number: \(remaining[j])") + guard let score = Double(remaining[j].text) else { + throw RedisParseError.invalidArgument("ZADD score must be a number: \(remaining[j].text)") } - scoreMembers.append((score, remaining[j + 1])) + scoreMembers.append((score, remaining[j + 1].bytes)) j += 2 } - return .zadd(key: args[0], flags: collectedFlags, scoreMembers: scoreMembers) + return .zadd(key: args[0].text, flags: collectedFlags, scoreMembers: scoreMembers) case "ZREM": guard args.count >= 2 else { throw RedisParseError.missingArgument("ZREM requires key and at least one member") } - return .zrem(key: args[0], members: Array(args.dropFirst())) + return .zrem(key: args[0].text, members: args.dropFirst().map(\.bytes)) case "ZCARD": guard args.count >= 1 else { throw RedisParseError.missingArgument("ZCARD requires a key") } - return .zcard(key: args[0]) + return .zcard(key: args[0].text) case "ZSCORE": guard args.count >= 2 else { @@ -673,7 +704,7 @@ struct RedisCommandParser { guard args.count >= 3 else { throw RedisParseError.missingArgument("ZREVRANGE requires key, start, and stop") } - guard Int(args[1]) != nil, Int(args[2]) != nil else { + guard Int(args[1].text) != nil, Int(args[2].text) != nil else { throw RedisParseError.invalidArgument("ZREVRANGE start and stop must be integers") } return .command(args: tokens) @@ -688,7 +719,7 @@ struct RedisCommandParser { guard args.count >= 3 else { throw RedisParseError.missingArgument("ZINCRBY requires key, increment, and member") } - guard Double(args[1]) != nil else { + guard Double(args[1].text) != nil else { throw RedisParseError.invalidArgument("ZINCRBY increment must be a number") } return .command(args: tokens) @@ -714,7 +745,7 @@ struct RedisCommandParser { case "ZPOPMIN": guard args.count >= 1 else { throw RedisParseError.missingArgument("ZPOPMIN requires a key") } if args.count >= 2 { - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("ZPOPMIN count must be an integer") } } @@ -723,7 +754,7 @@ struct RedisCommandParser { case "ZPOPMAX": guard args.count >= 1 else { throw RedisParseError.missingArgument("ZPOPMAX requires a key") } if args.count >= 2 { - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("ZPOPMAX count must be an integer") } } @@ -733,7 +764,7 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("ZSCAN requires key and cursor") } - guard Int(args[1]) != nil else { + guard Int(args[1].text) != nil else { throw RedisParseError.invalidArgument("ZSCAN cursor must be an integer") } return .command(args: tokens) @@ -746,7 +777,7 @@ struct RedisCommandParser { // MARK: - Stream Commands private static func parseStreamCommand( - _ command: String, args: [String], tokens: [String] + _ command: String, args: [RedisArgument], tokens: [RedisArgument] ) throws -> RedisOperation { switch command { case "XRANGE": @@ -754,14 +785,14 @@ struct RedisCommandParser { throw RedisParseError.missingArgument("XRANGE requires key, start, and end") } var count: Int? - if args.count >= 5, args[3].uppercased() == "COUNT" { - count = Int(args[4]) + if args.count >= 5, args[3].text.uppercased() == "COUNT" { + count = Int(args[4].text) } - return .xrange(key: args[0], start: args[1], end: args[2], count: count) + return .xrange(key: args[0].text, start: args[1].text, end: args[2].text, count: count) case "XLEN": guard args.count >= 1 else { throw RedisParseError.missingArgument("XLEN requires a key") } - return .xlen(key: args[0]) + return .xlen(key: args[0].text) case "XADD": // XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold] *|ID field value [field value ...] @@ -775,7 +806,7 @@ struct RedisCommandParser { guard args.count >= 3 else { throw RedisParseError.missingArgument("XREAD requires STREAMS keyword, at least one key, and an ID") } - let hasStreams = args.contains { $0.uppercased() == "STREAMS" } + let hasStreams = args.contains { $0.text.uppercased() == "STREAMS" } guard hasStreams else { throw RedisParseError.missingArgument("XREAD requires the STREAMS keyword") } @@ -803,7 +834,7 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("XINFO requires a subcommand and key") } - let sub = args[0].uppercased() + let sub = args[0].text.uppercased() guard sub == "STREAM" || sub == "GROUPS" || sub == "CONSUMERS" || sub == "HELP" else { throw RedisParseError.invalidArgument( "XINFO subcommand must be STREAM, GROUPS, CONSUMERS, or HELP" @@ -815,7 +846,7 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("XGROUP requires a subcommand and key") } - let sub = args[0].uppercased() + let sub = args[0].text.uppercased() guard sub == "CREATE" || sub == "SETID" || sub == "DELCONSUMER" || sub == "DESTROY" else { throw RedisParseError.invalidArgument( "XGROUP subcommand must be CREATE, SETID, DELCONSUMER, or DESTROY" @@ -837,14 +868,14 @@ struct RedisCommandParser { // MARK: - Server Commands private static func parseServerCommand( - _ command: String, args: [String], tokens: [String] + _ command: String, args: [RedisArgument], tokens: [RedisArgument] ) throws -> RedisOperation { switch command { case "PING": return .ping case "INFO": - return .info(section: args.first) + return .info(section: args.first?.text) case "DBSIZE": return .dbsize @@ -854,7 +885,7 @@ struct RedisCommandParser { case "FLUSHALL": // Optional ASYNC|SYNC flag - if let flag = args.first?.uppercased() { + if let flag = args.first?.text.uppercased() { guard flag == "ASYNC" || flag == "SYNC" else { throw RedisParseError.invalidArgument("FLUSHALL flag must be ASYNC or SYNC") } @@ -862,7 +893,7 @@ struct RedisCommandParser { return .command(args: tokens) case "SELECT": - guard args.count >= 1, let db = Int(args[0]) else { + guard args.count >= 1, let db = Int(args[0].text) else { throw RedisParseError.missingArgument("SELECT requires a database index (integer)") } return .select(database: db) @@ -871,15 +902,15 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("CONFIG requires a subcommand and parameter") } - let subcommand = args[0].uppercased() + let subcommand = args[0].text.uppercased() switch subcommand { case "GET": - return .configGet(parameter: args[1]) + return .configGet(parameter: args[1].text) case "SET": guard args.count >= 3 else { throw RedisParseError.missingArgument("CONFIG SET requires parameter and value") } - return .configSet(parameter: args[1], value: args[2]) + return .configSet(parameter: args[1].text, value: args[2].text) default: return .command(args: tokens) } @@ -903,7 +934,7 @@ struct RedisCommandParser { guard args.count >= 2 else { throw RedisParseError.missingArgument("OBJECT requires a subcommand and key") } - let sub = args[0].uppercased() + let sub = args[0].text.uppercased() guard sub == "ENCODING" || sub == "REFCOUNT" || sub == "IDLETIME" || sub == "HELP" || sub == "FREQ" else { throw RedisParseError.invalidArgument( @@ -917,95 +948,10 @@ struct RedisCommandParser { } } - // MARK: - Tokenizer - - /// Split input by whitespace, respecting quoted strings (single and double quotes). - /// Escape sequences (\n, \t, \r, \\, \", \') are only decoded inside quoted strings. - /// Outside quotes, backslash is treated as a literal character (matching Redis CLI behavior). - private static func tokenize(_ input: String) -> [String] { - var tokens: [String] = [] - var current = "" - var inQuote = false - var quoteChar: Character = "\"" - var escapeNext = false - var escapedInsideQuote = false - var hadQuote = false - - for char in input { - if escapeNext { - escapeNext = false - if escapedInsideQuote { - // Decode known escape sequences inside quoted strings - switch char { - case "n": current.append("\n") - case "t": current.append("\t") - case "r": current.append("\r") - case "\\": current.append("\\") - case "\"": current.append("\"") - case "'": current.append("'") - default: - // Unknown escape: preserve both characters - current.append("\\") - current.append(char) - } - } else { - // Outside quotes: backslash is literal - current.append("\\") - current.append(char) - } - continue - } - - if char == "\\" { - escapeNext = true - escapedInsideQuote = inQuote - continue - } - - if inQuote { - if char == quoteChar { - inQuote = false - } else { - current.append(char) - } - continue - } - - if char == "\"" || char == "'" { - inQuote = true - hadQuote = true - quoteChar = char - continue - } - - if char.isWhitespace { - if !current.isEmpty || hadQuote { - tokens.append(current) - current = "" - hadQuote = false - } - continue - } - - current.append(char) - } - - // Handle trailing backslash - if escapeNext { - current.append("\\") - } - - if !current.isEmpty || hadQuote { - tokens.append(current) - } - - return tokens - } - // MARK: - Option Parsers /// Parse SET command options: EX, PX, EXAT, PXAT, NX, XX - private static func parseSetOptions(_ args: [String]) throws -> RedisSetOptions? { + private static func parseSetOptions(_ args: [RedisArgument]) throws -> RedisSetOptions? { guard !args.isEmpty else { return nil } var options = RedisSetOptions() @@ -1013,13 +959,13 @@ struct RedisCommandParser { var i = 0 while i < args.count { - let arg = args[i].uppercased() + let arg = args[i].text.uppercased() switch arg { case "EX": guard i + 1 < args.count else { throw RedisParseError.missingArgument("EX requires a value") } - guard let seconds = Int(args[i + 1]), seconds > 0 else { + guard let seconds = Int(args[i + 1].text), seconds > 0 else { throw RedisParseError.invalidArgument("EX value must be a positive integer") } options.ex = seconds @@ -1029,7 +975,7 @@ struct RedisCommandParser { guard i + 1 < args.count else { throw RedisParseError.missingArgument("PX requires a value") } - guard let millis = Int(args[i + 1]), millis > 0 else { + guard let millis = Int(args[i + 1].text), millis > 0 else { throw RedisParseError.invalidArgument("PX value must be a positive integer") } options.px = millis @@ -1039,7 +985,7 @@ struct RedisCommandParser { guard i + 1 < args.count else { throw RedisParseError.missingArgument("EXAT requires a value") } - guard let timestamp = Int(args[i + 1]) else { + guard let timestamp = Int(args[i + 1].text) else { throw RedisParseError.invalidArgument("EXAT value must be a positive integer") } options.exat = timestamp @@ -1049,7 +995,7 @@ struct RedisCommandParser { guard i + 1 < args.count else { throw RedisParseError.missingArgument("PXAT requires a value") } - guard let timestamp = Int(args[i + 1]) else { + guard let timestamp = Int(args[i + 1].text) else { throw RedisParseError.invalidArgument("PXAT value must be a positive integer") } options.pxat = timestamp @@ -1071,24 +1017,24 @@ struct RedisCommandParser { } /// Parse SCAN options: MATCH pattern, COUNT count - private static func parseScanOptions(_ args: [String]) throws -> (pattern: String?, count: Int?) { + private static func parseScanOptions(_ args: [RedisArgument]) throws -> (pattern: String?, count: Int?) { var pattern: String? var count: Int? var i = 0 while i < args.count { - let arg = args[i].uppercased() + let arg = args[i].text.uppercased() switch arg { case "MATCH": if i + 1 < args.count { - pattern = args[i + 1] + pattern = args[i + 1].text i += 1 } case "COUNT": guard i + 1 < args.count else { throw RedisParseError.missingArgument("COUNT requires a value") } - guard let countVal = Int(args[i + 1]) else { + guard let countVal = Int(args[i + 1].text) else { throw RedisParseError.invalidArgument("COUNT must be a positive integer") } count = countVal diff --git a/Plugins/RedisDriverPlugin/RedisKeySummary.swift b/Plugins/RedisDriverPlugin/RedisKeySummary.swift new file mode 100644 index 000000000..a62736092 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisKeySummary.swift @@ -0,0 +1,109 @@ +// +// RedisKeySummary.swift +// RedisDriverPlugin +// + +import Foundation + +enum RedisKeyKind: String, CaseIterable { + case string + case hash + case list + case set + case zset + case stream + + init?(typeName: String) { + self.init(rawValue: typeName.lowercased()) + } +} + +enum RedisKeySummary { + static let collectionPreviewLimit = 100 + static let streamPreviewLimit = 5 + + static func lengthCommand(for kind: RedisKeyKind, key: String) -> [String] { + switch kind { + case .string: + return ["STRLEN", key] + case .hash: + return ["HLEN", key] + case .list: + return ["LLEN", key] + case .set: + return ["SCARD", key] + case .zset: + return ["ZCARD", key] + case .stream: + return ["XLEN", key] + } + } + + static func previewCommand(for kind: RedisKeyKind, key: String) -> [String] { + switch kind { + case .string: + return ["GET", key] + case .hash: + return ["HSCAN", key, "0", "COUNT", String(collectionPreviewLimit)] + case .list: + return ["LRANGE", key, "0", String(collectionPreviewLimit - 1)] + case .set: + return ["SSCAN", key, "0", "COUNT", String(collectionPreviewLimit)] + case .zset: + return ["ZRANGE", key, "0", String(collectionPreviewLimit - 1), "WITHSCORES"] + case .stream: + return ["XREVRANGE", key, "+", "-", "COUNT", String(streamPreviewLimit)] + } + } + + static func jsonObject(flatPairs: [String]) -> String? { + var object: [String: String] = [:] + object.reserveCapacity(flatPairs.count / 2) + for pair in pairs(from: flatPairs) { + object[pair.first] = pair.second + } + return encode(object) + } + + static func jsonArray(elements: [String]) -> String? { + encode(elements) + } + + static func jsonScorePairs(flatPairs: [String]) -> String? { + encode(pairs(from: flatPairs).map { [$0.first, $0.second] }) + } + + static func jsonStreamEntries(_ entries: [(id: String, flatFields: [String])]) -> String? { + let encoded = entries.map { entry -> [Any] in + var fields: [String: String] = [:] + fields.reserveCapacity(entry.flatFields.count / 2) + for pair in pairs(from: entry.flatFields) { + fields[pair.first] = pair.second + } + return [entry.id, fields] + } + return encode(encoded) + } + + static func pairs(from flat: [String]) -> [(first: String, second: String)] { + var result: [(first: String, second: String)] = [] + result.reserveCapacity(flat.count / 2) + var index = 0 + while index + 1 < flat.count { + result.append((first: flat[index], second: flat[index + 1])) + index += 2 + } + return result + } + + private static func encode(_ object: Any) -> String? { + guard JSONSerialization.isValidJSONObject(object) else { return nil } + guard let data = try? JSONSerialization.data( + withJSONObject: object, + options: [.sortedKeys, .withoutEscapingSlashes] + ) else { + return nil + } + return String(data: data, encoding: .utf8) + } +} diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 36271acfe..2bc8bdc89 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -270,6 +270,14 @@ final class RedisPluginConnection: @unchecked Sendable { // MARK: - Command Execution func executeCommand(_ args: [String]) async throws -> RedisReply { + try await executeCommand(args.map { Data($0.utf8) }) + } + + func executePipeline(_ commands: [[String]]) async throws -> [RedisReply] { + try await executePipeline(commands.map { $0.map { Data($0.utf8) } }) + } + + func executeCommand(_ args: [Data]) async throws -> RedisReply { #if canImport(CRedis) return try await pluginDispatchAsync(on: queue) { [self] in guard !isShuttingDown else { @@ -291,7 +299,7 @@ final class RedisPluginConnection: @unchecked Sendable { #endif } - func executePipeline(_ commands: [[String]]) async throws -> [RedisReply] { + func executePipeline(_ commands: [[Data]]) async throws -> [RedisReply] { #if canImport(CRedis) return try await pluginDispatchAsync(on: queue) { [self] in guard !isShuttingDown else { @@ -481,7 +489,15 @@ private extension RedisPluginConnection { error.code == Int(REDIS_ERR_EOF) || error.code == Int(REDIS_ERR_IO) } + func executeCommandSync(_ args: [String]) throws -> RedisReply { + try executeCommandSync(args.map { Data($0.utf8) }) + } + func executeCommandSyncRetrying(_ args: [String]) throws -> RedisReply { + try executeCommandSyncRetrying(args.map { Data($0.utf8) }) + } + + func executeCommandSyncRetrying(_ args: [Data]) throws -> RedisReply { do { return try executeCommandSync(args) } catch let error as RedisPluginError where isConnectionError(error) && !isShuttingDown { @@ -490,7 +506,7 @@ private extension RedisPluginConnection { } } - func executePipelineSyncRetrying(_ commands: [[String]]) throws -> [RedisReply] { + func executePipelineSyncRetrying(_ commands: [[Data]]) throws -> [RedisReply] { do { return try executePipelineSync(commands) } catch let error as RedisPluginError where isConnectionError(error) && !isShuttingDown { @@ -499,7 +515,7 @@ private extension RedisPluginConnection { } } - func executeCommandSync(_ args: [String]) throws -> RedisReply { + func executeCommandSync(_ args: [Data]) throws -> RedisReply { stateLock.lock() guard let ctx = context else { stateLock.unlock() @@ -508,9 +524,8 @@ private extension RedisPluginConnection { stateLock.unlock() let argc = Int32(args.count) - let lengths = args.map { $0.utf8.count } - return try withArgvPointers(args: args, lengths: lengths) { argv, argvlen in + return try withArgvPointers(args: args) { argv, argvlen in guard let rawReply = redisCommandArgv(ctx, argc, argv, argvlen) else { if ctx.pointee.err != 0 { throw RedisPluginError(code: Int(ctx.pointee.err), message: Self.contextErrorMessage(ctx)) @@ -525,7 +540,7 @@ private extension RedisPluginConnection { } } - func executePipelineSync(_ commands: [[String]]) throws -> [RedisReply] { + func executePipelineSync(_ commands: [[Data]]) throws -> [RedisReply] { stateLock.lock() guard let ctx = context else { stateLock.unlock() @@ -537,8 +552,7 @@ private extension RedisPluginConnection { var appendedCount = 0 for args in commands { let argc = Int32(args.count) - let lengths = args.map { $0.utf8.count } - try withArgvPointers(args: args, lengths: lengths) { argv, argvlen in + try withArgvPointers(args: args) { argv, argvlen in let status = redisAppendCommandArgv(ctx, argc, argv, argvlen) if status != REDIS_OK { for _ in 0 ..< appendedCount { @@ -597,26 +611,22 @@ private extension RedisPluginConnection { } func withArgvPointers( - args: [String], - lengths: [Int], + args: [Data], body: (UnsafeMutablePointer?>, UnsafeMutablePointer) throws -> T ) rethrows -> T { let count = args.count - let cStrings: [UnsafeMutablePointer] = args.map { arg in - let utf8 = Array(arg.utf8) - let ptr = UnsafeMutablePointer.allocate(capacity: utf8.count + 1) - utf8.withUnsafeBufferPointer { buffer in - if let base = buffer.baseAddress { - base.withMemoryRebound(to: CChar.self, capacity: utf8.count) { src in - ptr.initialize(from: src, count: utf8.count) - } + let buffers: [UnsafeMutablePointer] = args.map { arg in + let ptr = UnsafeMutablePointer.allocate(capacity: arg.count + 1) + arg.withUnsafeBytes { raw in + if let base = raw.bindMemory(to: CChar.self).baseAddress { + ptr.initialize(from: base, count: arg.count) } } - ptr[utf8.count] = 0 + ptr[arg.count] = 0 return ptr } - defer { cStrings.forEach { $0.deallocate() } } + defer { buffers.forEach { $0.deallocate() } } let argv = UnsafeMutablePointer?>.allocate(capacity: count) let argvlen = UnsafeMutablePointer.allocate(capacity: count) @@ -626,8 +636,8 @@ private extension RedisPluginConnection { } for i in 0 ..< count { - argv[i] = UnsafePointer(cStrings[i]) - argvlen[i] = lengths[i] + argv[i] = UnsafePointer(buffers[i]) + argvlen[i] = args[i].count } return try body(argv, argvlen) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 03384318d..3d20d0525 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -23,6 +23,11 @@ extension RedisPluginDriver { connection: conn, startTime: startTime ) + case .keyTree(let pattern, let limit): + return try await executeKeyTree( + pattern: pattern, limit: limit, connection: conn, startTime: startTime + ) + case .hget, .hset, .hgetall, .hdel: return try await executeHashOperation(operation, connection: conn, startTime: startTime) @@ -63,14 +68,14 @@ extension RedisPluginDriver { ) case .set(let key, let value, let options): - var args = ["SET", key, value] + var args = ["SET", key].asRedisArguments + [value] if let opts = options { - if let ex = opts.ex { args += ["EX", String(ex)] } - if let px = opts.px { args += ["PX", String(px)] } - if let exat = opts.exat { args += ["EXAT", String(exat)] } - if let pxat = opts.pxat { args += ["PXAT", String(pxat)] } - if opts.nx { args.append("NX") } - if opts.xx { args.append("XX") } + if let ex = opts.ex { args += ["EX", String(ex)].asRedisArguments } + if let px = opts.px { args += ["PX", String(px)].asRedisArguments } + if let exat = opts.exat { args += ["EXAT", String(exat)].asRedisArguments } + if let pxat = opts.pxat { args += ["PXAT", String(pxat)].asRedisArguments } + if opts.nx { args.append("NX".redisArgument) } + if opts.xx { args.append("XX".redisArgument) } } _ = try await conn.executeCommand(args) return buildStatusResult("OK", startTime: startTime) @@ -193,9 +198,9 @@ extension RedisPluginDriver { ) case .hset(let key, let fieldValues): - var args = ["HSET", key] + var args = ["HSET", key].asRedisArguments for (field, value) in fieldValues { - args += [field, value] + args += [field.redisArgument, value] } let result = try await conn.executeCommand(args) let added = result.intValue ?? 0 @@ -241,7 +246,7 @@ extension RedisPluginDriver { return buildListResult(result, startOffset: start, startTime: startTime) case .lpush(let key, let values): - let args = ["LPUSH", key] + values + let args = ["LPUSH", key].asRedisArguments + values let result = try await conn.executeCommand(args) let length = result.intValue ?? 0 return PluginQueryResult( @@ -253,7 +258,7 @@ extension RedisPluginDriver { ) case .rpush(let key, let values): - let args = ["RPUSH", key] + values + let args = ["RPUSH", key].asRedisArguments + values let result = try await conn.executeCommand(args) let length = result.intValue ?? 0 return PluginQueryResult( @@ -293,7 +298,7 @@ extension RedisPluginDriver { return buildSetResult(result, startTime: startTime) case .sadd(let key, let members): - let args = ["SADD", key] + members + let args = ["SADD", key].asRedisArguments + members let result = try await conn.executeCommand(args) let added = result.intValue ?? 0 return PluginQueryResult( @@ -305,7 +310,7 @@ extension RedisPluginDriver { ) case .srem(let key, let members): - let args = ["SREM", key] + members + let args = ["SREM", key].asRedisArguments + members let result = try await conn.executeCommand(args) let removed = result.intValue ?? 0 return PluginQueryResult( @@ -348,10 +353,10 @@ extension RedisPluginDriver { return buildSortedSetResult(result, withScores: withScores, startTime: startTime) case .zadd(let key, let flags, let scoreMembers): - var args = ["ZADD", key] - args += flags + var args = ["ZADD", key].asRedisArguments + args += flags.asRedisArguments for (score, member) in scoreMembers { - args += [String(score), member] + args += [String(score).redisArgument, member] } let result = try await conn.executeCommand(args) if flags.contains("INCR") { @@ -376,7 +381,7 @@ extension RedisPluginDriver { ) case .zrem(let key, let members): - let args = ["ZREM", key] + members + let args = ["ZREM", key].asRedisArguments + members let result = try await conn.executeCommand(args) let removed = result.intValue ?? 0 return PluginQueryResult( @@ -492,7 +497,7 @@ extension RedisPluginDriver { return buildStatusResult("OK", startTime: startTime) case .command(let args): - let result = try await conn.executeCommand(args) + let result = try await conn.executeCommand(args.asRedisArguments) return buildGenericResult(result, startTime: startTime) case .multi: diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift index 61d160ccd..c1cda8b93 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift @@ -4,12 +4,44 @@ // import Foundation -import OSLog import TableProPluginKit +private struct RedisKeyProbe { + let kind: RedisKeyKind + let lengthIndex: Int + let previewIndex: Int +} + extension RedisPluginDriver { - static let previewLimit = 100 - static let previewMaxChars = 1_000 + static let keyBrowseColumns = ["Key", "Type", "TTL", "Length", "Value"] + static let keyBrowseColumnTypeNames = ["String", "RedisType", "RedisInt", "Int64", "RedisRaw"] + static let keyTreeColumns = ["Key", "Type"] + static let keyTreeColumnTypeNames = ["String", "RedisType"] + + func buildKeyTreeResult( + keys: [String], + connection conn: RedisPluginConnection, + startTime: Date, + isTruncated: Bool + ) async throws -> PluginQueryResult { + var rows: [PluginRow] = [] + if !keys.isEmpty { + let typeReplies = try await conn.executePipeline(keys.map { ["TYPE", $0] }) + rows.reserveCapacity(keys.count) + for (i, key) in keys.enumerated() { + rows.append([.text(key), .text((typeReplies[i].stringValue ?? "unknown").uppercased())]) + } + } + + return PluginQueryResult( + columns: Self.keyTreeColumns, + columnTypeNames: Self.keyTreeColumnTypeNames, + rows: rows, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(startTime), + isTruncated: isTruncated + ) + } func buildKeyBrowseResult( keys: [String], @@ -21,6 +53,23 @@ extension RedisPluginDriver { return buildEmptyKeyResult(startTime: startTime) } + let rows = try await buildKeySummaryRows(keys: keys, connection: conn) + return PluginQueryResult( + columns: Self.keyBrowseColumns, + columnTypeNames: Self.keyBrowseColumnTypeNames, + rows: rows, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(startTime), + isTruncated: isTruncated + ) + } + + func buildKeySummaryRows( + keys: [String], + connection conn: RedisPluginConnection + ) async throws -> [PluginRow] { + guard !keys.isEmpty else { return [] } + var typeAndTtlCommands: [[String]] = [] typeAndTtlCommands.reserveCapacity(keys.count * 2) for key in keys { @@ -34,196 +83,102 @@ extension RedisPluginDriver { var ttlValues: [Int] = [] ttlValues.reserveCapacity(keys.count) for i in 0 ..< keys.count { - let typeName = (typeAndTtlReplies[i * 2].stringValue ?? "unknown").uppercased() - let ttl = typeAndTtlReplies[i * 2 + 1].intValue ?? -1 - typeNames.append(typeName) - ttlValues.append(ttl) + typeNames.append((typeAndTtlReplies[i * 2].stringValue ?? "unknown").uppercased()) + ttlValues.append(typeAndTtlReplies[i * 2 + 1].intValue ?? -1) } - var previewCommands: [[String]] = [] - previewCommands.reserveCapacity(keys.count) - var previewCommandIndices: [Int] = [] - previewCommandIndices.reserveCapacity(keys.count) + var probeCommands: [[String]] = [] + probeCommands.reserveCapacity(keys.count * 2) + var probes: [RedisKeyProbe?] = [] + probes.reserveCapacity(keys.count) for (i, key) in keys.enumerated() { - let command: [String]? = previewCommandForType(typeNames[i], key: key) - if let command { - previewCommandIndices.append(previewCommands.count) - previewCommands.append(command) - } else { - previewCommandIndices.append(-1) + guard let kind = RedisKeyKind(typeName: typeNames[i]) else { + probes.append(nil) + continue } + let lengthIndex = probeCommands.count + probeCommands.append(RedisKeySummary.lengthCommand(for: kind, key: key)) + let previewIndex = probeCommands.count + probeCommands.append(RedisKeySummary.previewCommand(for: kind, key: key)) + probes.append(RedisKeyProbe(kind: kind, lengthIndex: lengthIndex, previewIndex: previewIndex)) } - var previewReplies: [RedisReply] = [] - if !previewCommands.isEmpty { - previewReplies = try await conn.executePipeline(previewCommands) + var probeReplies: [RedisReply] = [] + if !probeCommands.isEmpty { + probeReplies = try await conn.executePipeline(probeCommands) } - var rows: [[PluginCellValue]] = [] + var rows: [PluginRow] = [] rows.reserveCapacity(keys.count) for (i, key) in keys.enumerated() { - let ttlStr = String(ttlValues[i]) - let pipelineIndex = previewCommandIndices[i] - let preview: String? - if pipelineIndex >= 0, pipelineIndex < previewReplies.count { - preview = formatPreviewReply( - previewReplies[pipelineIndex], type: typeNames[i] - ) - } else { - preview = nil + var length: String? + var value = PluginCellValue.null + if let probe = probes[i], probe.previewIndex < probeReplies.count { + length = probeReplies[probe.lengthIndex].intValue.map(String.init) + value = previewCell(probeReplies[probe.previewIndex], kind: probe.kind) } - rows.append([key, typeNames[i], ttlStr, preview].asCells) + rows.append([ + .text(key), + .text(typeNames[i]), + .text(String(ttlValues[i])), + PluginCellValue.fromOptional(length), + value + ]) } - - return PluginQueryResult( - columns: ["Key", "Type", "TTL", "Value"], - columnTypeNames: ["String", "RedisType", "RedisInt", "RedisRaw"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime), - isTruncated: isTruncated - ) + return rows } - func previewCommandForType(_ type: String, key: String) -> [String]? { - switch type.lowercased() { - case "string": - return ["GET", key] - case "hash": - return ["HSCAN", key, "0", "COUNT", String(Self.previewLimit)] - case "list": - return ["LRANGE", key, "0", String(Self.previewLimit - 1)] - case "set": - return ["SSCAN", key, "0", "COUNT", String(Self.previewLimit)] - case "zset": - return ["ZRANGE", key, "0", String(Self.previewLimit - 1), "WITHSCORES"] - case "stream": - return ["XREVRANGE", key, "+", "-", "COUNT", "5"] - default: - return nil + func previewCell(_ reply: RedisReply, kind: RedisKeyKind) -> PluginCellValue { + switch kind { + case .string: + return stringCell(from: reply) + case .hash: + return .fromOptional(RedisKeySummary.jsonObject(flatPairs: scanElements(from: reply).map(redisReplyToString))) + case .list: + return .fromOptional(RedisKeySummary.jsonArray(elements: (reply.arrayValue ?? []).map(redisReplyToString))) + case .set: + return .fromOptional(RedisKeySummary.jsonArray(elements: scanElements(from: reply).map(redisReplyToString))) + case .zset: + return .fromOptional(RedisKeySummary.jsonScorePairs(flatPairs: (reply.arrayValue ?? []).map(redisReplyToString))) + case .stream: + return .fromOptional(RedisKeySummary.jsonStreamEntries(streamEntries(from: reply))) } } - func formatPreviewReply(_ reply: RedisReply, type: String) -> String? { - switch type.lowercased() { - case "string": - return truncatePreview(redisReplyToString(reply)) - - case "hash": - let array: [RedisReply] - if case .array(let scanResult) = reply, - scanResult.count == 2, - let items = scanResult[1].arrayValue { - array = items - } else if let items = reply.arrayValue, !items.isEmpty { - array = items - } else { - return "{}" - } - guard !array.isEmpty else { return "{}" } - 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(field))\":\"\(escapeJsonString(value))\"" - ) - idx += 2 - } - return truncatePreview("{\(pairs.joined(separator: ","))}") - - case "list": - guard let items = reply.arrayValue else { return "[]" } - let quoted = items.map { "\"\(escapeJsonString(redisReplyToString($0)))\"" } - return truncatePreview("[\(quoted.joined(separator: ", "))]") - - case "set": - let members: [RedisReply] - if case .array(let scanResult) = reply, - scanResult.count == 2, - let items = scanResult[1].arrayValue { - members = items - } else if let items = reply.arrayValue { - members = items - } else { - return "[]" - } - 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.arrayValue, !items.isEmpty else { return "[]" } - var pairs: [String] = [] - var i = 0 - while i + 1 < items.count { - pairs.append("\(redisReplyToString(items[i])):\(redisReplyToString(items[i + 1]))") - i += 2 - } - return truncatePreview(pairs.joined(separator: ", ")) - - case "stream": - // Parse XREVRANGE result: array of [id, [field, value, ...]] entries - guard let entries = reply.arrayValue, !entries.isEmpty else { - return "(0 entries)" - } - var entryStrings: [String] = [] - for entry in entries { - guard let parts = entry.arrayValue, parts.count >= 2, - let fields = parts[1].arrayValue else { - continue - } - let entryId = redisReplyToString(parts[0]) - var fieldPairs: [String] = [] - var j = 0 - while j + 1 < fields.count { - fieldPairs.append("\(redisReplyToString(fields[j]))=\(redisReplyToString(fields[j + 1]))") - j += 2 - } - entryStrings.append("\(entryId): \(fieldPairs.joined(separator: ", "))") - } - return truncatePreview(entryStrings.joined(separator: "; ")) - + func stringCell(from reply: RedisReply) -> PluginCellValue { + switch reply { + case .null, .error: + return .null + case .data(let bytes): + return .bytes(bytes) default: - return nil + return .text(redisReplyToString(reply)) } } - func truncatePreview(_ value: String?) -> String? { - guard let value else { return nil } - let nsValue = value as NSString - if nsValue.length > Self.previewMaxChars { - return nsValue.substring(to: Self.previewMaxChars) + "..." + func scanElements(from reply: RedisReply) -> [RedisReply] { + if case .array(let parts) = reply, parts.count == 2, let items = parts[1].arrayValue { + return items } - return value + return reply.arrayValue ?? [] } - func escapeJsonString(_ str: String) -> String { - var result = "" - for scalar in str.unicodeScalars { - switch scalar { - case "\\": result += "\\\\" - case "\"": result += "\\\"" - case "\n": result += "\\n" - case "\r": result += "\\r" - case "\t": result += "\\t" - default: - if scalar.value < 0x20 { - result += String(format: "\\u%04X", scalar.value) - } else { - result += String(scalar) - } + func streamEntries(from reply: RedisReply) -> [(id: String, flatFields: [String])] { + guard let entries = reply.arrayValue else { return [] } + return entries.compactMap { entry in + guard let parts = entry.arrayValue, parts.count >= 2, + let fields = parts[1].arrayValue else { + return nil } + return (id: redisReplyToString(parts[0]), flatFields: fields.map(redisReplyToString)) } - return result } func buildEmptyKeyResult(startTime: Date) -> PluginQueryResult { PluginQueryResult( - columns: ["Key", "Type", "TTL", "Value"], - columnTypeNames: ["String", "RedisType", "RedisInt", "RedisRaw"], + columns: Self.keyBrowseColumns, + columnTypeNames: Self.keyBrowseColumnTypeNames, rows: [], rowsAffected: 0, executionTime: Date().timeIntervalSince(startTime) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift index ae8acef8c..0f432bc83 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift @@ -91,6 +91,20 @@ extension RedisPluginDriver { ) } + func executeKeyTree( + pattern: String?, + limit: Int, + connection conn: RedisPluginConnection, + startTime: Date + ) async throws -> PluginQueryResult { + let keys = try await scanAllKeys( + connection: conn, pattern: pattern, maxKeys: limit + ) + return try await buildKeyTreeResult( + keys: keys, connection: conn, startTime: startTime, isTruncated: keys.count >= limit + ) + } + func handleScanResult( _ result: RedisReply, connection conn: RedisPluginConnection, diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 14140cd7c..cddbcc6f5 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -167,6 +167,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { PluginColumnInfo(name: "Key", dataType: "String", isNullable: false, isPrimaryKey: true), PluginColumnInfo(name: "Type", dataType: "String", isNullable: false), PluginColumnInfo(name: "TTL", dataType: "Int64", isNullable: true), + PluginColumnInfo(name: "Length", dataType: "Int64", isNullable: true, isGenerated: true), PluginColumnInfo(name: "Value", dataType: "String", isNullable: true), ] } @@ -458,8 +459,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { continuation: AsyncThrowingStream.Continuation ) async throws { continuation.yield(.header(PluginStreamHeader( - columns: ["Key", "Type", "TTL", "Value"], - columnTypeNames: ["String", "RedisType", "RedisInt", "RedisRaw"], + columns: Self.keyBrowseColumns, + columnTypeNames: Self.keyBrowseColumnTypeNames, estimatedRowCount: nil ))) @@ -511,59 +512,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let batchEnd = min(batchStart + batchSize, keys.count) let batchKeys = Array(keys[batchStart..= 0, pipelineIndex < previewReplies.count { - preview = formatPreviewReply(previewReplies[pipelineIndex], type: typeNames[i]) - } else { - preview = nil - } - rowBatch.append([ - .text(key), - .text(typeNames[i]), - .text(ttlStr), - PluginCellValue.fromOptional(preview) - ]) - } + let rowBatch = try await buildKeySummaryRows(keys: batchKeys, connection: conn) if !rowBatch.isEmpty { continuation.yield(.rows(rowBatch)) } diff --git a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift index fa2407440..ef2e7bb6b 100644 --- a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift +++ b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift @@ -66,7 +66,7 @@ struct RedisStatementGenerator { } if !deleteKeys.isEmpty { - let keyList = deleteKeys.map { escapeArgument($0) }.joined(separator: " ") + let keyList = deleteKeys.map { RedisArgumentCodec.quote($0) }.joined(separator: " ") let cmd = "DEL \(keyList)" statements.append((statement: cmd, parameters: [])) } @@ -95,7 +95,7 @@ struct RedisStatementGenerator { type = values[ti].asText } if let vi = valueColumnIndex, vi < values.count { - value = values[vi].asText + value = Self.encodedArgument(values[vi]) } if let ttli = ttlColumnIndex, ttli < values.count, let ttlStr = values[ttli].asText { ttl = Int(ttlStr) @@ -105,7 +105,7 @@ struct RedisStatementGenerator { switch cellChange.columnName { case "Key": key = cellChange.newValue.asText case "Type": type = cellChange.newValue.asText - case "Value": value = cellChange.newValue.asText + case "Value": value = Self.encodedArgument(cellChange.newValue) case "TTL": if let ttlStr = cellChange.newValue.asText { ttl = Int(ttlStr) } default: break @@ -118,12 +118,12 @@ struct RedisStatementGenerator { return [] } - let v = value ?? "" - let cmd = generateInsertCommand(key: k, value: v, type: type?.lowercased()) + let v = value ?? RedisArgumentCodec.quote("") + let cmd = generateInsertCommand(key: k, encodedValue: v, type: type?.lowercased()) statements.append((statement: cmd, parameters: [])) if let ttlSeconds = ttl, ttlSeconds > 0 { - let expireCmd = "EXPIRE \(escapeArgument(k)) \(ttlSeconds)" + let expireCmd = "EXPIRE \(RedisArgumentCodec.quote(k)) \(ttlSeconds)" statements.append((statement: expireCmd, parameters: [])) } @@ -131,30 +131,37 @@ struct RedisStatementGenerator { } /// Generate the appropriate Redis command based on the data type - private func generateInsertCommand(key: String, value: String, type: String?) -> String { + private func generateInsertCommand(key: String, encodedValue: String, type: String?) -> String { + let quotedKey = RedisArgumentCodec.quote(key) switch type { case "hash": - // Try to parse value as JSON object for HSET key field1 val1 ... - if let data = value.data(using: .utf8), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { - var args = "HSET \(escapeArgument(key))" - for (field, val) in json { - args += " \(escapeArgument(field)) \(escapeArgument(String(describing: val)))" + if let fields = Self.hashFields(fromEncoded: encodedValue) { + return fields.reduce("HSET \(quotedKey)") { command, field in + command + " \(RedisArgumentCodec.quote(field.name)) \(RedisArgumentCodec.quote(field.value))" } - return args } - return "HSET \(escapeArgument(key)) value \(escapeArgument(value))" + return "HSET \(quotedKey) value \(encodedValue)" case "list": - return "RPUSH \(escapeArgument(key)) \(escapeArgument(value))" + return "RPUSH \(quotedKey) \(encodedValue)" case "set": - return "SADD \(escapeArgument(key)) \(escapeArgument(value))" + return "SADD \(quotedKey) \(encodedValue)" case "zset": - return "ZADD \(escapeArgument(key)) 0 \(escapeArgument(value))" + return "ZADD \(quotedKey) 0 \(encodedValue)" default: - return "SET \(escapeArgument(key)) \(escapeArgument(value))" + return "SET \(quotedKey) \(encodedValue)" } } + private static func hashFields(fromEncoded encoded: String) -> [(name: String, value: String)]? { + guard let decoded = RedisArgumentCodec.split(encoded)?.first, + let json = try? JSONSerialization.jsonObject(with: decoded) as? [String: Any] else { + return nil + } + return json + .map { (name: $0.key, value: String(describing: $0.value)) } + .sorted { $0.name < $1.name } + } + // MARK: - UPDATE private func generateUpdate(for change: PluginRowChange) -> [(statement: String, parameters: [PluginCellValue])] { @@ -169,7 +176,7 @@ struct RedisStatementGenerator { if let keyChange = change.cellChanges.first(where: { $0.columnName == "Key" }), let newKey = keyChange.newValue.asText, newKey != key { - let renameCmd = "RENAME \(escapeArgument(key)) \(escapeArgument(newKey))" + let renameCmd = "RENAME \(RedisArgumentCodec.quote(key)) \(RedisArgumentCodec.quote(newKey))" statements.append((statement: renameCmd, parameters: [])) } @@ -195,24 +202,23 @@ struct RedisStatementGenerator { case "Key": continue // Already handled above case "Value": - if let newValue = cellChange.newValue.asText { - let typeLower = redisType?.lowercased() ?? "string" - if typeLower != "string" { - // Non-string types show a preview; blindly SET would destroy the data structure - Self.logger.warning( - "Skipping Value update for \(typeLower) key '\(effectiveKey)' - use query editor" - ) - continue - } - let cmd = "SET \(escapeArgument(effectiveKey)) \(escapeArgument(newValue))" - statements.append((statement: cmd, parameters: [])) + guard let encodedValue = Self.encodedArgument(cellChange.newValue) else { continue } + let typeLower = redisType?.lowercased() ?? "string" + if typeLower != "string" { + // Non-string types show a preview; blindly SET would destroy the data structure + Self.logger.warning( + "Skipping Value update for \(typeLower) key '\(effectiveKey)' - use query editor" + ) + continue } + let cmd = "SET \(RedisArgumentCodec.quote(effectiveKey)) \(encodedValue)" + statements.append((statement: cmd, parameters: [])) case "TTL": if let ttlStr = cellChange.newValue.asText, let ttlSeconds = Int(ttlStr), ttlSeconds > 0 { - let cmd = "EXPIRE \(escapeArgument(effectiveKey)) \(ttlSeconds)" + let cmd = "EXPIRE \(RedisArgumentCodec.quote(effectiveKey)) \(ttlSeconds)" statements.append((statement: cmd, parameters: [])) } else if cellChange.newValue.isNull || cellChange.newValue.asText == "-1" { - let cmd = "PERSIST \(escapeArgument(effectiveKey))" + let cmd = "PERSIST \(RedisArgumentCodec.quote(effectiveKey))" statements.append((statement: cmd, parameters: [])) } default: @@ -235,22 +241,12 @@ struct RedisStatementGenerator { return originalRow[keyIndex].asText } - /// Escape a Redis argument for safe embedding in a command string. - /// Wraps in double quotes if the value contains whitespace or special characters. - /// Ensures special characters round-trip correctly through the tokenizer. - private func escapeArgument(_ value: String) -> String { - let needsQuoting = value.isEmpty || value.contains(where: { - $0.isWhitespace || $0 == "\"" || $0 == "'" || $0 == "\\" || $0 == "\n" || $0 == "\r" || $0 == "\t" - }) - if needsQuoting { - let escaped = value - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\r", with: "\\r") - .replacingOccurrences(of: "\t", with: "\\t") - return "\"\(escaped)\"" + /// Render a cell as one Redis command argument, keeping binary values byte exact. + private static func encodedArgument(_ value: PluginCellValue) -> String? { + switch value { + case .null: return nil + case .text(let text): return RedisArgumentCodec.quote(text) + case .bytes(let bytes): return RedisArgumentCodec.quote(bytes) } - return value } } diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 5a481112e..b041339dc 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -131,7 +131,7 @@ extension PluginMetadataRegistry { tableEntityName: "Keys", containerEntityName: "Database", defaultPrimaryKeyColumn: "Key", - immutableColumns: [], + immutableColumns: ["Length"], systemDatabaseNames: [], systemSchemaNames: [], fileExtensions: [], diff --git a/TablePro/ViewModels/RedisKeyTreeViewModel.swift b/TablePro/ViewModels/RedisKeyTreeViewModel.swift index 8c07a08ad..f25880ca8 100644 --- a/TablePro/ViewModels/RedisKeyTreeViewModel.swift +++ b/TablePro/ViewModels/RedisKeyTreeViewModel.swift @@ -38,8 +38,7 @@ internal final class RedisKeyTreeViewModel { } do { - // Use KEYS command for simplicity — returns all keys matching pattern - let result = try await driver.execute(query: "KEYS *") + let result = try await driver.execute(query: "KEYTREE LIMIT \(Self.maxKeys)") let keyColumnIndex = result.columns.firstIndex(of: "Key") ?? 0 let typeColumnIndex = result.columns.firstIndex(of: "Type") ?? 1 diff --git a/TablePro/Views/Results/CellInteractionResolver.swift b/TablePro/Views/Results/CellInteractionResolver.swift index b3e1892b4..63920c673 100644 --- a/TablePro/Views/Results/CellInteractionResolver.swift +++ b/TablePro/Views/Results/CellInteractionResolver.swift @@ -11,6 +11,7 @@ internal struct CellContext: Equatable { let isTableEditable: Bool let isRowDeleted: Bool let isImmutableColumn: Bool + let isBinaryValue: Bool let displayFormatOverride: ValueDisplayFormat? init( @@ -19,6 +20,7 @@ internal struct CellContext: Equatable { isTableEditable: Bool, isRowDeleted: Bool, isImmutableColumn: Bool, + isBinaryValue: Bool = false, displayFormatOverride: ValueDisplayFormat? = nil ) { self.columnType = columnType @@ -26,6 +28,7 @@ internal struct CellContext: Equatable { self.isTableEditable = isTableEditable self.isRowDeleted = isRowDeleted self.isImmutableColumn = isImmutableColumn + self.isBinaryValue = isBinaryValue self.displayFormatOverride = displayFormatOverride } } @@ -50,7 +53,7 @@ internal struct CellInteractionResolver { let isReadOnly = !context.isTableEditable || context.isImmutableColumn - if context.columnType?.isBlobType == true { + if context.columnType?.isBlobType == true || context.isBinaryValue { return isReadOnly ? .viewBlob : .editBlob } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Click.swift b/TablePro/Views/Results/Extensions/DataGridView+Click.swift index 8e42fc57a..a6854992c 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Click.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Click.swift @@ -53,6 +53,7 @@ extension TableViewCoordinator { isTableEditable: isEditable, isRowDeleted: changeManager.isRowDeleted(row), isImmutableColumn: immutable.contains(columnName), + isBinaryValue: cellTypedValue(at: row, column: columnIndex).asBytes != nil, displayFormatOverride: override ) } diff --git a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift index 89041c21c..deda02317 100644 --- a/TablePro/Views/Results/Extensions/DataGridView+Editing.swift +++ b/TablePro/Views/Results/Extensions/DataGridView+Editing.swift @@ -29,6 +29,8 @@ extension TableViewCoordinator { } } + guard cellTypedValue(at: row, column: columnIndex).asBytes == nil else { return .blocked } + let value: String if let displayRow = displayRow(at: row), columnIndex < displayRow.values.count, diff --git a/TableProTests/Core/Redis/RedisArgumentCodecTests.swift b/TableProTests/Core/Redis/RedisArgumentCodecTests.swift new file mode 100644 index 000000000..7830811f4 --- /dev/null +++ b/TableProTests/Core/Redis/RedisArgumentCodecTests.swift @@ -0,0 +1,121 @@ +// +// RedisArgumentCodecTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("RedisArgumentCodec - byte round-trip") +struct RedisArgumentCodecRoundTripTests { + @Test("every byte value survives quote then split") + func everyByteSurvives() { + for value in UInt8.min ... UInt8.max { + let data = Data([value]) + #expect(RedisArgumentCodec.split(RedisArgumentCodec.quote(data)) == [data]) + } + } + + @Test("binary blobs survive quote then split") + func blobsSurvive() { + var seed: UInt64 = 0x9E37_79B9_7F4A_7C15 + func nextByte() -> UInt8 { + seed ^= seed << 13 + seed ^= seed >> 7 + seed ^= seed << 17 + return UInt8(truncatingIfNeeded: seed) + } + for length in 1 ... 200 { + let blob = Data((0 ..< length).map { _ in nextByte() }) + #expect(RedisArgumentCodec.split(RedisArgumentCodec.quote(blob)) == [blob]) + } + } + + @Test("a gzip payload survives") + func gzipSurvives() { + let gzip = Data([0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFE]) + #expect(RedisArgumentCodec.split(RedisArgumentCodec.quote(gzip)) == [gzip]) + } + + @Test("an empty argument survives") + func emptySurvives() { + #expect(RedisArgumentCodec.quote(Data()) == "\"\"") + #expect(RedisArgumentCodec.split("\"\"") == [Data()]) + } +} + +@Suite("RedisArgumentCodec - readable output") +struct RedisArgumentCodecReadabilityTests { + @Test("a simple value is left unquoted") + func simpleValueIsBare() { + #expect(RedisArgumentCodec.quote(Data("hello".utf8)) == "hello") + } + + @Test("non-ASCII text stays readable instead of becoming hex escapes") + func unicodeStaysReadable() { + #expect(RedisArgumentCodec.quote(Data("café".utf8)) == "café") + #expect(RedisArgumentCodec.split("café") == [Data("café".utf8)]) + } + + @Test("a value with spaces is quoted") + func spacesAreQuoted() { + #expect(RedisArgumentCodec.quote(Data("hello world".utf8)) == "\"hello world\"") + } +} + +@Suite("RedisArgumentCodec - redis-cli grammar") +struct RedisArgumentCodecGrammarTests { + @Test("hex escapes decode to raw bytes") + func hexEscapes() { + #expect(RedisArgumentCodec.split("\"\\xff\\xfe\"") == [Data([0xFF, 0xFE])]) + #expect(RedisArgumentCodec.split("\"\\xFF\"") == [Data([0xFF])]) + } + + @Test("named escapes decode inside double quotes") + func namedEscapes() { + #expect(RedisArgumentCodec.split("\"a\\nb\\tc\\rd\"") == [Data("a\nb\tc\rd".utf8)]) + #expect(RedisArgumentCodec.split("\"\\a\\b\"") == [Data([0x07, 0x08])]) + } + + @Test("an unknown escape yields the escaped character") + func unknownEscape() { + #expect(RedisArgumentCodec.split("\"\\q\"") == [Data("q".utf8)]) + } + + @Test("single quotes take everything literally except an escaped quote") + func singleQuotes() { + #expect(RedisArgumentCodec.split("'a\\nb'") == [Data("a\\nb".utf8)]) + #expect(RedisArgumentCodec.split("'it\\'s'") == [Data("it's".utf8)]) + } + + @Test("arguments split on whitespace outside quotes") + func splitsOnWhitespace() { + #expect(RedisArgumentCodec.split("SET mykey \"hello world\"")?.count == 3) + #expect(RedisArgumentCodec.split("SET mykey \"hello world\"")?.last == Data("hello world".utf8)) + } + + @Test("blank input yields no arguments") + func blankInput() { + #expect(RedisArgumentCodec.split("")?.isEmpty == true) + #expect(RedisArgumentCodec.split(" ")?.isEmpty == true) + } + + @Test("unbalanced quotes are rejected") + func unbalancedQuotesRejected() { + #expect(RedisArgumentCodec.split("SET k \"unterminated") == nil) + } + + @Test("a closing quote must be followed by whitespace") + func trailingTextAfterQuoteRejected() { + #expect(RedisArgumentCodec.split("SET k \"ab\"cd") == nil) + } + + @Test("a value cannot break out into extra arguments") + func noArgumentInjection() { + let hostile = Data("a\" DEL other \"b".utf8) + let command = "SET k \(RedisArgumentCodec.quote(hostile))" + let parsed = RedisArgumentCodec.split(command) + #expect(parsed?.count == 3) + #expect(parsed?.last == hostile) + } +} diff --git a/TableProTests/Core/Redis/RedisBinaryValueTests.swift b/TableProTests/Core/Redis/RedisBinaryValueTests.swift new file mode 100644 index 000000000..f3ae5b3fc --- /dev/null +++ b/TableProTests/Core/Redis/RedisBinaryValueTests.swift @@ -0,0 +1,173 @@ +// +// RedisBinaryValueTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +private let browseColumns = ["Key", "Type", "TTL", "Length", "Value"] +private let gzipPayload = Data([0x1F, 0x8B, 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0xFE]) + +private func updateStatements( + key: String = "mykey", + type: String = "STRING", + newValue: PluginCellValue +) -> [(statement: String, parameters: [PluginCellValue])] { + let generator = RedisStatementGenerator(namespaceName: "", columns: browseColumns) + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [(columnIndex: 4, columnName: "Value", oldValue: .text("old"), newValue: newValue)], + originalRow: [.text(key), .text(type), "-1", "3", .text("old")] + ) + return generator.generateStatements( + from: [change], insertedRowData: [:], deletedRowIndices: [], insertedRowIndices: [] + ) +} + +private func parsedValue(of statement: String) -> Data? { + guard case .set(_, let value, _)? = try? RedisCommandParser.parse(statement) else { return nil } + return value +} + +@Suite("Redis write path - values survive the command round-trip") +struct RedisWriteRoundTripTests { + @Test("a plain value produces a readable command") + func plainValueStaysReadable() { + let statements = updateStatements(newValue: .text("hello")) + #expect(statements.count == 1) + #expect(statements.first?.statement == "SET mykey hello") + } + + @Test("a value with spaces is quoted and parses back whole") + func spacedValueRoundTrips() { + let statements = updateStatements(newValue: .text("hello world")) + #expect(statements.first?.statement == "SET mykey \"hello world\"") + #expect(parsedValue(of: statements[0].statement) == Data("hello world".utf8)) + } + + @Test("quotes, backslashes, and newlines round-trip") + func specialCharactersRoundTrip() { + let value = "a\"b\\c\nd" + let statements = updateStatements(newValue: .text(value)) + #expect(parsedValue(of: statements[0].statement) == Data(value.utf8)) + } + + @Test("non-ASCII text round-trips") + func unicodeRoundTrips() { + let value = "café ☕" + let statements = updateStatements(newValue: .text(value)) + #expect(parsedValue(of: statements[0].statement) == Data(value.utf8)) + } + + @Test("a long value round-trips whole") + func longValueRoundTrips() { + let value = String(repeating: "x", count: 20_000) + let statements = updateStatements(newValue: .text(value)) + #expect(parsedValue(of: statements[0].statement) == Data(value.utf8)) + } + + @Test("a binary value round-trips byte for byte") + func binaryValueRoundTrips() { + let statements = updateStatements(newValue: .bytes(gzipPayload)) + #expect(statements.count == 1) + #expect(parsedValue(of: statements[0].statement) == gzipPayload) + } + + @Test("binary blobs of many shapes round-trip") + func binaryBlobsRoundTrip() { + var seed: UInt64 = 0x2545_F491_4F6C_DD1D + func nextByte() -> UInt8 { + seed ^= seed << 13 + seed ^= seed >> 7 + seed ^= seed << 17 + return UInt8(truncatingIfNeeded: seed) + } + for length in 1 ... 120 { + let blob = Data((0 ..< length).map { _ in nextByte() }) + let statements = updateStatements(newValue: .bytes(blob)) + #expect(parsedValue(of: statements[0].statement) == blob) + } + } + + @Test("a key containing a space round-trips") + func keyWithSpaceRoundTrips() { + let statements = updateStatements(key: "my key", newValue: .text("v")) + guard case .set(let key, _, _)? = try? RedisCommandParser.parse(statements[0].statement) else { + Issue.record("Expected a SET operation") + return + } + #expect(key == "my key") + } + + @Test("a value cannot inject a second command") + func valueCannotInjectCommand() { + let hostile = "x\" \nDEL victim \"y" + let statements = updateStatements(newValue: .text(hostile)) + #expect(statements.count == 1) + #expect(RedisArgumentCodec.split(statements[0].statement)?.count == 3) + #expect(parsedValue(of: statements[0].statement) == Data(hostile.utf8)) + } + + @Test("a collection value is still refused so the structure survives") + func collectionValueRefused() { + #expect(updateStatements(type: "LIST", newValue: .text("[\"a\"]")).isEmpty) + } + + @Test("a binary insert round-trips") + func binaryInsertRoundTrips() { + let generator = RedisStatementGenerator(namespaceName: "", columns: browseColumns) + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let inserted: [Int: [PluginCellValue]] = [ + 0: [.text("bin"), .text("STRING"), .null, .null, .bytes(gzipPayload)] + ] + let statements = generator.generateStatements( + from: [change], insertedRowData: inserted, deletedRowIndices: [], insertedRowIndices: [0] + ) + #expect(statements.count == 1) + #expect(parsedValue(of: statements[0].statement) == gzipPayload) + } +} + +@Suite("RedisCommandParser - binary arguments") +struct RedisCommandParserBinaryTests { + @Test("SET carries a binary value through") + func setCarriesBinary() { + let command = "SET k \(RedisArgumentCodec.quote(gzipPayload))" + guard case .set(let key, let value, _)? = try? RedisCommandParser.parse(command) else { + Issue.record("Expected a SET operation") + return + } + #expect(key == "k") + #expect(value == gzipPayload) + } + + @Test("LPUSH carries binary members through") + func lpushCarriesBinary() { + let command = "LPUSH l \(RedisArgumentCodec.quote(gzipPayload))" + guard case .lpush(_, let values)? = try? RedisCommandParser.parse(command) else { + Issue.record("Expected an LPUSH operation") + return + } + #expect(values == [gzipPayload]) + } + + @Test("HSET carries a binary field value through") + func hsetCarriesBinary() { + let command = "HSET h field \(RedisArgumentCodec.quote(gzipPayload))" + guard case .hset(_, let fieldValues)? = try? RedisCommandParser.parse(command) else { + Issue.record("Expected an HSET operation") + return + } + #expect(fieldValues.count == 1) + #expect(fieldValues.first?.0 == "field") + #expect(fieldValues.first?.1 == gzipPayload) + } + + @Test("an unbalanced quote is rejected instead of silently mangled") + func unbalancedQuoteRejected() { + #expect((try? RedisCommandParser.parse("SET k \"oops")) == nil) + } +} diff --git a/TableProTests/Core/Redis/RedisKeySummaryTests.swift b/TableProTests/Core/Redis/RedisKeySummaryTests.swift new file mode 100644 index 000000000..59b96012e --- /dev/null +++ b/TableProTests/Core/Redis/RedisKeySummaryTests.swift @@ -0,0 +1,128 @@ +// +// RedisKeySummaryTests.swift +// TableProTests +// + +import Foundation +import Testing + +private func parseJson(_ text: String?) -> Any? { + guard let text, let data = text.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: [.fragmentsAllowed]) +} + +@Suite("RedisKeySummary - probe commands") +struct RedisKeySummaryCommandTests { + @Test("a string key is read with GET so the whole value arrives") + func stringPreviewReadsWholeValue() { + #expect(RedisKeySummary.previewCommand(for: .string, key: "session:1") == ["GET", "session:1"]) + } + + @Test("length command matches the Redis command for each kind") + func lengthCommandPerKind() { + #expect(RedisKeySummary.lengthCommand(for: .string, key: "k") == ["STRLEN", "k"]) + #expect(RedisKeySummary.lengthCommand(for: .hash, key: "k") == ["HLEN", "k"]) + #expect(RedisKeySummary.lengthCommand(for: .list, key: "k") == ["LLEN", "k"]) + #expect(RedisKeySummary.lengthCommand(for: .set, key: "k") == ["SCARD", "k"]) + #expect(RedisKeySummary.lengthCommand(for: .zset, key: "k") == ["ZCARD", "k"]) + #expect(RedisKeySummary.lengthCommand(for: .stream, key: "k") == ["XLEN", "k"]) + } + + @Test("collection previews are bounded by element count, never by bytes") + func collectionPreviewsBoundedByElements() { + #expect(RedisKeySummary.previewCommand(for: .list, key: "k") + == ["LRANGE", "k", "0", String(RedisKeySummary.collectionPreviewLimit - 1)]) + #expect(RedisKeySummary.previewCommand(for: .hash, key: "k") + == ["HSCAN", "k", "0", "COUNT", String(RedisKeySummary.collectionPreviewLimit)]) + #expect(RedisKeySummary.previewCommand(for: .stream, key: "k") + == ["XREVRANGE", "k", "+", "-", "COUNT", String(RedisKeySummary.streamPreviewLimit)]) + } + + @Test("an unknown server type produces no probe") + func unknownTypeHasNoKind() { + #expect(RedisKeyKind(typeName: "ReJSON-RL") == nil) + #expect(RedisKeyKind(typeName: "STRING") == .string) + #expect(RedisKeyKind(typeName: "zset") == .zset) + } +} + +@Suite("RedisKeySummary - previews are valid JSON") +struct RedisKeySummaryJsonTests { + @Test("a hash preview parses back to the same fields") + func hashRoundTrips() { + let json = RedisKeySummary.jsonObject(flatPairs: ["name", "vani", "id", "685713900339200013"]) + #expect(parseJson(json) as? [String: String] == ["name": "vani", "id": "685713900339200013"]) + } + + @Test("a hash preview escapes quotes, backslashes, and newlines") + func hashEscapesControlCharacters() { + let json = RedisKeySummary.jsonObject(flatPairs: ["raw", "a\"b\\c\nd\te"]) + #expect(parseJson(json) as? [String: String] == ["raw": "a\"b\\c\nd\te"]) + } + + @Test("a trailing field with no value is dropped instead of corrupting the object") + func hashIgnoresDanglingField() { + let json = RedisKeySummary.jsonObject(flatPairs: ["a", "1", "orphan"]) + #expect(parseJson(json) as? [String: String] == ["a": "1"]) + } + + @Test("a list preview parses back to the same ordered elements") + func listRoundTrips() { + let elements = ["first", "second \"quoted\"", "third\nline"] + #expect(parseJson(RedisKeySummary.jsonArray(elements: elements)) as? [String] == elements) + } + + @Test("an empty collection previews as an empty JSON container") + func emptyCollections() { + #expect(RedisKeySummary.jsonArray(elements: []) == "[]") + #expect(RedisKeySummary.jsonObject(flatPairs: []) == "{}") + } + + @Test("a sorted set preview keeps score order and score precision") + func sortedSetKeepsOrderAndPrecision() { + let json = RedisKeySummary.jsonScorePairs(flatPairs: ["low", "1.5", "high", "inf"]) + #expect(parseJson(json) as? [[String]] == [["low", "1.5"], ["high", "inf"]]) + } + + @Test("a stream preview pairs each entry id with its fields") + func streamEntries() { + let json = RedisKeySummary.jsonStreamEntries([ + (id: "1526919030474-55", flatFields: ["sensor", "2", "temp", "36"]) + ]) + let parsed = parseJson(json) as? [[Any]] + #expect(parsed?.count == 1) + #expect(parsed?.first?.first as? String == "1526919030474-55") + #expect(parsed?.first?.last as? [String: String] == ["sensor": "2", "temp": "36"]) + } + + @Test("slashes stay readable so URLs are not escaped") + func slashesAreNotEscaped() { + let json = RedisKeySummary.jsonArray(elements: ["https://cdn.discordapp.com/avatars/1.png"]) + #expect(json == "[\"https://cdn.discordapp.com/avatars/1.png\"]") + } +} + +@Suite("RedisKeySummary - values are never cut") +struct RedisKeySummaryLengthTests { + @Test("an element far past the old 1,000 character cap survives whole") + func longElementSurvives() { + let long = String(repeating: "a", count: 50_000) + let parsed = parseJson(RedisKeySummary.jsonArray(elements: [long])) as? [String] + #expect(parsed?.first?.count == 50_000) + } + + @Test("a long hash value survives whole and still parses") + func longHashValueSurvives() { + let long = String(repeating: "b", count: 20_000) + let parsed = parseJson(RedisKeySummary.jsonObject(flatPairs: ["blob", long])) as? [String: String] + #expect(parsed?["blob"]?.count == 20_000) + } + + @Test("a preview never ends in an ellipsis marker") + func noEllipsisMarker() { + let long = String(repeating: "c", count: 10_000) + let json = RedisKeySummary.jsonArray(elements: [long]) + #expect(json?.hasSuffix("...") == false) + #expect(parseJson(json) != nil) + } +} diff --git a/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift new file mode 100644 index 000000000..7e782bf13 --- /dev/null +++ b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift @@ -0,0 +1,52 @@ +// +// RedisKeyTreeCommandTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("RedisCommandParser - KEYTREE") +struct RedisKeyTreeCommandTests { + @Test("KEYTREE with a limit parses to a key tree operation") + func parsesLimit() throws { + guard case .keyTree(let pattern, let limit) = try RedisCommandParser.parse("KEYTREE LIMIT 50000") else { + Issue.record("Expected a keyTree operation") + return + } + #expect(pattern == nil) + #expect(limit == 50_000) + } + + @Test("KEYTREE carries a MATCH pattern through") + func parsesPattern() throws { + guard case .keyTree(let pattern, _) = try RedisCommandParser.parse("KEYTREE MATCH cache:* LIMIT 10") else { + Issue.record("Expected a keyTree operation") + return + } + #expect(pattern == "cache:*") + } + + @Test("KEYTREE without a limit falls back to the row cap") + func defaultsToRowCap() throws { + guard case .keyTree(_, let limit) = try RedisCommandParser.parse("KEYTREE") else { + Issue.record("Expected a keyTree operation") + return + } + #expect(limit == PluginRowLimits.emergencyMax) + } + + @Test("KEYBROWSE still parses to a key browse operation") + func keyBrowseUnaffected() throws { + guard case .keyBrowse(let pattern, let typeScope, let limit, let offset) = + try RedisCommandParser.parse("KEYBROWSE MATCH session:* TYPE hash LIMIT 100 OFFSET 50") else { + Issue.record("Expected a keyBrowse operation") + return + } + #expect(pattern == "session:*") + #expect(typeScope == "hash") + #expect(limit == 100) + #expect(offset == 50) + } +} diff --git a/TableProTests/PluginTestSources/RedisArgumentCodec.swift b/TableProTests/PluginTestSources/RedisArgumentCodec.swift new file mode 120000 index 000000000..ec6d897ae --- /dev/null +++ b/TableProTests/PluginTestSources/RedisArgumentCodec.swift @@ -0,0 +1 @@ +../../Plugins/RedisDriverPlugin/RedisArgumentCodec.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/RedisCommandParser.swift b/TableProTests/PluginTestSources/RedisCommandParser.swift new file mode 120000 index 000000000..2232011f8 --- /dev/null +++ b/TableProTests/PluginTestSources/RedisCommandParser.swift @@ -0,0 +1 @@ +../../Plugins/RedisDriverPlugin/RedisCommandParser.swift \ No newline at end of file diff --git a/TableProTests/PluginTestSources/RedisKeySummary.swift b/TableProTests/PluginTestSources/RedisKeySummary.swift new file mode 120000 index 000000000..0c33f3d7e --- /dev/null +++ b/TableProTests/PluginTestSources/RedisKeySummary.swift @@ -0,0 +1 @@ +../../Plugins/RedisDriverPlugin/RedisKeySummary.swift \ No newline at end of file diff --git a/TableProTests/Plugins/RedisStatementGeneratorTests.swift b/TableProTests/Plugins/RedisStatementGeneratorTests.swift index 41e72acb5..6a24538fb 100644 --- a/TableProTests/Plugins/RedisStatementGeneratorTests.swift +++ b/TableProTests/Plugins/RedisStatementGeneratorTests.swift @@ -723,3 +723,101 @@ struct RedisStatementGeneratorTests { #expect(results[2].statement == "EXPIRE newkey 600") } } + +@Suite("Redis Statement Generator - key browse columns") +struct RedisStatementGeneratorBrowseColumnTests { + private static let browseColumns = ["Key", "Type", "TTL", "Length", "Value"] + + @Test("A string value update still resolves with the Length column present") + func valueUpdateWithLengthColumn() { + let gen = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + (columnIndex: 4, columnName: "Value", oldValue: "old", newValue: "new") + ], + originalRow: ["mykey", "STRING", "-1", "3", "old"] + ) + + let results = gen.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + #expect(results.count == 1) + #expect(results[0].statement == "SET mykey new") + } + + @Test("A whole string value is written back, however long it is") + func longValueIsWrittenWhole() { + let gen = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + let long = String(repeating: "a", count: 5_000) + + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + (columnIndex: 4, columnName: "Value", oldValue: "old", newValue: PluginCellValue.text(long)) + ], + originalRow: ["mykey", "STRING", "-1", "3", "old"] + ) + + let results = gen.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + #expect(results.count == 1) + #expect(results[0].statement == "SET mykey \(long)") + } + + @Test("A collection value update is skipped so the structure survives") + func collectionValueUpdateSkipped() { + let gen = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + + let change = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + (columnIndex: 4, columnName: "Value", oldValue: "[\"a\"]", newValue: "[\"b\"]") + ], + originalRow: ["mylist", "LIST", "-1", "1", "[\"a\"]"] + ) + + let results = gen.generateStatements( + from: [change], + insertedRowData: [:], + deletedRowIndices: [], + insertedRowIndices: [] + ) + + #expect(results.isEmpty) + } + + @Test("An insert reads its cells by name, not by position") + func insertResolvesColumnsByName() { + let gen = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let insertedData: [Int: [PluginCellValue]] = [ + 0: ["mykey", "STRING", "600", nil, "hello"] + ] + + let results = gen.generateStatements( + from: [change], + insertedRowData: insertedData, + deletedRowIndices: [], + insertedRowIndices: [0] + ) + + #expect(results.count == 2) + #expect(results[0].statement == "SET mykey hello") + #expect(results[1].statement == "EXPIRE mykey 600") + } +} diff --git a/TableProTests/Views/Results/CellInteractionResolverTests.swift b/TableProTests/Views/Results/CellInteractionResolverTests.swift index 3ea3a1c3a..05690dd3f 100644 --- a/TableProTests/Views/Results/CellInteractionResolverTests.swift +++ b/TableProTests/Views/Results/CellInteractionResolverTests.swift @@ -181,6 +181,52 @@ struct CellInteractionResolverEditableTests { } } +@Suite("CellInteractionResolver - binary values") +struct CellInteractionResolverBinaryTests { + private let resolver = CellInteractionResolver() + + @Test("a binary cell in a text column opens the blob editor") + func binaryCellInTextColumnEdits() { + let context = ContextFactory.make( + value: nil, columnType: .text("RedisRaw"), isTableEditable: true, isBinaryValue: true + ) + #expect(resolver.resolve(context) == .editBlob) + } + + @Test("a binary cell in a read-only table opens the blob viewer") + func binaryCellReadOnlyViews() { + let context = ContextFactory.make( + value: nil, columnType: .text("RedisRaw"), isTableEditable: false, isBinaryValue: true + ) + #expect(resolver.resolve(context) == .viewBlob) + } + + @Test("a binary cell in an immutable column is not editable") + func binaryCellImmutableColumnViews() { + let context = ContextFactory.make( + value: nil, columnType: .text("RedisRaw"), isTableEditable: true, + isImmutableColumn: true, isBinaryValue: true + ) + #expect(resolver.resolve(context) == .viewBlob) + } + + @Test("a text cell in the same column still edits inline") + func textCellInSameColumnEditsInline() { + let context = ContextFactory.make( + value: "hello", columnType: .text("RedisRaw"), isTableEditable: true + ) + #expect(resolver.resolve(context) == .editInline(value: "hello")) + } + + @Test("a deleted row stays blocked even when binary") + func deletedBinaryRowBlocked() { + let context = ContextFactory.make( + value: nil, isTableEditable: true, isRowDeleted: true, isBinaryValue: true + ) + #expect(resolver.resolve(context) == .blocked) + } +} + private enum ContextFactory { static func make( value: String?, @@ -188,6 +234,7 @@ private enum ContextFactory { isTableEditable: Bool = false, isRowDeleted: Bool = false, isImmutableColumn: Bool = false, + isBinaryValue: Bool = false, displayFormatOverride: ValueDisplayFormat? = nil ) -> CellContext { CellContext( @@ -196,6 +243,7 @@ private enum ContextFactory { isTableEditable: isTableEditable, isRowDeleted: isRowDeleted, isImmutableColumn: isImmutableColumn, + isBinaryValue: isBinaryValue, displayFormatOverride: displayFormatOverride ) } diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index 02727ac4a..36fd93871 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -58,7 +58,7 @@ For untrusted-CA endpoints (Upstash, internal load balancers), pick **Required ( Redis keys grouped by namespace in the sidebar with values in the data grid -**Key-Value Viewing**: The grid has **Key**, **Type**, **TTL**, and **Value** columns. The Value column previews the key: strings as text, hashes as a JSON object, lists and sets as a JSON array, sorted sets as `member:score` pairs, streams as the newest 5 entries. Previews are cut off at 1,000 characters. Only a string Value cell can be edited in the grid; change the other types in the CLI. +**Key-Value Viewing**: The grid has **Key**, **Type**, **TTL**, **Length**, and **Value** columns. A string key shows its complete value, however long it is. The other types show the first 100 elements as JSON: hashes as an object, lists and sets as an array, sorted sets as `[member, score]` pairs, streams as `[id, fields]` for the newest 5 entries. The Length column is what Redis reports for the key, so you can tell how much a preview leaves out: bytes for a string (`STRLEN`), element count for everything else (`LLEN`, `HLEN`, `SCARD`, `ZCARD`, `XLEN`). Only a string Value cell can be edited in the grid; change the other types in the CLI. A value that is not valid UTF-8, such as a gzip or MessagePack payload, shows as binary and opens in the hex editor rather than as text. **TTL Management**: The TTL column shows seconds to expiry. `-1` means no expiration, `-2` means the key does not exist. Edit the cell to run `EXPIRE`, or set it to `-1` to run `PERSIST`. @@ -68,7 +68,7 @@ For untrusted-CA endpoints (Upstash, internal load balancers), pick **Required ( - Matching runs server-side with `SCAN MATCH` and `SCAN TYPE`, and stops after 10,000 matching keys. - Filtering by key value or TTL is not supported, since Redis has no server-side primitive for it. -**Redis CLI**: Each statement is one Redis command. To run several in one go, separate them with `;`. TablePro passes each command through as-is, including commands it does not recognize; only the result formatting is type-aware. Redis has no comment syntax, and TablePro does not strip comments, so a `--` or `#` line inside a statement is sent to the server and fails. +**Redis CLI**: Each statement is one Redis command. To run several in one go, separate them with `;`. TablePro passes each command through as-is, including commands it does not recognize; only the result formatting is type-aware. Arguments are quoted the same way `redis-cli` quotes them, so `"` and `'` both work and `\xHH` writes a raw byte. Unbalanced quotes are rejected instead of being guessed at. Redis has no comment syntax, and TablePro does not strip comments, so a `--` or `#` line inside a statement is sent to the server and fails. The blocks below list one command per line for reference. @@ -116,6 +116,7 @@ DBSIZE ## Limitations - Cluster mode is not supported. Connect to a single node. +- Keys must be valid UTF-8 to appear in the grid or the sidebar tree. Values have no such limit. - Pub/Sub has no grid support. `PUBLISH` runs in the CLI, but there is no subscriber view. -- The grid previews a stream's newest 5 entries. Use `XRANGE` in the CLI for the rest. +- The grid previews the first 100 elements of a hash, list, set, or sorted set, and a stream's newest 5 entries. The Length column shows the real size. Use `HGETALL`, `LRANGE`, or `XRANGE` in the CLI for the rest. - The sidebar key tree loads at most 50,000 keys, and key filtering scans at most 10,000 matching keys.