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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Redis connections now filter with a key-pattern search field and a key-type scope instead of the SQL-style filter row. Patterns use glob syntax like `user:*`, are matched server-side across the whole keyspace, and the type scope narrows results by value type. The old filter row only matched one batch of keys and ignored any filter on Type, TTL, or Value.
- Switcher, menus, and alerts now use each database's own container name: Dataset for BigQuery, Keyspace for Cassandra and ScyllaDB. (#509)
- Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection.
- Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload.
Expand Down
40 changes: 40 additions & 0 deletions Plugins/RedisDriverPlugin/RedisCommandParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum RedisOperation {
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 type(key: String)
case ttl(key: String)
case pttl(key: String)
Expand Down Expand Up @@ -151,11 +152,50 @@ struct RedisCommandParser {
"MULTI", "EXEC", "DISCARD", "AUTH", "OBJECT":
return try parseServerCommand(command, args: args, tokens: tokens)

case "KEYBROWSE":
return parseKeyBrowse(args)

default:
return .command(args: tokens)
}
}

private static func parseKeyBrowse(_ args: [String]) -> RedisOperation {
var pattern: String?
var typeScope: String?
var limit = 200
var offset = 0
var i = 0
while i < args.count {
switch args[i].uppercased() {
case "MATCH":
if i + 1 < args.count {
pattern = args[i + 1]
i += 1
}
case "TYPE":
if i + 1 < args.count {
typeScope = args[i + 1]
i += 1
}
case "LIMIT":
if i + 1 < args.count, let value = Int(args[i + 1]) {
limit = value
i += 1
}
case "OFFSET":
if i + 1 < args.count, let value = Int(args[i + 1]) {
offset = value
i += 1
}
default:
break
}
i += 1
}
return .keyBrowse(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset)
}

// MARK: - Key Commands

private static func parseKeyCommand(
Expand Down
17 changes: 17 additions & 0 deletions Plugins/RedisDriverPlugin/RedisPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,20 @@ final class RedisPlugin: NSObject, TableProPlugin, DriverPlugin {
RedisPluginDriver(config: config)
}
}

