From 0e6eff53f71fd5063bf950263e016958941e63bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 14:04:18 +0700 Subject: [PATCH 1/2] fix(plugin-postgresql): resolve effective schema when public does not exist (#1662) --- CHANGELOG.md | 1 + .../LibPQDriverCore.swift | 20 +++++++++-- .../PostgreSQLPluginDriver.swift | 4 ++- .../PostgreSQLSchemaQueries.swift | 19 ++++++++--- .../Views/Sidebar/SchemaPickerControl.swift | 6 +++- ...PostgreSQLDefaultSchemaFallbackTests.swift | 33 +++++++++++++++++++ .../Plugins/PostgreSQLSearchPathTests.swift | 12 +++---- .../Views/SchemaPickerVisibilityTests.swift | 27 +++++++++++++++ 8 files changed, 108 insertions(+), 14 deletions(-) create mode 100644 TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift create mode 100644 TableProTests/Views/SchemaPickerVisibilityTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f2367d2..0dac4662d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- PostgreSQL databases without a "public" schema now load tables from the first available schema, the schema selector also appears when only one schema exists, and the database list counts tables in every user schema instead of only "public". Switching schemas no longer keeps "public" on the search path. (#1662) - Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645) - Format Query now formats only the selected text when a selection is active, and the full query when nothing is selected. (#1656) - Foreign key jump arrows no longer disappear after sorting, filtering, or paginating a table, and a failed foreign key lookup is retried on the next load instead of hiding the arrows for the whole session. diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index 5102d61b3..158fec91b 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -41,9 +41,11 @@ final class LibPQDriverCore: @unchecked Sendable { try await pqConn.connect() libpqConnection = pqConn - if let schemaResult = try? await pqConn.executeQuery("SELECT current_schema()"), - let schema = schemaResult.rows.first?.first?.asText { + if let schema = await singleSchemaValue(pqConn, query: PostgreSQLSchemaQueries.currentSchema) { currentSchema = schema + } else if let fallback = await firstFallbackSchema(pqConn) { + currentSchema = fallback + _ = try? await pqConn.executeQuery(PostgreSQLSchemaQueries.setSearchPath(toSchema: fallback)) } if let selectedSchema, @@ -54,6 +56,20 @@ final class LibPQDriverCore: @unchecked Sendable { await onPostConnect?() } + private func firstFallbackSchema(_ pqConn: LibPQPluginConnection) async -> String? { + for query in PostgreSQLSchemaQueries.schemaFallbackQueries { + if let schema = await singleSchemaValue(pqConn, query: query) { + return schema + } + } + return nil + } + + private func singleSchemaValue(_ pqConn: LibPQPluginConnection, query: String) async -> String? { + guard let result = try? await pqConn.executeQuery(query) else { return nil } + return result.rows.first?.first?.asText + } + func applySchema(_ schema: String) async throws { _ = try await execute(query: PostgreSQLSchemaQueries.setSearchPath(toSchema: schema)) selectedSchema = schema diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 79230b574..a4a98b917 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -534,7 +534,9 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { SELECT (SELECT COUNT(*) FROM information_schema.tables - WHERE table_schema = 'public' AND table_catalog = '\(escapedDbLiteral)'), + WHERE table_catalog = '\(escapedDbLiteral)' + AND table_schema NOT LIKE 'pg!_%' ESCAPE '!' + AND table_schema <> 'information_schema'), pg_database_size('\(escapedDbLiteral)') """ let result = try await execute(query: query) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 2a93b77bd..ccb02413d 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -9,6 +9,20 @@ import Foundation enum PostgreSQLSchemaQueries { + /// Returns the first schema on the effective search path, or SQL NULL + /// when the path is empty (neither `$user` nor `public` exists). + static let currentSchema = "SELECT current_schema()" + + /// Like `current_schema()`, but resolves via `current_schemas(false)`, + /// which omits search path entries that do not correspond to existing, + /// searchable schemas. + static let firstSearchPathSchema = "SELECT current_schemas(false)[1]" + + /// Queries tried in order when `current_schema()` resolves to NULL, so a + /// database without a `public` schema still gets a usable default schema + /// instead of silently showing no tables. + static let schemaFallbackQueries = [firstSearchPathSchema, listSchemas] + /// Lists user-visible schemas, excluding PostgreSQL's built-in `pg_*` /// namespaces and `information_schema`. /// @@ -78,9 +92,6 @@ enum PostgreSQLSchemaQueries { static func setSearchPath(toSchema schema: String) -> String { let quotedIdentifier = "\"\(schema.replacingOccurrences(of: "\"", with: "\"\""))\"" - guard schema != "public" else { - return "SET search_path TO \(quotedIdentifier)" - } - return "SET search_path TO \(quotedIdentifier), public" + return "SET search_path TO \(quotedIdentifier)" } } diff --git a/TablePro/Views/Sidebar/SchemaPickerControl.swift b/TablePro/Views/Sidebar/SchemaPickerControl.swift index be61abb3c..177658538 100644 --- a/TablePro/Views/Sidebar/SchemaPickerControl.swift +++ b/TablePro/Views/Sidebar/SchemaPickerControl.swift @@ -40,8 +40,12 @@ struct SchemaPickerControl: View { ) } + static func shouldShow(schemaCount: Int) -> Bool { + schemaCount > 0 + } + var body: some View { - if allSchemas.count > 1 { + if Self.shouldShow(schemaCount: allSchemas.count) { Menu { Picker(String(localized: "Schema"), selection: selectedSchema) { ForEach(userSchemas, id: \.self) { schema in diff --git a/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift b/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift new file mode 100644 index 000000000..071015316 --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift @@ -0,0 +1,33 @@ +// +// PostgreSQLDefaultSchemaFallbackTests.swift +// TableProTests +// + +import Foundation +import Testing + +@Suite("PostgreSQLSchemaQueries default schema fallback") +struct PostgreSQLDefaultSchemaFallbackTests { + @Test("asks the server for the active schema first") + func currentSchemaQuery() { + #expect(PostgreSQLSchemaQueries.currentSchema == "SELECT current_schema()") + } + + @Test("resolves the first existing search path entry, omitting missing schemas") + func firstSearchPathSchemaQuery() { + #expect(PostgreSQLSchemaQueries.firstSearchPathSchema == "SELECT current_schemas(false)[1]") + } + + @Test("falls back to the effective search path before the alphabetical schema list") + func fallbackOrdering() { + #expect( + PostgreSQLSchemaQueries.schemaFallbackQueries + == [PostgreSQLSchemaQueries.firstSearchPathSchema, PostgreSQLSchemaQueries.listSchemas] + ) + } + + @Test("schema list fallback returns schemas alphabetically so the first row is deterministic") + func listSchemasIsOrdered() { + #expect(PostgreSQLSchemaQueries.listSchemas.contains("ORDER BY schema_name")) + } +} diff --git a/TableProTests/Plugins/PostgreSQLSearchPathTests.swift b/TableProTests/Plugins/PostgreSQLSearchPathTests.swift index b4587cf64..527d1ebaa 100644 --- a/TableProTests/Plugins/PostgreSQLSearchPathTests.swift +++ b/TableProTests/Plugins/PostgreSQLSearchPathTests.swift @@ -8,15 +8,15 @@ import Testing @Suite("PostgreSQLSchemaQueries.setSearchPath") struct PostgreSQLSearchPathTests { - @Test("quotes the schema as an identifier and keeps public on the path") + @Test("quotes the schema as an identifier") func plainSchema() { #expect( PostgreSQLSchemaQueries.setSearchPath(toSchema: "analytics") - == "SET search_path TO \"analytics\", public" + == "SET search_path TO \"analytics\"" ) } - @Test("omits the redundant public fallback when public is the selected schema") + @Test("sets public as the only search path entry when selected") func publicSchema() { #expect( PostgreSQLSchemaQueries.setSearchPath(toSchema: "public") @@ -28,7 +28,7 @@ struct PostgreSQLSearchPathTests { func mixedCaseSchema() { #expect( PostgreSQLSchemaQueries.setSearchPath(toSchema: "MySchema") - == "SET search_path TO \"MySchema\", public" + == "SET search_path TO \"MySchema\"" ) } @@ -36,7 +36,7 @@ struct PostgreSQLSearchPathTests { func schemaWithEmbeddedQuote() { #expect( PostgreSQLSchemaQueries.setSearchPath(toSchema: "wei\"rd") - == "SET search_path TO \"wei\"\"rd\", public" + == "SET search_path TO \"wei\"\"rd\"" ) } @@ -45,7 +45,7 @@ struct PostgreSQLSearchPathTests { let malicious = "public\"; DROP TABLE users; --" #expect( PostgreSQLSchemaQueries.setSearchPath(toSchema: malicious) - == "SET search_path TO \"public\"\"; DROP TABLE users; --\", public" + == "SET search_path TO \"public\"\"; DROP TABLE users; --\"" ) } } diff --git a/TableProTests/Views/SchemaPickerVisibilityTests.swift b/TableProTests/Views/SchemaPickerVisibilityTests.swift new file mode 100644 index 000000000..7a1283b9b --- /dev/null +++ b/TableProTests/Views/SchemaPickerVisibilityTests.swift @@ -0,0 +1,27 @@ +// +// SchemaPickerVisibilityTests.swift +// TableProTests +// + +import Foundation +import Testing + +@testable import TablePro + +@Suite("SchemaPickerControl visibility") +struct SchemaPickerVisibilityTests { + @Test("shows the picker for a single-schema database") + func visibleWithOneSchema() { + #expect(SchemaPickerControl.shouldShow(schemaCount: 1)) + } + + @Test("shows the picker when multiple schemas exist") + func visibleWithMultipleSchemas() { + #expect(SchemaPickerControl.shouldShow(schemaCount: 2)) + } + + @Test("hides the picker while no schemas are known") + func hiddenWithNoSchemas() { + #expect(!SchemaPickerControl.shouldShow(schemaCount: 0)) + } +} From 4f922c8a89e44c766d739e3a5c053b45542bc448 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Fri, 12 Jun 2026 15:10:59 +0700 Subject: [PATCH 2/2] refactor(plugin-postgresql): distinguish probe failure from empty search path and use USAGE-filtered fallback on Redshift --- CHANGELOG.md | 3 +- .../LibPQDriverCore.swift | 30 ++++++++++----- .../PostgreSQLSchemaQueries.swift | 20 ++++++++++ .../RedshiftPluginDriver.swift | 5 ++- ...PostgreSQLDefaultSchemaFallbackTests.swift | 37 +++++++++++++++++++ 5 files changed, 83 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0dac4662d..e1a809621 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,10 +25,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - The sidebar filter, database switcher, and connection switcher now use the same fuzzy matching as the Quick Switcher, so abbreviations like `upv` find `user_profile_view`. - Refresh (Cmd+R) now acts only on the focused window's connection, instead of also reloading views and clearing autocomplete caches for every other open connection. - Holding Cmd+R no longer queues a backlog of refreshes that kept running after the key was released; refresh fires once per key press, and rapid presses collapse into a single reload. +- Switching PostgreSQL schemas now sets the search path to just the selected schema instead of also keeping "public" on it. Unqualified references to objects in "public", such as extension functions, need a "public." prefix while another schema is selected. (#1662) ### Fixed -- PostgreSQL databases without a "public" schema now load tables from the first available schema, the schema selector also appears when only one schema exists, and the database list counts tables in every user schema instead of only "public". Switching schemas no longer keeps "public" on the search path. (#1662) +- PostgreSQL databases without a "public" schema now load tables from the first available schema, the schema selector also appears when only one schema exists, and the database list counts tables in every user schema instead of only "public". (#1662) - Format Query can now be undone with Cmd+Z; the formatting is applied as a single editor edit instead of clearing the undo history. (#1645) - Format Query now formats only the selected text when a selection is active, and the full query when nothing is selected. (#1656) - Foreign key jump arrows no longer disappear after sorting, filtering, or paginating a table, and a failed foreign key lookup is retried on the next load instead of hiding the arrows for the whole session. diff --git a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift index 158fec91b..66ce6d7b2 100644 --- a/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift +++ b/Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift @@ -11,6 +11,7 @@ import TableProPluginKit final class LibPQDriverCore: @unchecked Sendable { private let config: DriverConnectionConfig + private let schemaFallbackQueries: [String] private var libpqConnection: LibPQPluginConnection? var currentSchema: String = "public" @@ -21,8 +22,12 @@ final class LibPQDriverCore: @unchecked Sendable { var serverVersion: String? { libpqConnection?.serverVersion() } var serverVersionNumber: Int32 { libpqConnection?.serverVersionNumber() ?? 0 } - init(config: DriverConnectionConfig) { + init( + config: DriverConnectionConfig, + schemaFallbackQueries: [String] = PostgreSQLSchemaQueries.schemaFallbackQueries + ) { self.config = config + self.schemaFallbackQueries = schemaFallbackQueries } // MARK: - Connection @@ -41,11 +46,16 @@ final class LibPQDriverCore: @unchecked Sendable { try await pqConn.connect() libpqConnection = pqConn - if let schema = await singleSchemaValue(pqConn, query: PostgreSQLSchemaQueries.currentSchema) { + switch await probeSchema(pqConn, query: PostgreSQLSchemaQueries.currentSchema) { + case .schema(let schema): currentSchema = schema - } else if let fallback = await firstFallbackSchema(pqConn) { - currentSchema = fallback - _ = try? await pqConn.executeQuery(PostgreSQLSchemaQueries.setSearchPath(toSchema: fallback)) + case .empty: + if let fallback = await firstFallbackSchema(pqConn) { + currentSchema = fallback + _ = try? await pqConn.executeQuery(PostgreSQLSchemaQueries.setSearchPath(toSchema: fallback)) + } + case .failed: + break } if let selectedSchema, @@ -57,17 +67,17 @@ final class LibPQDriverCore: @unchecked Sendable { } private func firstFallbackSchema(_ pqConn: LibPQPluginConnection) async -> String? { - for query in PostgreSQLSchemaQueries.schemaFallbackQueries { - if let schema = await singleSchemaValue(pqConn, query: query) { + for query in schemaFallbackQueries { + if case .schema(let schema) = await probeSchema(pqConn, query: query) { return schema } } return nil } - private func singleSchemaValue(_ pqConn: LibPQPluginConnection, query: String) async -> String? { - guard let result = try? await pqConn.executeQuery(query) else { return nil } - return result.rows.first?.first?.asText + private func probeSchema(_ pqConn: LibPQPluginConnection, query: String) async -> PostgreSQLSchemaProbe { + let result = try? await pqConn.executeQuery(query) + return PostgreSQLSchemaQueries.probe(rows: result?.rows) } func applySchema(_ schema: String) async throws { diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index ccb02413d..108df6924 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -7,6 +7,13 @@ // import Foundation +import TableProPluginKit + +enum PostgreSQLSchemaProbe: Equatable { + case schema(String) + case empty + case failed +} enum PostgreSQLSchemaQueries { /// Returns the first schema on the effective search path, or SQL NULL @@ -23,6 +30,19 @@ enum PostgreSQLSchemaQueries { /// instead of silently showing no tables. static let schemaFallbackQueries = [firstSearchPathSchema, listSchemas] + /// Redshift fallback: ends with the `USAGE`-filtered schema list so the + /// chosen default is one the connected role can actually read. + static let schemaFallbackQueriesRedshift = [firstSearchPathSchema, listSchemasRedshift] + + /// Distinguishes a probe whose query failed (keep the prior schema, do + /// not fall back on a transient error) from one that succeeded with SQL + /// NULL (empty search path, try the next fallback query). + static func probe(rows: [[PluginCellValue]]?) -> PostgreSQLSchemaProbe { + guard let rows else { return .failed } + guard let schema = rows.first?.first?.asText, !schema.isEmpty else { return .empty } + return .schema(schema) + } + /// Lists user-visible schemas, excluding PostgreSQL's built-in `pg_*` /// namespaces and `information_schema`. /// diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index 9c99a1746..258132df6 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -26,7 +26,10 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { } init(config: DriverConnectionConfig) { - self.core = LibPQDriverCore(config: config) + self.core = LibPQDriverCore( + config: config, + schemaFallbackQueries: PostgreSQLSchemaQueries.schemaFallbackQueriesRedshift + ) } // MARK: - EXPLAIN diff --git a/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift b/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift index 071015316..a45d5a547 100644 --- a/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift +++ b/TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift @@ -4,6 +4,7 @@ // import Foundation +import TableProPluginKit import Testing @Suite("PostgreSQLSchemaQueries default schema fallback") @@ -26,8 +27,44 @@ struct PostgreSQLDefaultSchemaFallbackTests { ) } + @Test("Redshift falls back to the USAGE-filtered schema list") + func redshiftFallbackOrdering() { + #expect( + PostgreSQLSchemaQueries.schemaFallbackQueriesRedshift + == [PostgreSQLSchemaQueries.firstSearchPathSchema, PostgreSQLSchemaQueries.listSchemasRedshift] + ) + } + @Test("schema list fallback returns schemas alphabetically so the first row is deterministic") func listSchemasIsOrdered() { #expect(PostgreSQLSchemaQueries.listSchemas.contains("ORDER BY schema_name")) } } + +@Suite("PostgreSQLSchemaQueries.probe") +struct PostgreSQLSchemaProbeTests { + @Test("reports the schema when the first cell holds text") + func schemaFromText() { + #expect(PostgreSQLSchemaQueries.probe(rows: [[.text("foo")]]) == .schema("foo")) + } + + @Test("reports empty when the search path resolves to SQL NULL") + func emptyFromNull() { + #expect(PostgreSQLSchemaQueries.probe(rows: [[.null]]) == .empty) + } + + @Test("reports empty when the query returns no rows") + func emptyFromNoRows() { + #expect(PostgreSQLSchemaQueries.probe(rows: []) == .empty) + } + + @Test("reports empty for a blank schema name") + func emptyFromBlankText() { + #expect(PostgreSQLSchemaQueries.probe(rows: [[.text("")]]) == .empty) + } + + @Test("reports failure when the query itself failed") + func failedFromNilRows() { + #expect(PostgreSQLSchemaQueries.probe(rows: nil) == .failed) + } +}