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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions Plugins/OracleDriverPlugin/OraclePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 1 addition & 3 deletions TablePro/ViewModels/QuickSwitcherViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
)
}
}
}
Loading