From 9672ce18af6ee6dbecdc16835f7b6f8f9249881a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:30:15 +0700 Subject: [PATCH 01/30] refactor(coordinator): remove the empty driver branches left after reading the session status --- .../Core/Services/Infrastructure/SessionStateFactory.swift | 4 ---- TablePro/Views/Main/MainContentCommandActions.swift | 2 -- TablePro/Views/Main/MainContentCoordinator.swift | 4 ---- 3 files changed, 10 deletions(-) diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index fe889dcca6..5664f1d325 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -79,10 +79,6 @@ enum SessionStateFactory { if let session = DatabaseManager.shared.session(for: connection.id) { toolbarSt.updateConnectionState(from: session.reportedStatus) - if let driver = session.driver { - } - } else if let driver = DatabaseManager.shared.driver(for: connection.id) { - toolbarSt.connectionState = .connected } if connection.type.pluginTypeId == "Redis" { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index d70c207091..3f5c55bc01 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -1476,8 +1476,6 @@ final class MainContentCommandActions: ObservableObject { private func handleDatabaseDidConnect() { Task { [weak coordinator] in guard let coordinator, !coordinator.isTearingDown else { return } - if let driver = DatabaseManager.shared.driver(for: coordinator.connection.id) { - } if case .loading = SchemaService.shared.state(for: coordinator.connection.id) { coordinator.initRedisKeyTreeIfNeeded() return diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 167c6eb598..4ffb5a2d5d 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -1025,10 +1025,6 @@ final class MainContentCoordinator: ObservableObject { if let session = services.databaseManager.session(for: connectionId) { toolbarState.updateConnectionState(from: session.reportedStatus) - if let driver = session.driver { - } - } else if let driver = services.databaseManager.driver(for: connectionId) { - toolbarState.connectionState = .connected } } From ee9f4253752ff1b6381cee2f648da1b236a1b9d6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:30:22 +0700 Subject: [PATCH 02/30] fix(editor): dim Run and Explain Query in the Query menu for an editor holding only whitespace --- .../TableProSQLGrammar/StatementBlank.swift | 2 +- .../Models/Query/QueryTab+Protection.swift | 5 +++- .../Main/MainContentCommandActions.swift | 15 ++++++------ .../Query/QueryTabProtectionTests.swift | 9 ++++++++ .../Main/CommandActionsDispatchTests.swift | 23 +++++++++++++++++++ 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/Packages/TableProCore/Sources/TableProSQLGrammar/StatementBlank.swift b/Packages/TableProCore/Sources/TableProSQLGrammar/StatementBlank.swift index eaa63ae30c..db76b4e827 100644 --- a/Packages/TableProCore/Sources/TableProSQLGrammar/StatementBlank.swift +++ b/Packages/TableProCore/Sources/TableProSQLGrammar/StatementBlank.swift @@ -20,7 +20,7 @@ public enum StatementBlank { } public static func hasContent(_ text: String) -> Bool { - text.contains { !isBlank($0) } + text.unicodeScalars.contains { !isBlank($0) } } public static func blankLength(in text: NSString, at offset: Int) -> Int { diff --git a/TablePro/Models/Query/QueryTab+Protection.swift b/TablePro/Models/Query/QueryTab+Protection.swift index 27fa0d54f4..6a7a7716bf 100644 --- a/TablePro/Models/Query/QueryTab+Protection.swift +++ b/TablePro/Models/Query/QueryTab+Protection.swift @@ -1,8 +1,11 @@ import Foundation +import TableProSQLGrammar extension QueryTab { + /// The run path's own rule, so a command is offered exactly when running it would send + /// something: whitespace, control and zero-width characters alone run nothing. var hasQueryText: Bool { - !content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + StatementBlank.hasContent(content.query) } var hasExecutedQuery: Bool { diff --git a/TablePro/Views/Main/MainContentCommandActions.swift b/TablePro/Views/Main/MainContentCommandActions.swift index 3f5c55bc01..9ff1d2040b 100644 --- a/TablePro/Views/Main/MainContentCommandActions.swift +++ b/TablePro/Views/Main/MainContentCommandActions.swift @@ -543,7 +543,7 @@ final class MainContentCommandActions: ObservableObject { } var hasQueryText: Bool { - !(coordinator?.tabManager.selectedTab?.content.query.isEmpty ?? true) + coordinator?.tabManager.selectedTab?.hasQueryText ?? false } /// Whether there are pending data changes that the SQL preview can show. @@ -1049,8 +1049,7 @@ final class MainContentCommandActions: ObservableObject { } // Save As: untitled query tab with content else if let tab = coordinator?.tabManager.selectedTab, - tab.tabType == .query, tab.content.sourceFileURL == nil, - !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + tab.tabType == .query, tab.content.sourceFileURL == nil, tab.hasQueryText { saveFileAs() } } @@ -1086,15 +1085,15 @@ final class MainContentCommandActions: ObservableObject { } func aiExplainQuery() { - guard let query = coordinator?.tabManager.selectedTab?.content.query, !query.isEmpty else { return } + guard let tab = coordinator?.tabManager.selectedTab, tab.hasQueryText else { return } coordinator?.showAssistant() - coordinator?.aiViewModel?.handleExplainSelection(query) + coordinator?.aiViewModel?.handleExplainSelection(tab.content.query) } func aiOptimizeQuery() { - guard let query = coordinator?.tabManager.selectedTab?.content.query, !query.isEmpty else { return } + guard let tab = coordinator?.tabManager.selectedTab, tab.hasQueryText else { return } coordinator?.showAssistant() - coordinator?.aiViewModel?.handleOptimizeSelection(query) + coordinator?.aiViewModel?.handleOptimizeSelection(tab.content.query) } func previewFKReference() { @@ -1212,7 +1211,7 @@ final class MainContentCommandActions: ObservableObject { var canSaveAsFavorite: Bool { guard let tab = coordinator?.tabManager.selectedTab else { return false } - return tab.tabType == .query && !tab.content.query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return tab.tabType == .query && tab.hasQueryText } func previewSQL() { diff --git a/TableProTests/Models/Query/QueryTabProtectionTests.swift b/TableProTests/Models/Query/QueryTabProtectionTests.swift index 32a543b99d..0615199d2c 100644 --- a/TableProTests/Models/Query/QueryTabProtectionTests.swift +++ b/TableProTests/Models/Query/QueryTabProtectionTests.swift @@ -13,6 +13,15 @@ struct QueryTabProtectionTests { #expect(!tab.showsUnsavedIndicator) } + /// The run path treats these as blank, so a tab holding only them has nothing to run. + @Test("Zero-width and control characters alone are not query text") + func invisibleCharactersAreNotQueryText() { + #expect(!QueryTab(query: "\u{200B}\u{FEFF}").hasQueryText) + #expect(!QueryTab(query: "\u{00A0}\u{0000}").hasQueryText) + #expect(QueryTab(query: "-- note").hasQueryText) + #expect(QueryTab(query: "\u{200B}SELECT 1").hasQueryText) + } + @Test("Typed SQL in a scratch tab is reopenable work and shows the unsaved dot") func typedScratchTabIsProtected() { let tab = QueryTab(query: "SELECT 1") diff --git a/TableProTests/Views/Main/CommandActionsDispatchTests.swift b/TableProTests/Views/Main/CommandActionsDispatchTests.swift index bfe37bfe84..6423352c8c 100644 --- a/TableProTests/Views/Main/CommandActionsDispatchTests.swift +++ b/TableProTests/Views/Main/CommandActionsDispatchTests.swift @@ -72,6 +72,29 @@ struct CommandActionsDispatchTests { return (actions, coordinator) } + // MARK: - Query text + + /// The Query menu used to test the text for emptiness while the editor bar trimmed it, so + /// Run and Explain Query stayed enabled over an editor holding only whitespace. + @Test("An editor holding only whitespace or invisible characters has no query text for the menu") + func blankEditorHasNoQueryText() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(initialQuery: " \n\t\u{200B}\u{FEFF}", databaseName: "testdb") + + #expect(!actions.hasQueryText) + #expect(!actions.canSaveAsFavorite) + #expect(actions.canClearQuery) + } + + @Test("An editor with a statement has query text for the menu") + func editorWithStatementHasQueryText() { + let (actions, coordinator) = makeSUT() + coordinator.tabManager.addTab(initialQuery: " SELECT 1 ", databaseName: "testdb") + + #expect(actions.hasQueryText) + #expect(actions.canSaveAsFavorite) + } + // MARK: - loadQueryIntoEditor @Test("loadQueryIntoEditor forwards query to coordinator and updates tab") From 8f3e5c2ae0070a021ea9b8a53f4781a45637d74f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:30:22 +0700 Subject: [PATCH 03/30] fix(plugins): give Redshift and Teradata their own Explain variants and keep curated ones a plugin leaves empty --- .../CockroachPluginDriver.swift | 6 ------ .../PostgreSQLPluginDriver.swift | 6 ------ .../PostgreSQLDriverPlugin/RedshiftPluginDriver.swift | 6 ------ .../PluginMetadataRegistry+CuratedDefaults.swift | 5 +++-- .../PluginMetadataRegistry+MySQLVariantDefaults.swift | 7 +++++++ .../PluginMetadataRegistry+RegistryDefaults.swift | 4 +++- .../PluginMetadataRegistry+SnapshotAdoption.swift | 11 +++++++++++ TablePro/Core/Plugins/PluginMetadataRegistry.swift | 1 + .../Services/Query/ExplainPlanFormatDefaults.swift | 2 +- .../Query/ExplainPlanFormatResolutionTests.swift | 1 - docs/features/explain-visualization.mdx | 4 +++- 11 files changed, 29 insertions(+), 24 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift index e22c635048..35e8edd699 100644 --- a/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/CockroachPluginDriver.swift @@ -52,12 +52,6 @@ final class CockroachPluginDriver: LibPQBackedDriver, @unchecked Sendable { cachedServerVersion ?? core.serverVersion } - // MARK: - EXPLAIN - - func buildExplainQuery(_ sql: String) -> String? { - "EXPLAIN \(sql)" - } - // MARK: - Schema func fetchTables(schema: String?) async throws -> [PluginTableInfo] { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index aca06e12bc..7a4e86f3bc 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -107,12 +107,6 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { catalogPresence?.hasSequences ?? versionedCapabilities.hasSequencesCatalog } - // MARK: - EXPLAIN - - func buildExplainQuery(_ sql: String) -> String? { - "EXPLAIN \(sql)" - } - // MARK: - Foreign Keys func foreignKeyDisableStatements() -> [String]? { diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index aa79dec858..46c474d788 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -46,12 +46,6 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } } - // MARK: - EXPLAIN - - func buildExplainQuery(_ sql: String) -> String? { - "EXPLAIN \(sql)" - } - // MARK: - Schema /// Refreshed from `onPostConnect` and whenever the schema list is loaded, so diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift index 2611d32114..a72f094fa5 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift @@ -9,7 +9,8 @@ import TableProPluginKit /// What the app knows about a database type before its plugin loads. /// /// The primary type ids here are overwritten by `buildMetadataSnapshot` the moment the plugin -/// registers, so these are the pre-load answer for those. For a variant id they are the whole +/// registers, so these are the pre-load answer for those, apart from an explain list the plugin +/// leaves empty, which keeps the one here. For a variant id they are the whole /// answer: `registerVariant` keeps the curated entry and ignores the plugin's own statics, which /// is the only reason MariaDB, TiDB, Databend, OceanBase, Redshift, CockroachDB and PGlite can differ from /// the plugin that drives them. @@ -448,7 +449,7 @@ extension PluginMetadataRegistry { displayName: "Redshift", iconName: "redshift-icon", defaultPort: 5_439, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false, isDownloadable: false, primaryUrlScheme: "redshift", parameterStyle: .dollar, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + navigationModel: .standard, explainVariants: redshiftExplainVariants, pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["redshift"], postConnectActions: [.selectSchemaFromLastSession], brandColorHex: "#205B8E", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift index 381e6bdc8b..6201c6ac76 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+MySQLVariantDefaults.swift @@ -12,6 +12,13 @@ extension PluginMetadataRegistry { ExplainVariant(id: "explain-analyze", label: "EXPLAIN ANALYZE", sqlPrefix: "EXPLAIN ANALYZE", format: .plainText), ] + /// Redshift's EXPLAIN takes VERBOSE and nothing else: no FORMAT, no ANALYZE. It answers one + /// text row per plan line. + static let redshiftExplainVariants: [ExplainVariant] = [ + ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), + ExplainVariant(id: "verbose", label: "EXPLAIN VERBOSE", sqlPrefix: "EXPLAIN VERBOSE", format: .plainText), + ] + static let oceanbaseExplainVariants: [ExplainVariant] = [ ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText), ] diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 6c1e455f1b..7e66e8a8d5 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -315,7 +315,9 @@ extension PluginMetadataRegistry { displayName: "Teradata", iconName: "teradata-icon", defaultPort: 1_025, requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "teradata", parameterStyle: .questionMark, - navigationModel: .standard, explainVariants: [], pathFieldRole: .database, + navigationModel: .standard, + explainVariants: [ExplainVariant(id: "explain", label: "EXPLAIN", sqlPrefix: "EXPLAIN", format: .plainText)], + pathFieldRole: .database, supportsHealthMonitor: false, urlSchemes: ["teradata"], postConnectActions: [.selectDatabaseFromLastSession], brandColorHex: "#F37440", diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index cd1c2e9ca5..a6945aec79 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -75,6 +75,17 @@ extension PluginMetadataRegistry { /// A name is a system database or schema when either the plugin or the app's curated entry lists it. An installed /// plugin can predate the app's list or report none at all: every published Oracle plugin lists no system /// schemas, which left `SYS` and `XDB` among the user schemas whichever plugin version was installed. + /// A plugin that declares no explain variants says nothing about Explain, which is not the + /// same as switching it off: DuckDB's plugin declares none and lost the curated `EXPLAIN` the + /// moment it loaded. A plugin that declares its own list still wins. + static func adoptCuratedExplainVariants( + _ snapshot: inout PluginMetadataSnapshot, + registryDefault: PluginMetadataSnapshot + ) { + guard snapshot.explainVariants.isEmpty, !registryDefault.explainVariants.isEmpty else { return } + snapshot = snapshot.withExplainVariants(registryDefault.explainVariants) + } + static func adoptCuratedSystemNames( _ snapshot: inout PluginMetadataSnapshot, registryDefault: PluginMetadataSnapshot diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift index f1bee7fe33..010bb4adc6 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift @@ -501,6 +501,7 @@ final class PluginMetadataRegistry: @unchecked Sendable { resolved = resolved.withIsDownloadable(registryDefault.isDownloadable) Self.adoptCuratedCaseSensitivity(&resolved, registryDefault: registryDefault) Self.adoptCuratedSystemNames(&resolved, registryDefault: registryDefault) + Self.adoptCuratedExplainVariants(&resolved, registryDefault: registryDefault) if Self.declaresLegacySchemaOnlyRouting(resolved, registryDefault: registryDefault) { Logger(subsystem: "com.TablePro", category: "PluginMetadataRegistry").notice( "Plugin '\(typeId, privacy: .public)' declares legacy two-tier switching for a schema-only engine; applying the app's switch routing" diff --git a/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift b/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift index 6e21b713d9..2c9d318404 100644 --- a/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift +++ b/TablePro/Core/Services/Query/ExplainPlanFormatDefaults.swift @@ -13,7 +13,7 @@ import TableProPluginKit enum ExplainPlanFormatDefaults { static func format(for databaseType: DatabaseType) -> ExplainPlanFormat { switch databaseType { - case .postgresql, .redshift, .pglite: + case .postgresql, .pglite: return .postgresJson case .mysql, .mariadb: return .mysqlComposite diff --git a/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift b/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift index b3e527a85d..419ef159c8 100644 --- a/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift +++ b/TableProTests/Core/Services/Query/ExplainPlanFormatResolutionTests.swift @@ -48,7 +48,6 @@ struct ExplainPlanFormatResolutionTests { @Test("A database type the app knows resolves a format even when the variant declares none") func fallsBackToCuratedDefault() { #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .pglite) == .postgresJson) - #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .redshift) == .postgresJson) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .cloudflareD1) == .sqliteQueryPlan) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .libsql) == .sqliteQueryPlan) #expect(ExplainFormatResolver.resolve(declared: .plainText, databaseType: .turso) == .sqliteQueryPlan) diff --git a/docs/features/explain-visualization.mdx b/docs/features/explain-visualization.mdx index 87e4ecfb92..f1a1267876 100644 --- a/docs/features/explain-visualization.mdx +++ b/docs/features/explain-visualization.mdx @@ -135,7 +135,8 @@ Properties the driver reported as false or zero are left out of **Details**. | Database | Variants | Diagram and tree | |---|---|---| -| PostgreSQL, PGlite, Redshift | EXPLAIN, EXPLAIN ANALYZE | From `EXPLAIN (FORMAT JSON)` | +| PostgreSQL, PGlite | EXPLAIN, EXPLAIN ANALYZE | From `EXPLAIN (FORMAT JSON)` | +| Redshift | EXPLAIN, EXPLAIN VERBOSE | Raw text only | | CockroachDB | EXPLAIN, EXPLAIN ANALYZE | From the text plan | | MySQL | EXPLAIN, EXPLAIN (JSON) | From JSON, and from a typed `EXPLAIN FORMAT=TREE` or `EXPLAIN ANALYZE`. Plain EXPLAIN stays in the results grid | | MariaDB | EXPLAIN, EXPLAIN (JSON) | From JSON, including a typed `ANALYZE FORMAT=JSON`. Plain EXPLAIN stays in the results grid | @@ -148,6 +149,7 @@ Properties the driver reported as false or zero are left out of **Details**. | Cloudflare R2 SQL | Explain, Explain (JSON) | Raw text only | | BigQuery | Dry Run (Cost) | A dry run cost estimate, no plan | | Spanner | Plan | From the indented text | +| Teradata | EXPLAIN | Raw text only | An engine missing from the table has no plan to show, and **Query > Explain Query** is dimmed for it. On MongoDB, call `.explain()` on the query in the editor instead. From c7cba39b392952871e37404d118b413ba19fe6a6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:30:23 +0700 Subject: [PATCH 04/30] fix(mcp): send explain_query only a variant the database declares, and refuse analyze without one --- .../Core/MCP/MCPConnectionBridge+Data.swift | 56 ++++++--- .../Core/MCP/MCPExplainStatementTests.swift | 118 ++++++++++++++++++ docs/external-api/mcp-tools.mdx | 2 +- 3 files changed, 156 insertions(+), 20 deletions(-) create mode 100644 TableProTests/Core/MCP/MCPExplainStatementTests.swift diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index 1db34ca43a..6a47f1ddf0 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -345,37 +345,55 @@ extension MCPConnectionBridge { return .object(payload) } + /// Only a variant the engine declares is sent, which is the editor's own rule. Making up an + /// `EXPLAIN` for an engine without one sent `EXPLAIN GET k` to Redis, and answering `analyze` + /// with the first variant returned an estimate to a caller who asked for a measured run. static func explainStatement( for query: String, databaseType: DatabaseType, variantId: String?, analyze: Bool ) throws -> String { + let trimmed = statementText(query, databaseType: databaseType) + guard !trimmed.isEmpty else { + throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) + } + guard !QueryClassifier.isExplainStatement(trimmed) else { return trimmed } let variants = databaseType.explainVariants - let prefix: String - if let variantId { - guard let variant = variants.first(where: { $0.id == variantId }) else { + guard let first = variants.first else { + throw DatabaseAccessError.invalidArgument(String(localized: "This database does not explain statements.")) + } + let variant = try explainVariant(id: variantId, analyze: analyze, in: variants, first: first) + return "\(variant.sqlPrefix) \(trimmed)" + } + + private static func explainVariant( + id: String?, + analyze: Bool, + in variants: [ExplainVariant], + first: ExplainVariant + ) throws -> ExplainVariant { + let offered = variants.map(\.id).joined(separator: ", ") + if let id { + guard let variant = variants.first(where: { $0.id == id }) else { throw DatabaseAccessError.invalidArgument( - String( - format: String(localized: "Unknown explain variant '%@'."), - variantId - ) + String(format: String(localized: "Unknown explain variant '%@'. This database offers: %@."), id, offered) ) } - prefix = variant.sqlPrefix - } else if analyze, let variant = variants.first(where: { $0.sqlPrefix.uppercased().contains("ANALYZE") }) { - prefix = variant.sqlPrefix - } else if let variant = variants.first { - prefix = variant.sqlPrefix - } else { - prefix = analyze ? "EXPLAIN ANALYZE" : "EXPLAIN" + return variant } - let trimmed = statementText(query, databaseType: databaseType) - guard !trimmed.isEmpty else { - throw DatabaseAccessError.invalidArgument(String(localized: "The query is empty.")) + guard analyze else { return first } + guard let variant = variants.first(where: { $0.sqlPrefix.uppercased().contains("ANALYZE") }) else { + throw DatabaseAccessError.invalidArgument( + String( + format: String( + localized: "This database has no explain variant that runs the statement. Leave 'analyze' off, or pass one of these as 'variant': %@." + ), + offered + ) + ) } - guard !QueryClassifier.isExplainStatement(trimmed) else { return trimmed } - return "\(prefix) \(trimmed)" + return variant } static func explainVariants(for databaseType: DatabaseType) -> JsonValue { diff --git a/TableProTests/Core/MCP/MCPExplainStatementTests.swift b/TableProTests/Core/MCP/MCPExplainStatementTests.swift new file mode 100644 index 0000000000..250219caca --- /dev/null +++ b/TableProTests/Core/MCP/MCPExplainStatementTests.swift @@ -0,0 +1,118 @@ +// +// MCPExplainStatementTests.swift +// TableProTests +// +// explain_query made up an `EXPLAIN` prefix for any engine that declares no explain variant, so +// Redis was sent `EXPLAIN GET k`, and answered `analyze` with the first variant, which returned +// an estimate to a caller that asked for a measured run. +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("MCP explain statement") +struct MCPExplainStatementTests { + private func message(of attempt: () throws -> String) -> String? { + do { + _ = try attempt() + return nil + } catch let error as DatabaseAccessError { + guard case .invalidArgument(let detail) = error else { return nil } + return detail + } catch { + return nil + } + } + + private func statement(_ query: String, _ type: DatabaseType, variant: String? = nil, analyze: Bool = false) throws -> String { + try MCPConnectionBridge.explainStatement(for: query, databaseType: type, variantId: variant, analyze: analyze) + } + + @Test("An engine that declares no explain variant gets no invented EXPLAIN") + func engineWithoutVariantsIsRefused() throws { + let refusal = String(localized: "This database does not explain statements.") + #expect(message { try statement("GET k", .redis) } == refusal) + #expect(message { try statement("GET k", .redis, analyze: true) } == refusal) + #expect(try statement("EXPLAIN GET k", .redis) == "EXPLAIN GET k") + } + + @Test("A declared variant prefixes the statement, and analyze picks the one that runs it") + func declaredVariantPrefixes() throws { + #expect(try statement("SELECT 1", .cockroachdb) == "EXPLAIN SELECT 1") + #expect(try statement("SELECT 1", .cockroachdb, analyze: true) == "EXPLAIN ANALYZE SELECT 1") + } + + @Test("Analyze on an engine with no variant that runs the statement is refused, naming the variants") + func analyzeWithoutRunningVariantIsRefused() throws { + let refusal = message { try statement("SELECT 1", .redshift, analyze: true) } + #expect(refusal?.contains("explain, verbose") == true) + #expect(try statement("SELECT 1", .redshift, variant: "verbose", analyze: true) == "EXPLAIN VERBOSE SELECT 1") + } + + @Test("An unknown variant names the ones the engine offers") + func unknownVariantNamesOffered() { + #expect(message { try statement("SELECT 1", .redshift, variant: "nope") }?.contains("explain, verbose") == true) + } + + @Test("A typed EXPLAIN on an engine that explains is passed through") + func typedExplainPassesThrough() throws { + #expect(try statement("EXPLAIN SELECT 1", .postgresql) == "EXPLAIN SELECT 1") + } + + @Test("Teradata's EXPLAIN request modifier is offered") + func teradataExplains() throws { + #expect(try statement("SELECT 1", .teradata) == "EXPLAIN SELECT 1") + } +} + +@Suite("Plugin metadata registry - explain variants", .serialized) +struct PluginMetadataRegistryExplainVariantTests { + private let registry = PluginMetadataRegistry.shared + + /// DuckDB's plugin declares no variants, and registering it used to replace the curated + /// `EXPLAIN` with nothing, which took Explain away the moment the plugin loaded. + @Test("A plugin that declares no explain variants keeps the curated ones") + func emptyPluginListKeepsCurated() throws { + let curated = try #require(registry.snapshot(for: .duckdb)) + defer { registry.unregister(typeId: "DuckDB") } + registry.register(snapshot: curated.withExplainVariants([]), forTypeId: "DuckDB") + + #expect(registry.snapshot(for: .duckdb)?.explainVariants.map(\.id) == curated.explainVariants.map(\.id)) + #expect(registry.snapshot(for: .duckdb)?.explainVariants.isEmpty == false) + } + + @Test("A plugin's own explain variants win over the curated ones") + func pluginListWins() throws { + let curated = try #require(registry.snapshot(for: .duckdb)) + defer { registry.unregister(typeId: "DuckDB") } + let own = ExplainVariant(id: "own", label: "Own", sqlPrefix: "EXPLAIN ANALYZE", format: .plainText) + registry.register(snapshot: curated.withExplainVariants([own]), forTypeId: "DuckDB") + + #expect(registry.snapshot(for: .duckdb)?.explainVariants.map(\.id) == ["own"]) + } + + @Test("An engine with no curated explain variants stays without Explain") + func noCuratedVariantsStaysOff() throws { + let curated = try #require(registry.snapshot(for: .redis)) + defer { registry.unregister(typeId: "Redis") } + registry.register(snapshot: curated.withExplainVariants([]), forTypeId: "Redis") + + #expect(registry.snapshot(for: .redis)?.explainVariants.isEmpty == true) + } + + /// Redshift takes `EXPLAIN [VERBOSE]` only; PostgreSQL's `FORMAT JSON` and `ANALYZE` are syntax + /// errors there, and the curated list is what `registerVariant` keeps. + @Test("Redshift keeps its own variants over PostgreSQL's") + func redshiftKeepsItsOwn() throws { + let postgres = try #require(registry.snapshot(for: .postgresql)) + defer { registry.unregister(typeId: "Redshift") } + registry.registerVariant(pluginSnapshot: postgres, forTypeId: "Redshift", primaryTypeId: "PostgreSQL") + + #expect(registry.snapshot(for: .redshift)?.explainVariants.map(\.sqlPrefix) == ["EXPLAIN", "EXPLAIN VERBOSE"]) + #expect(ExplainPlanFormatDefaults.format(for: .redshift) == .plainText) + #expect(ExplainPlanFormatDefaults.format(for: .pglite) == .postgresJson) + } +} diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index 8aec606bd4..b9f2eb7230 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -115,7 +115,7 @@ One statement, 100 KB at most. `DROP` and `TRUNCATE` are refused here; use `conf ### `explain_query` -Pass the query with no `EXPLAIN` prefix, and with no `variant` to see what this engine offers in `available_variants[]`. `analyze: true` runs the statement for real, so an analyzed write needs `tools:write` and Safe Mode approval. +Pass the query with no `EXPLAIN` prefix. Without `variant` the engine's first variant runs, and every result lists them in `available_variants[]`. `analyze: true` picks the variant that runs the statement, so an analyzed write needs `tools:write` and Safe Mode approval, and an engine with no such variant refuses it. An engine missing from the [support table](/features/explain-visualization#database-support) is refused. ### `export_data` From 0eb4b60e7966892aa1b7288278c0b3511ceea5b8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:30:30 +0700 Subject: [PATCH 05/30] fix(connections): open the Redis database a connection names, including db4 and over a tunnel --- .../RedisDatabaseIndex.swift | 5 ++ TablePro/Core/Database/DatabaseDriver.swift | 2 +- .../Database/DatabaseManager+Sessions.swift | 23 +---- .../Database/DatabaseManager+Tunnel.swift | 1 + .../Infrastructure/SessionStateFactory.swift | 3 +- .../Connection/ConnectionURLFormatter.swift | 13 ++- ...atabaseConnection+RedisDatabaseIndex.swift | 40 +++++++++ .../ConnectionFormCoordinator.swift | 4 +- .../ViewModels/AdvancedPaneViewModel.swift | 6 +- .../Database/DatabaseManagerTunnelTests.swift | 33 +++++++ .../ConnectionURLFormatterTests.swift | 24 ++++++ ...aseConnectionRedisDatabaseIndexTests.swift | 85 +++++++++++++++++++ .../AdvancedPaneViewModelTests.swift | 32 +++++++ project.yml | 2 + 14 files changed, 241 insertions(+), 32 deletions(-) create mode 100644 TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift create mode 100644 TableProTests/Models/DatabaseConnectionRedisDatabaseIndexTests.swift diff --git a/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift b/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift index 422961fbe9..76b7118ace 100644 --- a/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift +++ b/Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift @@ -12,6 +12,11 @@ nonisolated enum RedisDatabaseIndex { return parse(database) ?? 0 } + static func selectableIndex(_ value: String) -> Int? { + guard let index = parse(value), selectable.contains(index) else { return nil } + return index + } + static func parse(_ value: String) -> Int? { let trimmed = value.trimmingCharacters(in: .whitespaces) if let index = Int(trimmed) { return index } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 970ea7d0ae..7504bdadf9 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -864,7 +864,7 @@ enum DatabaseDriverFactory { fields["mongoReadPreference"] = connection.mongoReadPreference ?? "" fields["mongoWriteConcern"] = connection.mongoWriteConcern ?? "" case .redis: - fields["redisDatabase"] = String(connection.redisDatabase ?? 0) + fields[RedisDatabaseIndex.fieldName] = String(connection.redisDatabaseIndex) case .mssql: fields["mssqlSchema"] = connection.mssqlSchema ?? "dbo" case .oracle: diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 2ce1088429..581f1bedb0 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -289,26 +289,9 @@ extension DatabaseManager { } } case .selectDatabaseFromConnectionField(let fieldId): - let initialDb: Int - if let fieldValue = resolvedConnection.additionalFields[fieldId], let parsed = Int(fieldValue) { - initialDb = parsed - } else if fieldId == "redisDatabase", let legacy = resolvedConnection.redisDatabase { - initialDb = legacy - } else if let fallback = Int(resolvedConnection.database) { - initialDb = fallback - } else { - initialDb = 0 - } - if initialDb != 0 { - do { - try await (driver as? PluginDriverAdapter)?.switchDatabase(to: String(initialDb)) - activeSessions[connection.id]?.browseDatabase = String(initialDb) - } catch { - Self.logger.error("Failed to switch to database \(initialDb): \(error.localizedDescription)") - } - } else { - activeSessions[connection.id]?.browseDatabase = "0" - } + activeSessions[connection.id]?.browseDatabase = String( + resolvedConnection.databaseIndex(selectedBy: fieldId) + ) case .selectSchemaFromLastSession: if let schemaDriver = driver as? SchemaSwitchable, let savedSchema = appSettingsStorage.loadLastSchema(for: connection.id) { diff --git a/TablePro/Core/Database/DatabaseManager+Tunnel.swift b/TablePro/Core/Database/DatabaseManager+Tunnel.swift index 9ccadf03e6..319d7072c9 100644 --- a/TablePro/Core/Database/DatabaseManager+Tunnel.swift +++ b/TablePro/Core/Database/DatabaseManager+Tunnel.swift @@ -67,6 +67,7 @@ extension DatabaseManager { effectiveFields["mongoParam_directConnection"] = "true" } if connection.type.pluginTypeId == "Redis" { + effectiveFields[RedisDatabaseIndex.fieldName] = String(connection.redisDatabaseIndex) effectiveFields["redisMode"] = "standalone" } /// Kafka's Metadata reply names every broker by its ADVERTISED address, and a client is diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 5664f1d325..6a372a1029 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -82,8 +82,7 @@ enum SessionStateFactory { } if connection.type.pluginTypeId == "Redis" { - let dbIndex = connection.redisDatabase ?? Int(connection.database) ?? 0 - toolbarSt.currentDatabase = String(dbIndex) + toolbarSt.currentDatabase = String(connection.redisDatabaseIndex) } if let payload { diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift index 7300d8d259..5a0cf61ce9 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLFormatter.swift @@ -122,8 +122,8 @@ struct ConnectionURLFormatter { var sshPathComponent = connection.type == .oracle ? (connection.oracleServiceName ?? connection.database) : connection.database - if connection.type == .redis, let redisDb = connection.redisDatabase, redisDb > 0 { - sshPathComponent = String(redisDb) + if connection.type == .redis { + sshPathComponent = redisPath(for: connection) } result += "/\(sshPathComponent)" @@ -164,8 +164,8 @@ struct ConnectionURLFormatter { var pathComponent = connection.type == .oracle ? (connection.oracleServiceName ?? connection.database) : connection.database - if connection.type == .redis, let redisDb = connection.redisDatabase, redisDb > 0 { - pathComponent = String(redisDb) + if connection.type == .redis { + pathComponent = redisPath(for: connection) } result += "/\(pathComponent)" @@ -177,6 +177,11 @@ struct ConnectionURLFormatter { return result } + private static func redisPath(for connection: DatabaseConnection) -> String { + let index = connection.redisDatabaseIndex + return index == 0 ? "" : String(index) + } + private static func buildQueryString( _ connection: DatabaseConnection, sshConfig: SSHConfiguration? = nil diff --git a/TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift b/TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift new file mode 100644 index 0000000000..1e6f133033 --- /dev/null +++ b/TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift @@ -0,0 +1,40 @@ +// +// DatabaseConnection+RedisDatabaseIndex.swift +// TablePro +// + +import Foundation + +extension DatabaseConnection { + private static let redisModeFieldId = "redisMode" + private static let redisClusterMode = "cluster" + + /// The database a Redis connection opens on. A cluster always starts on database 0, whatever a + /// Database Index field left over from a Standalone setup still holds, because the field is + /// hidden in Cluster mode and nothing on screen would say where that value came from. + var redisDatabaseIndex: Int { + isRedisCluster ? 0 : configuredRedisDatabaseIndex + } + + /// The index the connection names, read the way the Redis plugin and the iOS app read it: the + /// Database Index field, then the value saved before that field existed, then the database + /// name, where a synced `db4` means 4. + var configuredRedisDatabaseIndex: Int { + additionalFields[RedisDatabaseIndex.fieldName].flatMap(RedisDatabaseIndex.parse) + ?? redisDatabase + ?? RedisDatabaseIndex.parse(database) + ?? 0 + } + + func databaseIndex(selectedBy fieldId: String) -> Int { + if fieldId == RedisDatabaseIndex.fieldName { return redisDatabaseIndex } + return additionalFields[fieldId].flatMap(RedisDatabaseIndex.parse) + ?? RedisDatabaseIndex.parse(database) + ?? 0 + } + + private var isRedisCluster: Bool { + let mode = additionalFields[Self.redisModeFieldId] ?? "" + return mode.trimmingCharacters(in: .whitespaces).lowercased() == Self.redisClusterMode + } +} diff --git a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift index a60f5c3902..ad5c37d7aa 100644 --- a/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift +++ b/TablePro/Views/ConnectionForm/ConnectionFormCoordinator.swift @@ -340,7 +340,9 @@ final class ConnectionFormCoordinator: ObservableObject { aiPolicy: advanced.aiPolicy, aiRules: aiRules.trimmedRules, externalAccess: advanced.externalAccess, - redisDatabase: advanced.additionalFieldValues["redisDatabase"].map { Int($0) ?? 0 }, + redisDatabase: advanced.additionalFieldValues[RedisDatabaseIndex.fieldName].map { + RedisDatabaseIndex.parse($0) ?? 0 + }, startupCommands: advanced.startupCommands.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : advanced.startupCommands, localOnly: advanced.localOnly, diff --git a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift index 66d3fd31b2..49966e0f3e 100644 --- a/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift +++ b/TablePro/Views/ConnectionForm/ViewModels/AdvancedPaneViewModel.swift @@ -64,10 +64,8 @@ final class AdvancedPaneViewModel: ObservableObject { values[field.id] = defaultValue } } - if connection.additionalFields["redisDatabase"] == nil, - let rdb = connection.redisDatabase - { - values["redisDatabase"] = String(rdb) + if allFields.contains(where: { $0.id == RedisDatabaseIndex.fieldName && $0.section == .advanced }) { + values[RedisDatabaseIndex.fieldName] = String(connection.configuredRedisDatabaseIndex) } additionalFieldValues = values startupCommands = connection.startupCommands ?? "" diff --git a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift index a7be05b460..b13ec68613 100644 --- a/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTunnelTests.swift @@ -28,6 +28,39 @@ struct DatabaseManagerTunnelTests { #expect(tunneled.passwordSource == .env(variable: "DB_PASS")) } + @Test("Tunneled Redis keeps the database index the connection names") + func tunnelKeepsRedisDatabaseIndex() { + let connection = DatabaseConnection( + name: "redis", + host: "cache.internal", + port: 6_379, + type: .redis, + redisDatabase: 4, + additionalFields: ["redisMode": "standalone"] + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.additionalFields["redisDatabase"] == "4") + #expect(tunneled.redisDatabaseIndex == 4) + } + + @Test("Tunneled Redis Cluster opens database 0 after the tunnel forces Standalone") + func tunnelKeepsClusterOnDatabaseZero() { + let connection = DatabaseConnection( + name: "cluster", + host: "node.internal", + port: 7_000, + type: .redis, + additionalFields: ["redisMode": "cluster", "redisDatabase": "4"] + ) + + let tunneled = DatabaseManager.shared.tunneledConnection(from: connection, localPort: 62_000) + + #expect(tunneled.additionalFields["redisMode"] == "standalone") + #expect(tunneled.redisDatabaseIndex == 0) + } + @Test("Tunneled MongoDB collapses the seed list and forces a direct connection") func tunnelForcesMongoDirectConnection() { let connection = DatabaseConnection( diff --git a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift index 44eeb3bec0..cdeeaeef51 100644 --- a/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLFormatterTests.swift @@ -440,6 +440,30 @@ struct ConnectionURLFormatterTests { #expect(url == "redis://localhost/") } + @Test("Redis URL carries the Database Index field when no older index was saved") + func redisURLCarriesTheDatabaseIndexField() { + let conn = DatabaseConnection( + name: "", host: "localhost", port: 6_379, database: "", + username: "", type: .redis, additionalFields: ["redisDatabase": "4"] + ) + let url = ConnectionURLFormatter.format(conn, password: nil, sshPassword: nil) + #expect(url == "redis://localhost/4") + } + + @Test("Redis URL writes an index, never a database name the parser would refuse") + func redisURLNeverWritesADatabaseName() { + let named = DatabaseConnection( + name: "", host: "localhost", port: 6_379, database: "cache", + username: "", type: .redis + ) + let prefixed = DatabaseConnection( + name: "", host: "localhost", port: 6_379, database: "db4", + username: "", type: .redis + ) + #expect(ConnectionURLFormatter.format(named, password: nil, sshPassword: nil) == "redis://localhost/") + #expect(ConnectionURLFormatter.format(prefixed, password: nil, sshPassword: nil) == "redis://localhost/4") + } + // MARK: - MongoDB Auth Params @Test("MongoDB URL includes authSource") diff --git a/TableProTests/Models/DatabaseConnectionRedisDatabaseIndexTests.swift b/TableProTests/Models/DatabaseConnectionRedisDatabaseIndexTests.swift new file mode 100644 index 0000000000..d4539a955d --- /dev/null +++ b/TableProTests/Models/DatabaseConnectionRedisDatabaseIndexTests.swift @@ -0,0 +1,85 @@ +// +// DatabaseConnectionRedisDatabaseIndexTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import Testing + +@Suite("DatabaseConnection Redis database index") +struct DatabaseConnectionRedisDatabaseIndexTests { + private func redis( + field: String? = nil, + legacy: Int? = nil, + database: String = "", + mode: String? = nil + ) -> DatabaseConnection { + var fields: [String: String] = [:] + fields["redisDatabase"] = field + fields["redisMode"] = mode + return DatabaseConnection( + name: "cache", + host: "localhost", + port: 6_379, + database: database, + type: .redis, + redisDatabase: legacy, + additionalFields: fields + ) + } + + @Test("The Database Index field is read first, in either spelling") + func fieldComesFirst() { + #expect(redis(field: "4").redisDatabaseIndex == 4) + #expect(redis(field: "db4").redisDatabaseIndex == 4) + #expect(redis(field: "5", legacy: 3).redisDatabaseIndex == 5) + } + + @Test("A blank field falls back to the index saved before the field existed") + func legacyValueFillsABlankField() { + #expect(redis(field: "", legacy: 3).redisDatabaseIndex == 3) + #expect(redis(legacy: 3, database: "db7").redisDatabaseIndex == 3) + } + + @Test("With no field and no saved index, the database name is read the way iOS reads it") + func databaseNameIsTheLastSource() { + #expect(redis(database: "db7").redisDatabaseIndex == 7) + #expect(redis(database: "7").redisDatabaseIndex == 7) + #expect(redis(database: "cache").redisDatabaseIndex == 0) + #expect(redis().redisDatabaseIndex == 0) + } + + @Test("A negative index is kept so the server refuses it rather than db0 opening") + func negativeIndexIsKept() { + #expect(redis(field: "-1").redisDatabaseIndex == -1) + } + + @Test("A cluster starts on database 0 whatever a hidden field still holds") + func clusterStartsOnDatabaseZero() { + let cluster = redis(field: "4", legacy: 4, database: "db4", mode: " Cluster ") + #expect(cluster.redisDatabaseIndex == 0) + #expect(cluster.configuredRedisDatabaseIndex == 4) + #expect(redis(field: "4", mode: "sentinel").redisDatabaseIndex == 4) + #expect(redis(field: "4", mode: "standalone").redisDatabaseIndex == 4) + } + + @Test("The post-connect action resolves the Redis field through the same rule") + func postConnectFieldUsesTheSameRule() { + #expect(redis(field: "db2").databaseIndex(selectedBy: "redisDatabase") == 2) + #expect(redis(field: "2", mode: "cluster").databaseIndex(selectedBy: "redisDatabase") == 0) + } + + @Test("Another field reads that field and then the database name, never the Redis-only saved index") + func otherFieldIgnoresTheRedisSavedIndex() { + let connection = DatabaseConnection( + name: "other", + database: "db6", + type: .redis, + redisDatabase: 3, + additionalFields: ["other": "8"] + ) + #expect(connection.databaseIndex(selectedBy: "other") == 8) + #expect(connection.databaseIndex(selectedBy: "missing") == 6) + } +} diff --git a/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift index 51479df670..530e911c22 100644 --- a/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift +++ b/TableProTests/Views/ConnectionForm/ViewModels/AdvancedPaneViewModelTests.swift @@ -31,4 +31,36 @@ struct AdvancedPaneViewModelTests { #expect(fields["externalAccess"] == nil) } + + @Test("Shows a Redis database index saved as db4 as the number the stepper takes") + func loadsDbPrefixedRedisIndexAsANumber() throws { + try #require( + PluginManager.shared.additionalConnectionFields(for: .redis) + .contains { $0.id == "redisDatabase" && $0.section == .advanced } + ) + let connection = DatabaseConnection( + name: "cache", + type: .redis, + additionalFields: ["redisDatabase": "db4"] + ) + let viewModel = AdvancedPaneViewModel() + + viewModel.load(from: connection) + + #expect(viewModel.additionalFieldValues["redisDatabase"] == "4") + } + + @Test("Shows the Redis database a synced connection names when no index was saved") + func loadsRedisIndexFromTheDatabaseName() throws { + try #require( + PluginManager.shared.additionalConnectionFields(for: .redis) + .contains { $0.id == "redisDatabase" && $0.section == .advanced } + ) + let connection = DatabaseConnection(name: "cache", database: "db7", type: .redis) + let viewModel = AdvancedPaneViewModel() + + viewModel.load(from: connection) + + #expect(viewModel.additionalFieldValues["redisDatabase"] == "7") + } } diff --git a/project.yml b/project.yml index 1df369005e..b96f81b8cb 100644 --- a/project.yml +++ b/project.yml @@ -127,6 +127,8 @@ targets: - path: TablePro/Resources/ThirdPartyLicenses type: folder buildPhase: resources + # One parse for a Redis database index, shared with the Redis plugin and the iOS app. + - Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift configFiles: Debug: Configs/Version.xcconfig Release: Configs/Version.xcconfig From 2fef140ce71d7ca983a990ad6cbbd02870dd9311 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:30:30 +0700 Subject: [PATCH 06/30] fix(connections): refuse a Redis URL whose path is not a database index --- .../Connection/ConnectionURLParser.swift | 31 +++++++- .../Utilities/ConnectionURLParserTests.swift | 73 +++++++++++++++++++ .../Plugins/RedisDatabaseIndexTests.swift | 10 +++ docs/connections/urls.mdx | 2 +- 4 files changed, 112 insertions(+), 4 deletions(-) diff --git a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift index c11c489f8d..269a8c5c2a 100644 --- a/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift +++ b/TablePro/Core/Utilities/Connection/ConnectionURLParser.swift @@ -62,6 +62,7 @@ enum ConnectionURLParseError: Error, LocalizedError, Equatable { case invalidURL case unsupportedScheme(String) case missingHost + case invalidRedisDatabaseIndex(String) var errorDescription: String? { switch self { @@ -73,6 +74,8 @@ enum ConnectionURLParseError: Error, LocalizedError, Equatable { return String(format: String(localized: "Unsupported database scheme: %@"), scheme) case .missingHost: return String(localized: "Connection URL must include a host") + case .invalidRedisDatabaseIndex(let path): + return String(format: String(localized: "%@ is not a Redis database index."), path) } } } @@ -186,9 +189,12 @@ struct ConnectionURLParser { // Redis-specific: parse database index from path and handle TLS scheme var redisDatabase: Int? if dbType == .redis { - if !database.isEmpty { - redisDatabase = Int(database) + switch redisDatabaseIndex(fromPath: database) { + case .success(let index): + redisDatabase = index database = "" + case .failure(let error): + return .failure(error) } if scheme == "rediss" { sslMode = sslMode ?? .required @@ -396,6 +402,17 @@ struct ConnectionURLParser { database = "" } + var redisDatabase: Int? + if dbType == .redis { + switch redisDatabaseIndex(fromPath: database) { + case .success(let index): + redisDatabase = index + database = "" + case .failure(let error): + return .failure(error) + } + } + return .success(ParsedConnectionURL( type: dbType, host: host, @@ -414,7 +431,7 @@ struct ConnectionURLParser { sshNoAuth: ext.sshNoAuth, agentSocket: ext.agentSocket, connectionName: ext.connectionName, - redisDatabase: nil, + redisDatabase: redisDatabase, statusColor: ext.statusColor, envTag: ext.envTag, schema: ext.schema, @@ -652,6 +669,14 @@ struct ConnectionURLParser { // MARK: - Host/Port Parsing + private static func redisDatabaseIndex(fromPath path: String) -> Result { + guard !path.isEmpty else { return .success(nil) } + guard let index = RedisDatabaseIndex.selectableIndex(path) else { + return .failure(.invalidRedisDatabaseIndex(path)) + } + return .success(index) + } + /// Parse a host:port string, handling IPv6 bracket notation ([::1]:port). /// Returns nil if the string is empty or contains only a bare host with no port. private static func parseHostPort(_ hostPort: String) -> (host: String, port: Int?)? { diff --git a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift index 8aefcace0b..c3ff61d9fc 100644 --- a/TableProTests/Core/Utilities/ConnectionURLParserTests.swift +++ b/TableProTests/Core/Utilities/ConnectionURLParserTests.swift @@ -743,6 +743,79 @@ struct ConnectionURLParserTests { #expect(parsed.database == "") } + @Test("Redis URL with a negative database index is refused") + func redisURLWithNegativeIndexIsRefused() { + let result = ConnectionURLParser.parse("redis://localhost:6379/-1") + guard case .failure(let error) = result else { + Issue.record("Expected failure"); return + } + #expect(error == .invalidRedisDatabaseIndex("-1")) + #expect( + error.localizedDescription + == String(format: String(localized: "%@ is not a Redis database index."), "-1") + ) + } + + @Test( + "Redis URL whose path is not a database index is refused", + arguments: ["1.5", "abc", "2147483647", "3/"] + ) + func redisURLWithInvalidIndexIsRefused(path: String) { + guard case .failure(let error) = ConnectionURLParser.parse("redis://localhost/\(path)") else { + Issue.record("Expected failure"); return + } + #expect(error == .invalidRedisDatabaseIndex(path)) + } + + @Test("Redis URL accepts the highest selectable database index") + func redisURLAcceptsHighestIndex() { + let result = ConnectionURLParser.parse("redis://localhost/2147483646") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.redisDatabase == 2_147_483_646) + } + + @Test("Redis URL reads a db-prefixed path as the database index") + func redisURLReadsDbPrefixedPath() { + let result = ConnectionURLParser.parse("redis://localhost/db4") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.redisDatabase == 4) + #expect(parsed.database == "") + } + + @Test("Redis URL with an empty path names no database index") + func redisURLWithEmptyPath() { + let result = ConnectionURLParser.parse("redis://localhost/") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.redisDatabase == nil) + #expect(parsed.database == "") + } + + @Test("Redis over SSH reads the database index from the path") + func redisOverSSHReadsDatabaseIndex() { + let result = ConnectionURLParser.parse("redis+ssh://user@bastion:22/localhost:6379/3") + guard case .success(let parsed) = result else { + Issue.record("Expected success"); return + } + #expect(parsed.redisDatabase == 3) + #expect(parsed.database == "") + #expect(parsed.sshHost == "bastion") + } + + @Test("Redis over SSH refuses a negative database index") + func redisOverSSHRefusesNegativeIndex() { + let result = ConnectionURLParser.parse("redis+ssh://user@bastion:22/localhost:6379/-1") + guard case .failure(let error) = result else { + Issue.record("Expected failure"); return + } + #expect(error == .invalidRedisDatabaseIndex("-1")) + } + // MARK: - TablePlus Query Parameters @Test("Parse statusColor parameter") diff --git a/TableProTests/Plugins/RedisDatabaseIndexTests.swift b/TableProTests/Plugins/RedisDatabaseIndexTests.swift index 5a80f06941..91cdce1719 100644 --- a/TableProTests/Plugins/RedisDatabaseIndexTests.swift +++ b/TableProTests/Plugins/RedisDatabaseIndexTests.swift @@ -47,4 +47,14 @@ struct RedisDatabaseIndexTests { #expect(RedisDatabaseIndex.parse("cache") == nil) #expect(RedisDatabaseIndex.parse("dbx") == nil) } + + @Test("selectableIndex accepts only an index a server can select") + func selectableIndexHonoursTheRange() { + #expect(RedisDatabaseIndex.selectableIndex("0") == 0) + #expect(RedisDatabaseIndex.selectableIndex("db4") == 4) + #expect(RedisDatabaseIndex.selectableIndex("2147483646") == 2_147_483_646) + #expect(RedisDatabaseIndex.selectableIndex("-1") == nil) + #expect(RedisDatabaseIndex.selectableIndex("2147483647") == nil) + #expect(RedisDatabaseIndex.selectableIndex("abc") == nil) + } } diff --git a/docs/connections/urls.mdx b/docs/connections/urls.mdx index c14c1f3652..ea2e7b07a8 100644 --- a/docs/connections/urls.mdx +++ b/docs/connections/urls.mdx @@ -192,7 +192,7 @@ The part after the host is the database name on most schemes. Seven read it diff | Scheme | The path is | |---|---| -| `redis://`, `rediss://` | The database index, 0 when omitted | +| `redis://`, `rediss://` | The database index, from 0 to 2147483646, and 0 when omitted. `db2` reads as 2, and any other path is refused | | `cassandra://`, `scylladb://` | The default keyspace. Omit it to connect with none | | `sqlite://`, `duckdb://`, `beancount://` | An absolute file path, so the URL carries three slashes | | `quack://` | The alias a remote DuckDB server attaches the database under. See [DuckDB](/databases/duckdb) | From c96698fb755b2798837d09a2823489f445cd8567 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:30:30 +0700 Subject: [PATCH 07/30] fix(connection-form): accept only a whole number in a number field --- .../ConnectionField+IntegerEntry.swift | 32 ++++++++-- .../Views/Connection/ConnectionFieldRow.swift | 14 ++--- .../ConnectionFieldIntegerEntryTests.swift | 62 +++++++++++++++++++ docs/development/plugin-development.mdx | 2 + 4 files changed, 96 insertions(+), 14 deletions(-) diff --git a/TablePro/Core/Plugins/ConnectionField+IntegerEntry.swift b/TablePro/Core/Plugins/ConnectionField+IntegerEntry.swift index 785e68f977..bcf997b115 100644 --- a/TablePro/Core/Plugins/ConnectionField+IntegerEntry.swift +++ b/TablePro/Core/Plugins/ConnectionField+IntegerEntry.swift @@ -7,6 +7,8 @@ import Foundation import TableProPluginKit extension ConnectionField.IntRange { + static let wholeNumbers = ConnectionField.IntRange(0...Int.max) + func clamping(_ value: Int) -> Int { min(max(value, lowerBound), upperBound) } @@ -33,14 +35,32 @@ extension ConnectionField.IntRange { } } +extension ConnectionField.FieldType { + var integerEntryRange: ConnectionField.IntRange? { + switch self { + case .stepper(let range): + return range + case .number: + return .wholeNumbers + case .text, .secure, .dropdown, .toggle, .hostList: + return nil + } + } +} + extension ConnectionField { - /// A stepper field keeps the text as typed so a value can be entered digit by digit, which - /// leaves one below the range possible when the user stops typing. Saving it would hand the - /// driver a number the field says it never accepts. + /// An integer field keeps the text as typed so a value can be entered digit by digit, which + /// leaves one below the range possible when the user stops typing, and an imported or synced + /// value never went through the keystroke filter at all. Saving either would hand the driver + /// a number the field says it never accepts. func rangeIssue(in value: String) -> String? { - guard case .stepper(let range) = fieldType, - let number = Int(value.trimmingCharacters(in: .whitespaces)), - range.clamping(number) != number else { return nil } + guard let range = fieldType.integerEntryRange else { return nil } + let trimmed = value.trimmingCharacters(in: .whitespaces) + guard !trimmed.isEmpty else { return nil } + if let number = Int(trimmed), range.clamping(number) == number { return nil } + guard case .stepper = fieldType else { + return String(format: String(localized: "%@ must be a whole number"), label) + } return String( format: String(localized: "%@ must be between %@ and %@"), label, String(range.lowerBound), String(range.upperBound) diff --git a/TablePro/Views/Connection/ConnectionFieldRow.swift b/TablePro/Views/Connection/ConnectionFieldRow.swift index 9e8c472711..3a8005e537 100644 --- a/TablePro/Views/Connection/ConnectionFieldRow.swift +++ b/TablePro/Views/Connection/ConnectionFieldRow.swift @@ -48,16 +48,14 @@ struct ConnectionFieldRow: View { case .number: TextField( field.label, - text: Binding( - get: { value }, - set: { newValue in - value = String(newValue.unicodeScalars.filter { - CharacterSet.decimalDigits.contains($0) || $0 == "-" || $0 == "." - }) - } - ), + text: $value, prompt: field.placeholder.isEmpty ? nil : Text(field.placeholder) ) + .onChange(of: value) { newValue in + let sanitized = ConnectionField.IntRange.wholeNumbers.fieldText(sanitizing: newValue) + guard sanitized != newValue else { return } + value = sanitized + } case .toggle: Toggle( field.label, diff --git a/TableProTests/Core/Plugins/ConnectionFieldIntegerEntryTests.swift b/TableProTests/Core/Plugins/ConnectionFieldIntegerEntryTests.swift index e3b84af1e4..d1e4fab0b8 100644 --- a/TableProTests/Core/Plugins/ConnectionFieldIntegerEntryTests.swift +++ b/TableProTests/Core/Plugins/ConnectionFieldIntegerEntryTests.swift @@ -111,4 +111,66 @@ struct ConnectionFieldIntegerEntryTests { #expect(stepper.rangeIssue(in: " 120 ") == nil) #expect(field(.text).rangeIssue(in: "0") == nil) } + + @Test("Stepper text that is not a number is an issue, not a silent default") + func unparseableStepperTextIsAnIssue() { + let stepper = field(.stepper(range: timeout)) + #expect(stepper.rangeIssue(in: "abc") != nil) + #expect(stepper.rangeIssue(in: "-") != nil) + } + + @Test("A number field keeps ASCII digits only, with no sign or decimal point") + func wholeNumbersKeepDigits() { + let cases: [(typed: String, expected: String)] = [ + ("1.5", "15"), + ("-3", "3"), + ("9494", "9494"), + ("\u{0663}", ""), + ("\u{FF13}", ""), + ("1,000,000", "1000000"), + ("99999999999999999999", String(Int.max)), + ("", ""), + ] + for entry in cases { + #expect( + ConnectionField.IntRange.wholeNumbers.fieldText(sanitizing: entry.typed) == entry.expected, + "\(entry.typed)" + ) + } + } + + @Test("Only a stepper or number field takes integer entry") + func integerEntryRangeByFieldType() { + #expect(ConnectionField.FieldType.number.integerEntryRange == .wholeNumbers) + #expect(ConnectionField.FieldType.stepper(range: timeout).integerEntryRange == timeout) + #expect(ConnectionField.FieldType.text.integerEntryRange == nil) + #expect(ConnectionField.FieldType.secure.integerEntryRange == nil) + #expect(ConnectionField.FieldType.dropdown(options: []).integerEntryRange == nil) + #expect(ConnectionField.FieldType.toggle.integerEntryRange == nil) + #expect(ConnectionField.FieldType.hostList.integerEntryRange == nil) + } + + @Test("A number field that does not hold a whole number is an issue", arguments: [ + "1.5", "-1", "abc", "99999999999999999999", + ]) + func numberFieldRejectsNonWholeNumbers(value: String) { + let port = ConnectionField(id: "duckdbPort", label: "Port", defaultValue: "9494", fieldType: .number) + #expect(port.rangeIssue(in: value) == String(format: String(localized: "%@ must be a whole number"), "Port")) + } + + @Test("A number field that is empty or a whole number is not an issue", arguments: ["", "0", " 9494 "]) + func numberFieldAcceptsWholeNumbers(value: String) { + let port = ConnectionField(id: "duckdbPort", label: "Port", defaultValue: "9494", fieldType: .number) + #expect(port.rangeIssue(in: value) == nil) + } + + @Test("Every shipped integer field's default passes its own check") + func shippedDefaultsPassTheirOwnCheck() { + for entry in PluginMetadataRegistry.shared.builtInDefaults() { + for field in entry.snapshot.connection.additionalConnectionFields { + guard let defaultValue = field.defaultValue else { continue } + #expect(field.rangeIssue(in: defaultValue) == nil, "\(entry.typeId).\(field.id)") + } + } + } } diff --git a/docs/development/plugin-development.mdx b/docs/development/plugin-development.mdx index 4e0ce10f84..1bc515fd3d 100644 --- a/docs/development/plugin-development.mdx +++ b/docs/development/plugin-development.mdx @@ -44,6 +44,8 @@ final class SurrealDBPlugin: NSObject, TableProPlugin, DriverPlugin { `DriverConnectionConfig` carries host, port, username, password, database, SSL settings, and an `additionalFields` dictionary filled from whatever `additionalConnectionFields` you declared. +A `.number` field takes a whole number of 0 or more. The form keeps ASCII digits only and will not save anything else. Use `.stepper(range:)` for a bounded or signed integer, and `.text` for a value with a decimal point. + The remaining fifty-odd statics all have defaults: connection mode, URL schemes, brand color, editor language, `sqlDialect` (keywords, functions, completions), navigation model, system database names, and two dozen `supports*` capability flags. Override the ones that differ. `Plugins/TableProPluginKit/DriverPlugin.swift` is the full list. ## Implementing PluginDatabaseDriver From 0602c2ab47d1a3e248427df3ab168a52e6bb48f5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:15:28 +0700 Subject: [PATCH 08/30] test(plugin-redis): test the real Redis command parser instead of a copy --- .../Core/Redis/RedisCommandParserTests.swift | 851 ++++++------------ 1 file changed, 260 insertions(+), 591 deletions(-) diff --git a/TableProTests/Core/Redis/RedisCommandParserTests.swift b/TableProTests/Core/Redis/RedisCommandParserTests.swift index 549b6d9c2a..6e40f8e1d6 100644 --- a/TableProTests/Core/Redis/RedisCommandParserTests.swift +++ b/TableProTests/Core/Redis/RedisCommandParserTests.swift @@ -2,12 +2,6 @@ // RedisCommandParserTests.swift // TableProTests // -// Tests for RedisCommandParser, which parses Redis CLI-style commands -// into structured RedisOperation values. -// -// The parser lives inside RedisDriverPlugin (a bundle target), so we copy -// the pure-value types here as private helpers instead of using @testable import. -// import Foundation import TableProPluginKit @@ -19,7 +13,7 @@ import Testing struct RedisCommandParserKeyCommandTests { @Test("GET parses key") func getCommand() throws { - let op = try TestRedisCommandParser.parse("GET mykey") + let op = try RedisCommandParser.parse("GET mykey") guard case .get(let key) = op else { Issue.record("Expected .get, got \(op)") return @@ -29,26 +23,26 @@ struct RedisCommandParserKeyCommandTests { @Test("GET missing key throws") func getMissingKey() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("GET") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("GET") } } @Test("SET parses key and value") func setCommand() throws { - let op = try TestRedisCommandParser.parse("SET mykey myvalue") + let op = try RedisCommandParser.parse("SET mykey myvalue") guard case .set(let key, let value, let options) = op else { Issue.record("Expected .set, got \(op)") return } #expect(key == "mykey") - #expect(value == "myvalue") + #expect(value == Data("myvalue".utf8)) #expect(options == nil) } @Test("SET with EX option") func setWithExpiry() throws { - let op = try TestRedisCommandParser.parse("SET mykey myvalue EX 60") + let op = try RedisCommandParser.parse("SET mykey myvalue EX 60") guard case .set(_, _, let options) = op else { Issue.record("Expected .set") return @@ -58,7 +52,7 @@ struct RedisCommandParserKeyCommandTests { @Test("SET with NX option") func setWithNx() throws { - let op = try TestRedisCommandParser.parse("SET mykey myvalue NX") + let op = try RedisCommandParser.parse("SET mykey myvalue NX") guard case .set(_, _, let options) = op else { Issue.record("Expected .set") return @@ -68,14 +62,14 @@ struct RedisCommandParserKeyCommandTests { @Test("SET missing value throws") func setMissingValue() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("SET mykey") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("SET mykey") } } @Test("DEL parses single key") func delSingleKey() throws { - let op = try TestRedisCommandParser.parse("DEL mykey") + let op = try RedisCommandParser.parse("DEL mykey") guard case .del(let keys) = op else { Issue.record("Expected .del") return @@ -85,7 +79,7 @@ struct RedisCommandParserKeyCommandTests { @Test("DEL parses multiple keys") func delMultipleKeys() throws { - let op = try TestRedisCommandParser.parse("DEL key1 key2 key3") + let op = try RedisCommandParser.parse("DEL key1 key2 key3") guard case .del(let keys) = op else { Issue.record("Expected .del") return @@ -95,14 +89,14 @@ struct RedisCommandParserKeyCommandTests { @Test("DEL missing key throws") func delMissingKey() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("DEL") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("DEL") } } @Test("KEYS parses pattern") func keysCommand() throws { - let op = try TestRedisCommandParser.parse("KEYS user:*") + let op = try RedisCommandParser.parse("KEYS user:*") guard case .keys(let pattern) = op else { Issue.record("Expected .keys") return @@ -112,31 +106,31 @@ struct RedisCommandParserKeyCommandTests { @Test("SCAN parses cursor with MATCH and COUNT") func scanWithOptions() throws { - let op = try TestRedisCommandParser.parse("SCAN 0 MATCH user:* COUNT 100") + let op = try RedisCommandParser.parse("SCAN 0 MATCH user:* COUNT 100") guard case .scan(let cursor, let pattern, let count) = op else { Issue.record("Expected .scan") return } - #expect(cursor == 0) + #expect(cursor == "0") #expect(pattern == "user:*") #expect(count == 100) } @Test("SCAN without options") func scanBasic() throws { - let op = try TestRedisCommandParser.parse("SCAN 0") + let op = try RedisCommandParser.parse("SCAN 0") guard case .scan(let cursor, let pattern, let count) = op else { Issue.record("Expected .scan") return } - #expect(cursor == 0) + #expect(cursor == "0") #expect(pattern == nil) #expect(count == nil) } @Test("TYPE parses key") func typeCommand() throws { - let op = try TestRedisCommandParser.parse("TYPE mykey") + let op = try RedisCommandParser.parse("TYPE mykey") guard case .type(let key) = op else { Issue.record("Expected .type") return @@ -146,7 +140,7 @@ struct RedisCommandParserKeyCommandTests { @Test("TTL parses key") func ttlCommand() throws { - let op = try TestRedisCommandParser.parse("TTL mykey") + let op = try RedisCommandParser.parse("TTL mykey") guard case .ttl(let key) = op else { Issue.record("Expected .ttl") return @@ -156,7 +150,7 @@ struct RedisCommandParserKeyCommandTests { @Test("EXPIRE parses key and seconds") func expireCommand() throws { - let op = try TestRedisCommandParser.parse("EXPIRE mykey 300") + let op = try RedisCommandParser.parse("EXPIRE mykey 300") guard case .expire(let key, let seconds) = op else { Issue.record("Expected .expire") return @@ -167,14 +161,14 @@ struct RedisCommandParserKeyCommandTests { @Test("EXPIRE with non-integer seconds throws") func expireInvalidSeconds() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("EXPIRE mykey abc") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("EXPIRE mykey abc") } } @Test("RENAME parses key and newKey") func renameCommand() throws { - let op = try TestRedisCommandParser.parse("RENAME oldkey newkey") + let op = try RedisCommandParser.parse("RENAME oldkey newkey") guard case .rename(let key, let newKey) = op else { Issue.record("Expected .rename") return @@ -185,13 +179,72 @@ struct RedisCommandParserKeyCommandTests { @Test("EXISTS parses multiple keys") func existsCommand() throws { - let op = try TestRedisCommandParser.parse("EXISTS k1 k2") + let op = try RedisCommandParser.parse("EXISTS k1 k2") guard case .exists(let keys) = op else { Issue.record("Expected .exists") return } #expect(keys == ["k1", "k2"]) } + + @Test( + "A SET option the parser does not model goes out verbatim", + arguments: ["SET k v KEEPTTL", "SET k v GET"] + ) + func setWithUnmodelledOptionIsVerbatim(input: String) throws { + let op = try RedisCommandParser.parse(input) + guard case .command(let args) = op else { + Issue.record("Expected .command, got \(op)") + return + } + let texts = args.map(\.text) + #expect(texts == input.split(separator: " ").map(String.init)) + } + + @Test("SET with EXAT carries the timestamp") + func setWithExat() throws { + let op = try RedisCommandParser.parse("SET k v EXAT 100") + guard case .set(_, _, let options) = op else { + Issue.record("Expected .set, got \(op)") + return + } + #expect(options?.exat == 100) + } + + @Test("SET with an EX that is not a positive integer throws", arguments: ["SET k v EX abc", "SET k v EX 0"]) + func setWithInvalidExpiryThrows(input: String) { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse(input) + } + } + + @Test("EXPIRE with a condition flag goes out verbatim") + func expireWithFlagIsVerbatim() throws { + let op = try RedisCommandParser.parse("EXPIRE k 10 NX") + guard case .command(let args) = op else { + Issue.record("Expected .command, got \(op)") + return + } + let texts = args.map(\.text) + #expect(texts == ["EXPIRE", "k", "10", "NX"]) + } + + @Test("A SCAN cursor above Int.max keeps its text") + func scanCursorAboveIntMax() throws { + let op = try RedisCommandParser.parse("SCAN 18446744073709551615") + guard case .scan(let cursor, _, _) = op else { + Issue.record("Expected .scan, got \(op)") + return + } + #expect(cursor == "18446744073709551615") + } + + @Test("A SCAN COUNT that is not an integer throws") + func scanWithInvalidCountThrows() { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("SCAN 0 COUNT abc") + } + } } // MARK: - Hash Commands @@ -200,7 +253,7 @@ struct RedisCommandParserKeyCommandTests { struct RedisCommandParserHashTests { @Test("HGET parses key and field") func hgetCommand() throws { - let op = try TestRedisCommandParser.parse("HGET myhash field1") + let op = try RedisCommandParser.parse("HGET myhash field1") guard case .hget(let key, let field) = op else { Issue.record("Expected .hget") return @@ -211,7 +264,7 @@ struct RedisCommandParserHashTests { @Test("HSET parses key and field-value pairs") func hsetCommand() throws { - let op = try TestRedisCommandParser.parse("HSET myhash f1 v1 f2 v2") + let op = try RedisCommandParser.parse("HSET myhash f1 v1 f2 v2") guard case .hset(let key, let fieldValues) = op else { Issue.record("Expected .hset") return @@ -219,21 +272,21 @@ struct RedisCommandParserHashTests { #expect(key == "myhash") #expect(fieldValues.count == 2) #expect(fieldValues[0].0 == "f1") - #expect(fieldValues[0].1 == "v1") + #expect(fieldValues[0].1 == Data("v1".utf8)) #expect(fieldValues[1].0 == "f2") - #expect(fieldValues[1].1 == "v2") + #expect(fieldValues[1].1 == Data("v2".utf8)) } @Test("HSET with odd argument count throws") func hsetOddArgs() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("HSET myhash f1 v1 f2") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("HSET myhash f1 v1 f2") } } @Test("HGETALL parses key") func hgetallCommand() throws { - let op = try TestRedisCommandParser.parse("HGETALL myhash") + let op = try RedisCommandParser.parse("HGETALL myhash") guard case .hgetall(let key) = op else { Issue.record("Expected .hgetall") return @@ -243,7 +296,7 @@ struct RedisCommandParserHashTests { @Test("HDEL parses key and fields") func hdelCommand() throws { - let op = try TestRedisCommandParser.parse("HDEL myhash f1 f2") + let op = try RedisCommandParser.parse("HDEL myhash f1 f2") guard case .hdel(let key, let fields) = op else { Issue.record("Expected .hdel") return @@ -259,7 +312,7 @@ struct RedisCommandParserHashTests { struct RedisCommandParserListTests { @Test("LRANGE parses key, start, stop") func lrangeCommand() throws { - let op = try TestRedisCommandParser.parse("LRANGE mylist 0 -1") + let op = try RedisCommandParser.parse("LRANGE mylist 0 -1") guard case .lrange(let key, let start, let stop) = op else { Issue.record("Expected .lrange") return @@ -271,36 +324,38 @@ struct RedisCommandParserListTests { @Test("LRANGE with non-integer bounds throws") func lrangeInvalidBounds() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("LRANGE mylist abc def") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("LRANGE mylist abc def") } } @Test("LPUSH parses key and values") func lpushCommand() throws { - let op = try TestRedisCommandParser.parse("LPUSH mylist a b c") + let op = try RedisCommandParser.parse("LPUSH mylist a b c") guard case .lpush(let key, let values) = op else { Issue.record("Expected .lpush") return } + let expected = ["a", "b", "c"].map { Data($0.utf8) } #expect(key == "mylist") - #expect(values == ["a", "b", "c"]) + #expect(values == expected) } @Test("RPUSH parses key and values") func rpushCommand() throws { - let op = try TestRedisCommandParser.parse("RPUSH mylist x y") + let op = try RedisCommandParser.parse("RPUSH mylist x y") guard case .rpush(let key, let values) = op else { Issue.record("Expected .rpush") return } + let expected = ["x", "y"].map { Data($0.utf8) } #expect(key == "mylist") - #expect(values == ["x", "y"]) + #expect(values == expected) } @Test("LLEN parses key") func llenCommand() throws { - let op = try TestRedisCommandParser.parse("LLEN mylist") + let op = try RedisCommandParser.parse("LLEN mylist") guard case .llen(let key) = op else { Issue.record("Expected .llen") return @@ -315,7 +370,7 @@ struct RedisCommandParserListTests { struct RedisCommandParserSetTests { @Test("SMEMBERS parses key") func smembersCommand() throws { - let op = try TestRedisCommandParser.parse("SMEMBERS myset") + let op = try RedisCommandParser.parse("SMEMBERS myset") guard case .smembers(let key) = op else { Issue.record("Expected .smembers") return @@ -325,29 +380,30 @@ struct RedisCommandParserSetTests { @Test("SADD parses key and members") func saddCommand() throws { - let op = try TestRedisCommandParser.parse("SADD myset a b c") + let op = try RedisCommandParser.parse("SADD myset a b c") guard case .sadd(let key, let members) = op else { Issue.record("Expected .sadd") return } + let expected = ["a", "b", "c"].map { Data($0.utf8) } #expect(key == "myset") - #expect(members == ["a", "b", "c"]) + #expect(members == expected) } @Test("SREM parses key and members") func sremCommand() throws { - let op = try TestRedisCommandParser.parse("SREM myset a") + let op = try RedisCommandParser.parse("SREM myset a") guard case .srem(let key, let members) = op else { Issue.record("Expected .srem") return } #expect(key == "myset") - #expect(members == ["a"]) + #expect(members == [Data("a".utf8)]) } @Test("SCARD parses key") func scardCommand() throws { - let op = try TestRedisCommandParser.parse("SCARD myset") + let op = try RedisCommandParser.parse("SCARD myset") guard case .scard(let key) = op else { Issue.record("Expected .scard") return @@ -362,63 +418,99 @@ struct RedisCommandParserSetTests { struct RedisCommandParserSortedSetTests { @Test("ZRANGE parses key, start, stop") func zrangeCommand() throws { - let op = try TestRedisCommandParser.parse("ZRANGE myzset 0 -1") - guard case .zrange(let key, let start, let stop, let withScores) = op else { + let op = try RedisCommandParser.parse("ZRANGE myzset 0 -1") + guard case .zrange(let key, let start, let stop, let flags) = op else { Issue.record("Expected .zrange") return } #expect(key == "myzset") - #expect(start == 0) - #expect(stop == -1) - #expect(withScores == false) + #expect(start == "0") + #expect(stop == "-1") + #expect(flags.isEmpty) } @Test("ZRANGE with WITHSCORES") func zrangeWithScores() throws { - let op = try TestRedisCommandParser.parse("ZRANGE myzset 0 -1 WITHSCORES") - guard case .zrange(_, _, _, let withScores) = op else { + let op = try RedisCommandParser.parse("ZRANGE myzset 0 -1 WITHSCORES") + guard case .zrange(_, _, _, let flags) = op else { Issue.record("Expected .zrange") return } - #expect(withScores == true) + #expect(flags == ["WITHSCORES"]) + } + + @Test("ZRANGE keeps score bounds as text and every flag in order") + func zrangeByScoreWithLimit() throws { + let op = try RedisCommandParser.parse("ZRANGE z (1 +inf BYSCORE LIMIT 0 10 WITHSCORES") + guard case .zrange(let key, let start, let stop, let flags) = op else { + Issue.record("Expected .zrange, got \(op)") + return + } + #expect(key == "z") + #expect(start == "(1") + #expect(stop == "+inf") + #expect(flags == ["BYSCORE", "LIMIT", "0", "10", "WITHSCORES"]) + } + + @Test("ZRANGE with a LIMIT missing its count throws") + func zrangeWithShortLimitThrows() { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("ZRANGE z 0 -1 LIMIT 0") + } + } + + @Test("ZADD carries its flags ahead of the score-member pairs") + func zaddWithFlags() throws { + let op = try RedisCommandParser.parse("ZADD z NX CH 1 a") + guard case .zadd(let key, let flags, let scoreMembers) = op else { + Issue.record("Expected .zadd, got \(op)") + return + } + #expect(key == "z") + #expect(flags == ["NX", "CH"]) + #expect(scoreMembers.count == 1) + #expect(scoreMembers.first?.0 == 1) + #expect(scoreMembers.first?.1 == Data("a".utf8)) } @Test("ZADD parses key and score-member pairs") func zaddCommand() throws { - let op = try TestRedisCommandParser.parse("ZADD myzset 1.5 a 2.0 b") - guard case .zadd(let key, let scoreMembers) = op else { + let op = try RedisCommandParser.parse("ZADD myzset 1.5 a 2.0 b") + guard case .zadd(let key, let flags, let scoreMembers) = op else { Issue.record("Expected .zadd") return } #expect(key == "myzset") + #expect(flags.isEmpty) #expect(scoreMembers.count == 2) #expect(scoreMembers[0].0 == 1.5) - #expect(scoreMembers[0].1 == "a") + #expect(scoreMembers[0].1 == Data("a".utf8)) #expect(scoreMembers[1].0 == 2.0) - #expect(scoreMembers[1].1 == "b") + #expect(scoreMembers[1].1 == Data("b".utf8)) } @Test("ZADD with non-numeric score throws") func zaddInvalidScore() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("ZADD myzset notanumber member") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("ZADD myzset notanumber member") } } @Test("ZREM parses key and members") func zremCommand() throws { - let op = try TestRedisCommandParser.parse("ZREM myzset a b") + let op = try RedisCommandParser.parse("ZREM myzset a b") guard case .zrem(let key, let members) = op else { Issue.record("Expected .zrem") return } + let expected = ["a", "b"].map { Data($0.utf8) } #expect(key == "myzset") - #expect(members == ["a", "b"]) + #expect(members == expected) } @Test("ZCARD parses key") func zcardCommand() throws { - let op = try TestRedisCommandParser.parse("ZCARD myzset") + let op = try RedisCommandParser.parse("ZCARD myzset") guard case .zcard(let key) = op else { Issue.record("Expected .zcard") return @@ -433,7 +525,7 @@ struct RedisCommandParserSortedSetTests { struct RedisCommandParserStreamTests { @Test("XRANGE parses key, start, end") func xrangeCommand() throws { - let op = try TestRedisCommandParser.parse("XRANGE mystream - +") + let op = try RedisCommandParser.parse("XRANGE mystream - +") guard case .xrange(let key, let start, let end, let count) = op else { Issue.record("Expected .xrange") return @@ -446,7 +538,7 @@ struct RedisCommandParserStreamTests { @Test("XRANGE with COUNT") func xrangeWithCount() throws { - let op = try TestRedisCommandParser.parse("XRANGE mystream - + COUNT 10") + let op = try RedisCommandParser.parse("XRANGE mystream - + COUNT 10") guard case .xrange(_, _, _, let count) = op else { Issue.record("Expected .xrange") return @@ -456,7 +548,7 @@ struct RedisCommandParserStreamTests { @Test("XLEN parses key") func xlenCommand() throws { - let op = try TestRedisCommandParser.parse("XLEN mystream") + let op = try RedisCommandParser.parse("XLEN mystream") guard case .xlen(let key) = op else { Issue.record("Expected .xlen") return @@ -471,7 +563,7 @@ struct RedisCommandParserStreamTests { struct RedisCommandParserServerTests { @Test("PING") func pingCommand() throws { - let op = try TestRedisCommandParser.parse("PING") + let op = try RedisCommandParser.parse("PING") guard case .ping = op else { Issue.record("Expected .ping") return @@ -480,7 +572,7 @@ struct RedisCommandParserServerTests { @Test("INFO without section") func infoCommand() throws { - let op = try TestRedisCommandParser.parse("INFO") + let op = try RedisCommandParser.parse("INFO") guard case .info(let section) = op else { Issue.record("Expected .info") return @@ -490,7 +582,7 @@ struct RedisCommandParserServerTests { @Test("INFO with section") func infoWithSection() throws { - let op = try TestRedisCommandParser.parse("INFO memory") + let op = try RedisCommandParser.parse("INFO memory") guard case .info(let section) = op else { Issue.record("Expected .info") return @@ -500,7 +592,7 @@ struct RedisCommandParserServerTests { @Test("DBSIZE") func dbsizeCommand() throws { - let op = try TestRedisCommandParser.parse("DBSIZE") + let op = try RedisCommandParser.parse("DBSIZE") guard case .dbsize = op else { Issue.record("Expected .dbsize") return @@ -509,7 +601,7 @@ struct RedisCommandParserServerTests { @Test("SELECT parses database index") func selectCommand() throws { - let op = try TestRedisCommandParser.parse("SELECT 3") + let op = try RedisCommandParser.parse("SELECT 3") guard case .select(let database) = op else { Issue.record("Expected .select") return @@ -519,14 +611,14 @@ struct RedisCommandParserServerTests { @Test("SELECT with non-integer throws") func selectInvalid() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("SELECT abc") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("SELECT abc") } } @Test("CONFIG GET parses parameter") func configGetCommand() throws { - let op = try TestRedisCommandParser.parse("CONFIG GET maxmemory") + let op = try RedisCommandParser.parse("CONFIG GET maxmemory") guard case .configGet(let parameter) = op else { Issue.record("Expected .configGet") return @@ -536,7 +628,7 @@ struct RedisCommandParserServerTests { @Test("CONFIG SET parses parameter and value") func configSetCommand() throws { - let op = try TestRedisCommandParser.parse("CONFIG SET maxmemory 100mb") + let op = try RedisCommandParser.parse("CONFIG SET maxmemory 100mb") guard case .configSet(let parameter, let value) = op else { Issue.record("Expected .configSet") return @@ -547,7 +639,7 @@ struct RedisCommandParserServerTests { @Test("MULTI") func multiCommand() throws { - let op = try TestRedisCommandParser.parse("MULTI") + let op = try RedisCommandParser.parse("MULTI") guard case .multi = op else { Issue.record("Expected .multi") return @@ -556,7 +648,7 @@ struct RedisCommandParserServerTests { @Test("EXEC") func execCommand() throws { - let op = try TestRedisCommandParser.parse("EXEC") + let op = try RedisCommandParser.parse("EXEC") guard case .exec = op else { Issue.record("Expected .exec") return @@ -565,7 +657,7 @@ struct RedisCommandParserServerTests { @Test("DISCARD") func discardCommand() throws { - let op = try TestRedisCommandParser.parse("DISCARD") + let op = try RedisCommandParser.parse("DISCARD") guard case .discard = op else { Issue.record("Expected .discard") return @@ -579,26 +671,27 @@ struct RedisCommandParserServerTests { struct RedisCommandParserErrorTests { @Test("Empty input throws emptySyntax") func emptyInput() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse("") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("") } } @Test("Whitespace-only input throws emptySyntax") func whitespaceOnly() { - #expect(throws: TestRedisParseError.self) { - try TestRedisCommandParser.parse(" ") + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse(" ") } } @Test("Unknown command returns .command with all tokens") func unknownCommand() throws { - let op = try TestRedisCommandParser.parse("CUSTOM arg1 arg2") + let op = try RedisCommandParser.parse("CUSTOM arg1 arg2") guard case .command(let args) = op else { Issue.record("Expected .command") return } - #expect(args == ["CUSTOM", "arg1", "arg2"]) + let texts = args.map(\.text) + #expect(texts == ["CUSTOM", "arg1", "arg2"]) } } @@ -608,40 +701,94 @@ struct RedisCommandParserErrorTests { struct RedisCommandParserTokenizerTests { @Test("Double-quoted strings are parsed correctly") func doubleQuotedString() throws { - let op = try TestRedisCommandParser.parse("SET mykey \"hello world\"") + let op = try RedisCommandParser.parse("SET mykey \"hello world\"") guard case .set(let key, let value, _) = op else { Issue.record("Expected .set") return } #expect(key == "mykey") - #expect(value == "hello world") + #expect(value == Data("hello world".utf8)) } @Test("Single-quoted strings are parsed correctly") func singleQuotedString() throws { - let op = try TestRedisCommandParser.parse("SET mykey 'hello world'") + let op = try RedisCommandParser.parse("SET mykey 'hello world'") guard case .set(let key, let value, _) = op else { Issue.record("Expected .set") return } #expect(key == "mykey") - #expect(value == "hello world") + #expect(value == Data("hello world".utf8)) } - @Test("Escaped characters are preserved") - func escapedCharacters() throws { - let op = try TestRedisCommandParser.parse("SET mykey hello\\ world") - guard case .set(let key, let value, _) = op else { - Issue.record("Expected .set") + @Test("A backslash outside quotes is a literal byte, as in redis-cli") + func backslashOutsideQuotesIsLiteral() throws { + let op = try RedisCommandParser.parse("SET mykey hello\\ world") + guard case .command(let args) = op else { + Issue.record("Expected .command, got \(op)") return } - #expect(key == "mykey") - #expect(value == "hello world") + let texts = args.map(\.text) + #expect(texts == ["SET", "mykey", "hello\\", "world"]) + } + + @Test("Escapes inside double quotes are decoded") + func doubleQuotedEscapesDecode() throws { + let op = try RedisCommandParser.parse("SET k \"a\\nb\"") + guard case .set(_, let value, _) = op else { + Issue.record("Expected .set, got \(op)") + return + } + #expect(value == Data([0x61, 0x0A, 0x62])) + } + + @Test("A hex escape inside double quotes decodes to its byte") + func doubleQuotedHexEscapeDecodes() throws { + let op = try RedisCommandParser.parse("SET k \"\\x41\\x42\"") + guard case .set(_, let value, _) = op else { + Issue.record("Expected .set, got \(op)") + return + } + #expect(value == Data("AB".utf8)) + } + + @Test("Inside single quotes only an escaped quote is decoded") + func singleQuotedKeepsBackslashes() throws { + let op = try RedisCommandParser.parse("SET k 'a\\b\\'c'") + guard case .set(_, let value, _) = op else { + Issue.record("Expected .set, got \(op)") + return + } + #expect(value == Data("a\\b'c".utf8)) + } + + @Test("Text right after a closing quote is refused, as redis-cli refuses it") + func textAfterClosingQuoteThrows() { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("SET k a\"b c\"d") + } + } + + @Test("An unbalanced quote is refused") + func unbalancedQuoteThrows() { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("SET k \"abc") + } + } + + @Test("A no-break space is part of the argument, not a separator") + func noBreakSpaceIsNotBlank() throws { + let op = try RedisCommandParser.parse("GET a\u{00A0}b") + guard case .get(let key) = op else { + Issue.record("Expected .get, got \(op)") + return + } + #expect(key == "a\u{00A0}b") } @Test("Case insensitivity for commands") func caseInsensitivity() throws { - let op = try TestRedisCommandParser.parse("get mykey") + let op = try RedisCommandParser.parse("get mykey") guard case .get(let key) = op else { Issue.record("Expected .get") return @@ -651,7 +798,7 @@ struct RedisCommandParserTokenizerTests { @Test("Mixed case commands") func mixedCase() throws { - let op = try TestRedisCommandParser.parse("GeT mykey") + let op = try RedisCommandParser.parse("GeT mykey") guard case .get(let key) = op else { Issue.record("Expected .get") return @@ -661,7 +808,7 @@ struct RedisCommandParserTokenizerTests { @Test("Multiple spaces between tokens") func multipleSpaces() throws { - let op = try TestRedisCommandParser.parse("GET mykey") + let op = try RedisCommandParser.parse("GET mykey") guard case .get(let key) = op else { Issue.record("Expected .get") return @@ -671,7 +818,7 @@ struct RedisCommandParserTokenizerTests { @Test("Leading and trailing whitespace is trimmed") func leadingTrailingWhitespace() throws { - let op = try TestRedisCommandParser.parse(" GET mykey ") + let op = try RedisCommandParser.parse(" GET mykey ") guard case .get(let key) = op else { Issue.record("Expected .get") return @@ -687,8 +834,8 @@ struct RedisKeyBrowseRoundTripTests { @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 { + let op = try RedisCommandParser.parse(command) + guard case .keyBrowse(let pattern, let typeScope, let limit, let offset, let database) = op else { Issue.record("Expected .keyBrowse, got \(op)") return } @@ -696,14 +843,15 @@ struct RedisKeyBrowseRoundTripTests { #expect(typeScope == "hash") #expect(limit == 100) #expect(offset == 200) + #expect(database == nil) } @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 { + let op = try RedisCommandParser.parse(command) + guard case .keyBrowse(let pattern, let typeScope, _, _, _) = op else { Issue.record("Expected .keyBrowse, got \(op)") return } @@ -714,8 +862,8 @@ struct RedisKeyBrowseRoundTripTests { @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 { + let op = try RedisCommandParser.parse(command) + guard case .keyBrowse(let pattern, let typeScope, _, _, _) = op else { Issue.record("Expected .keyBrowse, got \(op)") return } @@ -723,482 +871,3 @@ struct RedisKeyBrowseRoundTripTests { #expect(typeScope == "stream") } } - -// MARK: - Private Local Helpers (copied from RedisDriverPlugin) - -private enum TestRedisOperation { - case get(key: String) - case set(key: String, value: String, options: TestRedisSetOptions?) - 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) - case expire(key: String, seconds: Int) - case persist(key: String) - case rename(key: String, newKey: String) - case exists(keys: [String]) - case hget(key: String, field: String) - case hset(key: String, fieldValues: [(String, String)]) - case hgetall(key: String) - case hdel(key: String, fields: [String]) - case lrange(key: String, start: Int, stop: Int) - case lpush(key: String, values: [String]) - case rpush(key: String, values: [String]) - case llen(key: String) - case smembers(key: String) - case sadd(key: String, members: [String]) - case srem(key: String, members: [String]) - case scard(key: String) - case zrange(key: String, start: Int, stop: Int, withScores: Bool) - case zadd(key: String, scoreMembers: [(Double, String)]) - case zrem(key: String, members: [String]) - case zcard(key: String) - case xrange(key: String, start: String, end: String, count: Int?) - case xlen(key: String) - case ping - case info(section: String?) - case dbsize - case flushdb - case select(database: Int) - case configGet(parameter: String) - case configSet(parameter: String, value: String) - case command(args: [String]) - case multi - case exec - case discard -} - -private struct TestRedisSetOptions { - var ex: Int? - var px: Int? - var nx: Bool = false - var xx: Bool = false -} - -private enum TestRedisParseError: Error, LocalizedError { - case emptySyntax - case invalidArgument(String) - case missingArgument(String) - - var errorDescription: String? { - switch self { - case .emptySyntax: - return "Empty Redis command" - case .invalidArgument(let msg): - return "Invalid argument: \(msg)" - case .missingArgument(let msg): - return "Missing argument: \(msg)" - } - } -} - -private struct TestRedisCommandParser { - static func parse(_ input: String) throws -> TestRedisOperation { - let trimmed = input.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { throw TestRedisParseError.emptySyntax } - - let tokens = tokenize(trimmed) - guard let first = tokens.first else { throw TestRedisParseError.emptySyntax } - - let command = first.uppercased() - let args = Array(tokens.dropFirst()) - - switch command { - case "GET", "SET", "DEL", "KEYS", "SCAN", "TYPE", "TTL", "PTTL", - "EXPIRE", "PERSIST", "RENAME", "EXISTS": - return try parseKeyCommand(command, args: args) - case "HGET", "HSET", "HGETALL", "HDEL": - return try parseHashCommand(command, args: args) - case "LRANGE", "LPUSH", "RPUSH", "LLEN": - return try parseListCommand(command, args: args) - case "SMEMBERS", "SADD", "SREM", "SCARD": - return try parseSetCommand(command, args: args) - case "ZRANGE", "ZADD", "ZREM", "ZCARD": - return try parseSortedSetCommand(command, args: args) - case "XRANGE", "XLEN": - return try parseStreamCommand(command, args: args) - 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": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("GET requires a key") } - return .get(key: args[0]) - case "SET": - guard args.count >= 2 else { throw TestRedisParseError.missingArgument("SET requires key and value") } - let options = parseSetOptions(Array(args.dropFirst(2))) - return .set(key: args[0], value: args[1], options: options) - case "DEL": - guard !args.isEmpty else { throw TestRedisParseError.missingArgument("DEL requires at least one key") } - return .del(keys: args) - case "KEYS": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("KEYS requires a pattern") } - return .keys(pattern: args[0]) - case "SCAN": - guard args.count >= 1, let cursor = Int(args[0]) else { - throw TestRedisParseError.missingArgument("SCAN requires a cursor (integer)") - } - let (pattern, count) = parseScanOptions(Array(args.dropFirst())) - return .scan(cursor: cursor, pattern: pattern, count: count) - case "TYPE": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("TYPE requires a key") } - return .type(key: args[0]) - case "TTL": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("TTL requires a key") } - return .ttl(key: args[0]) - case "PTTL": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("PTTL requires a key") } - return .pttl(key: args[0]) - case "EXPIRE": - guard args.count >= 2 else { throw TestRedisParseError.missingArgument("EXPIRE requires key and seconds") } - guard let seconds = Int(args[1]) else { - throw TestRedisParseError.invalidArgument("EXPIRE seconds must be an integer") - } - return .expire(key: args[0], seconds: seconds) - case "PERSIST": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("PERSIST requires a key") } - return .persist(key: args[0]) - case "RENAME": - guard args.count >= 2 else { throw TestRedisParseError.missingArgument("RENAME requires key and newKey") } - return .rename(key: args[0], newKey: args[1]) - case "EXISTS": - guard !args.isEmpty else { throw TestRedisParseError.missingArgument("EXISTS requires at least one key") } - return .exists(keys: args) - default: - throw TestRedisParseError.invalidArgument("Unknown key command: \(command)") - } - } - - private static func parseHashCommand(_ command: String, args: [String]) throws -> TestRedisOperation { - switch command { - case "HGET": - guard args.count >= 2 else { throw TestRedisParseError.missingArgument("HGET requires key and field") } - return .hget(key: args[0], field: args[1]) - case "HSET": - guard args.count >= 3, args.count % 2 == 1 else { - throw TestRedisParseError.missingArgument("HSET requires key followed by field value pairs") - } - var fieldValues: [(String, String)] = [] - var i = 1 - while i + 1 < args.count { - fieldValues.append((args[i], args[i + 1])) - i += 2 - } - return .hset(key: args[0], fieldValues: fieldValues) - case "HGETALL": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("HGETALL requires a key") } - return .hgetall(key: args[0]) - case "HDEL": - guard args.count >= 2 else { - throw TestRedisParseError.missingArgument("HDEL requires key and at least one field") - } - return .hdel(key: args[0], fields: Array(args.dropFirst())) - default: - throw TestRedisParseError.invalidArgument("Unknown hash command: \(command)") - } - } - - private static func parseListCommand(_ command: String, args: [String]) throws -> TestRedisOperation { - switch command { - case "LRANGE": - guard args.count >= 3 else { - throw TestRedisParseError.missingArgument("LRANGE requires key, start, and stop") - } - guard let start = Int(args[1]), let stop = Int(args[2]) else { - throw TestRedisParseError.invalidArgument("LRANGE start and stop must be integers") - } - return .lrange(key: args[0], start: start, stop: stop) - case "LPUSH": - guard args.count >= 2 else { - throw TestRedisParseError.missingArgument("LPUSH requires key and at least one value") - } - return .lpush(key: args[0], values: Array(args.dropFirst())) - case "RPUSH": - guard args.count >= 2 else { - throw TestRedisParseError.missingArgument("RPUSH requires key and at least one value") - } - return .rpush(key: args[0], values: Array(args.dropFirst())) - case "LLEN": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("LLEN requires a key") } - return .llen(key: args[0]) - default: - throw TestRedisParseError.invalidArgument("Unknown list command: \(command)") - } - } - - private static func parseSetCommand(_ command: String, args: [String]) throws -> TestRedisOperation { - switch command { - case "SMEMBERS": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("SMEMBERS requires a key") } - return .smembers(key: args[0]) - case "SADD": - guard args.count >= 2 else { - throw TestRedisParseError.missingArgument("SADD requires key and at least one member") - } - return .sadd(key: args[0], members: Array(args.dropFirst())) - case "SREM": - guard args.count >= 2 else { - throw TestRedisParseError.missingArgument("SREM requires key and at least one member") - } - return .srem(key: args[0], members: Array(args.dropFirst())) - case "SCARD": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("SCARD requires a key") } - return .scard(key: args[0]) - default: - throw TestRedisParseError.invalidArgument("Unknown set command: \(command)") - } - } - - private static func parseSortedSetCommand(_ command: String, args: [String]) throws -> TestRedisOperation { - switch command { - case "ZRANGE": - guard args.count >= 3 else { - throw TestRedisParseError.missingArgument("ZRANGE requires key, start, and stop") - } - guard let start = Int(args[1]), let stop = Int(args[2]) else { - throw TestRedisParseError.invalidArgument("ZRANGE start and stop must be integers") - } - let withScores = args.count > 3 && args[3].uppercased() == "WITHSCORES" - return .zrange(key: args[0], start: start, stop: stop, withScores: withScores) - case "ZADD": - guard args.count >= 3, (args.count - 1) % 2 == 0 else { - throw TestRedisParseError.missingArgument("ZADD requires key followed by score member pairs") - } - var scoreMembers: [(Double, String)] = [] - var i = 1 - while i + 1 < args.count { - guard let score = Double(args[i]) else { - throw TestRedisParseError.invalidArgument("ZADD score must be a number: \(args[i])") - } - scoreMembers.append((score, args[i + 1])) - i += 2 - } - return .zadd(key: args[0], scoreMembers: scoreMembers) - case "ZREM": - guard args.count >= 2 else { - throw TestRedisParseError.missingArgument("ZREM requires key and at least one member") - } - return .zrem(key: args[0], members: Array(args.dropFirst())) - case "ZCARD": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("ZCARD requires a key") } - return .zcard(key: args[0]) - default: - throw TestRedisParseError.invalidArgument("Unknown sorted set command: \(command)") - } - } - - private static func parseStreamCommand(_ command: String, args: [String]) throws -> TestRedisOperation { - switch command { - case "XRANGE": - guard args.count >= 3 else { - throw TestRedisParseError.missingArgument("XRANGE requires key, start, and end") - } - var count: Int? - if args.count >= 5, args[3].uppercased() == "COUNT" { - count = Int(args[4]) - } - return .xrange(key: args[0], start: args[1], end: args[2], count: count) - case "XLEN": - guard args.count >= 1 else { throw TestRedisParseError.missingArgument("XLEN requires a key") } - return .xlen(key: args[0]) - default: - throw TestRedisParseError.invalidArgument("Unknown stream command: \(command)") - } - } - - private static func parseServerCommand( - _ command: String, args: [String], tokens: [String] - ) throws -> TestRedisOperation { - switch command { - case "PING": - return .ping - case "INFO": - return .info(section: args.first) - case "DBSIZE": - return .dbsize - case "FLUSHDB": - return .flushdb - case "SELECT": - guard args.count >= 1, let db = Int(args[0]) else { - throw TestRedisParseError.missingArgument("SELECT requires a database index (integer)") - } - return .select(database: db) - case "CONFIG": - guard args.count >= 2 else { - throw TestRedisParseError.missingArgument("CONFIG requires a subcommand and parameter") - } - let subcommand = args[0].uppercased() - switch subcommand { - case "GET": - return .configGet(parameter: args[1]) - case "SET": - guard args.count >= 3 else { - throw TestRedisParseError.missingArgument("CONFIG SET requires parameter and value") - } - return .configSet(parameter: args[1], value: args[2]) - default: - return .command(args: tokens) - } - case "MULTI": - return .multi - case "EXEC": - return .exec - case "DISCARD": - return .discard - default: - throw TestRedisParseError.invalidArgument("Unknown server command: \(command)") - } - } - - private static func tokenize(_ input: String) -> [String] { - var tokens: [String] = [] - var current = "" - var inQuote = false - var quoteChar: Character = "\"" - var escapeNext = false - - for char in input { - if escapeNext { - current.append(char) - escapeNext = false - continue - } - if char == "\\" { - escapeNext = true - continue - } - if inQuote { - if char == quoteChar { - inQuote = false - } else { - current.append(char) - } - continue - } - if char == "\"" || char == "'" { - inQuote = true - quoteChar = char - continue - } - if char.isWhitespace { - if !current.isEmpty { - tokens.append(current) - current = "" - } - continue - } - current.append(char) - } - - if !current.isEmpty { - tokens.append(current) - } - return tokens - } - - private static func parseSetOptions(_ args: [String]) -> TestRedisSetOptions? { - guard !args.isEmpty else { return nil } - var options = TestRedisSetOptions() - var hasOption = false - var i = 0 - while i < args.count { - let arg = args[i].uppercased() - switch arg { - case "EX": - if i + 1 < args.count, let seconds = Int(args[i + 1]) { - options.ex = seconds - hasOption = true - i += 1 - } - case "PX": - if i + 1 < args.count, let millis = Int(args[i + 1]) { - options.px = millis - hasOption = true - i += 1 - } - case "NX": - options.nx = true - hasOption = true - case "XX": - options.xx = true - hasOption = true - default: - break - } - i += 1 - } - return hasOption ? options : nil - } - - private static func parseScanOptions(_ args: [String]) -> (pattern: String?, count: Int?) { - var pattern: String? - var count: Int? - var i = 0 - while i < args.count { - let arg = args[i].uppercased() - switch arg { - case "MATCH": - if i + 1 < args.count { - pattern = args[i + 1] - i += 1 - } - case "COUNT": - if i + 1 < args.count { - count = Int(args[i + 1]) - i += 1 - } - default: - break - } - i += 1 - } - return (pattern, count) - } -} From 44d0c4801f042709c034bb17680bf906ba9d3afa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:27:36 +0700 Subject: [PATCH 09/30] refactor(plugin-redis): render Redis replies from a type the tests compile --- .../RedisPluginDriver+Operations.swift | 18 +- .../RedisPluginDriver+ResultBuilding.swift | 260 +-------- Plugins/RedisDriverPlugin/RedisReply.swift | 14 + .../RedisDriverPlugin/RedisReplyGrid.swift | 116 ++++ .../Core/Redis/RedisResultBuildingTests.swift | 511 +++++++----------- project.yml | 1 + 6 files changed, 337 insertions(+), 583 deletions(-) create mode 100644 Plugins/RedisDriverPlugin/RedisReplyGrid.swift diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 2f46eb4f48..4f1ba12ec8 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -120,7 +120,7 @@ extension RedisPluginDriver { guard let items = result.arrayValue else { return buildEmptyKeyResult(startTime: startTime) } - let keys = items.map { redisReplyToString($0) } + let keys = items.map(\.displayText) let capped = Array(keys.prefix(PluginRowLimits.emergencyMax)) let keysTruncated = keys.count > PluginRowLimits.emergencyMax return try await buildKeyBrowseResult( @@ -236,7 +236,7 @@ extension RedisPluginDriver { case .hgetall(let key): let result = try await conn.run(["HGETALL", key]) - return buildHashResult(result, startTime: startTime) + return RedisReplyGrid.hash(result).queryResult(startTime: startTime) case .hdel(let key, let fields): let args = ["HDEL", key] + fields @@ -265,7 +265,7 @@ extension RedisPluginDriver { switch operation { case .lrange(let key, let start, let stop): let result = try await conn.run(["LRANGE", key, String(start), String(stop)]) - return buildListResult(result, startOffset: start, startTime: startTime) + return RedisReplyGrid.list(result, startOffset: start).queryResult(startTime: startTime) case .lpush(let key, let values): let args = ["LPUSH", key].asRedisArguments + values @@ -317,7 +317,7 @@ extension RedisPluginDriver { switch operation { case .smembers(let key): let result = try await conn.run(["SMEMBERS", key]) - return buildSetResult(result, startTime: startTime) + return RedisReplyGrid.set(result).queryResult(startTime: startTime) case .sadd(let key, let members): let args = ["SADD", key].asRedisArguments + members @@ -372,7 +372,7 @@ extension RedisPluginDriver { args += flags let withScores = flags.contains("WITHSCORES") let result = try await conn.run(args) - return buildSortedSetResult(result, withScores: withScores, startTime: startTime) + return RedisReplyGrid.sortedSet(result, withScores: withScores).queryResult(startTime: startTime) case .zadd(let key, let flags, let scoreMembers): var args = ["ZADD", key].asRedisArguments @@ -442,7 +442,7 @@ extension RedisPluginDriver { var args = ["XRANGE", key, start, end] if let c = count { args += ["COUNT", String(c)] } let result = try await conn.run(args) - return buildStreamResult(result, startTime: startTime) + return RedisReplyGrid.stream(result).queryResult(startTime: startTime) case .xlen(let key): let result = try await conn.run(["XLEN", key]) @@ -512,7 +512,7 @@ extension RedisPluginDriver { case .configGet(let parameter): let result = try await conn.run(["CONFIG", "GET", parameter]) - return buildConfigResult(result, startTime: startTime) + return RedisReplyGrid.config(result).queryResult(startTime: startTime) case .configSet(let parameter, let value): try await conn.run(["CONFIG", "SET", parameter, value]) @@ -520,7 +520,7 @@ extension RedisPluginDriver { case .command(let args): let result = try await conn.run(args.asRedisArguments) - return buildGenericResult(result, startTime: startTime) + return RedisReplyGrid.generic(result).queryResult(startTime: startTime) case .multi: try await conn.run(["MULTI"]) @@ -528,7 +528,7 @@ extension RedisPluginDriver { case .exec: let result = try await conn.run(["EXEC"]) - return buildGenericResult(result, startTime: startTime) + return RedisReplyGrid.generic(result).queryResult(startTime: startTime) case .discard: try await conn.run(["DISCARD"]) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift index 3f2971b55b..60dc2e4021 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+ResultBuilding.swift @@ -83,13 +83,13 @@ extension RedisPluginDriver { case .string: return stringCell(from: reply) case .hash: - return .fromOptional(RedisKeySummary.jsonObject(flatPairs: scanElements(from: reply).map(redisReplyToString))) + return .fromOptional(RedisKeySummary.jsonObject(flatPairs: scanElements(from: reply).map(\.displayText))) case .list: - return .fromOptional(RedisKeySummary.jsonArray(elements: (reply.arrayValue ?? []).map(redisReplyToString))) + return .fromOptional(RedisKeySummary.jsonArray(elements: (reply.arrayValue ?? []).map(\.displayText))) case .set: - return .fromOptional(RedisKeySummary.jsonArray(elements: scanElements(from: reply).map(redisReplyToString))) + return .fromOptional(RedisKeySummary.jsonArray(elements: scanElements(from: reply).map(\.displayText))) case .zset: - return .fromOptional(RedisKeySummary.jsonScorePairs(flatPairs: (reply.arrayValue ?? []).map(redisReplyToString))) + return .fromOptional(RedisKeySummary.jsonScorePairs(flatPairs: (reply.arrayValue ?? []).map(\.displayText))) case .stream: return .fromOptional(RedisKeySummary.jsonStreamEntries(streamEntries(from: reply))) } @@ -102,7 +102,7 @@ extension RedisPluginDriver { case .data(let bytes): return .bytes(bytes) default: - return .text(redisReplyToString(reply)) + return .text(reply.displayText) } } @@ -120,7 +120,7 @@ extension RedisPluginDriver { let fields = parts[1].arrayValue else { return nil } - return (id: redisReplyToString(parts[0]), flatFields: fields.map(redisReplyToString)) + return (id: parts[0].displayText, flatFields: fields.map(\.displayText)) } } @@ -143,252 +143,4 @@ extension RedisPluginDriver { executionTime: Date().timeIntervalSince(startTime) ) } - - func buildGenericResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - switch result { - case .string(let s), .status(let s): - return PluginQueryResult( - columns: ["result"], - columnTypeNames: ["String"], - rows: [[s].asCells], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - - case .integer(let i): - return PluginQueryResult( - columns: ["result"], - columnTypeNames: ["Int64"], - rows: [[String(i)].asCells], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - - case .data(let d): - let str = String(data: d, encoding: .utf8) ?? d.base64EncodedString() - return PluginQueryResult( - columns: ["result"], - columnTypeNames: ["String"], - rows: [[str].asCells], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - - case .array(let items): - let rows = items.map { ([redisReplyToString($0)] as [String?]).asCells } - return PluginQueryResult( - columns: ["result"], - columnTypeNames: ["String"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - - case .error(let e): - return PluginQueryResult( - columns: ["result"], - columnTypeNames: ["String"], - rows: [[e].asCells], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - - case .null: - return PluginQueryResult( - columns: ["result"], - columnTypeNames: ["String"], - rows: [["(nil)"].asCells], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - } - - /// An error element is marked the way `redis-cli` marks one, because `EXEC` answers with the - /// failures of the block inline among its values: an unmarked `WRONGTYPE Operation against a - /// key holding the wrong kind of value` in a result row reads as a stored string. - func redisReplyToString(_ reply: RedisReply) -> String { - switch reply { - case .string(let s), .status(let s): return s - case .error(let message): return "(error) \(message)" - case .integer(let i): return String(i) - case .data(let d): return String(data: d, encoding: .utf8) ?? d.base64EncodedString() - case .array(let items): return "[\(items.map { redisReplyToString($0) }.joined(separator: ", "))]" - case .null: return "(nil)" - } - } - - func buildHashResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - guard let items = result.arrayValue, !items.isEmpty else { - return PluginQueryResult( - columns: ["Field", "Value"], - columnTypeNames: ["String", "String"], - rows: [], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - var rows: [[PluginCellValue]] = [] - var i = 0 - while i + 1 < items.count { - rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])].asCells) - i += 2 - } - - return PluginQueryResult( - columns: ["Field", "Value"], - columnTypeNames: ["String", "String"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - func buildListResult(_ result: RedisReply, startOffset: Int = 0, startTime: Date) -> PluginQueryResult { - guard let items = result.arrayValue else { - return PluginQueryResult( - columns: ["Index", "Value"], - columnTypeNames: ["Int64", "String"], - rows: [], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - let rows = items.enumerated().map { index, item -> [PluginCellValue] in - ([String(startOffset + index), redisReplyToString(item)] as [String?]).asCells - } - - return PluginQueryResult( - columns: ["Index", "Value"], - columnTypeNames: ["Int64", "String"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - func buildSetResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - guard let items = result.arrayValue else { - return PluginQueryResult( - columns: ["Member"], - columnTypeNames: ["String"], - rows: [], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - let rows = items.map { ([redisReplyToString($0)] as [String?]).asCells } - - return PluginQueryResult( - columns: ["Member"], - columnTypeNames: ["String"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - func buildSortedSetResult(_ result: RedisReply, withScores: Bool, startTime: Date) -> PluginQueryResult { - guard let items = result.arrayValue else { - return PluginQueryResult( - columns: withScores ? ["Member", "Score"] : ["Member"], - columnTypeNames: withScores ? ["String", "Double"] : ["String"], - rows: [], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - if withScores { - var rows: [[PluginCellValue]] = [] - var i = 0 - while i + 1 < items.count { - rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])].asCells) - i += 2 - } - return PluginQueryResult( - columns: ["Member", "Score"], - columnTypeNames: ["String", "Double"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } else { - let rows = items.map { ([redisReplyToString($0)] as [String?]).asCells } - return PluginQueryResult( - columns: ["Member"], - columnTypeNames: ["String"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - } - - func buildStreamResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - guard let entries = result.arrayValue else { - return PluginQueryResult( - columns: ["ID", "Fields"], - columnTypeNames: ["String", "String"], - rows: [], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - var rows: [[PluginCellValue]] = [] - for entry in entries { - guard let entryParts = entry.arrayValue, entryParts.count >= 2, - let fields = entryParts[1].arrayValue else { - continue - } - let entryId = redisReplyToString(entryParts[0]) - - var fieldPairs: [String] = [] - var i = 0 - while i + 1 < fields.count { - fieldPairs.append("\(redisReplyToString(fields[i]))=\(redisReplyToString(fields[i + 1]))") - i += 2 - } - rows.append([entryId, fieldPairs.joined(separator: ", ")].asCells) - } - - return PluginQueryResult( - columns: ["ID", "Fields"], - columnTypeNames: ["String", "String"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - func buildConfigResult(_ result: RedisReply, startTime: Date) -> PluginQueryResult { - guard let items = result.arrayValue, !items.isEmpty else { - return PluginQueryResult( - columns: ["Parameter", "Value"], - columnTypeNames: ["String", "String"], - rows: [], - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } - - var rows: [[PluginCellValue]] = [] - var i = 0 - while i + 1 < items.count { - rows.append([redisReplyToString(items[i]), redisReplyToString(items[i + 1])].asCells) - i += 2 - } - - return PluginQueryResult( - columns: ["Parameter", "Value"], - columnTypeNames: ["String", "String"], - rows: rows, - rowsAffected: 0, - executionTime: Date().timeIntervalSince(startTime) - ) - } } diff --git a/Plugins/RedisDriverPlugin/RedisReply.swift b/Plugins/RedisDriverPlugin/RedisReply.swift index 46ab19ec4d..a4284b2381 100644 --- a/Plugins/RedisDriverPlugin/RedisReply.swift +++ b/Plugins/RedisDriverPlugin/RedisReply.swift @@ -47,6 +47,20 @@ enum RedisReply { return items } + /// An error element is marked the way `redis-cli` marks one, because `EXEC` answers with the + /// failures of the block inline among its values: an unmarked `WRONGTYPE Operation against a + /// key holding the wrong kind of value` in a result row reads as a stored string. + var displayText: String { + switch self { + case .string(let text), .status(let text): return text + case .error(let message): return "(error) \(message)" + case .integer(let value): return String(value) + case .data(let bytes): return String(data: bytes, encoding: .utf8) ?? bytes.base64EncodedString() + case .array(let items): return "[\(items.map(\.displayText).joined(separator: ", "))]" + case .null: return "(nil)" + } + } + var isError: Bool { if case .error = self { return true } return false diff --git a/Plugins/RedisDriverPlugin/RedisReplyGrid.swift b/Plugins/RedisDriverPlugin/RedisReplyGrid.swift new file mode 100644 index 0000000000..71e713090d --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisReplyGrid.swift @@ -0,0 +1,116 @@ +// +// RedisReplyGrid.swift +// RedisDriverPlugin +// + +import Foundation +import TableProPluginKit + +struct RedisReplyGrid: Equatable { + let columns: [String] + let columnTypeNames: [String] + let rows: [PluginRow] + + func queryResult(startTime: Date) -> PluginQueryResult { + PluginQueryResult( + columns: columns, + columnTypeNames: columnTypeNames, + rows: rows, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(startTime) + ) + } + + static func hash(_ reply: RedisReply) -> RedisReplyGrid { + RedisReplyGrid( + columns: ["Field", "Value"], + columnTypeNames: ["String", "String"], + rows: pairRows(reply.arrayValue ?? []) + ) + } + + static func list(_ reply: RedisReply, startOffset: Int) -> RedisReplyGrid { + let items = reply.arrayValue ?? [] + return RedisReplyGrid( + columns: ["Index", "Value"], + columnTypeNames: ["Int64", "String"], + rows: items.enumerated().map { index, item in + [.text(String(startOffset + index)), .text(item.displayText)] + } + ) + } + + static func set(_ reply: RedisReply) -> RedisReplyGrid { + RedisReplyGrid( + columns: ["Member"], + columnTypeNames: ["String"], + rows: singleRows(reply.arrayValue ?? []) + ) + } + + static func sortedSet(_ reply: RedisReply, withScores: Bool) -> RedisReplyGrid { + let items = reply.arrayValue ?? [] + guard withScores else { + return RedisReplyGrid(columns: ["Member"], columnTypeNames: ["String"], rows: singleRows(items)) + } + return RedisReplyGrid( + columns: ["Member", "Score"], + columnTypeNames: ["String", "Double"], + rows: pairRows(items) + ) + } + + static func stream(_ reply: RedisReply) -> RedisReplyGrid { + let entries = reply.arrayValue ?? [] + return RedisReplyGrid( + columns: ["ID", "Fields"], + columnTypeNames: ["String", "String"], + rows: entries.compactMap(streamRow) + ) + } + + static func config(_ reply: RedisReply) -> RedisReplyGrid { + RedisReplyGrid( + columns: ["Parameter", "Value"], + columnTypeNames: ["String", "String"], + rows: pairRows(reply.arrayValue ?? []) + ) + } + + static func generic(_ reply: RedisReply) -> RedisReplyGrid { + switch reply { + case .integer(let value): + return RedisReplyGrid(columns: ["result"], columnTypeNames: ["Int64"], rows: [[.text(String(value))]]) + case .array(let items): + return resultColumn(items.map(\.displayText)) + case .error(let message): + return resultColumn([message]) + default: + return resultColumn([reply.displayText]) + } + } + + private static func resultColumn(_ values: [String]) -> RedisReplyGrid { + RedisReplyGrid(columns: ["result"], columnTypeNames: ["String"], rows: values.map { [.text($0)] }) + } + + private static func singleRows(_ items: [RedisReply]) -> [PluginRow] { + items.map { [.text($0.displayText)] } + } + + private static func pairRows(_ items: [RedisReply]) -> [PluginRow] { + stride(from: 0, to: items.count - 1, by: 2).map { index in + [.text(items[index].displayText), .text(items[index + 1].displayText)] + } + } + + private static func streamRow(_ entry: RedisReply) -> PluginRow? { + guard let parts = entry.arrayValue, parts.count >= 2, let fields = parts[1].arrayValue else { + return nil + } + let fieldPairs = stride(from: 0, to: fields.count - 1, by: 2).map { index in + "\(fields[index].displayText)=\(fields[index + 1].displayText)" + } + return [.text(parts[0].displayText), .text(fieldPairs.joined(separator: ", "))] + } +} diff --git a/TableProTests/Core/Redis/RedisResultBuildingTests.swift b/TableProTests/Core/Redis/RedisResultBuildingTests.swift index 39a949eff7..9921418442 100644 --- a/TableProTests/Core/Redis/RedisResultBuildingTests.swift +++ b/TableProTests/Core/Redis/RedisResultBuildingTests.swift @@ -2,516 +2,387 @@ // RedisResultBuildingTests.swift // TableProTests // -// Regression tests for the Redis build*Result methods. -// -// The original bug: build methods used `stringArrayValue` (compactMap(\.stringValue)) -// which silently dropped `.data`, `.null`, and `.integer` entries, corrupting -// alternating field/value pairs in hashes and other paired structures. -// The fix switched to `arrayValue` (raw [RedisReply]) + `redisReplyToString()`. -// -// Because RedisPluginDriver lives in a plugin bundle and cannot be @testable -// imported, we replicate the fixed logic here as private helpers. -// import Foundation import TableProPluginKit import Testing -// MARK: - Private Local Helpers (copied from RedisDriverPlugin) - -private enum TestRedisReply { - case string(String) - case integer(Int64) - case array([TestRedisReply]) - case data(Data) - case status(String) - case error(String) - case null - - var stringValue: String? { - switch self { - case .string(let s), .status(let s): return s - case .data(let d): return String(data: d, encoding: .utf8) - default: return nil - } - } - - var intValue: Int? { - switch self { - case .integer(let i): return Int(i) - case .string(let s): return Int(s) - default: return nil - } - } - - var stringArrayValue: [String]? { - guard case .array(let items) = self else { return nil } - return items.compactMap(\.stringValue) - } - - var arrayValue: [TestRedisReply]? { - guard case .array(let items) = self else { return nil } - return items - } -} - -// MARK: - Fixed Logic Replicas - -/// Matches the fixed `redisReplyToString` in RedisPluginDriver. -private func testRedisReplyToString(_ reply: TestRedisReply) -> String { - switch reply { - case .string(let s), .status(let s), .error(let s): return s - case .integer(let i): return String(i) - case .data(let d): return String(data: d, encoding: .utf8) ?? d.base64EncodedString() - case .array(let items): return "[\(items.map { testRedisReplyToString($0) }.joined(separator: ", "))]" - case .null: return "(nil)" - } -} - -/// Result type mirroring the relevant fields of PluginQueryResult. -private struct TestResult { - let columns: [String] - let rows: [[String?]] -} - -private func buildTestHashResult(_ result: TestRedisReply) -> TestResult { - guard let items = result.arrayValue, !items.isEmpty else { - return TestResult(columns: ["Field", "Value"], rows: []) - } - - var rows: [[String?]] = [] - var i = 0 - while i + 1 < items.count { - rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])]) - i += 2 - } - - return TestResult(columns: ["Field", "Value"], rows: rows) -} - -private func buildTestListResult(_ result: TestRedisReply, startOffset: Int = 0) -> TestResult { - guard let items = result.arrayValue else { - return TestResult(columns: ["Index", "Value"], rows: []) - } - - let rows = items.enumerated().map { index, item -> [String?] in - [String(startOffset + index), testRedisReplyToString(item)] - } - - return TestResult(columns: ["Index", "Value"], rows: rows) -} - -private func buildTestSetResult(_ result: TestRedisReply) -> TestResult { - guard let items = result.arrayValue else { - return TestResult(columns: ["Member"], rows: []) - } - - let rows = items.map { [testRedisReplyToString($0)] as [String?] } - return TestResult(columns: ["Member"], rows: rows) -} - -private func buildTestSortedSetResult(_ result: TestRedisReply, withScores: Bool) -> TestResult { - guard let items = result.arrayValue else { - return TestResult( - columns: withScores ? ["Member", "Score"] : ["Member"], - rows: [] - ) - } - - if withScores { - var rows: [[String?]] = [] - var i = 0 - while i + 1 < items.count { - rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])]) - i += 2 - } - return TestResult(columns: ["Member", "Score"], rows: rows) - } else { - let rows = items.map { [testRedisReplyToString($0)] as [String?] } - return TestResult(columns: ["Member"], rows: rows) - } -} - -private func buildTestConfigResult(_ result: TestRedisReply) -> TestResult { - guard let items = result.arrayValue, !items.isEmpty else { - return TestResult(columns: ["Parameter", "Value"], rows: []) - } - - var rows: [[String?]] = [] - var i = 0 - while i + 1 < items.count { - rows.append([testRedisReplyToString(items[i]), testRedisReplyToString(items[i + 1])]) - i += 2 - } - - return TestResult(columns: ["Parameter", "Value"], rows: rows) -} - -// MARK: - redisReplyToString - -@Suite("Redis Result Building - redisReplyToString") -struct RedisReplyToStringTests { +@Suite("Redis Result Building - displayText") +struct RedisReplyDisplayTextTests { @Test("string returns the string") func stringCase() { - #expect(testRedisReplyToString(.string("hello")) == "hello") + #expect(RedisReply.string("hello").displayText == "hello") } @Test("integer returns string representation") func integerCase() { - #expect(testRedisReplyToString(.integer(42)) == "42") + #expect(RedisReply.integer(42).displayText == "42") } @Test("data with valid UTF-8 returns the decoded string") func dataValidUtf8() { - let data = Data("some text".utf8) - #expect(testRedisReplyToString(.data(data)) == "some text") + #expect(RedisReply.data(Data("some text".utf8)).displayText == "some text") } @Test("data with invalid UTF-8 returns base64") func dataInvalidUtf8() { let data = Data([0xFF, 0xFE, 0x80]) - let expected = data.base64EncodedString() - #expect(testRedisReplyToString(.data(data)) == expected) + #expect(RedisReply.data(data).displayText == data.base64EncodedString()) } @Test("null returns (nil)") func nullCase() { - #expect(testRedisReplyToString(.null) == "(nil)") + #expect(RedisReply.null.displayText == "(nil)") } @Test("status returns the status string") func statusCase() { - #expect(testRedisReplyToString(.status("OK")) == "OK") + #expect(RedisReply.status("OK").displayText == "OK") } - @Test("error returns the error string") + @Test("error is marked the way redis-cli marks it") func errorCase() { - #expect(testRedisReplyToString(.error("ERR unknown")) == "ERR unknown") + #expect(RedisReply.error("ERR unknown").displayText == "(error) ERR unknown") } @Test("array returns bracketed representation") func arrayCase() { - let reply = TestRedisReply.array([.string("a"), .integer(1), .null]) - #expect(testRedisReplyToString(reply) == "[a, 1, (nil)]") + let reply = RedisReply.array([.string("a"), .integer(1), .null]) + #expect(reply.displayText == "[a, 1, (nil)]") } -} -// MARK: - Hash + @Test("a nested array marks the errors inside it") + func nestedArrayMarksErrors() { + let reply = RedisReply.array([.string("a"), .array([.integer(1), .error("WRONGTYPE x")])]) + #expect(reply.displayText == "[a, [1, (error) WRONGTYPE x]]") + } +} @Suite("Redis Result Building - Hash") struct RedisHashResultTests { @Test("hash with all string values") func allStrings() { - let reply = TestRedisReply.array([ + let grid = RedisReplyGrid.hash(.array([ .string("field1"), .string("value1"), .string("field2"), .string("value2") - ]) - let result = buildTestHashResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["field1", "value1"]) - #expect(result.rows[1] == ["field2", "value2"]) + ])) + #expect(grid.columns == ["Field", "Value"]) + #expect(grid.columnTypeNames == ["String", "String"]) + #expect(grid.rows == [["field1", "value1"], ["field2", "value2"]]) } @Test("hash with binary data values preserves all pairs") func binaryDataValues() { let binaryData = Data([0xFF, 0xFE]) - let reply = TestRedisReply.array([ + let grid = RedisReplyGrid.hash(.array([ .string("field1"), .data(binaryData), .string("field2"), .string("value2") - ]) - let result = buildTestHashResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["field1", binaryData.base64EncodedString()]) - #expect(result.rows[1] == ["field2", "value2"]) + ])) + #expect(grid.rows == [["field1", .text(binaryData.base64EncodedString())], ["field2", "value2"]]) } @Test("hash with null values shows (nil) instead of dropping") func nullValues() { - let reply = TestRedisReply.array([ + let grid = RedisReplyGrid.hash(.array([ .string("field1"), .null, .string("field2"), .string("value2") - ]) - let result = buildTestHashResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["field1", "(nil)"]) - #expect(result.rows[1] == ["field2", "value2"]) + ])) + #expect(grid.rows == [["field1", "(nil)"], ["field2", "value2"]]) } @Test("hash with integer values shows string representation") func integerValues() { - let reply = TestRedisReply.array([ - .string("field1"), .integer(42) - ]) - let result = buildTestHashResult(reply) - #expect(result.rows.count == 1) - #expect(result.rows[0] == ["field1", "42"]) + let grid = RedisReplyGrid.hash(.array([.string("field1"), .integer(42)])) + #expect(grid.rows == [["field1", "42"]]) } @Test("hash with empty array returns zero rows") func emptyArray() { - let reply = TestRedisReply.array([]) - let result = buildTestHashResult(reply) - #expect(result.rows.isEmpty) + let grid = RedisReplyGrid.hash(.array([])) + #expect(grid.rows.isEmpty) + #expect(grid.columns == ["Field", "Value"]) } @Test("hash with null reply returns zero rows") func nullReply() { - let result = buildTestHashResult(.null) - #expect(result.rows.isEmpty) + #expect(RedisReplyGrid.hash(.null).rows.isEmpty) } @Test("hash with odd number of elements ignores orphan") func oddElements() { - let reply = TestRedisReply.array([ - .string("f1"), .string("v1"), - .string("orphan") - ]) - let result = buildTestHashResult(reply) - #expect(result.rows.count == 1) - #expect(result.rows[0] == ["f1", "v1"]) + let grid = RedisReplyGrid.hash(.array([.string("f1"), .string("v1"), .string("orphan")])) + #expect(grid.rows == [["f1", "v1"]]) } - @Test("regression: stringArrayValue would corrupt hash with binary data") - func regressionStringArrayValueCorruption() { - // This is the core regression scenario. With the old code using stringArrayValue, - // .data(non-UTF8) would be dropped, shifting "field2" into the value position of - // field1, and "value2" would become an orphan key with no value. + @Test("stringArrayValue drops binary entries, and the hash grid keeps every pair") + func binaryEntriesKeepTheirPairs() { let binaryData = Data([0xFF, 0xFE]) - let reply = TestRedisReply.array([ + let reply = RedisReply.array([ .string("field1"), .data(binaryData), .string("field2"), .string("value2") ]) - // Old (buggy) behavior: stringArrayValue drops the .data entry - let buggyArray = reply.stringArrayValue - // Would be ["field1", "field2", "value2"] — only 3 elements, pairs are misaligned - #expect(buggyArray?.count == 3) - #expect(buggyArray == ["field1", "field2", "value2"]) + #expect(reply.stringArrayValue == ["field1", "field2", "value2"]) - // Fixed behavior: arrayValue + redisReplyToString preserves all entries - let result = buildTestHashResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0][0] == "field1") - #expect(result.rows[0][1] == binaryData.base64EncodedString()) - #expect(result.rows[1] == ["field2", "value2"]) + let grid = RedisReplyGrid.hash(reply) + #expect(grid.rows == [["field1", .text(binaryData.base64EncodedString())], ["field2", "value2"]]) } - @Test("regression: stringArrayValue would corrupt hash with integer values") - func regressionStringArrayValueIntegerDrop() { - let reply = TestRedisReply.array([ + @Test("stringArrayValue drops integer entries, and the hash grid keeps every pair") + func integerEntriesKeepTheirPairs() { + let reply = RedisReply.array([ .string("counter"), .integer(100), .string("name"), .string("test") ]) - // Old (buggy) behavior: stringArrayValue drops .integer - let buggyArray = reply.stringArrayValue - #expect(buggyArray == ["counter", "name", "test"]) + #expect(reply.stringArrayValue == ["counter", "name", "test"]) - // Fixed behavior: integer is converted to "100" - let result = buildTestHashResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["counter", "100"]) - #expect(result.rows[1] == ["name", "test"]) + let grid = RedisReplyGrid.hash(reply) + #expect(grid.rows == [["counter", "100"], ["name", "test"]]) } } -// MARK: - List - @Suite("Redis Result Building - List") struct RedisListResultTests { @Test("list with all strings shows correct indices and values") func allStrings() { - let reply = TestRedisReply.array([.string("a"), .string("b"), .string("c")]) - let result = buildTestListResult(reply) - #expect(result.rows.count == 3) - #expect(result.rows[0] == ["0", "a"]) - #expect(result.rows[1] == ["1", "b"]) - #expect(result.rows[2] == ["2", "c"]) + let grid = RedisReplyGrid.list(.array([.string("a"), .string("b"), .string("c")]), startOffset: 0) + #expect(grid.columns == ["Index", "Value"]) + #expect(grid.columnTypeNames == ["Int64", "String"]) + #expect(grid.rows == [["0", "a"], ["1", "b"], ["2", "c"]]) } @Test("list with binary data uses base64 fallback") func binaryData() { let data = Data([0xFF, 0xFE]) - let reply = TestRedisReply.array([.string("ok"), .data(data)]) - let result = buildTestListResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["0", "ok"]) - #expect(result.rows[1] == ["1", data.base64EncodedString()]) + let grid = RedisReplyGrid.list(.array([.string("ok"), .data(data)]), startOffset: 0) + #expect(grid.rows == [["0", "ok"], ["1", .text(data.base64EncodedString())]]) } @Test("list with null entries shows (nil)") func nullEntries() { - let reply = TestRedisReply.array([.string("a"), .null, .string("c")]) - let result = buildTestListResult(reply) - #expect(result.rows.count == 3) - #expect(result.rows[1] == ["1", "(nil)"]) + let grid = RedisReplyGrid.list(.array([.string("a"), .null, .string("c")]), startOffset: 0) + #expect(grid.rows == [["0", "a"], ["1", "(nil)"], ["2", "c"]]) } @Test("list with offset starts indices from offset") func withOffset() { - let reply = TestRedisReply.array([.string("x"), .string("y")]) - let result = buildTestListResult(reply, startOffset: 10) - #expect(result.rows[0] == ["10", "x"]) - #expect(result.rows[1] == ["11", "y"]) + let grid = RedisReplyGrid.list(.array([.string("x"), .string("y")]), startOffset: 10) + #expect(grid.rows == [["10", "x"], ["11", "y"]]) } @Test("list with integer entries shows string representation") func integerEntries() { - let reply = TestRedisReply.array([.integer(1), .integer(2)]) - let result = buildTestListResult(reply) - #expect(result.rows[0] == ["0", "1"]) - #expect(result.rows[1] == ["1", "2"]) + let grid = RedisReplyGrid.list(.array([.integer(1), .integer(2)]), startOffset: 0) + #expect(grid.rows == [["0", "1"], ["1", "2"]]) } @Test("list with null reply returns zero rows") func nullReply() { - let result = buildTestListResult(.null) - #expect(result.rows.isEmpty) + let grid = RedisReplyGrid.list(.null, startOffset: 0) + #expect(grid.rows.isEmpty) + #expect(grid.columnTypeNames == ["Int64", "String"]) } } -// MARK: - Set - @Suite("Redis Result Building - Set") struct RedisSetResultTests { @Test("set with all strings shows correct members") func allStrings() { - let reply = TestRedisReply.array([.string("a"), .string("b"), .string("c")]) - let result = buildTestSetResult(reply) - #expect(result.rows.count == 3) - #expect(result.rows[0] == ["a"]) - #expect(result.rows[1] == ["b"]) - #expect(result.rows[2] == ["c"]) + let grid = RedisReplyGrid.set(.array([.string("a"), .string("b"), .string("c")])) + #expect(grid.columns == ["Member"]) + #expect(grid.columnTypeNames == ["String"]) + #expect(grid.rows == [["a"], ["b"], ["c"]]) } @Test("set with binary data uses base64 fallback") func binaryData() { let data = Data([0x80, 0x81]) - let reply = TestRedisReply.array([.string("ok"), .data(data)]) - let result = buildTestSetResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["ok"]) - #expect(result.rows[1] == [data.base64EncodedString()]) + let grid = RedisReplyGrid.set(.array([.string("ok"), .data(data)])) + #expect(grid.rows == [["ok"], [.text(data.base64EncodedString())]]) } @Test("set with null and integer entries") func mixedTypes() { - let reply = TestRedisReply.array([.null, .integer(7)]) - let result = buildTestSetResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["(nil)"]) - #expect(result.rows[1] == ["7"]) + let grid = RedisReplyGrid.set(.array([.null, .integer(7)])) + #expect(grid.rows == [["(nil)"], ["7"]]) } @Test("set with null reply returns zero rows") func nullReply() { - let result = buildTestSetResult(.null) - #expect(result.rows.isEmpty) + #expect(RedisReplyGrid.set(.null).rows.isEmpty) } } -// MARK: - Sorted Set - @Suite("Redis Result Building - Sorted Set") struct RedisSortedSetResultTests { @Test("sorted set with scores shows correct member/score pairs") func withScores() { - let reply = TestRedisReply.array([ + let grid = RedisReplyGrid.sortedSet(.array([ .string("alice"), .string("100"), .string("bob"), .string("200") - ]) - let result = buildTestSortedSetResult(reply, withScores: true) - #expect(result.columns == ["Member", "Score"]) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["alice", "100"]) - #expect(result.rows[1] == ["bob", "200"]) + ]), withScores: true) + #expect(grid.columns == ["Member", "Score"]) + #expect(grid.columnTypeNames == ["String", "Double"]) + #expect(grid.rows == [["alice", "100"], ["bob", "200"]]) } @Test("sorted set without scores shows just members") func withoutScores() { - let reply = TestRedisReply.array([.string("alice"), .string("bob")]) - let result = buildTestSortedSetResult(reply, withScores: false) - #expect(result.columns == ["Member"]) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["alice"]) - #expect(result.rows[1] == ["bob"]) + let grid = RedisReplyGrid.sortedSet(.array([.string("alice"), .string("bob")]), withScores: false) + #expect(grid.columns == ["Member"]) + #expect(grid.columnTypeNames == ["String"]) + #expect(grid.rows == [["alice"], ["bob"]]) } @Test("sorted set with binary data members uses base64 fallback") func binaryDataMembers() { let data = Data([0xFF, 0xFE]) - let reply = TestRedisReply.array([ - .data(data), .string("50") - ]) - let result = buildTestSortedSetResult(reply, withScores: true) - #expect(result.rows.count == 1) - #expect(result.rows[0] == [data.base64EncodedString(), "50"]) + let grid = RedisReplyGrid.sortedSet(.array([.data(data), .string("50")]), withScores: true) + #expect(grid.rows == [[.text(data.base64EncodedString()), "50"]]) } @Test("sorted set with integer scores") func integerScores() { - let reply = TestRedisReply.array([ - .string("member"), .integer(99) - ]) - let result = buildTestSortedSetResult(reply, withScores: true) - #expect(result.rows.count == 1) - #expect(result.rows[0] == ["member", "99"]) + let grid = RedisReplyGrid.sortedSet(.array([.string("member"), .integer(99)]), withScores: true) + #expect(grid.rows == [["member", "99"]]) } @Test("sorted set with null reply returns zero rows") func nullReply() { - let result = buildTestSortedSetResult(.null, withScores: true) - #expect(result.rows.isEmpty) + let grid = RedisReplyGrid.sortedSet(.null, withScores: true) + #expect(grid.rows.isEmpty) + #expect(grid.columns == ["Member", "Score"]) } @Test("sorted set with odd elements and scores ignores orphan") func oddElementsWithScores() { - let reply = TestRedisReply.array([ + let grid = RedisReplyGrid.sortedSet(.array([ .string("alice"), .string("100"), .string("orphan") - ]) - let result = buildTestSortedSetResult(reply, withScores: true) - #expect(result.rows.count == 1) - #expect(result.rows[0] == ["alice", "100"]) + ]), withScores: true) + #expect(grid.rows == [["alice", "100"]]) } } -// MARK: - Config +@Suite("Redis Result Building - Stream") +struct RedisStreamGridTests { + @Test("each XRANGE entry becomes its ID and its fields") + func entriesBecomeRows() { + let grid = RedisReplyGrid.stream(.array([ + .array([.string("1-0"), .array([.string("f1"), .string("v1"), .string("f2"), .string("v2")])]), + .array([.string("2-0"), .array([.string("only"), .integer(3)])]) + ])) + #expect(grid.columns == ["ID", "Fields"]) + #expect(grid.columnTypeNames == ["String", "String"]) + #expect(grid.rows == [["1-0", "f1=v1, f2=v2"], ["2-0", "only=3"]]) + } + + @Test("an entry that is not an ID and a field list is skipped") + func malformedEntryIsSkipped() { + let grid = RedisReplyGrid.stream(.array([ + .string("garbage"), + .array([.string("1-0")]), + .array([.string("2-0"), .string("not a field list")]), + .array([.string("3-0"), .array([.string("f"), .string("v")])]) + ])) + #expect(grid.rows == [["3-0", "f=v"]]) + } + + @Test("a reply that is not an array returns zero rows") + func nullReply() { + #expect(RedisReplyGrid.stream(.null).rows.isEmpty) + } +} @Suite("Redis Result Building - Config") struct RedisConfigResultTests { @Test("config with all strings shows correct parameter/value pairs") func allStrings() { - let reply = TestRedisReply.array([ + let grid = RedisReplyGrid.config(.array([ .string("maxmemory"), .string("0"), .string("timeout"), .string("300") - ]) - let result = buildTestConfigResult(reply) - #expect(result.rows.count == 2) - #expect(result.rows[0] == ["maxmemory", "0"]) - #expect(result.rows[1] == ["timeout", "300"]) + ])) + #expect(grid.columns == ["Parameter", "Value"]) + #expect(grid.columnTypeNames == ["String", "String"]) + #expect(grid.rows == [["maxmemory", "0"], ["timeout", "300"]]) } @Test("config with empty array returns zero rows") func emptyArray() { - let reply = TestRedisReply.array([]) - let result = buildTestConfigResult(reply) - #expect(result.rows.isEmpty) + #expect(RedisReplyGrid.config(.array([])).rows.isEmpty) } @Test("config with null reply returns zero rows") func nullReply() { - let result = buildTestConfigResult(.null) - #expect(result.rows.isEmpty) + #expect(RedisReplyGrid.config(.null).rows.isEmpty) } @Test("config with integer values shows string representation") func integerValues() { - let reply = TestRedisReply.array([ - .string("hz"), .integer(10) + let grid = RedisReplyGrid.config(.array([.string("hz"), .integer(10)])) + #expect(grid.rows == [["hz", "10"]]) + } +} + +@Suite("Redis Result Building - Generic") +struct RedisGenericGridTests { + @Test("an integer reply is typed Int64") + func integerReply() { + let grid = RedisReplyGrid.generic(.integer(7)) + #expect(grid.columns == ["result"]) + #expect(grid.columnTypeNames == ["Int64"]) + #expect(grid.rows == [["7"]]) + } + + @Test("a status reply is one text row") + func statusReply() { + let grid = RedisReplyGrid.generic(.status("OK")) + #expect(grid.columnTypeNames == ["String"]) + #expect(grid.rows == [["OK"]]) + } + + @Test("an array reply is one row per element, with the errors inside it marked") + func arrayReply() { + let grid = RedisReplyGrid.generic(.array([ + .status("OK"), + .error("WRONGTYPE Operation against a key holding the wrong kind of value"), + .integer(2), + .null + ])) + #expect(grid.columnTypeNames == ["String"]) + #expect(grid.rows == [ + ["OK"], + ["(error) WRONGTYPE Operation against a key holding the wrong kind of value"], + ["2"], + ["(nil)"] ]) - let result = buildTestConfigResult(reply) - #expect(result.rows.count == 1) - #expect(result.rows[0] == ["hz", "10"]) + } + + @Test("a top-level error is its bare message") + func errorReply() { + #expect(RedisReplyGrid.generic(.error("ERR unknown")).rows == [["ERR unknown"]]) + } + + @Test("a null reply reads (nil)") + func nullReply() { + #expect(RedisReplyGrid.generic(.null).rows == [["(nil)"]]) + } + + @Test("binary data that is not UTF-8 reads as base64") + func binaryReply() { + let data = Data([0xFF, 0x00, 0xFE]) + #expect(RedisReplyGrid.generic(.data(data)).rows == [[.text(data.base64EncodedString())]]) + } + + @Test("the query result carries the grid and affects no rows") + func queryResultCarriesTheGrid() { + let grid = RedisReplyGrid.generic(.array([.string("a"), .string("b")])) + let result = grid.queryResult(startTime: Date()) + #expect(result.columns == grid.columns) + #expect(result.columnTypeNames == grid.columnTypeNames) + #expect(result.rows == grid.rows) + #expect(result.rowsAffected == 0) + #expect(result.executionTime >= 0) } } diff --git a/project.yml b/project.yml index b96f81b8cb..e525dad3a7 100644 --- a/project.yml +++ b/project.yml @@ -649,6 +649,7 @@ targets: - Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift - Plugins/RedisDriverPlugin/RedisQueuedDatabase.swift - Plugins/RedisDriverPlugin/RedisReply.swift + - Plugins/RedisDriverPlugin/RedisReplyGrid.swift - Plugins/RedisDriverPlugin/RedisSentinelResolver.swift - Plugins/RedisDriverPlugin/RedisSessionFootprint.swift - Plugins/RedisDriverPlugin/RedisStatementGenerator.swift From ff7d147a5c1b3532c03917fed4f2df13cc50c805 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:34:03 +0700 Subject: [PATCH 10/30] fix(plugin-redis): keep every argument of a Redis command the parser models --- .../RedisCommandParser.swift | 138 +++++------ .../RedisPluginDriver+Operations.swift | 14 +- .../RedisDriverPlugin/RedisPluginDriver.swift | 6 +- .../Core/Redis/RedisCommandParserTests.swift | 216 +++++++++++++++++- 4 files changed, 277 insertions(+), 97 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index c73c1b303d..e2e671336d 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -16,7 +16,7 @@ enum RedisOperation { case set(key: String, value: Data, options: RedisSetOptions?) case del(keys: [String]) case keys(pattern: String) - case scan(cursor: String, pattern: String?, count: Int?) + case scan(cursor: String, pattern: String?, count: Int?, type: String?) case keyBrowse(pattern: String?, typeScope: String?, limit: Int, offset: Int, database: Int? = nil) case keyTree(pattern: String?, limit: Int) case type(key: String) @@ -57,11 +57,11 @@ enum RedisOperation { // Server case ping - case info(section: String?) + case info(sections: [String]) case dbsize case flushdb case select(database: Int) - case configGet(parameter: String) + case configGet(parameters: [String]) case configSet(parameter: String, value: String) case command(args: [RedisArgument]) @@ -121,6 +121,16 @@ extension RedisParseError: PluginDriverError { struct RedisCommandParser { private static let logger = Logger(subsystem: "com.TablePro", category: "RedisCommandParser") + /// The driver rebuilds a typed command from its case alone, so a form with more arguments than + /// the case carries is sent as typed and answered by the server, never trimmed to fit. + private static let typedArgumentCount: [String: Int] = [ + "PING": 0, "DBSIZE": 0, "FLUSHDB": 0, "MULTI": 0, "EXEC": 0, "DISCARD": 0, + "GET": 1, "KEYS": 1, "TYPE": 1, "TTL": 1, "PTTL": 1, "PERSIST": 1, "SELECT": 1, + "HGETALL": 1, "LLEN": 1, "SMEMBERS": 1, "SCARD": 1, "ZCARD": 1, "XLEN": 1, + "HGET": 2, "RENAME": 2, + "LRANGE": 3 + ] + // MARK: - Public API /// Parse a Redis CLI command string into a RedisOperation @@ -137,6 +147,10 @@ struct RedisCommandParser { let command = first.text.uppercased() let args = Array(tokens.dropFirst()) + if let typedCount = typedArgumentCount[command], args.count > typedCount { + return .command(args: tokens) + } + switch command { case "GET", "SET", "DEL", "KEYS", "SCAN", "TYPE", "TTL", "PTTL", "EXPIRE", "PEXPIRE", "EXPIREAT", "PEXPIREAT", @@ -169,11 +183,11 @@ struct RedisCommandParser { return try parseSortedSetCommand(command, args: args, tokens: tokens) case "XRANGE", "XLEN", "XADD", "XREAD", "XREVRANGE", "XDEL", - "XTRIM", "XINFO", "XGROUP", "XACK": + "XTRIM", "XACK": return try parseStreamCommand(command, args: args, tokens: tokens) case "PING", "INFO", "DBSIZE", "FLUSHDB", "FLUSHALL", "SELECT", "CONFIG", - "MULTI", "EXEC", "DISCARD", "AUTH", "OBJECT": + "MULTI", "EXEC", "DISCARD", "AUTH": return try parseServerCommand(command, args: args, tokens: tokens) case "KEYBROWSE": @@ -293,8 +307,10 @@ struct RedisCommandParser { guard let cursor = args.first?.text, !cursor.isEmpty else { throw RedisParseError.missingArgument("SCAN requires a cursor") } - let (pattern, count) = try parseScanOptions(Array(args.dropFirst())) - return .scan(cursor: cursor, pattern: pattern, count: count) + guard let options = try parseScanOptions(Array(args.dropFirst())) else { + return .command(args: tokens) + } + return .scan(cursor: cursor, pattern: options.pattern, count: options.count, type: options.type) case "TYPE": guard args.count >= 1 else { throw RedisParseError.missingArgument("TYPE requires a key") } @@ -675,16 +691,15 @@ struct RedisCommandParser { var i = 3 while i < args.count { 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].text) - flags.append(args[i + 2].text) - i += 2 + guard knownFlags.contains(upper) else { return .command(args: tokens) } + 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].text) + flags.append(args[i + 2].text) + i += 2 } i += 1 } @@ -823,11 +838,15 @@ struct RedisCommandParser { guard args.count >= 3 else { throw RedisParseError.missingArgument("XRANGE requires key, start, and end") } - var count: Int? - if args.count >= 5, args[3].text.uppercased() == "COUNT" { - count = Int(args[4].text) + switch args.count { + case 3: + return .xrange(key: args[0].text, start: args[1].text, end: args[2].text, count: nil) + case 5 where args[3].text.uppercased() == "COUNT": + guard let count = Int(args[4].text) else { return .command(args: tokens) } + return .xrange(key: args[0].text, start: args[1].text, end: args[2].text, count: count) + default: + return .command(args: tokens) } - 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") } @@ -869,30 +888,6 @@ struct RedisCommandParser { } return .command(args: tokens) - case "XINFO": - guard args.count >= 2 else { - throw RedisParseError.missingArgument("XINFO requires a subcommand and key") - } - 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" - ) - } - return .command(args: tokens) - - case "XGROUP": - guard args.count >= 2 else { - throw RedisParseError.missingArgument("XGROUP requires a subcommand and key") - } - 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" - ) - } - return .command(args: tokens) - case "XACK": guard args.count >= 3 else { throw RedisParseError.missingArgument("XACK requires key, group, and at least one ID") @@ -914,7 +909,7 @@ struct RedisCommandParser { return .ping case "INFO": - return .info(section: args.first?.text) + return .info(sections: args.map(\.text)) case "DBSIZE": return .dbsize @@ -938,18 +933,12 @@ struct RedisCommandParser { return .select(database: db) case "CONFIG": - guard args.count >= 2 else { - throw RedisParseError.missingArgument("CONFIG requires a subcommand and parameter") - } - let subcommand = args[0].text.uppercased() - switch subcommand { - case "GET": - 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].text, value: args[2].text) + let parameters = args.dropFirst().map(\.text) + switch args.first?.text.uppercased() { + case "GET" where !parameters.isEmpty: + return .configGet(parameters: parameters) + case "SET" where parameters.count == 2: + return .configSet(parameter: parameters[0], value: parameters[1]) default: return .command(args: tokens) } @@ -969,19 +958,6 @@ struct RedisCommandParser { } return .command(args: tokens) - case "OBJECT": - guard args.count >= 2 else { - throw RedisParseError.missingArgument("OBJECT requires a subcommand and key") - } - let sub = args[0].text.uppercased() - guard sub == "ENCODING" || sub == "REFCOUNT" || sub == "IDLETIME" - || sub == "HELP" || sub == "FREQ" else { - throw RedisParseError.invalidArgument( - "OBJECT subcommand must be ENCODING, REFCOUNT, IDLETIME, FREQ, or HELP" - ) - } - return .command(args: tokens) - default: return .command(args: tokens) } @@ -1055,20 +1031,26 @@ struct RedisCommandParser { return hasOption ? options : nil } - /// Parse SCAN options: MATCH pattern, COUNT count - private static func parseScanOptions(_ args: [RedisArgument]) throws -> (pattern: String?, count: Int?) { + /// Nil when an option is one the typed scan cannot carry, so the command goes out as typed. + private static func parseScanOptions( + _ args: [RedisArgument] + ) throws -> (pattern: String?, count: Int?, type: String?)? { var pattern: String? var count: Int? + var type: String? var i = 0 while i < args.count { let arg = args[i].text.uppercased() switch arg { case "MATCH": - if i + 1 < args.count { - pattern = args[i + 1].text - i += 1 - } + guard i + 1 < args.count else { return nil } + pattern = args[i + 1].text + i += 1 + case "TYPE": + guard i + 1 < args.count else { return nil } + type = args[i + 1].text + i += 1 case "COUNT": guard i + 1 < args.count else { throw RedisParseError.missingArgument("COUNT requires a value") @@ -1079,11 +1061,11 @@ struct RedisCommandParser { count = countVal i += 1 default: - break + return nil } i += 1 } - return (pattern, count) + return (pattern, count, type) } } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 4f1ba12ec8..6fb735a369 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -127,9 +127,9 @@ extension RedisPluginDriver { keys: capped, connection: conn, startTime: startTime, isTruncated: keysTruncated ) - case .scan(let cursor, let pattern, let count): + case .scan(let cursor, let pattern, let count, let type): let page = try await conn.scanKeyspace( - cursor: cursor, pattern: pattern, type: nil, count: count ?? 200 + cursor: cursor, pattern: pattern, type: type, count: count ?? 200 ) return try await buildScanPageResult(page, connection: conn, startTime: startTime) @@ -478,10 +478,8 @@ extension RedisPluginDriver { executionTime: Date().timeIntervalSince(startTime) ) - case .info(let section): - var args = ["INFO"] - if let s = section { args.append(s) } - let result = try await conn.run(args) + case .info(let sections): + let result = try await conn.run(["INFO"] + sections) let infoText = result.stringValue ?? String(describing: result) return PluginQueryResult( columns: ["info"], @@ -510,8 +508,8 @@ extension RedisPluginDriver { try await conn.selectDatabase(database) return buildStatusResult("OK", startTime: startTime) - case .configGet(let parameter): - let result = try await conn.run(["CONFIG", "GET", parameter]) + case .configGet(let parameters): + let result = try await conn.run(["CONFIG", "GET"] + parameters) return RedisReplyGrid.config(result).queryResult(startTime: startTime) case .configSet(let parameter, let value): diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 0c32b8003c..4abf1d9275 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -472,8 +472,10 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { let operation = try RedisCommandParser.parse(trimmed) switch operation { - case .scan(_, let pattern, _): - try await streamScanRows(connection: conn, pattern: pattern, scope: .session, continuation: continuation) + case .scan(_, let pattern, _, let type): + try await streamScanRows( + connection: conn, pattern: pattern, typeFilter: type, scope: .session, continuation: continuation + ) case .keyBrowse(let pattern, let typeScope, _, _, let database): try await conn.withDatabase(database) { try await streamScanRows( diff --git a/TableProTests/Core/Redis/RedisCommandParserTests.swift b/TableProTests/Core/Redis/RedisCommandParserTests.swift index 6e40f8e1d6..8d70762ea9 100644 --- a/TableProTests/Core/Redis/RedisCommandParserTests.swift +++ b/TableProTests/Core/Redis/RedisCommandParserTests.swift @@ -107,25 +107,27 @@ struct RedisCommandParserKeyCommandTests { @Test("SCAN parses cursor with MATCH and COUNT") func scanWithOptions() throws { let op = try RedisCommandParser.parse("SCAN 0 MATCH user:* COUNT 100") - guard case .scan(let cursor, let pattern, let count) = op else { + guard case .scan(let cursor, let pattern, let count, let type) = op else { Issue.record("Expected .scan") return } #expect(cursor == "0") #expect(pattern == "user:*") #expect(count == 100) + #expect(type == nil) } @Test("SCAN without options") func scanBasic() throws { let op = try RedisCommandParser.parse("SCAN 0") - guard case .scan(let cursor, let pattern, let count) = op else { + guard case .scan(let cursor, let pattern, let count, let type) = op else { Issue.record("Expected .scan") return } #expect(cursor == "0") #expect(pattern == nil) #expect(count == nil) + #expect(type == nil) } @Test("TYPE parses key") @@ -232,7 +234,7 @@ struct RedisCommandParserKeyCommandTests { @Test("A SCAN cursor above Int.max keeps its text") func scanCursorAboveIntMax() throws { let op = try RedisCommandParser.parse("SCAN 18446744073709551615") - guard case .scan(let cursor, _, _) = op else { + guard case .scan(let cursor, _, _, _) = op else { Issue.record("Expected .scan, got \(op)") return } @@ -245,6 +247,30 @@ struct RedisCommandParserKeyCommandTests { try RedisCommandParser.parse("SCAN 0 COUNT abc") } } + + @Test("SCAN carries its TYPE along with MATCH and COUNT") + func scanWithType() throws { + let op = try RedisCommandParser.parse("SCAN 0 MATCH u:* TYPE hash COUNT 5") + guard case .scan(let cursor, let pattern, let count, let type) = op else { + Issue.record("Expected .scan, got \(op)") + return + } + #expect(cursor == "0") + #expect(pattern == "u:*") + #expect(count == 5) + #expect(type == "hash") + } + + @Test("A SCAN option the typed scan cannot carry goes out verbatim", arguments: ["SCAN 0 NOVALUES", "SCAN 0 MATCH"]) + func scanWithUnmodelledOptionIsVerbatim(input: String) throws { + let op = try RedisCommandParser.parse(input) + guard case .command(let args) = op else { + Issue.record("Expected .command, got \(op)") + return + } + let texts = args.map(\.text) + #expect(texts == input.split(separator: " ").map(String.init)) + } } // MARK: - Hash Commands @@ -573,21 +599,31 @@ struct RedisCommandParserServerTests { @Test("INFO without section") func infoCommand() throws { let op = try RedisCommandParser.parse("INFO") - guard case .info(let section) = op else { + guard case .info(let sections) = op else { Issue.record("Expected .info") return } - #expect(section == nil) + #expect(sections.isEmpty) } @Test("INFO with section") func infoWithSection() throws { let op = try RedisCommandParser.parse("INFO memory") - guard case .info(let section) = op else { + guard case .info(let sections) = op else { Issue.record("Expected .info") return } - #expect(section == "memory") + #expect(sections == ["memory"]) + } + + @Test("INFO carries every section it names") + func infoWithSeveralSections() throws { + let op = try RedisCommandParser.parse("INFO server clients") + guard case .info(let sections) = op else { + Issue.record("Expected .info, got \(op)") + return + } + #expect(sections == ["server", "clients"]) } @Test("DBSIZE") @@ -619,11 +655,30 @@ struct RedisCommandParserServerTests { @Test("CONFIG GET parses parameter") func configGetCommand() throws { let op = try RedisCommandParser.parse("CONFIG GET maxmemory") - guard case .configGet(let parameter) = op else { + guard case .configGet(let parameters) = op else { Issue.record("Expected .configGet") return } - #expect(parameter == "maxmemory") + #expect(parameters == ["maxmemory"]) + } + + @Test("CONFIG GET carries every parameter it names") + func configGetSeveralParameters() throws { + let op = try RedisCommandParser.parse("CONFIG GET maxmemory maxclients") + guard case .configGet(let parameters) = op else { + Issue.record("Expected .configGet, got \(op)") + return + } + #expect(parameters == ["maxmemory", "maxclients"]) + } + + @Test("FLUSHDB with no argument stays typed") + func flushdbCommand() throws { + let op = try RedisCommandParser.parse("FLUSHDB") + guard case .flushdb = op else { + Issue.record("Expected .flushdb, got \(op)") + return + } } @Test("CONFIG SET parses parameter and value") @@ -871,3 +926,146 @@ struct RedisKeyBrowseRoundTripTests { #expect(typeScope == "stream") } } + +@Suite("RedisCommandParser - arguments a typed case cannot carry") +struct RedisCommandParserVerbatimTests { + @Test( + "A recognised command with arguments its typed case cannot carry goes out exactly as typed", + arguments: [ + "GET a b", + "PING hello", + "FLUSHDB ASYNC", + "MULTI x", + "SELECT 1 2", + "XRANGE s - + COUNT abc", + "ZRANGE z 0 -1 FOO", + "CONFIG SET a 1 b 2", + "CONFIG RESETSTAT", + "CONFIG GET", + "HGET h f extra", + "LRANGE l 0 -1 extra", + "RENAME a b c" + ] + ) + func extraArgumentsGoOutVerbatim(input: String) throws { + let op = try RedisCommandParser.parse(input) + guard case .command(let args) = op else { + Issue.record("Expected .command, got \(op)") + return + } + let expected = RedisArgumentCodec.split(input)?.map { RedisArgument($0).text } + let texts = args.map(\.text) + #expect(texts == expected) + } + + @Test( + "A subcommand the parser never modelled is left for the server to judge", + arguments: ["XGROUP CREATECONSUMER s g c1", "XGROUP HELP", "XINFO HELP", "OBJECT HELP"] + ) + func unmodelledSubcommandsParse(input: String) throws { + let op = try RedisCommandParser.parse(input) + guard case .command(let args) = op else { + Issue.record("Expected .command, got \(op)") + return + } + let texts = args.map(\.text) + #expect(texts == input.split(separator: " ").map(String.init)) + } + + @Test("XRANGE with a COUNT stays typed") + func xrangeWithCountStaysTyped() throws { + let op = try RedisCommandParser.parse("XRANGE s - + COUNT 5") + guard case .xrange(let key, let start, let end, let count) = op else { + Issue.record("Expected .xrange, got \(op)") + return + } + #expect(key == "s") + #expect(start == "-") + #expect(end == "+") + #expect(count == 5) + } + + @Test("A too-short command still throws before it reaches the server") + func tooFewArgumentsStillThrow() { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse("HGET h") + } + } +} + +@Suite("RedisCommandParser - statements the app builds stay typed") +struct RedisCommandParserAppStatementTests { + private static let browseColumns = ["Key", "Type", "TTL", "Length", "Value"] + + private func isVerbatim(_ statement: String) throws -> Bool { + if case .command = try RedisCommandParser.parse(statement) { return true } + return false + } + + private func insertStatements(key: String, type: String, value: String) -> [String] { + let generator = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + let change = PluginRowChange(rowIndex: 0, type: .insert, cellChanges: [], originalRow: nil) + let row: [PluginCellValue] = [.text(key), .text(type), "60", .null, .text(value)] + return generator.generateStatements( + from: [change], insertedRowData: [0: row], deletedRowIndices: [], insertedRowIndices: [0] + ).map(\.statement) + } + + @Test( + "Every insert the grid builds parses to its typed case", + arguments: ["string", "hash", "list", "set", "zset"] + ) + func insertsStayTyped(type: String) throws { + let value = type == "hash" ? #"{"f":"v w"}"# : "a \"quoted\" value" + let statements = insertStatements(key: "user:1 x", type: type, value: value) + #expect(statements.count == 2) + for statement in statements { + #expect(try !isVerbatim(statement), "\(statement)") + } + } + + @Test("A grid update, rename, TTL change and delete parse to their typed cases") + func updatesAndDeletesStayTyped() throws { + let generator = RedisStatementGenerator(namespaceName: "", columns: Self.browseColumns) + let original: [PluginCellValue] = [.text("old key"), .text("STRING"), "-1", "3", .text("old")] + let update = PluginRowChange( + rowIndex: 0, + type: .update, + cellChanges: [ + (columnIndex: 0, columnName: "Key", oldValue: .text("old key"), newValue: .text("new key")), + (columnIndex: 4, columnName: "Value", oldValue: .text("old"), newValue: .text("new value")), + (columnIndex: 2, columnName: "TTL", oldValue: "-1", newValue: "30") + ], + originalRow: original + ) + let persist = PluginRowChange( + rowIndex: 1, + type: .update, + cellChanges: [(columnIndex: 2, columnName: "TTL", oldValue: "30", newValue: "-1")], + originalRow: original + ) + let delete = PluginRowChange(rowIndex: 2, type: .delete, cellChanges: [], originalRow: original) + let statements = generator.generateStatements( + from: [update, persist, delete], insertedRowData: [:], deletedRowIndices: [2], insertedRowIndices: [] + ).map(\.statement) + + #expect(statements.count == 5) + for statement in statements { + #expect(try !isVerbatim(statement), "\(statement)") + } + } + + @Test("The count and browse queries parse to their typed cases") + func browseQueriesStayTyped() throws { + let builder = RedisQueryBuilder() + let queries = [ + builder.buildCountQuery(namespace: ""), + builder.buildCountQuery(namespace: "user:"), + builder.buildKeyBrowseQuery(pattern: "a*", typeScope: "hash", database: 3, limit: 50, offset: 0), + builder.buildExportQuery(database: 2) + ] + for query in queries { + #expect(try !isVerbatim(query), "\(query)") + } + } +} From c0ecd59d92a3e2d20dcb2c21901fbec52aa71a5e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:40:01 +0700 Subject: [PATCH 11/30] fix(sidebar): load the Redis key tree from its own database --- .../RedisCommandParser.swift | 44 +++--- .../RedisPluginDriver+Operations.swift | 10 +- TablePro/Models/UI/RedisKeyTreeCommand.swift | 50 +++++++ .../ViewModels/RedisKeyTreeViewModel.swift | 21 ++- .../MainContentCoordinator+Navigation.swift | 70 +++++++--- .../Core/Redis/RedisKeyTreeCommandTests.swift | 132 +++++++++++++++++- .../Plugins/RedisDatabaseTargetTests.swift | 31 ++++ .../RedisKeyTreeViewModelLoadTests.swift | 70 ++++++++-- .../RedisDatabaseSelectionGateTests.swift | 124 +++++++++++++++- 9 files changed, 484 insertions(+), 68 deletions(-) create mode 100644 TablePro/Models/UI/RedisKeyTreeCommand.swift diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index e2e671336d..3311c7b077 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -18,7 +18,7 @@ enum RedisOperation { case keys(pattern: String) case scan(cursor: String, pattern: String?, count: Int?, type: String?) case keyBrowse(pattern: String?, typeScope: String?, limit: Int, offset: Int, database: Int? = nil) - case keyTree(pattern: String?, limit: Int) + case keyTree(pattern: String?, limit: Int, database: Int? = nil) case type(key: String) case ttl(key: String) case pttl(key: String) @@ -194,16 +194,32 @@ struct RedisCommandParser { return try parseKeyBrowse(args) case "KEYTREE": - return parseKeyTree(args) + return try parseKeyTree(args) default: return .command(args: tokens) } } - /// `DB` names the database the browse reads, so a table's own query reaches it whichever - /// database the session is on: a refresh, a later page and an export all read the database - /// the row names rather than the one the session last moved to. + /// `DB` names the database the read reaches whichever database the session is on: a refresh, + /// a later page and an export all read the database the row names rather than the one the + /// session last moved to, and the key tree lists the database the sidebar shows. + private static func parseDatabaseArgument( + _ args: [RedisArgument], after index: Int, command: String + ) throws -> Int { + guard index + 1 < args.count else { + throw RedisParseError.missingArgument( + String(format: String(localized: "%@ DB requires a database index"), command) + ) + } + guard let database = RedisDatabaseIndex.parse(args[index + 1].text), database >= 0 else { + throw RedisParseError.invalidArgument( + String(format: String(localized: "%@ is not a Redis database index."), args[index + 1].text) + ) + } + return database + } + private static func parseKeyBrowse(_ args: [RedisArgument]) throws -> RedisOperation { var pattern: String? var typeScope: String? @@ -214,15 +230,7 @@ struct RedisCommandParser { while i < args.count { switch args[i].text.uppercased() { case "DB": - guard i + 1 < args.count else { - throw RedisParseError.missingArgument(String(localized: "KEYBROWSE DB requires a database index")) - } - guard let index = RedisDatabaseIndex.parse(args[i + 1].text), index >= 0 else { - throw RedisParseError.invalidArgument( - String(format: String(localized: "%@ is not a Redis database index."), args[i + 1].text) - ) - } - database = index + database = try parseDatabaseArgument(args, after: i, command: "KEYBROWSE") i += 1 case "MATCH": if i + 1 < args.count { @@ -252,12 +260,16 @@ struct RedisCommandParser { return .keyBrowse(pattern: pattern, typeScope: typeScope, limit: limit, offset: offset, database: database) } - private static func parseKeyTree(_ args: [RedisArgument]) -> RedisOperation { + private static func parseKeyTree(_ args: [RedisArgument]) throws -> RedisOperation { var pattern: String? var limit = PluginRowLimits.emergencyMax + var database: Int? var i = 0 while i < args.count { switch args[i].text.uppercased() { + case "DB": + database = try parseDatabaseArgument(args, after: i, command: "KEYTREE") + i += 1 case "MATCH": if i + 1 < args.count { pattern = args[i + 1].text @@ -273,7 +285,7 @@ struct RedisCommandParser { } i += 1 } - return .keyTree(pattern: pattern, limit: limit) + return .keyTree(pattern: pattern, limit: limit, database: database) } // MARK: - Key Commands diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 6fb735a369..7e5d10ee30 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -46,10 +46,12 @@ extension RedisPluginDriver { ) } - case .keyTree(let pattern, let limit): - return try await executeKeyTree( - pattern: pattern, limit: limit, connection: conn, startTime: startTime - ) + case .keyTree(let pattern, let limit, let database): + return try await conn.withDatabase(database) { + 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) diff --git a/TablePro/Models/UI/RedisKeyTreeCommand.swift b/TablePro/Models/UI/RedisKeyTreeCommand.swift new file mode 100644 index 0000000000..ef5b8d3c80 --- /dev/null +++ b/TablePro/Models/UI/RedisKeyTreeCommand.swift @@ -0,0 +1,50 @@ +// +// RedisKeyTreeCommand.swift +// TablePro +// + +import Foundation + +/// The commands the sidebar's key tree sends. Both name what they read explicitly, the database +/// the tree lists and the key a row stands for, so neither depends on where the session was left. +internal enum RedisKeyTreeCommand { + static func listKeys(inDatabase databaseIndex: Int, limit: Int) -> String { + "KEYTREE DB \(databaseIndex) LIMIT \(limit)" + } + + static func openKey(_ key: String, keyType: String?) -> String { + let argument = quoted(key) + switch keyType?.lowercased() { + case "hash"?: return "HGETALL \(argument)" + case "list"?: return "LRANGE \(argument) 0 -1" + case "set"?: return "SMEMBERS \(argument)" + case "zset"?: return "ZRANGE \(argument) 0 -1 WITHSCORES" + case "stream"?: return "XRANGE \(argument) - +" + default: return "GET \(argument)" + } + } + + /// Inside double quotes `redis-cli` decodes `\n`, `\t` and `\xHH`, so a backslash is escaped as + /// well as the quote. Single quotes cannot carry every key: `'a\'` reads as an unclosed quote. + private static func quoted(_ 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: + guard scalar.value < 0x20 || scalar.value == 0x7F else { + result.unicodeScalars.append(scalar) + continue + } + result += String(format: "\\x%02x", scalar.value) + } + } + return result + "\"" + } +} diff --git a/TablePro/ViewModels/RedisKeyTreeViewModel.swift b/TablePro/ViewModels/RedisKeyTreeViewModel.swift index e0a74d8c5a..0ed3369352 100644 --- a/TablePro/ViewModels/RedisKeyTreeViewModel.swift +++ b/TablePro/ViewModels/RedisKeyTreeViewModel.swift @@ -27,17 +27,28 @@ internal final class RedisKeyTreeViewModel: ObservableObject { private struct LoadRequest: Sendable { let connectionId: UUID - let database: String + let databaseIndex: Int let separator: String + + var database: String { + String(databaseIndex) + } } init(metadataProvider: any ScopedMetadataProviding = DatabaseManager.shared) { self.metadataProvider = metadataProvider } + /// The database whose keys the tree shows, which is the one a key opened from it is read from. + /// Nil while the tree shows no database's keys: before its first load, while it moves to + /// another database, and after a load that failed with nothing to keep. + var shownDatabaseIndex: Int? { + state.value.flatMap { Int($0.database) } + } + @discardableResult - func loadKeys(connectionId: UUID, database: String, separator: String) -> Task { - load(LoadRequest(connectionId: connectionId, database: database, separator: separator)) + func loadKeys(connectionId: UUID, databaseIndex: Int, separator: String) -> Task { + load(LoadRequest(connectionId: connectionId, databaseIndex: databaseIndex, separator: separator)) } /// Runs the most recent load again. Nil when nothing has been asked for yet, since there is no @@ -73,10 +84,10 @@ internal final class RedisKeyTreeViewModel: ObservableObject { from provider: any ScopedMetadataProviding ) async -> MetadataFetchOutcome { let scope = DatabaseScope(connectionId: request.connectionId, database: request.database, schema: nil) - let limit = maxKeys + let query = RedisKeyTreeCommand.listKeys(inDatabase: request.databaseIndex, limit: maxKeys) do { let result = try await provider.withMetadataDriver(scope: scope) { driver in - try await driver.execute(query: "KEYTREE LIMIT \(limit)") + try await driver.execute(query: query) } return .fetched(RedisKeyTreeContent(result: result, database: request.database, separator: request.separator)) } catch { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index 338b73c6f3..b6f2b1ca2c 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -758,24 +758,27 @@ extension MainContentCoordinator { executeTableTabQueryDirectly(viewport: .firstRow) } - loadRedisKeyTree(database: database) + loadRedisKeyTree(databaseIndex: dbIndex) } } + /// The session's own database rather than the connection's saved index: a Cluster serves + /// database 0 only and records no other, and neither does a server that refused the saved one. func initRedisKeyTreeIfNeeded() { guard connection.type == .redis else { return } guard SharedSidebarState.forConnection(connectionId).redisKeyTreeViewModel == nil else { return } - loadRedisKeyTree(database: toolbarState.currentDatabase) + let browsed = DatabaseManager.shared.session(for: connectionId)?.browseDatabase + loadRedisKeyTree(databaseIndex: browsed.flatMap { Int($0) } ?? 0) } /// The tree belongs to the connection's shared sidebar state rather than to this window's sidebar /// view model, which may not exist yet, so the load never depends on which window asked for it. - private func loadRedisKeyTree(database: String) { + private func loadRedisKeyTree(databaseIndex: Int) { let sidebarState = SharedSidebarState.forConnection(connectionId) let keyTree = sidebarState.redisKeyTreeViewModel ?? makeRedisKeyTree(in: sidebarState) keyTree.loadKeys( connectionId: connectionId, - database: database, + databaseIndex: databaseIndex, separator: connection.additionalFields["redisSeparator"] ?? ":" ) } @@ -793,23 +796,46 @@ extension MainContentCoordinator { } func openRedisKey(_ keyName: String, keyType: String?) { - let escapedKey = keyName.replacingOccurrences(of: "\"", with: "\\\"") - let query: String - switch keyType?.lowercased() { - case "hash"?: - query = "HGETALL \"\(escapedKey)\"" - case "list"?: - query = "LRANGE \"\(escapedKey)\" 0 -1" - case "set"?: - query = "SMEMBERS \"\(escapedKey)\"" - case "zset"?: - query = "ZRANGE \"\(escapedKey)\" 0 -1 WITHSCORES" - case "stream"?: - query = "XRANGE \"\(escapedKey)\" - +" - default: - query = "GET \"\(escapedKey)\"" - } - tabManager.addTab(initialQuery: query, title: keyName) - runQuery(viewport: .firstRow) + let keyTree = SharedSidebarState.forConnection(connectionId).redisKeyTreeViewModel + guard let databaseIndex = keyTree?.shownDatabaseIndex else { + navigationLogger.warning("Not opening a Redis key: the key tree shows no database") + return + } + openRedisKey(keyName, keyType: keyType, inDatabase: databaseIndex) + } + + /// A key is read from the database the tree listed it in, not from wherever a typed `SELECT` + /// left the session, so the session moves there first. The move waits behind a database click + /// still in flight instead of cancelling it, which would leave that click's tab loading, and a + /// later click cancels both. + func openRedisKey(_ keyName: String, keyType: String?, inDatabase databaseIndex: Int) { + tabManager.addTab(initialQuery: RedisKeyTreeCommand.openKey(keyName, keyType: keyType), title: keyName) + guard let tabId = tabManager.selectedTabId else { return } + + let connId = connectionId + let database = String(databaseIndex) + let inFlight = redisDatabaseSwitchTask + redisDatabaseSwitchTask = Task { [weak self] in + await withTaskCancellationHandler { + await inFlight?.value + } onCancel: { + inFlight?.cancel() + } + guard let self, !Task.isCancelled else { return } + do { + try await DatabaseManager.shared.switchDatabase(to: database, for: connId, persist: false) + } catch { + guard !Task.isCancelled else { return } + navigationLogger.error( + "Failed to SELECT Redis db\(databaseIndex) for a key: \(error.publicLogShape, privacy: .public)" + ) + reportRedisSelectionFailure(error, onTab: tabId) + return + } + guard !Task.isCancelled else { return } + toolbarState.currentDatabase = database + guard tabManager.selectedTabId == tabId else { return } + runQuery(viewport: .firstRow) + } } } diff --git a/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift index 597765f919..a56c718f2b 100644 --- a/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift +++ b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift @@ -4,6 +4,7 @@ // import Foundation +@testable import TablePro import TableProPluginKit import Testing @@ -11,17 +12,19 @@ import Testing 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 { + let op = try RedisCommandParser.parse("KEYTREE LIMIT 50000") + guard case .keyTree(let pattern, let limit, let database) = op else { Issue.record("Expected a keyTree operation") return } #expect(pattern == nil) #expect(limit == 50_000) + #expect(database == nil) } @Test("KEYTREE carries a MATCH pattern through") func parsesPattern() throws { - guard case .keyTree(let pattern, _) = try RedisCommandParser.parse("KEYTREE MATCH cache:* LIMIT 10") else { + guard case .keyTree(let pattern, _, _) = try RedisCommandParser.parse("KEYTREE MATCH cache:* LIMIT 10") else { Issue.record("Expected a keyTree operation") return } @@ -30,13 +33,48 @@ struct RedisKeyTreeCommandTests { @Test("KEYTREE without a limit falls back to the row cap") func defaultsToRowCap() throws { - guard case .keyTree(_, let limit) = try RedisCommandParser.parse("KEYTREE") else { + guard case .keyTree(_, let limit, _) = try RedisCommandParser.parse("KEYTREE") else { Issue.record("Expected a keyTree operation") return } #expect(limit == PluginRowLimits.emergencyMax) } + private func database(of command: String) throws -> Int? { + guard case .keyTree(_, _, let database) = try RedisCommandParser.parse(command) else { + Issue.record("Expected a keyTree operation for \(command)") + return nil + } + return database + } + + @Test("DB names the database the tree lists, as an index or as the sidebar spells it") + func parsesDatabase() throws { + #expect(try database(of: "KEYTREE DB 3 LIMIT 10") == 3) + #expect(try database(of: "KEYTREE MATCH a* DB db12") == 12) + #expect(try database(of: "KEYTREE LIMIT 10") == nil) + } + + @Test( + "A DB that names no database is refused rather than read as the current one", + arguments: ["KEYTREE DB", "KEYTREE DB x", "KEYTREE DB -1", "KEYTREE DB dbx"] + ) + func rejectsInvalidDatabase(command: String) { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse(command) + } + } + + @Test("A DB with no index names the command it belongs to", arguments: ["KEYTREE", "KEYBROWSE"]) + func missingIndexNamesItsCommand(command: String) throws { + do { + _ = try RedisCommandParser.parse("\(command) DB") + Issue.record("Expected \(command) DB to be refused") + } catch let error as RedisParseError { + #expect(error.pluginErrorMessage.contains("\(command) DB requires a database index")) + } + } + @Test("KEYBROWSE still parses to a key browse operation") func keyBrowseUnaffected() throws { guard case .keyBrowse(let pattern, let typeScope, let limit, let offset, _) = @@ -50,3 +88,91 @@ struct RedisKeyTreeCommandTests { #expect(offset == 50) } } + +@Suite("Redis key tree - the commands the app builds parse as the driver reads them") +struct RedisKeyTreeAppCommandTests { + @Test("The tree's listing names its database and its limit") + func listingRoundTrips() throws { + let command = RedisKeyTreeCommand.listKeys(inDatabase: 7, limit: 50_000) + guard case .keyTree(let pattern, let limit, let database) = try RedisCommandParser.parse(command) else { + Issue.record("Expected a keyTree operation for \(command)") + return + } + #expect(pattern == nil) + #expect(limit == 50_000) + #expect(database == 7) + } + + static let keys = [ + "zero:a", + "has space", + #"back\slash"#, + #"trailing\"#, + #"quote"d"#, + "it's", + "new\nline", + "tab\there", + #"\x41 stays text"#, + "semi;colon", + "café ☕", + "bell\u{07}and\u{7F}" + ] + + private func opened(_ key: String, as keyType: String?) throws -> RedisOperation { + try RedisCommandParser.parse(RedisKeyTreeCommand.openKey(key, keyType: keyType)) + } + + @Test("Opening a key reads exactly that key, whatever it holds", arguments: keys) + func openedKeyRoundTrips(key: String) throws { + let expected = Data(key.utf8) + + guard case .get(let getKey) = try opened(key, as: "string") else { + Issue.record("Expected GET for \(key)") + return + } + #expect(Data(getKey.utf8) == expected) + + guard case .hgetall(let hashKey) = try opened(key, as: "hash") else { + Issue.record("Expected HGETALL for \(key)") + return + } + #expect(Data(hashKey.utf8) == expected) + + guard case .lrange(let listKey, let start, let stop) = try opened(key, as: "list") else { + Issue.record("Expected LRANGE for \(key)") + return + } + #expect(Data(listKey.utf8) == expected) + #expect(start == 0) + #expect(stop == -1) + + guard case .smembers(let setKey) = try opened(key, as: "set") else { + Issue.record("Expected SMEMBERS for \(key)") + return + } + #expect(Data(setKey.utf8) == expected) + + guard case .zrange(let zsetKey, _, _, let flags) = try opened(key, as: "zset") else { + Issue.record("Expected ZRANGE for \(key)") + return + } + #expect(Data(zsetKey.utf8) == expected) + #expect(flags == ["WITHSCORES"]) + + guard case .xrange(let streamKey, _, _, let count) = try opened(key, as: "STREAM") else { + Issue.record("Expected XRANGE for \(key)") + return + } + #expect(Data(streamKey.utf8) == expected) + #expect(count == nil) + } + + @Test("A key of unknown type is opened with GET") + func unknownTypeOpensWithGet() throws { + guard case .get(let key) = try opened("k", as: nil) else { + Issue.record("Expected GET") + return + } + #expect(key == "k") + } +} diff --git a/TableProTests/Plugins/RedisDatabaseTargetTests.swift b/TableProTests/Plugins/RedisDatabaseTargetTests.swift index 7efbc5a7dd..e7a1a38b0f 100644 --- a/TableProTests/Plugins/RedisDatabaseTargetTests.swift +++ b/TableProTests/Plugins/RedisDatabaseTargetTests.swift @@ -325,3 +325,34 @@ struct RedisWriteAddressingTests { #expect(last == 5) } } + +@Suite("Redis key tree - the database it lists") +struct RedisKeyTreeDatabaseTests { + /// The tree's read is a walk of the keyspace plus one TYPE per key, run inside the database the + /// tree names. A typed SELECT moves where the session belongs, which the read has to leave alone. + @Test("The tree's read visits its own database and returns to the one a typed SELECT chose") + func readVisitsAndReturns() async throws { + let channel = StubRedisChannel([ + .status("OK"), + .status("OK"), + .array([.string("0"), .array([.string("a")])]), + .status("string"), + .status("OK") + ]) + try await channel.selectDatabase(5) + + let types = try await channel.withDatabase(0) { + let page = try await channel.scanKeyspace( + cursor: RedisClusterCursor.start, pattern: nil, type: nil, count: 1_000, scope: .outsideBlock + ) + return try await channel.keyTypeNames(page.keys) + } + + #expect(types == ["string"]) + #expect(channel.sentCommands == [ + ["SELECT", "5"], ["SELECT", "0"], ["SCAN", "0", "COUNT", "1000"], ["TYPE", "a"], ["SELECT", "5"] + ]) + #expect(channel.homeDatabase() == 5) + #expect(channel.currentDatabase() == 5) + } +} diff --git a/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift b/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift index a7af3b2fa3..ccfb9573d2 100644 --- a/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift +++ b/TableProTests/ViewModels/RedisKeyTreeViewModelLoadTests.swift @@ -125,8 +125,8 @@ struct RedisKeyTreeViewModelLoadTests { return (RedisKeyTreeViewModel(metadataProvider: provider), provider) } - private func load(_ viewModel: RedisKeyTreeViewModel, database: String) async { - await viewModel.loadKeys(connectionId: connection.id, database: database, separator: ":").value + private func load(_ viewModel: RedisKeyTreeViewModel, databaseIndex: Int) async { + await viewModel.loadKeys(connectionId: connection.id, databaseIndex: databaseIndex, separator: ":").value } @Test("A load commits the keys the server listed, for the database it asked about") @@ -134,7 +134,7 @@ struct RedisKeyTreeViewModelLoadTests { let (viewModel, provider) = makeViewModel() provider.answer("3", with: .keys(["user:1", "user:2", "counter"])) - await load(viewModel, database: "3") + await load(viewModel, databaseIndex: 3) let content = try #require(viewModel.state.value) #expect(content.database == "3") @@ -142,7 +142,7 @@ struct RedisKeyTreeViewModelLoadTests { #expect(content.rootNodes.count == 2) #expect(!content.isTruncated) #expect(provider.requestedDatabases == ["3"]) - #expect(provider.executedQueries == ["KEYTREE LIMIT \(RedisKeyTreeViewModel.maxKeys)"]) + #expect(provider.executedQueries == ["KEYTREE DB 3 LIMIT \(RedisKeyTreeViewModel.maxKeys)"]) } /// Each of these used to become an empty tree, which the section drew as "No items". @@ -157,7 +157,7 @@ struct RedisKeyTreeViewModelLoadTests { let (viewModel, provider) = makeViewModel() provider.answer("0", with: .failure(error)) - await load(viewModel, database: "0") + await load(viewModel, databaseIndex: 0) #expect(viewModel.state.erased == .failed(error.localizedDescription), "\(error)") } @@ -168,9 +168,9 @@ struct RedisKeyTreeViewModelLoadTests { let (viewModel, provider) = makeViewModel() provider.answer("0", with: .keys(["a"])) provider.answer("2", with: .keys(["b"])) - await load(viewModel, database: "0") + await load(viewModel, databaseIndex: 0) - let move = viewModel.loadKeys(connectionId: connection.id, database: "2", separator: ":") + let move = viewModel.loadKeys(connectionId: connection.id, databaseIndex: 2, separator: ":") #expect(viewModel.state.erased == .loading) await move.value @@ -191,9 +191,9 @@ struct RedisKeyTreeViewModelLoadTests { let (reached, release) = provider.hold("1") provider.answer("2", with: .keys(["fresh:1"])) - let stale = viewModel.loadKeys(connectionId: connection.id, database: "1", separator: ":") + let stale = viewModel.loadKeys(connectionId: connection.id, databaseIndex: 1, separator: ":") await reached.wait() - await load(viewModel, database: "2") + await load(viewModel, databaseIndex: 2) #expect(viewModel.state.value?.database == "2") provider.answer("1", with: outcome) @@ -212,9 +212,9 @@ struct RedisKeyTreeViewModelLoadTests { let (viewModel, provider) = makeViewModel() provider.answer("0", with: .keys(["a"])) provider.answer("1", with: .failure(CancellationError())) - await load(viewModel, database: "0") + await load(viewModel, databaseIndex: 0) - await load(viewModel, database: "1") + await load(viewModel, databaseIndex: 1) #expect(viewModel.state.erased == .idle) } @@ -223,7 +223,7 @@ struct RedisKeyTreeViewModelLoadTests { func cancelledRefreshKeepsTheRows() async { let (viewModel, provider) = makeViewModel() provider.answer("0", with: .keys(["a"])) - await load(viewModel, database: "0") + await load(viewModel, databaseIndex: 0) provider.answer("0", with: .failure(CancellationError())) await viewModel.reload()?.value @@ -237,7 +237,7 @@ struct RedisKeyTreeViewModelLoadTests { func refreshKeepsItsRows() async throws { let (viewModel, provider) = makeViewModel() provider.answer("0", with: .keys(["a"])) - await load(viewModel, database: "0") + await load(viewModel, databaseIndex: 0) provider.answer("0", with: .failure(RedisPluginError(code: 0, message: "ERR refused"))) let refresh = try #require(viewModel.reload()) @@ -248,11 +248,53 @@ struct RedisKeyTreeViewModelLoadTests { #expect(provider.requestedDatabases == ["0", "0"]) } + /// A typed `SELECT` moves the session, and a refresh that named no database listed wherever it + /// had moved. + @Test("Refresh names the database it lists, the same one each time") + func refreshNamesItsDatabase() async throws { + let (viewModel, provider) = makeViewModel() + provider.answer("0", with: .keys(["zero:a"])) + await load(viewModel, databaseIndex: 0) + + try await #require(viewModel.reload()).value + + let listing = "KEYTREE DB 0 LIMIT \(RedisKeyTreeViewModel.maxKeys)" + #expect(provider.executedQueries == [listing, listing]) + #expect(viewModel.state.value?.database == "0") + } + + @Test("The shown database is the one whose keys are on screen, and none while none are") + func shownDatabaseIndexFollowsTheKeysOnScreen() async throws { + let (viewModel, provider) = makeViewModel() + #expect(viewModel.shownDatabaseIndex == nil) + + provider.answer("4", with: .keys(["a"])) + await load(viewModel, databaseIndex: 4) + #expect(viewModel.shownDatabaseIndex == 4) + + let (refreshReached, refreshRelease) = provider.hold("4") + let refresh = try #require(viewModel.reload()) + await refreshReached.wait() + #expect(viewModel.shownDatabaseIndex == 4) + await refreshRelease.open() + await refresh.value + + let (moveReached, moveRelease) = provider.hold("6") + provider.answer("6", with: .failure(RedisPluginError(code: 0, message: "ERR refused"))) + let move = viewModel.loadKeys(connectionId: connection.id, databaseIndex: 6, separator: ":") + await moveReached.wait() + #expect(viewModel.shownDatabaseIndex == nil) + await moveRelease.open() + await move.value + + #expect(viewModel.shownDatabaseIndex == nil) + } + @Test("Refresh after a failed load retries it and shows the keys it gets") func refreshRecoversAFailedLoad() async throws { let (viewModel, provider) = makeViewModel() provider.answer("0", with: .failure(RedisQueuedCommand(command: "SCAN"))) - await load(viewModel, database: "0") + await load(viewModel, databaseIndex: 0) #expect(viewModel.state.value == nil) provider.answer("0", with: .keys(["a", "b"])) diff --git a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift index c2b94e0804..d846ea6dbf 100644 --- a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift +++ b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift @@ -271,7 +271,7 @@ struct RedisDatabaseSelectionGateTests { let holder = await holdDriver(connection.id, until: release) let viewModel = RedisKeyTreeViewModel() - let load = viewModel.loadKeys(connectionId: connection.id, database: "2", separator: ":") + let load = viewModel.loadKeys(connectionId: connection.id, databaseIndex: 2, separator: ":") await waitForQueuedCallers(1, on: connection.id) #expect(DatabaseManager.shared.sessionDriverGate.waiterCount(for: connection.id) == 1) @@ -282,9 +282,113 @@ struct RedisDatabaseSelectionGateTests { try await holder.value await load.value - #expect(recorder.executedQueries == ["KEYTREE LIMIT \(RedisKeyTreeViewModel.maxKeys)"]) + #expect(recorder.executedQueries == ["KEYTREE DB 2 LIMIT \(RedisKeyTreeViewModel.maxKeys)"]) #expect(viewModel.state.value?.database == "2") } + + /// Query execution runs off the switch task, so the key's command lands a moment after it. The + /// bound turns a command that never runs into a failed assertion rather than a hung suite. + private func waitForExecution(of query: String, on recorder: RecordingRedisPluginDriver) async { + for _ in 0..<500 where !recorder.executedQueries.contains(query) { + try? await Task.sleep(nanoseconds: 10_000_000) + } + } + + @Test("The key tree first lists the database the session was left on after connecting") + func firstTreeLoadListsTheSessionDatabase() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + DatabaseManager.shared.updateSession(connection.id) { $0.browseDatabase = "5" } + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + + coordinator.initRedisKeyTreeIfNeeded() + let listing = "KEYTREE DB 5 LIMIT \(RedisKeyTreeViewModel.maxKeys)" + await waitForExecution(of: listing, on: recorder) + + #expect(recorder.executedQueries == [listing]) + } + + /// A Cluster serves database 0 only, so a saved index from a standalone setup is refused at + /// connect and the session records no database. Listing that index fails the whole tree. + @Test("With no database recorded for the session the key tree lists database 0") + func firstTreeLoadWithoutASessionDatabaseListsZero() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + DatabaseManager.shared.updateSession(connection.id) { $0.browseDatabase = nil } + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + coordinator.toolbarState.currentDatabase = "5" + + coordinator.initRedisKeyTreeIfNeeded() + let listing = "KEYTREE DB 0 LIMIT \(RedisKeyTreeViewModel.maxKeys)" + await waitForExecution(of: listing, on: recorder) + + #expect(recorder.executedQueries == [listing]) + } + + @Test("Opening a key moves the session to the key's database before it reads the key") + func openingAKeyMovesFirst() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + + coordinator.openRedisKey("zero:a", keyType: "string", inDatabase: 0) + await coordinator.redisDatabaseSwitchTask?.value + let read = RedisKeyTreeCommand.openKey("zero:a", keyType: "string") + await waitForExecution(of: read, on: recorder) + + #expect(recorder.events == ["switch:0", "execute:\(read)"]) + #expect(coordinator.toolbarState.currentDatabase == "0") + #expect(coordinator.tabManager.selectedTab?.title == "zero:a") + } + + @Test("A key whose database the server refuses reports it on the key's tab and reads nothing") + func refusedKeyDatabaseIsReportedOnTheKeyTab() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + recorder.refuseSelections(with: RefusedSelection()) + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + + coordinator.openRedisKey("five:x", keyType: nil, inDatabase: 5) + await coordinator.redisDatabaseSwitchTask?.value + + let tab = try #require(coordinator.tabManager.selectedTab) + #expect(tab.title == "five:x") + #expect(tab.execution.errorMessage == RefusedSelection.message) + #expect(recorder.executedQueries.isEmpty) + #expect(DatabaseManager.shared.session(for: connection.id)?.browseDatabase == "0") + } + + /// Cancelling the click instead would leave its retargeted tab loading with nothing coming to + /// finish it. + @Test("Opening a key while a database click waits leaves no tab loading") + func keyOpenedBehindAPendingClickLeavesNoSpinner() async throws { + let (connection, recorder) = makeSession() + defer { cleanUp(connection.id) } + let coordinator = makeCoordinator(for: connection) + defer { coordinator.teardown() } + let release = Latch() + let holder = await holdDriver(connection.id, until: release) + + coordinator.openTableTab("db3") + await waitForQueuedCallers(1, on: connection.id) + let clickedTabId = try #require(coordinator.tabManager.selectedTabId) + coordinator.openRedisKey("three:a", keyType: "hash", inDatabase: 3) + + release.open() + try await holder.value + await coordinator.redisDatabaseSwitchTask?.value + let read = RedisKeyTreeCommand.openKey("three:a", keyType: "hash") + await waitForExecution(of: read, on: recorder) + + let clicked = try #require(coordinator.tabManager.tabs.first { $0.id == clickedTabId }) + #expect(clicked.pagination.isLoading == false) + #expect(recorder.switchedDatabases == ["3", "3"]) + #expect(recorder.executedQueries.contains(read)) + } } private struct RefusedSelection: LocalizedError { @@ -299,6 +403,7 @@ private final class RecordingRedisPluginDriver: PluginDatabaseDriver, @unchecked private let lock = NSLock() private var switched: [String] = [] private var executed: [String] = [] + private var log: [String] = [] private var selectionRefusal: Error? func refuseSelections(with error: Error) { @@ -313,16 +418,27 @@ private final class RecordingRedisPluginDriver: PluginDatabaseDriver, @unchecked lock.withLock { executed } } + /// Switches and executions in the order they reached the driver. + var events: [String] { + lock.withLock { log } + } + func ping() async throws {} func switchDatabase(to database: String) async throws { let refusal = lock.withLock { selectionRefusal } if let refusal { throw refusal } - lock.withLock { switched.append(database) } + lock.withLock { + switched.append(database) + log.append("switch:\(database)") + } } func execute(query: String) async throws -> PluginQueryResult { - lock.withLock { executed.append(query) } + lock.withLock { + executed.append(query) + log.append("execute:\(query)") + } return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) } From 264ed49a759d89eb070078e9f9c712ec99a46224 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:50:27 +0700 Subject: [PATCH 12/30] fix(plugin-redis): match a Redis namespace literally when counting its keys --- Plugins/RedisDriverPlugin/RedisQueryBuilder.swift | 2 +- TableProTests/Core/Redis/RedisCommandParserTests.swift | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift index 004cafa0cc..bd821c1aa8 100644 --- a/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift +++ b/Plugins/RedisDriverPlugin/RedisQueryBuilder.swift @@ -86,7 +86,7 @@ struct RedisQueryBuilder { if namespace.isEmpty { return "DBSIZE" } - return "SCAN 0 MATCH \"\(namespace)*\" COUNT 10000" + return "SCAN 0 MATCH \"\(quoteForCommand(escapeGlobChars(namespace)))*\" COUNT 10000" } // MARK: - Private Helpers diff --git a/TableProTests/Core/Redis/RedisCommandParserTests.swift b/TableProTests/Core/Redis/RedisCommandParserTests.swift index 8d70762ea9..7dd56ab5a2 100644 --- a/TableProTests/Core/Redis/RedisCommandParserTests.swift +++ b/TableProTests/Core/Redis/RedisCommandParserTests.swift @@ -1068,4 +1068,14 @@ struct RedisCommandParserAppStatementTests { #expect(try !isVerbatim(query), "\(query)") } } + + @Test("A namespace is matched literally, quotes and glob characters included") + func countQueryEscapesTheNamespace() throws { + let query = RedisQueryBuilder().buildCountQuery(namespace: "a\"b*c\\") + #expect(query == #"SCAN 0 MATCH "a\"b\\*c\\\\*" COUNT 10000"#) + guard case .scan(_, let pattern, _, _) = try RedisCommandParser.parse(query) else { + Issue.record("Expected SCAN"); return + } + #expect(pattern == #"a"b\*c\\*"#) + } } From 723e58c9f398a1fd25e4e8d8d252fcf4c9443e9d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:10:55 +0700 Subject: [PATCH 13/30] fix(ios): report a Redis command the server refuses instead of a one-row result --- .../TableProMobile/Drivers/RedisDriver.swift | 93 +------------------ .../Drivers/RedisQueryResultBuilder.swift | 81 ++++++++++++++++ .../Drivers/RedisReplyValue.swift | 2 +- .../Drivers/RedisKeyspaceReadsTests.swift | 6 ++ .../RedisQueryResultBuilderTests.swift | 88 ++++++++++++++++++ 5 files changed, 178 insertions(+), 92 deletions(-) create mode 100644 TableProMobile/TableProMobile/Drivers/RedisQueryResultBuilder.swift create mode 100644 TableProMobile/TableProMobileTests/Drivers/RedisQueryResultBuilderTests.swift diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index 522af203f7..0f70496e0e 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -71,9 +71,9 @@ nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { throw RedisError.queryFailed("Empty command") } - let reply = try await actor.command(args) + let reply = try await send(args) let elapsed = Date().timeIntervalSince(start) - return formatReply(reply, executionTime: elapsed) + return try RedisQueryResultBuilder.result(for: reply, executionTime: elapsed) } func cancelCurrentQuery() async throws { @@ -211,95 +211,6 @@ nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { if !current.isEmpty { args.append(current) } return args } - - private func formatReply(_ reply: RedisReplyValue, executionTime: TimeInterval) -> QueryResult { - switch reply { - case .string(let s): - return QueryResult( - columns: [ColumnInfo(name: "value", typeName: "string", ordinalPosition: 0)], - rows: [[s]], - rowsAffected: 0, - executionTime: executionTime, - statusMessage: nil - ) - case .integer(let i): - return QueryResult( - columns: [ColumnInfo(name: "value", typeName: "integer", ordinalPosition: 0)], - rows: [[String(i)]], - rowsAffected: 0, - executionTime: executionTime, - statusMessage: nil - ) - case .status(let s): - return QueryResult( - columns: [ColumnInfo(name: "status", typeName: "string", ordinalPosition: 0)], - rows: [[s]], - rowsAffected: 0, - executionTime: executionTime, - statusMessage: s - ) - case .error(let msg): - return QueryResult( - columns: [ColumnInfo(name: "error", typeName: "string", ordinalPosition: 0)], - rows: [[msg]], - rowsAffected: 0, - executionTime: executionTime, - statusMessage: nil - ) - case .array(let items): - if isHashResult(items) { - var rows: [[String?]] = [] - for i in stride(from: 0, to: items.count - 1, by: 2) { - let key = items[i].stringRepresentation - let value = items[i + 1].stringRepresentation - rows.append([key, value]) - } - return QueryResult( - columns: [ - ColumnInfo(name: "key", typeName: "string", ordinalPosition: 0), - ColumnInfo(name: "value", typeName: "string", ordinalPosition: 1) - ], - rows: rows, - rowsAffected: 0, - executionTime: executionTime, - isTruncated: rows.count >= 100_000, - statusMessage: nil - ) - } - - let rows: [[String?]] = items.prefix(100_000).enumerated().map { index, item in - [String(index), item.stringRepresentation] - } - return QueryResult( - columns: [ - ColumnInfo(name: "index", typeName: "integer", ordinalPosition: 0), - ColumnInfo(name: "value", typeName: "string", ordinalPosition: 1) - ], - rows: rows, - rowsAffected: 0, - executionTime: executionTime, - isTruncated: items.count > 100_000, - statusMessage: nil - ) - case .null: - return QueryResult( - columns: [ColumnInfo(name: "value", typeName: "string", ordinalPosition: 0)], - rows: [[nil]], - rowsAffected: 0, - executionTime: executionTime, - statusMessage: nil - ) - } - } - - private func isHashResult(_ items: [RedisReplyValue]) -> Bool { - guard items.count >= 2, items.count % 2 == 0 else { return false } - for i in stride(from: 0, to: items.count, by: 2) { - if case .string = items[i] { continue } - return false - } - return true - } } nonisolated private func withOptionalCString(_ string: String?, _ body: (UnsafePointer?) throws -> R) rethrows -> R { diff --git a/TableProMobile/TableProMobile/Drivers/RedisQueryResultBuilder.swift b/TableProMobile/TableProMobile/Drivers/RedisQueryResultBuilder.swift new file mode 100644 index 0000000000..c47790101c --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/RedisQueryResultBuilder.swift @@ -0,0 +1,81 @@ +import Foundation +import TableProModels + +nonisolated internal enum RedisQueryResultBuilder { + static let rowLimit = 100_000 + + static func result(for reply: RedisReplyValue, executionTime: TimeInterval) throws -> QueryResult { + switch reply { + case .error(let message): + throw RedisError.queryFailed(message) + case .string(let value): + return singleValue(value, typeName: "string", executionTime: executionTime) + case .integer(let value): + return singleValue(String(value), typeName: "integer", executionTime: executionTime) + case .null: + return singleValue(nil, typeName: "string", executionTime: executionTime) + case .status(let value): + return QueryResult( + columns: [ColumnInfo(name: "status", typeName: "string", ordinalPosition: 0)], + rows: [[value]], + rowsAffected: 0, + executionTime: executionTime, + statusMessage: value + ) + case .array(let items): + if isHashResult(items) { + return pairedResult(items, executionTime: executionTime) + } + return indexedResult(items, executionTime: executionTime) + } + } + + private static func singleValue(_ value: String?, typeName: String, executionTime: TimeInterval) -> QueryResult { + QueryResult( + columns: [ColumnInfo(name: "value", typeName: typeName, ordinalPosition: 0)], + rows: [[value]], + rowsAffected: 0, + executionTime: executionTime + ) + } + + private static func pairedResult(_ items: [RedisReplyValue], executionTime: TimeInterval) -> QueryResult { + let rows: [[String?]] = stride(from: 0, to: items.count - 1, by: 2).map { index in + [items[index].stringRepresentation, items[index + 1].stringRepresentation] + } + return QueryResult( + columns: [ + ColumnInfo(name: "key", typeName: "string", ordinalPosition: 0), + ColumnInfo(name: "value", typeName: "string", ordinalPosition: 1) + ], + rows: rows, + rowsAffected: 0, + executionTime: executionTime, + isTruncated: rows.count >= rowLimit + ) + } + + private static func indexedResult(_ items: [RedisReplyValue], executionTime: TimeInterval) -> QueryResult { + let rows: [[String?]] = items.prefix(rowLimit).enumerated().map { index, item in + [String(index), item.stringRepresentation] + } + return QueryResult( + columns: [ + ColumnInfo(name: "index", typeName: "integer", ordinalPosition: 0), + ColumnInfo(name: "value", typeName: "string", ordinalPosition: 1) + ], + rows: rows, + rowsAffected: 0, + executionTime: executionTime, + isTruncated: items.count > rowLimit + ) + } + + private static func isHashResult(_ items: [RedisReplyValue]) -> Bool { + guard items.count >= 2, items.count.isMultiple(of: 2) else { return false } + return stride(from: 0, to: items.count, by: 2).allSatisfy { index in + if case .string = items[index] { return true } + return false + } + } +} diff --git a/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift b/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift index 35c5064f61..ec6406950f 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift @@ -13,7 +13,7 @@ nonisolated internal enum RedisReplyValue: Sendable, Equatable { case .string(let s): return s case .integer(let i): return String(i) case .status(let s): return s - case .error(let s): return s + case .error(let s): return "(error) \(s)" case .null: return nil case .array(let items): return "[\(items.compactMap(\.stringRepresentation).joined(separator: ", "))]" } diff --git a/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift index 3a9f6ed47c..81ffdcb1ba 100644 --- a/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift @@ -60,6 +60,12 @@ struct RedisReplyValueGuardTests { #expect(reply == .status("OK")) } + @Test("an error reply reads as an error where it is rendered as text") + func errorStringRepresentation() { + let reply = RedisReplyValue.error("WRONGTYPE Operation against a key holding the wrong kind of value") + #expect(reply.stringRepresentation == "(error) WRONGTYPE Operation against a key holding the wrong kind of value") + } + @Test("the queued error names the command and the open block") func queuedDescription() { #expect( diff --git a/TableProMobile/TableProMobileTests/Drivers/RedisQueryResultBuilderTests.swift b/TableProMobile/TableProMobileTests/Drivers/RedisQueryResultBuilderTests.swift new file mode 100644 index 0000000000..708ab6ca3c --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/RedisQueryResultBuilderTests.swift @@ -0,0 +1,88 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Redis query result builder") +struct RedisQueryResultBuilderTests { + private func build(_ reply: RedisReplyValue) throws -> QueryResult { + try RedisQueryResultBuilder.result(for: reply, executionTime: 0.25) + } + + @Test("an error reply throws instead of filling a result") + func errorReplyThrows() { + let message = "ERR unknown command 'DELETE', with args beginning with: 'FROM'" + #expect(throws: RedisError.queryFailed(message)) { + try build(.error(message)) + } + } + + @Test("a QUEUED status is a result") + func queuedStatusIsAResult() throws { + let result = try build(.status("QUEUED")) + #expect(result.columns.map(\.name) == ["status"]) + #expect(result.rows == [["QUEUED"]]) + #expect(result.statusMessage == "QUEUED") + } + + @Test("an EXEC array marks its inline error") + func execArrayMarksInlineError() throws { + let result = try build(.array([.status("OK"), .error("ERR value is not an integer or out of range")])) + #expect(result.columns.map(\.name) == ["index", "value"]) + #expect(result.rows == [["0", "OK"], ["1", "(error) ERR value is not an integer or out of range"]]) + } + + @Test("a bulk string is one value row") + func bulkString() throws { + let result = try build(.string("ada")) + #expect(result.columns.map(\.name) == ["value"]) + #expect(result.columns.map(\.typeName) == ["string"]) + #expect(result.rows == [["ada"]]) + #expect(result.statusMessage == nil) + #expect(result.executionTime == 0.25) + } + + @Test("an integer is one value row typed integer") + func integer() throws { + let result = try build(.integer(42)) + #expect(result.columns.map(\.typeName) == ["integer"]) + #expect(result.rows == [["42"]]) + } + + @Test("a nil reply is one null value row") + func null() throws { + let result = try build(.null) + #expect(result.columns.map(\.name) == ["value"]) + #expect(result.rows == [[nil]]) + } + + @Test("an array with a non-string at an even position reads by index") + func indexedArray() throws { + let result = try build(.array([.integer(1), .string("b"), .null])) + #expect(result.columns.map(\.name) == ["index", "value"]) + #expect(result.rows == [["0", "1"], ["1", "b"], ["2", nil]]) + #expect(!result.isTruncated) + } + + @Test("an even array of strings reads as field and value pairs") + func pairedArray() throws { + let result = try build(.array([.string("name"), .string("ada"), .string("age"), .integer(36)])) + #expect(result.columns.map(\.name) == ["key", "value"]) + #expect(result.rows == [["name", "ada"], ["age", "36"]]) + } + + @Test("an empty array has no rows") + func emptyArray() throws { + let result = try build(.array([])) + #expect(result.columns.map(\.name) == ["index", "value"]) + #expect(result.rows.isEmpty) + } + + @Test("an indexed array past the row limit is cut and marked truncated") + func truncatesLongArrays() throws { + let items = Array(repeating: RedisReplyValue.integer(1), count: RedisQueryResultBuilder.rowLimit + 1) + let result = try build(.array(items)) + #expect(result.rows.count == RedisQueryResultBuilder.rowLimit) + #expect(result.isTruncated) + } +} From 4cec69c735facee070d7609b331eefdbf7de8fb7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:24:29 +0700 Subject: [PATCH 14/30] fix(ios): list each Redis key once when SCAN returns it twice --- .../Drivers/RedisKeyspaceReads.swift | 12 +++- .../Drivers/RedisKeyspaceReadsTests.swift | 62 ++++++++++--------- .../Support/ScriptedRedisServer.swift | 39 ++++++++++++ 3 files changed, 82 insertions(+), 31 deletions(-) create mode 100644 TableProMobile/TableProMobileTests/Support/ScriptedRedisServer.swift diff --git a/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift b/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift index fb7fb6c1c2..cbc6bdff30 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift @@ -55,15 +55,23 @@ nonisolated internal enum RedisKeyspaceReads { ["SCAN", cursor, "MATCH", "*", "COUNT", String(scanPageSize)] } + /// SCAN may return a key more than once, for example when the keyspace shrinks during the walk, + /// so each key is kept at its first sighting. The limit counts every key the server sent, + /// repeats included, because it bounds the round trips rather than the size of the list. static func keys(sending send: Send) async throws -> [String] { var keys: [String] = [] + var seen = Set() + var received = 0 var cursor = RedisScanPage.startCursor repeat { let reply = try await send(scanArguments(cursor: cursor)) let page = try RedisScanPage(reply: reply) cursor = page.cursor - keys.append(contentsOf: page.keys) - } while cursor != RedisScanPage.startCursor && keys.count < keyLimit + received += page.keys.count + for key in page.keys where seen.insert(key).inserted { + keys.append(key) + } + } while cursor != RedisScanPage.startCursor && received < keyLimit return keys } diff --git a/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift index 81ffdcb1ba..550a7285c6 100644 --- a/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift @@ -2,32 +2,6 @@ import Foundation @testable import TableProMobile import Testing -private actor ScriptedRedisServer { - private var replies: [RedisReplyValue] - private let repeatsLastReply: Bool - private(set) var sent: [[String]] = [] - - init(replies: [RedisReplyValue], repeatsLastReply: Bool = false) { - self.replies = replies - self.repeatsLastReply = repeatsLastReply - } - - func reply(to arguments: [String]) throws -> RedisReplyValue { - sent.append(arguments) - guard let next = replies.first else { throw ScriptExhausted() } - if replies.count > 1 || !repeatsLastReply { - replies.removeFirst() - } - return next - } - - struct ScriptExhausted: Error {} -} - -private func scanReply(cursor: String, keys: [String]) -> RedisReplyValue { - .array([.string(cursor), .array(keys.map { .string($0) })]) -} - @Suite("Redis reply guards") struct RedisReplyValueGuardTests { @Test("an error reply throws the server's message") @@ -161,12 +135,42 @@ struct RedisKeyspaceReadsTests { #expect(await server.sent.count == 2) } - @Test("the walk stops at the key limit") - func stopsAtTheKeyLimit() async throws { + @Test("a key two pages return is listed once") + func repeatedKeyIsListedOnce() async throws { + let server = ScriptedRedisServer(replies: [ + scanReply(cursor: "17", keys: ["a", "b"]), + scanReply(cursor: "0", keys: ["b", "c"]) + ]) + let keys = try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } + #expect(keys == ["a", "b", "c"]) + #expect(await server.sent.count == 2) + } + + @Test("a key repeated within one page is listed once") + func repeatedKeyWithinAPageIsListedOnce() async throws { + let server = ScriptedRedisServer(replies: [scanReply(cursor: "0", keys: ["a", "a", "b"])]) + let keys = try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } + #expect(keys == ["a", "b"]) + } + + @Test("distinct keys stop at the key limit") + func distinctKeysStopAtTheKeyLimit() async throws { + let pageSize = RedisKeyspaceReads.scanPageSize + let server = ScriptedRedisServer { page in + scanReply(cursor: "7", keys: (0 ..< pageSize).map { "key:\(page):\($0)" }) + } + let keys = try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } + #expect(keys.count == RedisKeyspaceReads.keyLimit) + #expect(Set(keys).count == RedisKeyspaceReads.keyLimit) + #expect(await server.sent.count == RedisKeyspaceReads.keyLimit / pageSize) + } + + @Test("a server repeating one page ends at the work limit with each key once") + func repeatingServerEndsAtTheWorkLimit() async throws { let pageKeys = (0 ..< RedisKeyspaceReads.scanPageSize).map { "key:\($0)" } let server = ScriptedRedisServer(replies: [scanReply(cursor: "7", keys: pageKeys)], repeatsLastReply: true) let keys = try await RedisKeyspaceReads.keys { try await server.reply(to: $0) } - #expect(keys.count == RedisKeyspaceReads.keyLimit) + #expect(keys == pageKeys) #expect(await server.sent.count == RedisKeyspaceReads.keyLimit / RedisKeyspaceReads.scanPageSize) } diff --git a/TableProMobile/TableProMobileTests/Support/ScriptedRedisServer.swift b/TableProMobile/TableProMobileTests/Support/ScriptedRedisServer.swift new file mode 100644 index 0000000000..b64dc70c82 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Support/ScriptedRedisServer.swift @@ -0,0 +1,39 @@ +import Foundation +@testable import TableProMobile + +actor ScriptedRedisServer { + struct ScriptExhausted: Error {} + + private var replies: [RedisReplyValue] + private let repeatsLastReply: Bool + private let replyForRequest: (@Sendable (_ requestIndex: Int) -> RedisReplyValue)? + private(set) var sent: [[String]] = [] + + init(replies: [RedisReplyValue], repeatsLastReply: Bool = false) { + self.replies = replies + self.repeatsLastReply = repeatsLastReply + self.replyForRequest = nil + } + + init(replyingTo replyForRequest: @escaping @Sendable (_ requestIndex: Int) -> RedisReplyValue) { + self.replies = [] + self.repeatsLastReply = false + self.replyForRequest = replyForRequest + } + + func reply(to arguments: [String]) throws -> RedisReplyValue { + sent.append(arguments) + if let replyForRequest { + return replyForRequest(sent.count - 1) + } + guard let next = replies.first else { throw ScriptExhausted() } + if replies.count > 1 || !repeatsLastReply { + replies.removeFirst() + } + return next + } +} + +func scanReply(cursor: String, keys: [String]) -> RedisReplyValue { + .array([.string(cursor), .array(keys.map { .string($0) })]) +} From 3f085222b17eb6c998c882bee2fe7be59b786c31 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:36:15 +0700 Subject: [PATCH 15/30] fix(ios): browse Redis keys with Redis commands instead of SQL --- .../TableProDatabase/DatabaseDriver.swift | 44 +--- .../QueryResultStreaming.swift | 61 ++++++ .../QueryResultStreamingTests.swift | 104 ++++++++++ .../RedisKeyContentsRead.swift | 79 +++++++ .../RedisDriverPlugin/RedisKeySummary.swift | 77 +------ .../Drivers/KeyContentsBrowsing.swift | 12 ++ .../TableProMobile/Drivers/RedisDriver.swift | 12 +- .../Drivers/RedisKeyBrowse.swift | 189 +++++++++++++++++ .../Drivers/RedisKeyspaceReads.swift | 32 +-- .../Drivers/RedisReplyValue.swift | 12 ++ .../Models/TableBrowseMode.swift | 15 ++ .../ViewModels/DataBrowserViewModel.swift | 92 ++++++--- .../Views/DataBrowserView.swift | 104 ++++++---- .../DataBrowserViewModelTests.swift | 82 ++++++++ .../Drivers/RedisKeyBrowseTests.swift | 195 ++++++++++++++++++ .../Drivers/RedisKeyspaceReadsTests.swift | 35 ++-- .../Mocks/MockKeyContentsDriver.swift | 67 ++++++ .../TableBrowseModeTests.swift | 28 +++ TableProMobile/project.yml | 3 +- project.yml | 1 + 20 files changed, 1016 insertions(+), 228 deletions(-) create mode 100644 Packages/TableProCore/Sources/TableProDatabase/QueryResultStreaming.swift create mode 100644 Packages/TableProCore/Tests/TableProDatabaseTests/QueryResultStreamingTests.swift create mode 100644 Plugins/RedisDriverPlugin/RedisKeyContentsRead.swift create mode 100644 TableProMobile/TableProMobile/Drivers/KeyContentsBrowsing.swift create mode 100644 TableProMobile/TableProMobile/Drivers/RedisKeyBrowse.swift create mode 100644 TableProMobile/TableProMobile/Models/TableBrowseMode.swift create mode 100644 TableProMobile/TableProMobileTests/Drivers/RedisKeyBrowseTests.swift create mode 100644 TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift create mode 100644 TableProMobile/TableProMobileTests/TableBrowseModeTests.swift diff --git a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift index 5501b414dc..35bcf3fff4 100644 --- a/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProDatabase/DatabaseDriver.swift @@ -50,48 +50,6 @@ public extension DatabaseDriver { } func executeStreaming(query: String, options: StreamOptions = .default) -> AsyncThrowingStream { - AsyncThrowingStream { continuation in - let task = Task { - do { - let result = try await self.execute(query: query) - continuation.yield(.columns(result.columns)) - - var emitted = 0 - for legacyRow in result.rows { - if Task.isCancelled { - continuation.yield(.truncated(reason: .cancelled)) - break - } - if emitted >= options.maxRows { - continuation.yield(.truncated(reason: .rowCap(options.maxRows))) - break - } - let cells = legacyRow.enumerated().map { index, value -> Cell in - let typeName = index < result.columns.count ? result.columns[index].typeName : nil - return Cell.from(legacyValue: value, columnTypeName: typeName, options: options) - } - continuation.yield(.row(Row(cells: cells))) - emitted += 1 - } - - if let message = result.statusMessage { - continuation.yield(.statusMessage(message)) - } - if result.rowsAffected != 0 { - continuation.yield(.rowsAffected(result.rowsAffected)) - } - if result.isTruncated && emitted < options.maxRows { - continuation.yield(.truncated(reason: .driverLimit("driver returned isTruncated=true"))) - } - continuation.finish() - } catch is CancellationError { - continuation.yield(.truncated(reason: .cancelled)) - continuation.finish() - } catch { - continuation.finish(throwing: error) - } - } - continuation.onTermination = { _ in task.cancel() } - } + QueryResultStreaming.stream(options: options) { try await self.execute(query: query) } } } diff --git a/Packages/TableProCore/Sources/TableProDatabase/QueryResultStreaming.swift b/Packages/TableProCore/Sources/TableProDatabase/QueryResultStreaming.swift new file mode 100644 index 0000000000..ab1baa1ff4 --- /dev/null +++ b/Packages/TableProCore/Sources/TableProDatabase/QueryResultStreaming.swift @@ -0,0 +1,61 @@ +import Foundation +import TableProModels + +public enum QueryResultStreaming { + public static func stream( + options: StreamOptions, + producing produce: @escaping @Sendable () async throws -> QueryResult + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let task = Task { + do { + let result = try await produce() + emit(result, options: options, into: continuation) + continuation.finish() + } catch is CancellationError { + continuation.yield(.truncated(reason: .cancelled)) + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + private static func emit( + _ result: QueryResult, + options: StreamOptions, + into continuation: AsyncThrowingStream.Continuation + ) { + continuation.yield(.columns(result.columns)) + + var emitted = 0 + for legacyRow in result.rows { + if Task.isCancelled { + continuation.yield(.truncated(reason: .cancelled)) + break + } + if emitted >= options.maxRows { + continuation.yield(.truncated(reason: .rowCap(options.maxRows))) + break + } + let cells = legacyRow.enumerated().map { index, value -> Cell in + let typeName = index < result.columns.count ? result.columns[index].typeName : nil + return Cell.from(legacyValue: value, columnTypeName: typeName, options: options) + } + continuation.yield(.row(Row(cells: cells))) + emitted += 1 + } + + if let message = result.statusMessage { + continuation.yield(.statusMessage(message)) + } + if result.rowsAffected != 0 { + continuation.yield(.rowsAffected(result.rowsAffected)) + } + if result.isTruncated && emitted < options.maxRows { + continuation.yield(.truncated(reason: .driverLimit("driver returned isTruncated=true"))) + } + } +} diff --git a/Packages/TableProCore/Tests/TableProDatabaseTests/QueryResultStreamingTests.swift b/Packages/TableProCore/Tests/TableProDatabaseTests/QueryResultStreamingTests.swift new file mode 100644 index 0000000000..11c41aeaa1 --- /dev/null +++ b/Packages/TableProCore/Tests/TableProDatabaseTests/QueryResultStreamingTests.swift @@ -0,0 +1,104 @@ +import Foundation +import TableProDatabase +import TableProModels +import Testing + +private struct ProducerFailure: Error, Equatable {} + +private enum StreamEvent: Equatable { + case columns([String]) + case row([String?]) + case rowsAffected(Int) + case statusMessage(String) + case rowCap(Int) + case cancelled + case memoryPressure + case driverLimit +} + +private func collect(_ stream: AsyncThrowingStream) async throws -> [StreamEvent] { + var events: [StreamEvent] = [] + for try await element in stream { + events.append(event(for: element)) + } + return events +} + +private func event(for element: StreamElement) -> StreamEvent { + switch element { + case .columns(let columns): + return .columns(columns.map(\.name)) + case .row(let row): + return .row(row.legacyValues) + case .rowsAffected(let count): + return .rowsAffected(count) + case .statusMessage(let message): + return .statusMessage(message) + case .truncated(let reason): + switch reason { + case .rowCap(let cap): return .rowCap(cap) + case .cancelled: return .cancelled + case .memoryPressure: return .memoryPressure + case .driverLimit: return .driverLimit + } + } +} + +private let valueColumn = [ColumnInfo(name: "value", typeName: "string", ordinalPosition: 0)] + +@Suite("QueryResultStreaming") +struct QueryResultStreamingTests { + @Test("the columns come first, then one element per row") + func streamsColumnsThenRows() async throws { + let result = QueryResult(columns: valueColumn, rows: [["a"], ["b"]], rowsAffected: 0, executionTime: 0) + let events = try await collect(QueryResultStreaming.stream(options: .default) { result }) + #expect(events == [.columns(["value"]), .row(["a"]), .row(["b"])]) + } + + @Test("rows past the cap are cut and the cut is reported") + func rowCapTruncates() async throws { + let result = QueryResult(columns: valueColumn, rows: [["a"], ["b"], ["c"]], rowsAffected: 0, executionTime: 0) + let options = StreamOptions(maxRows: 2) + let events = try await collect(QueryResultStreaming.stream(options: options) { result }) + #expect(events == [.columns(["value"]), .row(["a"]), .row(["b"]), .rowCap(2)]) + } + + @Test("a status message and an affected count follow the rows") + func statusAndRowsAffected() async throws { + let result = QueryResult( + columns: [], + rows: [], + rowsAffected: 3, + executionTime: 0, + statusMessage: "OK" + ) + let events = try await collect(QueryResultStreaming.stream(options: .default) { result }) + #expect(events == [.columns([]), .statusMessage("OK"), .rowsAffected(3)]) + } + + @Test("a result the producer marked truncated says so") + func producerTruncation() async throws { + let result = QueryResult(columns: valueColumn, rows: [["a"]], rowsAffected: 0, executionTime: 0, isTruncated: true) + let events = try await collect(QueryResultStreaming.stream(options: .default) { result }) + #expect(events == [.columns(["value"]), .row(["a"]), .driverLimit]) + } + + @Test("a producer that throws finishes the stream with its error") + func producerErrorFinishesTheStream() async { + let stream = QueryResultStreaming.stream(options: .default) { () async throws -> QueryResult in + throw ProducerFailure() + } + await #expect(throws: ProducerFailure()) { + _ = try await collect(stream) + } + } + + @Test("a producer that is cancelled ends the stream as cancelled") + func producerCancellationEndsAsCancelled() async throws { + let stream = QueryResultStreaming.stream(options: .default) { () async throws -> QueryResult in + throw CancellationError() + } + let events = try await collect(stream) + #expect(events == [.cancelled]) + } +} diff --git a/Plugins/RedisDriverPlugin/RedisKeyContentsRead.swift b/Plugins/RedisDriverPlugin/RedisKeyContentsRead.swift new file mode 100644 index 0000000000..9d7121e0f3 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisKeyContentsRead.swift @@ -0,0 +1,79 @@ +// +// RedisKeyContentsRead.swift +// RedisDriverPlugin +// + +import Foundation +import TableProPluginKit + +/// What the server said about one key. Either half is nil when the server would not say, which +/// an ACL user whose key patterns do not cover the key gets for both, while `SCAN` still lists it. +struct RedisKeyDescription: Equatable, Sendable { + let typeName: String? + let ttlSeconds: Int? + + var kind: RedisKeyKind? { + typeName.flatMap(RedisKeyKind.init(typeName:)) + } + + var typeCell: PluginCellValue { + .fromOptional(typeName?.uppercased()) + } + + var ttlCell: PluginCellValue { + .fromOptional(ttlSeconds.map(String.init)) + } +} + +struct RedisKeyContents { + let kind: RedisKeyKind + let length: Int? + let preview: RedisReply? + + var lengthCell: PluginCellValue { + .fromOptional(length.map(String.init)) + } +} + +extension RedisCommandChannel { + func keyTypeNames(_ keys: [String]) async throws -> [String?] { + try await runMetadataReads(keys.map { ["TYPE", $0] }).map { $0?.stringValue } + } + + func describeKeys(_ keys: [String]) async throws -> [RedisKeyDescription] { + let answers = try await runMetadataReads(keys.flatMap { [["TYPE", $0], ["TTL", $0]] }) + return stride(from: 0, to: answers.count - 1, by: 2).map { index in + RedisKeyDescription(typeName: answers[index]?.stringValue, ttlSeconds: answers[index + 1]?.intValue) + } + } + + /// A key whose type is unknown gets no probe at all, because the length and preview commands + /// are chosen by type. + func readContents( + of keys: [String], + describedAs descriptions: [RedisKeyDescription] + ) async throws -> [RedisKeyContents?] { + let kinds = zip(keys, descriptions).map { (key: $0, kind: $1.kind) } + var commands: [[String]] = [] + commands.reserveCapacity(kinds.count * 2) + for entry in kinds { + guard let kind = entry.kind else { continue } + commands.append(RedisKeySummary.lengthCommand(for: kind, key: entry.key)) + commands.append(RedisKeySummary.previewCommand(for: kind, key: entry.key)) + } + let answers = try await runMetadataReads(commands) + + var contents: [RedisKeyContents?] = [] + contents.reserveCapacity(kinds.count) + var next = answers.startIndex + for entry in kinds { + guard let kind = entry.kind, next + 1 < answers.endIndex else { + contents.append(nil) + continue + } + contents.append(RedisKeyContents(kind: kind, length: answers[next]?.intValue, preview: answers[next + 1])) + next += 2 + } + return contents + } +} diff --git a/Plugins/RedisDriverPlugin/RedisKeySummary.swift b/Plugins/RedisDriverPlugin/RedisKeySummary.swift index a2adb59c72..5b5afd6d33 100644 --- a/Plugins/RedisDriverPlugin/RedisKeySummary.swift +++ b/Plugins/RedisDriverPlugin/RedisKeySummary.swift @@ -4,9 +4,8 @@ // import Foundation -import TableProPluginKit -enum RedisKeyKind: String, CaseIterable { +nonisolated enum RedisKeyKind: String, CaseIterable { case string case hash case list @@ -19,7 +18,7 @@ enum RedisKeyKind: String, CaseIterable { } } -enum RedisKeySummary { +nonisolated enum RedisKeySummary { static let collectionPreviewLimit = 100 static let streamPreviewLimit = 5 @@ -108,75 +107,3 @@ enum RedisKeySummary { return String(data: data, encoding: .utf8) } } - -/// What the server said about one key. Either half is nil when the server would not say, which -/// an ACL user whose key patterns do not cover the key gets for both, while `SCAN` still lists it. -struct RedisKeyDescription: Equatable, Sendable { - let typeName: String? - let ttlSeconds: Int? - - var kind: RedisKeyKind? { - typeName.flatMap(RedisKeyKind.init(typeName:)) - } - - var typeCell: PluginCellValue { - .fromOptional(typeName?.uppercased()) - } - - var ttlCell: PluginCellValue { - .fromOptional(ttlSeconds.map(String.init)) - } -} - -struct RedisKeyContents { - let kind: RedisKeyKind - let length: Int? - let preview: RedisReply? - - var lengthCell: PluginCellValue { - .fromOptional(length.map(String.init)) - } -} - -extension RedisCommandChannel { - func keyTypeNames(_ keys: [String]) async throws -> [String?] { - try await runMetadataReads(keys.map { ["TYPE", $0] }).map { $0?.stringValue } - } - - func describeKeys(_ keys: [String]) async throws -> [RedisKeyDescription] { - let answers = try await runMetadataReads(keys.flatMap { [["TYPE", $0], ["TTL", $0]] }) - return stride(from: 0, to: answers.count - 1, by: 2).map { index in - RedisKeyDescription(typeName: answers[index]?.stringValue, ttlSeconds: answers[index + 1]?.intValue) - } - } - - /// A key whose type is unknown gets no probe at all, because the length and preview commands - /// are chosen by type. - func readContents( - of keys: [String], - describedAs descriptions: [RedisKeyDescription] - ) async throws -> [RedisKeyContents?] { - let kinds = zip(keys, descriptions).map { (key: $0, kind: $1.kind) } - var commands: [[String]] = [] - commands.reserveCapacity(kinds.count * 2) - for entry in kinds { - guard let kind = entry.kind else { continue } - commands.append(RedisKeySummary.lengthCommand(for: kind, key: entry.key)) - commands.append(RedisKeySummary.previewCommand(for: kind, key: entry.key)) - } - let answers = try await runMetadataReads(commands) - - var contents: [RedisKeyContents?] = [] - contents.reserveCapacity(kinds.count) - var next = answers.startIndex - for entry in kinds { - guard let kind = entry.kind, next + 1 < answers.endIndex else { - contents.append(nil) - continue - } - contents.append(RedisKeyContents(kind: kind, length: answers[next]?.intValue, preview: answers[next + 1])) - next += 2 - } - return contents - } -} diff --git a/TableProMobile/TableProMobile/Drivers/KeyContentsBrowsing.swift b/TableProMobile/TableProMobile/Drivers/KeyContentsBrowsing.swift new file mode 100644 index 0000000000..fc72a262e8 --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/KeyContentsBrowsing.swift @@ -0,0 +1,12 @@ +import Foundation +import TableProDatabase +import TableProModels + +nonisolated struct KeyContentsPage: Sendable { + let result: QueryResult + let totalCount: Int? +} + +nonisolated protocol KeyContentsBrowsing: DatabaseDriver { + func keyContentsPage(ofKey key: String, limit: Int, offset: Int) async throws -> KeyContentsPage +} diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index 0f70496e0e..ebde35ae93 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -4,7 +4,7 @@ import os import TableProDatabase import TableProModels -nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { +nonisolated final class RedisDriver: DatabaseDriver, KeyContentsBrowsing, @unchecked Sendable { private let actor = RedisActor() private let host: String private let port: Int @@ -115,6 +115,10 @@ nonisolated final class RedisDriver: DatabaseDriver, @unchecked Sendable { ] } + func keyContentsPage(ofKey key: String, limit: Int, offset: Int) async throws -> KeyContentsPage { + try await RedisKeyBrowse.page(ofKey: key, limit: limit, offset: offset, sending: send) + } + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } @@ -452,6 +456,8 @@ nonisolated enum RedisError: Error, LocalizedError, Equatable { case notConnected case queryFailed(String) case commandQueued(String) + case keyNotFound(String) + case keyTypeNotBrowsable(String) case unsupported(String) var errorDescription: String? { @@ -478,6 +484,10 @@ nonisolated enum RedisError: Error, LocalizedError, Equatable { localized: "A MULTI block is open on this connection. Run EXEC to apply it, or DISCARD to drop it." ) return "\(message) \(hint)" + case .keyNotFound(let key): + return String(format: String(localized: "The key %@ no longer exists."), key) + case .keyTypeNotBrowsable(let typeName): + return String(format: String(localized: "Keys of type %@ cannot be opened here."), typeName) case .unsupported(let msg): return msg } } diff --git a/TableProMobile/TableProMobile/Drivers/RedisKeyBrowse.swift b/TableProMobile/TableProMobile/Drivers/RedisKeyBrowse.swift new file mode 100644 index 0000000000..c299bd007e --- /dev/null +++ b/TableProMobile/TableProMobile/Drivers/RedisKeyBrowse.swift @@ -0,0 +1,189 @@ +import Foundation +import TableProModels + +nonisolated internal enum RedisKeyBrowse { + static let missingTypeName = "none" + static let scanCount = 1_000 + + static func page( + ofKey key: String, + limit: Int, + offset: Int, + sending send: RedisKeyspaceReads.Send + ) async throws -> KeyContentsPage { + let start = Date() + let typeName = try await RedisKeyspaceReads.typeName(ofKey: key, sending: send) + guard typeName != missingTypeName else { throw RedisError.keyNotFound(key) } + guard let kind = RedisKeyKind(typeName: typeName) else { throw RedisError.keyTypeNotBrowsable(typeName) } + + let window = PageWindow(offset: max(offset, 0), limit: max(limit, 0)) + let totalCount = try await count(of: kind, key: key, sending: send) + let rows = try await rows(of: kind, key: key, window: window, sending: send) + let result = QueryResult( + columns: columns(for: kind), + rows: rows, + rowsAffected: 0, + executionTime: Date().timeIntervalSince(start) + ) + return KeyContentsPage(result: result, totalCount: totalCount) + } + + static func columns(for kind: RedisKeyKind) -> [ColumnInfo] { + let fields: [(name: String, typeName: String)] + switch kind { + case .string: + fields = [("value", "string")] + case .list: + fields = [("index", "integer"), ("element", "string")] + case .zset: + fields = [("member", "string"), ("score", "double")] + case .hash: + fields = [("field", "string"), ("value", "string")] + case .set: + fields = [("member", "string")] + case .stream: + fields = [("id", "string"), ("fields", "json")] + } + return fields.enumerated().map { position, field in + ColumnInfo(name: field.name, typeName: field.typeName, ordinalPosition: position) + } + } + + private struct PageWindow { + let offset: Int + let limit: Int + + var end: Int { offset + limit } + var lastIndex: Int { end - 1 } + } + + private static func count( + of kind: RedisKeyKind, + key: String, + sending send: RedisKeyspaceReads.Send + ) async throws -> Int? { + guard kind != .string else { return 1 } + let command = RedisKeySummary.lengthCommand(for: kind, key: key) + let reply = try await checked(command, sending: send) + guard case .integer(let length) = reply else { return nil } + return Int(length) + } + + private static func rows( + of kind: RedisKeyKind, + key: String, + window: PageWindow, + sending send: RedisKeyspaceReads.Send + ) async throws -> [[String?]] { + guard window.limit > 0 else { return [] } + switch kind { + case .string: + return try await stringRows(key: key, window: window, sending: send) + case .list: + return try await listRows(key: key, window: window, sending: send) + case .zset: + return try await sortedSetRows(key: key, window: window, sending: send) + case .hash: + let fields = try await scanned("HSCAN", key: key, collecting: window.end, sending: send) { elements in + RedisKeySummary.pairs(from: elements).map { [$0.first, $0.second] } + } + return slice(fields, to: window) + case .set: + let members = try await scanned("SSCAN", key: key, collecting: window.end, sending: send) { elements in + elements.map { [$0] } + } + return slice(members, to: window) + case .stream: + return try await streamRows(key: key, window: window, sending: send) + } + } + + private static func stringRows( + key: String, + window: PageWindow, + sending send: RedisKeyspaceReads.Send + ) async throws -> [[String?]] { + guard window.offset == 0 else { return [] } + let reply = try await checked(["GET", key], sending: send) + guard reply != .null else { throw RedisError.keyNotFound(key) } + return [[reply.stringRepresentation]] + } + + private static func listRows( + key: String, + window: PageWindow, + sending send: RedisKeyspaceReads.Send + ) async throws -> [[String?]] { + let command = ["LRANGE", key, String(window.offset), String(window.lastIndex)] + let elements = try await checked(command, sending: send).stringElements + return elements.enumerated().map { position, element in + [String(window.offset + position), element] + } + } + + private static func sortedSetRows( + key: String, + window: PageWindow, + sending send: RedisKeyspaceReads.Send + ) async throws -> [[String?]] { + let command = ["ZRANGE", key, String(window.offset), String(window.lastIndex), "WITHSCORES"] + let elements = try await checked(command, sending: send).stringElements + return RedisKeySummary.pairs(from: elements).map { [$0.first, $0.second] } + } + + private static func streamRows( + key: String, + window: PageWindow, + sending send: RedisKeyspaceReads.Send + ) async throws -> [[String?]] { + let reply = try await checked(["XRANGE", key, "-", "+", "COUNT", String(window.end)], sending: send) + guard case .array(let entries) = reply else { return [] } + return entries.dropFirst(window.offset).compactMap { entry -> [String?]? in + guard case .array(let parts) = entry, let id = parts.first?.stringRepresentation else { return nil } + let fields = parts.count > 1 ? parts[1].stringElements : [] + return [id, RedisKeySummary.jsonObject(flatPairs: fields)] + } + } + + /// HSCAN and SSCAN may return an element more than once, so each is kept at its first sighting. + /// The walk ends once the page is covered or the cursor comes back to 0. The last bound only + /// stops a server that keeps repeating itself without ever ending its cursor. + private static func scanned( + _ command: String, + key: String, + collecting needed: Int, + sending send: RedisKeyspaceReads.Send, + entries: ([String]) -> [[String]] + ) async throws -> [[String]] { + var collected: [[String]] = [] + var seen = Set() + var received = 0 + var cursor = RedisScanPage.startCursor + repeat { + let reply = try await send([command, key, cursor, "COUNT", String(scanCount)]) + let page = try RedisScanPage(reply: reply, command: command) + cursor = page.cursor + let pageEntries = entries(page.elements) + received += pageEntries.count + for entry in pageEntries { + guard let identity = entry.first, seen.insert(identity).inserted else { continue } + collected.append(entry) + } + } while cursor != RedisScanPage.startCursor + && collected.count < needed + && received < needed + RedisKeyspaceReads.keyLimit + return collected + } + + private static func slice(_ entries: [[String]], to window: PageWindow) -> [[String?]] { + guard window.offset < entries.count else { return [] } + return entries[window.offset ..< min(window.end, entries.count)].map { $0.map(Optional.some) } + } + + private static func checked( + _ command: [String], + sending send: RedisKeyspaceReads.Send + ) async throws -> RedisReplyValue { + try await send(command).throwIfError().throwIfQueued(command[0]) + } +} diff --git a/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift b/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift index cbc6bdff30..a22b7c8c1b 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisKeyspaceReads.swift @@ -4,20 +4,20 @@ nonisolated internal struct RedisScanPage: Equatable, Sendable { static let startCursor = "0" let cursor: String - let keys: [String] + let elements: [String] - init(cursor: String, keys: [String]) { + init(cursor: String, elements: [String]) { self.cursor = cursor - self.keys = keys + self.elements = elements } - init(reply: RedisReplyValue) throws { - try reply.throwIfError().throwIfQueued("SCAN") + init(reply: RedisReplyValue, command: String) throws { + try reply.throwIfError().throwIfQueued(command) guard case .array(let parts) = reply, parts.count == 2 else { - self.init(cursor: Self.startCursor, keys: []) + self.init(cursor: Self.startCursor, elements: []) return } - self.init(cursor: Self.cursor(from: parts[0]), keys: Self.keys(from: parts[1])) + self.init(cursor: Self.cursor(from: parts[0]), elements: parts[1].stringElements) } private static func cursor(from reply: RedisReplyValue) -> String { @@ -30,18 +30,6 @@ nonisolated internal struct RedisScanPage: Equatable, Sendable { return startCursor } } - - private static func keys(from reply: RedisReplyValue) -> [String] { - guard case .array(let items) = reply else { return [] } - return items.compactMap { item in - switch item { - case .string(let key), .status(let key): - return key - default: - return nil - } - } - } } nonisolated internal enum RedisKeyspaceReads { @@ -65,10 +53,10 @@ nonisolated internal enum RedisKeyspaceReads { var cursor = RedisScanPage.startCursor repeat { let reply = try await send(scanArguments(cursor: cursor)) - let page = try RedisScanPage(reply: reply) + let page = try RedisScanPage(reply: reply, command: "SCAN") cursor = page.cursor - received += page.keys.count - for key in page.keys where seen.insert(key).inserted { + received += page.elements.count + for key in page.elements where seen.insert(key).inserted { keys.append(key) } } while cursor != RedisScanPage.startCursor && received < keyLimit diff --git a/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift b/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift index ec6406950f..4e1119577e 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisReplyValue.swift @@ -19,6 +19,18 @@ nonisolated internal enum RedisReplyValue: Sendable, Equatable { } } + var stringElements: [String] { + guard case .array(let items) = self else { return [] } + return items.compactMap { item in + switch item { + case .string(let value), .status(let value): + return value + default: + return nil + } + } + } + var errorMessage: String? { guard case .error(let message) = self else { return nil } return message diff --git a/TableProMobile/TableProMobile/Models/TableBrowseMode.swift b/TableProMobile/TableProMobile/Models/TableBrowseMode.swift new file mode 100644 index 0000000000..45137ad578 --- /dev/null +++ b/TableProMobile/TableProMobile/Models/TableBrowseMode.swift @@ -0,0 +1,15 @@ +import Foundation +import TableProDatabase + +nonisolated enum TableBrowseMode: Equatable, Sendable { + case sql + case keyContents + + init(driver: (any DatabaseDriver)?) { + self = Self.keyContentsReader(of: driver) == nil ? .sql : .keyContents + } + + static func keyContentsReader(of driver: (any DatabaseDriver)?) -> (any KeyContentsBrowsing)? { + driver as? any KeyContentsBrowsing + } +} diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 421c16d8bb..57f492eb7e 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -122,6 +122,16 @@ final class DataBrowserViewModel { } loadError = nil + if let reader = TableBrowseMode.keyContentsReader(of: session.driver) { + await loadKeyContents(reader: reader, key: table.name) + } else { + await loadRows(session: session, table: table, isInitial: isInitial) + } + isLoading = false + isPageLoading = false + } + + private func loadRows(session: ConnectionSession, table: TableInfo, isInitial: Bool) async { do { if columnDetails.isEmpty || isInitial { columnDetails = try await session.driver.fetchColumns(table: table.name, schema: nil) @@ -130,24 +140,17 @@ final class DataBrowserViewModel { let pkColumns = columnDetails.filter(\.isPrimaryKey).map(\.name) let lazyContext = pkColumns.isEmpty ? nil : LazyContext(table: table.name, primaryKeyColumns: pkColumns) let query = buildSelectQuery(table: table) + let driver = session.driver - await loadPage( - driver: session.driver, - query: query, - lazyContext: lazyContext, - pageSize: pagination.pageSize - ) + await loadPage(options: pageOptions(lazyContext: lazyContext), startedAt: Date()) { options in + driver.executeStreaming(query: query, options: options) + } if case .error(let err) = phase { loadError = err - isLoading = false - isPageLoading = false return } - - if legacyRows.count < pagination.pageSize, pagination.totalRows == nil { - pagination.totalRows = pagination.currentOffset + legacyRows.count - } + settleTotalRowsFromShortPage() if foreignKeys.isEmpty || isInitial { do { @@ -160,19 +163,58 @@ final class DataBrowserViewModel { if pagination.totalRows == nil { await fetchTotalRows(session: session, table: table) } - - isLoading = false - isPageLoading = false } catch { loadError = ErrorClassifier.classify( error, context: ErrorContext(operation: "loadData", databaseType: databaseType, host: host) ) - isLoading = false - isPageLoading = false } } + private func loadKeyContents(reader: any KeyContentsBrowsing, key: String) async { + let start = Date() + do { + let page = try await reader.keyContentsPage( + ofKey: key, + limit: pagination.pageSize, + offset: pagination.currentOffset + ) + columnDetails = page.result.columns + foreignKeys = [] + pagination.totalRows = page.totalCount + + let result = page.result + await loadPage(options: pageOptions(lazyContext: nil), startedAt: start) { options in + QueryResultStreaming.stream(options: options) { result } + } + + if case .error(let err) = phase { + loadError = err + return + } + settleTotalRowsFromShortPage() + } catch { + loadError = ErrorClassifier.classify( + error, + context: ErrorContext(operation: "loadKeyContents", databaseType: databaseType, host: host) + ) + } + } + + private func settleTotalRowsFromShortPage() { + guard legacyRows.count < pagination.pageSize, pagination.totalRows == nil else { return } + pagination.totalRows = pagination.currentOffset + legacyRows.count + } + + private func pageOptions(lazyContext: LazyContext?) -> StreamOptions { + StreamOptions( + textTruncationBytes: 4_096, + inlineBinary: false, + maxRows: pagination.pageSize, + lazyContext: lazyContext + ) + } + private func buildSelectQuery(table: TableInfo) -> String { let activeSort = effectiveSortState() if hasActiveSearch { @@ -428,26 +470,18 @@ final class DataBrowserViewModel { // MARK: - Streaming (Internal) private func loadPage( - driver: DatabaseDriver, - query: String, - lazyContext: LazyContext?, - pageSize: Int + options: StreamOptions, + startedAt start: Date, + stream makeStream: @escaping @Sendable (StreamOptions) -> AsyncThrowingStream ) async { fetchTask?.cancel() - let options = StreamOptions( - textTruncationBytes: 4_096, - inlineBinary: false, - maxRows: pageSize, - lazyContext: lazyContext - ) phase = .loading buffer.reset() - let start = Date() let task = Task { [weak self] in guard let self else { return } do { - for try await element in driver.executeStreaming(query: query, options: options) { + for try await element in makeStream(options) { if Task.isCancelled { break } self.buffer.apply(element) } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index ef80e337d4..c1018f6f0f 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -37,15 +37,20 @@ struct DataBrowserView: View { /// Asked of the kind rather than compared against the two view cases, so a MariaDB sequence, /// which refuses UPDATE and DELETE with ERROR 1031, is read-only here as it is on Mac. private var allowsRowEditing: Bool { table.type.allowsRowEditing } - private var isRedis: Bool { connection.type == .redis } + private var browseMode: TableBrowseMode { TableBrowseMode(driver: session?.driver) } + private var browsesSQLRows: Bool { browseMode == .sql } - /// Both entry points ask this. Redis takes no `INSERT`, and the form cannot be filled in before - /// the column list has arrived. + /// Both entry points ask this. A key's contents take no `INSERT`, and the form cannot be filled + /// in before the column list has arrived. private var canInsertRow: Bool { - allowsRowEditing && !isRedis + allowsRowEditing && browsesSQLRows && !connection.safeModeLevel.blocksWrites && !viewModel.columnDetails.isEmpty } + private var canDeleteRows: Bool { + allowsRowEditing && browsesSQLRows && viewModel.hasPrimaryKeys && !connection.safeModeLevel.blocksWrites + } + private var columns: [ColumnInfo] { viewModel.columns } private var rows: [[String?]] { viewModel.legacyRows } @@ -196,7 +201,7 @@ struct DataBrowserView: View { @ViewBuilder private var searchableContent: some View { - if isRedis { + if !browsesSQLRows { content .navigationTitle(table.name) .navigationBarTitleDisplayMode(.inline) @@ -298,23 +303,27 @@ struct DataBrowserView: View { .hoverEffect() .contextMenu { rowContextMenu(row: row) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { - if allowsRowEditing && viewModel.hasPrimaryKeys && !connection.safeModeLevel.blocksWrites { + if canDeleteRows { Button { - deleteTarget = viewModel.primaryKeyValues(for: row) - showDeleteConfirmation = true + confirmDelete(row) } label: { Label("Delete", systemImage: "trash") } .tint(.red) } } - .accessibilityAction(named: Text("Delete row")) { - guard allowsRowEditing, viewModel.hasPrimaryKeys, !connection.safeModeLevel.blocksWrites else { return } - deleteTarget = viewModel.primaryKeyValues(for: row) - showDeleteConfirmation = true + .accessibilityActions { + if canDeleteRows { + Button("Delete row") { confirmDelete(row) } + } } } + private func confirmDelete(_ row: [String?]) { + deleteTarget = viewModel.primaryKeyValues(for: row) + showDeleteConfirmation = true + } + @ViewBuilder private func rowContextMenu(row: [String?]) -> some View { Menu("Share Row") { @@ -365,6 +374,46 @@ struct DataBrowserView: View { @ToolbarContentBuilder private var topToolbar: some ToolbarContent { + if browsesSQLRows { + sortAndFilterItems + } + ToolbarItem(placement: .topBarTrailing) { + Menu { + if browsesSQLRows { + Button { showStructure = true } label: { + Label("Table Structure", systemImage: "info.circle") + } + Divider() + } + Section("Export") { + ForEach(ExportFormat.allCases) { format in + Button { + let text = ClipboardExporter.exportRows( + columns: columns, rows: rows, + format: format, tableName: table.name, + databaseType: connection.type, driver: session?.driver + ) + ClipboardExporter.copyToClipboard(text) + } label: { + Label(format.rawValue, systemImage: "doc.on.clipboard") + } + } + } + } label: { + Label("More", systemImage: "ellipsis.circle") + } + } + if canInsertRow { + ToolbarItem(placement: .primaryAction) { + Button { showInsertSheet = true } label: { + Label("Insert Row", systemImage: "plus") + } + } + } + } + + @ToolbarContentBuilder + private var sortAndFilterItems: some ToolbarContent { ToolbarItem(placement: .topBarTrailing) { Menu { Picker("Sort By", selection: sortColumnBinding) { @@ -397,37 +446,6 @@ struct DataBrowserView: View { } .badge(viewModel.activeFilterCount) } - ToolbarItem(placement: .topBarTrailing) { - Menu { - Button { showStructure = true } label: { - Label("Table Structure", systemImage: "info.circle") - } - Divider() - Section("Export") { - ForEach(ExportFormat.allCases) { format in - Button { - let text = ClipboardExporter.exportRows( - columns: columns, rows: rows, - format: format, tableName: table.name, - databaseType: connection.type, driver: session?.driver - ) - ClipboardExporter.copyToClipboard(text) - } label: { - Label(format.rawValue, systemImage: "doc.on.clipboard") - } - } - } - } label: { - Label("More", systemImage: "ellipsis.circle") - } - } - if canInsertRow { - ToolbarItem(placement: .primaryAction) { - Button { showInsertSheet = true } label: { - Label("Insert Row", systemImage: "plus") - } - } - } } private var duoWidthClass: DuoWidthClass { diff --git a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index d8936e88d7..2c012c9de1 100644 --- a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -371,4 +371,86 @@ struct DataBrowserViewModelTests { #expect(vm.canGoToPreviousPage) #expect(vm.canGoToNextPage == false) } + + private func keyPage(from start: Int, count: Int, total: Int) -> KeyContentsPage { + let columns = [ + ColumnInfo(name: "index", typeName: "integer", ordinalPosition: 0), + ColumnInfo(name: "element", typeName: "string", ordinalPosition: 1) + ] + let rows: [[String?]] = (start ..< start + count).map { [String($0), "e\($0)"] } + return KeyContentsPage( + result: QueryResult(columns: columns, rows: rows, rowsAffected: 0, executionTime: 0), + totalCount: total + ) + } + + @Test("a key browse sends no SQL and pages by offset") + func keyBrowsePagesByOffset() async { + let driver = MockKeyContentsDriver() + let vm = DataBrowserViewModel() + let pageSize = vm.pagination.pageSize + driver.scriptedPages = [ + .success(keyPage(from: 0, count: pageSize, total: pageSize * 3)), + .success(keyPage(from: pageSize, count: pageSize, total: pageSize * 3)) + ] + let session = ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "db0", tables: []) + vm.attach(session: session, table: TableInfo(name: "queue"), databaseType: .redis, host: "localhost") + + await vm.load(isInitial: true) + await vm.goToNextPage() + + #expect(driver.pageRequests == [ + MockKeyContentsDriver.PageRequest(key: "queue", limit: pageSize, offset: 0), + MockKeyContentsDriver.PageRequest(key: "queue", limit: pageSize, offset: pageSize) + ]) + #expect(driver.executedQueries.isEmpty) + #expect(driver.fetchColumnsCalls == 0) + #expect(driver.fetchForeignKeysCalls == 0) + #expect(vm.pagination.totalRows == pageSize * 3) + #expect(vm.columnDetails.map(\.name) == ["index", "element"]) + #expect(vm.hasPrimaryKeys == false) + #expect(vm.legacyRows.first == [String(pageSize), "e\(pageSize)"]) + #expect(vm.loadError == nil) + #expect(vm.isLoading == false) + } + + @Test("a short key page with no count settles the total from what arrived") + func shortKeyPageSettlesTotal() async { + let driver = MockKeyContentsDriver() + driver.scriptedPages = [ + .success(KeyContentsPage( + result: QueryResult( + columns: [ColumnInfo(name: "member", typeName: "string", ordinalPosition: 0)], + rows: [["x"], ["y"]], + rowsAffected: 0, + executionTime: 0 + ), + totalCount: nil + )) + ] + let vm = DataBrowserViewModel() + let session = ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "db0", tables: []) + vm.attach(session: session, table: TableInfo(name: "tags"), databaseType: .redis, host: "localhost") + + await vm.load(isInitial: true) + + #expect(vm.pagination.totalRows == 2) + #expect(vm.canGoToNextPage == false) + } + + @Test("a key that cannot be read shows the error instead of rows") + func unreadableKeyShowsError() async { + let driver = MockKeyContentsDriver() + driver.scriptedPages = [.failure(RedisError.keyNotFound("gone"))] + let vm = DataBrowserViewModel() + let session = ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "db0", tables: []) + vm.attach(session: session, table: TableInfo(name: "gone"), databaseType: .redis, host: "localhost") + + await vm.load(isInitial: true) + + #expect(vm.loadError != nil) + #expect(vm.legacyRows.isEmpty) + #expect(vm.isLoading == false) + #expect(driver.executedQueries.isEmpty) + } } diff --git a/TableProMobile/TableProMobileTests/Drivers/RedisKeyBrowseTests.swift b/TableProMobile/TableProMobileTests/Drivers/RedisKeyBrowseTests.swift new file mode 100644 index 0000000000..fe67f2d673 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Drivers/RedisKeyBrowseTests.swift @@ -0,0 +1,195 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +@Suite("Redis key browse") +struct RedisKeyBrowseTests { + private func browse( + _ replies: [RedisReplyValue], + key: String = "k", + limit: Int = 100, + offset: Int = 0 + ) async throws -> (page: KeyContentsPage, sent: [[String]]) { + let server = ScriptedRedisServer(replies: replies) + let page = try await RedisKeyBrowse.page(ofKey: key, limit: limit, offset: offset) { + try await server.reply(to: $0) + } + return (page, await server.sent) + } + + private func sentBeforeFailing(_ replies: [RedisReplyValue], expecting error: RedisError) async -> [[String]] { + let server = ScriptedRedisServer(replies: replies) + await #expect(throws: error) { + try await RedisKeyBrowse.page(ofKey: "k", limit: 100, offset: 0) { try await server.reply(to: $0) } + } + return await server.sent + } + + @Test("a string key is read with GET and counts as one row") + func stringKey() async throws { + let (page, sent) = try await browse([.status("string"), .string("hello")]) + #expect(sent == [["TYPE", "k"], ["GET", "k"]]) + #expect(page.result.columns.map(\.name) == ["value"]) + #expect(page.result.rows == [["hello"]]) + #expect(page.totalCount == 1) + } + + @Test("a string key past its first page reads nothing more") + func stringKeyPastFirstPage() async throws { + let (page, sent) = try await browse([.status("string")], offset: 100) + #expect(sent == [["TYPE", "k"]]) + #expect(page.result.rows.isEmpty) + #expect(page.totalCount == 1) + } + + @Test("a string key deleted after its TYPE was read is reported gone") + func stringKeyGoneBeforeGet() async { + let sent = await sentBeforeFailing([.status("string"), .null], expecting: .keyNotFound("k")) + #expect(sent == [["TYPE", "k"], ["GET", "k"]]) + } + + @Test("a list page is an exact range whose index counts from the offset") + func listPage() async throws { + let (page, sent) = try await browse( + [.status("list"), .integer(250), .array([.string("e100"), .string("e101")])], + offset: 100 + ) + #expect(sent == [["TYPE", "k"], ["LLEN", "k"], ["LRANGE", "k", "100", "199"]]) + #expect(page.result.columns.map(\.name) == ["index", "element"]) + #expect(page.result.rows == [["100", "e100"], ["101", "e101"]]) + #expect(page.totalCount == 250) + } + + @Test("a sorted set page pairs each member with its score") + func sortedSetPage() async throws { + let (page, sent) = try await browse( + [.status("zset"), .integer(2), .array([.string("a"), .string("1"), .string("b"), .string("2.5")])], + limit: 2 + ) + #expect(sent == [["TYPE", "k"], ["ZCARD", "k"], ["ZRANGE", "k", "0", "1", "WITHSCORES"]]) + #expect(page.result.columns.map(\.name) == ["member", "score"]) + #expect(page.result.rows == [["a", "1"], ["b", "2.5"]]) + } + + @Test("a hash field two HSCAN pages return is listed once") + func hashFieldRepeatedAcrossPages() async throws { + let (page, sent) = try await browse([ + .status("hash"), + .integer(2), + scanReply(cursor: "5", keys: ["name", "ada"]), + scanReply(cursor: "0", keys: ["name", "ada", "age", "36"]) + ]) + #expect(sent == [ + ["TYPE", "k"], + ["HLEN", "k"], + ["HSCAN", "k", "0", "COUNT", "1000"], + ["HSCAN", "k", "5", "COUNT", "1000"] + ]) + #expect(page.result.columns.map(\.name) == ["field", "value"]) + #expect(page.result.rows == [["name", "ada"], ["age", "36"]]) + #expect(page.totalCount == 2) + } + + @Test("a hash walk stops once the page is covered and slices it out") + func hashWalkStopsAtThePage() async throws { + let (page, sent) = try await browse( + [.status("hash"), .integer(40), scanReply(cursor: "5", keys: ["name", "ada", "age", "36"])], + limit: 1, + offset: 1 + ) + #expect(sent.filter { $0.first == "HSCAN" }.count == 1) + #expect(page.result.rows == [["age", "36"]]) + } + + @Test("a set member two SSCAN pages return is listed once") + func setMemberRepeatedAcrossPages() async throws { + let (page, sent) = try await browse([ + .status("set"), + .integer(3), + scanReply(cursor: "3", keys: ["x", "y"]), + scanReply(cursor: "0", keys: ["y", "z"]) + ]) + #expect(sent.last == ["SSCAN", "k", "3", "COUNT", "1000"]) + #expect(page.result.columns.map(\.name) == ["member"]) + #expect(page.result.rows == [["x"], ["y"], ["z"]]) + } + + @Test("a set page past the end of the set is empty") + func setPagePastTheEnd() async throws { + let (page, _) = try await browse( + [.status("set"), .integer(1), scanReply(cursor: "0", keys: ["x"])], + offset: 100 + ) + #expect(page.result.rows.isEmpty) + } + + @Test("a stream page skips the entries before its offset and reads fields as JSON") + func streamPage() async throws { + let entry: (String, [String]) -> RedisReplyValue = { id, fields in + .array([.string(id), .array(fields.map { .string($0) })]) + } + let (page, sent) = try await browse( + [ + .status("stream"), + .integer(3), + .array([ + entry("1-0", ["name", "bob"]), + entry("2-0", ["name", "ada", "age", "36"]), + entry("3-0", ["name", "cy"]) + ]) + ], + limit: 2, + offset: 1 + ) + #expect(sent == [["TYPE", "k"], ["XLEN", "k"], ["XRANGE", "k", "-", "+", "COUNT", "3"]]) + #expect(page.result.columns.map(\.name) == ["id", "fields"]) + #expect(page.result.rows == [["2-0", #"{"age":"36","name":"ada"}"#], ["3-0", #"{"name":"cy"}"#]]) + } + + @Test("a key that no longer exists is reported by name") + func missingKey() async { + let sent = await sentBeforeFailing([.status("none")], expecting: .keyNotFound("k")) + #expect(sent == [["TYPE", "k"]]) + } + + @Test("a key of a type the browser cannot read is refused by type") + func unbrowsableType() async { + let sent = await sentBeforeFailing([.status("vectorset")], expecting: .keyTypeNotBrowsable("vectorset")) + #expect(sent == [["TYPE", "k"]]) + } + + @Test("a refused read throws the server's message") + func refusedRead() async { + let noperm = "NOPERM No permissions to access a key" + _ = await sentBeforeFailing([.status("string"), .error(noperm)], expecting: .queryFailed(noperm)) + } + + @Test("a refused length throws the server's message") + func refusedLength() async { + let noperm = "NOPERM User limited has no permissions to run the 'llen' command" + let sent = await sentBeforeFailing([.status("list"), .error(noperm)], expecting: .queryFailed(noperm)) + #expect(sent == [["TYPE", "k"], ["LLEN", "k"]]) + } + + @Test("a queued TYPE throws instead of naming the type QUEUED") + func queuedType() async { + _ = await sentBeforeFailing([.status("QUEUED")], expecting: .commandQueued("TYPE")) + } + + @Test("a queued range read names the command it held") + func queuedRange() async { + _ = await sentBeforeFailing( + [.status("list"), .integer(3), .status("QUEUED")], + expecting: .commandQueued("LRANGE") + ) + } + + @Test("no kind reads a primary key, so no page can be edited as a row") + func noKindHasAPrimaryKey() { + for kind in RedisKeyKind.allCases { + let primaryKeys = RedisKeyBrowse.columns(for: kind).filter(\.isPrimaryKey) + #expect(primaryKeys.isEmpty, "\(kind)") + } + } +} diff --git a/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift index 550a7285c6..60d4c7425c 100644 --- a/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift +++ b/TableProMobile/TableProMobileTests/Drivers/RedisKeyspaceReadsTests.swift @@ -52,28 +52,28 @@ struct RedisReplyValueGuardTests { @Suite("Redis SCAN page") struct RedisScanPageTests { - @Test("a cursor and its keys parse") + @Test("a cursor and its elements parse") func parsesCursorAndKeys() throws { - let page = try RedisScanPage(reply: scanReply(cursor: "0", keys: ["b", "a"])) - #expect(page == RedisScanPage(cursor: "0", keys: ["b", "a"])) + let page = try RedisScanPage(reply: scanReply(cursor: "0", keys: ["b", "a"]), command: "SCAN") + #expect(page == RedisScanPage(cursor: "0", elements: ["b", "a"])) } @Test("a status or integer cursor is accepted") func acceptsStatusAndIntegerCursors() throws { - let status = try RedisScanPage(reply: .array([.status("17"), .array([.status("k")])])) - #expect(status == RedisScanPage(cursor: "17", keys: ["k"])) - let integer = try RedisScanPage(reply: .array([.integer(42), .array([])])) - #expect(integer == RedisScanPage(cursor: "42", keys: [])) + let status = try RedisScanPage(reply: .array([.status("17"), .array([.status("k")])]), command: "SCAN") + #expect(status == RedisScanPage(cursor: "17", elements: ["k"])) + let integer = try RedisScanPage(reply: .array([.integer(42), .array([])]), command: "SCAN") + #expect(integer == RedisScanPage(cursor: "42", elements: [])) } - @Test("a reply of any other shape ends the walk with no keys", arguments: [ + @Test("a reply of any other shape ends the walk with no elements", arguments: [ RedisReplyValue.null, .string("5"), .array([.string("5")]) ]) func otherShapesEndTheWalk(reply: RedisReplyValue) throws { - let page = try RedisScanPage(reply: reply) - #expect(page == RedisScanPage(cursor: RedisScanPage.startCursor, keys: [])) + let page = try RedisScanPage(reply: reply, command: "SCAN") + #expect(page == RedisScanPage(cursor: RedisScanPage.startCursor, elements: [])) } @Test("a refused SCAN throws the server's message", arguments: [ @@ -82,21 +82,28 @@ struct RedisScanPageTests { ]) func refusedScanThrows(message: String) { #expect(throws: RedisError.queryFailed(message)) { - try RedisScanPage(reply: .error(message)) + try RedisScanPage(reply: .error(message), command: "SCAN") } } @Test("a queued SCAN throws instead of reading as an empty keyspace") func queuedScanThrows() { #expect(throws: RedisError.commandQueued("SCAN")) { - try RedisScanPage(reply: .status("QUEUED")) + try RedisScanPage(reply: .status("QUEUED"), command: "SCAN") + } + } + + @Test("a queued HSCAN names the command it held") + func queuedCollectionScanNamesItsCommand() { + #expect(throws: RedisError.commandQueued("HSCAN")) { + try RedisScanPage(reply: .status("QUEUED"), command: "HSCAN") } } @Test("a key named QUEUED stays a key") func keyNamedQueuedIsAKey() throws { - let page = try RedisScanPage(reply: scanReply(cursor: "0", keys: ["QUEUED"])) - #expect(page.keys == ["QUEUED"]) + let page = try RedisScanPage(reply: scanReply(cursor: "0", keys: ["QUEUED"]), command: "SCAN") + #expect(page.elements == ["QUEUED"]) } } diff --git a/TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift new file mode 100644 index 0000000000..e5c9a16658 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift @@ -0,0 +1,67 @@ +import Foundation +import TableProDatabase +@testable import TableProMobile +import TableProModels + +final class MockKeyContentsDriver: KeyContentsBrowsing, @unchecked Sendable { + struct PageRequest: Equatable { + let key: String + let limit: Int + let offset: Int + } + + var scriptedPages: [Result] = [] + + private(set) var pageRequests: [PageRequest] = [] + private(set) var executedQueries: [String] = [] + private(set) var fetchColumnsCalls = 0 + private(set) var fetchForeignKeysCalls = 0 + + var supportsSchemas: Bool { false } + var currentSchema: String? { nil } + var supportsTransactions: Bool { false } + var serverVersion: String? { nil } + + func keyContentsPage(ofKey key: String, limit: Int, offset: Int) async throws -> KeyContentsPage { + pageRequests.append(PageRequest(key: key, limit: limit, offset: offset)) + guard !scriptedPages.isEmpty else { + return KeyContentsPage( + result: QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0), + totalCount: 0 + ) + } + return try scriptedPages.removeFirst().get() + } + + func connect() async throws {} + func disconnect() async throws {} + func ping() async throws -> Bool { true } + func cancelCurrentQuery() async throws {} + + func execute(query: String) async throws -> QueryResult { + executedQueries.append(query) + return QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchTables(schema: String?) async throws -> [TableInfo] { [] } + + func fetchColumns(table: String, schema: String?) async throws -> [ColumnInfo] { + fetchColumnsCalls += 1 + return [] + } + + func fetchIndexes(table: String, schema: String?) async throws -> [IndexInfo] { [] } + + func fetchForeignKeys(table: String, schema: String?) async throws -> [ForeignKeyInfo] { + fetchForeignKeysCalls += 1 + return [] + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchSchemas() async throws -> [String] { [] } + func switchDatabase(to name: String) async throws {} + func switchSchema(to name: String) async throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} +} diff --git a/TableProMobile/TableProMobileTests/TableBrowseModeTests.swift b/TableProMobile/TableProMobileTests/TableBrowseModeTests.swift new file mode 100644 index 0000000000..0d8aeefafd --- /dev/null +++ b/TableProMobile/TableProMobileTests/TableBrowseModeTests.swift @@ -0,0 +1,28 @@ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("Table browse mode") +struct TableBrowseModeTests { + @Test("a driver that reads key contents browses keys") + func keyContentsDriver() { + #expect(TableBrowseMode(driver: MockKeyContentsDriver()) == .keyContents) + #expect(TableBrowseMode.keyContentsReader(of: MockKeyContentsDriver()) != nil) + } + + @Test("the Redis driver browses keys") + func redisDriver() { + #expect(TableBrowseMode(driver: RedisDriver(host: "localhost", port: 6_379, password: nil)) == .keyContents) + } + + @Test("a SQL driver browses rows") + func sqlDriver() { + #expect(TableBrowseMode(driver: MockDatabaseDriver()) == .sql) + #expect(TableBrowseMode.keyContentsReader(of: MockDatabaseDriver()) == nil) + } + + @Test("no driver browses rows") + func noDriver() { + #expect(TableBrowseMode(driver: nil) == .sql) + } +} diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index 0548ca7184..7269da1f12 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -51,10 +51,11 @@ targets: # bundle on iOS, so the driver links into the app. - ../Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift - ../Plugins/MSSQLDriverPlugin/MSSQLLoginParameters.swift - # Redis credential rules the iOS driver shares with the macOS plugin. + # Redis credential rules and key kinds the iOS driver shares with the macOS plugin. - ../Plugins/RedisDriverPlugin/RedisAuthCommand.swift - ../Plugins/RedisDriverPlugin/RedisConnectProbe.swift - ../Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift + - ../Plugins/RedisDriverPlugin/RedisKeySummary.swift # MySQL session character set and result decoding the iOS driver shares with the macOS plugin. - ../Plugins/MySQLDriverPlugin/DatabendResultShape.swift - ../Plugins/MySQLDriverPlugin/GeometryWKBParser.swift diff --git a/project.yml b/project.yml index e525dad3a7..2ba66e5d6d 100644 --- a/project.yml +++ b/project.yml @@ -641,6 +641,7 @@ targets: - Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift - Plugins/RedisDriverPlugin/RedisDatabaseListing.swift - Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift + - Plugins/RedisDriverPlugin/RedisKeyContentsRead.swift - Plugins/RedisDriverPlugin/RedisKeySlot.swift - Plugins/RedisDriverPlugin/RedisKeySummary.swift - Plugins/RedisDriverPlugin/RedisMetadataRead.swift From a1ae35af0af718713a498ded8a74219f8951ce2a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:49:45 +0700 Subject: [PATCH 16/30] fix(ios): report a Redis ACL refusal as a permission error, not failed sign-in --- .../TableProMobile/Drivers/RedisDriver.swift | 4 +- .../TableProMobile/Helpers/AppError.swift | 80 +++++++++++- .../Views/Components/ErrorView.swift | 1 + .../DataBrowserViewModelTests.swift | 2 +- .../RedisErrorClassifierTests.swift | 123 ++++++++++++++++++ 5 files changed, 206 insertions(+), 4 deletions(-) create mode 100644 TableProMobile/TableProMobileTests/RedisErrorClassifierTests.swift diff --git a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift index ebde35ae93..b01626e13e 100644 --- a/TableProMobile/TableProMobile/Drivers/RedisDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/RedisDriver.swift @@ -389,9 +389,9 @@ private actor RedisActor { guard let rawReply = redisCommandArgv(ctx, argc, &argv, &argvlen) else { if ctx.pointee.err != 0 { let msg = withUnsafePointer(to: &ctx.pointee.errstr.0) { String(cString: $0) } - throw RedisError.queryFailed(msg) + throw RedisError.connectionFailed(msg) } - throw RedisError.queryFailed("No reply from server") + throw RedisError.connectionFailed("No reply from server") } let reply = rawReply.assumingMemoryBound(to: redisReply.self) diff --git a/TableProMobile/TableProMobile/Helpers/AppError.swift b/TableProMobile/TableProMobile/Helpers/AppError.swift index 78b63bccae..85b51bcf64 100644 --- a/TableProMobile/TableProMobile/Helpers/AppError.swift +++ b/TableProMobile/TableProMobile/Helpers/AppError.swift @@ -9,6 +9,7 @@ import TableProOracleCore nonisolated enum AppErrorCategory: Sendable { case network case auth + case permission case config case query case ssh @@ -96,7 +97,7 @@ nonisolated enum ErrorClassifier { static func classify(_ error: Error, context: ErrorContext) -> AppError { let message = error.localizedDescription.lowercased() - logger.error("[\(context.operation)] \(error.localizedDescription, privacy: .public)") + logger.error("[\(context.operation, privacy: .public)] \(error.localizedDescription, privacy: .private)") if let fileError = error as? LocalDatabaseFileError { return AppError( @@ -112,6 +113,10 @@ nonisolated enum ErrorClassifier { return connectionFailure(connectionError) } + if let redisError = error as? RedisError, let classified = redisFailure(redisError, context: context) { + return classified + } + if error is LocalNetworkPermissionError { return AppError( category: .network, @@ -220,6 +225,79 @@ nonisolated enum ErrorClassifier { } } + /// A Redis reply is read by its error class, never by its words, because the server echoes the + /// user's own arguments back: `FOO password` answers `ERR unknown command 'FOO', with args + /// beginning with: 'password'`. A transport or setup failure returns nil and is read as text. + private static func redisFailure(_ error: RedisError, context: ErrorContext) -> AppError? { + switch error { + case .authenticationFailed, .sessionUnverified(.unauthenticated): + return auth(error, context: context) + case .queryFailed(let serverMessage), .sessionUnverified(.refused(let serverMessage)): + return redisReplyFailure(error, serverMessage: serverMessage, context: context) + case .commandQueued: + return redisQueryFailure(error) + case .notConnected: + return AppError( + category: .system, + title: String(localized: "Not Connected"), + message: error.localizedDescription, + recovery: String(localized: "Reconnect and try again."), + underlying: error + ) + case .keyNotFound: + return AppError( + category: .query, + title: String(localized: "Key Not Found"), + message: error.localizedDescription, + recovery: String(localized: "Pull down on the key list to refresh it."), + underlying: error + ) + case .keyTypeNotBrowsable: + return AppError( + category: .config, + title: String(localized: "Unsupported Key Type"), + message: error.localizedDescription, + recovery: String(localized: "Read it with a command in Query."), + underlying: error + ) + case .sessionUnverified(.established), .connectionFailed, .unsupported: + return nil + } + } + + private static func redisReplyFailure(_ error: RedisError, serverMessage: String, context: ErrorContext) -> AppError { + switch RedisConnectProbe.errorClass(of: serverMessage) { + case "NOAUTH", "WRONGPASS": + return auth(error, context: context) + case "NOPERM": + return permissionDenied(error) + default: + return redisQueryFailure(error) + } + } + + private static func redisQueryFailure(_ error: RedisError) -> AppError { + AppError( + category: .query, + title: String(localized: "Query Error"), + message: error.localizedDescription, + recovery: nil, + underlying: error + ) + } + + private static func permissionDenied(_ error: Error) -> AppError { + AppError( + category: .permission, + title: String(localized: "Permission Denied"), + message: error.localizedDescription, + recovery: String( + localized: "This connection's Redis user is not allowed to run this command or reach this key. Ask an administrator to grant it in the user's ACL." + ), + underlying: error + ) + } + private static func ssh(_ error: Error, context: ErrorContext) -> AppError { let msg = error.localizedDescription let recovery: String diff --git a/TableProMobile/TableProMobile/Views/Components/ErrorView.swift b/TableProMobile/TableProMobile/Views/Components/ErrorView.swift index 79e8fadb2a..aac329bfdf 100644 --- a/TableProMobile/TableProMobile/Views/Components/ErrorView.swift +++ b/TableProMobile/TableProMobile/Views/Components/ErrorView.swift @@ -30,6 +30,7 @@ struct ErrorView: View { switch error.category { case .network: return "wifi.exclamationmark" case .auth: return "lock.trianglebadge.exclamationmark" + case .permission: return "hand.raised" case .config: return "gear.badge.xmark" case .query: return "exclamationmark.triangle" case .ssh: return "terminal" diff --git a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index 2c012c9de1..b30d4a6af4 100644 --- a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -448,7 +448,7 @@ struct DataBrowserViewModelTests { await vm.load(isInitial: true) - #expect(vm.loadError != nil) + #expect(vm.loadError?.title == String(localized: "Key Not Found")) #expect(vm.legacyRows.isEmpty) #expect(vm.isLoading == false) #expect(driver.executedQueries.isEmpty) diff --git a/TableProMobile/TableProMobileTests/RedisErrorClassifierTests.swift b/TableProMobile/TableProMobileTests/RedisErrorClassifierTests.swift new file mode 100644 index 0000000000..eeb0192c06 --- /dev/null +++ b/TableProMobile/TableProMobileTests/RedisErrorClassifierTests.swift @@ -0,0 +1,123 @@ +import Foundation +@testable import TableProMobile +import TableProModels +import Testing + +private struct DescribedError: LocalizedError { + let errorDescription: String? +} + +@Suite("Redis error classification") +struct RedisErrorClassifierTests { + private func classify(_ error: Error, databaseType: DatabaseType = .redis) -> AppError { + ErrorClassifier.classify( + error, + context: ErrorContext(operation: "executeQuery", databaseType: databaseType, host: "cache.example.com") + ) + } + + @Test("an ACL refusal is a permission error, not a failed sign-in", arguments: [ + "NOPERM User limited has no permissions to run the 'get' command", + "NOPERM No permissions to access a key" + ]) + func noPermIsPermissionDenied(reply: String) { + let error = classify(RedisError.queryFailed(reply)) + #expect(error.category == .permission) + #expect(error.title == String(localized: "Permission Denied")) + #expect(error.recovery == String( + localized: "This connection's Redis user is not allowed to run this command or reach this key. Ask an administrator to grant it in the user's ACL." + )) + #expect(error.message.contains(reply)) + } + + @Test("a rejected sign-in stays an authentication failure", arguments: [ + RedisError.authenticationFailed( + serverMessage: "WRONGPASS invalid username-password pair or user is disabled.", + failure: .rejectedCredentials + ), + .queryFailed("WRONGPASS invalid username-password pair or user is disabled."), + .queryFailed("NOAUTH Authentication required."), + .sessionUnverified(.unauthenticated) + ]) + func authRepliesStayAuth(error: RedisError) { + let classified = classify(error) + #expect(classified.category == .auth) + #expect(classified.title == String(localized: "Authentication Failed")) + } + + @Test("words the server echoes from the command do not decide the category", arguments: [ + "ERR unknown command 'FOO', with args beginning with: 'password' ", + "ERR unknown command 'FOO', with args beginning with: 'ssh-keys' ", + "ERR unknown command 'DELETE', with args beginning with: 'FROM' 'user:1' " + ]) + func echoedWordsAreNotRead(reply: String) { + let error = classify(RedisError.queryFailed(reply)) + #expect(error.category == .query) + #expect(error.title == String(localized: "Query Error")) + #expect(error.recovery == nil) + } + + @Test("a server that refuses the probe for another reason is a query error") + func refusedProbeIsQuery() { + let error = classify(RedisError.sessionUnverified(.refused("LOADING Redis is loading the dataset in memory"))) + #expect(error.category == .query) + } + + @Test("a queued command is a query error that carries its own hint") + func queuedCommandIsQuery() { + let error = classify(RedisError.commandQueued("TYPE")) + #expect(error.category == .query) + #expect(error.recovery == nil) + #expect(error.message == RedisError.commandQueued("TYPE").localizedDescription) + } + + @Test("a closed handle asks for a reconnect") + func notConnected() { + let error = classify(RedisError.notConnected) + #expect(error.category == .system) + #expect(error.title == String(localized: "Not Connected")) + #expect(error.recovery == String(localized: "Reconnect and try again.")) + } + + @Test("a key that is gone points back to the key list") + func keyNotFound() { + let error = classify(RedisError.keyNotFound("session:42")) + #expect(error.category == .query) + #expect(error.title == String(localized: "Key Not Found")) + #expect(error.recovery == String(localized: "Pull down on the key list to refresh it.")) + #expect(error.message == String(format: String(localized: "The key %@ no longer exists."), "session:42")) + } + + @Test("a key type the browser cannot open points to Query") + func keyTypeNotBrowsable() { + let error = classify(RedisError.keyTypeNotBrowsable("vectorset")) + #expect(error.category == .config) + #expect(error.title == String(localized: "Unsupported Key Type")) + #expect(error.recovery == String(localized: "Read it with a command in Query.")) + } + + @Test("a refused connection is still a network failure") + func connectionRefusedIsNetwork() { + let error = classify(RedisError.connectionFailed("Connection refused")) + #expect(error.category == .network) + } + + @Test("a connection the server drops mid-command is still a network failure") + func connectionResetIsNetwork() { + let error = classify(RedisError.connectionFailed("Connection reset by peer")) + #expect(error.category == .network) + } + + @Test("other engines' sign-in failures keep their classification") + func otherEnginesStayAuth() { + let cases: [(message: String, databaseType: DatabaseType)] = [ + (#"password authentication failed for user "app""#, .postgresql), + ("Access denied for user 'root'@'localhost' (using password: YES)", .mysql) + ] + for testCase in cases { + let error = classify(DescribedError(errorDescription: testCase.message), databaseType: testCase.databaseType) + #expect(error.category == .auth, "\(testCase.message)") + #expect(error.title == String(localized: "Authentication Failed"), "\(testCase.message)") + } + } +} From 21aba05e86093fbbdb10cddf4b3c5628f08d0e6c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:23:39 +0700 Subject: [PATCH 17/30] fix(plugin-redis): read the server's subcommand routing tips instead of doubling the container name --- .../RedisClusterChannel.swift | 13 ++- .../RedisCommandRouting.swift | 36 +++++++- .../Plugins/RedisCommandRoutingTests.swift | 89 ++++++++++++++++++- scripts/check-redis-command-routing.sh | 15 +++- 4 files changed, 139 insertions(+), 14 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift index e992e15d56..dfdbc6a063 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift @@ -129,17 +129,14 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { let snapshot = snapshotState() let spec = snapshot.routing.spec(for: args) - switch spec?.requestPolicy { - case .allNodes: + switch spec?.clusterFanOut ?? .single { + case .everyNode: return try await broadcast(args, to: snapshot.topology.allNodes, policy: spec?.responsePolicy, scope: scope) - case .allShards: - guard spec?.responsePolicy != .special else { - return try await routeToAnyMaster(args, snapshot: snapshot, scope: scope) - } + case .everyPrimary: return try await broadcast(args, to: snapshot.topology.masters, policy: spec?.responsePolicy, scope: scope) - case .multiShard: + case .keyedShards: return try await runMultiShard(args, spec: spec, snapshot: snapshot, scope: scope) - case .special, .none: + case .single: return try await routeSingle(args, spec: spec, snapshot: snapshot, scope: scope) } } diff --git a/Plugins/RedisDriverPlugin/RedisCommandRouting.swift b/Plugins/RedisDriverPlugin/RedisCommandRouting.swift index 7412d15884..b61631c99f 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandRouting.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandRouting.swift @@ -66,6 +66,29 @@ struct RedisCommandSpec: Sendable, Equatable { guard last >= firstKey else { return [] } return Array(stride(from: firstKey, through: last, by: step)) } + + /// A `special` response has no rule for combining several nodes' answers, so a command that + /// carries one goes to a single node whatever its request policy says: `LATENCY DOCTOR` is + /// tipped `all_nodes`, and gluing each node's report together would label none of them. + var clusterFanOut: RedisClusterFanOut { + switch requestPolicy { + case .allNodes where responsePolicy != .special: + return .everyNode + case .allShards where responsePolicy != .special: + return .everyPrimary + case .multiShard: + return .keyedShards + default: + return .single + } + } +} + +enum RedisClusterFanOut: Sendable, Equatable { + case everyNode + case everyPrimary + case keyedShards + case single } struct RedisCommandRouting: Sendable { @@ -124,9 +147,13 @@ struct RedisCommandRouting: Sendable { return RedisCommandRouting(specs: merged) } + /// Redis names a subcommand entry with its container already in front (`config|set`), so the + /// entry is keyed by the name the server reports. A nested entry named anything else is not + /// one of its container's subcommands and would overwrite a top-level command of that name. private static func collect(_ entry: RedisReply, container: String?, into specs: inout [String: RedisCommandSpec]) { - guard case .array(let fields) = entry, fields.count >= 6, let rawName = fields[0].stringValue else { return } - let name = container.map { "\($0)|\(rawName.lowercased())" } ?? rawName.lowercased() + guard case .array(let fields) = entry, fields.count >= 6, + let name = fields[0].stringValue?.lowercased(), + isNamed(name, under: container) else { return } let flags = Set((fields[2].stringArrayValue ?? []).map { $0.lowercased() }) let tips = fields.count >= 8 ? (fields[7].stringArrayValue ?? []) : [] @@ -159,6 +186,11 @@ struct RedisCommandRouting: Sendable { } } + private static func isNamed(_ name: String, under container: String?) -> Bool { + guard let container else { return true } + return name.hasPrefix("\(container)|") + } + // MARK: - Curated fallback private static func spec( diff --git a/TableProTests/Plugins/RedisCommandRoutingTests.swift b/TableProTests/Plugins/RedisCommandRoutingTests.swift index 62c7629df5..96288d6ccd 100644 --- a/TableProTests/Plugins/RedisCommandRoutingTests.swift +++ b/TableProTests/Plugins/RedisCommandRoutingTests.swift @@ -2,7 +2,9 @@ // RedisCommandRoutingTests.swift // TableProTests // -// Key positions and policies here match what `COMMAND INFO` reports on Redis 8.10.1. +// Key positions and policies here match what `COMMAND INFO` reports on Redis 8.10.1, and the +// entries are shaped the way its RESP2 reply is: a bulk name, which for a subcommand already +// carries the container (`config|set`), and flags as simple strings. // import Foundation @@ -22,7 +24,7 @@ private func commandEntry( .array([ .string(name), .integer(-1), - .array(flags.map { RedisReply.string($0) }), + .array(flags.map { RedisReply.status($0) }), .integer(Int64(firstKey)), .integer(Int64(lastKey)), .integer(Int64(step)), @@ -191,16 +193,66 @@ struct RedisCommandRoutingParsingTests { #expect(spec.isReadOnly) } + /// Redis already names a subcommand `function|load`. Prefixing the container again keyed it as + /// `function|function|load`, which no lookup ever reaches, so the command ran on one node. @Test("Reads a policy that only exists on a subcommand entry") func parsesSubcommandPolicy() throws { + let reply = RedisReply.array([ + commandEntry(name: "function", subcommands: [ + commandEntry( + name: "function|load", + flags: ["write", "denyoom", "noscript"], + tips: ["request_policy:all_shards", "response_policy:all_succeeded"] + ), + ]), + ]) + let routing = try #require(RedisCommandRouting.parse(commandReply: reply)) + let spec = try #require(routing.spec(for: args("FUNCTION", "LOAD", "#!lua name=lib"))) + #expect(spec.name == "function|load") + #expect(spec.requestPolicy == .allShards) + #expect(spec.responsePolicy == .allSucceeded) + } + + @Test("Reads the key positions of a subcommand entry") + func parsesSubcommandKeyPositions() throws { + let reply = RedisReply.array([ + commandEntry(name: "xinfo", subcommands: [ + commandEntry(name: "xinfo|stream", flags: ["readonly"], firstKey: 2, lastKey: 2, step: 1), + ]), + ]) + let routing = try #require(RedisCommandRouting.parse(commandReply: reply)) + let keys = routing.keys(in: args("XINFO", "STREAM", "orders")).compactMap { String(data: $0, encoding: .utf8) } + #expect(keys == ["orders"]) + #expect(routing.isReadOnly(args("XINFO", "STREAM", "orders"))) + } + + /// The curated `config|set` says all_nodes. A server that tips it differently is the one that + /// knows, and its entry could only win once it was keyed by the name the lookup uses. + @Test("A server's subcommand entry overrides the curated one") + func serverSubcommandOverridesCurated() throws { + let reply = RedisReply.array([ + commandEntry(name: "config", subcommands: [ + commandEntry(name: "config|set", tips: ["request_policy:all_shards", "response_policy:all_succeeded"]), + ]), + ]) + let routing = try #require(RedisCommandRouting.parse(commandReply: reply)) + #expect(routing.spec(for: args("CONFIG", "SET", "maxmemory", "0"))?.requestPolicy == .allShards) + } + + /// A nested entry keyed by its bare name would replace the top-level SET with a keyless + /// entry, and every SET would then go to an arbitrary master instead of the key's owner. + @Test("A nested entry not named under its container cannot replace a top-level command") + func nestedEntryCannotReplaceTopLevelCommand() throws { let reply = RedisReply.array([ commandEntry(name: "config", subcommands: [ commandEntry(name: "set", tips: ["request_policy:all_nodes", "response_policy:all_succeeded"]), ]), ]) let routing = try #require(RedisCommandRouting.parse(commandReply: reply)) + let keys = routing.keys(in: args("SET", "k", "v")).compactMap { String(data: $0, encoding: .utf8) } + #expect(keys == ["k"]) + #expect(routing.spec(for: args("SET", "k", "v"))?.requestPolicy == nil) #expect(routing.spec(for: args("CONFIG", "SET", "a", "b"))?.requestPolicy == .allNodes) - #expect(routing.spec(for: args("CONFIG", "SET", "a", "b"))?.responsePolicy == .allSucceeded) } @Test("Reads the movablekeys flag") @@ -265,6 +317,37 @@ struct RedisCommandRoutingParsingTests { } } +@Suite("Redis command routing - how far a command fans out on a cluster") +struct RedisClusterFanOutTests { + let routing = RedisCommandRouting() + + /// LATENCY DOCTOR is tipped all_nodes with a special response, measured on Redis 8.10.1. + @Test("A special response goes to one node whatever the request policy") + func specialResponseGoesToOneNode() throws { + let reply = RedisReply.array([ + commandEntry(name: "latency", subcommands: [ + commandEntry( + name: "latency|doctor", + flags: ["admin", "noscript", "loading", "stale"], + tips: ["nondeterministic_output", "request_policy:all_nodes", "response_policy:special"] + ), + ]), + ]) + let parsed = try #require(RedisCommandRouting.parse(commandReply: reply)) + #expect(parsed.spec(for: args("LATENCY", "DOCTOR"))?.clusterFanOut == .single) + #expect(routing.spec(for: args("INFO"))?.clusterFanOut == .single) + } + + @Test("Each policy maps to how far the command goes") + func policies() { + #expect(routing.spec(for: args("CONFIG", "SET", "maxmemory", "0"))?.clusterFanOut == .everyNode) + #expect(routing.spec(for: args("DBSIZE"))?.clusterFanOut == .everyPrimary) + #expect(routing.spec(for: args("DEL", "a", "b"))?.clusterFanOut == .keyedShards) + #expect(routing.spec(for: args("GET", "k"))?.clusterFanOut == .single) + #expect(routing.spec(for: args("SCAN", "0"))?.clusterFanOut == .single) + } +} + @Suite("Redis command routing - key index arithmetic") struct RedisCommandSpecIndexTests { private func spec(first: Int, last: Int, step: Int) -> RedisCommandSpec { diff --git a/scripts/check-redis-command-routing.sh b/scripts/check-redis-command-routing.sh index 9b7bef9cab..6bbac74f97 100755 --- a/scripts/check-redis-command-routing.sh +++ b/scripts/check-redis-command-routing.sh @@ -135,12 +135,19 @@ if not curated: sys.exit("could not parse any curated entries") server = {} +misnamed = [] +# Read every entry exactly as RedisCommandRouting.parse does: a subcommand is keyed by the name the +# server reports, which already carries its container, and a nested entry named anything else is +# skipped there, so it fails the check here rather than passing unnoticed. def collect(entry, container=None): if not isinstance(entry, list) or len(entry) < 6 or not isinstance(entry[0], str): return - name = f"{container}|{entry[0].lower().split('|')[-1]}" if container else entry[0].lower() + name = entry[0].lower() + if container and not name.startswith(f"{container}|"): + misnamed.append(f"{container} > {name}") + return flags = {str(f).lower() for f in (entry[2] or [])} tips = [str(t) for t in (entry[7] or [])] if len(entry) > 7 else [] server[name] = { @@ -176,11 +183,17 @@ print(f"compared {checked} commands") missing = sorted(n for n in curated if n not in server) if missing: print(f"not on this server, so unchecked: {', '.join(missing)}") +if misnamed: + print() + for line in misnamed: + print(f" subcommand not named under its container: {line}") + print(f"\n{len(misnamed)} subcommand entry(ies) the driver would skip") if mismatches: print() for line in mismatches: print(f" {line}") print(f"\n{len(mismatches)} disagreement(s)") +if misnamed or mismatches: sys.exit(1) print("the curated table matches the server") PY From 9146600d7e46377fcd4458639cf6e594890f354d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:36:12 +0700 Subject: [PATCH 18/30] fix(plugin-redis): report a split Redis Cluster write that only some shards applied --- .../RedisClusterChannel.swift | 124 ++++++++---- .../RedisClusterWriteOutcome.swift | 137 +++++++++++++ .../RedisCommandChannel.swift | 4 + .../RedisCommandRouting.swift | 35 ++-- Plugins/RedisDriverPlugin/RedisKeySlot.swift | 12 ++ .../RedisDriverPlugin/RedisPluginDriver.swift | 6 +- .../RedisStatementGenerator.swift | 30 ++- .../Plugins/RedisCommandRoutingTests.swift | 47 ++++- TableProTests/Plugins/RedisKeySlotTests.swift | 19 ++ .../Plugins/RedisMultiShardPlannerTests.swift | 2 +- .../RedisPartialClusterWriteTests.swift | 182 ++++++++++++++++++ .../RedisStatementGeneratorTests.swift | 44 +++++ project.yml | 1 + scripts/check-redis-command-routing.sh | 10 +- 14 files changed, 589 insertions(+), 64 deletions(-) create mode 100644 Plugins/RedisDriverPlugin/RedisClusterWriteOutcome.swift create mode 100644 TableProTests/Plugins/RedisPartialClusterWriteTests.swift diff --git a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift index dfdbc6a063..1503306f6f 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift @@ -67,6 +67,8 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { /// the grid saves its rows one statement at a time instead. var supportsTransactions: Bool { false } + var partitionsKeyspace: Bool { true } + func connect(reportingStage report: @escaping ConnectionStageReporter) async throws { guard !seeds.isEmpty else { throw RedisPluginError(code: 0, message: String(localized: "Cluster mode needs at least one seed node.")) @@ -131,9 +133,9 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { switch spec?.clusterFanOut ?? .single { case .everyNode: - return try await broadcast(args, to: snapshot.topology.allNodes, policy: spec?.responsePolicy, scope: scope) + return try await broadcast(args, to: snapshot.topology.allNodes, spec: spec, scope: scope) case .everyPrimary: - return try await broadcast(args, to: snapshot.topology.masters, policy: spec?.responsePolicy, scope: scope) + return try await broadcast(args, to: snapshot.topology.masters, spec: spec, scope: scope) case .keyedShards: return try await runMultiShard(args, spec: spec, snapshot: snapshot, scope: scope) case .single: @@ -281,69 +283,115 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { return try await routeSingle(args, spec: spec, snapshot: snapshot, scope: scope) } - var replies: [RedisReply] = [] - var nodes: [RedisNodeAddress] = [] - replies.reserveCapacity(groups.count) - nodes.reserveCapacity(groups.count) - for group in groups { + let targets = try groups.map { group in guard let node = snapshot.topology.master(forSlot: group.slot) else { throw RedisPluginError.notConnected } - replies.append(try await send(group.arguments, to: node.address, scope: scope)) - nodes.append(node.address) - } + return node.address + } + let replies = try await sendParts( + groups.map(\.arguments), + to: targets, + carrying: groups.map { group in group.keyIndices.map { args[$0] } }, + of: args, + isWrite: spec.isWrite, + followRedirects: true, + scope: scope + ) - let combined: RedisReply - if let policy = spec.responsePolicy { - combined = RedisClusterAggregator.combine(replies, policy: policy) - } else { - combined = RedisMultiShardPlanner.scatterInKeyOrder( + guard let policy = spec.responsePolicy else { + return RedisMultiShardPlanner.scatterInKeyOrder( groups: groups, replies: replies, keyIndices: spec.keyIndices(forArgumentCount: args.count) ) } - noteShardFailures(of: args, combined: combined, replies: replies, nodes: nodes) - return combined + return RedisClusterAggregator.combine(replies, policy: policy) } private func broadcast( _ args: [Data], to nodes: [RedisClusterNode], - policy: RedisResponsePolicy?, + spec: RedisCommandSpec?, scope: RedisCommandScope ) async throws -> RedisReply { - let targets = nodes.isEmpty ? snapshotState().topology.masters : nodes + let targets = (nodes.isEmpty ? snapshotState().topology.masters : nodes).map(\.address) guard !targets.isEmpty else { throw RedisPluginError.notConnected } + let replies = try await sendParts( + Array(repeating: args, count: targets.count), + to: targets, + carrying: [], + of: args, + isWrite: spec?.isWrite ?? false, + followRedirects: false, + scope: scope + ) + return RedisClusterAggregator.combine(replies, policy: spec?.responsePolicy) + } + + /// Nothing ties the parts of a split command together, so each runs on its own node and one + /// can be refused after another has already run. A write whose parts disagree is reported + /// with what ran, because the refusing part's reply alone reads as if nothing did. Every + /// owner is known before this is called, so a part is never stranded by a slot with no owner. + private func sendParts( + _ parts: [[Data]], + to targets: [RedisNodeAddress], + carrying keys: [[Data]], + of args: [Data], + isWrite: Bool, + followRedirects: Bool, + scope: RedisCommandScope + ) async throws -> [RedisReply] { + let command = Self.commandName(of: args) + let nodes = targets.map(\.identifier) var replies: [RedisReply] = [] - replies.reserveCapacity(targets.count) - for node in targets { - replies.append(try await send(args, to: node.address, followRedirects: false, scope: scope)) + replies.reserveCapacity(parts.count) + for (part, target) in zip(parts, targets) { + do { + replies.append(try await send(part, to: target, followRedirects: followRedirects, scope: scope)) + } catch where !(error is CancellationError) { + noteShardFailures(of: command, replies: replies, nodes: targets) + guard let partial = RedisPartialClusterWrite.assemble( + command: command, isWrite: isWrite, nodes: nodes, keys: keys, + replies: replies, interruption: error + ) else { throw error } + throw notePartialWrite(partial) + } + } + noteShardFailures(of: command, replies: replies, nodes: targets) + if let partial = RedisPartialClusterWrite.assemble( + command: command, isWrite: isWrite, nodes: nodes, keys: keys, + replies: replies, interruption: nil + ) { + throw notePartialWrite(partial) } - let combined = RedisClusterAggregator.combine(replies, policy: policy) - noteShardFailures(of: args, combined: combined, replies: replies, nodes: targets.map(\.address)) - return combined + return replies } - /// The reply the user sees is the refusing shard's own, which names neither the node nor the - /// fact that the other shards ran their part, so the log keeps both. The class only, because - /// the rest of an error message can quote a key. - private func noteShardFailures( - of args: [Data], - combined: RedisReply, - replies: [RedisReply], - nodes: [RedisNodeAddress] - ) { - guard combined.isError else { return } - let name = args.first.flatMap { String(data: $0, encoding: .utf8) }?.uppercased() ?? "" + /// The reply the user sees is one shard's own, which names neither the node nor the other + /// shards, so the log keeps both. The class only, because the rest of an error message can + /// quote a key. + private func noteShardFailures(of command: String, replies: [RedisReply], nodes: [RedisNodeAddress]) { for (position, (reply, node)) in zip(replies, nodes).enumerated() { guard let message = reply.errorMessage else { continue } let errorClass = RedisConnectProbe.errorClass(of: message) - let part = "\(node.identifier), part \(position + 1) of \(replies.count)" - logger.notice("\(name, privacy: .public) failed on \(part, privacy: .public): \(errorClass, privacy: .public)") + let part = "\(node.identifier), part \(position + 1) of \(nodes.count)" + logger.notice("\(command, privacy: .public) failed on \(part, privacy: .public): \(errorClass, privacy: .public)") } } + private func notePartialWrite(_ partial: RedisPartialClusterWrite) -> RedisPartialClusterWrite { + let applied = partial.appliedParts.count + logger.notice( + "\(partial.command, privacy: .public) partly applied: \(applied, privacy: .public) of \(partial.parts.count, privacy: .public)" + ) + return partial + } + + private static func commandName(of args: [Data]) -> String { + args.first.flatMap { String(data: $0, encoding: .utf8) }?.uppercased() ?? "" + } + private func serverResolvedKeys(for args: [Data], snapshot: Snapshot) async throws -> [Data] { guard let node = snapshot.topology.orderedMasters.first else { return [] } var request: [Data] = [Data("COMMAND".utf8), Data("GETKEYS".utf8)] diff --git a/Plugins/RedisDriverPlugin/RedisClusterWriteOutcome.swift b/Plugins/RedisDriverPlugin/RedisClusterWriteOutcome.swift new file mode 100644 index 0000000000..7e16e07964 --- /dev/null +++ b/Plugins/RedisDriverPlugin/RedisClusterWriteOutcome.swift @@ -0,0 +1,137 @@ +// +// RedisClusterWriteOutcome.swift +// RedisDriverPlugin +// +// What a write a cluster split across several nodes left behind when only some of them ran it. +// +// Each part of a split write runs on its own node with nothing tying the parts together, and +// Redis has no way to take back the parts that ran. Measured on a two-master Redis 8.10.1 +// cluster with an ACL user limited to `allowed:*`: `DEL allowed:1 forbidden:1` answered with the +// refusing shard's `NOPERM` alone, while `allowed:1` was already gone. +// + +import Foundation +import TableProPluginKit + +enum RedisShardPartOutcome: Equatable, Sendable { + case ran + case refused(String) + case queued + /// The send threw, so whether this part reached the server is unknown. + case interrupted(String) + case notSent + + init(_ reply: RedisReply) { + if let message = reply.errorMessage { + self = .refused(message) + } else if reply.isQueued { + self = .queued + } else { + self = .ran + } + } + + fileprivate func failureMessage(for command: String) -> String? { + switch self { + case .refused(let message): + return "\(command): \(message)" + case .interrupted(let message): + return message + case .queued: + return RedisQueuedCommand(command: command).pluginErrorMessage + case .ran, .notSent: + return nil + } + } +} + +struct RedisShardPart: Equatable, Sendable { + let node: String + /// The keys this part carried, or none for a command sent whole to every node. + let keys: [Data] + let outcome: RedisShardPartOutcome +} + +struct RedisPartialClusterWrite: Error, Equatable { + static let listedKeyLimit = 20 + + let command: String + let parts: [RedisShardPart] + private let headline: String + + /// Part `i` answered with `replies[i]`. When `interruption` is set, the part after the last + /// reply is the one whose send threw, and every later part was never sent. Nil unless the + /// command writes and the parts disagree: a write every part ran, or none did, is reported by + /// the reply itself, and a read that one shard refused changed nothing. + static func assemble( + command: String, + isWrite: Bool, + nodes: [String], + keys: [[Data]], + replies: [RedisReply], + interruption: (any Error)? + ) -> RedisPartialClusterWrite? { + guard isWrite else { return nil } + let parts = nodes.indices.map { index in + RedisShardPart( + node: nodes[index], + keys: index < keys.count ? keys[index] : [], + outcome: outcome(at: index, replies: replies, interruption: interruption) + ) + } + guard parts.contains(where: { $0.outcome == .ran }), + let headline = parts.lazy.compactMap({ $0.outcome.failureMessage(for: command) }).first else { + return nil + } + return RedisPartialClusterWrite(command: command, parts: parts, headline: headline) + } + + private static func outcome( + at index: Int, + replies: [RedisReply], + interruption: (any Error)? + ) -> RedisShardPartOutcome { + if index < replies.count { return RedisShardPartOutcome(replies[index]) } + guard index == replies.count, let interruption else { return .notSent } + let message = (interruption as? PluginDriverError)?.pluginErrorMessage ?? interruption.localizedDescription + return .interrupted(message) + } + + var appliedParts: [RedisShardPart] { + parts.filter { $0.outcome == .ran } + } + + private var isSplitByKey: Bool { + parts.allSatisfy { !$0.keys.isEmpty } + } + + private var appliedKeyList: String { + let keys = appliedParts.flatMap(\.keys).map { RedisArgumentCodec.quote($0) } + let listed = keys.prefix(Self.listedKeyLimit).joined(separator: " ") + guard keys.count > Self.listedKeyLimit else { return listed } + return String( + format: String(localized: "%1$@ and %2$lld more"), + listed, + Int64(keys.count - Self.listedKeyLimit) + ) + } + + private var appliedNodeList: String { + ListFormatter.localizedString(byJoining: appliedParts.map(\.node)) + } +} + +extension RedisPartialClusterWrite: PluginDriverError { + var pluginErrorMessage: String { headline } + + var pluginErrorDetail: String? { + let applied = Int64(appliedParts.count) + let total = Int64(parts.count) + guard isSplitByKey else { + let template = String(localized: "%1$@ already ran on %2$lld of the %3$lld nodes it was sent to, and Redis cannot undo that. Nodes it ran on: %4$@") + return String(format: template, command, applied, total, appliedNodeList) + } + let template = String(localized: "%1$@ already ran on %2$lld of the %3$lld hash slots it was split across, and Redis cannot undo that. Keys it ran on: %4$@") + return String(format: template, command, applied, total, appliedKeyList) + } +} diff --git a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift index 811908dd0a..c071b9bcb3 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift @@ -26,6 +26,9 @@ protocol RedisCommandChannel: AnyObject, Sendable { var isConnected: Bool { get } var supportsDatabaseSelection: Bool { get } var supportsTransactions: Bool { get } + /// True when keys live on different nodes by hash slot, so one command over several keys can + /// be applied on some nodes and refused on others. + var partitionsKeyspace: Bool { get } func connect(reportingStage report: @escaping ConnectionStageReporter) async throws func disconnect() @@ -62,6 +65,7 @@ protocol RedisCommandChannel: AnyObject, Sendable { extension RedisCommandChannel { var supportsDatabaseSelection: Bool { true } var supportsTransactions: Bool { true } + var partitionsKeyspace: Bool { false } func databaseForNextCommand() -> Int { currentDatabase() } diff --git a/Plugins/RedisDriverPlugin/RedisCommandRouting.swift b/Plugins/RedisDriverPlugin/RedisCommandRouting.swift index b61631c99f..b7078d8499 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandRouting.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandRouting.swift @@ -38,6 +38,9 @@ struct RedisCommandSpec: Sendable, Equatable { let lastKey: Int let step: Int let isReadOnly: Bool + /// COMMAND's own `write` flag. A split write whose parts disagree has changed some shards and + /// not others, which a split read never has. + let isWrite: Bool let hasMovableKeys: Bool let requestPolicy: RedisRequestPolicy? let responsePolicy: RedisResponsePolicy? @@ -52,6 +55,7 @@ struct RedisCommandSpec: Sendable, Equatable { lastKey: lastKey, step: step, isReadOnly: isReadOnly || fallback.isReadOnly, + isWrite: isWrite || fallback.isWrite, hasMovableKeys: hasMovableKeys || fallback.hasMovableKeys, requestPolicy: fallback.requestPolicy, responsePolicy: fallback.responsePolicy @@ -175,6 +179,7 @@ struct RedisCommandRouting: Sendable { lastKey: fields[4].intValue ?? 0, step: fields[5].intValue ?? 0, isReadOnly: flags.contains("readonly"), + isWrite: flags.contains("write"), hasMovableKeys: flags.contains("movablekeys"), requestPolicy: request, responsePolicy: response @@ -199,13 +204,14 @@ struct RedisCommandRouting: Sendable { _ lastKey: Int, _ step: Int, readOnly: Bool = false, + write: Bool = false, movable: Bool = false, request: RedisRequestPolicy? = nil, response: RedisResponsePolicy? = nil ) -> RedisCommandSpec { RedisCommandSpec( name: name, firstKey: firstKey, lastKey: lastKey, step: step, - isReadOnly: readOnly, hasMovableKeys: movable, + isReadOnly: readOnly, isWrite: write, hasMovableKeys: movable, requestPolicy: request, responsePolicy: response ) } @@ -229,16 +235,16 @@ struct RedisCommandRouting: Sendable { "rpushx", "lpop", "rpop", "lset", "linsert", "lrem", "ltrim", "sadd", "srem", "spop", "zadd", "zrem", "zincrby", "zpopmin", "zpopmax", "xadd", "xdel", "xtrim", "setex", "psetex", "setnx", "restore"] { - add(spec(name, 1, 1, 1)) + add(spec(name, 1, 1, 1, write: true)) } for name in ["rename", "renamenx", "smove", "lmove", "rpoplpush", "copy"] { - add(spec(name, 1, 2, 1)) + add(spec(name, 1, 2, 1, write: true)) } add(spec("mget", 1, -1, 1, readOnly: true, request: .multiShard)) - add(spec("mset", 1, -1, 2, request: .multiShard, response: .allSucceeded)) - add(spec("msetnx", 1, -1, 2)) + add(spec("mset", 1, -1, 2, write: true, request: .multiShard, response: .allSucceeded)) + add(spec("msetnx", 1, -1, 2, write: true)) for name in ["del", "unlink"] { - add(spec(name, 1, -1, 1, request: .multiShard, response: .aggSum)) + add(spec(name, 1, -1, 1, write: true, request: .multiShard, response: .aggSum)) } for name in ["exists", "touch"] { add(spec(name, 1, -1, 1, readOnly: true, request: .multiShard, response: .aggSum)) @@ -247,27 +253,30 @@ struct RedisCommandRouting: Sendable { add(spec(name, 1, -1, 1, readOnly: true)) } for name in ["sunionstore", "sinterstore", "sdiffstore"] { - add(spec(name, 1, -1, 1)) + add(spec(name, 1, -1, 1, write: true)) } for name in ["zunionstore", "zinterstore"] { - add(spec(name, 1, 1, 1, movable: true)) + add(spec(name, 1, 1, 1, write: true, movable: true)) } - for name in ["eval", "evalsha", "fcall", "lmpop", "zmpop", "xreadgroup"] { + for name in ["eval", "evalsha", "fcall"] { add(spec(name, 0, 0, 0, movable: true)) } + for name in ["lmpop", "zmpop", "xreadgroup"] { + add(spec(name, 0, 0, 0, write: true, movable: true)) + } for name in ["fcall_ro", "xread", "zdiff", "zunion", "zinter", "sintercard"] { add(spec(name, 0, 0, 0, readOnly: true, movable: true)) } - add(spec("sort", 1, 1, 1, movable: true)) + add(spec("sort", 1, 1, 1, write: true, movable: true)) add(spec("sort_ro", 1, 1, 1, readOnly: true, movable: true)) for name in ["georadius", "georadiusbymember"] { - add(spec(name, 1, 1, 1, movable: true)) + add(spec(name, 1, 1, 1, write: true, movable: true)) } add(spec("keys", 0, 0, 0, readOnly: true, request: .allShards)) add(spec("dbsize", 0, 0, 0, readOnly: true, request: .allShards, response: .aggSum)) - add(spec("flushdb", 0, 0, 0, request: .allShards, response: .allSucceeded)) - add(spec("flushall", 0, 0, 0, request: .allShards, response: .allSucceeded)) + add(spec("flushdb", 0, 0, 0, write: true, request: .allShards, response: .allSucceeded)) + add(spec("flushall", 0, 0, 0, write: true, request: .allShards, response: .allSucceeded)) add(spec("info", 0, 0, 0, request: .allShards, response: .special)) add(spec("randomkey", 0, 0, 0, readOnly: true, request: .allShards, response: .special)) add(spec("scan", 0, 0, 0, readOnly: true, request: .special, response: .special)) diff --git a/Plugins/RedisDriverPlugin/RedisKeySlot.swift b/Plugins/RedisDriverPlugin/RedisKeySlot.swift index 22110fc4ea..c2127c7b12 100644 --- a/Plugins/RedisDriverPlugin/RedisKeySlot.swift +++ b/Plugins/RedisDriverPlugin/RedisKeySlot.swift @@ -32,6 +32,18 @@ enum RedisKeySlot { return keys.allSatisfy { slot(for: $0) == reference } } + /// Keys that share a slot, in the order each slot first appears, with duplicates kept. + static func groupedBySlot(_ keys: [String]) -> [[String]] { + var order: [Int] = [] + var groups: [Int: [String]] = [:] + for key in keys { + let keySlot = slot(for: key) + if groups[keySlot] == nil { order.append(keySlot) } + groups[keySlot, default: []].append(key) + } + return order.compactMap { groups[$0] } + } + private static func hashedRegion(of key: Data) -> Data { hashTag(of: key) ?? key } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 4abf1d9275..42ff3021f0 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -582,7 +582,11 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { deletedRowIndices: Set, insertedRowIndices: Set ) -> [(statement: String, parameters: [PluginCellValue])]? { - let generator = RedisStatementGenerator(namespaceName: table, columns: columns) + let generator = RedisStatementGenerator( + namespaceName: table, + columns: columns, + deleteBatching: redisConnection?.partitionsKeyspace == true ? .perHashSlot : .singleCommand + ) let statements = generator.generateStatements( from: changes, insertedRowData: insertedRowData, deletedRowIndices: deletedRowIndices, insertedRowIndices: insertedRowIndices diff --git a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift index 60b8895e2c..af79066cc7 100644 --- a/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift +++ b/Plugins/RedisDriverPlugin/RedisStatementGenerator.swift @@ -10,11 +10,22 @@ import Foundation import os import TableProPluginKit +/// How the grid's deleted keys become `DEL` statements. +enum RedisDeleteBatching: Sendable { + /// One `DEL` for every key, which a server holding the whole keyspace applies all at once. + case singleCommand + /// One `DEL` per hash slot. A cluster splits a `DEL` by slot anyway and one slot can refuse + /// after another ran, while a single-slot `DEL` is checked against every key before it runs, + /// so each statement is all or nothing and a save can say how many of them went through. + case perHashSlot +} + struct RedisStatementGenerator { private static let logger = Logger(subsystem: "com.TablePro", category: "RedisStatementGenerator") let namespaceName: String let columns: [String] + var deleteBatching: RedisDeleteBatching = .singleCommand /// Index of the "Key" column (used as primary identifier, like MongoDB's "_id") var keyColumnIndex: Int? { @@ -65,13 +76,20 @@ struct RedisStatementGenerator { } } - if !deleteKeys.isEmpty { - let keyList = deleteKeys.map { RedisArgumentCodec.quote($0) }.joined(separator: " ") - let cmd = "DEL \(keyList)" - statements.append((statement: cmd, parameters: [])) - } + return statements + deleteStatements(for: deleteKeys) + } - return statements + private func deleteStatements(for keys: [String]) -> [(statement: String, parameters: [PluginCellValue])] { + guard !keys.isEmpty else { return [] } + let batches: [[String]] + switch deleteBatching { + case .singleCommand: batches = [keys] + case .perHashSlot: batches = RedisKeySlot.groupedBySlot(keys) + } + return batches.map { batch in + let keyList = batch.map { RedisArgumentCodec.quote($0) }.joined(separator: " ") + return (statement: "DEL \(keyList)", parameters: []) + } } // MARK: - INSERT diff --git a/TableProTests/Plugins/RedisCommandRoutingTests.swift b/TableProTests/Plugins/RedisCommandRoutingTests.swift index 96288d6ccd..8742b7495e 100644 --- a/TableProTests/Plugins/RedisCommandRoutingTests.swift +++ b/TableProTests/Plugins/RedisCommandRoutingTests.swift @@ -136,6 +136,19 @@ struct RedisCommandRoutingPolicyTests { #expect(!routing.isReadOnly(args("GETEX", "k"))) } + /// Only a write can leave a cluster half changed, so this flag decides whether a split command + /// one shard refused is reported as partly applied. + @Test("Writes are recognised, and neither a read nor PING or CONFIG SET is one") + func writeClassification() { + for name in ["DEL", "UNLINK", "MSET", "FLUSHDB", "FLUSHALL", "SET", "LMPOP", "SORT"] { + #expect(routing.spec(for: args(name, "k"))?.isWrite == true, "\(name) should be a write") + } + for name in ["PING", "EXISTS", "DBSIZE", "GET", "EVAL", "MGET"] { + #expect(routing.spec(for: args(name, "k"))?.isWrite == false, "\(name) should not be a write") + } + #expect(routing.spec(for: args("CONFIG", "SET", "maxmemory", "0"))?.isWrite == false) + } + @Test("Commands whose keys only COMMAND GETKEYS knows are flagged") func movableKeys() { for name in ["EVAL", "SORT", "GEORADIUS", "LMPOP", "XREAD", "ZUNIONSTORE"] { @@ -255,6 +268,38 @@ struct RedisCommandRoutingParsingTests { #expect(routing.spec(for: args("CONFIG", "SET", "a", "b"))?.requestPolicy == .allNodes) } + @Test("Reads the write flag") + func parsesWriteFlag() throws { + let reply = RedisReply.array([ + commandEntry(name: "del", flags: ["write"], firstKey: 1, lastKey: -1, step: 1, + tips: ["request_policy:multi_shard", "response_policy:agg_sum"]), + commandEntry(name: "ping", flags: ["fast", "sentinel"], + tips: ["request_policy:all_shards", "response_policy:all_succeeded"]), + commandEntry(name: "config", subcommands: [ + commandEntry(name: "config|set", flags: ["admin", "noscript", "loading", "stale"], + tips: ["request_policy:all_nodes", "response_policy:all_succeeded"]), + ]), + ]) + let routing = try #require(RedisCommandRouting.parse(commandReply: reply)) + #expect(routing.spec(for: args("DEL", "a"))?.isWrite == true) + #expect(routing.spec(for: args("PING"))?.isWrite == false) + #expect(routing.spec(for: args("CONFIG", "SET", "a", "b"))?.isWrite == false) + } + + /// A Redis 6 entry carries its flags but no tips, so the curated policy fills in; the write + /// flag has to survive that merge from either side. + @Test("A merged entry keeps the write flag") + func mergeKeepsWriteFlag() throws { + let sixElementEntry = RedisReply.array([ + .string("flushdb"), .integer(-1), .array([.status("write")]), + .integer(0), .integer(0), .integer(0), + ]) + let routing = try #require(RedisCommandRouting.parse(commandReply: .array([sixElementEntry]))) + let spec = try #require(routing.spec(for: args("FLUSHDB"))) + #expect(spec.isWrite) + #expect(spec.requestPolicy == .allShards) + } + @Test("Reads the movablekeys flag") func parsesMovableKeys() throws { let reply = RedisReply.array([commandEntry(name: "eval", flags: ["movablekeys"])]) @@ -353,7 +398,7 @@ struct RedisCommandSpecIndexTests { private func spec(first: Int, last: Int, step: Int) -> RedisCommandSpec { RedisCommandSpec( name: "x", firstKey: first, lastKey: last, step: step, - isReadOnly: false, hasMovableKeys: false, requestPolicy: nil, responsePolicy: nil + isReadOnly: false, isWrite: false, hasMovableKeys: false, requestPolicy: nil, responsePolicy: nil ) } diff --git a/TableProTests/Plugins/RedisKeySlotTests.swift b/TableProTests/Plugins/RedisKeySlotTests.swift index 4bfa2d0926..27e36f5901 100644 --- a/TableProTests/Plugins/RedisKeySlotTests.swift +++ b/TableProTests/Plugins/RedisKeySlotTests.swift @@ -100,3 +100,22 @@ struct RedisKeySlotCrossSlotTests { #expect(RedisKeySlot.slotsAreEqual(for: [])) } } + +@Suite("Redis key slot - grouping keys by slot") +struct RedisKeySlotGroupingTests { + @Test("Keys group by slot in the order each slot first appears") + func firstSeenOrder() { + let groups = RedisKeySlot.groupedBySlot(["allowed:1", "{u}a", "forbidden:1", "{u}b"]) + #expect(groups == [["allowed:1"], ["{u}a", "{u}b"], ["forbidden:1"]]) + } + + @Test("A duplicate key stays in its group") + func keepsDuplicates() { + #expect(RedisKeySlot.groupedBySlot(["a", "a", "b"]) == [["a", "a"], ["b"]]) + } + + @Test("No keys make no groups") + func empty() { + #expect(RedisKeySlot.groupedBySlot([]).isEmpty) + } +} diff --git a/TableProTests/Plugins/RedisMultiShardPlannerTests.swift b/TableProTests/Plugins/RedisMultiShardPlannerTests.swift index 3bccf76cf2..56ae74c152 100644 --- a/TableProTests/Plugins/RedisMultiShardPlannerTests.swift +++ b/TableProTests/Plugins/RedisMultiShardPlannerTests.swift @@ -11,7 +11,7 @@ private func args(_ tokens: String...) -> [Data] { tokens.map { Data($0.utf8) } private func spec(first: Int, last: Int, step: Int, response: RedisResponsePolicy? = nil) -> RedisCommandSpec { RedisCommandSpec( name: "test", firstKey: first, lastKey: last, step: step, - isReadOnly: false, hasMovableKeys: false, requestPolicy: .multiShard, responsePolicy: response + isReadOnly: false, isWrite: false, hasMovableKeys: false, requestPolicy: .multiShard, responsePolicy: response ) } diff --git a/TableProTests/Plugins/RedisPartialClusterWriteTests.swift b/TableProTests/Plugins/RedisPartialClusterWriteTests.swift new file mode 100644 index 0000000000..80f0a041c1 --- /dev/null +++ b/TableProTests/Plugins/RedisPartialClusterWriteTests.swift @@ -0,0 +1,182 @@ +// +// RedisPartialClusterWriteTests.swift +// TableProTests +// +// A split write one shard refused used to read as if nothing ran. Measured on a two-master +// Redis 8.10.1 cluster with an ACL user limited to `allowed:*`: `DEL allowed:1 forbidden:1` +// answered `NOPERM No permissions to access a key` and `allowed:1` was already deleted. +// + +import Foundation +import TableProPluginKit +import Testing + +private struct Dropped: Error, LocalizedError { + var errorDescription: String? { "No reply from Redis" } +} + +private func keys(_ names: String...) -> [Data] { names.map { Data($0.utf8) } } + +private let refusal = "NOPERM No permissions to access a key" + +@Suite("Redis partial cluster write - when a split write counts as partly applied") +struct RedisPartialClusterWriteAssemblyTests { + private let nodes = ["127.0.0.1:6505", "127.0.0.1:6506"] + + @Test("One part ran and one was refused") + func ranAndRefused() throws { + let partial = try #require(RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: nodes, + keys: [keys("allowed:1"), keys("forbidden:1")], + replies: [.integer(1), .error(refusal)], + interruption: nil + )) + #expect(partial.parts.map(\.outcome) == [.ran, .refused(refusal)]) + #expect(partial.pluginErrorMessage == "DEL: \(refusal)") + let detail = try #require(partial.pluginErrorDetail) + #expect(detail.contains("1 of the 2 hash slots")) + #expect(detail.hasSuffix("Keys it ran on: allowed:1")) + #expect(!detail.contains("forbidden:1")) + } + + @Test("A read one shard refused changed nothing, so it is not a partial write") + func readIsNotPartial() { + let partial = RedisPartialClusterWrite.assemble( + command: "EXISTS", + isWrite: false, + nodes: nodes, + keys: [keys("allowed:1"), keys("forbidden:1")], + replies: [.integer(1), .error(refusal)], + interruption: nil + ) + #expect(partial == nil) + } + + @Test("A write every part ran is whole") + func everyPartRan() { + let partial = RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: nodes, + keys: [keys("a"), keys("b")], + replies: [.integer(1), .integer(1)], + interruption: nil + ) + #expect(partial == nil) + } + + @Test("A write no part ran changed nothing") + func noPartRan() { + let partial = RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: nodes, + keys: [keys("forbidden:1"), keys("forbidden:2")], + replies: [.error(refusal)], + interruption: nil + ) + #expect(partial == nil) + } + + @Test("A send that threw after a part ran leaves the rest unsent") + func interruption() throws { + let partial = try #require(RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: nodes + ["127.0.0.1:6507"], + keys: [keys("a"), keys("b"), keys("k1")], + replies: [.integer(1)], + interruption: Dropped() + )) + #expect(partial.parts.map(\.outcome) == [.ran, .interrupted("No reply from Redis"), .notSent]) + #expect(partial.pluginErrorMessage == "No reply from Redis") + } + + @Test("A driver error's own message is the headline, without its detail") + func driverErrorHeadline() throws { + let partial = try #require(RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: nodes, + keys: [keys("a"), keys("b")], + replies: [.integer(1)], + interruption: RedisPluginError(code: 0, message: "The cluster is not serving requests: down", detail: "x") + )) + #expect(partial.pluginErrorMessage == "The cluster is not serving requests: down") + } + + @Test("A part queued into an open block while another ran is reported as queued") + func queuedAndRan() throws { + let partial = try #require(RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: nodes, + keys: [keys("allowed:1"), keys("allowed:3")], + replies: [.integer(1), .status("QUEUED")], + interruption: nil + )) + #expect(partial.pluginErrorMessage == "Redis queued DEL instead of running it.") + #expect(partial.pluginErrorDetail?.hasSuffix("Keys it ran on: allowed:1") == true) + } +} + +@Suite("Redis partial cluster write - what the detail lists") +struct RedisPartialClusterWriteDetailTests { + @Test("A command sent whole to every node names the nodes it ran on") + func broadcastNamesNodes() throws { + let partial = try #require(RedisPartialClusterWrite.assemble( + command: "FLUSHDB", + isWrite: true, + nodes: ["127.0.0.1:6505", "127.0.0.1:6506"], + keys: [], + replies: [.error("NOPERM User limited has no permissions to run the 'flushdb' command"), .status("OK")], + interruption: nil + )) + let detail = try #require(partial.pluginErrorDetail) + #expect(detail.contains("1 of the 2 nodes")) + #expect(detail.hasSuffix("Nodes it ran on: 127.0.0.1:6506")) + } + + @Test("A long key list stops at twenty and counts the rest") + func longKeyListIsCapped() throws { + let ran = (1 ... 25).map { Data("k\($0)".utf8) } + let partial = try #require(RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: ["n1", "n2"], + keys: [ran, keys("forbidden:1")], + replies: [.integer(25), .error(refusal)], + interruption: nil + )) + let detail = try #require(partial.pluginErrorDetail) + #expect(detail.hasSuffix("k19 k20 and 5 more")) + #expect(!detail.contains("k21")) + } + + @Test("A key that needs quoting is listed the way the editor would write it") + func keysAreQuoted() throws { + let partial = try #require(RedisPartialClusterWrite.assemble( + command: "DEL", + isWrite: true, + nodes: ["n1", "n2"], + keys: [keys("with space"), keys("forbidden:1")], + replies: [.integer(1), .error(refusal)], + interruption: nil + )) + #expect(partial.pluginErrorDetail?.hasSuffix("Keys it ran on: \"with space\"") == true) + } +} + +@Suite("Redis partial cluster write - reading one shard's reply") +struct RedisShardPartOutcomeTests { + @Test("An error is a refusal, a queued acknowledgement is queued, anything else ran") + func outcomes() { + #expect(RedisShardPartOutcome(.error("ERR x")) == .refused("ERR x")) + #expect(RedisShardPartOutcome(.status("QUEUED")) == .queued) + #expect(RedisShardPartOutcome(.integer(1)) == .ran) + #expect(RedisShardPartOutcome(.status("OK")) == .ran) + #expect(RedisShardPartOutcome(.string("QUEUED")) == .ran) + } +} diff --git a/TableProTests/Plugins/RedisStatementGeneratorTests.swift b/TableProTests/Plugins/RedisStatementGeneratorTests.swift index b6c4fa3411..8c45e457ea 100644 --- a/TableProTests/Plugins/RedisStatementGeneratorTests.swift +++ b/TableProTests/Plugins/RedisStatementGeneratorTests.swift @@ -506,6 +506,50 @@ struct RedisStatementGeneratorTests { #expect(results[0].statement == "DEL key1 key2 key3") } + /// A cluster splits a DEL by slot, and one slot can refuse after another ran. Deleting a slot + /// per statement makes each one all or nothing, so a save can say how many went through. + @Test("On a partitioned keyspace each hash slot gets its own DEL") + func deletePerHashSlot() { + let gen = RedisStatementGenerator( + namespaceName: "", + columns: ["Key", "Value", "TTL"], + deleteBatching: .perHashSlot + ) + let changes = ["allowed:1", "forbidden:1", "{u}a", "{u}b"].enumerated().map { index, key in + PluginRowChange(rowIndex: index, type: .delete, cellChanges: [], originalRow: [.text(key), "v", "-1"]) + } + + let results = gen.generateStatements( + from: changes, + insertedRowData: [:], + deletedRowIndices: [0, 1, 2, 3], + insertedRowIndices: [] + ) + + #expect(results.map(\.statement) == ["DEL allowed:1", "DEL forbidden:1", "DEL {u}a {u}b"]) + } + + @Test("Per-slot deletes quote each key the way a single DEL does") + func deletePerHashSlotQuotes() { + let gen = RedisStatementGenerator( + namespaceName: "", + columns: ["Key", "Value", "TTL"], + deleteBatching: .perHashSlot + ) + let changes = ["{s} one", "{s}\"two\""].enumerated().map { index, key in + PluginRowChange(rowIndex: index, type: .delete, cellChanges: [], originalRow: [.text(key), "v", "-1"]) + } + + let results = gen.generateStatements( + from: changes, + insertedRowData: [:], + deletedRowIndices: [0, 1], + insertedRowIndices: [] + ) + + #expect(results.map(\.statement) == ["DEL \"{s} one\" \"{s}\\\"two\\\"\""]) + } + @Test("Delete not in deletedRowIndices is skipped") func deleteNotInIndices() { let gen = RedisStatementGenerator( diff --git a/project.yml b/project.yml index 2ba66e5d6d..35626cd497 100644 --- a/project.yml +++ b/project.yml @@ -633,6 +633,7 @@ targets: - Plugins/RedisDriverPlugin/RedisClusterCursor.swift - Plugins/RedisDriverPlugin/RedisClusterRedirect.swift - Plugins/RedisDriverPlugin/RedisClusterTopology.swift + - Plugins/RedisDriverPlugin/RedisClusterWriteOutcome.swift - Plugins/RedisDriverPlugin/RedisCommandChannel.swift - Plugins/RedisDriverPlugin/RedisCommandParser.swift - Plugins/RedisDriverPlugin/RedisCommandRouting.swift diff --git a/scripts/check-redis-command-routing.sh b/scripts/check-redis-command-routing.sh index 6bbac74f97..4f945f0552 100755 --- a/scripts/check-redis-command-routing.sh +++ b/scripts/check-redis-command-routing.sh @@ -100,7 +100,8 @@ for match in re.finditer(r'spec\(\s*"([^"]+)",\s*(-?\d+),\s*(-?\d+),\s*(-?\d+)([ request, response = policies(rest) curated[name] = { "firstKey": int(first), "lastKey": int(last), "step": int(step), - "readOnly": "readOnly: true" in rest, "movable": "movable: true" in rest, + "readOnly": "readOnly: true" in rest, "write": "write: true" in rest, + "movable": "movable: true" in rest, "request": request, "response": response, } @@ -127,7 +128,8 @@ for block, values in blocks: for name in re.findall(r'"([^"]+)"', block): curated.setdefault(name, { "firstKey": positions[0], "lastKey": positions[1], "step": positions[2], - "readOnly": "readOnly: true" in rest, "movable": "movable: true" in rest, + "readOnly": "readOnly: true" in rest, "write": "write: true" in rest, + "movable": "movable: true" in rest, "request": request, "response": response, }) @@ -152,7 +154,7 @@ def collect(entry, container=None): tips = [str(t) for t in (entry[7] or [])] if len(entry) > 7 else [] server[name] = { "firstKey": entry[3], "lastKey": entry[4], "step": entry[5], - "readOnly": "readonly" in flags, "movable": "movablekeys" in flags, + "readOnly": "readonly" in flags, "write": "write" in flags, "movable": "movablekeys" in flags, "request": next((t.split(":", 1)[1] for t in tips if t.startswith("request_policy:")), None), "response": next((t.split(":", 1)[1] for t in tips if t.startswith("response_policy:")), None), } @@ -173,7 +175,7 @@ for name in sorted(curated): expected = curated[name] for field, label in [ ("firstKey", "first key"), ("lastKey", "last key"), ("step", "key step"), - ("readOnly", "readonly"), ("movable", "movablekeys"), + ("readOnly", "readonly"), ("write", "write"), ("movable", "movablekeys"), ("request", "request_policy"), ("response", "response_policy"), ]: if expected[field] != actual[field]: From bf68ba3219fb2037ca715db2f2d38846192200e1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 18:50:23 +0700 Subject: [PATCH 19/30] feat(plugin-redis): browse numbered databases on a Valkey 9 cluster --- .../RedisClusterAggregator.swift | 11 + .../RedisClusterChannel.swift | 159 +++++--- .../RedisCommandChannel.swift | 4 + .../RedisCommandParser.swift | 34 +- .../RedisConnectionMode.swift | 3 - .../RedisDatabaseListing.swift | 34 +- .../RedisDatabaseTarget.swift | 12 +- .../RedisPluginConnection.swift | 8 + .../RedisPluginDriver+Operations.swift | 5 + .../RedisDriverPlugin/RedisPluginDriver.swift | 25 +- .../RedisQueuedCommandPolicy.swift | 1 + .../RedisSessionFootprint.swift | 6 + .../Core/Utilities/SQL/QueryClassifier.swift | 19 +- .../SQL/QueryClassifierHardeningTests.swift | 12 + TableProTests/Helpers/StubRedisChannel.swift | 14 +- TableProTests/Helpers/StubRedisCluster.swift | 65 ++++ .../Plugins/RedisClusterAggregatorTests.swift | 18 + .../Plugins/RedisClusterChannelTests.swift | 346 ++++++++++++++++++ .../Plugins/RedisConnectionModeTests.swift | 7 - .../Plugins/RedisDatabaseListingTests.swift | 22 ++ .../Plugins/RedisDatabaseTargetTests.swift | 22 +- .../RedisNamedDatabaseWriteTests.swift | 94 +++++ project.yml | 1 + 23 files changed, 840 insertions(+), 82 deletions(-) create mode 100644 TableProTests/Helpers/StubRedisCluster.swift create mode 100644 TableProTests/Plugins/RedisClusterChannelTests.swift create mode 100644 TableProTests/Plugins/RedisNamedDatabaseWriteTests.swift diff --git a/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift b/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift index f7e1f65518..d2ef58a445 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterAggregator.swift @@ -40,6 +40,17 @@ enum RedisClusterAggregator { } } + /// Each primary's `INFO keyspace` added up per database. Nil when any primary declined, the + /// same rule `DBSIZE` follows, because a count missing one primary is short, not zero. + static func keyspace(_ perShard: [[Int: Int]?]) -> [Int: Int]? { + var merged: [Int: Int] = [:] + for shard in perShard { + guard let shard else { return nil } + merged.merge(shard, uniquingKeysWith: +) + } + return merged + } + /// The first shard error in the order the shards were asked, else the first `+QUEUED`. static func firstNonAnswer(in replies: [RedisReply]) -> RedisReply? { replies.first(where: \.isError) ?? replies.first(where: \.isQueued) diff --git a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift index 1503306f6f..c7d634f60e 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift @@ -9,6 +9,10 @@ // errors. A MOVED or ASK arrives as an ordinary error reply with the context still usable, so a // redirect costs a re-dispatch and never a reconnect. // +// Redis Cluster serves database 0 alone. Valkey 9 serves numbered databases in cluster mode when +// `cluster-databases` is above 1, each node keeping its own selected database, so the channel +// owns where the whole cluster belongs and every node moves there before its next command. +// import Foundation import os @@ -17,6 +21,16 @@ import TableProPluginKit private let logger = Logger(subsystem: "com.TablePro.RedisDriver", category: "RedisClusterChannel") +/// One node's connection, as the cluster channel drives it. +protocol RedisClusterNodeConnection: RedisCommandChannel { + func adoptRouting(_ newRouting: RedisCommandRouting) + /// Where the node's session belongs, which the cluster sets for every node at once. The node + /// moves there before its next command that is not part of a visit. + func adoptHomeDatabase(_ index: Int) +} + +typealias RedisClusterNodeFactory = @Sendable (RedisNodeAddress) -> any RedisClusterNodeConnection + final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { private enum Limits { static let maxRedirects = 5 @@ -26,31 +40,21 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { } private let seeds: [RedisNodeAddress] - private let username: String? - private let password: String? - private let sslConfig: SSLConfiguration - private let connectTimeout: TimeInterval + private let openNode: RedisClusterNodeFactory private let lock = NSLock() - private var connections: [String: RedisPluginConnection] = [:] + private var connections: [String: any RedisClusterNodeConnection] = [:] private var topology = RedisClusterTopology(shards: []) private var routing = RedisCommandRouting() private var redirectsSinceReload = 0 private var isShuttingDown = false private var cachedVersion: String? + private var home = 0 + private var servedDatabases = 1 - init( - seeds: [RedisNodeAddress], - username: String?, - password: String?, - sslConfig: SSLConfiguration, - connectTimeout: TimeInterval = 5 - ) { + init(seeds: [RedisNodeAddress], openNode: @escaping RedisClusterNodeFactory) { self.seeds = seeds - self.username = username - self.password = password - self.sslConfig = sslConfig - self.connectTimeout = connectTimeout + self.openNode = openNode } var isConnected: Bool { @@ -59,7 +63,13 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { return !connections.isEmpty } - var supportsDatabaseSelection: Bool { false } + var supportsDatabaseSelection: Bool { servedDatabaseCount > 1 } + + private var servedDatabaseCount: Int { + lock.lock() + defer { lock.unlock() } + return servedDatabases + } /// Redis refuses MULTI's queued commands with MOVED whenever they hash outside the node the /// transaction opened on, and the driver has to pick that node before it has seen a key. On a @@ -96,6 +106,8 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { let open = Array(connections.values) connections.removeAll() topology = RedisClusterTopology(shards: []) + home = 0 + servedDatabases = 1 lock.unlock() open.forEach { $0.disconnect() } } @@ -113,15 +125,56 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { return cachedVersion } - func currentDatabase() -> Int { 0 } + func currentDatabase() -> Int { + lock.lock() + defer { lock.unlock() } + return home + } + /// The first primary answers for the cluster, since every primary serves the same databases, + /// and the rest follow before their next command. A block open on it holds the SELECT back: + /// queued there it would move that one primary when EXEC runs and leave the others behind. func selectDatabase(_ index: Int, scope: RedisCommandScope) async throws { - guard index == 0 else { - throw RedisPluginError( - code: 0, - message: String(localized: "Redis Cluster serves database 0 only, so it cannot switch databases.") - ) + guard supportsDatabaseSelection else { + guard index == 0 else { throw Self.singleDatabaseRefusal } + return + } + guard let primary = snapshotState().topology.orderedMasters.first else { + throw RedisPluginError.notConnected + } + try await connection(to: primary.address).selectDatabase(index, scope: .outsideBlock) + adoptHome(index) + } + + /// Every node reads the visit before each command it sends, so there is nothing to move here. + func visitDatabase(_ index: Int) async throws { + guard supportsDatabaseSelection || index == 0 else { throw Self.singleDatabaseRefusal } + } + + func reportedDatabaseCount() async throws -> Int? { + servedDatabaseCount + } + + /// `INFO keyspace` describes the node that answers it, so each primary's is read and added up. + func keyCountsByDatabase() async throws -> [Int: Int]? { + guard supportsDatabaseSelection else { return try await databaseZeroKeyCounts() } + var perPrimary: [[Int: Int]?] = [] + for primary in snapshotState().topology.orderedMasters { + perPrimary.append(try await connection(to: primary.address).keyspaceKeyCounts()) } + return RedisClusterAggregator.keyspace(perPrimary) + } + + private func adoptHome(_ index: Int) { + lock.lock() + home = index + lock.unlock() + } + + private func adoptServedDatabases(_ count: Int) { + lock.lock() + servedDatabases = count + lock.unlock() } // MARK: - Command dispatch @@ -506,7 +559,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { lock.unlock() } - private func adoptRoutingTable(_ table: RedisCommandRouting) -> [RedisPluginConnection] { + private func adoptRoutingTable(_ table: RedisCommandRouting) -> [any RedisClusterNodeConnection] { lock.lock() routing = table let open = Array(connections.values) @@ -527,21 +580,20 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { ?? RedisClusterRedirect.parseEndpoint(identifier, fallbackHost: identifier) } - private func connection(to address: RedisNodeAddress) async throws -> RedisPluginConnection { + /// Every node is handed the cluster's database each time it is handed out, so a node that has + /// not run a command since the cluster moved, or one opened afterwards, catches up before it + /// sends anything. Connecting starts a session on database 0, so the database follows it. + private func connection(to address: RedisNodeAddress) async throws -> any RedisClusterNodeConnection { let existing = try reusableConnection(to: address) - if let connection = existing.connection { return connection } - - let opened = RedisPluginConnection( - host: address.host, - port: address.port, - username: username, - password: password, - database: 0, - sslConfig: sslConfig, - connectTimeout: connectTimeout - ) + if let connection = existing.connection { + connection.adoptHomeDatabase(existing.home) + return connection + } + + let opened = openNode(address) opened.adoptRouting(existing.routing) try await opened.connect() + opened.adoptHomeDatabase(existing.home) guard let replaced = store(opened, at: address) else { opened.disconnect() @@ -553,20 +605,20 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { private func reusableConnection( to address: RedisNodeAddress - ) throws -> (connection: RedisPluginConnection?, routing: RedisCommandRouting) { + ) throws -> (connection: (any RedisClusterNodeConnection)?, routing: RedisCommandRouting, home: Int) { lock.lock() defer { lock.unlock() } guard !isShuttingDown else { throw RedisPluginError.notConnected } if let existing = connections[address.identifier], existing.isConnected { - return (existing, routing) + return (existing, routing, home) } - return (nil, routing) + return (nil, routing, home) } private func store( - _ connection: RedisPluginConnection, + _ connection: any RedisClusterNodeConnection, at address: RedisNodeAddress - ) -> (previous: RedisPluginConnection?, Void)? { + ) -> (previous: (any RedisClusterNodeConnection)?, Void)? { lock.lock() defer { lock.unlock() } guard !isShuttingDown else { return nil } @@ -621,6 +673,24 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { let info = (try? await connection.executeCommand(["INFO", "server"], scope: .outsideBlock))?.stringValue adoptVersion(info.flatMap(RedisServerInfo.version(from:))) } + + let served = await databasesServed(by: discovered.orderedMasters) + adoptServedDatabases(served) + logger.info("Cluster serves \(served, privacy: .public) database(s)") + } + + /// Measured on Valkey 9.1.2, `cluster-databases` answers the count each node serves and cannot + /// change at runtime, while Redis 8.10.1 answers an empty list for a setting it does not have. + /// The fewest any primary serves is the count, because a database one primary lacks cannot + /// hold the keys that hash to it. A primary that declines CONFIG says nothing either way. + private func databasesServed(by primaries: [RedisClusterNode]) async -> Int { + var replies: [RedisReply?] = [] + for primary in primaries { + let reply = try? await connection(to: primary.address) + .runMetadataRead(["CONFIG", "GET", "cluster-databases"]) + replies.append(reply) + } + return RedisDatabaseCount.servedByCluster(primaryReplies: replies) } /// One COMMAND at connect rather than a lookup per unknown command. The full answer is about @@ -638,6 +708,13 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { // MARK: - Messages + private static var singleDatabaseRefusal: RedisPluginError { + RedisPluginError( + code: 0, + message: String(localized: "This cluster serves database 0 only, so it cannot switch databases.") + ) + } + private static func crossSlotMessage(for args: [Data]) -> String { let name = args.first.flatMap { String(data: $0, encoding: .utf8) }?.uppercased() ?? "This command" let template = String( diff --git a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift index c071b9bcb3..7165dbae0a 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandChannel.swift @@ -43,6 +43,10 @@ protocol RedisCommandChannel: AnyObject, Sendable { func homeDatabase() -> Int /// Moves the session for one read the app makes, without moving where it belongs. func visitDatabase(_ index: Int) async throws + /// How many numbered databases the server says it has, or nil when it would not say. + func reportedDatabaseCount() async throws -> Int? + /// Keys per database, or nil when the server declined to count them. + func keyCountsByDatabase() async throws -> [Int: Int]? func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] diff --git a/Plugins/RedisDriverPlugin/RedisCommandParser.swift b/Plugins/RedisDriverPlugin/RedisCommandParser.swift index 3311c7b077..36ba3fc631 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandParser.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandParser.swift @@ -69,6 +69,10 @@ enum RedisOperation { case multi case exec case discard + + /// One command run on the database it names, after which the session goes back to the one it + /// belongs on. + indirect case inDatabase(database: Int, operation: RedisOperation) } /// Options for SET command @@ -141,11 +145,15 @@ struct RedisCommandParser { guard let split = RedisArgumentCodec.split(trimmed) else { throw RedisParseError.invalidArgument(String(localized: "unbalanced quotes")) } - let tokens = split.map { RedisArgument($0) } + return try parse(tokens: split.map { RedisArgument($0) }) + } + + private static func parse(tokens: [RedisArgument]) throws -> RedisOperation { guard let first = tokens.first else { throw RedisParseError.emptySyntax } let command = first.text.uppercased() let args = Array(tokens.dropFirst()) + if command == "DB" { return try parseInDatabase(args) } if let typedCount = typedArgumentCount[command], args.count > typedCount { return .command(args: tokens) @@ -201,6 +209,20 @@ struct RedisCommandParser { } } + /// `DB` names the database one command runs on, the way `KEYBROWSE DB` names the one a browse + /// reads, and leaves where the session belongs alone. A grid save on a cluster writes this way: + /// with no `MULTI` across shards, a `SELECT` sent ahead of the writes stayed in force when one + /// of them failed, and every command after it ran on the row's database. + private static func parseInDatabase(_ args: [RedisArgument]) throws -> RedisOperation { + guard let indexArgument = args.first, args.count > 1 else { + throw RedisParseError.missingArgument(String(localized: "DB needs a database index and a command")) + } + return .inDatabase( + database: try databaseIndex(indexArgument), + operation: try parse(tokens: Array(args.dropFirst())) + ) + } + /// `DB` names the database the read reaches whichever database the session is on: a refresh, /// a later page and an export all read the database the row names rather than the one the /// session last moved to, and the key tree lists the database the sidebar shows. @@ -212,12 +234,16 @@ struct RedisCommandParser { String(format: String(localized: "%@ DB requires a database index"), command) ) } - guard let database = RedisDatabaseIndex.parse(args[index + 1].text), database >= 0 else { + return try databaseIndex(args[index + 1]) + } + + private static func databaseIndex(_ argument: RedisArgument) throws -> Int { + guard let index = RedisDatabaseIndex.parse(argument.text), index >= 0 else { throw RedisParseError.invalidArgument( - String(format: String(localized: "%@ is not a Redis database index."), args[index + 1].text) + String(format: String(localized: "%@ is not a Redis database index."), argument.text) ) } - return database + return index } private static func parseKeyBrowse(_ args: [RedisArgument]) throws -> RedisOperation { diff --git a/Plugins/RedisDriverPlugin/RedisConnectionMode.swift b/Plugins/RedisDriverPlugin/RedisConnectionMode.swift index 14f816013d..94221bb26b 100644 --- a/Plugins/RedisDriverPlugin/RedisConnectionMode.swift +++ b/Plugins/RedisDriverPlugin/RedisConnectionMode.swift @@ -22,9 +22,6 @@ enum RedisConnectionMode: String, Sendable, CaseIterable { } var usesHostList: Bool { self != .standalone } - - /// Redis Cluster serves database 0 only, and refuses SELECT with any other index. - var supportsDatabaseSelection: Bool { self != .cluster } } enum RedisSentinelFieldKey { diff --git a/Plugins/RedisDriverPlugin/RedisDatabaseListing.swift b/Plugins/RedisDriverPlugin/RedisDatabaseListing.swift index 03136e716c..c4b618ec68 100644 --- a/Plugins/RedisDriverPlugin/RedisDatabaseListing.swift +++ b/Plugins/RedisDriverPlugin/RedisDatabaseListing.swift @@ -28,6 +28,14 @@ enum RedisDatabaseCount { let highest = known.filter { (0 ..< limit).contains($0) }.max() ?? 0 return max(assumed, highest + 1) } + + /// The databases a cluster serves, from each primary's `CONFIG GET cluster-databases`: the + /// fewest any primary reports, since a database one primary lacks cannot hold the keys that + /// hash to it. Redis answers an empty list and a declined read answers nil, neither of which + /// says anything, so a cluster no primary vouches for serves database 0 alone. + static func servedByCluster(primaryReplies: [RedisReply?]) -> Int { + primaryReplies.compactMap { $0.flatMap(reported(by:)) }.min() ?? 1 + } } struct RedisDatabaseListing: Equatable, Sendable { @@ -52,8 +60,7 @@ extension RedisCommandChannel { keyCounts: includingKeyCounts ? try await keyCountsByDatabase() : nil ) } - let reported = try await runMetadataRead(["CONFIG", "GET", "databases"]) - .flatMap(RedisDatabaseCount.reported(by:)) + let reported = try await reportedDatabaseCount() let keyCounts = includingKeyCounts || reported == nil ? try await keyCountsByDatabase() : nil let count = RedisDatabaseCount.resolve( reported: reported, @@ -63,13 +70,24 @@ extension RedisCommandChannel { return RedisDatabaseListing(databaseCount: count, keyCounts: includingKeyCounts ? keyCounts : nil) } - /// Nil when the server declines `INFO`, which an ACL user outside `@dangerous` is. A cluster - /// answers `INFO` from one master, so its single keyspace is counted with `DBSIZE`, which - /// every master answers and which is nil when any of them declines. + func reportedDatabaseCount() async throws -> Int? { + try await runMetadataRead(["CONFIG", "GET", "databases"]).flatMap(RedisDatabaseCount.reported(by:)) + } + + /// Nil when the server declines, which an ACL user outside `@dangerous` is for `INFO`. A + /// server with one database counts it with `DBSIZE`, which a cluster sends to every primary + /// and adds up, and which is nil when any of them declines. func keyCountsByDatabase() async throws -> [Int: Int]? { - guard supportsDatabaseSelection else { - return try await runMetadataRead(["DBSIZE"])?.intValue.map { [0: $0] } - } + guard supportsDatabaseSelection else { return try await databaseZeroKeyCounts() } + return try await keyspaceKeyCounts() + } + + func databaseZeroKeyCounts() async throws -> [Int: Int]? { + try await runMetadataRead(["DBSIZE"])?.intValue.map { [0: $0] } + } + + /// `INFO keyspace` describes the server that answers it, one line per database holding keys. + func keyspaceKeyCounts() async throws -> [Int: Int]? { guard let reply = try await runMetadataRead(["INFO", "keyspace"]) else { return nil } return RedisServerInfo.keyspace(from: reply.stringValue ?? "") } diff --git a/Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift b/Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift index 705cc16795..903017ae22 100644 --- a/Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift +++ b/Plugins/RedisDriverPlugin/RedisDatabaseTarget.swift @@ -20,8 +20,18 @@ enum RedisDatabaseTarget { /// A grid's writes belong to the database its rows came from, which the session is not on /// when the user moved it elsewhere. Run inside the save's `MULTI`, the SELECTs are queued /// with the writes and applied together by `EXEC`, which leaves the session where it was. - static func addressing(_ statements: [Statement], toDatabase index: Int?, from home: Int) -> [Statement] { + /// Without one, as on a cluster, a SELECT sent first stays in force when a write after it + /// fails, so each write names its database and the session never leaves where it belongs. + static func addressing( + _ statements: [Statement], + toDatabase index: Int?, + from home: Int, + insideTransaction: Bool + ) -> [Statement] { guard let index, index != home, !statements.isEmpty else { return statements } + guard insideTransaction else { + return statements.map { (statement: "DB \(index) \($0.statement)", parameters: $0.parameters) } + } return [(statement: "SELECT \(index)", parameters: [])] + statements + [(statement: "SELECT \(home)", parameters: [])] diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index bbcf9c957e..12f8a0b5b4 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -62,6 +62,12 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { routingLock.unlock() } + func adoptHomeDatabase(_ index: Int) { + stateLock.lock() + _database.rehomed(index) + stateLock.unlock() + } + private let stateLock = NSLock() private let cancellationGate = PluginQueryCancellationGate() private var _isConnected: Bool = false @@ -376,6 +382,8 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { } } +extension RedisPluginConnection: RedisClusterNodeConnection {} + // MARK: - Synchronous Helpers (must be called on the serial queue) #if canImport(CRedis) diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift index 7e5d10ee30..2b54d03c66 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver+Operations.swift @@ -70,6 +70,11 @@ extension RedisPluginDriver { case .ping, .info, .dbsize, .flushdb, .select, .configGet, .configSet, .command, .multi, .exec, .discard: return try await executeServerOperation(operation, connection: conn, startTime: startTime) + + case .inDatabase(let database, let operation): + return try await conn.withDatabase(database) { + try await runOperation(operation, connection: conn, startTime: startTime) + } } } diff --git a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift index 42ff3021f0..1c5f536550 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginDriver.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginDriver.swift @@ -88,9 +88,7 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { private func makeChannel(for mode: RedisConnectionMode) throws -> any RedisCommandChannel { let username = config.username.isEmpty ? nil : config.username let password = config.password.isEmpty ? nil : config.password - let database = mode.supportsDatabaseSelection - ? RedisDatabaseIndex.resolve(additionalFields: config.additionalFields, database: config.database) - : 0 + let database = RedisDatabaseIndex.resolve(additionalFields: config.additionalFields, database: config.database) switch mode { case .standalone: @@ -127,12 +125,18 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { config.additionalFields[RedisClusterFieldKey.hosts] ?? "", defaultPort: RedisClusterFieldKey.defaultPort ) - return RedisClusterChannel( - seeds: seeds, - username: username, - password: password, - sslConfig: config.ssl - ) + let sslConfig = config.ssl + return RedisClusterChannel(seeds: seeds) { address in + RedisPluginConnection( + host: address.host, + port: address.port, + username: username, + password: password, + database: 0, + sslConfig: sslConfig, + connectTimeout: 5 + ) + } } } @@ -595,7 +599,8 @@ final class RedisPluginDriver: PluginDatabaseDriver, @unchecked Sendable { return RedisDatabaseTarget.addressing( statements, toDatabase: RedisDatabaseIndex.parse(table), - from: conn.homeDatabase() + from: conn.homeDatabase(), + insideTransaction: conn.supportsTransactions ) } } diff --git a/Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift b/Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift index eb465696d2..8207c0f6ce 100644 --- a/Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift +++ b/Plugins/RedisDriverPlugin/RedisQueuedCommandPolicy.swift @@ -28,6 +28,7 @@ extension RedisOperation { var queuedCommandAnswer: RedisQueuedCommandAnswer { switch self { case .keyBrowse, .keyTree: return .refuse + case .inDatabase(_, let operation): return operation.queuedCommandAnswer default: return .reportQueued } } diff --git a/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift b/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift index 51deac9740..b3bd5b17eb 100644 --- a/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift +++ b/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift @@ -143,6 +143,12 @@ struct RedisSessionDatabase: Equatable, Sendable { current = index } + /// Where the session belongs moved without a command on this session: a cluster moves every + /// node at once, and each goes there on its next command. + mutating func rehomed(_ index: Int) { + home = index + } + /// The database a command has to move to before it runs, or nil when the session is already /// there: the one being visited for a command that is part of a visit, home for any other. func databaseToMoveTo(visiting: Int?) -> Int? { diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index c296b8ac9c..c65cc28245 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -706,14 +706,15 @@ private extension QueryClassifier { databaseType: DatabaseType ) -> QueryClassification? { guard databaseType == .redis else { return nil } - let command = trimmed.prefix { !$0.isWhitespace }.uppercased() + let statement = redisCommandPastDatabasePrefix(trimmed) + let command = statement.prefix { !$0.isWhitespace }.uppercased() guard !command.isEmpty else { return .safe } let touchesUnsafeSurface = redisCodeExecutionCommands.contains(command) || redisFilesystemCommands.contains(command) if command == "CONFIG" { - let rest = trimmed.dropFirst(command.count).trimmingCharacters(in: .whitespaces).uppercased() + let rest = statement.dropFirst(command.count).trimmingCharacters(in: .whitespaces).uppercased() let tier: QueryTier = rest.hasPrefix("GET") ? .safe : .destructive return QueryClassification(tier: tier, reachesFilesystemOrExecutesCode: false) } @@ -732,6 +733,20 @@ private extension QueryClassifier { return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: touchesUnsafeSurface) } + /// `DB ` runs the command on the database it names, so the command decides + /// the tier: read as the bare `DB`, `DB 0 FLUSHDB` would pass as an ordinary write. A prefix + /// with nothing after its index is left whole, which classifies as a write. + static func redisCommandPastDatabasePrefix(_ statement: String) -> Substring { + var rest = Substring(statement) + while rest.prefix(while: { !$0.isWhitespace }).uppercased() == "DB" { + let afterKeyword: Substring = rest.dropFirst(2).drop(while: \.isWhitespace) + let afterIndex: Substring = afterKeyword.drop(while: { !$0.isWhitespace }).drop(while: \.isWhitespace) + guard !afterIndex.isEmpty else { return rest } + rest = afterIndex + } + return rest + } + static let mongoReadMethods: Set = [ "find", "findone", "aggregate", "count", "countdocuments", "estimateddocumentcount", "distinct", "explain", "getindexes", "listindexes", "listcollections", "getcollectionnames", diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift index 704293ee76..7d6dcda092 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift @@ -419,6 +419,18 @@ struct QueryClassifierNonSqlTests { #expect(QueryClassifier.classifyTier("CONFIG SET maxmemory 100", databaseType: .redis) == .destructive) } + /// Read as the bare word `DB`, `DB 0 FLUSHDB` passed as an ordinary write and skipped the + /// confirmation a destructive statement asks for. + @Test("A Redis DB prefix is classified by the command it wraps") + func redisDatabasePrefix() { + #expect(QueryClassifier.classifyTier("DB 0 FLUSHDB", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("db db2 CONFIG SET maxmemory 1", databaseType: .redis) == .destructive) + #expect(!QueryClassifier.isWriteQuery("DB 3 GET key", databaseType: .redis)) + #expect(QueryClassifier.isWriteQuery("DB 3 SET key value", databaseType: .redis)) + #expect(QueryClassifier.reachesFilesystemOrExecutesCode("DB 1 EVAL \"return 1\" 0", databaseType: .redis)) + #expect(QueryClassifier.isWriteQuery("DB 3", databaseType: .redis)) + } + @Test("etcd verbs separate reads, writes, deletes and snapshots") func etcdTiers() { #expect(!QueryClassifier.isWriteQuery("get /keys", databaseType: .etcd)) diff --git a/TableProTests/Helpers/StubRedisChannel.swift b/TableProTests/Helpers/StubRedisChannel.swift index fed0376d6f..72b3421046 100644 --- a/TableProTests/Helpers/StubRedisChannel.swift +++ b/TableProTests/Helpers/StubRedisChannel.swift @@ -11,7 +11,8 @@ import Foundation import TableProPluginKit /// Driven by one task at a time, so the outcomes are handed out in order with no synchronisation. -final class StubRedisChannel: RedisCommandChannel, @unchecked Sendable { +/// It also stands in for one node of a cluster, which hands it the cluster's database. +final class StubRedisChannel: RedisClusterNodeConnection, @unchecked Sendable { private var outcomes: [Result] private(set) var sentCommands: [[String]] = [] private(set) var sentScopes: [RedisCommandScope] = [] @@ -51,6 +52,17 @@ final class StubRedisChannel: RedisCommandChannel, @unchecked Sendable { try moveSession(to: index, scope: .outsideBlock) { $0.visited(index) } } + func adoptRouting(_ newRouting: RedisCommandRouting) {} + + func adoptHomeDatabase(_ index: Int) { + sessionDatabase.rehomed(index) + } + + func forgetSentCommands() { + sentCommands = [] + sentScopes = [] + } + /// Mirrors the hiredis connection: a SELECT queued in an open block moves nothing yet and /// answers queued, and a move records itself only once the server accepted it. private func moveSession( diff --git a/TableProTests/Helpers/StubRedisCluster.swift b/TableProTests/Helpers/StubRedisCluster.swift new file mode 100644 index 0000000000..addbfb9d2e --- /dev/null +++ b/TableProTests/Helpers/StubRedisCluster.swift @@ -0,0 +1,65 @@ +// +// StubRedisCluster.swift +// TableProTests +// +// The real cluster channel over two scripted stub primaries, for driving its routing, fan-out +// and database logic without hiredis or a server. The first primary owns slots 0-8191 and the +// second 8192-16383, the way CLUSTER SLOTS reports a two-master cluster. +// + +import Foundation +import TableProPluginKit + +struct StubRedisCluster { + static let firstAddress = RedisNodeAddress(host: "127.0.0.1", port: 7_000) + static let secondAddress = RedisNodeAddress(host: "127.0.0.1", port: 7_001) + + static let bothPrimaries = RedisReply.array([ + .array([.integer(0), .integer(8_191), .array([.string("127.0.0.1"), .integer(7_000), .string("node-a")])]), + .array([.integer(8_192), .integer(16_383), .array([.string("127.0.0.1"), .integer(7_001), .string("node-b")])]), + ]) + + /// What Redis OSS answers for a setting it does not have. + static let noSuchSetting = RedisReply.array([]) + static let commandRefused = RedisReply.error("NOPERM User app has no permissions to run the 'command' command") + + static func servedDatabases(_ count: Int) -> RedisReply { + .array([.string("cluster-databases"), .string(String(count))]) + } + + let channel: RedisClusterChannel + let first: StubRedisChannel + let second: StubRedisChannel + + /// Connects through the seed at 7000, which answers CLUSTER SHARDS with an error, CLUSTER SLOTS + /// with `slots`, COMMAND with `command` and INFO server, and then both primaries answer + /// CONFIG GET cluster-databases. The replies after those are the ones each test scripts. + static func connect( + slots: RedisReply = bothPrimaries, + command: RedisReply = commandRefused, + clusterDatabases: (first: RedisReply, second: RedisReply) = (noSuchSetting, noSuchSetting), + first firstReplies: [Result] = [], + second secondReplies: [Result] = [] + ) async throws -> StubRedisCluster { + let connectScript: [RedisReply] = [ + .error("ERR unknown subcommand 'SHARDS'"), + slots, + command, + .string("# Server\r\nredis_version:8.10.1\r\nredis_mode:cluster\r\n"), + clusterDatabases.first, + ] + let first = StubRedisChannel(outcomes: connectScript.map { .success($0) } + firstReplies) + let second = StubRedisChannel(outcomes: [.success(clusterDatabases.second)] + secondReplies) + let nodes: [String: StubRedisChannel] = [ + firstAddress.identifier: first, + secondAddress.identifier: second, + ] + let channel = RedisClusterChannel(seeds: [firstAddress]) { address in + nodes[address.identifier] ?? StubRedisChannel([]) + } + try await channel.connect() + first.forgetSentCommands() + second.forgetSentCommands() + return StubRedisCluster(channel: channel, first: first, second: second) + } +} diff --git a/TableProTests/Plugins/RedisClusterAggregatorTests.swift b/TableProTests/Plugins/RedisClusterAggregatorTests.swift index bea4a7349c..6d005d1044 100644 --- a/TableProTests/Plugins/RedisClusterAggregatorTests.swift +++ b/TableProTests/Plugins/RedisClusterAggregatorTests.swift @@ -13,6 +13,24 @@ private func intValue(_ reply: RedisReply) -> Int64? { return value } +@Suite("Redis cluster aggregation - keyspace across primaries") +struct RedisClusterAggregatorKeyspaceTests { + @Test("Each database's key counts add up across the primaries") + func sumsPerDatabase() { + #expect(RedisClusterAggregator.keyspace([[0: 2, 3: 1], [3: 4]]) == [0: 2, 3: 5]) + } + + @Test("A primary with no keys adds nothing") + func emptyPrimaryAddsNothing() { + #expect(RedisClusterAggregator.keyspace([[0: 2], [:]]) == [0: 2]) + } + + @Test("A primary that declined leaves every count unknown") + func declinedPrimaryIsUnknown() { + #expect(RedisClusterAggregator.keyspace([[0: 2], nil]) == nil) + } +} + @Suite("Redis cluster aggregation - numeric policies") struct RedisClusterAggregatorNumericTests { @Test("agg_sum adds every shard's count, which is what DBSIZE needs") diff --git a/TableProTests/Plugins/RedisClusterChannelTests.swift b/TableProTests/Plugins/RedisClusterChannelTests.swift new file mode 100644 index 0000000000..56e7487d5a --- /dev/null +++ b/TableProTests/Plugins/RedisClusterChannelTests.swift @@ -0,0 +1,346 @@ +// +// RedisClusterChannelTests.swift +// TableProTests +// +// The real cluster channel over two scripted primaries: 127.0.0.1:7000 owns slots 0-8191 and +// 127.0.0.1:7001 owns 8192-16383. Key `b` hashes to slot 3300 and `a` to 15495; `forbidden:1` +// to 5435 and `allowed:1` to 8225, which is how the ACL cases split across the two. +// + +import Foundation +import TableProPluginKit +import Testing + +private func entry( + _ name: String, + flags: [String] = [], + tips: [String] = [], + subcommands: [RedisReply] = [] +) -> RedisReply { + .array([ + .string(name), .integer(-1), .array(flags.map { RedisReply.status($0) }), + .integer(0), .integer(0), .integer(0), + .array([]), .array(tips.map { RedisReply.string($0) }), .array([]), .array(subcommands), + ]) +} + +private let keyRefusal = "NOPERM No permissions to access a key" + +@Suite("Redis cluster channel - how far a command goes") +struct RedisClusterDispatchTests { + @Test("CONFIG SET goes to every node") + func configSetReachesEveryNode() async throws { + let cluster = try await StubRedisCluster.connect( + first: [.success(.status("OK"))], + second: [.success(.status("OK"))] + ) + let reply = try await cluster.channel.executeCommand(["CONFIG", "SET", "maxmemory", "0"], scope: .session) + #expect(reply.stringValue == "OK") + #expect(cluster.first.sentCommands == [["CONFIG", "SET", "maxmemory", "0"]]) + #expect(cluster.second.sentCommands == [["CONFIG", "SET", "maxmemory", "0"]]) + } + + @Test("DBSIZE goes to every primary and the counts add up") + func dbsizeSums() async throws { + let cluster = try await StubRedisCluster.connect(first: [.success(.integer(2))], second: [.success(.integer(3))]) + #expect(try await cluster.channel.executeCommand(["DBSIZE"], scope: .session).intValue == 5) + } + + /// Before the parser keyed a subcommand by the name Redis reports, `function|load` was filed + /// as `function|function|load`, so FUNCTION LOAD reached one primary and the library was + /// missing on the rest. + @Test("A subcommand the server tips all_shards reaches every primary") + func serverTippedSubcommandFansOut() async throws { + let command = RedisReply.array([ + entry("function", subcommands: [ + entry("function|load", flags: ["write", "denyoom", "noscript"], + tips: ["request_policy:all_shards", "response_policy:all_succeeded"]), + ]), + ]) + let cluster = try await StubRedisCluster.connect( + command: command, + first: [.success(.string("lib"))], + second: [.success(.string("lib"))] + ) + let reply = try await cluster.channel.executeCommand(["FUNCTION", "LOAD", "#!lua name=lib"], scope: .session) + #expect(reply.stringValue == "lib") + #expect(cluster.first.sentCommands == [["FUNCTION", "LOAD", "#!lua name=lib"]]) + #expect(cluster.second.sentCommands == [["FUNCTION", "LOAD", "#!lua name=lib"]]) + } + + @Test("A command tipped all_nodes with a special response goes to one node") + func specialResponseGoesToOneNode() async throws { + let command = RedisReply.array([ + entry("latency", subcommands: [ + entry("latency|doctor", flags: ["admin", "noscript", "loading", "stale"], + tips: ["nondeterministic_output", "request_policy:all_nodes", "response_policy:special"]), + ]), + ]) + let cluster = try await StubRedisCluster.connect(command: command, first: [.success(.string("report"))]) + let reply = try await cluster.channel.executeCommand(["LATENCY", "DOCTOR"], scope: .session) + #expect(reply.stringValue == "report") + #expect(cluster.first.sentCommands == [["LATENCY", "DOCTOR"]]) + #expect(cluster.second.sentCommands.isEmpty) + } + + @Test("A multi-key DEL is split by slot and the counts add up") + func delSplitsBySlot() async throws { + let cluster = try await StubRedisCluster.connect(first: [.success(.integer(1))], second: [.success(.integer(1))]) + #expect(try await cluster.channel.executeCommand(["DEL", "a", "b"], scope: .session).intValue == 2) + #expect(cluster.second.sentCommands == [["DEL", "a"]]) + #expect(cluster.first.sentCommands == [["DEL", "b"]]) + } +} + +@Suite("Redis cluster channel - a split write only some shards applied") +struct RedisClusterPartialWriteTests { + @Test("A split DEL one shard refused names the keys the other already deleted") + func refusedPart() async throws { + let cluster = try await StubRedisCluster.connect( + first: [.success(.error(keyRefusal))], + second: [.success(.integer(1))] + ) + do { + _ = try await cluster.channel.executeCommand(["DEL", "allowed:1", "forbidden:1"], scope: .session) + Issue.record("expected a partial write") + } catch let partial as RedisPartialClusterWrite { + #expect(partial.parts == [ + RedisShardPart(node: "127.0.0.1:7001", keys: [Data("allowed:1".utf8)], outcome: .ran), + RedisShardPart(node: "127.0.0.1:7000", keys: [Data("forbidden:1".utf8)], outcome: .refused(keyRefusal)), + ]) + #expect(partial.pluginErrorMessage == "DEL: \(keyRefusal)") + } + } + + @Test("A split read one shard refused answers with the refusal, since nothing changed") + func refusedRead() async throws { + let cluster = try await StubRedisCluster.connect( + first: [.success(.error(keyRefusal))], + second: [.success(.integer(1))] + ) + let reply = try await cluster.channel.executeCommand(["EXISTS", "allowed:1", "forbidden:1"], scope: .session) + #expect(reply.errorMessage == keyRefusal) + } + + @Test("A send that fails after one part ran reports the part that ran") + func interruptedPart() async throws { + let dropped = RedisTransportFailure(code: -1, message: "No reply from Redis", wasDelivered: true) + let cluster = try await StubRedisCluster.connect( + first: [.failure(dropped)], + second: [.success(.integer(1))] + ) + do { + _ = try await cluster.channel.executeCommand(["DEL", "a", "b"], scope: .session) + Issue.record("expected a partial write") + } catch let partial as RedisPartialClusterWrite { + #expect(partial.parts.map(\.outcome) == [.ran, .interrupted("No reply from Redis")]) + } + } + + @Test("A FLUSHDB one primary refused names the primary that flushed") + func refusedBroadcast() async throws { + let cluster = try await StubRedisCluster.connect( + first: [.success(.error("NOPERM User limited has no permissions to run the 'flushdb' command"))], + second: [.success(.status("OK"))] + ) + do { + _ = try await cluster.channel.executeCommand(["FLUSHDB"], scope: .session) + Issue.record("expected a partial write") + } catch let partial as RedisPartialClusterWrite { + #expect(partial.pluginErrorDetail?.hasSuffix("Nodes it ran on: 127.0.0.1:7001") == true) + } + } + + /// The owner of every part is looked up before the first is sent. Looking it up in the loop + /// deleted `b` and then failed on `a`, with nothing to say that `b` was gone. + @Test("A slot with no owner stops a split command before any part is sent") + func missingOwnerSendsNothing() async throws { + let firstHalfOnly = RedisReply.array([ + .array([.integer(0), .integer(8_191), .array([.string("127.0.0.1"), .integer(7_000), .string("node-a")])]), + ]) + let cluster = try await StubRedisCluster.connect(slots: firstHalfOnly, first: [.success(.integer(1))]) + await #expect(throws: RedisPluginError.self) { + try await cluster.channel.executeCommand(["DEL", "b", "a"], scope: .session) + } + #expect(cluster.first.sentCommands.isEmpty) + } +} + +@Suite("Redis cluster channel - numbered databases") +struct RedisClusterDatabaseSelectionTests { + private static let sixteen = (StubRedisCluster.servedDatabases(16), StubRedisCluster.servedDatabases(16)) + + @Test("Primaries that serve 16 databases list 16 and allow selecting them") + func servedDatabasesAreListed() async throws { + let cluster = try await StubRedisCluster.connect(clusterDatabases: Self.sixteen) + #expect(cluster.channel.supportsDatabaseSelection) + let listing = try await cluster.channel.databaseListing(includingKeyCounts: false) + #expect(listing.databaseCount == 16) + #expect(cluster.first.sentCommands.isEmpty) + #expect(cluster.second.sentCommands.isEmpty) + } + + @Test("The fewest databases any primary serves is the count") + func fewestWins() async throws { + let cluster = try await StubRedisCluster.connect( + clusterDatabases: (StubRedisCluster.servedDatabases(16), StubRedisCluster.servedDatabases(8)) + ) + #expect(try await cluster.channel.reportedDatabaseCount() == 8) + } + + /// Redis OSS answers `CONFIG GET cluster-databases` with an empty list and refuses SELECT with + /// any other index, measured on Redis 8.10.1. + @Test("Redis Cluster keeps one database and refuses another with nothing sent") + func singleDatabaseRefuses() async throws { + let cluster = try await StubRedisCluster.connect() + #expect(!cluster.channel.supportsDatabaseSelection) + do { + try await cluster.channel.selectDatabase(3, scope: .session) + Issue.record("expected a refusal") + } catch let error as RedisPluginError { + #expect(error.message == "This cluster serves database 0 only, so it cannot switch databases.") + } + await #expect(throws: RedisPluginError.self) { + try await cluster.channel.withDatabase(3) { try await cluster.channel.executeCommand(["DBSIZE"]) } + } + try await cluster.channel.selectDatabase(0, scope: .session) + #expect(cluster.first.sentCommands.isEmpty) + #expect(cluster.second.sentCommands.isEmpty) + } + + @Test("SELECT goes to the first primary alone, and every node follows before its next command") + func selectMovesEveryNode() async throws { + let cluster = try await StubRedisCluster.connect( + clusterDatabases: Self.sixteen, + first: [.success(.status("OK")), .success(.integer(2))], + second: [.success(.status("OK")), .success(.integer(5))] + ) + try await cluster.channel.selectDatabase(3, scope: .session) + #expect(cluster.first.sentCommands == [["SELECT", "3"]]) + #expect(cluster.first.sentScopes == [.outsideBlock]) + #expect(cluster.second.sentCommands.isEmpty) + #expect(cluster.channel.homeDatabase() == 3) + + #expect(try await cluster.channel.executeCommand(["DBSIZE"], scope: .session).intValue == 7) + #expect(cluster.first.sentCommands == [["SELECT", "3"], ["DBSIZE"]]) + #expect(cluster.second.sentCommands == [["SELECT", "3"], ["DBSIZE"]]) + } + + @Test("A refused SELECT leaves the cluster where it was") + func refusedSelectStays() async throws { + let cluster = try await StubRedisCluster.connect( + clusterDatabases: Self.sixteen, + first: [.success(.error("ERR DB index is out of range"))] + ) + await #expect(throws: RedisPluginError.self) { + try await cluster.channel.selectDatabase(20, scope: .session) + } + #expect(cluster.channel.homeDatabase() == 0) + } + + /// A SELECT queued into the block would move one primary when EXEC runs and none of the + /// others, so it is held back rather than sent. + @Test("A SELECT is held back from a block open on the primary") + func selectHeldBackFromBlock() async throws { + let cluster = try await StubRedisCluster.connect(clusterDatabases: Self.sixteen) + cluster.first.observeOpenBlock() + await #expect(throws: RedisHeldBackCommand(command: "SELECT", held: .openBlock)) { + try await cluster.channel.selectDatabase(3, scope: .session) + } + #expect(cluster.channel.homeDatabase() == 0) + } + + @Test("A read on another database visits it on every primary and leaves the cluster home") + func visitReachesEveryPrimary() async throws { + let emptyPage = RedisReply.array([.string("0"), .array([])]) + let cluster = try await StubRedisCluster.connect( + clusterDatabases: Self.sixteen, + first: [.success(.status("OK")), .success(.array([.string("0"), .array([.string("b")])])), + .success(.status("OK")), .success(.integer(1))], + second: [.success(.status("OK")), .success(emptyPage), .success(.status("OK")), .success(.integer(1))] + ) + let keys = try await cluster.channel.withDatabase(4) { + var cursor = RedisClusterCursor.start + var keys: [String] = [] + repeat { + let page = try await cluster.channel.scanKeyspace( + cursor: cursor, pattern: nil, type: nil, count: 10, scope: .outsideBlock + ) + keys += page.keys + cursor = page.cursor + } while cursor != RedisClusterCursor.start + return keys + } + #expect(keys == ["b"]) + #expect(cluster.first.sentCommands == [["SELECT", "4"], ["SCAN", "0", "COUNT", "10"]]) + #expect(cluster.second.sentCommands == [["SELECT", "4"], ["SCAN", "0", "COUNT", "10"]]) + #expect(cluster.channel.homeDatabase() == 0) + + _ = try await cluster.channel.executeCommand(["DBSIZE"], scope: .session) + #expect(cluster.first.sentCommands.suffix(2) == [["SELECT", "0"], ["DBSIZE"]]) + #expect(cluster.second.sentCommands.suffix(2) == [["SELECT", "0"], ["DBSIZE"]]) + } + + /// ASKING only lasts for the next command, so the visit's SELECT has to go first. Measured on + /// Valkey 9.1.2: ASKING then SELECT loses the ASKING and the command answers MOVED. + @Test("An ASK during a visit selects the visited database before ASKING") + func askDuringVisit() async throws { + let cluster = try await StubRedisCluster.connect( + clusterDatabases: Self.sixteen, + first: [.success(.status("OK")), .success(.error("ASK 3300 127.0.0.1:7001"))], + second: [.success(.status("OK")), .success(.status("OK")), .success(.string("v"))] + ) + let value = try await cluster.channel.withDatabase(4) { + try await cluster.channel.executeCommand(["GET", "b"], scope: .session).stringValue + } + #expect(value == "v") + #expect(cluster.second.sentCommands == [["SELECT", "4"], ["ASKING"], ["GET", "b"]]) + } + + @Test("Key counts add up each primary's keyspace per database") + func keyCountsSumPerDatabase() async throws { + let cluster = try await StubRedisCluster.connect( + clusterDatabases: Self.sixteen, + first: [.success(.string("# Keyspace\r\ndb0:keys=2,expires=0\r\ndb3:keys=1\r\n"))], + second: [.success(.string("# Keyspace\r\ndb3:keys=4\r\n"))] + ) + #expect(try await cluster.channel.keyCountsByDatabase() == [0: 2, 3: 5]) + #expect(cluster.first.sentCommands == [["INFO", "keyspace"]]) + #expect(cluster.second.sentCommands == [["INFO", "keyspace"]]) + } + + @Test("A primary that declines its keyspace leaves every count unknown") + func declinedKeyspaceIsUnknown() async throws { + let cluster = try await StubRedisCluster.connect( + clusterDatabases: Self.sixteen, + first: [.success(.string("# Keyspace\r\ndb0:keys=2\r\n"))], + second: [.success(.error("NOPERM User app has no permissions to run the 'info' command"))] + ) + #expect(try await cluster.channel.keyCountsByDatabase() == nil) + } + + @Test("A single-database cluster still counts its keys with DBSIZE") + func singleDatabaseCountsWithDbsize() async throws { + let cluster = try await StubRedisCluster.connect(first: [.success(.integer(2))], second: [.success(.integer(3))]) + #expect(try await cluster.channel.keyCountsByDatabase() == [0: 5]) + #expect(cluster.first.sentCommands == [["DBSIZE"]]) + #expect(cluster.second.sentCommands == [["DBSIZE"]]) + } + + /// A cluster cannot hold a MULTI across shards, so a grid save names its database on every + /// write instead of selecting it first. A SELECT sent ahead would have stayed in force when + /// a write after it failed. + @Test("A write that names its database and fails leaves the cluster home") + func namedWriteFailureStaysHome() async throws { + let cluster = try await StubRedisCluster.connect( + clusterDatabases: Self.sixteen, + first: [.success(.status("OK")), .success(.error(keyRefusal)), .success(.status("OK")), .success(.string("v"))] + ) + await #expect(throws: RedisPluginError.self) { + try await cluster.channel.withDatabase(3) { try await cluster.channel.run(["SET", "b", "v"]) } + } + #expect(cluster.channel.homeDatabase() == 0) + _ = try await cluster.channel.executeCommand(["GET", "b"], scope: .session) + #expect(cluster.first.sentCommands == [["SELECT", "3"], ["SET", "b", "v"], ["SELECT", "0"], ["GET", "b"]]) + } +} diff --git a/TableProTests/Plugins/RedisConnectionModeTests.swift b/TableProTests/Plugins/RedisConnectionModeTests.swift index 721aeeec59..29e8675c0a 100644 --- a/TableProTests/Plugins/RedisConnectionModeTests.swift +++ b/TableProTests/Plugins/RedisConnectionModeTests.swift @@ -26,13 +26,6 @@ struct RedisConnectionModeTests { #expect(RedisConnectionMode.resolve(additionalFields: ["redisMode": "galaxy"]) == .standalone) } - @Test("Only cluster gives up database selection") - func databaseSelection() { - #expect(RedisConnectionMode.standalone.supportsDatabaseSelection) - #expect(RedisConnectionMode.sentinel.supportsDatabaseSelection) - #expect(!RedisConnectionMode.cluster.supportsDatabaseSelection) - } - @Test("Only standalone uses the plain Host and Port fields") func hostListUsage() { #expect(!RedisConnectionMode.standalone.usesHostList) diff --git a/TableProTests/Plugins/RedisDatabaseListingTests.swift b/TableProTests/Plugins/RedisDatabaseListingTests.swift index b5a1855bc9..98f7f89d82 100644 --- a/TableProTests/Plugins/RedisDatabaseListingTests.swift +++ b/TableProTests/Plugins/RedisDatabaseListingTests.swift @@ -144,6 +144,28 @@ struct RedisDatabaseCountTests { #expect(RedisDatabaseCount.resolve(reported: nil, keyspace: [:], currentDatabase: 40) == 41) } + /// Measured: Valkey 9.1.2 answers `cluster-databases 16` when the setting is 16, Redis 8.10.1 + /// answers an empty list for a setting it does not have. + @Test("A cluster serves the fewest databases any primary reports") + func servedByClusterTakesTheFewest() { + let sixteen = RedisReply.array([.string("cluster-databases"), .string("16")]) + let eight = RedisReply.array([.string("cluster-databases"), .string("8")]) + #expect(RedisDatabaseCount.servedByCluster(primaryReplies: [sixteen, eight]) == 8) + #expect(RedisDatabaseCount.servedByCluster(primaryReplies: [sixteen, sixteen]) == 16) + } + + @Test("A cluster no primary vouches for serves database 0 alone") + func servedByClusterDefaultsToOne() { + let four = RedisReply.array([.string("cluster-databases"), .string("4")]) + #expect(RedisDatabaseCount.servedByCluster(primaryReplies: [.array([]), .array([])]) == 1) + #expect(RedisDatabaseCount.servedByCluster(primaryReplies: [nil, four]) == 4) + #expect(RedisDatabaseCount.servedByCluster(primaryReplies: [nil, nil]) == 1) + #expect(RedisDatabaseCount.servedByCluster(primaryReplies: []) == 1) + let zero = RedisReply.array([.string("cluster-databases"), .string("0")]) + let word = RedisReply.array([.string("cluster-databases"), .string("many")]) + #expect(RedisDatabaseCount.servedByCluster(primaryReplies: [zero, word, four]) == 4) + } + @Test("An index past Int32 cannot overflow the count") func boundsHugeIndices() { let huge = Int(Int32.max) diff --git a/TableProTests/Plugins/RedisDatabaseTargetTests.swift b/TableProTests/Plugins/RedisDatabaseTargetTests.swift index e7a1a38b0f..6f43a445f6 100644 --- a/TableProTests/Plugins/RedisDatabaseTargetTests.swift +++ b/TableProTests/Plugins/RedisDatabaseTargetTests.swift @@ -228,6 +228,18 @@ struct RedisSessionDatabaseTests { #expect(database.home == 3) #expect(database.databaseToMoveTo(visiting: nil) == nil) } + + /// A cluster moves every node's home at once, and each node goes there on its next command. + @Test("A new home moves only where the session belongs") + func rehomed() { + var database = RedisSessionDatabase(0) + database.rehomed(3) + #expect(database.current == 0) + #expect(database.home == 3) + #expect(database.databaseToMoveTo(visiting: nil) == 3) + #expect(database.databaseToMoveTo(visiting: 5) == 5) + #expect(database.databaseToMoveTo(visiting: 0) == nil) + } } @Suite("Redis command channel - a visit the app abandoned") @@ -296,26 +308,26 @@ struct RedisWriteAddressingTests { @Test("Writes for the database the session belongs on are unchanged") func sameDatabase() { - let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 3) + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 3, insideTransaction: true) #expect(addressed.map(\.statement) == Self.writes.map(\.statement)) } @Test("Writes for another database select it first and return afterwards") func otherDatabase() { - let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 5) + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 5, insideTransaction: true) #expect(addressed.map(\.statement) == ["SELECT 3", "SET \"k\" \"v\"", "DEL \"old\"", "SELECT 5"]) } @Test("A table that names no database, or no writes, is left alone") func nothingToAddress() { - #expect(RedisDatabaseTarget.addressing(Self.writes, toDatabase: nil, from: 5).count == 2) - #expect(RedisDatabaseTarget.addressing([], toDatabase: 3, from: 5).isEmpty) + #expect(RedisDatabaseTarget.addressing(Self.writes, toDatabase: nil, from: 5, insideTransaction: true).count == 2) + #expect(RedisDatabaseTarget.addressing([], toDatabase: 3, from: 5, insideTransaction: true).isEmpty) } /// The SELECTs go through the parser a save runs every statement through. @Test("Every addressing statement parses as a SELECT") func selectsParse() throws { - let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 5) + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 5, insideTransaction: true) guard case .select(let first) = try RedisCommandParser.parse(addressed[0].statement), case .select(let last) = try RedisCommandParser.parse(addressed[3].statement) else { Issue.record("Expected SELECT operations") diff --git a/TableProTests/Plugins/RedisNamedDatabaseWriteTests.swift b/TableProTests/Plugins/RedisNamedDatabaseWriteTests.swift new file mode 100644 index 0000000000..ce21bb60bc --- /dev/null +++ b/TableProTests/Plugins/RedisNamedDatabaseWriteTests.swift @@ -0,0 +1,94 @@ +// +// RedisNamedDatabaseWriteTests.swift +// TableProTests +// +// A grid save on a cluster cannot wrap its writes in MULTI, so a SELECT sent ahead of them stayed +// in force when one failed, and every command after it ran on the row's database. Each write +// names its database instead, as `DB `, and the session never leaves home. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("Redis DB prefix - parsing") +struct RedisDatabasePrefixParsingTests { + @Test("DB names the database a command runs on, as an index or as the sidebar spells it") + func parsesDatabaseAndCommand() throws { + guard case .inDatabase(let database, let operation) = try RedisCommandParser.parse("DB 3 SET \"k\" \"v\""), + case .set(let key, let value, _) = operation else { + Issue.record("Expected a SET run in a named database") + return + } + #expect(database == 3) + #expect(key == "k") + #expect(value == Data("v".utf8)) + + guard case .inDatabase(let spelled, .del(let keys)) = try RedisCommandParser.parse("db db12 DEL a b") else { + Issue.record("Expected a DEL run in a named database") + return + } + #expect(spelled == 12) + #expect(keys == ["a", "b"]) + } + + static let invalid = ["DB", "DB 3", "DB x GET k", "DB -1 GET k", "DB dbx GET k"] + + @Test("A DB with no database or no command is refused", arguments: invalid) + func rejectsIncomplete(command: String) { + #expect(throws: RedisParseError.self) { + try RedisCommandParser.parse(command) + } + } + + @Test("A command the user queued reports the acknowledgement, and a walk the app built refuses it") + func queuedAnswerFollowsTheCommand() throws { + #expect(try RedisCommandParser.parse("DB 2 SET k v").queuedCommandAnswer == .reportQueued) + #expect(try RedisCommandParser.parse("DB 2 KEYBROWSE LIMIT 10").queuedCommandAnswer == .refuse) + } +} + +@Suite("Redis grid writes - naming their database without a transaction") +struct RedisNamedDatabaseAddressingTests { + private static let writes: [RedisDatabaseTarget.Statement] = [ + (statement: "SET \"k\" \"v\"", parameters: []), + (statement: "DEL old", parameters: []), + ] + + @Test("Without a transaction every write names the database and no SELECT is sent") + func namesEachWrite() { + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 0, insideTransaction: false) + #expect(addressed.map(\.statement) == ["DB 3 SET \"k\" \"v\"", "DB 3 DEL old"]) + } + + @Test("Writes for the database the session belongs on are unchanged") + func homeDatabaseUnchanged() { + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 0, from: 0, insideTransaction: false) + #expect(addressed.map(\.statement) == Self.writes.map(\.statement)) + } + + /// The statements go through the parser a save runs every statement through, so each must + /// come back as the write it wraps, in the database it names. + @Test("Every named write parses back to its database and command") + func namedWritesParse() throws { + let addressed = RedisDatabaseTarget.addressing(Self.writes, toDatabase: 3, from: 0, insideTransaction: false) + for statement in addressed { + guard case .inDatabase(let database, _) = try RedisCommandParser.parse(statement.statement) else { + Issue.record("Expected \(statement.statement) to name its database") + continue + } + #expect(database == 3) + } + } + + @Test("A write whose SELECT failed on the session leaves it where it belongs") + func failedWriteStaysHome() async throws { + let channel = StubRedisChannel([.status("OK"), .error("NOPERM No permissions to access a key"), .status("OK")]) + await #expect(throws: RedisPluginError.self) { + try await channel.withDatabase(3) { try await channel.run(["SET", "k", "v"]) } + } + #expect(channel.homeDatabase() == 0) + #expect(channel.currentDatabase() == 0) + #expect(channel.sentCommands == [["SELECT", "3"], ["SET", "k", "v"], ["SELECT", "0"]]) + } +} diff --git a/project.yml b/project.yml index 35626cd497..9a7ec27636 100644 --- a/project.yml +++ b/project.yml @@ -630,6 +630,7 @@ targets: - Plugins/RedisDriverPlugin/RedisArgumentCodec.swift - Plugins/RedisDriverPlugin/RedisAuthCommand.swift - Plugins/RedisDriverPlugin/RedisClusterAggregator.swift + - Plugins/RedisDriverPlugin/RedisClusterChannel.swift - Plugins/RedisDriverPlugin/RedisClusterCursor.swift - Plugins/RedisDriverPlugin/RedisClusterRedirect.swift - Plugins/RedisDriverPlugin/RedisClusterTopology.swift From dbfbad1326a2f96fed96d221fa5828b278a7082a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:06:18 +0700 Subject: [PATCH 20/30] fix(ci): read wrapped and escaped plugin strings into the app catalog --- TablePro/Resources/Localizable.xcstrings | 211 ++++++++++++++---- .../TableProMobile/Localizable.xcstrings | 24 ++ scripts/localization.py | 24 +- 3 files changed, 215 insertions(+), 44 deletions(-) diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index ca872c27b9..72b5a499ef 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -171862,40 +171862,6 @@ } } }, - "Unknown explain variant '%@'." : { - "localizations" : { - "ko" : { - "stringUnit" : { - "state" : "translated", - "value" : "알 수 없는 EXPLAIN 변형 '%@'입니다." - } - }, - "tr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bilinmeyen açıklama varyantı '%@'." - } - }, - "vi" : { - "stringUnit" : { - "state" : "translated", - "value" : "Biến thể EXPLAIN không xác định '%@'." - } - }, - "zh-Hans" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知的 EXPLAIN 变体 '%@'。" - } - }, - "zh-Hant" : { - "stringUnit" : { - "state" : "translated", - "value" : "未知的 EXPLAIN 變體 '%@'。" - } - } - } - }, "Unknown panel(s): %@" : { "localizations" : { "ko" : { @@ -181733,9 +181699,6 @@ }, "Discovering cluster topology" : { - }, - "Redis Cluster serves database 0 only, so it cannot switch databases." : { - }, "The cluster reported no slot coverage, so it has no data to browse yet." : { @@ -182378,9 +182341,6 @@ }, "%@ takes nothing after it, and %@ was given." : { - }, - "KEYBROWSE DB requires a database index" : { - }, "%@ was not sent because a MULTI block is open on this connection." : { @@ -182414,6 +182374,177 @@ }, "Run WATCH again before starting the block." : { + }, + "This database does not explain statements." : { + + }, + "Unknown explain variant '%@'. This database offers: %@." : { + + }, + "This database has no explain variant that runs the statement. Leave 'analyze' off, or pass one of these as 'variant': %@." : { + + }, + "%@ must be a whole number" : { + + }, + "%@ DB requires a database index" : { + + }, + "The connection to the server was closed before the statement was sent. It was not run." : { + + }, + "Can't reach a PGlite socket server at %@:%d. Start it with 'npx @electric-sql/pglite-socket', then try again." : { + + }, + "Drop and recreate these views first, or the script stops when it drops the old table: %@." : { + + }, + "This cluster serves database 0 only, so it cannot switch databases." : { + + }, + "%@ needs every key in the same hash slot. Give the keys a shared hash tag, like {user}:1 and {user}:2." : { + + }, + "The cluster is moving slot %d between shards. Try again once the migration finishes." : { + + }, + "%1$@ and %2$lld more" : { + + }, + "%1$@ already ran on %2$lld of the %3$lld nodes it was sent to, and Redis cannot undo that. Nodes it ran on: %4$@" : { + + }, + "%1$@ already ran on %2$lld of the %3$lld hash slots it was split across, and Redis cannot undo that. Keys it ran on: %4$@" : { + + }, + "DB needs a database index and a command" : { + + }, + "Fill in Password, and Username too if the server uses Redis 6 ACL users." : { + + }, + "No Sentinel monitors a primary group named \"%@\". Tried %@." : { + + }, + "No Sentinel monitors a primary group named \"%@\". Tried %@, which monitor %@." : { + + }, + "This server is not running in cluster mode. Set Connection Mode to Standalone, or point Cluster mode at a node started with cluster-enabled yes." : { + + }, + "A tunnel forwards one address, and the node Sentinel names is on the server's own network, so Sentinel mode cannot run through one. Connect to a data node directly, or turn the tunnel off." : { + + }, + "This server is part of a Redis Cluster, so requests for keys it does not own will fail. Set Connection Mode to Cluster and list this address under Cluster Seed Nodes." : { + + }, + "This is a Redis Sentinel, not a data node, so it only answers SENTINEL commands. Set Connection Mode to Sentinel and list this address under Sentinel Nodes." : { + + }, + "Sentinel resolved to another Sentinel rather than a data node. Check the primary group name in your sentinel configuration." : { + + }, + "Compressing…" : { + + }, + "Profile \"%@\" was not found, or has no access keys or credential_process, in ~/.aws/config or ~/.aws/credentials." : { + + }, + "Could not determine an AWS region for \"%@\". Set the AWS Region field." : { + + }, + "TablePro cannot sign an RDS token for \"%@\". Enter the RDS Endpoint (for example mydb.abc123.us-east-1.rds.amazonaws.com:5432) when you connect through a port forward or bastion." : { + + }, + "\"%@\" is not a valid RDS endpoint. Use the form host or host:port." : { + + }, + "The credential_process command for profile \"%@\" is empty or invalid." : { + + }, + "Could not run the credential_process command for profile \"%@\": %@" : { + + }, + "The credential_process command for profile \"%@\" exited with status %lld.%@" : { + + }, + "The credential_process command for profile \"%@\" did not return valid credentials JSON." : { + + }, + "The credential_process command for profile \"%@\" returned unsupported Version %lld (expected 1)." : { + + }, + "The credential_process command for profile \"%@\" is only supported on macOS." : { + + }, + "Profile \"%@\" sets role_arn but has no source_profile or credential_source to provide base credentials." : { + + }, + "Profile \"%@\" has a source_profile chain that is too long to resolve." : { + + }, + "Could not assume role \"%@\": %@" : { + + }, + "Profile \"%@\" requires an MFA token code, which is not supported yet. Use a profile without mfa_serial." : { + + }, + "Profile \"%@\" signs in with a web identity token, which is not supported yet." : { + + }, + "Profile \"%@\" uses credential_source \"%@\", which is not supported on the desktop app." : { + + }, + "Could not build the %1$@ endpoint for the AWS region \"%2$@\"." : { + + }, + "Profile \"%@\" not found in ~/.aws/config." : { + + }, + "Profile \"%@\" in ~/.aws/config is missing sso_account_id or sso_role_name." : { + + }, + "SSO session \"%@\" referenced by profile \"%@\" was not found in ~/.aws/config." : { + + }, + "SSO session \"%@\" in ~/.aws/config is missing sso_start_url or sso_region." : { + + }, + "Profile \"%@\" in ~/.aws/config is missing sso_start_url or sso_region." : { + + }, + "SSO token cache not found for profile \"%@\". Run 'aws sso login --profile %@' first." : { + + }, + "SSO token cache for profile \"%@\" is malformed. Run 'aws sso login --profile %@' to refresh." : { + + }, + "SSO session for profile \"%@\" has expired. Run 'aws sso login --profile %@' to refresh." : { + + }, + "Failed to build the SSO portal URL for profile \"%@\"." : { + + }, + "Failed to reach the SSO portal for profile \"%@\": %@" : { + + }, + "Unexpected response from the SSO portal for profile \"%@\"." : { + + }, + "Role \"%@\" in account \"%@\" is not accessible via SSO. Check role permissions in IAM Identity Center." : { + + }, + "SSO portal returned HTTP %lld for profile \"%@\"." : { + + }, + "Failed to decode the SSO portal response for profile \"%@\"." : { + + }, + "SSO role credentials for profile \"%@\" were already expired. Run 'aws sso login --profile %@' to refresh." : { + + }, + "A renamed column's foreign key is rewritten, so its MATCH and DEFERRABLE clauses are not carried over." : { + } }, "version" : "1.1" diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index 445b0ad129..4e71332ea7 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -20992,6 +20992,30 @@ } } } + }, + "The key %@ no longer exists." : { + + }, + "Keys of type %@ cannot be opened here." : { + + }, + "Permission Denied" : { + + }, + "This connection's Redis user is not allowed to run this command or reach this key. Ask an administrator to grant it in the user's ACL." : { + + }, + "Key Not Found" : { + + }, + "Pull down on the key list to refresh it." : { + + }, + "Unsupported Key Type" : { + + }, + "Read it with a command in Query." : { + } }, "version" : "1.0" diff --git a/scripts/localization.py b/scripts/localization.py index 61d7dee72e..637be582d0 100755 --- a/scripts/localization.py +++ b/scripts/localization.py @@ -173,7 +173,23 @@ def verify() -> int: return failures -PLUGIN_KEY = re.compile(r'String\(localized:\s*"((?:[^"\\\\]|\\\\.)*)"') +# A call wrapped after `String(` and a literal holding `\"` both read as a key. The first pattern +# missed both, so a wrapped message and every one naming a quoted value never reached the catalog. +PLUGIN_KEY = re.compile(r'String\(\s*localized:\s*"((?:[^"\\]|\\.)*)"') +SWIFT_ESCAPE = re.compile(r'\\(u\{[0-9A-Fa-f]+\}|[ntr0"\'\\])') +SWIFT_ESCAPED_CHARACTER = {"n": "\n", "t": "\t", "r": "\r", "0": "\0", '"': '"', "'": "'", "\\": "\\"} + + +def swift_literal_value(literal: str) -> str: + """The string a Swift literal spells, which is what the catalog keys it by.""" + + def replace(match: re.Match) -> str: + escape = match.group(1) + if escape.startswith("u{"): + return chr(int(escape[2:-1], 16)) + return SWIFT_ESCAPED_CHARACTER[escape] + + return SWIFT_ESCAPE.sub(replace, literal) def plugin_keys() -> list[str]: @@ -190,10 +206,10 @@ def plugin_keys() -> list[str]: if "Tests" in path.parts: continue for match in PLUGIN_KEY.finditer(path.read_text(encoding="utf8", errors="replace")): - key = match.group(1) + literal = match.group(1) # An interpolated key is a different defect: it never matches any catalog entry. - if key and "\\(" not in key: - seen.setdefault(key, None) + if literal and "\\(" not in literal: + seen.setdefault(swift_literal_value(literal), None) return list(seen) From f7346d968c338327f2ccf0825f8977178fd13e02 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:06:19 +0700 Subject: [PATCH 21/30] docs(plugin-redis): document the Redis follow-ups to #3036 --- CHANGELOG.md | 19 +++++++++++++++++-- docs/databases/redis.mdx | 14 ++++++++++---- docs/ios/index.mdx | 4 +++- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ffc6ed47b..d80f4434bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Several label columns beside the key in the foreign key picker, for a parent row only told apart by a combination. (#2996) - **Saved Filters** in the filter bar's **Filter Settings**, for restoring a table's filter without running it, or not saving it at all. (#3006) - **Always Show Filter Bar** in **Filter Settings**, split out from the option that also decided what happened to the saved filter. +- Numbered databases on Valkey 9 clusters with `cluster-databases` above 1. +- `DB ` in the Redis editor, running one command on another database. +- Explain for Teradata. ### Changed @@ -231,14 +234,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - No wrong-mode error when a Standalone Redis connection points at a Valkey 8 or later Sentinel or cluster node. - Redis `MULTI` blocks aborted, or padded with extra replies, by the sidebar, the key browser and the connection check. - A Redis `MULTI` block or `WATCH` lost without notice when the connection dropped. -- Redis row counts, statistics, DDL preview, export and grid edits using the session's current database instead of the row's own. +- Redis row counts, statistics, DDL preview, export, grid edits and key tree using the session's database, not the row's. - Refreshing a Redis database tab after a refused switch showing another database's keys. - Redis key grid showing type UNKNOWN, TTL -1 and empty collections for keys an ACL user cannot read. -- Redis Cluster deletes and key counts reported as complete when a shard refused them, and `SCRIPT EXISTS` answering 0. +- Redis Cluster writes and key counts reported as complete when a shard refused them, and `SCRIPT EXISTS` answering 0. - Database Index in the Redis connection form stopping at 15. - Empty Redis key list on iPhone and iPad when the server refuses the scan or a `MULTI` block is open. - Explain Query failing on Redis with a `DEBUG` command error, and enabled for databases with no query plan. - Redis key tree showing "No items" when the server refuses the key scan or a `MULTI` block is open. +- Redis commands losing arguments such as `SCAN … TYPE` or `FLUSHDB ASYNC`, and valid `XGROUP` or `OBJECT` commands refused. +- Redis Cluster running `FUNCTION LOAD`, `ACL SETUSER`, `CONFIG REWRITE` and other subcommands on a single node. +- Redis connections whose database is written `db4` opening database 0 on the Mac. +- Negative or non-numeric database index in a Redis connection URL accepted without an error. +- Redis command errors on iPhone and iPad reported as a successful result. +- Redis keys listed twice on iPhone and iPad. +- Redis keys on iPhone and iPad failing to open with `ERR wrong number of arguments for 'select' command`. +- Redis permission errors on iPhone and iPad reported as a failed sign-in. +- Run and Explain Query in the Query menu enabled for an editor holding only whitespace. +- `explain_query` sending an invented `EXPLAIN` to databases without one, and an estimate when `analyze` was asked for. +- Explain on Redshift failing on PostgreSQL's `FORMAT JSON` and `ANALYZE` options. +- Decimal points and minus signs accepted in DuckDB Port and BigQuery Max Bytes Billed. ### Security diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index 1aa8c2946f..e354dcc093 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -76,7 +76,7 @@ For a sharded [Redis Cluster](https://redis.io/docs/latest/operate/oss_and_stack |-------|-------| | **Cluster Seed Nodes** | One or more `host:port` entries. Any reachable member is enough, the rest are discovered. Port defaults to `6379` | -`DBSIZE` is summed across shards, browsing merges their keys into one tree, and `MGET`, `MSET`, `DEL`, `EXISTS`, `TOUCH` and `UNLINK` are split per shard and recombined. When one shard refuses its part, or cannot answer because it is loading or busy running a script, the command reports that shard's error instead of a partial total; a split `DEL` has already run on the shards that accepted it. A shard that denies `DBSIZE` leaves the sidebar key count blank. A `MOVED` re-points the slot and retries, an `ASK` retries against the importing node, and one command follows at most five redirects. What Cluster mode cannot do is under [Limitations](#limitations). +`DBSIZE` is summed across shards, browsing merges their keys into one tree, and `MGET`, `MSET`, `DEL`, `EXISTS`, `TOUCH` and `UNLINK` are split per shard and recombined. When one shard refuses its part, or cannot answer because it is loading or busy running a script, the command reports that shard's error instead of a partial total. Redis cannot undo the part of a split write that already ran, so the error lists the keys it changed, or for `FLUSHDB` the nodes. A shard that denies `DBSIZE` leaves the sidebar key count blank, and on a cluster with more than one database each count is `INFO keyspace` summed across primaries. A `MOVED` re-points the slot and retries, an `ASK` retries against the importing node, and one command follows at most five redirects. What Cluster mode cannot do is under [Limitations](#limitations). The wrong mode is caught at connect, on Valkey as on Redis: Standalone against a cluster member, or any data mode against a Sentinel port, names the field to change. @@ -120,7 +120,7 @@ Toggle the filter bar to search keys by pattern. Patterns are Redis glob (`*` an ## Redis CLI -Each statement is one command; separate several with `;`. Commands the driver does not recognize go through too, and only the result formatting is type-aware. Arguments are quoted the way `redis-cli` quotes them, so `"` and `'` both work and `\xHH` writes a raw byte; unbalanced quotes are rejected rather than guessed at. Redis has no comment syntax and none is stripped, so a `--` or `#` line is sent to the server and fails. +Each statement is one command; separate several with `;`. Commands the driver does not recognize go through too, and only the result formatting is type-aware. A recognized command with arguments its result view does not model, such as `FLUSHDB ASYNC` or `CONFIG SET a 1 b 2`, is also sent exactly as typed, so the server answers it as `redis-cli` would. Arguments are quoted the way `redis-cli` quotes them, so `"` and `'` both work and `\xHH` writes a raw byte; unbalanced quotes are rejected rather than guessed at. Redis has no comment syntax and none is stripped, so a `--` or `#` line is sent to the server and fails. ```redis SET mykey "hello" EX 60 @@ -128,6 +128,10 @@ HGETALL myhash; LRANGE mylist 0 -1 SCAN 0 MATCH user:* COUNT 100 ``` +`SELECT` moves the editor's session only. The commands after it run on that database, while the sidebar, the key tree and the database tab stay on the database you clicked. Clicking a database, or opening a key from the tree, moves the editor to that database. + +`DB 3 GET key` runs one command on database 3 and leaves the session where it was. Grid saves on a cluster write this way. + ### MULTI blocks A command sent after `MULTI` answers `QUEUED` instead of its own reply, and nothing runs until `EXEC`. `EXEC` then returns every reply in order, errors included; an error element reads `(error) WRONGTYPE …`, the way `redis-cli` prints one. @@ -153,10 +157,10 @@ New connections default to **Disabled**. SNI is sent in every TLS mode. ## Limitations - A command that fails while `EXEC` is running cannot be taken back. Standalone and Sentinel wrap a grid save in `MULTI`/`EXEC`, so the rest of the block stays applied and the error names the one that failed; check the keys it touched. A command refused before it runs, for a missing ACL permission, a full `maxmemory` or wrong arity, aborts the block and writes nothing. -- No transactions in Cluster mode. Grid saves run their commands one at a time, so a failure leaves the earlier ones applied. Group keys under one hash tag if they must move together. +- No transactions in Cluster mode. Grid saves run their commands one at a time, so a failure leaves the earlier ones applied. Deleting grid rows sends one `DEL` per hash slot, and **Save Incomplete** counts how many ran. Group keys under one hash tag if they must move together. - A `SELECT` typed inside a `MULTI` block waits for `EXEC`, and never happens at all after a `DISCARD`. Clicking a database in the sidebar while a block is open is refused; close the block first. - A service with one database, such as Upstash or Redis Cloud, refuses `CONFIG`, so the sidebar lists 16. Every database but `db0` shows the server's error when you click it. -- Cluster mode serves database 0 only. The Database Index field is hidden and the sidebar shows a single `db0`. +- Redis Cluster serves database 0 only, and so does Valkey unless `cluster-databases` is above 1, in which case the sidebar lists each database. A cluster whose primaries refuse `CONFIG` shows `db0` alone. The Database Index field is hidden in Cluster mode, and a cluster connection always opens on `db0`. - A command whose keys span hash slots, such as `RENAME`, `SMOVE`, or the `*STORE` commands, is refused in Cluster mode before it is sent. Give the keys a shared hash tag, like `{user}:1` and `{user}:2`. - A key that is not valid UTF-8 never appears in the grid or the tree. Reach it from the CLI; values have no such limit. - Pub/Sub has no grid support. `PUBLISH` runs in the CLI, and there is no subscriber view. @@ -170,6 +174,8 @@ New connections default to **Disabled**. SNI is sent in every TLS mode. The ACL user cannot run `SCAN`, which the key tree and the key browser read keys with, and key types need `TYPE`. Grant both with `ACL SETUSER +scan +type`, then right-click **Keys** and choose **Refresh**. +On iPhone and iPad the key list reads `SCAN` and `TYPE` too, and opening a key reads it by type: `GET`, `LLEN` and `LRANGE`, `HLEN` and `HSCAN`, `SCARD` and `SSCAN`, `ZCARD` and `ZRANGE … WITHSCORES`, or `XLEN` and `XRANGE`. A refusal there reads **Permission Denied**. + ### SELECT … failed: ERR DB index is out of range The server has no database with that index. A server that refuses `CONFIG` is listed as 16 databases whatever it holds, so check `databases` in `redis.conf` or the service's own limit, and browse a database the server has. diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 54b1e53fe5..7d0ca666e3 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -106,6 +106,8 @@ On iPhone Duo the system draws the toolbar and the tab bar down the side of the A row in the list previews four of its fields, and eight where the display is wide enough. Search the table list by name. Page a table at 50, 100, 200, or 500 rows, jump to a page number, sort by a column, search text columns, and stack filters with AND or OR. A foreign key value previews the row it points at. **Table Structure** lists columns, indexes, and foreign keys, read only. +On Redis the list holds the current database's keys. A key opens by type: a string's value, a hash's fields, a list's elements by index, a set's members, a sorted set's members and scores, a stream's entries. There is no search, sort, filter or **Table Structure** for a key. + ### Editing Tap a row to open it full screen, page between rows, edit values, toggle one to `NULL`, and save. While you edit, **Cancel** takes the place of the back button and asks before it throws away a changed value, and so does **Connections** in another tab. Inserting and deleting rows, and truncating or dropping a table, are here too. Editing needs a primary key. @@ -123,7 +125,7 @@ A running query appears in a Live Activity on the lock screen and Dynamic Island ## What is missing - **Schema changes.** Truncating and dropping a table is the whole list; creating or altering tables, columns, indexes, triggers, and views is Mac only. -- **Inserting a row on Redis.** Editing and deleting a key still work. +- **Changing Redis data.** Keys open read only; change them in **Query**. - **Jump hosts.** A connection whose SSH tunnel goes through a bastion opens on the Mac only. Its hops sync here and back intact. - **AI.** No chat, no inline suggestions, no MCP server. From 968aaea73bfa026d98c896d9389395a6e335f200 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:30 +0700 Subject: [PATCH 22/30] fix(editor): classify a quoted Redis command as the command the driver runs --- CHANGELOG.md | 1 + .../RedisArgumentCodec.swift | 2 +- .../Core/Utilities/SQL/QueryClassifier.swift | 25 +++++++++---------- .../SQL/QueryClassifierHardeningTests.swift | 13 ++++++++++ project.yml | 4 ++- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d80f4434bf..069a69a01d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -276,6 +276,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Statements hidden the same ways passed the one-statement check on MCP and AI chat queries. - Writes hidden in a dollar-quoted string, a nested comment or a bracketed identifier skipped Safe Mode on iPhone and iPad. +- A quoted Redis command such as `"FLUSHALL"` skipping Safe Mode and the MCP destructive-statement check. ## [0.75.0] - 2026-09-18 Runs on macOS 13 Ventura and later. diff --git a/Plugins/RedisDriverPlugin/RedisArgumentCodec.swift b/Plugins/RedisDriverPlugin/RedisArgumentCodec.swift index 7363d4950f..d0bdb16bfb 100644 --- a/Plugins/RedisDriverPlugin/RedisArgumentCodec.swift +++ b/Plugins/RedisDriverPlugin/RedisArgumentCodec.swift @@ -153,7 +153,7 @@ enum RedisArgumentCodec { } } - private static func quotedText(_ text: String) -> String { + static func quotedText(_ text: String) -> String { var result = "\"" for scalar in text.unicodeScalars { switch scalar { diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index c65cc28245..6332b59786 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -706,16 +706,17 @@ private extension QueryClassifier { databaseType: DatabaseType ) -> QueryClassification? { guard databaseType == .redis else { return nil } - let statement = redisCommandPastDatabasePrefix(trimmed) - let command = statement.prefix { !$0.isWhitespace }.uppercased() - guard !command.isEmpty else { return .safe } + guard let arguments = RedisArgumentCodec.split(trimmed) else { + return QueryClassification(tier: .write, reachesFilesystemOrExecutesCode: false) + } + let statement = redisCommandPastDatabasePrefix(arguments.map { String(bytes: $0, encoding: .utf8) ?? "" }) + guard let command = statement.first?.uppercased() else { return .safe } let touchesUnsafeSurface = redisCodeExecutionCommands.contains(command) || redisFilesystemCommands.contains(command) if command == "CONFIG" { - let rest = statement.dropFirst(command.count).trimmingCharacters(in: .whitespaces).uppercased() - let tier: QueryTier = rest.hasPrefix("GET") ? .safe : .destructive + let tier: QueryTier = statement.dropFirst().first?.uppercased() == "GET" ? .safe : .destructive return QueryClassification(tier: tier, reachesFilesystemOrExecutesCode: false) } @@ -735,14 +736,12 @@ private extension QueryClassifier { /// `DB ` runs the command on the database it names, so the command decides /// the tier: read as the bare `DB`, `DB 0 FLUSHDB` would pass as an ordinary write. A prefix - /// with nothing after its index is left whole, which classifies as a write. - static func redisCommandPastDatabasePrefix(_ statement: String) -> Substring { - var rest = Substring(statement) - while rest.prefix(while: { !$0.isWhitespace }).uppercased() == "DB" { - let afterKeyword: Substring = rest.dropFirst(2).drop(while: \.isWhitespace) - let afterIndex: Substring = afterKeyword.drop(while: { !$0.isWhitespace }).drop(while: \.isWhitespace) - guard !afterIndex.isEmpty else { return rest } - rest = afterIndex + /// with nothing after its index is left whole, which classifies as a write. The words are read + /// the way the driver reads them, so a quoted `"FLUSHALL"` is the command it runs. + static func redisCommandPastDatabasePrefix(_ words: [String]) -> ArraySlice { + var rest = words[...] + while rest.first?.uppercased() == "DB", rest.count > 2 { + rest = rest.dropFirst(2) } return rest } diff --git a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift index 7d6dcda092..24c8c3981a 100644 --- a/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift +++ b/TableProTests/Core/Utilities/SQL/QueryClassifierHardeningTests.swift @@ -431,6 +431,19 @@ struct QueryClassifierNonSqlTests { #expect(QueryClassifier.isWriteQuery("DB 3", databaseType: .redis)) } + /// The driver strips redis-cli quoting before it sends, so a quoted command runs as the bare + /// one. Read as written, `"FLUSHALL"` matched no set and passed as an ordinary write. + @Test("A quoted Redis command is classified as the command the driver runs") + func redisQuotedCommand() { + #expect(QueryClassifier.classifyTier("\"FLUSHALL\"", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("'FLUSHDB' ASYNC", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("DB 0 \"FLUSHALL\"", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("'DB' 0 FLUSHALL", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("\"\\x46LUSHALL\"", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("CONFIG \"SET\" maxmemory 1", databaseType: .redis) == .destructive) + #expect(QueryClassifier.classifyTier("\"GET\" key", databaseType: .redis) == .safe) + } + @Test("etcd verbs separate reads, writes, deletes and snapshots") func etcdTiers() { #expect(!QueryClassifier.isWriteQuery("get /keys", databaseType: .etcd)) diff --git a/project.yml b/project.yml index 9a7ec27636..c187bf9e33 100644 --- a/project.yml +++ b/project.yml @@ -127,8 +127,10 @@ targets: - path: TablePro/Resources/ThirdPartyLicenses type: folder buildPhase: resources - # One parse for a Redis database index, shared with the Redis plugin and the iOS app. + # The Redis plugin's own readings of a database index and of redis-cli quoting, so the + # app classifies, quotes and resolves a Redis statement exactly as the driver will. - Plugins/RedisDriverPlugin/RedisDatabaseIndex.swift + - Plugins/RedisDriverPlugin/RedisArgumentCodec.swift configFiles: Debug: Configs/Version.xcconfig Release: Configs/Version.xcconfig From 3ba910f3d3ec1385b050c0065f55c61b8dbd8f77 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:31 +0700 Subject: [PATCH 23/30] fix(sidebar): read a key opened from the Redis key tree without moving the session --- TablePro/Models/UI/RedisKeyTreeCommand.swift | 32 +++----------- .../MainContentCoordinator+Navigation.swift | 37 +++------------- .../Core/Redis/RedisKeyTreeCommandTests.swift | 7 ++- .../RedisDatabaseSelectionGateTests.swift | 43 ++++++------------- 4 files changed, 31 insertions(+), 88 deletions(-) diff --git a/TablePro/Models/UI/RedisKeyTreeCommand.swift b/TablePro/Models/UI/RedisKeyTreeCommand.swift index ef5b8d3c80..3696abe1bd 100644 --- a/TablePro/Models/UI/RedisKeyTreeCommand.swift +++ b/TablePro/Models/UI/RedisKeyTreeCommand.swift @@ -12,8 +12,12 @@ internal enum RedisKeyTreeCommand { "KEYTREE DB \(databaseIndex) LIMIT \(limit)" } - static func openKey(_ key: String, keyType: String?) -> String { - let argument = quoted(key) + static func openKey(_ key: String, keyType: String?, inDatabase databaseIndex: Int) -> String { + "DB \(databaseIndex) \(readKey(key, keyType: keyType))" + } + + private static func readKey(_ key: String, keyType: String?) -> String { + let argument = RedisArgumentCodec.quotedText(key) switch keyType?.lowercased() { case "hash"?: return "HGETALL \(argument)" case "list"?: return "LRANGE \(argument) 0 -1" @@ -23,28 +27,4 @@ internal enum RedisKeyTreeCommand { default: return "GET \(argument)" } } - - /// Inside double quotes `redis-cli` decodes `\n`, `\t` and `\xHH`, so a backslash is escaped as - /// well as the quote. Single quotes cannot carry every key: `'a\'` reads as an unclosed quote. - private static func quoted(_ 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: - guard scalar.value < 0x20 || scalar.value == 0x7F else { - result.unicodeScalars.append(scalar) - continue - } - result += String(format: "\\x%02x", scalar.value) - } - } - return result + "\"" - } } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index b6f2b1ca2c..d6185a3281 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -804,38 +804,11 @@ extension MainContentCoordinator { openRedisKey(keyName, keyType: keyType, inDatabase: databaseIndex) } - /// A key is read from the database the tree listed it in, not from wherever a typed `SELECT` - /// left the session, so the session moves there first. The move waits behind a database click - /// still in flight instead of cancelling it, which would leave that click's tab loading, and a - /// later click cancels both. func openRedisKey(_ keyName: String, keyType: String?, inDatabase databaseIndex: Int) { - tabManager.addTab(initialQuery: RedisKeyTreeCommand.openKey(keyName, keyType: keyType), title: keyName) - guard let tabId = tabManager.selectedTabId else { return } - - let connId = connectionId - let database = String(databaseIndex) - let inFlight = redisDatabaseSwitchTask - redisDatabaseSwitchTask = Task { [weak self] in - await withTaskCancellationHandler { - await inFlight?.value - } onCancel: { - inFlight?.cancel() - } - guard let self, !Task.isCancelled else { return } - do { - try await DatabaseManager.shared.switchDatabase(to: database, for: connId, persist: false) - } catch { - guard !Task.isCancelled else { return } - navigationLogger.error( - "Failed to SELECT Redis db\(databaseIndex) for a key: \(error.publicLogShape, privacy: .public)" - ) - reportRedisSelectionFailure(error, onTab: tabId) - return - } - guard !Task.isCancelled else { return } - toolbarState.currentDatabase = database - guard tabManager.selectedTabId == tabId else { return } - runQuery(viewport: .firstRow) - } + tabManager.addTab( + initialQuery: RedisKeyTreeCommand.openKey(keyName, keyType: keyType, inDatabase: databaseIndex), + title: keyName + ) + runQuery(viewport: .firstRow) } } diff --git a/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift index a56c718f2b..c0c45962a9 100644 --- a/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift +++ b/TableProTests/Core/Redis/RedisKeyTreeCommandTests.swift @@ -119,7 +119,12 @@ struct RedisKeyTreeAppCommandTests { ] private func opened(_ key: String, as keyType: String?) throws -> RedisOperation { - try RedisCommandParser.parse(RedisKeyTreeCommand.openKey(key, keyType: keyType)) + let command = RedisKeyTreeCommand.openKey(key, keyType: keyType, inDatabase: 4) + guard case .inDatabase(4, let operation) = try RedisCommandParser.parse(command) else { + Issue.record("Expected a read in database 4 for \(key)") + return .command(args: []) + } + return operation } @Test("Opening a key reads exactly that key, whatever it holds", arguments: keys) diff --git a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift index d846ea6dbf..5d0db30ba4 100644 --- a/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift +++ b/TableProTests/Views/Main/RedisDatabaseSelectionGateTests.swift @@ -327,43 +327,28 @@ struct RedisDatabaseSelectionGateTests { #expect(recorder.executedQueries == [listing]) } - @Test("Opening a key moves the session to the key's database before it reads the key") - func openingAKeyMovesFirst() async throws { + /// The key stays quoted even when it needs no quoting: left bare, the editor reads the `:x` in + /// `five:x` as a query parameter, opens the parameter panel and runs nothing. + @Test("Opening a key reads it in the key's database and leaves the session where it was") + func openingAKeyLeavesTheSessionAlone() async throws { let (connection, recorder) = makeSession() defer { cleanUp(connection.id) } let coordinator = makeCoordinator(for: connection) defer { coordinator.teardown() } + coordinator.toolbarState.currentDatabase = "2" - coordinator.openRedisKey("zero:a", keyType: "string", inDatabase: 0) - await coordinator.redisDatabaseSwitchTask?.value - let read = RedisKeyTreeCommand.openKey("zero:a", keyType: "string") + coordinator.openRedisKey("five:x", keyType: "string", inDatabase: 5) + let read = RedisKeyTreeCommand.openKey("five:x", keyType: "string", inDatabase: 5) await waitForExecution(of: read, on: recorder) - #expect(recorder.events == ["switch:0", "execute:\(read)"]) - #expect(coordinator.toolbarState.currentDatabase == "0") - #expect(coordinator.tabManager.selectedTab?.title == "zero:a") - } - - @Test("A key whose database the server refuses reports it on the key's tab and reads nothing") - func refusedKeyDatabaseIsReportedOnTheKeyTab() async throws { - let (connection, recorder) = makeSession() - defer { cleanUp(connection.id) } - recorder.refuseSelections(with: RefusedSelection()) - let coordinator = makeCoordinator(for: connection) - defer { coordinator.teardown() } - - coordinator.openRedisKey("five:x", keyType: nil, inDatabase: 5) - await coordinator.redisDatabaseSwitchTask?.value - - let tab = try #require(coordinator.tabManager.selectedTab) - #expect(tab.title == "five:x") - #expect(tab.execution.errorMessage == RefusedSelection.message) - #expect(recorder.executedQueries.isEmpty) + #expect(read == #"DB 5 GET "five:x""#) + #expect(recorder.events == ["execute:\(read)"]) + #expect(coordinator.redisDatabaseSwitchTask == nil) + #expect(coordinator.toolbarState.currentDatabase == "2") #expect(DatabaseManager.shared.session(for: connection.id)?.browseDatabase == "0") + #expect(coordinator.tabManager.selectedTab?.title == "five:x") } - /// Cancelling the click instead would leave its retargeted tab loading with nothing coming to - /// finish it. @Test("Opening a key while a database click waits leaves no tab loading") func keyOpenedBehindAPendingClickLeavesNoSpinner() async throws { let (connection, recorder) = makeSession() @@ -381,12 +366,12 @@ struct RedisDatabaseSelectionGateTests { release.open() try await holder.value await coordinator.redisDatabaseSwitchTask?.value - let read = RedisKeyTreeCommand.openKey("three:a", keyType: "hash") + let read = RedisKeyTreeCommand.openKey("three:a", keyType: "hash", inDatabase: 3) await waitForExecution(of: read, on: recorder) let clicked = try #require(coordinator.tabManager.tabs.first { $0.id == clickedTabId }) #expect(clicked.pagination.isLoading == false) - #expect(recorder.switchedDatabases == ["3", "3"]) + #expect(recorder.switchedDatabases == ["3"]) #expect(recorder.executedQueries.contains(read)) } } From 8a5ef8c53030f3a5e2ed1d50a8ea2c02d07e7d63 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:31 +0700 Subject: [PATCH 24/30] fix(plugin-redis): refuse a cluster command on another database inside an open MULTI block --- .../RedisPluginConnection.swift | 23 +++++++++++++------ .../RedisSessionFootprint.swift | 12 ++++++++++ TableProTests/Helpers/StubRedisChannel.swift | 21 ++++++++++++----- .../Plugins/RedisClusterChannelTests.swift | 15 ++++++++++++ .../Plugins/RedisDatabaseTargetTests.swift | 14 +++++++++++ 5 files changed, 72 insertions(+), 13 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift index 12f8a0b5b4..a2f235af8c 100644 --- a/Plugins/RedisDriverPlugin/RedisPluginConnection.swift +++ b/Plugins/RedisDriverPlugin/RedisPluginConnection.swift @@ -247,7 +247,7 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { } stateLock.unlock() try admit(scope, command: args.first) - try moveToCommandDatabase(visiting: visiting) + try moveToCommandDatabase(visiting: visiting, command: args.first) let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } let result = try executeCommandSyncRetrying(args, scope: scope) @@ -273,7 +273,7 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { } stateLock.unlock() try admit(scope, command: commands.first?.first) - try moveToCommandDatabase(visiting: visiting) + try moveToCommandDatabase(visiting: visiting, command: commands.first?.first) let generation = cancellationGate.beginQuery() defer { cancellationGate.endQuery(generation) } let results = try executePipelineSyncRetrying(commands, scope: scope) @@ -300,13 +300,22 @@ final class RedisPluginConnection: RedisCommandChannel, @unchecked Sendable { } /// Runs on the serial queue right before the send, so no other command can land between the - /// move and the command it is for. An open block is left alone: a visit is held back from one, - /// so the session cannot be away from home while it is open. - private func moveToCommandDatabase(visiting: Int?) throws { + /// move and the command it is for. A cluster node is visited per command rather than by a + /// `SELECT` of its own, so this is where a visit inside an open block is refused. + private func moveToCommandDatabase(visiting: Int?, command: Data?) throws { stateLock.lock() - let target = _footprint.hasOpenBlock ? nil : _database.databaseToMoveTo(visiting: visiting) + let move = _database.move(beforeCommandVisiting: visiting, blockOpen: _footprint.hasOpenBlock) stateLock.unlock() - guard let target else { return } + let target: Int + switch move { + case .stay: + return + case .refuse: + let name = command.flatMap { String(data: $0, encoding: .utf8) } ?? "" + throw RedisHeldBackCommand(command: name, held: .openBlock) + case .select(let index): + target = index + } try select(target, scope: .outsideBlock) stateLock.lock() _database.visited(target) diff --git a/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift b/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift index b3bd5b17eb..be98e102c8 100644 --- a/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift +++ b/Plugins/RedisDriverPlugin/RedisSessionFootprint.swift @@ -155,6 +155,18 @@ struct RedisSessionDatabase: Equatable, Sendable { let target = visiting ?? home return current == target ? nil : target } + + func move(beforeCommandVisiting visiting: Int?, blockOpen: Bool) -> RedisCommandDatabaseMove { + guard let target = databaseToMoveTo(visiting: visiting) else { return .stay } + guard blockOpen else { return .select(target) } + return visiting == nil ? .stay : .refuse + } +} + +enum RedisCommandDatabaseMove: Equatable, Sendable { + case stay + case select(Int) + case refuse } /// The database a read the app makes for one row is visiting, for the length of that read. diff --git a/TableProTests/Helpers/StubRedisChannel.swift b/TableProTests/Helpers/StubRedisChannel.swift index 72b3421046..c8d8c7a578 100644 --- a/TableProTests/Helpers/StubRedisChannel.swift +++ b/TableProTests/Helpers/StubRedisChannel.swift @@ -95,7 +95,7 @@ final class StubRedisChannel: RedisClusterNodeConnection, @unchecked Sendable { func executeCommand(_ args: [Data], scope: RedisCommandScope) async throws -> RedisReply { let command = decoded(args) try admit(scope, command: command) - try moveToCommandDatabase() + try moveToCommandDatabase(command: command.first) guard let reply = try send(command, scope: scope) else { return .null } observe(command: command.first, reply: reply) return reply @@ -106,7 +106,7 @@ final class StubRedisChannel: RedisClusterNodeConnection, @unchecked Sendable { func executePipeline(_ commands: [[Data]], scope: RedisCommandScope) async throws -> [RedisReply] { let pipeline = commands.map(decoded) try admit(scope, command: pipeline.first ?? []) - try moveToCommandDatabase() + try moveToCommandDatabase(command: pipeline.first?.first) let replies = try pipeline.map { try send($0, scope: scope) } for (command, reply) in zip(pipeline, replies) { guard let reply else { continue } @@ -115,10 +115,19 @@ final class StubRedisChannel: RedisClusterNodeConnection, @unchecked Sendable { return replies.map { $0 ?? .null } } - private func moveToCommandDatabase() throws { - guard !footprint.hasOpenBlock, - let target = sessionDatabase.databaseToMoveTo(visiting: RedisDatabaseVisit.database) else { return } - try moveSession(to: target, scope: .outsideBlock) { $0.visited(target) } + private func moveToCommandDatabase(command: String?) throws { + let move = sessionDatabase.move( + beforeCommandVisiting: RedisDatabaseVisit.database, + blockOpen: footprint.hasOpenBlock + ) + switch move { + case .stay: + return + case .refuse: + throw RedisHeldBackCommand(command: command ?? "", held: .openBlock) + case .select(let target): + try moveSession(to: target, scope: .outsideBlock) { $0.visited(target) } + } } private func observe(command: String?, reply: RedisReply) { diff --git a/TableProTests/Plugins/RedisClusterChannelTests.swift b/TableProTests/Plugins/RedisClusterChannelTests.swift index 56e7487d5a..22db684cd2 100644 --- a/TableProTests/Plugins/RedisClusterChannelTests.swift +++ b/TableProTests/Plugins/RedisClusterChannelTests.swift @@ -266,6 +266,21 @@ struct RedisClusterDatabaseSelectionTests { let page = try await cluster.channel.scanKeyspace( cursor: cursor, pattern: nil, type: nil, count: 10, scope: .outsideBlock ) + /// On a cluster the visit is taken per command, so this is the node's own check. Queued, the + /// write would run on the home database when EXEC runs. + @Test("A write on another database is refused on a primary holding an open block") + func visitRefusedInsideBlock() async throws { + let cluster = try await StubRedisCluster.connect(clusterDatabases: Self.sixteen) + cluster.second.observeOpenBlock() + await #expect(throws: RedisHeldBackCommand(command: "HSET", held: .openBlock)) { + try await cluster.channel.withDatabase(3) { + try await cluster.channel.executeCommand(["HSET", "a", "f", "v"], scope: .session) + } + } + #expect(cluster.second.sentCommands.isEmpty) + #expect(cluster.channel.homeDatabase() == 0) + } + keys += page.keys cursor = page.cursor } while cursor != RedisClusterCursor.start diff --git a/TableProTests/Plugins/RedisDatabaseTargetTests.swift b/TableProTests/Plugins/RedisDatabaseTargetTests.swift index 6f43a445f6..f323abc688 100644 --- a/TableProTests/Plugins/RedisDatabaseTargetTests.swift +++ b/TableProTests/Plugins/RedisDatabaseTargetTests.swift @@ -240,6 +240,20 @@ struct RedisSessionDatabaseTests { #expect(database.databaseToMoveTo(visiting: 5) == 5) #expect(database.databaseToMoveTo(visiting: 0) == nil) } + + @Test("A visit that needs a move inside an open block is refused, and nothing else moves one") + func moveInsideABlock() { + let home = RedisSessionDatabase(0) + #expect(home.move(beforeCommandVisiting: 3, blockOpen: false) == .select(3)) + #expect(home.move(beforeCommandVisiting: 3, blockOpen: true) == .refuse) + #expect(home.move(beforeCommandVisiting: 0, blockOpen: true) == .stay) + #expect(home.move(beforeCommandVisiting: nil, blockOpen: true) == .stay) + + var away = RedisSessionDatabase(0) + away.visited(3) + #expect(away.move(beforeCommandVisiting: nil, blockOpen: false) == .select(0)) + #expect(away.move(beforeCommandVisiting: nil, blockOpen: true) == .stay) + } } @Suite("Redis command channel - a visit the app abandoned") From d87ee357d8b110b398b28e3dec69e0362d4c5f35 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:31 +0700 Subject: [PATCH 25/30] fix(plugin-redis): report a partial Redis Cluster fan-out of a command with no write flag --- .../RedisClusterChannel.swift | 2 +- .../RedisCommandRouting.swift | 4 ++ .../Plugins/RedisClusterChannelTests.swift | 46 +++++++++++++------ .../Plugins/RedisCommandRoutingTests.swift | 8 ++++ 4 files changed, 44 insertions(+), 16 deletions(-) diff --git a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift index c7d634f60e..457cb051b4 100644 --- a/Plugins/RedisDriverPlugin/RedisClusterChannel.swift +++ b/Plugins/RedisDriverPlugin/RedisClusterChannel.swift @@ -375,7 +375,7 @@ final class RedisClusterChannel: RedisCommandChannel, @unchecked Sendable { to: targets, carrying: [], of: args, - isWrite: spec?.isWrite ?? false, + isWrite: spec?.changesEveryNodeItReaches ?? false, followRedirects: false, scope: scope ) diff --git a/Plugins/RedisDriverPlugin/RedisCommandRouting.swift b/Plugins/RedisDriverPlugin/RedisCommandRouting.swift index b7078d8499..f42e39b087 100644 --- a/Plugins/RedisDriverPlugin/RedisCommandRouting.swift +++ b/Plugins/RedisDriverPlugin/RedisCommandRouting.swift @@ -47,6 +47,10 @@ struct RedisCommandSpec: Sendable, Equatable { var declaresKeys: Bool { firstKey > 0 && step > 0 } + var changesEveryNodeItReaches: Bool { + isWrite || (!isReadOnly && responsePolicy == .allSucceeded) + } + func fillingPoliciesFrom(_ fallback: RedisCommandSpec?) -> RedisCommandSpec { guard let fallback, requestPolicy == nil, responsePolicy == nil else { return self } return RedisCommandSpec( diff --git a/TableProTests/Plugins/RedisClusterChannelTests.swift b/TableProTests/Plugins/RedisClusterChannelTests.swift index 22db684cd2..5b7fbaecff 100644 --- a/TableProTests/Plugins/RedisClusterChannelTests.swift +++ b/TableProTests/Plugins/RedisClusterChannelTests.swift @@ -40,6 +40,22 @@ struct RedisClusterDispatchTests { #expect(cluster.second.sentCommands == [["CONFIG", "SET", "maxmemory", "0"]]) } + /// `CONFIG SET` has no `write` flag, so keying the report on that flag alone left a setting + /// applied on one node and refused on the other reading as if nothing had changed. + @Test("A CONFIG SET one node refused names the node it already changed") + func refusedAdminBroadcast() async throws { + let cluster = try await StubRedisCluster.connect( + first: [.success(.status("OK"))], + second: [.success(.error("NOPERM User limited has no permissions to run the 'config|set' command"))] + ) + do { + _ = try await cluster.channel.executeCommand(["CONFIG", "SET", "maxmemory", "0"], scope: .session) + Issue.record("expected a partial write") + } catch let partial as RedisPartialClusterWrite { + #expect(partial.pluginErrorDetail?.hasSuffix("Nodes it ran on: 127.0.0.1:7000") == true) + } + } + @Test("DBSIZE goes to every primary and the counts add up") func dbsizeSums() async throws { let cluster = try await StubRedisCluster.connect(first: [.success(.integer(2))], second: [.success(.integer(3))]) @@ -250,6 +266,21 @@ struct RedisClusterDatabaseSelectionTests { #expect(cluster.channel.homeDatabase() == 0) } + /// On a cluster the visit is taken per command, so this is the node's own check. Queued, the + /// write would run on the home database when EXEC runs. + @Test("A write on another database is refused on a primary holding an open block") + func visitRefusedInsideBlock() async throws { + let cluster = try await StubRedisCluster.connect(clusterDatabases: Self.sixteen) + cluster.second.observeOpenBlock() + await #expect(throws: RedisHeldBackCommand(command: "HSET", held: .openBlock)) { + try await cluster.channel.withDatabase(3) { + try await cluster.channel.executeCommand(["HSET", "a", "f", "v"], scope: .session) + } + } + #expect(cluster.second.sentCommands.isEmpty) + #expect(cluster.channel.homeDatabase() == 0) + } + @Test("A read on another database visits it on every primary and leaves the cluster home") func visitReachesEveryPrimary() async throws { let emptyPage = RedisReply.array([.string("0"), .array([])]) @@ -266,21 +297,6 @@ struct RedisClusterDatabaseSelectionTests { let page = try await cluster.channel.scanKeyspace( cursor: cursor, pattern: nil, type: nil, count: 10, scope: .outsideBlock ) - /// On a cluster the visit is taken per command, so this is the node's own check. Queued, the - /// write would run on the home database when EXEC runs. - @Test("A write on another database is refused on a primary holding an open block") - func visitRefusedInsideBlock() async throws { - let cluster = try await StubRedisCluster.connect(clusterDatabases: Self.sixteen) - cluster.second.observeOpenBlock() - await #expect(throws: RedisHeldBackCommand(command: "HSET", held: .openBlock)) { - try await cluster.channel.withDatabase(3) { - try await cluster.channel.executeCommand(["HSET", "a", "f", "v"], scope: .session) - } - } - #expect(cluster.second.sentCommands.isEmpty) - #expect(cluster.channel.homeDatabase() == 0) - } - keys += page.keys cursor = page.cursor } while cursor != RedisClusterCursor.start diff --git a/TableProTests/Plugins/RedisCommandRoutingTests.swift b/TableProTests/Plugins/RedisCommandRoutingTests.swift index 8742b7495e..8d29e61913 100644 --- a/TableProTests/Plugins/RedisCommandRoutingTests.swift +++ b/TableProTests/Plugins/RedisCommandRoutingTests.swift @@ -111,6 +111,14 @@ struct RedisCommandRoutingPolicyTests { #expect(spec?.responsePolicy == .allSucceeded) } + @Test("A fan-out that is no read and wants every node to succeed changes what it reaches") + func changesEveryNodeItReaches() { + #expect(routing.spec(for: args("FLUSHDB"))?.changesEveryNodeItReaches == true) + #expect(routing.spec(for: args("CONFIG", "SET", "maxmemory", "0"))?.changesEveryNodeItReaches == true) + #expect(routing.spec(for: args("DBSIZE"))?.changesEveryNodeItReaches == false) + #expect(routing.spec(for: args("KEYS", "*"))?.changesEveryNodeItReaches == false) + } + @Test("INFO is special, so it goes to one node rather than being merged") func info() { #expect(routing.spec(for: args("INFO"))?.responsePolicy == .special) From 4e9bc1c4dbb7c6756e74fe34d52c01d7b7b3e597 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:32 +0700 Subject: [PATCH 26/30] fix(ios): drop a Redis key read that a newer one overtook --- .../ViewModels/DataBrowserViewModel.swift | 15 ++++++---- .../DataBrowserViewModelTests.swift | 27 +++++++++++++++++ .../Mocks/MockKeyContentsDriver.swift | 29 ++++++++++++++++--- 3 files changed, 62 insertions(+), 9 deletions(-) diff --git a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift index 57f492eb7e..0d844e18c6 100644 --- a/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/DataBrowserViewModel.swift @@ -51,6 +51,7 @@ final class DataBrowserViewModel { @ObservationIgnored private var host: String = "" @ObservationIgnored private var fetchTask: Task? @ObservationIgnored private var searchTask: Task? + @ObservationIgnored private var keyReadTask: Task? init(windowCapacity: Int = 1_000) { self.buffer = StreamingResultBuffer(capacity: windowCapacity) @@ -173,12 +174,14 @@ final class DataBrowserViewModel { private func loadKeyContents(reader: any KeyContentsBrowsing, key: String) async { let start = Date() + let limit = pagination.pageSize + let offset = pagination.currentOffset + keyReadTask?.cancel() + let read = Task { try await reader.keyContentsPage(ofKey: key, limit: limit, offset: offset) } + keyReadTask = read do { - let page = try await reader.keyContentsPage( - ofKey: key, - limit: pagination.pageSize, - offset: pagination.currentOffset - ) + let page = try await read.value + guard keyReadTask == read, !read.isCancelled else { return } columnDetails = page.result.columns foreignKeys = [] pagination.totalRows = page.totalCount @@ -194,6 +197,7 @@ final class DataBrowserViewModel { } settleTotalRowsFromShortPage() } catch { + guard keyReadTask == read, !read.isCancelled else { return } loadError = ErrorClassifier.classify( error, context: ErrorContext(operation: "loadKeyContents", databaseType: databaseType, host: host) @@ -502,6 +506,7 @@ final class DataBrowserViewModel { } func cancel() { + keyReadTask?.cancel() fetchTask?.cancel() buffer.cancelFlush() searchTask?.cancel() diff --git a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift index b30d4a6af4..2b0b842165 100644 --- a/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift +++ b/TableProMobile/TableProMobileTests/DataBrowserViewModelTests.swift @@ -414,6 +414,33 @@ struct DataBrowserViewModelTests { #expect(vm.isLoading == false) } + @Test("a key read overtaken by a newer one leaves the newer page on screen") + func overtakenKeyReadIsDropped() async { + let driver = MockKeyContentsDriver() + let vm = DataBrowserViewModel() + let pageSize = vm.pagination.pageSize + driver.scriptedPages = [ + .success(keyPage(from: 0, count: pageSize, total: pageSize * 3)), + .success(keyPage(from: pageSize, count: pageSize, total: pageSize * 3)) + ] + driver.holdsFirstRequest = true + let session = ConnectionSession(connectionId: UUID(), driver: driver, activeDatabase: "db0", tables: []) + vm.attach(session: session, table: TableInfo(name: "queue"), databaseType: .redis, host: "localhost") + + let overtaken = Task { await vm.load(isInitial: true) } + while !driver.isHoldingRequest { + try? await Task.sleep(for: .milliseconds(5)) + } + await vm.load() + driver.releaseHeldRequest() + await overtaken.value + + #expect(driver.pageRequests.count == 2) + #expect(vm.legacyRows.first == [String(pageSize), "e\(pageSize)"]) + #expect(vm.loadError == nil) + #expect(vm.isLoading == false) + } + @Test("a short key page with no count settles the total from what arrived") func shortKeyPageSettlesTotal() async { let driver = MockKeyContentsDriver() diff --git a/TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift b/TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift index e5c9a16658..994dcf4aa3 100644 --- a/TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift +++ b/TableProMobile/TableProMobileTests/Mocks/MockKeyContentsDriver.swift @@ -11,6 +11,22 @@ final class MockKeyContentsDriver: KeyContentsBrowsing, @unchecked Sendable { } var scriptedPages: [Result] = [] + var holdsFirstRequest = false + + private let holdLock = NSLock() + private var heldRequest: CheckedContinuation? + + var isHoldingRequest: Bool { + holdLock.withLock { heldRequest != nil } + } + + func releaseHeldRequest() { + let held = holdLock.withLock { + defer { heldRequest = nil } + return heldRequest + } + held?.resume() + } private(set) var pageRequests: [PageRequest] = [] private(set) var executedQueries: [String] = [] @@ -24,13 +40,18 @@ final class MockKeyContentsDriver: KeyContentsBrowsing, @unchecked Sendable { func keyContentsPage(ofKey key: String, limit: Int, offset: Int) async throws -> KeyContentsPage { pageRequests.append(PageRequest(key: key, limit: limit, offset: offset)) - guard !scriptedPages.isEmpty else { - return KeyContentsPage( + let answer: Result = scriptedPages.isEmpty + ? .success(KeyContentsPage( result: QueryResult(columns: [], rows: [], rowsAffected: 0, executionTime: 0), totalCount: 0 - ) + )) + : scriptedPages.removeFirst() + if holdsFirstRequest, pageRequests.count == 1 { + await withCheckedContinuation { continuation in + holdLock.withLock { heldRequest = continuation } + } } - return try scriptedPages.removeFirst().get() + return try answer.get() } func connect() async throws {} From 18db1079662aa6bc57d13aacc1440c619833944e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:32 +0700 Subject: [PATCH 27/30] fix(mcp): refuse analyze with an explain variant that only estimates --- .../Core/MCP/MCPConnectionBridge+Data.swift | 23 +++++++++++++------ TablePro/Resources/Localizable.xcstrings | 3 +++ .../Core/MCP/MCPExplainStatementTests.swift | 17 +++++++++++++- docs/external-api/mcp-tools.mdx | 2 +- 4 files changed, 36 insertions(+), 9 deletions(-) diff --git a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift index 6a47f1ddf0..693ff67b05 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge+Data.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge+Data.swift @@ -345,9 +345,6 @@ extension MCPConnectionBridge { return .object(payload) } - /// Only a variant the engine declares is sent, which is the editor's own rule. Making up an - /// `EXPLAIN` for an engine without one sent `EXPLAIN GET k` to Redis, and answering `analyze` - /// with the first variant returned an estimate to a caller who asked for a measured run. static func explainStatement( for query: String, databaseType: DatabaseType, @@ -374,16 +371,20 @@ extension MCPConnectionBridge { first: ExplainVariant ) throws -> ExplainVariant { let offered = variants.map(\.id).joined(separator: ", ") + let running = variants.filter { $0.sqlPrefix.uppercased().contains("ANALYZE") } + let chosen: ExplainVariant if let id { guard let variant = variants.first(where: { $0.id == id }) else { throw DatabaseAccessError.invalidArgument( String(format: String(localized: "Unknown explain variant '%@'. This database offers: %@."), id, offered) ) } - return variant + chosen = variant + } else { + chosen = analyze ? running.first ?? first : first } - guard analyze else { return first } - guard let variant = variants.first(where: { $0.sqlPrefix.uppercased().contains("ANALYZE") }) else { + guard analyze, !running.contains(where: { $0.id == chosen.id }) else { return chosen } + guard !running.isEmpty else { throw DatabaseAccessError.invalidArgument( String( format: String( @@ -393,7 +394,15 @@ extension MCPConnectionBridge { ) ) } - return variant + throw DatabaseAccessError.invalidArgument( + String( + format: String( + localized: "The '%1$@' variant does not run the statement. Leave 'analyze' off, or pass one that does: %2$@." + ), + chosen.id, + running.map(\.id).joined(separator: ", ") + ) + ) } static func explainVariants(for databaseType: DatabaseType) -> JsonValue { diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index 72b5a499ef..57ca5456c2 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -182545,6 +182545,9 @@ }, "A renamed column's foreign key is rewritten, so its MATCH and DEFERRABLE clauses are not carried over." : { + }, + "The '%1$@' variant does not run the statement. Leave 'analyze' off, or pass one that does: %2$@." : { + } }, "version" : "1.1" diff --git a/TableProTests/Core/MCP/MCPExplainStatementTests.swift b/TableProTests/Core/MCP/MCPExplainStatementTests.swift index 250219caca..5152d79a04 100644 --- a/TableProTests/Core/MCP/MCPExplainStatementTests.swift +++ b/TableProTests/Core/MCP/MCPExplainStatementTests.swift @@ -49,7 +49,22 @@ struct MCPExplainStatementTests { func analyzeWithoutRunningVariantIsRefused() throws { let refusal = message { try statement("SELECT 1", .redshift, analyze: true) } #expect(refusal?.contains("explain, verbose") == true) - #expect(try statement("SELECT 1", .redshift, variant: "verbose", analyze: true) == "EXPLAIN VERBOSE SELECT 1") + #expect(message { try statement("SELECT 1", .redshift, variant: "verbose", analyze: true) } == refusal) + #expect(try statement("SELECT 1", .redshift, variant: "verbose") == "EXPLAIN VERBOSE SELECT 1") + } + + @Test("A variant that only estimates cannot answer analyze, and the refusal names the ones that run") + func estimatingVariantRefusesAnalyze() throws { + let refusal = message { try statement("SELECT 1", .cockroachdb, variant: "explain", analyze: true) } + #expect(refusal == String( + format: String( + localized: "The '%1$@' variant does not run the statement. Leave 'analyze' off, or pass one that does: %2$@." + ), + "explain", + "analyze" + )) + #expect(try statement("SELECT 1", .cockroachdb, variant: "analyze", analyze: true) == "EXPLAIN ANALYZE SELECT 1") + #expect(try statement("SELECT 1", .cockroachdb, variant: "analyze") == "EXPLAIN ANALYZE SELECT 1") } @Test("An unknown variant names the ones the engine offers") diff --git a/docs/external-api/mcp-tools.mdx b/docs/external-api/mcp-tools.mdx index b9f2eb7230..56e03bf343 100644 --- a/docs/external-api/mcp-tools.mdx +++ b/docs/external-api/mcp-tools.mdx @@ -115,7 +115,7 @@ One statement, 100 KB at most. `DROP` and `TRUNCATE` are refused here; use `conf ### `explain_query` -Pass the query with no `EXPLAIN` prefix. Without `variant` the engine's first variant runs, and every result lists them in `available_variants[]`. `analyze: true` picks the variant that runs the statement, so an analyzed write needs `tools:write` and Safe Mode approval, and an engine with no such variant refuses it. An engine missing from the [support table](/features/explain-visualization#database-support) is refused. +Pass the query with no `EXPLAIN` prefix. Without `variant` the engine's first variant runs, and every result lists them in `available_variants[]`. `analyze: true` picks the variant that runs the statement, so an analyzed write needs `tools:write` and Safe Mode approval, and it is refused when the engine has no such variant or the `variant` you pass only estimates. An engine missing from the [support table](/features/explain-visualization#database-support) is refused. ### `export_data` From 1c9948b977f8a44671b38566f7a77e8c2495334d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:32 +0700 Subject: [PATCH 28/30] fix(connections): show the live Redis database in a new window and keep the post-connect switch --- CHANGELOG.md | 3 +- .../Database/DatabaseManager+Sessions.swift | 15 ++++++++-- .../Infrastructure/SessionStateFactory.swift | 5 ++-- .../Views/Main/SessionStateFactoryTests.swift | 28 +++++++++++++++++++ 4 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 069a69a01d..97446365ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -254,6 +254,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `explain_query` sending an invented `EXPLAIN` to databases without one, and an estimate when `analyze` was asked for. - Explain on Redshift failing on PostgreSQL's `FORMAT JSON` and `ANALYZE` options. - Decimal points and minus signs accepted in DuckDB Port and BigQuery Max Bytes Billed. +- Wrong Redis database in the toolbar of a second window opened on the same connection. ### Security @@ -275,8 +276,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Statements hidden behind an engine's own literal or comment forms, such as `E'\''`, `'''` or `--1`, skipped Safe Mode. - Statements hidden the same ways passed the one-statement check on MCP and AI chat queries. - Writes hidden in a dollar-quoted string, a nested comment or a bracketed identifier skipped Safe Mode on iPhone and iPad. - - A quoted Redis command such as `"FLUSHALL"` skipping Safe Mode and the MCP destructive-statement check. + ## [0.75.0] - 2026-09-18 Runs on macOS 13 Ventura and later. diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 581f1bedb0..ed2d55a781 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -289,9 +289,18 @@ extension DatabaseManager { } } case .selectDatabaseFromConnectionField(let fieldId): - activeSessions[connection.id]?.browseDatabase = String( - resolvedConnection.databaseIndex(selectedBy: fieldId) - ) + let initialDb = resolvedConnection.databaseIndex(selectedBy: fieldId) + if initialDb != 0 { + do { + try await (driver as? PluginDriverAdapter)?.switchDatabase(to: String(initialDb)) + } catch { + Self.logger.error( + "Failed to switch to database \(initialDb): \(error.publicLogShape, privacy: .public)" + ) + continue + } + } + activeSessions[connection.id]?.browseDatabase = String(initialDb) case .selectSchemaFromLastSession: if let schemaDriver = driver as? SchemaSwitchable, let savedSchema = appSettingsStorage.loadLastSchema(for: connection.id) { diff --git a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift index 6a372a1029..fa794da2f8 100644 --- a/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift +++ b/TablePro/Core/Services/Infrastructure/SessionStateFactory.swift @@ -77,12 +77,13 @@ enum SessionStateFactory { changeMgr.databaseType = connection.type let toolbarSt = ConnectionToolbarState(connection: connection) - if let session = DatabaseManager.shared.session(for: connection.id) { + let session = DatabaseManager.shared.session(for: connection.id) + if let session { toolbarSt.updateConnectionState(from: session.reportedStatus) } if connection.type.pluginTypeId == "Redis" { - toolbarSt.currentDatabase = String(connection.redisDatabaseIndex) + toolbarSt.currentDatabase = session?.browseDatabase ?? String(connection.redisDatabaseIndex) } if let payload { diff --git a/TableProTests/Views/Main/SessionStateFactoryTests.swift b/TableProTests/Views/Main/SessionStateFactoryTests.swift index 799101cf76..b77c792b37 100644 --- a/TableProTests/Views/Main/SessionStateFactoryTests.swift +++ b/TableProTests/Views/Main/SessionStateFactoryTests.swift @@ -158,6 +158,34 @@ struct SessionStateFactoryTests { #expect(state.tabManager.tabs.first?.tableContext.schemaName == nil) } + @Test("A Redis window opened on a live session shows the database the session is on") + @MainActor + func redisWindowFollowsTheLiveSession() { + let conn = DatabaseConnection(name: "cache", type: .redis, additionalFields: ["redisDatabase": "4"]) + var session = ConnectionSession(connection: conn) + session.browseDatabase = "7" + DatabaseManager.shared.injectSession(session, for: conn.id) + defer { DatabaseManager.shared.removeSession(for: conn.id) } + + let state = SessionStateFactory.create(connection: conn, payload: nil) + + #expect(state.toolbarState.currentDatabase == "7") + } + + @Test("A Redis window with no session shows the database the connection opens on") + @MainActor + func redisWindowWithoutSessionShowsTheConfiguredDatabase() { + let named = DatabaseConnection(name: "cache", type: .redis, additionalFields: ["redisDatabase": "db4"]) + let cluster = DatabaseConnection( + name: "cluster", + type: .redis, + additionalFields: ["redisDatabase": "4", "redisMode": "cluster"] + ) + + #expect(SessionStateFactory.create(connection: named, payload: nil).toolbarState.currentDatabase == "4") + #expect(SessionStateFactory.create(connection: cluster, payload: nil).toolbarState.currentDatabase == "0") + } + @Test("Nil payload creates empty tab manager") @MainActor func nilPayload_createsEmptyTabManager() { From da8f337c003bfa8f4dd6e45ad39ff5dc24085275 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:33 +0700 Subject: [PATCH 29/30] refactor: drop explanatory comments from this branch's new code --- .../PluginMetadataRegistry+SnapshotAdoption.swift | 9 +++------ .../DatabaseConnection+RedisDatabaseIndex.swift | 6 ------ TablePro/Models/Query/QueryTab+Protection.swift | 2 -- 3 files changed, 3 insertions(+), 14 deletions(-) diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift index a6945aec79..194d242f14 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+SnapshotAdoption.swift @@ -72,12 +72,6 @@ extension PluginMetadataRegistry { ) } - /// A name is a system database or schema when either the plugin or the app's curated entry lists it. An installed - /// plugin can predate the app's list or report none at all: every published Oracle plugin lists no system - /// schemas, which left `SYS` and `XDB` among the user schemas whichever plugin version was installed. - /// A plugin that declares no explain variants says nothing about Explain, which is not the - /// same as switching it off: DuckDB's plugin declares none and lost the curated `EXPLAIN` the - /// moment it loaded. A plugin that declares its own list still wins. static func adoptCuratedExplainVariants( _ snapshot: inout PluginMetadataSnapshot, registryDefault: PluginMetadataSnapshot @@ -86,6 +80,9 @@ extension PluginMetadataRegistry { snapshot = snapshot.withExplainVariants(registryDefault.explainVariants) } + /// A name is a system database or schema when either the plugin or the app's curated entry lists it. An installed + /// plugin can predate the app's list or report none at all: every published Oracle plugin lists no system + /// schemas, which left `SYS` and `XDB` among the user schemas whichever plugin version was installed. static func adoptCuratedSystemNames( _ snapshot: inout PluginMetadataSnapshot, registryDefault: PluginMetadataSnapshot diff --git a/TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift b/TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift index 1e6f133033..bb59172b37 100644 --- a/TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift +++ b/TablePro/Models/Connection/DatabaseConnection+RedisDatabaseIndex.swift @@ -9,16 +9,10 @@ extension DatabaseConnection { private static let redisModeFieldId = "redisMode" private static let redisClusterMode = "cluster" - /// The database a Redis connection opens on. A cluster always starts on database 0, whatever a - /// Database Index field left over from a Standalone setup still holds, because the field is - /// hidden in Cluster mode and nothing on screen would say where that value came from. var redisDatabaseIndex: Int { isRedisCluster ? 0 : configuredRedisDatabaseIndex } - /// The index the connection names, read the way the Redis plugin and the iOS app read it: the - /// Database Index field, then the value saved before that field existed, then the database - /// name, where a synced `db4` means 4. var configuredRedisDatabaseIndex: Int { additionalFields[RedisDatabaseIndex.fieldName].flatMap(RedisDatabaseIndex.parse) ?? redisDatabase diff --git a/TablePro/Models/Query/QueryTab+Protection.swift b/TablePro/Models/Query/QueryTab+Protection.swift index 6a7a7716bf..5719b22370 100644 --- a/TablePro/Models/Query/QueryTab+Protection.swift +++ b/TablePro/Models/Query/QueryTab+Protection.swift @@ -2,8 +2,6 @@ import Foundation import TableProSQLGrammar extension QueryTab { - /// The run path's own rule, so a command is offered exactly when running it would send - /// something: whitespace, control and zero-width characters alone run nothing. var hasQueryText: Bool { StatementBlank.hasContent(content.query) } From 9ea715962d8a4372c0708bd27d422e096a87523e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Mon, 21 Sep 2026 19:43:45 +0700 Subject: [PATCH 30/30] docs(plugin-redis): say a key opened from the tree leaves the editor's database alone --- docs/databases/redis.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/databases/redis.mdx b/docs/databases/redis.mdx index e354dcc093..bb72543825 100644 --- a/docs/databases/redis.mdx +++ b/docs/databases/redis.mdx @@ -128,9 +128,9 @@ HGETALL myhash; LRANGE mylist 0 -1 SCAN 0 MATCH user:* COUNT 100 ``` -`SELECT` moves the editor's session only. The commands after it run on that database, while the sidebar, the key tree and the database tab stay on the database you clicked. Clicking a database, or opening a key from the tree, moves the editor to that database. +`SELECT` moves the editor's session only. The commands after it run on that database, while the sidebar, the key tree and the database tab stay on the database you clicked. Clicking a database moves the editor to that database. -`DB 3 GET key` runs one command on database 3 and leaves the session where it was. Grid saves on a cluster write this way. +`DB 3 GET key` runs one command on database 3 and leaves the session where it was. A key opened from the tree is read this way, and so are grid saves on a cluster. ### MULTI blocks