extension RedisPlugin: PluginBrowseFilterProvider {
var browseFilterDescriptor: BrowseFilterDescriptor? {
BrowseFilterDescriptor(
usesGlob: true,
caseSensitive: true,
typeScopes: [
BrowseFilterDescriptor.TypeScope(id: "string", label: "String"),
BrowseFilterDescriptor.TypeScope(id: "hash", label: "Hash"),
BrowseFilterDescriptor.TypeScope(id: "list", label: "List"),
BrowseFilterDescriptor.TypeScope(id: "set", label: "Set"),
BrowseFilterDescriptor.TypeScope(id: "zset", label: "Sorted Set"),
BrowseFilterDescriptor.TypeScope(id: "stream", label: "Stream"),
]
)
}
}
6 changes: 6 additions & 0 deletions Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ extension RedisPluginDriver {
case .get, .set, .del, .keys, .scan, .type, .ttl, .pttl, .expire, .persist, .rename, .exists:
return try await executeKeyOperation(operation, connection: conn, startTime: startTime)

case .keyBrowse(let pattern, let typeScope, let limit, let offset):
return try await executeKeyBrowse(
pattern: pattern, typeScope: typeScope, limit: limit, offset: offset,
connection: conn, startTime: startTime
)

case .hget, .hset, .hgetall, .hdel:
return try await executeHashOperation(operation, connection: conn, startTime: startTime)

Expand Down
32 changes: 32 additions & 0 deletions Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ extension RedisPluginDriver {
func scanAllKeys(
connection conn: RedisPluginConnection,
pattern: String?,
typeFilter: String? = nil,
maxKeys: Int
) async throws -> [String] {
var allKeys: [String] = []
Expand All @@ -22,6 +23,9 @@ extension RedisPluginDriver {
args += ["MATCH", p]
}
args += ["COUNT", "1000"]
if let type = typeFilter {
args += ["TYPE", type]
}

let result = try await conn.executeCommand(args)

Expand Down Expand Up @@ -59,6 +63,34 @@ extension RedisPluginDriver {
return allKeys.sorted()
}

func executeKeyBrowse(
pattern: String?,
typeScope: String?,
limit: Int,
offset: Int,
connection conn: RedisPluginConnection,
startTime: Date
) async throws -> PluginQueryResult {
let scanCap = RedisPluginDriver.maxKeyBrowseScan
let rawKeys = try await scanAllKeys(
connection: conn,
pattern: pattern,
typeFilter: typeScope,
maxKeys: scanCap
)
var seen = Set<String>()
let matchedKeys = rawKeys.filter { seen.insert($0).inserted }
let scanWasCapped = rawKeys.count >= scanCap

let pageStart = min(max(0, offset), matchedKeys.count)
let pageEnd = limit <= 0 ? matchedKeys.count : min(pageStart + limit, matchedKeys.count)
let pageKeys = Array(matchedKeys[pageStart..<pageEnd])

return try await buildKeyBrowseResult(
keys: pageKeys, connection: conn, startTime: startTime, isTruncated: scanWasCapped
)
}

func handleScanResult(
_ result: RedisReply,
connection conn: RedisPluginConnection,
Expand Down
9 changes: 8 additions & 1 deletion Plugins/RedisDriverPlugin/RedisPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
private static let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "RedisPluginDriver")

private static let maxScanKeys = PluginRowLimits.emergencyMax
static let maxKeyBrowseScan = 10_000

var cachedScanPattern: String?
var cachedScanKeys: [String]?
Expand Down Expand Up @@ -443,6 +444,10 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
switch operation {
case .scan(_, let pattern, _):
try await streamScanRows(connection: conn, pattern: pattern, continuation: continuation)
case .keyBrowse(let pattern, let typeScope, _, _):
try await streamScanRows(
connection: conn, pattern: pattern, typeFilter: typeScope, continuation: continuation
)
default:
let startTime = Date()
let result = try await executeOperation(operation, connection: conn, startTime: startTime)
Expand All @@ -461,6 +466,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
private func streamScanRows(
connection conn: RedisPluginConnection,
pattern: String?,
typeFilter: String? = nil,
continuation: AsyncThrowingStream<PluginStreamElement, Error>.Continuation
) async throws {
continuation.yield(.header(PluginStreamHeader(
Expand All @@ -478,6 +484,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
var args = ["SCAN", cursor]
if let p = pattern { args += ["MATCH", p] }
args += ["COUNT", "1000"]
if let type = typeFilter { args += ["TYPE", type] }

let result = try await conn.executeCommand(args)

Expand Down Expand Up @@ -610,7 +617,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
let builder = RedisQueryBuilder()
return builder.buildFilteredQuery(
namespace: "", filters: filters,
logicMode: logicMode, limit: limit
logicMode: logicMode, limit: limit, offset: offset
)
}

Expand Down
70 changes: 61 additions & 9 deletions Plugins/RedisDriverPlugin/RedisQueryBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,37 @@ struct RedisQueryBuilder {
return "SCAN 0 MATCH \"\(pattern)\" COUNT \(limit)"
}

/// Build a SCAN command with filters applied.
/// Redis does not support server-side filtering beyond pattern matching;
/// complex filters are applied client-side after SCAN results are returned.
/// Build a key-browse command from filter tuples.
/// A ("Key", "MATCH", glob) tuple is a raw glob typed by the user and passed verbatim.
/// A ("Type", "=", type) tuple selects a server-side SCAN TYPE scope.
/// Legacy Key operators (CONTAINS, STARTS WITH, ...) still resolve to an escaped glob.
func buildFilteredQuery(
namespace: String,
filters: [(column: String, op: String, value: String)],
logicMode: String = "and",
limit: Int = 200
limit: Int = 200,
offset: Int = 0
) -> String {
// Check if any filter targets the Key column with a pattern-compatible operator
let keyPattern = extractKeyPattern(from: filters, namespace: namespace)
if let pattern = keyPattern {
return "SCAN 0 MATCH \"\(pattern)\" COUNT \(limit)"
let pattern = extractBrowsePattern(from: filters, namespace: namespace)
let typeScope = extractTypeScope(from: filters)

guard pattern != nil || typeScope != nil else {
return buildBaseQuery(namespace: namespace, limit: limit)
}

return buildBaseQuery(namespace: namespace, limit: limit)
return buildKeyBrowseQuery(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset)
}

func buildKeyBrowseQuery(pattern: String?, typeScope: String?, limit: Int, offset: Int) -> String {
var command = "KEYBROWSE"
if let pattern, !pattern.isEmpty {
command += " MATCH \"\(quoteForCommand(pattern))\""
}
if let typeScope, !typeScope.isEmpty {
command += " TYPE \(typeScope)"
}
command += " LIMIT \(limit) OFFSET \(offset)"
return command
}

/// Build a count command for a namespace.
Expand All @@ -57,6 +72,43 @@ struct RedisQueryBuilder {

// MARK: - Private Helpers

/// Resolve the SCAN MATCH glob from key-column filters.
/// A MATCH op carries a raw glob the user typed; legacy operators build an escaped glob.
private func extractBrowsePattern(
from filters: [(column: String, op: String, value: String)],
namespace: String
) -> String? {
let keyFilters = filters.filter { $0.column == "Key" }
guard keyFilters.count == 1, let filter = keyFilters.first else { return nil }

let prefix = namespace.isEmpty ? "" : namespace

if filter.op == "MATCH" {
return prefix.isEmpty ? filter.value : "\(prefix)\(filter.value)"
}
return extractKeyPattern(from: filters, namespace: namespace)
}

private func extractTypeScope(
from filters: [(column: String, op: String, value: String)]
) -> String? {
let typeFilters = filters.filter { $0.column == "Type" && $0.op == "=" }
guard typeFilters.count == 1, let filter = typeFilters.first, !filter.value.isEmpty else { return nil }
return filter.value.lowercased()
}

private func quoteForCommand(_ str: String) -> String {
var result = ""
for char in str {
switch char {
case "\\": result += "\\\\"
case "\"": result += "\\\""
default: result.append(char)
}
}
return result
}

/// Try to extract a SCAN-compatible glob pattern from key-column filters
private func extractKeyPattern(
from filters: [(column: String, op: String, value: String)],
Expand Down
27 changes: 27 additions & 0 deletions Plugins/TableProPluginKit/BrowseFilterDescriptor.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import Foundation

public struct BrowseFilterDescriptor: Sendable, Equatable {
public struct TypeScope: Sendable, Equatable, Identifiable {
public let id: String
public let label: String

public init(id: String, label: String) {
self.id = id
self.label = label
}
}

public let usesGlob: Bool
public let caseSensitive: Bool
public let typeScopes: [TypeScope]

public init(usesGlob: Bool, caseSensitive: Bool, typeScopes: [TypeScope]) {
self.usesGlob = usesGlob
self.caseSensitive = caseSensitive
self.typeScopes = typeScopes
}
}

public protocol PluginBrowseFilterProvider: AnyObject {
var browseFilterDescriptor: BrowseFilterDescriptor? { get }
}
Loading
Loading