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

- 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)
- Filtering a table now updates the row count and page count in the bottom-right to match the filtered result, instead of showing the whole-table totals.
- 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.
Expand Down
27 changes: 27 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLCatalogPresence.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
//
// PostgreSQLCatalogPresence.swift
// PostgreSQLDriverPlugin
//

import Foundation

struct PostgreSQLCatalogPresence: Sendable, Equatable {
let hasMaterializedViews: Bool
let hasForeignTables: Bool
let 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(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")
}
}
79 changes: 48 additions & 31 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -39,6 +43,35 @@ 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 {
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)")
}
}

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? {
Expand Down Expand Up @@ -101,40 +134,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"
Expand Down Expand Up @@ -533,7 +550,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,
Expand Down
43 changes: 43 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
98 changes: 98 additions & 0 deletions TableProTests/Plugins/PostgreSQLCatalogCompatibilityTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
2 changes: 2 additions & 0 deletions docs/databases/postgresql.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading