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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +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". (#1662)
- Creating a table now turns the Create Table tab into the new table's tab instead of leaving the creation tab open next to a duplicate, and the sidebar shows the new table without a manual refresh. (#1664)
- Cmd+S in the Create Table tab now creates the table, matching the Save shortcut everywhere else. (#1664)
- 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)
Expand Down
32 changes: 29 additions & 3 deletions Plugins/PostgreSQLDriverPlugin/LibPQDriverCore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -41,9 +46,16 @@ 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 {
switch await probeSchema(pqConn, query: PostgreSQLSchemaQueries.currentSchema) {
case .schema(let schema):
currentSchema = schema
case .empty:
if let fallback = await firstFallbackSchema(pqConn) {
currentSchema = fallback
_ = try? await pqConn.executeQuery(PostgreSQLSchemaQueries.setSearchPath(toSchema: fallback))
}
case .failed:
break
}

if let selectedSchema,
Expand All @@ -54,6 +66,20 @@ final class LibPQDriverCore: @unchecked Sendable {
await onPostConnect?()
}

private func firstFallbackSchema(_ pqConn: LibPQPluginConnection) async -> String? {
for query in schemaFallbackQueries {
if case .schema(let schema) = await probeSchema(pqConn, query: query) {
return schema
}
}
return nil
}

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 {
_ = try await execute(query: PostgreSQLSchemaQueries.setSearchPath(toSchema: schema))
selectedSchema = schema
Expand Down
4 changes: 3 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
39 changes: 35 additions & 4 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,42 @@
//

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
/// 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]

/// 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`.
///
Expand Down Expand Up @@ -78,9 +112,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)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve PostGIS lookup for spatial rendering

When a user switches to a non-public schema on a database where PostGIS is installed in public, this removes public from the session search path. The driver's own spatial rendering path still runs unqualified conversion SQL (PostGISSpatialRewrite.geometryConversionQuery / geographyConversionQuery use ST_AsEWKT(t::geometry)), so those internal PQexecParams conversions fail and the app falls back to raw EWKB hex for geometry/geography columns. Please either keep the extension schema available for these internal conversions or qualify/probe the PostGIS functions/types before dropping public.

Useful? React with 👍 / 👎.

}
}
5 changes: 4 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion TablePro/Views/Sidebar/SchemaPickerControl.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
70 changes: 70 additions & 0 deletions TableProTests/Plugins/PostgreSQLDefaultSchemaFallbackTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//
// PostgreSQLDefaultSchemaFallbackTests.swift
// TableProTests
//

import Foundation
import TableProPluginKit
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("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)
}
}
12 changes: 6 additions & 6 deletions TableProTests/Plugins/PostgreSQLSearchPathTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -28,15 +28,15 @@ struct PostgreSQLSearchPathTests {
func mixedCaseSchema() {
#expect(
PostgreSQLSchemaQueries.setSearchPath(toSchema: "MySchema")
== "SET search_path TO \"MySchema\", public"
== "SET search_path TO \"MySchema\""
)
}

@Test("doubles embedded double quotes so the name stays a single identifier")
func schemaWithEmbeddedQuote() {
#expect(
PostgreSQLSchemaQueries.setSearchPath(toSchema: "wei\"rd")
== "SET search_path TO \"wei\"\"rd\", public"
== "SET search_path TO \"wei\"\"rd\""
)
}

Expand All @@ -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; --\""
)
}
}
27 changes: 27 additions & 0 deletions TableProTests/Views/SchemaPickerVisibilityTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
Loading