Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
223 changes: 223 additions & 0 deletions Plugins/RedisDriverPlugin/RedisArgumentCodec.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading
Loading