diff --git a/CHANGELOG.md b/CHANGELOG.md index a480be547..ea6b199b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- A PostgreSQL partitioned table is listed once instead of once per partition. Expand it in the sidebar to see its partitions, and expand one again if it is subpartitioned. A table split into hundreds of partitions no longer buries the rest of the schema, and those partitions no longer fill up query autocomplete, Cmd+K, and the export picker. (#1984) - An SSH tunnel that cannot reach the database now says so, instead of leaving the database driver to report a timeout reading the server greeting. MySQL showed this as "Lost connection to server at 'handshake: reading initial communication packet', system error: 35", which named no cause. TablePro now checks the destination is reachable before the tunnel is handed over, and reports whether it was refused or timed out. The most common cause is the **Host** field holding the server's public address while the database only listens on `127.0.0.1`; the SSH server resolves that address from where it sits, so **Host** should be `localhost`. (#1981) - The macOS local network prompt now says why TablePro wants access. The app never declared a reason, so the prompt showed a generic message that was easy to deny, which left SSH tunnels and databases on your own network failing with "no route to host". - An SSH tunnel no longer dies while it is busy. The keep-alive read a send that had to wait as a dead tunnel, so heavy traffic through the tunnel could tear it down along with every connection using it. diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift index dd9c098b0..d82368a51 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLCapabilities.swift @@ -17,6 +17,7 @@ struct PostgreSQLCapabilities: Sendable, Equatable { var hasIdentityColumns: Bool { serverVersion >= 100_000 } var hasGeneratedColumns: Bool { serverVersion >= 120_000 } + var hasDeclarativePartitioning: Bool { serverVersion >= 100_000 } var hasArrayPosition: Bool { serverVersion >= 90_500 } var hasOrderedAggregates: Bool { serverVersion >= 90_000 } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index c021c3d7f..b8c61a650 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -15,8 +15,8 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { private static let logger = Logger(subsystem: "com.TablePro.PostgreSQLDriver", category: "PostgreSQLPluginDriver") - private static let undefinedTableSQLState = "42P01" - private static let undefinedFunctionSQLState = "42883" + private static let undefinedTableSQLState = PostgreSQLTableListingLadder.undefinedTableSQLState + private static let undefinedFunctionSQLState = PostgreSQLTableListingLadder.undefinedFunctionSQLState private var catalogPresence: PostgreSQLCatalogPresence? @@ -156,29 +156,35 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - func query(includeOptionalCatalogs: Bool, includeComments: Bool) -> String { + func query(_ attempt: PostgreSQLTableListingAttempt) -> String { PostgreSQLSchemaQueries.fetchTables( schemaLiteral: schemaLiteral, - includeMaterializedViews: includeOptionalCatalogs && includesMaterializedViews(), - includeForeignTables: includeOptionalCatalogs && includesForeignTables(), - includeComments: includeComments + includeMaterializedViews: attempt.includeOptionalCatalogs && includesMaterializedViews(), + includeForeignTables: attempt.includeOptionalCatalogs && includesForeignTables(), + includeComments: attempt.includeComments, + includePartitionAwareness: attempt.includePartitionAwareness ) } - let result: PluginQueryResult - do { - result = try await execute(query: query(includeOptionalCatalogs: true, includeComments: true)) - } catch let error as LibPQPluginError where error.sqlState == Self.undefinedTableSQLState { - result = try await execute(query: query(includeOptionalCatalogs: false, includeComments: true)) - } catch let error as LibPQPluginError where error.sqlState == Self.undefinedFunctionSQLState { - result = try await execute(query: query(includeOptionalCatalogs: false, includeComments: false)) + var result: PluginQueryResult? + for attempt in PostgreSQLTableListingLadder.degradableAttempts where result == nil { + do { + result = try await execute(query: query(attempt)) + } catch let error as LibPQPluginError where PostgreSQLTableListingLadder.isDegradable(sqlState: error.sqlState) { + Self.logger.debug("Table listing degrading past \(attempt.label, privacy: .public): \(error.localizedDescription)") + } + } + if result == nil { + result = try await execute(query: query(PostgreSQLTableListingLadder.leastCapableAttempt)) } + guard let result else { return [] } return result.rows.compactMap { row -> PluginTableInfo? in guard let name = row[0].asText else { return nil } let typeStr = row[1].asText ?? "BASE TABLE" let type: String switch typeStr { + case "PARTITIONED TABLE": type = "PARTITIONED TABLE" case "MATERIALIZED VIEW": type = "MATERIALIZED VIEW" case "FOREIGN TABLE": type = "FOREIGN TABLE" case "VIEW": type = "VIEW" @@ -189,6 +195,26 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { } } + func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { + guard versionedCapabilities.hasDeclarativePartitioning else { return [] } + let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) + let result = try await execute( + query: PostgreSQLSchemaQueries.fetchPartitions( + schemaLiteral: schemaLiteral, + tableLiteral: escapeLiteral(table) + ) + ) + return result.rows.compactMap { row -> PluginTableInfo? in + guard let name = row[0].asText else { return nil } + let isSubpartitioned = row[safe: 1]?.asText == "p" + return PluginTableInfo( + name: name, + type: isSubpartitioned ? "PARTITIONED TABLE" : "TABLE", + schema: schema ?? core.currentSchema, + comment: nil + ) + } + } func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { let columnOrdering = versionedCapabilities.hasArrayPosition @@ -656,10 +682,19 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { let query = """ SELECT (SELECT COUNT(*) - FROM information_schema.tables - WHERE table_catalog = '\(escapedDbLiteral)' - AND table_schema NOT LIKE 'pg!_%' ESCAPE '!' - AND table_schema <> 'information_schema'), + FROM information_schema.tables t + WHERE t.table_catalog = '\(escapedDbLiteral)' + AND t.table_schema NOT LIKE 'pg!_%' ESCAPE '!' + AND t.table_schema <> 'information_schema' + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_inherits i + JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent + JOIN pg_catalog.pg_class child ON child.oid = i.inhrelid + JOIN pg_catalog.pg_namespace cn ON cn.oid = child.relnamespace + WHERE cn.nspname = t.table_schema + AND child.relname = t.table_name + AND parent.relkind IN ('p', 'I'))), pg_database_size('\(escapedDbLiteral)') """ let result = try await execute(query: query) diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 90e189070..53f7175f2 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -76,23 +76,62 @@ enum PostgreSQLSchemaQueries { /// `includeComments` projects each table's comment via `obj_description` / /// `to_regclass`. Engines that lack those functions fail the whole listing, /// so the caller passes `false` to fall back to a comment-free listing. + /// + /// `includePartitionAwareness` labels a declarative partition parent as + /// `PARTITIONED TABLE` and drops its partition children, which + /// `information_schema.tables` reports as plain `BASE TABLE` rows + /// indistinguishable from the parent. The test is `pg_inherits` joined to + /// the parent's `relkind`, not `pg_class.relispartition`: `relispartition` + /// only exists from PostgreSQL 10, and referencing a missing column fails + /// at parse time, which would break the listing outright on older servers. + /// Comparing `relkind` against `'p'`/`'I'` is a value test on a column + /// present since PostgreSQL 8, so it parses everywhere and simply matches + /// nothing before declarative partitioning existed. Rows still come from + /// `information_schema.tables`, which keeps its privilege filtering; the + /// catalog joins only label and exclude rows it already returned. The + /// caller passes `false` for engines without these catalogs. + /// + /// Legacy `INHERITS` children stay listed on purpose. Their parent is an + /// ordinary table (`relkind = 'r'`), and they are independently useful + /// tables rather than an implementation detail of one parent. static func fetchTables( schemaLiteral: String, includeMaterializedViews: Bool, includeForeignTables: Bool, - includeComments: Bool = true + includeComments: Bool = true, + includePartitionAwareness: Bool = true ) -> String { func commentColumn(_ expression: String) -> String { includeComments ? expression : "NULL::text" } + let partitionJoin = includePartitionAwareness ? """ + + LEFT JOIN pg_catalog.pg_namespace pn ON pn.nspname = t.table_schema + LEFT JOIN pg_catalog.pg_class pc ON pc.relnamespace = pn.oid AND pc.relname = t.table_name + """ : "" + + let tableTypeColumn = includePartitionAwareness + ? "CASE WHEN pc.relkind = 'p' THEN 'PARTITIONED TABLE' ELSE t.table_type END" + : "t.table_type" + + let partitionFilter = includePartitionAwareness ? """ + + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_inherits i + JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent + WHERE i.inhrelid = pc.oid + AND parent.relkind IN ('p', 'I')) + """ : "" + var unions: [String] = [ """ - SELECT t.table_name, t.table_type, + SELECT t.table_name, \(tableTypeColumn) AS table_type, \(commentColumn("obj_description(to_regclass(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name)), 'pg_class')")) AS table_comment - FROM information_schema.tables t + FROM information_schema.tables t\(partitionJoin) WHERE t.table_schema = '\(schemaLiteral)' - AND t.table_type IN ('BASE TABLE', 'VIEW') + AND t.table_type IN ('BASE TABLE', 'VIEW')\(partitionFilter) """ ] @@ -123,6 +162,27 @@ enum PostgreSQLSchemaQueries { return unions.joined(separator: "\nUNION ALL\n") + "\nORDER BY table_name" } + /// Lists one partitioned table's direct partitions, ordered so the DEFAULT + /// partition sorts last. A child that is itself subpartitioned comes back + /// with `relkind = 'p'` so it can be expanded in turn. + /// + /// `relpartbound` exists only from PostgreSQL 10, so unlike `fetchTables` + /// this query cannot be issued against an older server. The caller gates it + /// on `PostgreSQLCapabilities.hasDeclarativePartitioning`. + static func fetchPartitions(schemaLiteral: String, tableLiteral: String) -> String { + """ + SELECT cc.relname, cc.relkind + FROM pg_catalog.pg_inherits i + JOIN pg_catalog.pg_class parent ON parent.oid = i.inhparent + JOIN pg_catalog.pg_namespace pn ON pn.oid = parent.relnamespace + JOIN pg_catalog.pg_class cc ON cc.oid = i.inhrelid + WHERE pn.nspname = '\(schemaLiteral)' + AND parent.relname = '\(tableLiteral)' + AND parent.relkind = 'p' + ORDER BY pg_catalog.pg_get_expr(cc.relpartbound, cc.oid) = 'DEFAULT', cc.relname + """ + } + static func setSearchPath(toSchema schema: String) -> String { let quotedIdentifier = "\"\(schema.replacingOccurrences(of: "\"", with: "\"\""))\"" return "SET search_path TO \(quotedIdentifier)" diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift new file mode 100644 index 000000000..2ef7f4df5 --- /dev/null +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift @@ -0,0 +1,51 @@ +// +// PostgreSQLTableListingLadder.swift +// PostgreSQLDriverPlugin +// +// Ordered fallbacks for the table listing, extracted so the ordering can be +// exercised by unit tests via TableProTests/PluginTestSources. +// + +import Foundation + +struct PostgreSQLTableListingAttempt: Sendable, Equatable { + let includeOptionalCatalogs: Bool + let includeComments: Bool + let includePartitionAwareness: Bool + + var label: String { + "catalogs=\(includeOptionalCatalogs) comments=\(includeComments) partitions=\(includePartitionAwareness)" + } +} + +/// A PostgreSQL-compatible engine may lack the optional catalogs the listing +/// reads, so each rung drops one more of them and retries. Partition awareness +/// degrades last: it is the only rung that changes which rows come back rather +/// than which columns, so a partition-free listing is worth every other +/// fallback first. A single flat `catch` cannot express this, because the same +/// SQLSTATE means a different missing catalog at each rung. +enum PostgreSQLTableListingLadder { + static let undefinedTableSQLState = "42P01" + static let undefinedFunctionSQLState = "42883" + + static let degradableAttempts: [PostgreSQLTableListingAttempt] = [ + PostgreSQLTableListingAttempt( + includeOptionalCatalogs: true, includeComments: true, includePartitionAwareness: true + ), + PostgreSQLTableListingAttempt( + includeOptionalCatalogs: false, includeComments: true, includePartitionAwareness: true + ), + PostgreSQLTableListingAttempt( + includeOptionalCatalogs: false, includeComments: false, includePartitionAwareness: true + ) + ] + + static let leastCapableAttempt = PostgreSQLTableListingAttempt( + includeOptionalCatalogs: false, includeComments: false, includePartitionAwareness: false + ) + + static func isDegradable(sqlState: String?) -> Bool { + guard let sqlState else { return false } + return sqlState == undefinedTableSQLState || sqlState == undefinedFunctionSQLState + } +} diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index a05af9886..361d75d3a 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -82,6 +82,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { func executeUserQuery(query: String, rowCap: Int?, parameters: [PluginCellValue]?) async throws -> PluginQueryResult func fetchTables(schema: String?) async throws -> [PluginTableInfo] + func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] @@ -201,6 +202,10 @@ public extension PluginDatabaseDriver { func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] { [] } + /// Engines whose partitions are metadata on one table object, rather than + /// separate relations, have nothing to nest and keep the empty default. + func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { [] } + func createTriggerTemplate(table: String, schema: String?) -> String? { nil } func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { nil } func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { nil } diff --git a/TablePro/Core/Database/DatabaseDriver.swift b/TablePro/Core/Database/DatabaseDriver.swift index 7251e751a..97faa7972 100644 --- a/TablePro/Core/Database/DatabaseDriver.swift +++ b/TablePro/Core/Database/DatabaseDriver.swift @@ -69,6 +69,9 @@ protocol DatabaseDriver: AnyObject, Sendable { func fetchTables(schema: String?) async throws -> [TableInfo] + /// Fetch the direct partitions of one partitioned table + func fetchPartitions(table: String, schema: String?) async throws -> [TableInfo] + /// Fetch columns for a specific table func fetchColumns(table: String) async throws -> [ColumnInfo] @@ -252,6 +255,8 @@ extension DatabaseDriver { try await fetchColumns(table: table) } + func fetchPartitions(table: String, schema: String?) async throws -> [TableInfo] { [] } + func fetchTriggers(table: String) async throws -> [TriggerInfo] { [] } func createTriggerTemplate(table: String) -> String? { nil } diff --git a/TablePro/Core/Database/TableOperationSQLBuilder.swift b/TablePro/Core/Database/TableOperationSQLBuilder.swift index a62ae1704..1cce44172 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, .none: + case .table, .systemTable, .partitionedTable, .none: return "TABLE" } } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 6a0b1422e..de01405e5 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -181,11 +181,19 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) } } + func fetchPartitions(table: String, schema: String?) async throws -> [TableInfo] { + let resolvedSchema = schema ?? pluginDriver.currentSchema + let pluginTables = try await pluginDriver.fetchPartitions(table: table, schema: resolvedSchema) + return pluginTables.map { mapPluginTable($0, schemaFallback: resolvedSchema) } + } + private func mapPluginTable(_ table: PluginTableInfo, schemaFallback: String?) -> TableInfo { let tableType: TableInfo.TableType switch table.type.lowercased() { case "table", "base table", "prefix": tableType = .table + case "partitioned table", "partitioned_table": + tableType = .partitionedTable case "view": tableType = .view case "materialized view", "materialized_view": diff --git a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift index a2c73c39e..72a432b6a 100644 --- a/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift +++ b/TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift @@ -23,15 +23,24 @@ final class DatabaseTreeMetadataService { let schema: String? } + struct PartitionsKey: Hashable, Sendable { + let connectionId: UUID + let database: String + let schema: String? + let table: String + } + private(set) var databaseList: [UUID: MetadataLoadState<[DatabaseMetadata]>] = [:] private(set) var schemaList: [DatabaseKey: MetadataLoadState<[String]>] = [:] private(set) var tablesState: [ObjectsKey: MetadataLoadState<[TableInfo]>] = [:] private(set) var routinesState: [ObjectsKey: MetadataLoadState<[RoutineInfo]>] = [:] + private(set) var partitionsState: [PartitionsKey: MetadataLoadState<[TableInfo]>] = [:] @ObservationIgnored private let databaseDedup = OnceTask() @ObservationIgnored private let schemaDedup = OnceTask() @ObservationIgnored private let tablesDedup = OnceTask() @ObservationIgnored private let routinesDedup = OnceTask() + @ObservationIgnored private let partitionsDedup = OnceTask() @ObservationIgnored private static let logger = Logger( subsystem: "com.TablePro", category: "SidebarTree" @@ -73,6 +82,13 @@ final class DatabaseTreeMetadataService { routinesState[Self.objectsKey(connectionId: connectionId, database: database, schema: schema)]?.value ?? [] } + func partitionsLoadState( + connectionId: UUID, database: String, schema: String?, table: String + ) -> MetadataLoadState<[TableInfo]> { + let key = Self.partitionsKey(connectionId: connectionId, database: database, schema: schema, table: table) + return partitionsState[key] ?? .idle + } + // MARK: - Loads func loadDatabases(connectionId: UUID, databaseType: DatabaseType) async { @@ -182,6 +198,32 @@ final class DatabaseTreeMetadataService { } } + func loadPartitions(connectionId: UUID, database: String, schema: String?, table: String) async { + guard isConnected(connectionId) else { return } + let key = Self.partitionsKey(connectionId: connectionId, database: database, schema: schema, table: table) + switch partitionsState[key] ?? .idle { + case .loaded, .loading: return + case .idle, .failed: break + } + partitionsState[key] = .loading + let normalizedSchema = key.schema + do { + let list = try await partitionsDedup.execute(key: key) { [self] in + try await withDriver(connectionId: connectionId, database: database) { driver in + try await driver.fetchPartitions(table: table, schema: normalizedSchema) + } + } + partitionsState[key] = .loaded(list) + } catch is CancellationError { + if case .loading = partitionsState[key] { partitionsState[key] = .idle } + } catch { + partitionsState[key] = .failed(error.localizedDescription) + Self.logger.warning( + "partitions load failed db=\(database, privacy: .public) table=\(table, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + // MARK: - Refresh func refreshDatabases(connectionId: UUID, databaseType: DatabaseType) async { @@ -203,6 +245,10 @@ final class DatabaseTreeMetadataService { await routinesDedup.cancel(key: key) tablesState.removeValue(forKey: key) routinesState.removeValue(forKey: key) + for partitionKey in partitionKeys(matching: key) { + await partitionsDedup.cancel(key: partitionKey) + partitionsState.removeValue(forKey: partitionKey) + } async let tables = loadTables(connectionId: connectionId, database: database, schema: schema) async let routines = loadRoutines(connectionId: connectionId, database: database, schema: schema) _ = await (tables, routines) @@ -212,12 +258,20 @@ final class DatabaseTreeMetadataService { let keys = tablesState.keys.filter { key in key.connectionId == connectionId && (database == nil || key.database == database) } + let loadedPartitionKeys = partitionsState.keys.filter { key in + key.connectionId == connectionId && (database == nil || key.database == database) + } await withTaskGroup(of: Void.self) { group in for key in keys { group.addTask { @MainActor in await self.reloadTablesInPlace(key) } } + for key in loadedPartitionKeys { + group.addTask { @MainActor in + await self.reloadPartitionsInPlace(key) + } + } } } @@ -241,6 +295,26 @@ final class DatabaseTreeMetadataService { } } + private func reloadPartitionsInPlace(_ key: PartitionsKey) async { + guard isConnected(key.connectionId) else { return } + await partitionsDedup.cancel(key: key) + do { + let list = try await partitionsDedup.execute(key: key) { [self] in + try await withDriver(connectionId: key.connectionId, database: key.database) { driver in + try await driver.fetchPartitions(table: key.table, schema: key.schema) + } + } + let next: MetadataLoadState<[TableInfo]> = .loaded(list) + guard partitionsState[key] != next else { return } + partitionsState[key] = next + } catch is CancellationError { + } catch { + Self.logger.warning( + "partitions refresh failed db=\(key.database, privacy: .public) table=\(key.table, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + } + } + // MARK: - Lifecycle func handleReconnect(connectionId: UUID) async { @@ -260,10 +334,14 @@ final class DatabaseTreeMetadataService { await tablesDedup.cancel(key: key) await routinesDedup.cancel(key: key) } + for key in connectionPartitionKeys(connectionId) { + await partitionsDedup.cancel(key: key) + } databaseList.removeValue(forKey: connectionId) schemaList = schemaList.filter { $0.key.connectionId != connectionId } tablesState = tablesState.filter { $0.key.connectionId != connectionId } routinesState = routinesState.filter { $0.key.connectionId != connectionId } + partitionsState = partitionsState.filter { $0.key.connectionId != connectionId } } // MARK: - Private @@ -284,6 +362,10 @@ final class DatabaseTreeMetadataService { if isPending(tablesState[key]) { await tablesDedup.cancel(key: key) } if isPending(routinesState[key]) { await routinesDedup.cancel(key: key) } } + let partitionKeys = connectionPartitionKeys(connectionId) + for key in partitionKeys where isPending(partitionsState[key]) { + await partitionsDedup.cancel(key: key) + } if isPending(databaseList[connectionId]) { databaseList[connectionId] = .idle } for key in schemaKeys where isPending(schemaList[key]) { schemaList[key] = .idle } @@ -291,6 +373,7 @@ final class DatabaseTreeMetadataService { if isPending(tablesState[key]) { tablesState[key] = .idle } if isPending(routinesState[key]) { routinesState[key] = .idle } } + for key in partitionKeys where isPending(partitionsState[key]) { partitionsState[key] = .idle } } private func isPending(_ state: MetadataLoadState?) -> Bool { @@ -325,6 +408,23 @@ final class DatabaseTreeMetadataService { return ObjectsKey(connectionId: connectionId, database: database, schema: normalized) } + private static func partitionsKey( + connectionId: UUID, database: String, schema: String?, table: String + ) -> PartitionsKey { + let normalized: String? = (schema?.isEmpty == true) ? nil : schema + return PartitionsKey(connectionId: connectionId, database: database, schema: normalized, table: table) + } + + private func partitionKeys(matching key: ObjectsKey) -> [PartitionsKey] { + partitionsState.keys.filter { + $0.connectionId == key.connectionId && $0.database == key.database && $0.schema == key.schema + } + } + + private func connectionPartitionKeys(_ connectionId: UUID) -> [PartitionsKey] { + partitionsState.keys.filter { $0.connectionId == connectionId } + } + nonisolated static func connectionObjectKeys( tableKeys: some Sequence, routineKeys: some Sequence, diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 8665bceae..6f9125227 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -99,6 +99,7 @@ struct TableInfo: Identifiable, Hashable, Sendable { case materializedView = "MATERIALIZED VIEW" case foreignTable = "FOREIGN TABLE" case systemTable = "SYSTEM TABLE" + case partitionedTable = "PARTITIONED TABLE" } init(name: String, type: TableType, rowCount: Int?, schema: String? = nil, comment: String? = nil) { diff --git a/TablePro/Models/UI/WindowSidebarState.swift b/TablePro/Models/UI/WindowSidebarState.swift index 6670179e0..0bc3725e8 100644 --- a/TablePro/Models/UI/WindowSidebarState.swift +++ b/TablePro/Models/UI/WindowSidebarState.swift @@ -12,6 +12,12 @@ struct DatabaseSchemaKey: Hashable, Sendable, Codable { let schema: String } +struct DatabaseTableKey: Hashable, Sendable, Codable { + let database: String + let schema: String? + let table: String +} + @MainActor @Observable internal final class WindowSidebarState { @@ -23,6 +29,7 @@ internal final class WindowSidebarState { var expandedTreeSchemas: Set = [] { didSet { persistExpansion() } } var expandedTreeDatabases: Set = [] { didSet { persistExpansion() } } var expandedTreeDatabaseSchemas: Set = [] { didSet { persistExpansion() } } + var expandedTreeTables: Set = [] { didSet { persistExpansion() } } init(connectionId: UUID? = nil, defaults: UserDefaults = .standard) { self.connectionId = connectionId @@ -35,6 +42,7 @@ internal final class WindowSidebarState { var schemas: [String] var databases: [String] var databaseSchemas: [DatabaseSchemaKey] + var tables: [DatabaseTableKey]? } private var storageKey: String? { @@ -48,12 +56,14 @@ internal final class WindowSidebarState { expandedTreeSchemas = Set(decoded.schemas) expandedTreeDatabases = Set(decoded.databases) expandedTreeDatabaseSchemas = Set(decoded.databaseSchemas) + expandedTreeTables = Set(decoded.tables ?? []) } private func persistExpansion() { guard isLoaded, let storageKey else { return } - if expandedTreeSchemas.isEmpty, expandedTreeDatabases.isEmpty, expandedTreeDatabaseSchemas.isEmpty { + if expandedTreeSchemas.isEmpty, expandedTreeDatabases.isEmpty, + expandedTreeDatabaseSchemas.isEmpty, expandedTreeTables.isEmpty { defaults.removeObject(forKey: storageKey) return } @@ -61,7 +71,8 @@ internal final class WindowSidebarState { let snapshot = PersistedExpansion( schemas: Array(expandedTreeSchemas), databases: Array(expandedTreeDatabases), - databaseSchemas: Array(expandedTreeDatabaseSchemas) + databaseSchemas: Array(expandedTreeDatabaseSchemas), + tables: Array(expandedTreeTables) ) if let data = try? JSONEncoder().encode(snapshot) { defaults.set(data, forKey: storageKey) diff --git a/TablePro/ViewModels/QuickSwitcherViewModel.swift b/TablePro/ViewModels/QuickSwitcherViewModel.swift index 462b748ca..4fefe92ca 100644 --- a/TablePro/ViewModels/QuickSwitcherViewModel.swift +++ b/TablePro/ViewModels/QuickSwitcherViewModel.swift @@ -115,6 +115,9 @@ internal final class QuickSwitcherViewModel { case .systemTable: kind = .systemTable subtitle = String(localized: "System") + case .partitionedTable: + kind = .table + subtitle = String(localized: "Partitioned Table") } items.append(QuickSwitcherItem( id: "table_\(table.name)_\(table.type.rawValue)", diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 50c6d6fef..7508a6153 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -721,42 +721,8 @@ struct ExportDialog: View { } private func fetchTablesForSchema(_ schema: String) async throws -> [TableInfo] { - let isOracle = connection.type.pluginTypeId == "Oracle" - return try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in - if isOracle { - let escapedSchema = schema.replacingOccurrences(of: "'", with: "''") - let query = """ - SELECT TABLE_NAME, 'BASE TABLE' AS TABLE_TYPE FROM ALL_TABLES WHERE OWNER = '\(escapedSchema)' - UNION ALL - SELECT VIEW_NAME, 'VIEW' FROM ALL_VIEWS WHERE OWNER = '\(escapedSchema)' - ORDER BY 1 - """ - let result = try await driver.execute(query: query) - return result.rows.compactMap { row -> TableInfo? in - guard let name = row[safe: 0]?.asText else { return nil } - let typeStr = row[safe: 1]?.asText ?? "BASE TABLE" - let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: name, type: type, rowCount: nil) - } - } - - let query = """ - SELECT table_schema, table_name, table_type - FROM information_schema.tables - ORDER BY table_name - """ - let result = try await driver.execute(query: query) - return result.rows.compactMap { row -> TableInfo? in - guard row.count >= 2, - let rowSchema = row[0].asText, - rowSchema == schema, - let name = row[1].asText else { - return nil - } - let typeStr = row.count > 2 ? (row[2].asText ?? "BASE TABLE") : "BASE TABLE" - let type: TableInfo.TableType = typeStr.uppercased().contains("VIEW") ? .view : .table - return TableInfo(name: name, type: type, rowCount: nil) - } + try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in + try await driver.fetchTables(schema: schema) } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeNode.swift b/TablePro/Views/Sidebar/DatabaseTreeNode.swift index 1d7516bda..f2c4b9cbf 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeNode.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeNode.swift @@ -34,7 +34,8 @@ final class DatabaseTreeNode { var isExpandable: Bool { switch kind { case .recentSection, .database, .schema: return true - case .recentTable, .table, .routine, .status: return false + case .table(let ref): return ref.table.type == .partitionedTable + case .recentTable, .routine, .status: return false } } diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index fc4c2e363..c34631dc6 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -134,6 +134,10 @@ final class DatabaseTreeOutlineCoordinator: NSObject { case .schema(let database, let schema): _ = service.tablesLoadState(connectionId: connectionId, database: database, schema: schema) _ = service.routinesLoadState(connectionId: connectionId, database: database, schema: schema) + case .table(let ref) where ref.table.type == .partitionedTable: + _ = service.partitionsLoadState( + connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name + ) case .recentSection, .recentTable, .table, .routine, .status: break } @@ -184,11 +188,32 @@ final class DatabaseTreeOutlineCoordinator: NSObject { : objectNodes(database: metadata.name, schema: nil) case .schema(let database, let schema): return objectNodes(database: database, schema: schema) - case .recentTable, .table, .routine, .status: + case .table(let ref): + return ref.table.type == .partitionedTable ? partitionNodes(of: ref) : [] + case .recentTable, .routine, .status: return [] } } + private func partitionNodes(of ref: DatabaseTreeTableRef) -> [DatabaseTreeNode] { + let parentId = DatabaseTreeNode.tableId(ref) + let state = service.partitionsLoadState( + connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name + ) + switch state { + case .idle, .loading: + return [statusNode(parentId: parentId, status: .loading)] + case .failed(let message): + return [statusNode(parentId: parentId, status: .error(message))] + case .loaded(let partitions): + if partitions.isEmpty { return [statusNode(parentId: parentId, status: .empty)] } + return partitions.map { partition in + let childRef = DatabaseTreeTableRef(database: ref.database, schema: ref.schema, table: partition) + return node(id: DatabaseTreeNode.tableId(childRef), kind: .table(childRef)) + } + } + } + private func rootNodes() -> [DatabaseTreeNode] { var nodes: [DatabaseTreeNode] = [] if !recentTableRefs().isEmpty { @@ -324,7 +349,10 @@ final class DatabaseTreeOutlineCoordinator: NSObject { setExpanded(databaseNode, want) guard outlineView.isItemExpanded(databaseNode) else { continue } triggerLoad(for: databaseNode) - guard supportsSchemaLevel else { continue } + guard supportsSchemaLevel else { + restorePartitionExpansion(under: databaseNode) + continue + } for schemaNode in resolvedChildren(of: databaseNode) { guard case .schema(let database, let schema) = schemaNode.kind else { continue } let wantSchema = searching @@ -333,11 +361,25 @@ final class DatabaseTreeOutlineCoordinator: NSObject { setExpanded(schemaNode, wantSchema) if outlineView.isItemExpanded(schemaNode) { triggerLoad(for: schemaNode) + restorePartitionExpansion(under: schemaNode) } } } } + private func restorePartitionExpansion(under parent: DatabaseTreeNode) { + guard searchText.isEmpty, let outlineView, let windowState else { return } + for tableNode in resolvedChildren(of: parent) { + guard case .table(let ref) = tableNode.kind, ref.table.type == .partitionedTable else { continue } + let key = DatabaseTableKey(database: ref.database, schema: ref.schema, table: ref.table.name) + guard windowState.expandedTreeTables.contains(key) else { continue } + setExpanded(tableNode, true) + guard outlineView.isItemExpanded(tableNode) else { continue } + triggerLoad(for: tableNode) + restorePartitionExpansion(under: tableNode) + } + } + private func setExpanded(_ node: DatabaseTreeNode, _ expanded: Bool) { guard let outlineView else { return } if expanded, !outlineView.isItemExpanded(node) { @@ -364,7 +406,14 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } else { windowState?.expandedTreeDatabaseSchemas.remove(key) } - case .recentTable, .table, .routine, .status: + case .table(let ref): + let key = DatabaseTableKey(database: ref.database, schema: ref.schema, table: ref.table.name) + if expanded { + windowState?.expandedTreeTables.insert(key) + } else { + windowState?.expandedTreeTables.remove(key) + } + case .recentTable, .routine, .status: break } } @@ -381,11 +430,26 @@ final class DatabaseTreeOutlineCoordinator: NSObject { } case .schema(let database, let schema): loadObjects(database: database, schema: schema) - case .recentSection, .recentTable, .table, .routine, .status: + case .table(let ref): + loadPartitions(ref) + case .recentSection, .recentTable, .routine, .status: break } } + private func loadPartitions(_ ref: DatabaseTreeTableRef) { + guard ref.table.type == .partitionedTable else { return } + let state = service.partitionsLoadState( + connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name + ) + guard isIdle(state) else { return } + Task { + await service.loadPartitions( + connectionId: connectionId, database: ref.database, schema: ref.schema, table: ref.table.name + ) + } + } + private func loadObjects(database: String, schema: String?) { if isIdle(service.tablesLoadState(connectionId: connectionId, database: database, schema: schema)) { Task { await service.loadTables(connectionId: connectionId, database: database, schema: schema) } diff --git a/TablePro/Views/Sidebar/SidebarContextMenu.swift b/TablePro/Views/Sidebar/SidebarContextMenu.swift index af7248f72..72e0500f7 100644 --- a/TablePro/Views/Sidebar/SidebarContextMenu.swift +++ b/TablePro/Views/Sidebar/SidebarContextMenu.swift @@ -19,7 +19,7 @@ enum SidebarContextMenuLogic { switch type { case .view, .materializedView, .foreignTable, .systemTable: return true - case .table, .none: + case .table, .partitionedTable, .none: return false } } @@ -39,7 +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 .table, .none: return String(localized: "Delete") + case .table, .partitionedTable, .none: return String(localized: "Delete") } } diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index a5ad08a4e..a684a1fba 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -13,6 +13,7 @@ enum TableRowLogic { case .materializedView: return "square.stack.3d.up" case .foreignTable: return "link" case .systemTable: return "tablecells.badge.ellipsis" + case .partitionedTable: return "rectangle.split.3x1" } } @@ -23,6 +24,7 @@ enum TableRowLogic { case .materializedView: return String(localized: "Materialized View") case .foreignTable: return String(localized: "Foreign Table") case .systemTable: return String(localized: "System Table") + case .partitionedTable: return String(localized: "Partitioned Table") } } diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterPartitionDefaultTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterPartitionDefaultTests.swift new file mode 100644 index 000000000..3480eed73 --- /dev/null +++ b/TableProTests/Core/Plugins/PluginDriverAdapterPartitionDefaultTests.swift @@ -0,0 +1,49 @@ +// +// PluginDriverAdapterPartitionDefaultTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class PartitionUnawareDriver: PluginDatabaseDriver { + var supportsSchemas: Bool { false } + var supportsTransactions: Bool { false } + var currentSchema: String? { nil } + var serverVersion: String? { nil } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + + func connect() async throws {} + func disconnect() {} + func ping() async throws {} + func execute(query: String) async throws -> PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +@Suite("Partition support stays optional for plugins") +struct PluginDriverAdapterPartitionDefaultTests { + @Test("A driver that never implements fetchPartitions still resolves through the protocol default") + func unimplementedFetchPartitionsReturnsEmpty() async throws { + let connection = DatabaseConnection(name: "Test", type: .postgresql) + let adapter = PluginDriverAdapter(connection: connection, pluginDriver: PartitionUnawareDriver()) + let partitions = try await adapter.fetchPartitions(table: "orders", schema: "public") + #expect(partitions.isEmpty) + } +} diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift index 4d82a20f6..fced40ede 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift @@ -15,11 +15,18 @@ private final class StubTableTypeDriver: PluginDatabaseDriver { var serverVersion: String? { nil } var stubbedTables: [PluginTableInfo] = [] + var stubbedPartitions: [PluginTableInfo] = [] + private(set) var requestedPartitionTable: String? func fetchTables(schema: String?) async throws -> [PluginTableInfo] { stubbedTables } + func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] { + requestedPartitionTable = table + return stubbedPartitions + } + func connect() async throws {} func disconnect() {} func ping() async throws {} @@ -151,8 +158,53 @@ struct PluginDriverAdapterTableTypeMappingTests { func rawValueRoundTrip() { #expect(TableInfo.TableType.materializedView.rawValue == "MATERIALIZED VIEW") #expect(TableInfo.TableType.foreignTable.rawValue == "FOREIGN TABLE") + #expect(TableInfo.TableType.partitionedTable.rawValue == "PARTITIONED TABLE") #expect(TableInfo.TableType(rawValue: "MATERIALIZED VIEW") == .materializedView) #expect(TableInfo.TableType(rawValue: "FOREIGN TABLE") == .foreignTable) + #expect(TableInfo.TableType(rawValue: "PARTITIONED TABLE") == .partitionedTable) + } + + @Test("Maps PARTITIONED TABLE variants to .partitionedTable rather than falling back to .table") + func mapsPartitionedTable() async throws { + let driver = StubTableTypeDriver() + driver.stubbedTables = [ + PluginTableInfo(name: "orders", type: "PARTITIONED TABLE"), + PluginTableInfo(name: "events", type: "partitioned_table"), + PluginTableInfo(name: "logs", type: "Partitioned Table") + ] + let adapter = makeAdapter(driver: driver) + let tables = try await adapter.fetchTables() + #expect(tables.count == 3) + #expect(tables.allSatisfy { $0.type == .partitionedTable }) + } + + @Test("A partitioned parent stays distinct from an ordinary table") + func partitionedTableIsDistinctFromTable() async throws { + let driver = StubTableTypeDriver() + driver.stubbedTables = [ + PluginTableInfo(name: "orders", type: "PARTITIONED TABLE"), + PluginTableInfo(name: "users", type: "BASE TABLE") + ] + let adapter = makeAdapter(driver: driver) + let tables = try await adapter.fetchTables() + #expect(tables[0].type == .partitionedTable) + #expect(tables[1].type == .table) + } + + @Test("fetchPartitions bridges plugin rows and resolves the schema") + func fetchPartitionsBridgesRows() async throws { + let driver = StubTableTypeDriver() + driver.stubbedPartitions = [ + PluginTableInfo(name: "orders_2024_01", type: "TABLE"), + PluginTableInfo(name: "orders_2024_02", type: "PARTITIONED TABLE") + ] + let adapter = makeAdapter(driver: driver) + let partitions = try await adapter.fetchPartitions(table: "orders", schema: "app") + #expect(driver.requestedPartitionTable == "orders") + #expect(partitions.map(\.name) == ["orders_2024_01", "orders_2024_02"]) + #expect(partitions[0].type == .table) + #expect(partitions[1].type == .partitionedTable) + #expect(partitions.allSatisfy { $0.schema == "app" }) } @Test("Plugin schema propagates to TableInfo when set on PluginTableInfo") diff --git a/TableProTests/PluginTestSources/PostgreSQLTableListingLadder.swift b/TableProTests/PluginTestSources/PostgreSQLTableListingLadder.swift new file mode 120000 index 000000000..6afa21a72 --- /dev/null +++ b/TableProTests/PluginTestSources/PostgreSQLTableListingLadder.swift @@ -0,0 +1 @@ +../../Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift \ No newline at end of file diff --git a/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift index c6ae9687a..a8e84e30b 100644 --- a/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift +++ b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift @@ -15,15 +15,18 @@ struct PostgreSQLFetchTablesCommentTests { #expect(query.contains("obj_description")) } - @Test("Base query does not reference pg_class/pg_namespace so the portability fallback stays minimal") - func baseQueryStaysPortable() { + @Test("Fully degraded query does not reference pg_class/pg_namespace so the portability fallback stays minimal") + func fallbackQueryStaysPortable() { let query = PostgreSQLSchemaQueries.fetchTables( schemaLiteral: "public", includeMaterializedViews: false, - includeForeignTables: false + includeForeignTables: false, + includeComments: false, + includePartitionAwareness: false ) #expect(!query.contains("pg_catalog.pg_class")) #expect(!query.contains("pg_catalog.pg_namespace")) + #expect(!query.contains("pg_catalog.pg_inherits")) } @Test("Every union branch projects a comment column so columns stay aligned") diff --git a/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift b/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift new file mode 100644 index 000000000..87956a55b --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLPartitionFilterTests.swift @@ -0,0 +1,131 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQLSchemaQueries partition awareness") +struct PostgreSQLPartitionFilterTests { + private func awareQuery() -> String { + PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: false, + includeForeignTables: false + ) + } + + @Test("Partition children are excluded through pg_inherits") + func excludesPartitionChildren() { + let query = awareQuery() + #expect(query.contains("NOT EXISTS")) + #expect(query.contains("pg_catalog.pg_inherits")) + #expect(query.contains("i.inhrelid = pc.oid")) + } + + @Test("Children are matched by their parent relkind, not relispartition") + func usesParentRelkindNotRelispartition() { + let query = awareQuery() + #expect(query.contains("parent.relkind IN ('p', 'I')")) + #expect(!query.contains("relispartition")) + } + + @Test("A partitioned parent is labelled instead of reported as a plain base table") + func labelsPartitionedParent() { + let query = awareQuery() + #expect(query.contains("CASE WHEN pc.relkind = 'p' THEN 'PARTITIONED TABLE' ELSE t.table_type END")) + } + + @Test("Rows still come from information_schema so privilege filtering is preserved") + func keepsInformationSchemaAsRowSource() { + let query = awareQuery() + #expect(query.contains("FROM information_schema.tables t")) + } + + @Test("Partition awareness degrades independently of the optional catalogs") + func partitionAwarenessDegradesIndependently() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: true, + includeForeignTables: true, + includePartitionAwareness: false + ) + #expect(!query.contains("pg_catalog.pg_inherits")) + #expect(!query.contains("PARTITIONED TABLE")) + #expect(query.contains("pg_matviews")) + #expect(query.contains("pg_foreign_table")) + } + + @Test("Every union branch still projects three aligned columns when partition aware") + func unionBranchesStayAligned() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: true, + includeForeignTables: true + ) + let typeColumns = query.components(separatedBy: "AS table_type").count - 1 + let commentColumns = query.components(separatedBy: "AS table_comment").count - 1 + let branches = query.components(separatedBy: "UNION ALL").count + #expect(typeColumns == branches) + #expect(commentColumns == branches) + } + + @Test("Partition listing is scoped to one parent in one schema") + func fetchPartitionsScopesToParent() { + let query = PostgreSQLSchemaQueries.fetchPartitions(schemaLiteral: "public", tableLiteral: "orders") + #expect(query.contains("pn.nspname = 'public'")) + #expect(query.contains("parent.relname = 'orders'")) + #expect(query.contains("parent.relkind = 'p'")) + } + + @Test("Partition listing sorts the DEFAULT partition last") + func fetchPartitionsSortsDefaultLast() { + let query = PostgreSQLSchemaQueries.fetchPartitions(schemaLiteral: "public", tableLiteral: "orders") + #expect(query.contains("ORDER BY pg_catalog.pg_get_expr(cc.relpartbound, cc.oid) = 'DEFAULT', cc.relname")) + } + + @Test("Partition listing projects relkind so subpartitioned children stay expandable") + func fetchPartitionsProjectsRelkind() { + let query = PostgreSQLSchemaQueries.fetchPartitions(schemaLiteral: "public", tableLiteral: "orders") + #expect(query.contains("SELECT cc.relname, cc.relkind")) + } +} + +@Suite("PostgreSQL table listing degradation ladder") +struct PostgreSQLTableListingLadderTests { + @Test("Partition awareness survives every rung that only drops columns") + func partitionAwarenessDegradesLast() { + let attempts = PostgreSQLTableListingLadder.degradableAttempts + let everyRungKeepsPartitions = attempts.filter(\.includePartitionAwareness).count == attempts.count + #expect(everyRungKeepsPartitions) + #expect(attempts.first?.includeOptionalCatalogs == true) + #expect(attempts.first?.includeComments == true) + } + + @Test("Each rung drops strictly more than the one before it") + func ladderDegradesMonotonically() { + let attempts = PostgreSQLTableListingLadder.degradableAttempts + + [PostgreSQLTableListingLadder.leastCapableAttempt] + let ranks = attempts.map { attempt in + [attempt.includeOptionalCatalogs, attempt.includeComments, attempt.includePartitionAwareness] + .filter { $0 }.count + } + let descending = ranks.sorted { $0 > $1 } + #expect(ranks == descending) + #expect(Set(ranks).count == ranks.count) + } + + @Test("Only the final rung abandons partition awareness") + func finalRungAbandonsPartitionAwareness() { + let last = PostgreSQLTableListingLadder.leastCapableAttempt + #expect(!last.includePartitionAwareness) + #expect(!last.includeOptionalCatalogs) + #expect(!last.includeComments) + } + + @Test("Only a missing relation or function degrades the listing") + func onlyCatalogFailuresDegrade() { + #expect(PostgreSQLTableListingLadder.isDegradable(sqlState: "42P01")) + #expect(PostgreSQLTableListingLadder.isDegradable(sqlState: "42883")) + #expect(!PostgreSQLTableListingLadder.isDegradable(sqlState: "42703")) + #expect(!PostgreSQLTableListingLadder.isDegradable(sqlState: "28000")) + #expect(!PostgreSQLTableListingLadder.isDegradable(sqlState: nil)) + } +} diff --git a/TableProTests/ViewModels/WindowSidebarStateTests.swift b/TableProTests/ViewModels/WindowSidebarStateTests.swift index c86f5d9ea..5280e1804 100644 --- a/TableProTests/ViewModels/WindowSidebarStateTests.swift +++ b/TableProTests/ViewModels/WindowSidebarStateTests.swift @@ -73,6 +73,63 @@ struct WindowSidebarStateTests { #expect(restored.expandedTreeDatabases.isEmpty) } + @Test("Expanded partitioned tables persist and restore") + func persistsExpandedPartitionedTables() throws { + let defaults = try makeDefaults() + let connectionId = UUID() + let orders = DatabaseTableKey(database: "shop", schema: "public", table: "orders") + + let state = WindowSidebarState(connectionId: connectionId, defaults: defaults) + state.expandedTreeTables.insert(orders) + + let restored = WindowSidebarState(connectionId: connectionId, defaults: defaults) + #expect(restored.expandedTreeTables == [orders]) + } + + @Test("A schema-less table key stays distinct from a schema-qualified one") + func partitionedTableKeysDistinguishSchema() throws { + let defaults = try makeDefaults() + let connectionId = UUID() + let qualified = DatabaseTableKey(database: "shop", schema: "public", table: "orders") + let unqualified = DatabaseTableKey(database: "shop", schema: nil, table: "orders") + + let state = WindowSidebarState(connectionId: connectionId, defaults: defaults) + state.expandedTreeTables = [qualified, unqualified] + + let restored = WindowSidebarState(connectionId: connectionId, defaults: defaults) + #expect(restored.expandedTreeTables.count == 2) + #expect(restored.expandedTreeTables.contains(qualified)) + #expect(restored.expandedTreeTables.contains(unqualified)) + } + + @Test("Expansion saved before partitions shipped still decodes") + func decodesExpansionWrittenBeforePartitionSupport() throws { + let defaults = try makeDefaults() + let connectionId = UUID() + let legacy = """ + {"schemas":["public"],"databases":["shop"],"databaseSchemas":[{"database":"shop","schema":"public"}]} + """ + defaults.set(Data(legacy.utf8), forKey: "com.TablePro.sidebar.treeExpansion.\(connectionId.uuidString)") + + let restored = WindowSidebarState(connectionId: connectionId, defaults: defaults) + #expect(restored.expandedTreeDatabases == ["shop"]) + #expect(restored.expandedTreeSchemas == ["public"]) + #expect(restored.expandedTreeTables.isEmpty) + } + + @Test("Collapsing every partitioned table clears storage alongside the rest") + func clearingPartitionedTablesRemovesStorage() throws { + let defaults = try makeDefaults() + let connectionId = UUID() + + let state = WindowSidebarState(connectionId: connectionId, defaults: defaults) + state.expandedTreeTables.insert(DatabaseTableKey(database: "shop", schema: "public", table: "orders")) + state.expandedTreeTables.removeAll() + + let restored = WindowSidebarState(connectionId: connectionId, defaults: defaults) + #expect(restored.expandedTreeTables.isEmpty) + } + @Test("A window without a connection does not persist") func nilConnectionDoesNotPersist() throws { let defaults = try makeDefaults() diff --git a/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift index f16ae6a2a..7ca901d3e 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeNodeTests.swift @@ -29,7 +29,15 @@ struct DatabaseTreeNodeTests { #expect(Set([loading, empty, errored, otherParent]).count == 4) } - @Test("only database and schema nodes are expandable") + private func partitionedRef(_ name: String, schema: String? = "public") -> DatabaseTreeTableRef { + DatabaseTreeTableRef( + database: "shop", + schema: schema, + table: TableInfo(name: name, type: .partitionedTable, rowCount: 0) + ) + } + + @Test("database, schema, and partitioned table nodes are expandable") func expandable() { let database = DatabaseTreeNode(id: "d", kind: .database(.minimal(name: "shop"))) let schema = DatabaseTreeNode(id: "s", kind: .schema(database: "shop", schema: "public")) @@ -42,6 +50,26 @@ struct DatabaseTreeNodeTests { #expect(!status.isExpandable) } + @Test("a partitioned table expands but its partitions and other kinds do not") + func partitionedTableExpandable() { + let parent = DatabaseTreeNode(id: "p", kind: .table(partitionedRef("orders"))) + let leafPartition = DatabaseTreeNode(id: "c", kind: .table(tableRef("orders_2024_01"))) + let subpartitioned = DatabaseTreeNode(id: "sp", kind: .table(partitionedRef("orders_2024_02"))) + let recent = DatabaseTreeNode(id: "r", kind: .recentTable(partitionedRef("orders"))) + + #expect(parent.isExpandable) + #expect(!leafPartition.isExpandable) + #expect(subpartitioned.isExpandable) + #expect(!recent.isExpandable) + } + + @Test("a partition child gets its own node identity, distinct from its parent") + func partitionChildIdentity() { + let parentId = DatabaseTreeNode.tableId(partitionedRef("orders")) + let childId = DatabaseTreeNode.tableId(tableRef("orders_2024_01")) + #expect(parentId != childId) + } + @Test("tableRef is returned only for table nodes") func tableRefExtraction() { let ref = tableRef("users") diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 3083c7b6d..10cf2d9d3 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -39,6 +39,8 @@ Connect to RDS or Aurora with your AWS identity instead of a static password: se **Databases**: Every database on the server is listed, including `postgres`. It is an ordinary database that `initdb` creates for users and applications, not a system database. `template0` and `template1` are not listed. +**Partitioned tables**: A partitioned table is listed once, under its own icon. Its partitions are not listed beside it; expand the table in the sidebar to see them, and expand a partition again if it is subpartitioned. Opening a partition works like opening any other table. Tables that use the older `INHERITS` inheritance are listed normally, since a child there is a table in its own right. Requires PostgreSQL 10 or later, which is where declarative partitioning was added. + **Types**: `jsonb` renders as formatted JSON. `array`, `uuid`, `inet`, `timestamp with time zone`, `interval`, and `bytea` display natively. **EXPLAIN**: `EXPLAIN` and `EXPLAIN ANALYZE` run with `FORMAT JSON` and render as a plan diagram or tree. See [EXPLAIN Visualization](/features/explain-visualization).