From f925ac639037cbbd91c60318b99d2488c17c5e8b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 12 Jun 2026 00:16:36 +0700 Subject: [PATCH 1/2] feat(plugin-redis): native key-pattern search for key browsing --- CHANGELOG.md | 1 + .../RedisCommandParser.swift | 34 +++++ Plugins/RedisDriverPlugin/RedisPlugin.swift | 17 +++ .../RedisPluginDriver+Operations.swift | 5 + .../RedisPluginDriver+Scan.swift | 26 ++++ .../RedisDriverPlugin/RedisQueryBuilder.swift | 69 ++++++++-- .../BrowseFilterDescriptor.swift | 27 ++++ .../Core/Coordinators/FilterCoordinator.swift | 91 +++++++++++- TablePro/Core/Plugins/PluginManager.swift | 6 + .../Services/Query/TableQueryBuilder.swift | 36 +++++ .../Core/Storage/FilterSettingsStorage.swift | 80 +++++++++++ TablePro/Models/Database/TableFilter.swift | 18 +++ TablePro/Models/UI/FilterState.swift | 28 ++++ .../Views/Filter/KeyPatternSearchBar.swift | 74 ++++++++++ .../Main/Child/MainEditorContentView.swift | 22 +-- .../MainContentCoordinator+Filtering.swift | 13 ++ .../Models/TabFilterStateTests.swift | 46 +++++++ .../Plugins/RedisQueryBuilderTests.swift | 130 +++++------------- docs/databases/redis.mdx | 2 + 19 files changed, 612 insertions(+), 113 deletions(-) create mode 100644 Plugins/TableProPluginKit/BrowseFilterDescriptor.swift create mode 100644 TablePro/Views/Filter/KeyPatternSearchBar.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cbafd913..48de8594a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index ea595f531..c14672738 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -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) case type(key: String) case ttl(key: String) case pttl(key: String) @@ -151,11 +152,44 @@ 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 = 10_000 + 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 + } + default: + break + } + i += 1 + } + return .keyBrowse(pattern: pattern, typeScope: typeScope, limit: limit) + } + // MARK: - Key Commands private static func parseKeyCommand( diff --git a/Plugins/RedisDriverPlugin/RedisPlugin.swift b/Plugins/RedisDriverPlugin/RedisPlugin.swift index 412db55dd..cf56bd174 100644 --- a/Plugins/RedisDriverPlugin/RedisPlugin.swift +++ b/Plugins/RedisDriverPlugin/RedisPlugin.swift @@ -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"), + ] + ) + } +} diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 120200c2a..21f6e3ef5 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -17,6 +17,11 @@ 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): + return try await executeKeyBrowse( + pattern: pattern, typeScope: typeScope, limit: limit, connection: conn, startTime: startTime + ) + case .hget, .hset, .hgetall, .hdel: return try await executeHashOperation(operation, connection: conn, startTime: startTime) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift index ebd86c01b..2f75e32a7 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift @@ -11,6 +11,7 @@ extension RedisPluginDriver { func scanAllKeys( connection conn: RedisPluginConnection, pattern: String?, + typeFilter: String? = nil, maxKeys: Int ) async throws -> [String] { var allKeys: [String] = [] @@ -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) @@ -59,6 +63,28 @@ extension RedisPluginDriver { return allKeys.sorted() } + func executeKeyBrowse( + pattern: String?, + typeScope: String?, + limit: Int, + connection conn: RedisPluginConnection, + startTime: Date + ) async throws -> PluginQueryResult { + let cap = max(1, limit) + let rawKeys = try await scanAllKeys( + connection: conn, + pattern: pattern, + typeFilter: typeScope, + maxKeys: cap + ) + var seen = Set() + let uniqueKeys = rawKeys.filter { seen.insert($0).inserted } + let wasCapped = rawKeys.count >= cap + return try await buildKeyBrowseResult( + keys: uniqueKeys, connection: conn, startTime: startTime, isTruncated: wasCapped + ) + } + func handleScanResult( _ result: RedisReply, connection conn: RedisPluginConnection, diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index 8b8e303a6..2fa666d27 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -10,6 +10,8 @@ import Foundation import TableProPluginKit struct RedisQueryBuilder { + static let maxKeyBrowseScan = 10_000 + // MARK: - Base Query /// Build a SCAN command for browsing keys in a namespace. @@ -25,22 +27,36 @@ 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 ) -> 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: Self.maxKeyBrowseScan) + } + + func buildKeyBrowseQuery(pattern: String?, typeScope: String?, limit: 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)" + return command } /// Build a count command for a namespace. @@ -57,6 +73,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)], diff --git a/Plugins/TableProPluginKit/BrowseFilterDescriptor.swift b/Plugins/TableProPluginKit/BrowseFilterDescriptor.swift new file mode 100644 index 000000000..fefc85186 --- /dev/null +++ b/Plugins/TableProPluginKit/BrowseFilterDescriptor.swift @@ -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 } +} diff --git a/TablePro/Core/Coordinators/FilterCoordinator.swift b/TablePro/Core/Coordinators/FilterCoordinator.swift index 8e111d00c..9e192b7ce 100644 --- a/TablePro/Core/Coordinators/FilterCoordinator.swift +++ b/TablePro/Core/Coordinators/FilterCoordinator.swift @@ -84,12 +84,86 @@ final class FilterCoordinator { func restoreFiltersForTable(_ tableName: String) { restoreLastFilters(for: tableName) + restoreBrowseSearch(for: tableName) guard let (_, tabIndex) = parent.tabManager.selectedTabAndIndex else { return } - if parent.tabManager.tabs[tabIndex].filterState.hasAppliedFilters { + let state = parent.tabManager.tabs[tabIndex].filterState + if state.hasAppliedFilters || state.hasActiveBrowseSearch { rebuildTableQuery(at: tabIndex) } } + var usesBrowseSearch: Bool { + PluginManager.shared.browseFilterDescriptor(for: parent.connection.type) != nil + } + + func applyBrowseSearch(_ search: BrowseSearchState) { + guard let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, + let tableName = tab.tableContext.tableName else { return } + + let capturedTabIndex = tabIndex + let capturedTableName = tableName + parent.confirmDiscardChangesIfNeeded(action: .filter) { [weak self] confirmed in + guard let self, confirmed else { return } + guard capturedTabIndex < parent.tabManager.tabs.count else { return } + + mutateSelectedTabFilterState { state in + state.browseSearch = search + state.isVisible = true + } + parent.tabManager.mutate(at: capturedTabIndex) { $0.pagination.reset() } + rebuildTableQuery(at: capturedTabIndex) + saveBrowseSearch(for: capturedTableName) + parent.runQuery() + } + } + + func clearBrowseSearchAndReload() { + guard let (tab, tabIndex) = parent.tabManager.selectedTabAndIndex, + let tableName = tab.tableContext.tableName else { return } + + let capturedTabIndex = tabIndex + let capturedTableName = tableName + parent.confirmDiscardChangesIfNeeded(action: .filter) { [weak self] confirmed in + guard let self, confirmed else { return } + guard capturedTabIndex < parent.tabManager.tabs.count else { return } + + mutateSelectedTabFilterState { state in + state.browseSearch = BrowseSearchState() + } + parent.tabManager.mutate(at: capturedTabIndex) { $0.pagination.reset() } + rebuildTableQuery(at: capturedTabIndex) + saveBrowseSearch(for: capturedTableName) + parent.runQuery() + } + } + + func saveBrowseSearch(for tableName: String) { + guard let tab = parent.tabManager.selectedTab else { return } + FilterSettingsStorage.shared.saveBrowseSearch( + tab.filterState.browseSearch, + for: tableName, + connectionId: parent.connectionId, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName + ) + } + + private func restoreBrowseSearch(for tableName: String) { + guard usesBrowseSearch, let tab = parent.tabManager.selectedTab else { return } + let saved = FilterSettingsStorage.shared.loadBrowseSearch( + for: tableName, + connectionId: parent.connectionId, + databaseName: tab.tableContext.databaseName, + schemaName: tab.tableContext.schemaName + ) + mutateSelectedTabFilterState { state in + state.browseSearch = saved + if saved.isActive { + state.isVisible = true + } + } + } + func rebuildTableQuery(at tabIndex: Int) { guard tabIndex < parent.tabManager.tabs.count, let tableName = parent.tabManager.tabs[tabIndex].tableContext.tableName else { return } @@ -102,7 +176,20 @@ final class FilterCoordinator { : buffer.columns let newQuery: String - if hasFilters { + if usesBrowseSearch, tab.filterState.hasActiveBrowseSearch { + let search = tab.filterState.browseSearch + newQuery = parent.queryBuilder.buildKeyPatternBrowseQuery( + tableName: tableName, + schemaName: tab.tableContext.schemaName, + pattern: search.pattern, + typeScope: search.typeScope, + sortState: tab.sortState, + columns: columns, + selectColumns: parent.selectColumns(for: tab), + limit: tab.pagination.pageSize, + offset: tab.pagination.currentOffset + ) + } else if hasFilters { newQuery = parent.queryBuilder.buildFilteredQuery( tableName: tableName, schemaName: tab.tableContext.schemaName, diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 3afcecd67..58205a26e 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -832,6 +832,12 @@ final class PluginManager { return provider.defaultSortHint(forTable: table) } + func browseFilterDescriptor(for type: DatabaseType) -> BrowseFilterDescriptor? { + guard let driver = driverPlugins[type.pluginTypeId] else { return nil } + guard let provider = driver as? PluginBrowseFilterProvider else { return nil } + return provider.browseFilterDescriptor + } + func replaceExistingPlugin(bundleId: String) { guard let existingIndex = plugins.firstIndex(where: { $0.id == bundleId }) else { return } unregisterCapabilities(pluginId: bundleId) diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index ceddd88ef..9a4200a7b 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -134,6 +134,42 @@ struct TableQueryBuilder { return query } + func buildKeyPatternBrowseQuery( + tableName: String, + schemaName: String? = nil, + pattern: String, + typeScope: String?, + sortState: SortState? = nil, + columns: [String] = [], + selectColumns: [String]? = nil, + limit: Int = 200, + offset: Int = 0 + ) -> String { + if let pluginDriver { + let sortCols = sortColumnsAsTuples(sortState) + var tuples: [(column: String, op: String, value: String)] = [] + let trimmedPattern = pattern.trimmingCharacters(in: .whitespaces) + if !trimmedPattern.isEmpty { + tuples.append((column: "Key", op: "MATCH", value: trimmedPattern)) + } + if let typeScope, !typeScope.isEmpty { + tuples.append((column: "Type", op: "=", value: typeScope)) + } + if let result = pluginDriver.buildFilteredQuery( + table: tableName, schema: schemaName, filters: tuples, + logicMode: "and", sortColumns: sortCols, + columns: selectColumns ?? columns, limit: limit, offset: offset + ) { + return result + } + } + + return buildBaseQuery( + tableName: tableName, schemaName: schemaName, sortState: sortState, + columns: columns, selectColumns: selectColumns, limit: limit, offset: offset + ) + } + func buildFilteredCountQuery( tableName: String, schemaName: String? = nil, diff --git a/TablePro/Core/Storage/FilterSettingsStorage.swift b/TablePro/Core/Storage/FilterSettingsStorage.swift index 59af594ca..424e3e1f7 100644 --- a/TablePro/Core/Storage/FilterSettingsStorage.swift +++ b/TablePro/Core/Storage/FilterSettingsStorage.swift @@ -93,6 +93,7 @@ final class FilterSettingsStorage { private var cachedSettings: FilterSettings? private var lastFiltersCache: [String: [TableFilter]] = [:] + private var browseSearchCache: [String: BrowseSearchState] = [:] private convenience init() { self.init(filterStateDirectory: Self.resolvedFilterStateDirectory(), defaults: .standard) @@ -225,6 +226,84 @@ final class FilterSettingsStorage { lastFiltersCache.removeValue(forKey: key) } + func loadBrowseSearch( + for tableName: String, + connectionId: UUID, + databaseName: String, + schemaName: String? + ) -> BrowseSearchState { + let key = browseKey( + tableName: tableName, + connectionId: connectionId, + databaseName: databaseName, + schemaName: schemaName + ) + if let cached = browseSearchCache[key] { return cached } + + let fileURL = fileURL(forKey: key) + guard FileManager.default.fileExists(atPath: fileURL.path) else { + let empty = BrowseSearchState() + browseSearchCache[key] = empty + return empty + } + + do { + let data = try Data(contentsOf: fileURL) + let state = try decoder.decode(BrowseSearchState.self, from: data) + browseSearchCache[key] = state + return state + } catch { + Self.logger.error("Failed to load browse search for \(tableName): \(error)") + let empty = BrowseSearchState() + browseSearchCache[key] = empty + return empty + } + } + + func saveBrowseSearch( + _ state: BrowseSearchState, + for tableName: String, + connectionId: UUID, + databaseName: String, + schemaName: String? + ) { + let key = browseKey( + tableName: tableName, + connectionId: connectionId, + databaseName: databaseName, + schemaName: schemaName + ) + let fileURL = fileURL(forKey: key) + + guard state.isActive else { + removeFile(at: fileURL, label: tableName) + browseSearchCache.removeValue(forKey: key) + return + } + + do { + let data = try encoder.encode(state) + try data.write(to: fileURL, options: .atomic) + browseSearchCache[key] = state + } catch { + Self.logger.error("Failed to save browse search for \(tableName): \(error)") + } + } + + private func browseKey( + tableName: String, + connectionId: UUID, + databaseName: String, + schemaName: String? + ) -> String { + compositeKey( + tableName: tableName, + connectionId: connectionId, + databaseName: databaseName, + schemaName: schemaName + ) + ".browse" + } + func clearAllLastFilters() { let fm = FileManager.default do { @@ -236,6 +315,7 @@ final class FilterSettingsStorage { Self.logger.error("Failed to enumerate filter state directory: \(error.localizedDescription)") } lastFiltersCache.removeAll() + browseSearchCache.removeAll() } private func fileURL(forKey key: String) -> URL { diff --git a/TablePro/Models/Database/TableFilter.swift b/TablePro/Models/Database/TableFilter.swift index da5ba8f39..639985325 100644 --- a/TablePro/Models/Database/TableFilter.swift +++ b/TablePro/Models/Database/TableFilter.swift @@ -187,12 +187,30 @@ struct TabFilterState: Equatable, Hashable, Codable { var commit: FilterCommit? var isVisible: Bool var filterLogicMode: FilterLogicMode + var keyPattern: String + var keyTypeScope: String? init(isVisible: Bool = false) { self.filters = [] self.commit = nil self.isVisible = isVisible self.filterLogicMode = .and + self.keyPattern = "" + self.keyTypeScope = nil + } + + enum CodingKeys: String, CodingKey { + case filters, commit, isVisible, filterLogicMode, keyPattern, keyTypeScope + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.filters = try container.decodeIfPresent([TableFilter].self, forKey: .filters) ?? [] + self.commit = try container.decodeIfPresent(FilterCommit.self, forKey: .commit) + self.isVisible = try container.decodeIfPresent(Bool.self, forKey: .isVisible) ?? false + self.filterLogicMode = try container.decodeIfPresent(FilterLogicMode.self, forKey: .filterLogicMode) ?? .and + self.keyPattern = try container.decodeIfPresent(String.self, forKey: .keyPattern) ?? "" + self.keyTypeScope = try container.decodeIfPresent(String.self, forKey: .keyTypeScope) } var appliedFilters: [TableFilter] { diff --git a/TablePro/Models/UI/FilterState.swift b/TablePro/Models/UI/FilterState.swift index 529b028bf..22f4a71b8 100644 --- a/TablePro/Models/UI/FilterState.swift +++ b/TablePro/Models/UI/FilterState.swift @@ -19,11 +19,39 @@ enum FilterCommit: Codable, Equatable, Hashable { case solo(UUID) } +struct BrowseSearchState: Codable, Equatable { + var pattern: String + var typeScope: String? + + init(pattern: String = "", typeScope: String? = nil) { + self.pattern = pattern + self.typeScope = typeScope + } + + var isActive: Bool { + !pattern.trimmingCharacters(in: .whitespaces).isEmpty || typeScope != nil + } +} + extension TabFilterState { init(filters: [TableFilter], commit: FilterCommit?, isVisible: Bool, filterLogicMode: FilterLogicMode) { self.filters = filters self.commit = commit self.isVisible = isVisible self.filterLogicMode = filterLogicMode + self.keyPattern = "" + self.keyTypeScope = nil + } + + var browseSearch: BrowseSearchState { + get { BrowseSearchState(pattern: keyPattern, typeScope: keyTypeScope) } + set { + keyPattern = newValue.pattern + keyTypeScope = newValue.typeScope + } + } + + var hasActiveBrowseSearch: Bool { + browseSearch.isActive } } diff --git a/TablePro/Views/Filter/KeyPatternSearchBar.swift b/TablePro/Views/Filter/KeyPatternSearchBar.swift new file mode 100644 index 000000000..7becaa965 --- /dev/null +++ b/TablePro/Views/Filter/KeyPatternSearchBar.swift @@ -0,0 +1,74 @@ +import SwiftUI +import TableProPluginKit + +struct KeyPatternSearchBar: View { + let coordinator: MainContentCoordinator + let descriptor: BrowseFilterDescriptor + + @State private var pattern: String = "" + @State private var typeScope: String? + + var body: some View { + HStack(spacing: 8) { + NativeSearchField( + text: $pattern, + placeholder: placeholder, + controlSize: .regular, + onSubmit: apply + ) + .frame(maxWidth: 360) + + if !descriptor.typeScopes.isEmpty { + Picker(String(localized: "Type"), selection: $typeScope) { + Text("All Types").tag(String?.none) + ForEach(descriptor.typeScopes) { scope in + Text(scope.label).tag(String?.some(scope.id)) + } + } + .labelsHidden() + .pickerStyle(.menu) + .fixedSize() + .onChange(of: typeScope) { _, _ in apply() } + } + + if isActive { + Button(String(localized: "Clear"), action: clear) + .buttonStyle(.borderless) + } + + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .onAppear(perform: syncFromState) + .onChange(of: coordinator.selectedTabFilterState.browseSearch) { _, _ in + syncFromState() + } + } + + private var isActive: Bool { + !pattern.trimmingCharacters(in: .whitespaces).isEmpty || typeScope != nil + } + + private var placeholder: String { + descriptor.usesGlob + ? String(localized: "Key pattern, e.g. user:*") + : String(localized: "Key pattern") + } + + private func syncFromState() { + let search = coordinator.selectedTabFilterState.browseSearch + pattern = search.pattern + typeScope = search.typeScope + } + + private func apply() { + coordinator.applyBrowseSearch(BrowseSearchState(pattern: pattern, typeScope: typeScope)) + } + + private func clear() { + pattern = "" + typeScope = nil + coordinator.clearBrowseSearchAndReload() + } +} diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift index 71e5a6860..0c83c2a21 100644 --- a/TablePro/Views/Main/Child/MainEditorContentView.swift +++ b/TablePro/Views/Main/Child/MainEditorContentView.swift @@ -477,15 +477,19 @@ struct MainEditorContentView: View { } } else { if tab.filterState.isVisible && tab.tabType == .table { - FilterPanelView( - coordinator: coordinator, - columns: resolvedRows.columns, - primaryKeyColumn: changeManager.primaryKeyColumn, - databaseType: connection.type, - enumValuesByColumn: resolvedRows.columnEnumValues, - onApply: onApplyFilters, - onUnset: onClearFilters - ) + if let descriptor = coordinator.browseFilterDescriptor { + KeyPatternSearchBar(coordinator: coordinator, descriptor: descriptor) + } else { + FilterPanelView( + coordinator: coordinator, + columns: resolvedRows.columns, + primaryKeyColumn: changeManager.primaryKeyColumn, + databaseType: connection.type, + enumValuesByColumn: resolvedRows.columnEnumValues, + onApply: onApplyFilters, + onUnset: onClearFilters + ) + } Divider() } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift index 04c8640ca..e793ec068 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Filtering.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit extension MainContentCoordinator { func applyFilters(_ filters: [TableFilter]) { @@ -14,6 +15,18 @@ extension MainContentCoordinator { filterCoordinator.clearFiltersAndReload() } + var browseFilterDescriptor: BrowseFilterDescriptor? { + PluginManager.shared.browseFilterDescriptor(for: connection.type) + } + + func applyBrowseSearch(_ search: BrowseSearchState) { + filterCoordinator.applyBrowseSearch(search) + } + + func clearBrowseSearchAndReload() { + filterCoordinator.clearBrowseSearchAndReload() + } + func restoreFiltersForTable(_ tableName: String) { filterCoordinator.restoreFiltersForTable(tableName) } diff --git a/TableProTests/Models/TabFilterStateTests.swift b/TableProTests/Models/TabFilterStateTests.swift index 49b94ab65..9a710e8b5 100644 --- a/TableProTests/Models/TabFilterStateTests.swift +++ b/TableProTests/Models/TabFilterStateTests.swift @@ -74,4 +74,50 @@ struct TabFilterStateTests { #expect(decoded == state) #expect(decoded.appliedFilters.map(\.id) == [filter.id]) } + + @Test("browseSearch reads and writes the key pattern fields") + func browseSearchAccessor() { + var state = TabFilterState() + #expect(!state.hasActiveBrowseSearch) + + state.browseSearch = BrowseSearchState(pattern: "user:*", typeScope: "hash") + #expect(state.keyPattern == "user:*") + #expect(state.keyTypeScope == "hash") + #expect(state.hasActiveBrowseSearch) + #expect(state.browseSearch == BrowseSearchState(pattern: "user:*", typeScope: "hash")) + } + + @Test("A type scope alone counts as an active browse search") + func typeScopeAloneIsActive() { + var state = TabFilterState() + state.browseSearch = BrowseSearchState(pattern: "", typeScope: "stream") + #expect(state.hasActiveBrowseSearch) + } + + @Test("Legacy JSON without key pattern fields decodes with defaults") + func decodesLegacyJsonWithoutKeyPatternFields() throws { + let legacy = Data(""" + {"filters":[],"isVisible":true,"filterLogicMode":"AND"} + """.utf8) + + let decoded = try JSONDecoder().decode(TabFilterState.self, from: legacy) + + #expect(decoded.isVisible) + #expect(decoded.keyPattern.isEmpty) + #expect(decoded.keyTypeScope == nil) + #expect(!decoded.hasActiveBrowseSearch) + } + + @Test("Key pattern fields survive a Codable round-trip") + func browseSearchRoundTrip() throws { + var state = TabFilterState(isVisible: true) + state.browseSearch = BrowseSearchState(pattern: "cache:*", typeScope: "string") + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(TabFilterState.self, from: data) + + #expect(decoded.keyPattern == "cache:*") + #expect(decoded.keyTypeScope == "string") + #expect(decoded == state) + } } diff --git a/TableProTests/Plugins/RedisQueryBuilderTests.swift b/TableProTests/Plugins/RedisQueryBuilderTests.swift index e35a9bfa0..9c2a479e1 100644 --- a/TableProTests/Plugins/RedisQueryBuilderTests.swift +++ b/TableProTests/Plugins/RedisQueryBuilderTests.swift @@ -6,8 +6,8 @@ // import Foundation -import Testing import TableProPluginKit +import Testing @Suite("Redis Query Builder") struct RedisQueryBuilderTests { @@ -45,63 +45,60 @@ struct RedisQueryBuilderTests { #expect(query == "SCAN 0 MATCH \"test:*\" COUNT 100") } - // MARK: - Filtered Query + // MARK: - Key Browse Query - @Test("Contains filter on Key column") - func containsFilterOnKey() { + @Test("Raw glob pattern is passed verbatim to MATCH") + func rawGlobPatternVerbatim() { let query = builder.buildFilteredQuery( namespace: "", - filters: [(column: "Key", op: "CONTAINS", value: "session")] + filters: [(column: "Key", op: "MATCH", value: "user:*")] ) - #expect(query == "SCAN 0 MATCH \"*session*\" COUNT 200") + #expect(query == "KEYBROWSE MATCH \"user:*\" LIMIT 10000") } - @Test("Contains filter with namespace prefix") - func containsFilterWithNamespace() { + @Test("Type scope maps to a server-side TYPE clause") + func typeScopeMapsToType() { let query = builder.buildFilteredQuery( - namespace: "app:", - filters: [(column: "Key", op: "CONTAINS", value: "user")] + namespace: "", + filters: [(column: "Type", op: "=", value: "STRING")] ) - #expect(query == "SCAN 0 MATCH \"app:*user*\" COUNT 200") + #expect(query == "KEYBROWSE TYPE string LIMIT 10000") } - @Test("StartsWith filter on Key column") - func startsWithFilterOnKey() { + @Test("Pattern and type scope combine into one KEYBROWSE command") + func patternAndTypeCombine() { let query = builder.buildFilteredQuery( namespace: "", - filters: [(column: "Key", op: "STARTS WITH", value: "user")] + filters: [ + (column: "Key", op: "MATCH", value: "session:*"), + (column: "Type", op: "=", value: "hash") + ] ) - #expect(query == "SCAN 0 MATCH \"user*\" COUNT 200") + #expect(query == "KEYBROWSE MATCH \"session:*\" TYPE hash LIMIT 10000") } - @Test("EndsWith filter on Key column") - func endsWithFilterOnKey() { - let query = builder.buildFilteredQuery( - namespace: "", - filters: [(column: "Key", op: "ENDS WITH", value: ":data")] - ) - #expect(query == "SCAN 0 MATCH \"*:data\" COUNT 200") + @Test("Quotes and backslashes in a pattern are escaped for the command string") + func patternQuotingEscaped() { + let query = builder.buildKeyBrowseQuery(pattern: "a\"b\\c", typeScope: nil, limit: 10_000) + #expect(query == "KEYBROWSE MATCH \"a\\\"b\\\\c\" LIMIT 10000") } - @Test("Equals filter on Key column") - func equalsFilterOnKey() { - let query = builder.buildFilteredQuery( - namespace: "", - filters: [(column: "Key", op: "=", value: "mykey")] - ) - #expect(query == "SCAN 0 MATCH \"mykey\" COUNT 200") + @Test("Empty pattern with no type scope produces a bare LIMIT browse") + func emptyPatternNoScope() { + let query = builder.buildKeyBrowseQuery(pattern: "", typeScope: nil, limit: 10_000) + #expect(query == "KEYBROWSE LIMIT 10000") } - @Test("Equals filter with namespace") - func equalsFilterWithNamespace() { + @Test("Legacy Contains operator resolves to an escaped glob KEYBROWSE") + func legacyContainsResolvesToKeyBrowse() { let query = builder.buildFilteredQuery( - namespace: "ns:", - filters: [(column: "Key", op: "=", value: "mykey")] + namespace: "", + filters: [(column: "Key", op: "CONTAINS", value: "session")] ) - #expect(query == "SCAN 0 MATCH \"ns:mykey\" COUNT 200") + #expect(query == "KEYBROWSE MATCH \"*session*\" LIMIT 10000") } - @Test("Non-Key column filter falls back to base query") + @Test("Non-Key, non-Type filter falls back to base SCAN") func nonKeyColumnFallsBack() { let query = builder.buildFilteredQuery( namespace: "test:", @@ -110,73 +107,18 @@ struct RedisQueryBuilderTests { #expect(query == "SCAN 0 MATCH \"test:*\" COUNT 200") } - @Test("Multiple filters fall back to base query (only single Key filter supported)") - func multipleFiltersFallBack() { + @Test("Multiple Key filters fall back to base SCAN") + func multipleKeyFiltersFallBack() { let query = builder.buildFilteredQuery( namespace: "", filters: [ - (column: "Key", op: "CONTAINS", value: "a"), - (column: "Key", op: "CONTAINS", value: "b") + (column: "Key", op: "MATCH", value: "a*"), + (column: "Key", op: "MATCH", value: "b*") ] ) #expect(query == "SCAN 0 MATCH \"*\" COUNT 200") } - @Test("Unsupported operator on Key falls back to base query") - func unsupportedOperatorFallsBack() { - let query = builder.buildFilteredQuery( - namespace: "", - filters: [(column: "Key", op: "IS NULL", value: "")] - ) - #expect(query == "SCAN 0 MATCH \"*\" COUNT 200") - } - - @Test("Custom limit in filtered query") - func filteredQueryCustomLimit() { - let query = builder.buildFilteredQuery( - namespace: "data:", - filters: [(column: "Key", op: "CONTAINS", value: "test")], - limit: 1000 - ) - #expect(query == "SCAN 0 MATCH \"data:*test*\" COUNT 1000") - } - - @Test("Glob special characters are escaped in filter value") - func globCharsEscaped() { - let query = builder.buildFilteredQuery( - namespace: "", - filters: [(column: "Key", op: "CONTAINS", value: "user*data")] - ) - #expect(query == "SCAN 0 MATCH \"*user\\*data*\" COUNT 200") - } - - @Test("Glob question mark is escaped") - func globQuestionMarkEscaped() { - let query = builder.buildFilteredQuery( - namespace: "", - filters: [(column: "Key", op: "CONTAINS", value: "item?")] - ) - #expect(query == "SCAN 0 MATCH \"*item\\?*\" COUNT 200") - } - - @Test("Glob bracket is escaped") - func globBracketEscaped() { - let query = builder.buildFilteredQuery( - namespace: "", - filters: [(column: "Key", op: "CONTAINS", value: "[test]")] - ) - #expect(query == "SCAN 0 MATCH \"*\\[test\\]*\" COUNT 200") - } - - @Test("Backslash is escaped") - func backslashEscaped() { - let query = builder.buildFilteredQuery( - namespace: "", - filters: [(column: "Key", op: "CONTAINS", value: "path\\to")] - ) - #expect(query == "SCAN 0 MATCH \"*path\\\\to*\" COUNT 200") - } - // MARK: - Count Query @Test("Count with empty namespace uses DBSIZE") diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index 93f5e294b..ae0ee6a64 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -65,6 +65,8 @@ For untrusted-CA endpoints (Upstash, internal load balancers), pick **Required ( **TTL Management**: View TTL for each key. `-1` = no expiration, `-2` = doesn't exist. Update TTL directly from interface. +**Key Filtering**: Toggle the filter bar to search keys by pattern. Patterns use Redis glob syntax (`*` matches any sequence, `?` matches one character, `[ae]` matches a character set) and are case-sensitive, the same as `redis-cli`. The type scope narrows results to one value type (String, Hash, List, Set, Sorted Set, Stream). Matching runs server-side with `SCAN MATCH` and `SCAN TYPE` across the whole keyspace, scanning up to 10,000 matching keys; a truncation indicator appears when more exist. Filtering by key value or TTL is not supported, since Redis has no server-side primitive for it. + **Redis CLI** (execute commands directly): ```redis From 5e4d2b81d0d4e4d6b8be3e1ad3915d4efb7cb307 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 12 Jun 2026 00:28:50 +0700 Subject: [PATCH 2/2] refactor(plugin-redis): page key-browse results by offset and stream full set on export --- .../RedisCommandParser.swift | 12 ++- .../RedisPluginDriver+Operations.swift | 5 +- .../RedisPluginDriver+Scan.swift | 16 ++-- .../RedisDriverPlugin/RedisPluginDriver.swift | 9 +- .../RedisDriverPlugin/RedisQueryBuilder.swift | 11 ++- .../Views/Filter/KeyPatternSearchBar.swift | 2 +- .../Core/Redis/RedisCommandParserTests.swift | 83 +++++++++++++++++++ .../Plugins/RedisQueryBuilderTests.swift | 29 +++++-- 8 files changed, 140 insertions(+), 27 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index c14672738..21d360a9c 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -17,7 +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) + case keyBrowse(pattern: String?, typeScope: String?, limit: Int, offset: Int) case type(key: String) case ttl(key: String) case pttl(key: String) @@ -163,7 +163,8 @@ struct RedisCommandParser { private static func parseKeyBrowse(_ args: [String]) -> RedisOperation { var pattern: String? var typeScope: String? - var limit = 10_000 + var limit = 200 + var offset = 0 var i = 0 while i < args.count { switch args[i].uppercased() { @@ -182,12 +183,17 @@ struct RedisCommandParser { 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) + return .keyBrowse(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset) } // MARK: - Key Commands diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 21f6e3ef5..63434b189 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -17,9 +17,10 @@ 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): + case .keyBrowse(let pattern, let typeScope, let limit, let offset): return try await executeKeyBrowse( - pattern: pattern, typeScope: typeScope, limit: limit, connection: conn, startTime: startTime + pattern: pattern, typeScope: typeScope, limit: limit, offset: offset, + connection: conn, startTime: startTime ) case .hget, .hset, .hgetall, .hdel: diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift index 2f75e32a7..ae8acef8c 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Scan.swift @@ -67,21 +67,27 @@ extension RedisPluginDriver { pattern: String?, typeScope: String?, limit: Int, + offset: Int, connection conn: RedisPluginConnection, startTime: Date ) async throws -> PluginQueryResult { - let cap = max(1, limit) + let scanCap = RedisPluginDriver.maxKeyBrowseScan let rawKeys = try await scanAllKeys( connection: conn, pattern: pattern, typeFilter: typeScope, - maxKeys: cap + maxKeys: scanCap ) var seen = Set() - let uniqueKeys = rawKeys.filter { seen.insert($0).inserted } - let wasCapped = rawKeys.count >= cap + 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...Continuation ) async throws { continuation.yield(.header(PluginStreamHeader( @@ -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) @@ -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 ) } diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index 2fa666d27..a114ea4b9 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -10,8 +10,6 @@ import Foundation import TableProPluginKit struct RedisQueryBuilder { - static let maxKeyBrowseScan = 10_000 - // MARK: - Base Query /// Build a SCAN command for browsing keys in a namespace. @@ -35,7 +33,8 @@ struct RedisQueryBuilder { namespace: String, filters: [(column: String, op: String, value: String)], logicMode: String = "and", - limit: Int = 200 + limit: Int = 200, + offset: Int = 0 ) -> String { let pattern = extractBrowsePattern(from: filters, namespace: namespace) let typeScope = extractTypeScope(from: filters) @@ -44,10 +43,10 @@ struct RedisQueryBuilder { return buildBaseQuery(namespace: namespace, limit: limit) } - return buildKeyBrowseQuery(pattern: pattern, typeScope: typeScope, limit: Self.maxKeyBrowseScan) + return buildKeyBrowseQuery(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset) } - func buildKeyBrowseQuery(pattern: String?, typeScope: String?, limit: Int) -> String { + func buildKeyBrowseQuery(pattern: String?, typeScope: String?, limit: Int, offset: Int) -> String { var command = "KEYBROWSE" if let pattern, !pattern.isEmpty { command += " MATCH \"\(quoteForCommand(pattern))\"" @@ -55,7 +54,7 @@ struct RedisQueryBuilder { if let typeScope, !typeScope.isEmpty { command += " TYPE \(typeScope)" } - command += " LIMIT \(limit)" + command += " LIMIT \(limit) OFFSET \(offset)" return command } diff --git a/TablePro/Views/Filter/KeyPatternSearchBar.swift b/TablePro/Views/Filter/KeyPatternSearchBar.swift index 7becaa965..6ab90780e 100644 --- a/TablePro/Views/Filter/KeyPatternSearchBar.swift +++ b/TablePro/Views/Filter/KeyPatternSearchBar.swift @@ -47,7 +47,7 @@ struct KeyPatternSearchBar: View { } private var isActive: Bool { - !pattern.trimmingCharacters(in: .whitespaces).isEmpty || typeScope != nil + BrowseSearchState(pattern: pattern, typeScope: typeScope).isActive } private var placeholder: String { diff --git a/TableProTests/Core/Redis/RedisCommandParserTests.swift b/TableProTests/Core/Redis/RedisCommandParserTests.swift index f9ca4ebcd..549b6d9c2 100644 --- a/TableProTests/Core/Redis/RedisCommandParserTests.swift +++ b/TableProTests/Core/Redis/RedisCommandParserTests.swift @@ -680,6 +680,50 @@ struct RedisCommandParserTokenizerTests { } } +@Suite("RedisCommandParser - KEYBROWSE round-trip") +struct RedisKeyBrowseRoundTripTests { + private let builder = RedisQueryBuilder() + + @Test("A built key-browse command parses back to its pattern, type, limit, and offset") + func keyBrowseRoundTrips() throws { + let command = builder.buildKeyBrowseQuery(pattern: "user:*", typeScope: "hash", limit: 100, offset: 200) + let op = try TestRedisCommandParser.parse(command) + guard case .keyBrowse(let pattern, let typeScope, let limit, let offset) = op else { + Issue.record("Expected .keyBrowse, got \(op)") + return + } + #expect(pattern == "user:*") + #expect(typeScope == "hash") + #expect(limit == 100) + #expect(offset == 200) + } + + @Test("A pattern with quotes and spaces survives the build and parse round-trip") + func quotedPatternRoundTrips() throws { + let raw = #"a "b" c*"# + let command = builder.buildKeyBrowseQuery(pattern: raw, typeScope: nil, limit: 200, offset: 0) + let op = try TestRedisCommandParser.parse(command) + guard case .keyBrowse(let pattern, let typeScope, _, _) = op else { + Issue.record("Expected .keyBrowse, got \(op)") + return + } + #expect(pattern == raw) + #expect(typeScope == nil) + } + + @Test("A type-only key-browse command parses with no pattern") + func typeOnlyRoundTrips() throws { + let command = builder.buildKeyBrowseQuery(pattern: nil, typeScope: "stream", limit: 200, offset: 0) + let op = try TestRedisCommandParser.parse(command) + guard case .keyBrowse(let pattern, let typeScope, _, _) = op else { + Issue.record("Expected .keyBrowse, got \(op)") + return + } + #expect(pattern == nil) + #expect(typeScope == "stream") + } +} + // MARK: - Private Local Helpers (copied from RedisDriverPlugin) private enum TestRedisOperation { @@ -688,6 +732,7 @@ private enum TestRedisOperation { 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) @@ -778,11 +823,49 @@ private struct TestRedisCommandParser { case "PING", "INFO", "DBSIZE", "FLUSHDB", "SELECT", "CONFIG", "MULTI", "EXEC", "DISCARD": return try parseServerCommand(command, args: args, tokens: tokens) + case "KEYBROWSE": + return parseKeyBrowse(args) default: return .command(args: tokens) } } + private static func parseKeyBrowse(_ args: [String]) -> TestRedisOperation { + 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) + } + private static func parseKeyCommand(_ command: String, args: [String]) throws -> TestRedisOperation { switch command { case "GET": diff --git a/TableProTests/Plugins/RedisQueryBuilderTests.swift b/TableProTests/Plugins/RedisQueryBuilderTests.swift index 9c2a479e1..f3d575d00 100644 --- a/TableProTests/Plugins/RedisQueryBuilderTests.swift +++ b/TableProTests/Plugins/RedisQueryBuilderTests.swift @@ -53,7 +53,7 @@ struct RedisQueryBuilderTests { namespace: "", filters: [(column: "Key", op: "MATCH", value: "user:*")] ) - #expect(query == "KEYBROWSE MATCH \"user:*\" LIMIT 10000") + #expect(query == "KEYBROWSE MATCH \"user:*\" LIMIT 200 OFFSET 0") } @Test("Type scope maps to a server-side TYPE clause") @@ -62,7 +62,7 @@ struct RedisQueryBuilderTests { namespace: "", filters: [(column: "Type", op: "=", value: "STRING")] ) - #expect(query == "KEYBROWSE TYPE string LIMIT 10000") + #expect(query == "KEYBROWSE TYPE string LIMIT 200 OFFSET 0") } @Test("Pattern and type scope combine into one KEYBROWSE command") @@ -74,19 +74,30 @@ struct RedisQueryBuilderTests { (column: "Type", op: "=", value: "hash") ] ) - #expect(query == "KEYBROWSE MATCH \"session:*\" TYPE hash LIMIT 10000") + #expect(query == "KEYBROWSE MATCH \"session:*\" TYPE hash LIMIT 200 OFFSET 0") + } + + @Test("Page limit and offset pass through to the command") + func limitAndOffsetPassThrough() { + let query = builder.buildFilteredQuery( + namespace: "", + filters: [(column: "Key", op: "MATCH", value: "user:*")], + limit: 50, + offset: 100 + ) + #expect(query == "KEYBROWSE MATCH \"user:*\" LIMIT 50 OFFSET 100") } @Test("Quotes and backslashes in a pattern are escaped for the command string") func patternQuotingEscaped() { - let query = builder.buildKeyBrowseQuery(pattern: "a\"b\\c", typeScope: nil, limit: 10_000) - #expect(query == "KEYBROWSE MATCH \"a\\\"b\\\\c\" LIMIT 10000") + let query = builder.buildKeyBrowseQuery(pattern: "a\"b\\c", typeScope: nil, limit: 200, offset: 0) + #expect(query == "KEYBROWSE MATCH \"a\\\"b\\\\c\" LIMIT 200 OFFSET 0") } - @Test("Empty pattern with no type scope produces a bare LIMIT browse") + @Test("Empty pattern with no type scope produces a bare browse command") func emptyPatternNoScope() { - let query = builder.buildKeyBrowseQuery(pattern: "", typeScope: nil, limit: 10_000) - #expect(query == "KEYBROWSE LIMIT 10000") + let query = builder.buildKeyBrowseQuery(pattern: "", typeScope: nil, limit: 200, offset: 0) + #expect(query == "KEYBROWSE LIMIT 200 OFFSET 0") } @Test("Legacy Contains operator resolves to an escaped glob KEYBROWSE") @@ -95,7 +106,7 @@ struct RedisQueryBuilderTests { namespace: "", filters: [(column: "Key", op: "CONTAINS", value: "session")] ) - #expect(query == "KEYBROWSE MATCH \"*session*\" LIMIT 10000") + #expect(query == "KEYBROWSE MATCH \"*session*\" LIMIT 200 OFFSET 0") } @Test("Non-Key, non-Type filter falls back to base SCAN")