From 3fa300dd36adae2044563cb8f19662ccaf3733d2 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 6 Aug 2026 23:08:13 +0700 Subject: [PATCH] fix(plugin-postgresql): list Redshift external schemas and their tables --- CHANGELOG.md | 5 + .../RedshiftExternalSchemaQueries.swift | 85 ++++++++ .../RedshiftPluginDriver.swift | 188 +++++++++++++++++- .../PluginDatabaseDriver.swift | 6 + TablePro/Core/Database/DatabaseDriver.swift | 6 + .../Database/DatabaseManager+Sessions.swift | 1 + .../Database/TableOperationSQLBuilder.swift | 2 +- .../Core/Plugins/PluginDriverAdapter.swift | 6 + .../Query/ExternalSchemaTracker.swift | 50 +++++ TablePro/Models/Query/QueryResult.swift | 13 ++ TablePro/Models/UI/QuickSwitcherItem.swift | 1 + TablePro/Resources/Localizable.xcstrings | 84 ++++++++ .../ViewModels/QuickSwitcherViewModel.swift | 6 +- .../MainContentCoordinator+Navigation.swift | 2 +- ...MainContentCoordinator+QuickSwitcher.swift | 1 + .../DatabaseTreeOutlineCoordinator.swift | 25 ++- .../Views/Sidebar/DatabaseTreeRowView.swift | 25 ++- .../Views/Sidebar/SidebarContextMenu.swift | 3 +- TablePro/Views/Sidebar/TableRowView.swift | 2 + .../TableOperationSQLBuilderTests.swift | 7 + ...inDriverAdapterTableTypeMappingTests.swift | 21 ++ .../Plugins/PluginKitABIResilienceTests.swift | 1 + TableProTests/Models/TableInfoTests.swift | 27 +++ .../RedshiftExternalSchemaQueries.swift | 1 + .../RedshiftExternalObjectsTests.swift | 185 +++++++++++++++++ .../Services/ExternalSchemaTrackerTests.swift | 166 ++++++++++++++++ .../Views/SidebarContextMenuLogicTests.swift | 24 +++ TableProTests/Views/TableRowLogicTests.swift | 12 ++ docs/databases/redshift.mdx | 5 + 29 files changed, 944 insertions(+), 16 deletions(-) create mode 100644 Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift create mode 100644 TablePro/Core/Services/Query/ExternalSchemaTracker.swift create mode 120000 TableProTests/PluginTestSources/RedshiftExternalSchemaQueries.swift create mode 100644 TableProTests/Plugins/RedshiftExternalObjectsTests.swift create mode 100644 TableProTests/Services/ExternalSchemaTrackerTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index b74142411..f24c993ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. + ## [0.63.0] - 2026-08-05 ### Added diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift new file mode 100644 index 000000000..a26217dcc --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift @@ -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)" + } +} diff --git a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift index c77855dae..0dc622d2a 100644 --- a/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/RedshiftPluginDriver.swift @@ -13,6 +13,10 @@ import TableProPluginKit final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { let core: LibPQDriverCore + private let connectedDatabase: String + + private var externalSchemaCache: Set? + private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "RedshiftPluginDriver") var capabilities: PluginCapabilities { @@ -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 @@ -40,8 +48,32 @@ 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 { + 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 @@ -49,16 +81,110 @@ final class RedshiftPluginDriver: LibPQBackedDriver, @unchecked Sendable { 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) @@ -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]] = [:] @@ -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 @@ -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, @@ -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 } } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 0cf5fe18f..cb63349c9 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -95,6 +95,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { var supportsSchemas: Bool { get } func fetchSchemas() async throws -> [String] + func fetchExternalSchemaNames() async throws -> Set func switchSchema(to schema: String) async throws var currentSchema: String? { get } @@ -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 { [] } + func switchSchema(to schema: String) async throws {} var currentSchema: String? { nil } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 43e1f8f8f..28b8c1e90 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -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 + /// 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] @@ -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 { [] } + func fetchTables(schema: String?) async throws -> [TableInfo] { try await fetchTables() } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index 3a2be2db8..ff1d6ab6e 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -378,6 +378,7 @@ extension DatabaseManager { await DatabaseTreeMetadataService.shared.handleDisconnect(connectionId: sessionId) SchemaProviderRegistry.shared.clear(for: sessionId) + ExternalSchemaTracker.shared.reset(connectionId: sessionId) SharedSidebarState.removeConnection(sessionId) SidebarViewModel.removeConnection(sessionId) diff --git a/TablePro/Core/Database/TableOperationSQLBuilder.swift b/TablePro/Core/Database/TableOperationSQLBuilder.swift index 1cce44172..9daabaf74 100644 --- a/TablePro/Core/Database/TableOperationSQLBuilder.swift +++ b/TablePro/Core/Database/TableOperationSQLBuilder.swift @@ -111,7 +111,7 @@ struct TableOperationSQLBuilder { return "MATERIALIZED VIEW" case .foreignTable: return "FOREIGN TABLE" - case .table, .systemTable, .partitionedTable, .none: + case .table, .systemTable, .partitionedTable, .externalTable, .none: return "TABLE" } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 15a8d54dd..6bc343e49 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -201,6 +201,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { tableType = .foreignTable case "system table", "system base table", "system view": tableType = .systemTable + case "external table", "external_table": + tableType = .externalTable default: Self.logger.warning("Unknown plugin table type \"\(table.type, privacy: .public)\" for \"\(table.name, privacy: .public)\"; defaulting to .table") tableType = .table @@ -372,6 +374,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { try await pluginDriver.fetchSchemas() } + func fetchExternalSchemaNames() async throws -> Set { + try await pluginDriver.fetchExternalSchemaNames() + } + func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { guard let support = pluginDriver as? PluginProcedureFunctionSupport else { return [] } let resolvedSchema = schema ?? pluginDriver.currentSchema diff --git a/TablePro/Core/Services/Query/ExternalSchemaTracker.swift b/TablePro/Core/Services/Query/ExternalSchemaTracker.swift new file mode 100644 index 000000000..149f5de7a --- /dev/null +++ b/TablePro/Core/Services/Query/ExternalSchemaTracker.swift @@ -0,0 +1,50 @@ +// +// ExternalSchemaTracker.swift +// TablePro +// + +import Foundation +import os + +@MainActor +@Observable +final class ExternalSchemaTracker { + static let shared = ExternalSchemaTracker() + + struct Key: Hashable, Sendable { + let connectionId: UUID + let database: String + } + + private static let logger = Logger(subsystem: "com.TablePro", category: "ExternalSchemaTracker") + + private var namesByDatabase: [Key: Set] = [:] + + @ObservationIgnored private let dedup = OnceTask>() + + private init() {} + + func isExternal(connectionId: UUID, database: String, schema: String) -> Bool { + namesByDatabase[Key(connectionId: connectionId, database: database)]?.contains(schema) ?? false + } + + func load(connectionId: UUID, database: String, driver: DatabaseDriver) async { + let key = Key(connectionId: connectionId, database: database) + guard namesByDatabase[key] == nil else { return } + do { + let names = try await dedup.execute(key: key) { + try await driver.fetchExternalSchemaNames() + } + namesByDatabase[key] = names + } catch { + Self.logger.warning( + "Could not load external schema names for \(database, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + namesByDatabase[key] = [] + } + } + + func reset(connectionId: UUID) { + namesByDatabase = namesByDatabase.filter { $0.key.connectionId != connectionId } + } +} diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 80d2f1257..f5890ced6 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -100,6 +100,19 @@ struct TableInfo: Identifiable, Hashable, Sendable { case foreignTable = "FOREIGN TABLE" case systemTable = "SYSTEM TABLE" case partitionedTable = "PARTITIONED TABLE" + case externalTable = "EXTERNAL TABLE" + + /// An external table lives in a catalog outside the database, has no + /// primary key and no row identifier to target, and rejects UPDATE and + /// DELETE, so the grid must not offer row editing for one. + var allowsRowEditing: Bool { + switch self { + case .view, .externalTable: + return false + case .table, .materializedView, .foreignTable, .systemTable, .partitionedTable: + return true + } + } } init(name: String, type: TableType, rowCount: Int?, schema: String? = nil, comment: String? = nil) { diff --git a/TablePro/Models/UI/QuickSwitcherItem.swift b/TablePro/Models/UI/QuickSwitcherItem.swift index 4a5c3f2be..6045441e2 100644 --- a/TablePro/Models/UI/QuickSwitcherItem.swift +++ b/TablePro/Models/UI/QuickSwitcherItem.swift @@ -71,6 +71,7 @@ internal struct QuickSwitcherItem: Identifiable, Hashable, Sendable { var matchedIndices: [Int] = [] var payload: String? var isOpenInTab: Bool = false + var isReadOnly: Bool = false static func tableItemId(name: String, isView: Bool) -> String { "table_\(name)_\(isView ? "VIEW" : "TABLE")" diff --git a/TablePro/Resources/Localizable.xcstrings b/TablePro/Resources/Localizable.xcstrings index dc7a9752b..7e9a21769 100644 --- a/TablePro/Resources/Localizable.xcstrings +++ b/TablePro/Resources/Localizable.xcstrings @@ -31528,6 +31528,34 @@ } } }, + "Drop External Table" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici Tabloyu Sil" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa External Table" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除外部表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "捨棄外部資料表" + } + } + } + }, "Drop Foreign Table" : { "localizations" : { "tr" : { @@ -37204,6 +37232,34 @@ } } }, + "External" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bên ngoài" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部" + } + } + } + }, "External Access" : { "localizations" : { "tr" : { @@ -37232,6 +37288,34 @@ } } }, + "External Table" : { + "localizations" : { + "tr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Harici Tablo" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "External Table" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "外部資料表" + } + } + } + }, "External access is disabled for this connection" : { "localizations" : { "tr" : { diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 4fefe92ca..1096bb009 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -118,13 +118,17 @@ internal final class QuickSwitcherViewModel { case .partitionedTable: kind = .table subtitle = String(localized: "Partitioned Table") + case .externalTable: + kind = .table + subtitle = String(localized: "External Table") } items.append(QuickSwitcherItem( id: "table_\(table.name)_\(table.type.rawValue)", name: table.name, kind: kind, subtitle: subtitle, - isOpenInTab: openTableNames.contains(table.name) + isOpenInTab: openTableNames.contains(table.name), + isReadOnly: !table.type.allowsRowEditing )) } diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f5e62bff4..a4d612fc3 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -27,7 +27,7 @@ extension MainContentCoordinator { table.name, schema: schema ?? table.schema, showStructure: showStructure, - isView: table.type == .view, + isView: !table.type.allowsRowEditing, forceNonPreview: forceNonPreview, activateGridFocus: activateGridFocus, forceNewWindowTab: forceNewWindowTab diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift index a1959c098..8f5c0dd00 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+QuickSwitcher.swift @@ -36,6 +36,7 @@ extension MainContentCoordinator { openTableTab( item.name, showStructure: intent == .openStructure, + isView: item.isReadOnly, activateGridFocus: true, forceNewWindowTab: intent == .openInNewWindowTab ) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index e0629e455..87540243f 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -425,6 +425,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject { if isIdle(service.schemaListState(connectionId: connectionId, database: metadata.name)) { Task { await service.loadSchemas(connectionId: connectionId, database: metadata.name) } } + loadExternalSchemaNames(database: metadata.name) } else { loadObjects(database: metadata.name, schema: nil) } @@ -437,6 +438,21 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } } + private func loadExternalSchemaNames(database: String) { + guard let session = DatabaseManager.shared.session(for: connectionId), + DatabaseManager.shared.activeDatabaseName(for: session.connection) == database, + let driver = DatabaseManager.shared.driver(for: connectionId) + else { return } + let connectionId = connectionId + Task { + await ExternalSchemaTracker.shared.load( + connectionId: connectionId, + database: database, + driver: driver + ) + } + } + private func loadPartitions(_ ref: DatabaseTreeTableRef) { guard ref.table.type == .partitionedTable else { return } let state = service.partitionsLoadState( @@ -549,7 +565,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject { activeSchema: activeSchema, systemSchemas: systemSchemas, pendingTruncates: pendingTruncates, - pendingDeletes: pendingDeletes + pendingDeletes: pendingDeletes, + isExternalSchema: { [connectionId] database, schema in + ExternalSchemaTracker.shared.isExternal( + connectionId: connectionId, + database: database, + schema: schema + ) + } ) } diff --git a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift index f565f5844..9fabfebd0 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeRowView.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeRowView.swift @@ -29,6 +29,7 @@ struct DatabaseTreeRowContext { let systemSchemas: Set let pendingTruncates: Set let pendingDeletes: Set + var isExternalSchema: @MainActor (String, String) -> Bool = { _, _ in false } } struct DatabaseTreeRowView: View { @@ -86,9 +87,10 @@ struct DatabaseTreeRowView: View { case .schema(let database, let schema): header( text: schema, - systemImage: "folder", + systemImage: context.isExternalSchema(database, schema) ? "folder.badge.gearshape" : "folder", isActive: database == context.activeDatabase && schema == context.activeSchema, - isSystem: context.systemSchemas.contains(schema) + isSystem: context.systemSchemas.contains(schema), + caption: context.isExternalSchema(database, schema) ? String(localized: "External") : nil ) case .table(let ref): TableRow( @@ -105,10 +107,23 @@ struct DatabaseTreeRowView: View { } } - private func header(text: String, systemImage: String, isActive: Bool, isSystem: Bool) -> some View { + private func header( + text: String, + systemImage: String, + isActive: Bool, + isSystem: Bool, + caption: String? = nil + ) -> some View { Label { - Text(text) - .fontWeight(isActive ? .bold : .regular) + HStack(spacing: 6) { + Text(text) + .fontWeight(isActive ? .bold : .regular) + if let caption { + Text(caption) + .font(.caption) + .foregroundStyle(.secondary) + } + } } icon: { Image(systemName: systemImage) } diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index 72e0500f7..358845b86 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -17,7 +17,7 @@ enum SidebarContextMenuLogic { static func isReadOnlyKind(_ type: TableInfo.TableType?) -> Bool { switch type { - case .view, .materializedView, .foreignTable, .systemTable: + case .view, .materializedView, .foreignTable, .systemTable, .externalTable: return true case .table, .partitionedTable, .none: return false @@ -39,6 +39,7 @@ enum SidebarContextMenuLogic { case .materializedView: return String(localized: "Drop Materialized View") case .foreignTable: return String(localized: "Drop Foreign Table") case .systemTable: return String(localized: "Drop") + case .externalTable: return String(localized: "Drop External Table") case .table, .partitionedTable, .none: return String(localized: "Delete") } } diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index a684a1fba..2f34bb186 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -14,6 +14,7 @@ enum TableRowLogic { case .foreignTable: return "link" case .systemTable: return "tablecells.badge.ellipsis" case .partitionedTable: return "rectangle.split.3x1" + case .externalTable: return "externaldrive.connected.to.line.below" } } @@ -25,6 +26,7 @@ enum TableRowLogic { case .foreignTable: return String(localized: "Foreign Table") case .systemTable: return String(localized: "System Table") case .partitionedTable: return String(localized: "Partitioned Table") + case .externalTable: return String(localized: "External Table") } } diff --git a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift index 43362c4be..6c8b0194d 100644 --- a/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift +++ b/TableProTests/Core/Database/TableOperationSQLBuilderTests.swift @@ -74,6 +74,13 @@ struct TableOperationSQLBuilderTests { #expect(stmts == ["DROP FOREIGN TABLE \"remote_orders\""]) } + @Test("External table drops with DROP TABLE") + func dropsExternalTable() { + let builder = makeBuilder(tables: [TableInfo(name: "customers", type: .externalTable, rowCount: nil)]) + let stmts = builder.generate(truncates: [], deletes: ["customers"], options: [:], includeFKHandling: false) + #expect(stmts == ["DROP TABLE \"customers\""]) + } + @Test("Plain table drops with DROP TABLE") func dropsTable() { let builder = makeBuilder(tables: [TableInfo(name: "orders", type: .table, rowCount: nil)]) diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift index db52a9fee..e88de1a4b 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift @@ -133,6 +133,27 @@ struct PluginDriverAdapterTableTypeMappingTests { #expect(tables.allSatisfy { $0.type == .systemTable }) } + @Test("Maps the Redshift external classifier output to an external table") + func mapsExternalTable() async throws { + let driver = StubTableTypeDriver() + driver.stubbedTables = [ + PluginTableInfo( + name: "customers", + type: RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: "TABLE") + ), + PluginTableInfo( + name: "orders", + type: RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: " ") + ), + PluginTableInfo(name: "events", type: "external_table") + ] + let adapter = makeAdapter(driver: driver) + let tables = try await adapter.fetchTables() + #expect(tables.count == 3) + #expect(tables.allSatisfy { $0.type == .externalTable }) + #expect(tables.allSatisfy { !$0.type.allowsRowEditing }) + } + @Test("Maps unknown type to .table with warning") func mapsUnknownToTable() async throws { let driver = StubTableTypeDriver() diff --git a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift index ecbf5973c..a9e4912eb 100644 --- a/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift +++ b/TableProTests/Core/Plugins/PluginKitABIResilienceTests.swift @@ -42,6 +42,7 @@ struct PluginKitABIResilienceTests { func asynchronousDefaults() async throws { let driver = makeMinimalDriver() #expect(try await driver.fetchSchemas().isEmpty) + #expect(try await driver.fetchExternalSchemaNames().isEmpty) #expect(try await driver.fetchApproximateRowCount(table: "users", schema: nil) == nil) } } diff --git a/TableProTests/Models/TableInfoTests.swift b/TableProTests/Models/TableInfoTests.swift index e0670de29..dff5edd71 100644 --- a/TableProTests/Models/TableInfoTests.swift +++ b/TableProTests/Models/TableInfoTests.swift @@ -193,4 +193,31 @@ struct TableInfoTests { #expect(result.contains(TableInfo(name: "users", type: .table, rowCount: nil))) #expect(result.contains(TableInfo(name: "products", type: .view, rowCount: nil))) } + + // MARK: - Row Editing + + @Test("A view does not allow row editing") + func viewDisallowsRowEditing() { + #expect(!TableInfo.TableType.view.allowsRowEditing) + } + + @Test("An external table does not allow row editing") + func externalTableDisallowsRowEditing() { + #expect(!TableInfo.TableType.externalTable.allowsRowEditing) + } + + @Test("Local relations still allow row editing") + func localRelationsAllowRowEditing() { + #expect(TableInfo.TableType.table.allowsRowEditing) + #expect(TableInfo.TableType.materializedView.allowsRowEditing) + #expect(TableInfo.TableType.foreignTable.allowsRowEditing) + #expect(TableInfo.TableType.systemTable.allowsRowEditing) + #expect(TableInfo.TableType.partitionedTable.allowsRowEditing) + } + + @Test("External table round-trips through its raw value") + func externalTableRawValue() { + #expect(TableInfo.TableType.externalTable.rawValue == "EXTERNAL TABLE") + #expect(TableInfo.TableType(rawValue: "EXTERNAL TABLE") == .externalTable) + } } diff --git a/TableProTests/PluginTestSources/RedshiftExternalSchemaQueries.swift b/TableProTests/PluginTestSources/RedshiftExternalSchemaQueries.swift new file mode 120000 index 000000000..0923d5f11 --- /dev/null +++ b/TableProTests/PluginTestSources/RedshiftExternalSchemaQueries.swift @@ -0,0 +1 @@ +../../Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift \ No newline at end of file diff --git a/TableProTests/Plugins/RedshiftExternalObjectsTests.swift b/TableProTests/Plugins/RedshiftExternalObjectsTests.swift new file mode 100644 index 000000000..9af554c32 --- /dev/null +++ b/TableProTests/Plugins/RedshiftExternalObjectsTests.swift @@ -0,0 +1,185 @@ +// +// RedshiftExternalObjectsTests.swift +// TableProTests +// +// Tests for the Redshift external catalog query builders and row classifiers +// (compiled via symlink from PostgreSQLDriverPlugin). Regression cover for +// external schemas whose tables live only in SVV_EXTERNAL_TABLES, so a +// listing built on information_schema showed the schema as empty. +// + +import Foundation +import Testing + +@Suite("RedshiftExternalSchemaQueries") +struct RedshiftExternalSchemaQueriesTests { + private var allQueries: [String] { + [ + RedshiftExternalSchemaQueries.listExternalSchemaNames, + RedshiftExternalSchemaQueries.listExternalTables(schemaLiteral: "etl", databaseLiteral: "dev"), + RedshiftExternalSchemaQueries.listExternalColumns( + schemaLiteral: "etl", + tableLiteral: "customers", + databaseLiteral: "dev" + ), + RedshiftExternalSchemaQueries.listExternalColumns( + schemaLiteral: "etl", + tableLiteral: nil, + databaseLiteral: "dev" + ), + ] + } + + @Test("no query mixes a leader-node-only function with an svv_ view") + func noLeaderNodeOnlyFunctions() { + for query in allQueries { + let lowered = query.lowercased() + #expect(!lowered.contains("has_schema_privilege")) + #expect(!lowered.contains("has_table_privilege")) + #expect(!lowered.contains("has_database_privilege")) + #expect(!lowered.contains("current_schema")) + #expect(!lowered.contains("substr")) + #expect(!lowered.contains("version()")) + } + } + + @Test("schema listing reads svv_external_schemas unfiltered") + func schemaListingReadsExternalSchemas() { + let query = RedshiftExternalSchemaQueries.listExternalSchemaNames + #expect(query.contains("svv_external_schemas")) + #expect(query.contains("schemaname")) + } + + @Test("table listing filters on the requested schema and orders by name") + func tableListingFiltersOnSchema() { + let query = RedshiftExternalSchemaQueries.listExternalTables(schemaLiteral: "etl", databaseLiteral: "dev") + #expect(query.contains("FROM svv_external_tables")) + #expect(query.contains("WHERE schemaname = 'etl'")) + #expect(query.contains("ORDER BY tablename")) + #expect(query.contains("tabletype")) + } + + @Test("every external catalog read is scoped to the connected database") + func externalReadsAreScopedToDatabase() { + let tables = RedshiftExternalSchemaQueries.listExternalTables(schemaLiteral: "etl", databaseLiteral: "dev") + #expect(tables.contains("redshift_database_name = 'dev'")) + + let single = RedshiftExternalSchemaQueries.listExternalColumns( + schemaLiteral: "etl", + tableLiteral: "customers", + databaseLiteral: "dev" + ) + #expect(single.contains("redshift_database_name = 'dev'")) + + let all = RedshiftExternalSchemaQueries.listExternalColumns( + schemaLiteral: "etl", + tableLiteral: nil, + databaseLiteral: "dev" + ) + #expect(all.contains("redshift_database_name = 'dev'")) + } + + @Test("single-table column query filters on the table and orders by column number") + func singleTableColumnQuery() { + let query = RedshiftExternalSchemaQueries.listExternalColumns( + schemaLiteral: "etl", + tableLiteral: "customers", + databaseLiteral: "dev" + ) + #expect(query.contains("FROM svv_external_columns")) + #expect(query.contains("WHERE schemaname = 'etl'")) + #expect(query.contains("AND tablename = 'customers'")) + #expect(query.contains("ORDER BY columnnum")) + #expect(query.contains("external_type")) + #expect(query.contains("part_key")) + } + + @Test("all-tables column query prefixes tablename and omits the table filter") + func allTablesColumnQuery() { + let query = RedshiftExternalSchemaQueries.listExternalColumns( + schemaLiteral: "etl", + tableLiteral: nil, + databaseLiteral: "dev" + ) + #expect(query.contains("tablename,")) + #expect(!query.contains("AND tablename =")) + #expect(query.contains("ORDER BY tablename, columnnum")) + } + + @Test("escaped schema literals reach the generated SQL intact") + func escapedLiteralsAreInterpolated() { + let query = RedshiftExternalSchemaQueries.listExternalTables( + schemaLiteral: "o''brien", + databaseLiteral: "d''ev" + ) + #expect(query.contains("WHERE schemaname = 'o''brien'")) + #expect(query.contains("redshift_database_name = 'd''ev'")) + } +} + +@Suite("RedshiftExternalSchemaQueries.classifyTableType") +struct RedshiftExternalTableTypeTests { + @Test("a table stays an external table") + func tableIsExternalTable() { + #expect(RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: "TABLE") == "EXTERNAL TABLE") + } + + @Test("a view maps onto the existing read-only view kind") + func viewMapsToView() { + #expect(RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: "VIEW") == "VIEW") + } + + @Test("tabletype casing varies across Redshift views and is matched loosely") + func viewCasingIsNormalized() { + #expect(RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: "view") == "VIEW") + #expect(RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: " View ") == "VIEW") + } + + @Test("a materialized view stays an external table rather than a hidden bucket") + func materializedViewIsExternalTable() { + #expect( + RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: "MATERIALIZED VIEW") == "EXTERNAL TABLE" + ) + } + + @Test("a blank tabletype is kept as an external table instead of being dropped") + func blankTabletypeIsKept() { + #expect(RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: " ") == "EXTERNAL TABLE") + #expect(RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: "") == "EXTERNAL TABLE") + } + + @Test("a missing tabletype is kept as an external table") + func missingTabletypeIsKept() { + #expect(RedshiftExternalSchemaQueries.classifyTableType(rawTabletype: nil) == "EXTERNAL TABLE") + } +} + +@Suite("RedshiftExternalSchemaQueries column classifiers") +struct RedshiftExternalColumnClassifierTests { + @Test("only an explicit false marks a column required") + func nullabilityDefaultsToPermissive() { + #expect(RedshiftExternalSchemaQueries.classifyIsNullable(raw: "true")) + #expect(RedshiftExternalSchemaQueries.classifyIsNullable(raw: "TRUE")) + #expect(!RedshiftExternalSchemaQueries.classifyIsNullable(raw: "false")) + #expect(!RedshiftExternalSchemaQueries.classifyIsNullable(raw: "FALSE")) + } + + @Test("an unknown nullability is treated as nullable") + func unknownNullabilityIsNullable() { + #expect(RedshiftExternalSchemaQueries.classifyIsNullable(raw: " ")) + #expect(RedshiftExternalSchemaQueries.classifyIsNullable(raw: nil)) + } + + @Test("a positive part_key describes its position in the partition key") + func partitionKeyPositionIsDescribed() { + #expect(RedshiftExternalSchemaQueries.partitionKeyDescription(rawPartKey: "1") == "PARTITION KEY 1") + #expect(RedshiftExternalSchemaQueries.partitionKeyDescription(rawPartKey: "2") == "PARTITION KEY 2") + } + + @Test("an ordinary column carries no partition marker") + func ordinaryColumnHasNoPartitionMarker() { + #expect(RedshiftExternalSchemaQueries.partitionKeyDescription(rawPartKey: "0") == nil) + #expect(RedshiftExternalSchemaQueries.partitionKeyDescription(rawPartKey: " ") == nil) + #expect(RedshiftExternalSchemaQueries.partitionKeyDescription(rawPartKey: nil) == nil) + } +} diff --git a/TableProTests/Services/ExternalSchemaTrackerTests.swift b/TableProTests/Services/ExternalSchemaTrackerTests.swift new file mode 100644 index 000000000..49d262b97 --- /dev/null +++ b/TableProTests/Services/ExternalSchemaTrackerTests.swift @@ -0,0 +1,166 @@ +// +// ExternalSchemaTrackerTests.swift +// TableProTests +// + +import Foundation +import TableProPluginKit +import Testing + +@testable import TablePro + +private final class ExternalSchemaMockDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var externalSchemasToReturn: Set = [] + var externalSchemasError: Error? + private(set) var externalSchemaCallCount = 0 + + init(connection: DatabaseConnection = TestFixtures.makeConnection()) { + self.connection = connection + } + + func connect() async throws {} + func disconnect() {} + func testConnection() async throws -> Bool { true } + func applyQueryTimeout(_ seconds: Int) async throws {} + + func execute(query: String) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeParameterized(query: String, parameters: [Any?]) async throws -> QueryResult { + try await execute(query: query) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + try await execute(query: query) + } + + func fetchExternalSchemaNames() async throws -> Set { + externalSchemaCallCount += 1 + if let externalSchemasError { throw externalSchemasError } + return externalSchemasToReturn + } + + func fetchTables() async throws -> [TableInfo] { [] } + func fetchColumns(table: String) async throws -> [ColumnInfo] { [] } + func fetchIndexes(table: String) async throws -> [IndexInfo] { [] } + func fetchForeignKeys(table: String) async throws -> [ForeignKeyInfo] { [] } + func fetchApproximateRowCount(table: String) async throws -> Int? { nil } + func fetchTableDDL(table: String) async throws -> String { "" } + func fetchViewDefinition(view: String) async throws -> String { "" } + + func fetchTableMetadata(tableName: String) async throws -> TableMetadata { + TableMetadata( + tableName: tableName, dataSize: nil, indexSize: nil, totalSize: nil, + avgRowLength: nil, rowCount: nil, comment: nil, engine: nil, + collation: nil, createTime: nil, updateTime: nil + ) + } + + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> DatabaseMetadata { + DatabaseMetadata( + id: database, name: database, tableCount: nil, sizeBytes: nil, + lastAccessed: nil, isSystemDatabase: false, icon: "cylinder" + ) + } + + func cancelQuery() throws {} + func beginTransaction() async throws {} + func commitTransaction() async throws {} + func rollbackTransaction() async throws {} + func fetchProcedures(schema: String?) async throws -> [RoutineInfo] { [] } + func fetchFunctions(schema: String?) async throws -> [RoutineInfo] { [] } +} + +@Suite("ExternalSchemaTracker") +@MainActor +struct ExternalSchemaTrackerTests { + private func freshTracker(connectionId: UUID) -> ExternalSchemaTracker { + let tracker = ExternalSchemaTracker.shared + tracker.reset(connectionId: connectionId) + return tracker + } + + @Test("An unloaded schema is not reported as external") + func unloadedSchemaIsNotExternal() { + let connectionId = UUID() + let tracker = freshTracker(connectionId: connectionId) + #expect(!tracker.isExternal(connectionId: connectionId, database: "dev", schema: "etl")) + tracker.reset(connectionId: connectionId) + } + + @Test("Loaded external schemas are reported, local ones are not") + func loadedSchemasAreClassified() async { + let connectionId = UUID() + let tracker = freshTracker(connectionId: connectionId) + let driver = ExternalSchemaMockDriver() + driver.externalSchemasToReturn = ["etl", "pennylane"] + + await tracker.load(connectionId: connectionId, database: "dev", driver: driver) + + #expect(tracker.isExternal(connectionId: connectionId, database: "dev", schema: "etl")) + #expect(tracker.isExternal(connectionId: connectionId, database: "dev", schema: "pennylane")) + #expect(!tracker.isExternal(connectionId: connectionId, database: "dev", schema: "public")) + tracker.reset(connectionId: connectionId) + } + + @Test("A second load for the same database does not refetch") + func repeatLoadDoesNotRefetch() async { + let connectionId = UUID() + let tracker = freshTracker(connectionId: connectionId) + let driver = ExternalSchemaMockDriver() + driver.externalSchemasToReturn = ["etl"] + + await tracker.load(connectionId: connectionId, database: "dev", driver: driver) + await tracker.load(connectionId: connectionId, database: "dev", driver: driver) + + #expect(driver.externalSchemaCallCount == 1) + tracker.reset(connectionId: connectionId) + } + + @Test("A failed load degrades to no external schemas instead of propagating") + func failedLoadDegrades() async { + struct ProbeFailure: Error {} + let connectionId = UUID() + let tracker = freshTracker(connectionId: connectionId) + let driver = ExternalSchemaMockDriver() + driver.externalSchemasError = ProbeFailure() + + await tracker.load(connectionId: connectionId, database: "dev", driver: driver) + + #expect(!tracker.isExternal(connectionId: connectionId, database: "dev", schema: "etl")) + tracker.reset(connectionId: connectionId) + } + + @Test("Classification is scoped per database") + func classificationIsScopedPerDatabase() async { + let connectionId = UUID() + let tracker = freshTracker(connectionId: connectionId) + let driver = ExternalSchemaMockDriver() + driver.externalSchemasToReturn = ["etl"] + + await tracker.load(connectionId: connectionId, database: "dev", driver: driver) + + #expect(tracker.isExternal(connectionId: connectionId, database: "dev", schema: "etl")) + #expect(!tracker.isExternal(connectionId: connectionId, database: "analytics", schema: "etl")) + tracker.reset(connectionId: connectionId) + } + + @Test("Reset clears the connection's classification") + func resetClearsConnection() async { + let connectionId = UUID() + let tracker = freshTracker(connectionId: connectionId) + let driver = ExternalSchemaMockDriver() + driver.externalSchemasToReturn = ["etl"] + + await tracker.load(connectionId: connectionId, database: "dev", driver: driver) + tracker.reset(connectionId: connectionId) + + #expect(!tracker.isExternal(connectionId: connectionId, database: "dev", schema: "etl")) + } +} diff --git a/TableProTests/Views/SidebarContextMenuLogicTests.swift b/TableProTests/Views/SidebarContextMenuLogicTests.swift index c627f71c0..87fa1eb19 100644 --- a/TableProTests/Views/SidebarContextMenuLogicTests.swift +++ b/TableProTests/Views/SidebarContextMenuLogicTests.swift @@ -213,4 +213,28 @@ struct SidebarContextMenuLogicTests { supportedOperations: [] )) } + + // MARK: - External Tables + + @Test("External table counts as a read-only kind") + func externalTableIsReadOnlyKind() { + #expect(SidebarContextMenuLogic.isReadOnlyKind(.externalTable)) + } + + @Test("Import is hidden for an external table") + func importHiddenForExternalTable() { + let table = TableInfo(name: "customers", type: .externalTable, rowCount: nil) + #expect(!SidebarContextMenuLogic.importVisible(clickedTable: table, supportsImport: true)) + } + + @Test("Truncate is hidden for an external table") + func truncateHiddenForExternalTable() { + let table = TableInfo(name: "customers", type: .externalTable, rowCount: nil) + #expect(!SidebarContextMenuLogic.truncateVisible(clickedTable: table)) + } + + @Test("External table drop label names the object kind") + func externalTableDeleteLabel() { + #expect(SidebarContextMenuLogic.deleteLabel(for: .externalTable) == "Drop External Table") + } } diff --git a/TableProTests/Views/TableRowLogicTests.swift b/TableProTests/Views/TableRowLogicTests.swift index 023ddcf2c..5a42d5dea 100644 --- a/TableProTests/Views/TableRowLogicTests.swift +++ b/TableProTests/Views/TableRowLogicTests.swift @@ -96,4 +96,16 @@ struct TableRowLogicTests { let label = TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) #expect(label == "System Table: pg_class") } + + @Test("External table accessibility label") + func accessibilityLabelExternalTable() { + let table = TestFixtures.makeTableInfo(name: "customers", type: .externalTable) + let label = TableRowLogic.accessibilityLabel(table: table, isPendingDelete: false, isPendingTruncate: false) + #expect(label == "External Table: customers") + } + + @Test("External table uses an icon distinct from a local table") + func externalTableIconIsDistinct() { + #expect(TableRowLogic.iconName(for: .externalTable) != TableRowLogic.iconName(for: .table)) + } } diff --git a/docs/databases/redshift.mdx b/docs/databases/redshift.mdx index 847de337d..0d1841329 100644 --- a/docs/databases/redshift.mdx +++ b/docs/databases/redshift.mdx @@ -31,6 +31,10 @@ See [Connection URL Reference](/databases/connection-urls). **Schemas**: like PostgreSQL, default schema `public`. Table metadata comes from `svv_table_info`: distribution style, sort keys, and table size. +**External schemas**: schemas created with `CREATE EXTERNAL SCHEMA` are listed alongside regular ones and marked **External**. This covers Redshift Spectrum over S3, federated query to Aurora, RDS, and MySQL, cross-database references, and datashare consumers. Their tables appear under the schema with an external icon. + +External tables are read-only. Redshift does not support `UPDATE` or `DELETE` on them and they have no primary key, so the data grid disables cell editing, adding rows, and deleting rows. Reading works as usual: browse, filter, sort, and export. Row counts are not shown, because `svv_table_info` does not cover external tables. + **Databases**: `dev`, the database created with every cluster, is listed like any other. Only `padb_harvest` is marked as a system database. **DDL**: table definitions include DISTKEY, SORTKEY, DISTSTYLE, and ENCODE directives. Foreign keys display as informational; Redshift does not enforce them. @@ -56,6 +60,7 @@ Redshift is a columnar warehouse, not a general-purpose RDBMS. - No enums, sequences, or triggers. - Foreign keys are informational. Redshift does not enforce them. - No Maintenance menu. Run `VACUUM` and `ANALYZE` from the SQL editor. +- External tables cannot be edited, and have no row count. Column types show as the external catalog reports them, such as `varchar(65535)` or `array`. ## Troubleshooting