From 4624b0b957fe1e4e7a79b47464b42a82230c19a0 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 23:06:47 +0700 Subject: [PATCH 1/2] fix(plugin-postgresql): probe catalog presence so compatible engines without pg_matviews load (#1383) --- CHANGELOG.md | 1 + .../PostgreSQLCatalogPresence.swift | 33 +++++++ .../PostgreSQLPluginDriver.swift | 77 +++++++++------ .../PostgreSQLSchemaQueries.swift | 43 ++++++++ .../PostgreSQLCatalogPresence.swift | 1 + .../PostgreSQLCatalogCompatibilityTests.swift | 98 +++++++++++++++++++ docs/databases/postgresql.mdx | 2 + 7 files changed, 224 insertions(+), 31 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift create mode 120000 TableProTests/PluginTestSources/PostgreSQLCatalogPresence.swift create mode 100644 TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 40f0ce817..096c2910b 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 +- Connecting to a PostgreSQL-compatible engine that doesn't implement the pg_matviews catalog (such as db9.ai) no longer fails to load tables. (#1383) - Reopening a table now restores the filter you had applied, instead of clearing it. Filters are remembered per connection. (#1347) - Importing connections from TablePlus brings over saved passwords again. A recent release looked under the wrong keychain name, so connections imported with no passwords and no warning. - Importing an SSH connection from TablePlus no longer fills in a fake private key path such as `~/.ssh/Import a private key...` when no key was selected. Empty TLS certificate paths are skipped too. diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift new file mode 100644 index 000000000..8b703e7ef --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift @@ -0,0 +1,33 @@ +// +// PostgreSQLCatalogPresence.swift +// PostgreSQLDriverPlugin +// + +import Foundation + +struct PostgreSQLCatalogPresence: Sendable, Equatable { + var hasMaterializedViews: Bool + var hasForeignTables: Bool + var hasSequences: Bool + + static let probeQuery = """ + SELECT c.relname + FROM pg_catalog.pg_class c + JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = 'pg_catalog' + AND c.relname IN ('pg_matviews', 'pg_foreign_table', 'pg_sequences') + """ + + init(hasMaterializedViews: Bool, hasForeignTables: Bool, hasSequences: Bool) { + self.hasMaterializedViews = hasMaterializedViews + self.hasForeignTables = hasForeignTables + self.hasSequences = hasSequences + } + + init(relationNames: [String]) { + let names = Set(relationNames) + self.hasMaterializedViews = names.contains("pg_matviews") + self.hasForeignTables = names.contains("pg_foreign_table") + self.hasSequences = names.contains("pg_sequences") + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index c67de3fef..c68a22732 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -15,6 +15,10 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "PostgreSQLPluginDriver") + private static let undefinedTableSQLState = "42P01" + + private var catalogPresence: PostgreSQLCatalogPresence? + var serverVersionNumber: Int32 { core.serverVersionNumber } var versionedCapabilities: PostgreSQLCapabilities { PostgreSQLCapabilities(serverVersion: core.serverVersionNumber) @@ -39,6 +43,33 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { self.core = LibPQDriverCore(config: config) } + // MARK: - Connection + + func connect() async throws { + try await core.connect() + await probeCatalogPresence() + } + + private func probeCatalogPresence() async { + guard let result = try? await core.execute(query: PostgreSQLCatalogPresence.probeQuery) else { + return + } + let relationNames = result.rows.compactMap { $0.first?.asText } + catalogPresence = PostgreSQLCatalogPresence(relationNames: relationNames) + } + + private func includesMaterializedViews() -> Bool { + catalogPresence?.hasMaterializedViews ?? versionedCapabilities.hasMaterializedViewsCatalog + } + + private func includesForeignTables() -> Bool { + catalogPresence?.hasForeignTables ?? versionedCapabilities.hasForeignTablesCatalog + } + + private func includesSequencesCatalog() -> Bool { + catalogPresence?.hasSequences ?? versionedCapabilities.hasSequencesCatalog + } + // MARK: - EXPLAIN func buildExplainQuery(_ sql: String) -> String? { @@ -101,40 +132,24 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let caps = versionedCapabilities - - var unions: [String] = [ - """ - SELECT table_name, table_type FROM information_schema.tables - WHERE table_schema = '\(schemaLiteral)' - AND table_type IN ('BASE TABLE', 'VIEW') - """ - ] - - if caps.hasMaterializedViewsCatalog { - unions.append( - """ - SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type - FROM pg_matviews - WHERE schemaname = '\(schemaLiteral)' - """ - ) - } + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: schemaLiteral, + includeMaterializedViews: includesMaterializedViews(), + includeForeignTables: includesForeignTables() + ) - if caps.hasForeignTablesCatalog { - unions.append( - """ - SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type - FROM pg_foreign_table ft - JOIN pg_class c ON c.oid = ft.ftrelid - JOIN pg_namespace n ON n.oid = c.relnamespace - WHERE n.nspname = '\(schemaLiteral)' - """ + let result: PluginQueryResult + do { + result = try await execute(query: query) + } catch let error as LibPQPluginError where error.sqlState == Self.undefinedTableSQLState { + let baseQuery = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: schemaLiteral, + includeMaterializedViews: false, + includeForeignTables: false ) + result = try await execute(query: baseQuery) } - let query = unions.joined(separator: "\nUNION ALL\n") + "\nORDER BY table_name" - let result = try await execute(query: query) return result.rows.compactMap { row -> PluginTableInfo? in guard let name = row[0].asText else { return nil } let typeStr = row[1].asText ?? "BASE TABLE" @@ -533,7 +548,7 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } func fetchDependentSequences(table: String, schema: String?) async throws -> [(name: String, ddl: String)] { - guard versionedCapabilities.hasSequencesCatalog else { return [] } + guard includesSequencesCatalog() else { return [] } let safeTable = escapeLiteral(table) let query = """ SELECT s.sequencename, diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index fc28c29a4..7fbe604d7 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -32,4 +32,47 @@ enum PostgreSQLSchemaQueries { AND has_schema_privilege(current_user, nspname, 'USAGE') ORDER BY nspname """ + + /// Lists tables and views, optionally including materialized views and + /// foreign tables. The optional unions reference `pg_matviews` and + /// `pg_foreign_table`, which some PostgreSQL-compatible engines do not + /// implement; the caller passes `false` when those catalogs are absent so + /// the whole query does not fail with `relation does not exist`. + static func fetchTables( + schemaLiteral: String, + includeMaterializedViews: Bool, + includeForeignTables: Bool + ) -> String { + var unions: [String] = [ + """ + SELECT table_name, table_type FROM information_schema.tables + WHERE table_schema = '\(schemaLiteral)' + AND table_type IN ('BASE TABLE', 'VIEW') + """ + ] + + if includeMaterializedViews { + unions.append( + """ + SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type + FROM pg_matviews + WHERE schemaname = '\(schemaLiteral)' + """ + ) + } + + if includeForeignTables { + unions.append( + """ + SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type + FROM pg_foreign_table ft + JOIN pg_class c ON c.oid = ft.ftrelid + JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE n.nspname = '\(schemaLiteral)' + """ + ) + } + + return unions.joined(separator: "\nUNION ALL\n") + "\nORDER BY table_name" + } } diff --git a/TableProTests/PluginTestSources/PostgreSQLCatalogPresence.swift b/TableProTests/PluginTestSources/PostgreSQLCatalogPresence.swift new file mode 120000 index 000000000..22d9a804e --- /dev/null +++ b/TableProTests/PluginTestSources/PostgreSQLCatalogPresence.swift @@ -0,0 +1 @@ +../../Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift \ No newline at end of file diff --git a/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift b/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift new file mode 100644 index 000000000..cdb0db3e8 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift @@ -0,0 +1,98 @@ +// +// PostgreSQLCatalogCompatibilityTests.swift +// TableProTests +// +// Regression cover for #1383: PostgreSQL-compatible engines (e.g. db9.ai) +// report a recent Postgres version but lack optional catalogs like +// pg_matviews. fetchTables must omit those unions when the catalog is absent, +// and the catalog probe must parse presence from pg_class relation names. +// + +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQLSchemaQueries.fetchTables") +struct PostgreSQLFetchTablesQueryTests { + @Test("Always selects base tables and views from information_schema") + func alwaysIncludesBaseTables() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: true, + includeForeignTables: true + ) + #expect(query.contains("information_schema.tables")) + } + + @Test("Omits the pg_matviews union when materialized views are unavailable") + func omitsMatviewsWhenAbsent() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: false, + includeForeignTables: true + ) + #expect(!query.contains("pg_matviews")) + } + + @Test("Includes the pg_matviews union when materialized views are available") + func includesMatviewsWhenPresent() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: true, + includeForeignTables: false + ) + #expect(query.contains("pg_matviews")) + } + + @Test("Omits the pg_foreign_table union when foreign tables are unavailable") + func omitsForeignTablesWhenAbsent() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: true, + includeForeignTables: false + ) + #expect(!query.contains("pg_foreign_table")) + } + + @Test("With no optional catalogs, only the base query remains") + func baseOnlyWhenNoOptionalCatalogs() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: false, + includeForeignTables: false + ) + #expect(query.contains("information_schema.tables")) + #expect(!query.contains("pg_matviews")) + #expect(!query.contains("pg_foreign_table")) + #expect(!query.contains("UNION ALL")) + } +} + +@Suite("PostgreSQLCatalogPresence") +struct PostgreSQLCatalogPresenceTests { + @Test("Parses a single present catalog") + func parsesSingleCatalog() { + let presence = PostgreSQLCatalogPresence(relationNames: ["pg_matviews"]) + #expect(presence.hasMaterializedViews) + #expect(!presence.hasForeignTables) + #expect(!presence.hasSequences) + } + + @Test("Parses all catalogs present") + func parsesAllCatalogs() { + let presence = PostgreSQLCatalogPresence( + relationNames: ["pg_matviews", "pg_foreign_table", "pg_sequences"] + ) + #expect(presence.hasMaterializedViews) + #expect(presence.hasForeignTables) + #expect(presence.hasSequences) + } + + @Test("Empty probe result means no optional catalogs") + func parsesEmpty() { + let presence = PostgreSQLCatalogPresence(relationNames: []) + #expect(!presence.hasMaterializedViews) + #expect(!presence.hasForeignTables) + #expect(!presence.hasSequences) + } +} diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 19eb72abc..10ce91f7c 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -107,6 +107,8 @@ Supports `jsonb` (formatted JSON), `array`, `uuid`, `inet` (IP), `timestamp with **SSL/TLS**: New connections default to **Preferred** (libpq `sslmode=prefer`), which tries TLS first and falls back to plain. Works for both local Postgres and hosted providers (AWS RDS, Cloud SQL, Heroku, Supabase, Neon). Pick **Verify CA** when you need to validate the server certificate. See [SSL/TLS](/features/ssl) for details. +**Postgres-compatible engines**: Connect wire-compatible engines (such as db9.ai) using the PostgreSQL type. TablePro checks which system catalogs each server actually provides, so engines that omit catalogs like `pg_matviews` still load their tables; materialized views or foreign tables just won't appear if the server doesn't expose them. + ## Advanced Configuration **Startup Commands** (Advanced tab): Set session variables like `SET timezone = 'UTC'; SET search_path TO myschema, public;` to apply automatically on connect. From c549a2d0b021bc0a31ab34765d093fd3be8c54c6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 22 May 2026 23:13:33 +0700 Subject: [PATCH 2/2] refactor(plugin-postgresql): drop unused catalog-presence init and log probe failures (#1383) --- .../PostgreSQLCatalogPresence.swift | 12 +++--------- .../PostgreSQLPluginDriver.swift | 10 ++++++---- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift index 8b703e7ef..5af177f30 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift @@ -6,9 +6,9 @@ import Foundation struct PostgreSQLCatalogPresence: Sendable, Equatable { - var hasMaterializedViews: Bool - var hasForeignTables: Bool - var hasSequences: Bool + let hasMaterializedViews: Bool + let hasForeignTables: Bool + let hasSequences: Bool static let probeQuery = """ SELECT c.relname @@ -18,12 +18,6 @@ struct PostgreSQLCatalogPresence: Sendable, Equatable { AND c.relname IN ('pg_matviews', 'pg_foreign_table', 'pg_sequences') """ - init(hasMaterializedViews: Bool, hasForeignTables: Bool, hasSequences: Bool) { - self.hasMaterializedViews = hasMaterializedViews - self.hasForeignTables = hasForeignTables - self.hasSequences = hasSequences - } - init(relationNames: [String]) { let names = Set(relationNames) self.hasMaterializedViews = names.contains("pg_matviews") diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index c68a22732..15ee7f495 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -51,11 +51,13 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } private func probeCatalogPresence() async { - guard let result = try? await core.execute(query: PostgreSQLCatalogPresence.probeQuery) else { - return + do { + let result = try await core.execute(query: PostgreSQLCatalogPresence.probeQuery) + let relationNames = result.rows.compactMap { $0.first?.asText } + catalogPresence = PostgreSQLCatalogPresence(relationNames: relationNames) + } catch { + Self.logger.debug("Catalog presence probe failed; using version-based capabilities: \(error.localizedDescription)") } - let relationNames = result.rows.compactMap { $0.first?.asText } - catalogPresence = PostgreSQLCatalogPresence(relationNames: relationNames) } private func includesMaterializedViews() -> Bool {