From d57e689f452cca9fbdc5d5147dc517634c584fc4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 23:02:43 +0700 Subject: [PATCH 1/2] fix(mssql, oracle): enable schema switching from Cmd+K quick switcher --- CHANGELOG.md | 1 + ...PluginMetadataRegistry+RegistryDefaults.swift | 9 +++++---- TablePro/ViewModels/QuickSwitcherViewModel.swift | 4 +--- .../MainContentCoordinator+Navigation.swift | 16 ++++++++++++++-- 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f1a1950..ec19dff0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- SQL Server and Oracle connections silently ignored schema selection from the Cmd+K Quick Switcher. Picking a schema like `rpt` (or any non-default) appeared in the search list but did nothing on selection. Three things were misaligned: `QuickSwitcherViewModel` listed both engines in a hardcoded schema-fetch allowlist (so schemas appeared as searchable items), but `PluginMetadataRegistry` had `supportsSchemaSwitching: false` for both, and `MainContentCoordinator+Navigation.switchSchema` early-returned silently on that flag with no log and no user feedback. The MSSQL and Oracle plugin drivers themselves already implemented `switchSchema(to:)` correctly (MSSQL updates `_currentSchema` for table-listing filters; Oracle runs `ALTER SESSION SET CURRENT_SCHEMA`), so flipping the registry flag is sufficient. `supportsSchemaSwitching` is now `true` for both, post-connect actions include `selectSchemaFromLastSession` so a chosen schema persists across reconnects, the `QuickSwitcherViewModel` allowlist is replaced with `PluginManager.shared.supportsSchemaSwitching(for:)` so future engines auto-pick up the right behavior, and the early-return in `switchSchema` now logs and surfaces a `Schema Switching Not Supported` alert instead of failing silently. Reported in r/macapps feedback. - iOS: app crashed with `EXC_BREAKPOINT` "Not enough bits to represent the passed value" when opening some MySQL tables (TestFlight report on a 100k-record table). `MySQLActor.execute` did `Int(mysql_affected_rows(mysql))`, but libmariadb is documented to return `~(my_ulonglong)0` (= `UInt64.max`) as an error sentinel ("for a SELECT, mysql_affected_rows() was called prior to mysql_store_result()") and the unchecked Int conversion trapped on the sentinel. The same shape applied to per-cell `mysql_fetch_lengths` values, which on arm64 are `unsigned long` (`UInt64`); a length above `Int.max` would trap rather than fail the read recoverably. Both paths now use `Int(clamping:)` and the affected-rows sites explicitly map the `~0` sentinel to `0`. Same hardening applied to the macOS MySQL plugin's two cell-length conversion sites in `MariaDBPluginConnection.swift` (default-fetch and streaming) which had identical exposure but no reported crash. - iOS: connections to `.local` (Bonjour) hostnames and other local-network addresses (10.x, 192.168.x, 172.16-31.x, 169.254.x, IPv6 ULA / link-local) timed out silently. The bundle was missing `NSLocalNetworkUsageDescription` and `NSBonjourServices`, so iOS never prompted the user for Local Network access and quietly dropped every outbound `connect()` to a local-network address. Most visible variant: SSH Tunnel set to `Some-MacBook.local`, error surfaced as "MySQL connection failed: Lost connection to server at 'handshake: reading initial communication packet', system error: 60" (errno 60 = `ETIMEDOUT`). Both Info.plist keys are now declared (purpose string explains database/SSH access; Bonjour types `_ssh._tcp`, `_mysql._tcp`, `_postgresql._tcp`, `_redis._tcp`). A new `LocalNetworkPermission` actor starts an `NWBrowser` for `_ssh._tcp` the first time a connection targets a local-network host (the documented Apple pattern from the DTS "Local Network Privacy FAQ" since a bare `connect()` does not always trigger the consent prompt for `getaddrinfo`-based connections), watches the `NWBrowser.State` stream for `.ready` (granted) or `.waiting`/`.failed` (unavailable), and caches the resolution per process. On denial the gate throws `LocalNetworkPermissionError.unavailable` immediately on every subsequent attempt instead of waiting for the 10-second TCP timeout, so the error surfaces in under a second. Concurrent first-time gate calls share one in-flight resolution `Task` so a parallel SSH + DB connect does not double-prompt. Wired through `SSHTunnelFactory.create()` and `MySQLDriver` / `PostgreSQLDriver` / `RedisDriver` `connect()` (loopback-only and non-local hosts no-op the gate). `ErrorClassifier` matches the typed `LocalNetworkPermissionError` directly and falls back to detecting `ETIMEDOUT` on SSH-enabled or local-network connections; both paths show "Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again." instead of the previous generic "server is not responding" or misleading SSH-handshake copy. - Data grid column headers now use the same 4pt horizontal inset as result cells, including the right-aligned `#` row-number header. diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift index 6d99287c0..cb6fe8fa5 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift @@ -647,13 +647,13 @@ extension PluginMetadataRegistry { isDownloadable: true, primaryUrlScheme: "sqlserver", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [], pathFieldRole: .database, supportsHealthMonitor: true, urlSchemes: ["sqlserver", "mssql"], - postConnectActions: [.selectDatabaseFromLastSession], + postConnectActions: [.selectDatabaseFromLastSession, .selectSchemaFromLastSession], brandColorHex: "#E34517", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, + supportsSchemaSwitching: true, supportsImport: true, supportsExport: true, supportsSSH: true, @@ -698,13 +698,14 @@ extension PluginMetadataRegistry { requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: true, isDownloadable: true, primaryUrlScheme: "oracle", parameterStyle: .questionMark, navigationModel: .standard, explainVariants: [], pathFieldRole: .serviceName, - supportsHealthMonitor: true, urlSchemes: ["oracle"], postConnectActions: [], + supportsHealthMonitor: true, urlSchemes: ["oracle"], + postConnectActions: [.selectSchemaFromLastSession], brandColorHex: "#C3160B", queryLanguageName: "SQL", editorLanguage: .sql, connectionMode: .network, supportsDatabaseSwitching: true, supportsColumnReorder: false, capabilities: PluginMetadataSnapshot.CapabilityFlags( - supportsSchemaSwitching: false, + supportsSchemaSwitching: true, supportsImport: true, supportsExport: true, supportsSSH: true, diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index e76cdfbe2..2714d954f 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -86,9 +86,7 @@ internal final class QuickSwitcherViewModel { Self.logger.warning("Failed to fetch databases for quick switcher: \(error.localizedDescription, privacy: .public)") } - // Schemas (only for databases that support them) - let supportsSchemas = [DatabaseType.postgresql, .redshift, .oracle, .mssql] - if supportsSchemas.contains(databaseType) { + if PluginManager.shared.supportsSchemaSwitching(for: databaseType) { do { let schemas = try await driver.fetchSchemas() for schema in schemas { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index d7b3a3ca9..521eb3c32 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -428,9 +428,21 @@ extension MainContentCoordinator { } } - /// Switch to a different PostgreSQL schema (used for URL-based schema selection) func switchSchema(to schema: String) async { - guard PluginManager.shared.supportsSchemaSwitching(for: connection.type) else { return } + guard PluginManager.shared.supportsSchemaSwitching(for: connection.type) else { + navigationLogger.warning( + "switchSchema(to: \(schema, privacy: .public)) ignored: \(connection.type.rawValue, privacy: .public) does not support schema switching" + ) + AlertHelper.showErrorSheet( + title: String(localized: "Schema Switching Not Supported"), + message: String( + format: String(localized: "%@ does not support switching schemas in TablePro."), + connection.type.rawValue + ), + window: contentWindow + ) + return + } clearFilterState() let previousSchema = toolbarState.databaseName From b974afef32fe1b1d17fdb27c836a9a1f9651e1e0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Wed, 6 May 2026 23:06:59 +0700 Subject: [PATCH 2/2] test(plugin): cover MSSQL and Oracle schema-switching capability flag Also pin plugin-level supportsSchemaSwitching = true for MSSQL and Oracle so plugin metadata, registry default, and Quick Switcher allowlist all agree. --- Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift | 3 +- Plugins/OracleDriverPlugin/OraclePlugin.swift | 2 + ...MetadataRegistrySchemaSwitchingTests.swift | 114 ++++++++++++++++++ 3 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 38d34cd06..d6434d139 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -24,7 +24,8 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin { // MARK: - UI/Capability Metadata - static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession] + static let supportsSchemaSwitching = true + static let postConnectActions: [PostConnectAction] = [.selectDatabaseFromLastSession, .selectSchemaFromLastSession] static let brandColorHex = "#E34517" static let systemDatabaseNames: [String] = ["master", "tempdb", "model", "msdb"] static let defaultSchemaName = "dbo" diff --git a/Plugins/OracleDriverPlugin/OraclePlugin.swift b/Plugins/OracleDriverPlugin/OraclePlugin.swift index a3d8aed87..7d5f38d32 100644 --- a/Plugins/OracleDriverPlugin/OraclePlugin.swift +++ b/Plugins/OracleDriverPlugin/OraclePlugin.swift @@ -26,6 +26,8 @@ final class OraclePlugin: NSObject, TableProPlugin, DriverPlugin, PluginDiagnost static let isDownloadable = true static let pathFieldRole: PathFieldRole = .serviceName static let supportsForeignKeyDisable = false + static let supportsSchemaSwitching = true + static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession] static let brandColorHex = "#C3160B" static let systemDatabaseNames: [String] = ["SYS", "SYSTEM", "OUTLN", "DBSNMP", "APPQOSSYS", "WMSYS", "XDB"] static let databaseGroupingStrategy: GroupingStrategy = .bySchema diff --git a/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift b/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift new file mode 100644 index 000000000..1d28a1fd3 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginMetadataRegistrySchemaSwitchingTests.swift @@ -0,0 +1,114 @@ +// +// PluginMetadataRegistrySchemaSwitchingTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +@MainActor +@Suite("PluginMetadataRegistry schema switching") +struct PluginMetadataRegistrySchemaSwitchingTests { + private func snapshot(forTypeId typeId: String) -> PluginMetadataSnapshot? { + PluginMetadataRegistry.shared.snapshot(forTypeId: typeId) + } + + // MARK: - SQL Server + + @Test("SQL Server supports schema switching") + func sqlServerSupportsSchemaSwitching() { + guard let snap = snapshot(forTypeId: "SQL Server") else { + Issue.record("Registry default for SQL Server missing") + return + } + #expect(snap.capabilities.supportsSchemaSwitching == true) + } + + @Test("SQL Server post-connect actions restore last schema") + func sqlServerRestoresLastSchema() { + guard let snap = snapshot(forTypeId: "SQL Server") else { + Issue.record("Registry default for SQL Server missing") + return + } + #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) + } + + @Test("SQL Server post-connect actions still restore last database") + func sqlServerRestoresLastDatabase() { + guard let snap = snapshot(forTypeId: "SQL Server") else { + Issue.record("Registry default for SQL Server missing") + return + } + #expect(snap.postConnectActions.contains(.selectDatabaseFromLastSession)) + } + + // MARK: - Oracle + + @Test("Oracle supports schema switching") + func oracleSupportsSchemaSwitching() { + guard let snap = snapshot(forTypeId: "Oracle") else { + Issue.record("Registry default for Oracle missing") + return + } + #expect(snap.capabilities.supportsSchemaSwitching == true) + } + + @Test("Oracle post-connect actions restore last schema") + func oracleRestoresLastSchema() { + guard let snap = snapshot(forTypeId: "Oracle") else { + Issue.record("Registry default for Oracle missing") + return + } + #expect(snap.postConnectActions.contains(.selectSchemaFromLastSession)) + } + + // MARK: - PostgreSQL (regression for the working reference) + + @Test("PostgreSQL supports schema switching") + func postgreSQLSupportsSchemaSwitching() { + guard let snap = snapshot(forTypeId: "PostgreSQL") else { + Issue.record("Registry default for PostgreSQL missing") + return + } + #expect(snap.capabilities.supportsSchemaSwitching == true) + } + + // MARK: - Negative cases (engines without schemas) + + @Test("MySQL does not support schema switching") + func mysqlDoesNotSupportSchemaSwitching() { + guard let snap = snapshot(forTypeId: "MySQL") else { + Issue.record("Registry default for MySQL missing") + return + } + #expect(snap.capabilities.supportsSchemaSwitching == false) + } + + @Test("SQLite does not support schema switching") + func sqliteDoesNotSupportSchemaSwitching() { + guard let snap = snapshot(forTypeId: "SQLite") else { + Issue.record("Registry default for SQLite missing") + return + } + #expect(snap.capabilities.supportsSchemaSwitching == false) + } + + // MARK: - Cross-component consistency + + @Test("Quick Switcher allowlist agrees with registry capability flag") + func quickSwitcherAllowlistMatchesRegistry() { + let typesThatShouldSupportSchemas = ["PostgreSQL", "Redshift", "Oracle", "SQL Server"] + for typeId in typesThatShouldSupportSchemas { + guard let snap = snapshot(forTypeId: typeId) else { + Issue.record("Registry default for \(typeId) missing") + continue + } + #expect( + snap.capabilities.supportsSchemaSwitching == true, + "\(typeId) is in the documented schema-aware engine set but registry has supportsSchemaSwitching = false" + ) + } + } +}