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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Redshift external schemas now list their tables. Spectrum, federated query, cross-database, and datashare schemas showed up empty because their tables are not in the standard catalog.
- External schemas are marked in the sidebar, and their tables show an external icon. External tables open read-only, because Redshift rejects `UPDATE` and `DELETE` on them.

### Fixed

- An open tab now keeps running against the database it was opened on, so changing the database in the sidebar no longer breaks it with a "table doesn't exist" error. (#2026)
Expand Down
85 changes: 85 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
//
// RedshiftExternalSchemaQueries.swift
// PostgreSQLDriverPlugin
//
// Static SQL for Redshift external catalog introspection. External schemas
// register a pg_namespace row but their tables and columns exist only in the
// SVV_EXTERNAL_* views, so information_schema never reports them.
//
// Every statement here reads an svv_* view, which Redshift distributes to the
// compute nodes. Leader-node-only functions (has_schema_privilege, substr,
// current_schema, version) must never appear in one of these queries.
//
// Extracted so the queries and their row classification can be exercised by
// unit tests via TableProTests/PluginTestSources without the libpq C bridge.
//

import Foundation

enum RedshiftExternalSchemaQueries {
static let listExternalSchemaNames = "SELECT schemaname FROM svv_external_schemas"

/// Tables registered in one external schema. Both views carry rows for
/// every database on the cluster, so the connected database is part of the
/// filter; two databases can each hold a schema of the same name. Literals
/// are escaped by the caller, matching the convention in
/// RedshiftSchemaQueries.
static func listExternalTables(schemaLiteral: String, databaseLiteral: String) -> String {
"""
SELECT tablename, tabletype
FROM svv_external_tables
WHERE schemaname = '\(schemaLiteral)'
AND redshift_database_name = '\(databaseLiteral)'
ORDER BY tablename
"""
}

/// Column introspection for one external schema. Passing `tableLiteral`
/// restricts the result to a single table; passing `nil` returns every
/// table's columns and prefixes each row with `tablename`.
static func listExternalColumns(
schemaLiteral: String,
tableLiteral: String?,
databaseLiteral: String
) -> String {
let selectPrefix = tableLiteral == nil ? "tablename,\n " : ""
let tableFilter = tableLiteral.map { " AND tablename = '\($0)'" } ?? ""
let orderBy = tableLiteral == nil ? "tablename, columnnum" : "columnnum"
return """
SELECT
\(selectPrefix)columnname,
external_type,
is_nullable,
part_key
FROM svv_external_columns
WHERE schemaname = '\(schemaLiteral)'\(tableFilter)
AND redshift_database_name = '\(databaseLiteral)'
ORDER BY \(orderBy)
"""
}

/// `tabletype` is `TABLE`, `VIEW`, `MATERIALIZED VIEW`, or a blank string
/// when the external catalog reports nothing. Only a view maps onto the
/// existing read-only view handling; everything else, blank included, stays
/// an external table so no object is dropped from the listing.
static func classifyTableType(rawTabletype: String?) -> String {
let normalized = rawTabletype?.trimmingCharacters(in: .whitespaces).uppercased()
return normalized == "VIEW" ? "VIEW" : "EXTERNAL TABLE"
}

/// `is_nullable` is `true`, `false`, or a blank string when the external
/// catalog reports nothing. Only an explicit `false` marks a column
/// required, so an unknown value never claims a constraint that is not there.
static func classifyIsNullable(raw: String?) -> Bool {
raw?.trimmingCharacters(in: .whitespaces).lowercased() != "false"
}

/// `part_key` is 0 for an ordinary column, or the 1-based position of the
/// column within the partition key.
static func partitionKeyDescription(rawPartKey: String?) -> String? {
guard let raw = rawPartKey?.trimmingCharacters(in: .whitespaces),
let position = Int(raw), position > 0
else { return nil }
return "PARTITION KEY \(position)"
}
}
188 changes: 182 additions & 6 deletions Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import TableProPluginKit
final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {
let core: LibPQDriverCore

private let connectedDatabase: String

private var externalSchemaCache: Set<String>?

private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "RedshiftPluginDriver")

var capabilities: PluginCapabilities {
Expand All @@ -26,10 +30,14 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {
}

init(config: DriverConnectionConfig) {
self.connectedDatabase = config.database
self.core = LibPQDriverCore(
config: config,
schemaFallbackQueries: PostgreSQLSchemaQueries.schemaFallbackQueriesRedshift
)
core.onPostConnect = { [weak self] in
await self?.probeExternalSchemas()
}
}

// MARK: - EXPLAIN
Expand All @@ -40,25 +48,143 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {

// MARK: - Schema

/// Refreshed from `onPostConnect` and whenever the schema list is loaded, so
/// a schema created mid-session is classified without a reconnect. A failed
/// probe leaves the previous answer in place rather than replacing it with
/// an empty one. A cluster with no external catalog answers in one cheap read.
private func probeExternalSchemas() async {
do {
let result = try await execute(query: RedshiftExternalSchemaQueries.listExternalSchemaNames)
externalSchemaCache = Set(result.rows.compactMap { $0.first?.asText })
} catch {
Self.logger.warning(
"Could not read svv_external_schemas; external schemas stay unresolved: \(error.localizedDescription, privacy: .public)"
)
}
}

func fetchExternalSchemaNames() async throws -> Set<String> {
externalSchemaCache ?? []
}

private func isExternalSchema(_ schema: String) -> Bool {
externalSchemaCache?.contains(schema) ?? false
}

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
let resolvedSchema = schema ?? core.currentSchema
let schemaLiteral = escapeLiteral(resolvedSchema)
let query = """
SELECT table_name, table_type
FROM information_schema.tables
WHERE table_schema = '\(schemaLiteral)'
ORDER BY table_name
"""
let result = try await execute(query: query)
return result.rows.compactMap { row -> PluginTableInfo? in
let localTables = result.rows.compactMap { row -> PluginTableInfo? in
guard let name = row[0].asText else { return nil }
let typeStr = row[1].asText ?? "BASE TABLE"
let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE"
return PluginTableInfo(name: name, type: type)
}

guard isExternalSchema(resolvedSchema) else { return localTables }

let externalTables = await fetchExternalTables(schemaLiteral: schemaLiteral, schema: resolvedSchema)
guard !externalTables.isEmpty else { return localTables }

let localNames = Set(localTables.map(\.name))
let merged = localTables + externalTables.filter { !localNames.contains($0.name) }
return merged.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}

private func fetchExternalTables(schemaLiteral: String, schema: String) async -> [PluginTableInfo] {
do {
let result = try await execute(
query: RedshiftExternalSchemaQueries.listExternalTables(
schemaLiteral: schemaLiteral,
databaseLiteral: escapeLiteral(connectedDatabase)
)
)
return result.rows.compactMap { row -> PluginTableInfo? in
guard let name = row[0].asText else { return nil }
let rawType = row.count > 1 ? row[1].asText : nil
return PluginTableInfo(
name: name,
type: RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: rawType),
schema: schema
)
}
} catch {
Self.logger.warning(
"svv_external_tables failed for schema \(schema, privacy: .public); listing local tables only: \(error.localizedDescription, privacy: .public)"
)
return []
}
}

func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] {
let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
let resolvedSchema = schema ?? core.currentSchema
if isExternalSchema(resolvedSchema) {
let external = await fetchExternalColumns(
schemaLiteral: escapeLiteral(resolvedSchema),
tableLiteral: escapeLiteral(table),
schema: resolvedSchema
)
if !external.isEmpty { return external }
}
return try await fetchLocalColumns(table: table, schema: resolvedSchema)
}

private func fetchExternalColumns(
schemaLiteral: String,
tableLiteral: String,
schema: String
) async -> [PluginColumnInfo] {
do {
let result = try await execute(
query: RedshiftExternalSchemaQueries.listExternalColumns(
schemaLiteral: schemaLiteral,
tableLiteral: tableLiteral,
databaseLiteral: escapeLiteral(connectedDatabase)
)
)
return result.rows.compactMap { row -> PluginColumnInfo? in
guard row.count >= 2, let name = row[0].asText, let dataType = row[1].asText else { return nil }
return Self.externalColumn(name: name, dataType: dataType, row: row, typeIndex: 1)
}
} catch {
Self.logger.warning(
"svv_external_columns failed for schema \(schema, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
return []
}
}

/// External columns carry no default, charset, collation, comment, or key
/// information, and `external_type` is an opaque Hive type string that must
/// reach the UI unparsed so nested `struct`/`array` declarations survive.
private static func externalColumn(
name: String,
dataType: String,
row: [PluginCellValue],
typeIndex: Int
) -> PluginColumnInfo {
let nullableIndex = typeIndex + 1
let partKeyIndex = typeIndex + 2
let rawNullable = row.count > nullableIndex ? row[nullableIndex].asText : nil
let rawPartKey = row.count > partKeyIndex ? row[partKeyIndex].asText : nil
return PluginColumnInfo(
name: name,
dataType: dataType,
isNullable: RedshiftExternalSchemaQueries.classifyIsNullable(raw: rawNullable),
isPrimaryKey: false,
extra: RedshiftExternalSchemaQueries.partitionKeyDescription(rawPartKey: rawPartKey)
)
}

private func fetchLocalColumns(table: String, schema: String) async throws -> [PluginColumnInfo] {
let schemaLiteral = escapeLiteral(schema)
let query = RedshiftSchemaQueries.columnsQuery(
schemaLiteral: schemaLiteral,
tableLiteral: escapeLiteral(table)
Expand Down Expand Up @@ -106,7 +232,50 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {
}

func fetchAllColumns(schema: String?) async throws -> [String: [PluginColumnInfo]] {
let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
let resolvedSchema = schema ?? core.currentSchema
if isExternalSchema(resolvedSchema) {
let external = await fetchExternalAllColumns(
schemaLiteral: escapeLiteral(resolvedSchema),
schema: resolvedSchema
)
if !external.isEmpty { return external }
}
return try await fetchLocalAllColumns(schema: resolvedSchema)
}

private func fetchExternalAllColumns(
schemaLiteral: String,
schema: String
) async -> [String: [PluginColumnInfo]] {
do {
let result = try await execute(
query: RedshiftExternalSchemaQueries.listExternalColumns(
schemaLiteral: schemaLiteral,
tableLiteral: nil,
databaseLiteral: escapeLiteral(connectedDatabase)
)
)
var allColumns: [String: [PluginColumnInfo]] = [:]
for row in result.rows {
guard row.count >= 3,
let tableName = row[0].asText,
let name = row[1].asText,
let dataType = row[2].asText
else { continue }
let column = Self.externalColumn(name: name, dataType: dataType, row: row, typeIndex: 2)
allColumns[tableName, default: []].append(column)
}
return allColumns
} catch {
Self.logger.warning(
"svv_external_columns failed for schema \(schema, privacy: .public): \(error.localizedDescription, privacy: .public)"
)
return [:]
}
}

private func fetchLocalAllColumns(schema: String) async throws -> [String: [PluginColumnInfo]] {
let schemaLiteral = escapeLiteral(schema)
let query = RedshiftSchemaQueries.columnsQuery(schemaLiteral: schemaLiteral, tableLiteral: nil)
let result = try await execute(query: query)
var allColumns: [String: [PluginColumnInfo]] = [:]
Expand Down Expand Up @@ -232,8 +401,10 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {
}

func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? {
let resolvedSchema = schema ?? core.currentSchema
guard !isExternalSchema(resolvedSchema) else { return nil }
let safeTable = escapeLiteral(table)
let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
let schemaLiteral = escapeLiteral(resolvedSchema)
let query = """
SELECT tbl_rows
FROM svv_table_info
Expand Down Expand Up @@ -323,8 +494,12 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {
}

func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata {
let resolvedSchema = schema ?? core.currentSchema
guard !isExternalSchema(resolvedSchema) else {
return PluginTableMetadata(tableName: table, engine: "Redshift External")
}
let safeTable = escapeLiteral(table)
let schemaLiteral = escapeLiteral(schema ?? core.currentSchema)
let schemaLiteral = escapeLiteral(resolvedSchema)
let query = """
SELECT
tbl_rows,
Expand Down Expand Up @@ -367,6 +542,7 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable {

func fetchSchemas() async throws -> [String] {
let result = try await execute(query: PostgreSQLSchemaQueries.listSchemasRedshift)
await probeExternalSchemas()
return result.rows.compactMap { row in row.first?.asText }
}

Expand Down
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {

var supportsSchemas: Bool { get }
func fetchSchemas() async throws -> [String]
func fetchExternalSchemaNames() async throws -> Set<String>
func switchSchema(to schema: String) async throws
var currentSchema: String? { get }

Expand Down Expand Up @@ -219,6 +220,11 @@ public extension PluginDatabaseDriver {

func fetchSchemas() async throws -> [String] { [] }

/// Schemas whose objects live in a catalog outside the database itself, such
/// as Redshift external schemas backed by Glue, Hive, or a federated source.
/// Engines without that concept keep the empty default.
func fetchExternalSchemaNames() async throws -> Set<String> { [] }

func switchSchema(to schema: String) async throws {}

var currentSchema: String? { nil }
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ protocol DatabaseDriver: AnyObject, Sendable {
/// Fetch list of schemas in the current database (PostgreSQL only)
func fetchSchemas() async throws -> [String]

/// Names of schemas whose objects live in a catalog outside the database.
/// Default implementation returns an empty set; drivers that support them override.
func fetchExternalSchemaNames() async throws -> Set<String>

/// Fetch stored procedures for the given schema (or current schema if nil).
/// Default implementation returns an empty list; drivers that support routines override.
func fetchProcedures(schema: String?) async throws -> [RoutineInfo]
Expand Down Expand Up @@ -426,6 +430,8 @@ extension DatabaseDriver {
/// Default: no schema support (MySQL/SQLite don't use schemas in the same way)
func fetchSchemas() async throws -> [String] { [] }

func fetchExternalSchemaNames() async throws -> Set<String> { [] }

func fetchTables(schema: String?) async throws -> [TableInfo] {
try await fetchTables()
}
Expand Down
Loading
Loading