From a1de0b34ac667b715b9a20ba9933ae8fd38e3f51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 27 May 2026 12:54:41 +0700 Subject: [PATCH 1/3] feat(sidebar): show BigQuery datasets as an expandable tree --- .../TableProPluginKit/GroupingStrategy.swift | 1 + .../PluginDatabaseDriver.swift | 42 ++++ .../BigQueryDriverPlugin/BigQueryPlugin.swift | 2 +- .../BigQueryPluginDriver.swift | 40 +++- Plugins/BigQueryDriverPlugin/Info.plist | 2 +- Plugins/CSVExportPlugin/Info.plist | 2 +- Plugins/CassandraDriverPlugin/Info.plist | 2 +- Plugins/ClickHouseDriverPlugin/Info.plist | 2 +- Plugins/CloudflareD1DriverPlugin/Info.plist | 2 +- Plugins/DuckDBDriverPlugin/Info.plist | 2 +- Plugins/DynamoDBDriverPlugin/Info.plist | 2 +- Plugins/EtcdDriverPlugin/Info.plist | 2 +- Plugins/JSONExportPlugin/Info.plist | 2 +- Plugins/LibSQLDriverPlugin/Info.plist | 2 +- Plugins/MQLExportPlugin/Info.plist | 2 +- Plugins/MSSQLDriverPlugin/Info.plist | 2 +- Plugins/MongoDBDriverPlugin/Info.plist | 2 +- Plugins/MySQLDriverPlugin/Info.plist | 2 +- Plugins/OracleDriverPlugin/Info.plist | 2 +- Plugins/PostgreSQLDriverPlugin/Info.plist | 2 +- Plugins/RedisDriverPlugin/Info.plist | 2 +- Plugins/SQLExportPlugin/Info.plist | 2 +- Plugins/SQLImportPlugin/Info.plist | 2 +- Plugins/SQLiteDriverPlugin/Info.plist | 2 +- .../TableProPluginKit/GroupingStrategy.swift | 1 + .../PluginDatabaseDriver.swift | 10 + Plugins/XLSXExportPlugin/Info.plist | 2 +- .../Plugins/ExportDataSourceAdapter.swift | 2 +- TablePro/Core/Plugins/PluginManager.swift | 2 +- ...PluginMetadataRegistry+CloudDefaults.swift | 2 +- .../Core/Services/Query/SchemaService.swift | 90 ++++++++ .../Services/Query/TableQueryBuilder.swift | 4 +- .../Connection/ConnectionToolbarState.swift | 2 +- TablePro/Models/Query/QueryTab.swift | 2 +- TablePro/Views/Export/ExportDialog.swift | 2 +- TablePro/Views/Sidebar/SidebarTreeView.swift | 195 ++++++++++++++++++ TablePro/Views/Sidebar/SidebarView.swift | 36 +++- .../Views/Toolbar/ConnectionStatusView.swift | 4 +- .../Plugins/DriverPluginMetadataTests.swift | 3 +- .../Models/MultiRowEditStateTests.swift | 16 +- TableProTests/Models/TableInfoTests.swift | 16 ++ .../SchemaServiceHierarchicalTests.swift | 179 ++++++++++++++++ docs/databases/bigquery.mdx | 4 +- 43 files changed, 650 insertions(+), 47 deletions(-) create mode 100644 TablePro/Views/Sidebar/SidebarTreeView.swift create mode 100644 TableProTests/Services/SchemaServiceHierarchicalTests.swift diff --git a/Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift b/Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift index 9b6f640c2..ff458889a 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/GroupingStrategy.swift @@ -4,4 +4,5 @@ public enum GroupingStrategy: String, Codable, Sendable { case byDatabase case bySchema case flat + case hierarchicalSchema } diff --git a/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift index 5eaa50d46..f44f6140b 100644 --- a/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Packages/TableProCore/Sources/TableProPluginKit/PluginDatabaseDriver.swift @@ -112,6 +112,24 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { limit: Int, offset: Int ) -> String? + func buildBrowseQuery( + table: String, + schema: String?, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? // Filtered row count (optional, for NoSQL plugins; SQL plugins use COUNT(*) WHERE) func fetchFilteredRowCount( table: String, @@ -177,6 +195,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Default export query (optional) func defaultExportQuery(table: String) -> String? + func defaultExportQuery(table: String, schema: String?) -> String? // Streaming row fetch for export func streamRows(query: String) -> AsyncThrowingStream @@ -305,6 +324,28 @@ public extension PluginDatabaseDriver { limit: Int, offset: Int ) -> String? { nil } + func buildBrowseQuery( + table: String, + schema: String?, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildBrowseQuery(table: table, sortColumns: sortColumns, columns: columns, limit: limit, offset: offset) + } + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int + ) -> String? { + buildFilteredQuery(table: table, filters: filters, logicMode: logicMode, sortColumns: sortColumns, columns: columns, limit: limit, offset: offset) + } func fetchFilteredRowCount( table: String, filters: [(column: String, op: String, value: String)], @@ -356,6 +397,7 @@ public extension PluginDatabaseDriver { func castColumnToText(_ column: String) -> String { column } func allTablesMetadataSQL(schema: String?) -> String? { nil } func defaultExportQuery(table: String) -> String? { nil } + func defaultExportQuery(table: String, schema: String?) -> String? { defaultExportQuery(table: table) } func quoteIdentifier(_ name: String) -> String { let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift index e4ce4cef8..05422ad54 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPlugin.swift @@ -43,7 +43,7 @@ final class BigQueryPlugin: NSObject, TableProPlugin, DriverPlugin { static let tableEntityName = "Tables" static let supportsForeignKeyDisable = false static let supportsReadOnlyMode = true - static let databaseGroupingStrategy: GroupingStrategy = .bySchema + static let databaseGroupingStrategy: GroupingStrategy = .hierarchicalSchema static let defaultGroupName = "default" static let defaultPrimaryKeyColumn: String? = nil static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable, .comment] diff --git a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift index 15eb91ab8..010b693b2 100644 --- a/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift +++ b/Plugins/BigQueryDriverPlugin/BigQueryPluginDriver.swift @@ -81,8 +81,12 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send } func defaultExportQuery(table: String) -> String? { + defaultExportQuery(table: table, schema: nil) + } + + func defaultExportQuery(table: String, schema: String?) -> String? { guard let conn = connection else { return nil } - let dataset = lock.withLock { _currentDataset } ?? "" + let dataset = schema ?? (lock.withLock { _currentDataset }) ?? "" return "SELECT * FROM `\(conn.projectId).\(dataset).\(table)`" } @@ -494,9 +498,23 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send columns: [String], limit: Int, offset: Int + ) -> String? { + buildBrowseQuery( + table: table, schema: nil, sortColumns: sortColumns, + columns: columns, limit: limit, offset: offset + ) + } + + func buildBrowseQuery( + table: String, + schema: String?, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int ) -> String? { let dataset: String = lock.withLock { - let ds = _currentDataset ?? "" + let ds = schema ?? _currentDataset ?? "" _columnCache["\(ds).\(table)"] = columns return ds } @@ -514,9 +532,25 @@ internal final class BigQueryPluginDriver: PluginDatabaseDriver, @unchecked Send columns: [String], limit: Int, offset: Int + ) -> String? { + buildFilteredQuery( + table: table, schema: nil, filters: filters, logicMode: logicMode, + sortColumns: sortColumns, columns: columns, limit: limit, offset: offset + ) + } + + func buildFilteredQuery( + table: String, + schema: String?, + filters: [(column: String, op: String, value: String)], + logicMode: String, + sortColumns: [(columnIndex: Int, ascending: Bool)], + columns: [String], + limit: Int, + offset: Int ) -> String? { let dataset: String = lock.withLock { - let ds = _currentDataset ?? "" + let ds = schema ?? _currentDataset ?? "" _columnCache["\(ds).\(table)"] = columns return ds } diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist index f2c8db75d..75e41d512 100644 --- a/Plugins/BigQueryDriverPlugin/Info.plist +++ b/Plugins/BigQueryDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist index 8e55b776d..1a61170f7 100644 --- a/Plugins/CSVExportPlugin/Info.plist +++ b/Plugins/CSVExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesExportFormatIds csv diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist index 232a3ad12..1ba42659b 100644 --- a/Plugins/CassandraDriverPlugin/Info.plist +++ b/Plugins/CassandraDriverPlugin/Info.plist @@ -21,6 +21,6 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).CassandraPlugin TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist index 5655e8720..10cefc68f 100644 --- a/Plugins/ClickHouseDriverPlugin/Info.plist +++ b/Plugins/ClickHouseDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesDatabaseTypeIds ClickHouse diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist index f2c8db75d..75e41d512 100644 --- a/Plugins/CloudflareD1DriverPlugin/Info.plist +++ b/Plugins/CloudflareD1DriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist index 69961ffcc..c48cad80a 100644 --- a/Plugins/DuckDBDriverPlugin/Info.plist +++ b/Plugins/DuckDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist index f2c8db75d..75e41d512 100644 --- a/Plugins/DynamoDBDriverPlugin/Info.plist +++ b/Plugins/DynamoDBDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist index f2c8db75d..75e41d512 100644 --- a/Plugins/EtcdDriverPlugin/Info.plist +++ b/Plugins/EtcdDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist index 115ffb8f4..2ee7ed0ae 100644 --- a/Plugins/JSONExportPlugin/Info.plist +++ b/Plugins/JSONExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesExportFormatIds json diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist index f2c8db75d..75e41d512 100644 --- a/Plugins/LibSQLDriverPlugin/Info.plist +++ b/Plugins/LibSQLDriverPlugin/Info.plist @@ -5,6 +5,6 @@ TableProMinAppVersion 0.42.0 TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist index 668c3c303..22d6e0154 100644 --- a/Plugins/MQLExportPlugin/Info.plist +++ b/Plugins/MQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesExportFormatIds mql diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist index 69961ffcc..c48cad80a 100644 --- a/Plugins/MSSQLDriverPlugin/Info.plist +++ b/Plugins/MSSQLDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist index 69961ffcc..c48cad80a 100644 --- a/Plugins/MongoDBDriverPlugin/Info.plist +++ b/Plugins/MongoDBDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist index dc3b11ec1..b128aece8 100644 --- a/Plugins/MySQLDriverPlugin/Info.plist +++ b/Plugins/MySQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesDatabaseTypeIds MySQL diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist index 69961ffcc..c48cad80a 100644 --- a/Plugins/OracleDriverPlugin/Info.plist +++ b/Plugins/OracleDriverPlugin/Info.plist @@ -3,6 +3,6 @@ TableProPluginKitVersion - 15 + 16 diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist index 1f6e89f99..dd39302a3 100644 --- a/Plugins/PostgreSQLDriverPlugin/Info.plist +++ b/Plugins/PostgreSQLDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesDatabaseTypeIds PostgreSQL diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist index 9eb561c94..78780caab 100644 --- a/Plugins/RedisDriverPlugin/Info.plist +++ b/Plugins/RedisDriverPlugin/Info.plist @@ -21,7 +21,7 @@ NSPrincipalClass $(PRODUCT_MODULE_NAME).RedisPlugin TableProPluginKitVersion - 15 + 16 TableProProvidesDatabaseTypeIds Redis diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist index f7e013f53..186a39e0d 100644 --- a/Plugins/SQLExportPlugin/Info.plist +++ b/Plugins/SQLExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesExportFormatIds sql diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist index 3720b81c6..749f1e566 100644 --- a/Plugins/SQLImportPlugin/Info.plist +++ b/Plugins/SQLImportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesImportFormatIds sql diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist index 68d1dc934..27e1a5f0c 100644 --- a/Plugins/SQLiteDriverPlugin/Info.plist +++ b/Plugins/SQLiteDriverPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesDatabaseTypeIds SQLite diff --git a/Plugins/TableProPluginKit/GroupingStrategy.swift b/Plugins/TableProPluginKit/GroupingStrategy.swift index 9b12538bd..093639e9e 100644 --- a/Plugins/TableProPluginKit/GroupingStrategy.swift +++ b/Plugins/TableProPluginKit/GroupingStrategy.swift @@ -7,4 +7,5 @@ public enum GroupingStrategy: String, Codable, Sendable { case byDatabase case bySchema case flat + case hierarchicalSchema } diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 13738401d..a5db41b1b 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -88,6 +88,8 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Query building (optional, for NoSQL plugins) func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? + func buildBrowseQuery(table: String, schema: String?, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? + func buildFilteredQuery(table: String, schema: String?, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? // Filtered row count (optional, for NoSQL plugins; SQL plugins use COUNT(*) WHERE) func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? // Statement generation (optional, for NoSQL plugins) @@ -141,6 +143,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable { // Default export query (optional — returns nil to use app-level fallback) func defaultExportQuery(table: String) -> String? + func defaultExportQuery(table: String, schema: String?) -> String? // Streaming row fetch for export func streamRows(query: String) -> AsyncThrowingStream @@ -251,6 +254,12 @@ public extension PluginDatabaseDriver { func buildBrowseQuery(table: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } func buildFilteredQuery(table: String, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { nil } + func buildBrowseQuery(table: String, schema: String?, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { + buildBrowseQuery(table: table, sortColumns: sortColumns, columns: columns, limit: limit, offset: offset) + } + func buildFilteredQuery(table: String, schema: String?, filters: [(column: String, op: String, value: String)], logicMode: String, sortColumns: [(columnIndex: Int, ascending: Bool)], columns: [String], limit: Int, offset: Int) -> String? { + buildFilteredQuery(table: table, filters: filters, logicMode: logicMode, sortColumns: sortColumns, columns: columns, limit: limit, offset: offset) + } func fetchFilteredRowCount(table: String, filters: [(column: String, op: String, value: String)], logicMode: String) async throws -> Int? { nil } func generateStatements(table: String, columns: [String], primaryKeyColumns: [String], changes: [PluginRowChange], insertedRowData: [Int: [PluginCellValue]], deletedRowIndices: Set, insertedRowIndices: Set) -> [(statement: String, parameters: [PluginCellValue])]? { nil } @@ -284,6 +293,7 @@ public extension PluginDatabaseDriver { func castColumnToText(_ column: String) -> String { column } func allTablesMetadataSQL(schema: String?) -> String? { nil } func defaultExportQuery(table: String) -> String? { nil } + func defaultExportQuery(table: String, schema: String?) -> String? { defaultExportQuery(table: table) } func quoteIdentifier(_ name: String) -> String { let escaped = name.replacingOccurrences(of: "\"", with: "\"\"") diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist index fe617488f..cbeb6cfba 100644 --- a/Plugins/XLSXExportPlugin/Info.plist +++ b/Plugins/XLSXExportPlugin/Info.plist @@ -3,7 +3,7 @@ TableProPluginKitVersion - 15 + 16 TableProProvidesExportFormatIds xlsx diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift index 5e456bab8..26f473ab0 100644 --- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift +++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift @@ -23,7 +23,7 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable func streamRows(table: String, databaseName: String) -> AsyncThrowingStream { let query: String if let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver, - let customQuery = pluginDriver.defaultExportQuery(table: table) { + let customQuery = pluginDriver.defaultExportQuery(table: table, schema: databaseName.isEmpty ? nil : databaseName) { query = customQuery } else { let tableRef = qualifiedTableRef(table: table, databaseName: databaseName) diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift index 44c40bf7d..bfd65dce8 100644 --- a/TablePro/Core/Plugins/PluginManager.swift +++ b/TablePro/Core/Plugins/PluginManager.swift @@ -13,7 +13,7 @@ import TableProPluginKit @MainActor @Observable final class PluginManager { static let shared = PluginManager() - static let currentPluginKitVersion = 15 + static let currentPluginKitVersion = 16 static let currentInspectorKitVersion = 1 private static let disabledPluginsKey = "com.TablePro.disabledPlugins" private static let legacyDisabledPluginsKey = "disabledPlugins" diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift index 5f83becd5..6aa848f4a 100644 --- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift +++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift @@ -187,7 +187,7 @@ extension PluginMetadataRegistry { systemDatabaseNames: [], systemSchemaNames: ["INFORMATION_SCHEMA"], fileExtensions: [], - databaseGroupingStrategy: .bySchema, + databaseGroupingStrategy: .hierarchicalSchema, structureColumnFields: [.name, .type, .nullable, .comment] ), editor: PluginMetadataSnapshot.EditorConfig( diff --git a/TablePro/Core/Services/Query/SchemaService.swift b/TablePro/Core/Services/Query/SchemaService.swift index 7badc73dc..ead0ed194 100644 --- a/TablePro/Core/Services/Query/SchemaService.swift +++ b/TablePro/Core/Services/Query/SchemaService.swift @@ -6,6 +6,7 @@ import Combine import Foundation import os +import TableProPluginKit @MainActor @Observable @@ -16,11 +17,18 @@ final class SchemaService { private(set) var procedures: [UUID: [RoutineInfo]] = [:] private(set) var functions: [UUID: [RoutineInfo]] = [:] private(set) var schemasInOrder: [UUID: [String]] = [:] + private(set) var perSchemaStates: [UUID: [String: SchemaState]] = [:] @ObservationIgnored private let loadDedup = OnceTask() @ObservationIgnored private let procedureDedup = OnceTask() @ObservationIgnored private let functionDedup = OnceTask() @ObservationIgnored private let schemasDedup = OnceTask() + @ObservationIgnored private let perSchemaDedup = OnceTask() + + struct SchemaKey: Hashable, Sendable { + let connectionId: UUID + let schema: String + } @ObservationIgnored private var schemaChangeCancellable: AnyCancellable? @ObservationIgnored private static let logger = Logger(subsystem: "com.TablePro", category: "SchemaService") @@ -60,6 +68,53 @@ final class SchemaService { schemasInOrder[connectionId] ?? [] } + func schemaState(for connectionId: UUID, schema: String) -> SchemaState { + perSchemaStates[connectionId]?[schema] ?? .idle + } + + func tables(for connectionId: UUID, schema: String) -> [TableInfo] { + if case .loaded(let tables) = schemaState(for: connectionId, schema: schema) { + return tables + } + return [] + } + + func loadSchemaTables(connectionId: UUID, schema: String, driver: DatabaseDriver) async { + if case .loaded = schemaState(for: connectionId, schema: schema) { return } + setPerSchemaState(.loading, connectionId: connectionId, schema: schema) + do { + let tables = try await perSchemaDedup.execute(key: SchemaKey(connectionId: connectionId, schema: schema)) { + try await driver.fetchTables(schema: schema) + } + setPerSchemaState(.loaded(tables), connectionId: connectionId, schema: schema) + } catch is CancellationError { + return + } catch { + Self.logger.warning( + "[schema] per-schema load failed connId=\(connectionId, privacy: .public) schema=\(schema, privacy: .public) error=\(error.localizedDescription, privacy: .public)" + ) + setPerSchemaState(.failed(error.localizedDescription), connectionId: connectionId, schema: schema) + } + } + + func reloadSchemaTables(connectionId: UUID, schema: String, driver: DatabaseDriver) async { + await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema)) + clearPerSchemaState(connectionId: connectionId, schema: schema) + await loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) + } + + private func setPerSchemaState(_ state: SchemaState, connectionId: UUID, schema: String) { + var inner = perSchemaStates[connectionId] ?? [:] + inner[schema] = state + perSchemaStates[connectionId] = inner + } + + private func clearPerSchemaState(connectionId: UUID, schema: String) { + guard var inner = perSchemaStates[connectionId] else { return } + inner.removeValue(forKey: schema) + perSchemaStates[connectionId] = inner + } + func load(connectionId: UUID, driver: DatabaseDriver, connection: DatabaseConnection) async { switch state(for: connectionId) { case .loaded: @@ -108,10 +163,16 @@ final class SchemaService { await procedureDedup.cancel(key: connectionId) await functionDedup.cancel(key: connectionId) await schemasDedup.cancel(key: connectionId) + if let schemas = perSchemaStates[connectionId]?.keys { + for schema in schemas { + await perSchemaDedup.cancel(key: SchemaKey(connectionId: connectionId, schema: schema)) + } + } states.removeValue(forKey: connectionId) procedures.removeValue(forKey: connectionId) functions.removeValue(forKey: connectionId) schemasInOrder.removeValue(forKey: connectionId) + perSchemaStates.removeValue(forKey: connectionId) } func refresh(connectionId: UUID) async { @@ -133,6 +194,12 @@ final class SchemaService { schemasInOrder.removeValue(forKey: connectionId) } + let grouping = PluginManager.shared.databaseGroupingStrategy(for: connection.type) + if grouping == .hierarchicalSchema { + await runHierarchicalLoad(connectionId: connectionId, driver: driver) + return + } + async let tablesTask: [TableInfo] = loadDedup.execute(key: connectionId) { try await driver.fetchTables() } @@ -170,6 +237,29 @@ final class SchemaService { } } + private func runHierarchicalLoad(connectionId: UUID, driver: DatabaseDriver) async { + async let proceduresTask: [RoutineInfo] = Self.fetchRoutinesSafely( + connectionId: connectionId, + kind: .procedure, + dedup: procedureDedup, + fetch: { try await driver.fetchProcedures(schema: nil) } + ) + async let functionsTask: [RoutineInfo] = Self.fetchRoutinesSafely( + connectionId: connectionId, + kind: .function, + dedup: functionDedup, + fetch: { try await driver.fetchFunctions(schema: nil) } + ) + + let loadedProcedures = await proceduresTask + let loadedFunctions = await functionsTask + await loadSchemaList(connectionId: connectionId, driver: driver) + + procedures[connectionId] = loadedProcedures + functions[connectionId] = loadedFunctions + states[connectionId] = .loaded([]) + } + private func loadSchemaList(connectionId: UUID, driver: DatabaseDriver) async { do { let allSchemas = try await schemasDedup.execute(key: connectionId) { diff --git a/TablePro/Core/Services/Query/TableQueryBuilder.swift b/TablePro/Core/Services/Query/TableQueryBuilder.swift index c7c65cbe5..ceddd88ef 100644 --- a/TablePro/Core/Services/Query/TableQueryBuilder.swift +++ b/TablePro/Core/Services/Query/TableQueryBuilder.swift @@ -71,7 +71,7 @@ struct TableQueryBuilder { if let pluginDriver { let sortCols = sortColumnsAsTuples(sortState) if let result = pluginDriver.buildBrowseQuery( - table: tableName, sortColumns: sortCols, + table: tableName, schema: schemaName, sortColumns: sortCols, columns: selectColumns ?? columns, limit: limit, offset: offset ) { return result @@ -106,7 +106,7 @@ struct TableQueryBuilder { .filter { $0.isEnabled && !$0.columnName.isEmpty } .map(\.asPluginFilterTuple) if let result = pluginDriver.buildFilteredQuery( - table: tableName, filters: filterTuples, + table: tableName, schema: schemaName, filters: filterTuples, logicMode: logicMode == .and ? "and" : "or", sortColumns: sortCols, columns: selectColumns ?? columns, limit: limit, offset: offset ) { diff --git a/TablePro/Models/Connection/ConnectionToolbarState.swift b/TablePro/Models/Connection/ConnectionToolbarState.swift index d30292278..bdb6c64e0 100644 --- a/TablePro/Models/Connection/ConnectionToolbarState.swift +++ b/TablePro/Models/Connection/ConnectionToolbarState.swift @@ -234,7 +234,7 @@ final class ConnectionToolbarState { return schema } return currentDatabase - case .byDatabase, .flat: + case .byDatabase, .flat, .hierarchicalSchema: return currentDatabase } } diff --git a/TablePro/Models/Query/QueryTab.swift b/TablePro/Models/Query/QueryTab.swift index b955c79a7..13abe699d 100644 --- a/TablePro/Models/Query/QueryTab.swift +++ b/TablePro/Models/Query/QueryTab.swift @@ -102,7 +102,7 @@ struct QueryTab: Identifiable, Equatable { if let pluginDriver = PluginManager.shared.queryBuildingDriver(for: databaseType), let pluginQuery = pluginDriver.buildBrowseQuery( - table: tableName, sortColumns: [], columns: [], limit: pageSize, offset: 0 + table: tableName, schema: schemaName, sortColumns: [], columns: [], limit: pageSize, offset: 0 ) { return pluginQuery } diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 11ac42506..5937681f9 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -569,7 +569,7 @@ struct ExportDialog: View { let dbType = connection.type let grouping = PluginManager.shared.databaseGroupingStrategy(for: dbType) switch grouping { - case .bySchema: + case .bySchema, .hierarchicalSchema: let schemas = try await driver.fetchSchemas() let defaultSchema = PluginManager.shared.defaultSchemaName(for: dbType) for schema in schemas { diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift new file mode 100644 index 000000000..73720abef --- /dev/null +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -0,0 +1,195 @@ +import SwiftUI +import TableProPluginKit + +struct SidebarTreeView: View { + @Bindable private var schemaService = SchemaService.shared + + let connectionId: UUID + let viewModel: SidebarViewModel + let windowState: WindowSidebarState + @Binding var pendingTruncates: Set + @Binding var pendingDeletes: Set + var onDoubleClick: ((TableInfo) -> Void)? + weak var coordinator: MainContentCoordinator? + + @State private var expandedSchemas: Set = [] + + private var systemSchemas: Set { + Set(PluginManager.shared.systemSchemaNames(for: viewModel.databaseType)) + } + + private var schemas: [String] { + schemaService.schemas(for: connectionId).filter { !systemSchemas.contains($0) } + } + + private var searchText: String { + viewModel.searchText + } + + private var visibleSchemas: [String] { + guard !searchText.isEmpty else { return schemas } + return schemas.filter { schemaIsVisibleDuringSearch($0) } + } + + private var selectedTablesBinding: Binding> { + Binding( + get: { windowState.selectedTables }, + set: { windowState.selectedTables = $0 } + ) + } + + var body: some View { + Group { + if schemas.isEmpty { + emptyDatasetsState + } else if !searchText.isEmpty && visibleSchemas.isEmpty { + noMatchState + } else { + treeList + } + } + .onChange(of: searchText) { _, newValue in + guard !newValue.isEmpty else { return } + for schema in schemas { + loadTables(for: schema) + } + } + } + + private var treeList: some View { + List(selection: selectedTablesBinding) { + ForEach(visibleSchemas, id: \.self) { schema in + Section(isExpanded: expansionBinding(for: schema)) { + datasetContent(for: schema) + } header: { + datasetHeader(schema) + } + } + } + .listStyle(.sidebar) + .scrollContentBackground(.hidden) + .contextMenu(forSelectionType: TableInfo.self) { _ in + EmptyView() + } primaryAction: { selection in + guard let table = selection.first else { return } + onDoubleClick?(table) + } + .onExitCommand { + windowState.selectedTables.removeAll() + } + } + + @ViewBuilder + private func datasetContent(for schema: String) -> some View { + switch schemaService.schemaState(for: connectionId, schema: schema) { + case .idle, .loading: + ProgressView() + .controlSize(.small) + .frame(maxWidth: .infinity, alignment: .center) + case .failed(let message): + Label(message, systemImage: "exclamationmark.triangle") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + case .loaded: + let tables = tablesToShow(for: schema) + if tables.isEmpty { + Text("No tables") + .font(.caption) + .foregroundStyle(.secondary) + } else { + ForEach(tables) { table in + tableRow(table) + } + } + } + } + + private func tableRow(_ table: TableInfo) -> some View { + TableRow( + table: table, + isPendingTruncate: pendingTruncates.contains(table.name), + isPendingDelete: pendingDeletes.contains(table.name) + ) + .tag(table) + .contextMenu { + SidebarContextMenu( + clickedTable: table, + selectedTables: windowState.selectedTables, + isReadOnly: coordinator?.safeModeLevel.blocksAllWrites ?? false, + onBatchToggleTruncate: { viewModel.batchToggleTruncate(tableNames: $0) }, + onBatchToggleDelete: { viewModel.batchToggleDelete(tableNames: $0) }, + coordinator: coordinator + ) + } + } + + private func datasetHeader(_ schema: String) -> some View { + Label(schema, systemImage: "tablecells.badge.ellipsis") + .contextMenu { + Button(String(localized: "Refresh")) { + reloadTables(for: schema) + } + } + } + + private var emptyDatasetsState: some View { + ContentUnavailableView( + String(localized: "No Datasets"), + systemImage: "tablecells", + description: Text("This project has no datasets yet.") + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var noMatchState: some View { + ContentUnavailableView.search(text: searchText) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private func expansionBinding(for schema: String) -> Binding { + Binding( + get: { !searchText.isEmpty || expandedSchemas.contains(schema) }, + set: { isExpanded in + if isExpanded { + expandedSchemas.insert(schema) + loadTables(for: schema) + } else { + expandedSchemas.remove(schema) + } + } + ) + } + + private func tablesToShow(for schema: String) -> [TableInfo] { + let tables = schemaService.tables(for: connectionId, schema: schema) + guard !searchText.isEmpty, !schema.localizedCaseInsensitiveContains(searchText) else { + return tables + } + return tables.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } + + private func schemaIsVisibleDuringSearch(_ schema: String) -> Bool { + if schema.localizedCaseInsensitiveContains(searchText) { return true } + switch schemaService.schemaState(for: connectionId, schema: schema) { + case .loaded: + return !tablesToShow(for: schema).isEmpty + case .idle, .loading, .failed: + return true + } + } + + private func loadTables(for schema: String) { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + Task { + await schemaService.loadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) + } + } + + private func reloadTables(for schema: String) { + guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return } + Task { + await schemaService.reloadSchemaTables(connectionId: connectionId, schema: schema, driver: driver) + } + } +} diff --git a/TablePro/Views/Sidebar/SidebarView.swift b/TablePro/Views/Sidebar/SidebarView.swift index 39f807456..cc072cdbf 100644 --- a/TablePro/Views/Sidebar/SidebarView.swift +++ b/TablePro/Views/Sidebar/SidebarView.swift @@ -39,8 +39,13 @@ struct SidebarView: View { } } + private var groupingStrategy: GroupingStrategy { + PluginManager.shared.databaseGroupingStrategy(for: viewModel.databaseType) + } + private var supportsSchemaFooter: Bool { - PluginManager.shared.supportsSchemaSwitching(for: viewModel.databaseType) + guard PluginManager.shared.supportsSchemaSwitching(for: viewModel.databaseType) else { return false } + return groupingStrategy != .hierarchicalSchema } private var selectedTablesBinding: Binding> { @@ -144,6 +149,35 @@ struct SidebarView: View { @ViewBuilder private var tablesContent: some View { + if groupingStrategy == .hierarchicalSchema { + hierarchicalContent + } else { + flatContent + } + } + + @ViewBuilder + private var hierarchicalContent: some View { + switch schemaService.state(for: connectionId) { + case .idle, .loading: + loadingState + case .failed(let message): + errorState(message: message) + case .loaded: + SidebarTreeView( + connectionId: connectionId, + viewModel: viewModel, + windowState: windowState, + pendingTruncates: $pendingTruncates, + pendingDeletes: $pendingDeletes, + onDoubleClick: onDoubleClick, + coordinator: coordinator + ) + } + } + + @ViewBuilder + private var flatContent: some View { switch schemaService.state(for: connectionId) { case .loading where tables.isEmpty: loadingState diff --git a/TablePro/Views/Toolbar/ConnectionStatusView.swift b/TablePro/Views/Toolbar/ConnectionStatusView.swift index c75db55d8..271140ca8 100644 --- a/TablePro/Views/Toolbar/ConnectionStatusView.swift +++ b/TablePro/Views/Toolbar/ConnectionStatusView.swift @@ -89,7 +89,7 @@ struct ConnectionStatusView: View { private var chipKindLabel: String { switch databaseGroupingStrategy { case .bySchema: return String(localized: "Schema") - case .byDatabase, .flat: return String(localized: "Database") + case .byDatabase, .flat, .hierarchicalSchema: return String(localized: "Database") } } @@ -100,7 +100,7 @@ struct ConnectionStatusView: View { private var switchableChipTooltip: String { let switchVerb: String = switch databaseGroupingStrategy { case .bySchema: String(localized: "switch schema") - case .byDatabase, .flat: String(localized: "switch database") + case .byDatabase, .flat, .hierarchicalSchema: String(localized: "switch database") } if safeModeLevel == .readOnly { return String( diff --git a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift index 6eb7a044e..59347aaa9 100644 --- a/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift +++ b/TableProTests/Core/Plugins/DriverPluginMetadataTests.swift @@ -125,11 +125,12 @@ struct GroupingStrategyTests { #expect(GroupingStrategy.byDatabase.rawValue == "byDatabase") #expect(GroupingStrategy.bySchema.rawValue == "bySchema") #expect(GroupingStrategy.flat.rawValue == "flat") + #expect(GroupingStrategy.hierarchicalSchema.rawValue == "hierarchicalSchema") } @Test("Codable round-trip") func codable() throws { - for strategy in [GroupingStrategy.byDatabase, .bySchema, .flat] { + for strategy in [GroupingStrategy.byDatabase, .bySchema, .flat, .hierarchicalSchema] { let data = try JSONEncoder().encode(strategy) let decoded = try JSONDecoder().decode(GroupingStrategy.self, from: data) #expect(decoded == strategy) diff --git a/TableProTests/Models/MultiRowEditStateTests.swift b/TableProTests/Models/MultiRowEditStateTests.swift index 50ab7ff1a..bce4b32db 100644 --- a/TableProTests/Models/MultiRowEditStateTests.swift +++ b/TableProTests/Models/MultiRowEditStateTests.swift @@ -41,7 +41,7 @@ struct MultiRowEditStateTests { func hasEditFalseWhenNoPendingChanges() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: nil, isPendingNull: false, isPendingDefault: false ) #expect(field.hasEdit == false) @@ -51,7 +51,7 @@ struct MultiRowEditStateTests { func hasEditTrueWhenPendingValueSet() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: "2", isPendingNull: false, isPendingDefault: false ) #expect(field.hasEdit == true) @@ -61,7 +61,7 @@ struct MultiRowEditStateTests { func hasEditTrueWhenPendingNull() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: nil, isPendingNull: true, isPendingDefault: false ) #expect(field.hasEdit == true) @@ -71,7 +71,7 @@ struct MultiRowEditStateTests { func hasEditTrueWhenPendingDefault() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: nil, isPendingNull: false, isPendingDefault: true ) #expect(field.hasEdit == true) @@ -81,7 +81,7 @@ struct MultiRowEditStateTests { func effectiveValueReturnsPendingValue() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: "updated", isPendingNull: false, isPendingDefault: false ) #expect(field.effectiveValue == "updated") @@ -91,7 +91,7 @@ struct MultiRowEditStateTests { func effectiveValueReturnsNilWhenPendingNull() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: nil, isPendingNull: true, isPendingDefault: false ) #expect(field.effectiveValue == nil) @@ -101,7 +101,7 @@ struct MultiRowEditStateTests { func effectiveValueReturnsDefaultWhenPendingDefault() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: nil, isPendingNull: false, isPendingDefault: true ) #expect(field.effectiveValue == "__DEFAULT__") @@ -111,7 +111,7 @@ struct MultiRowEditStateTests { func effectiveValueReturnsNilWhenNoEdit() { let field = FieldEditState( columnIndex: 0, columnName: "id", columnTypeEnum: .text(rawType: nil), - isLongText: false, originalValue: "1", hasMultipleValues: false, + isLongText: false, isJson: false, originalValue: "1", hasMultipleValues: false, pendingValue: nil, isPendingNull: false, isPendingDefault: false ) #expect(field.effectiveValue == nil) diff --git a/TableProTests/Models/TableInfoTests.swift b/TableProTests/Models/TableInfoTests.swift index ef7639dd6..65843b951 100644 --- a/TableProTests/Models/TableInfoTests.swift +++ b/TableProTests/Models/TableInfoTests.swift @@ -47,6 +47,22 @@ struct TableInfoTests { #expect(table.id != view.id) } + @Test("Schema-qualified id includes the schema") + func testSchemaQualifiedId() { + let info = TableInfo(name: "events", type: .table, rowCount: nil, schema: "analytics") + #expect(info.id == "analytics.events_TABLE") + } + + @Test("Same table name in different schemas has distinct id, equality, and hash") + func testCrossSchemaDistinctIdentity() { + let a = TableInfo(name: "orders", type: .table, rowCount: nil, schema: "dataset_a") + let b = TableInfo(name: "orders", type: .table, rowCount: nil, schema: "dataset_b") + #expect(a.id != b.id) + #expect(a != b) + let set: Set = [a, b] + #expect(set.count == 2) + } + // MARK: - Equatable @Test("Same name and type are equal even with different rowCount") diff --git a/TableProTests/Services/SchemaServiceHierarchicalTests.swift b/TableProTests/Services/SchemaServiceHierarchicalTests.swift new file mode 100644 index 000000000..7cfdb079e --- /dev/null +++ b/TableProTests/Services/SchemaServiceHierarchicalTests.swift @@ -0,0 +1,179 @@ +// +// SchemaServiceHierarchicalTests.swift +// TableProTests +// + +import Foundation +@testable import TablePro +import TableProPluginKit +import Testing + +private final class HierarchicalMockDriver: DatabaseDriver, @unchecked Sendable { + let connection: DatabaseConnection + var status: ConnectionStatus = .connected + var serverVersion: String? { nil } + + var schemasToReturn: [String] = [] + var tablesBySchema: [String: [TableInfo]] = [:] + var fetchTablesCallCount: [String: Int] = [:] + var fetchSchemasCallCount = 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 { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func executeUserQuery(query: String, rowCap: Int?, parameters: [Any?]?) async throws -> QueryResult { + QueryResult(columns: [], columnTypes: [], rows: [], rowsAffected: 0, executionTime: 0, error: nil) + } + + func fetchSchemas() async throws -> [String] { + fetchSchemasCallCount += 1 + return schemasToReturn + } + + func fetchTables() async throws -> [TableInfo] { [] } + + func fetchTables(schema: String?) async throws -> [TableInfo] { + let key = schema ?? "" + fetchTablesCallCount[key, default: 0] += 1 + return tablesBySchema[key] ?? [] + } + + 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 {} +} + +@Suite("SchemaService hierarchical schema") +@MainActor +struct SchemaServiceHierarchicalTests { + private func bigQueryTable(_ name: String, schema: String) -> TableInfo { + TableInfo(name: name, type: .table, rowCount: nil, schema: schema) + } + + @Test("BigQuery resolves to hierarchicalSchema grouping while Postgres stays bySchema") + func groupingStrategyResolution() { + #expect(PluginManager.shared.databaseGroupingStrategy(for: .bigQuery) == .hierarchicalSchema) + #expect(PluginManager.shared.databaseGroupingStrategy(for: .postgresql) == .bySchema) + } + + @Test("loadSchemaTables stores tables per schema without touching other schemas") + func perSchemaStorage() async { + let service = SchemaService() + let connectionId = UUID() + let driver = HierarchicalMockDriver() + driver.tablesBySchema = [ + "analytics": [bigQueryTable("events", schema: "analytics"), bigQueryTable("sessions", schema: "analytics")], + "marketing": [bigQueryTable("campaigns", schema: "marketing")] + ] + + await service.loadSchemaTables(connectionId: connectionId, schema: "analytics", driver: driver) + + #expect(service.tables(for: connectionId, schema: "analytics").map(\.name) == ["events", "sessions"]) + #expect(service.tables(for: connectionId, schema: "marketing").isEmpty) + #expect(driver.fetchTablesCallCount["analytics"] == 1) + #expect(driver.fetchTablesCallCount["marketing"] == nil) + } + + @Test("loadSchemaTables is idempotent once a schema is loaded") + func loadIsIdempotent() async { + let service = SchemaService() + let connectionId = UUID() + let driver = HierarchicalMockDriver() + driver.tablesBySchema = ["analytics": [bigQueryTable("events", schema: "analytics")]] + + await service.loadSchemaTables(connectionId: connectionId, schema: "analytics", driver: driver) + await service.loadSchemaTables(connectionId: connectionId, schema: "analytics", driver: driver) + + #expect(driver.fetchTablesCallCount["analytics"] == 1) + } + + @Test("reloadSchemaTables refetches a single schema") + func reloadRefetches() async { + let service = SchemaService() + let connectionId = UUID() + let driver = HierarchicalMockDriver() + driver.tablesBySchema = ["analytics": [bigQueryTable("events", schema: "analytics")]] + + await service.loadSchemaTables(connectionId: connectionId, schema: "analytics", driver: driver) + driver.tablesBySchema["analytics"] = [ + bigQueryTable("events", schema: "analytics"), + bigQueryTable("clicks", schema: "analytics") + ] + await service.reloadSchemaTables(connectionId: connectionId, schema: "analytics", driver: driver) + + #expect(driver.fetchTablesCallCount["analytics"] == 2) + #expect(service.tables(for: connectionId, schema: "analytics").map(\.name) == ["events", "clicks"]) + } + + @Test("hierarchical load fills the schema list and leaves the flat table list empty") + func hierarchicalLoadPopulatesSchemasOnly() async { + let service = SchemaService() + let connectionId = UUID() + let connection = TestFixtures.makeConnection(id: connectionId, type: .bigQuery) + let driver = HierarchicalMockDriver(connection: connection) + driver.schemasToReturn = ["analytics", "marketing", "staging"] + driver.tablesBySchema = ["analytics": [bigQueryTable("events", schema: "analytics")]] + + await service.load(connectionId: connectionId, driver: driver, connection: connection) + + #expect(service.schemas(for: connectionId) == ["analytics", "marketing", "staging"]) + #expect(service.tables(for: connectionId).isEmpty) + #expect(driver.fetchTablesCallCount.isEmpty) + if case .loaded = service.state(for: connectionId) {} else { + Issue.record("expected loaded state for hierarchical connection") + } + } + + @Test("invalidate clears per-schema state") + func invalidateClearsPerSchema() async { + let service = SchemaService() + let connectionId = UUID() + let driver = HierarchicalMockDriver() + driver.tablesBySchema = ["analytics": [bigQueryTable("events", schema: "analytics")]] + + await service.loadSchemaTables(connectionId: connectionId, schema: "analytics", driver: driver) + #expect(!service.tables(for: connectionId, schema: "analytics").isEmpty) + + await service.invalidate(connectionId: connectionId) + + #expect(service.tables(for: connectionId, schema: "analytics").isEmpty) + } +} diff --git a/docs/databases/bigquery.mdx b/docs/databases/bigquery.mdx index ce0aa0a54..ba37320e4 100644 --- a/docs/databases/bigquery.mdx +++ b/docs/databases/bigquery.mdx @@ -48,7 +48,7 @@ OAuth tokens are session-only. You'll need to re-authorize after disconnecting. ## Features -**Dataset Browsing**: The first dataset is auto-selected on connect. Switch datasets with ⌘K or the database switcher. Datasets show as schemas in the sidebar. +**Dataset Browsing**: The sidebar lists every dataset as an expandable node. Click a dataset to load its tables; they load the first time you open it. Search filters across the datasets you have open. **Table Structure**: Columns with full BigQuery types: `STRUCT`, `ARRAY`, nullable status, field descriptions. Clustering and partitioning info in the Indexes tab. @@ -113,7 +113,7 @@ Minimum roles: **Cost**: Use `LIMIT`, select specific columns, prefer partitioned tables. Set **Max Bytes Billed** to prevent expensive queries. Table browsing caps rows automatically. -**No tables after connect**: Switch datasets with ⌘K. The first dataset is auto-selected, but if it's empty, switch to one with tables. +**No tables after connect**: Expand a dataset node in the sidebar to load its tables. Empty datasets stay empty; open another dataset that has tables. ## Limitations From 14f7bbaefb3eac75387c05624be26742a416bf5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 27 May 2026 15:21:08 +0700 Subject: [PATCH 2/3] fix(datagrid): execute table-tab queries directly so BigQuery table switching loads data --- CHANGELOG.md | 1 + TablePro/Views/Main/MainContentCoordinator.swift | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63029bac1..ee2b115fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- BigQuery: switching to another table now loads its data right away, instead of leaving the grid empty until you close and reopen the tab. - Custom and OpenAI-compatible AI providers now work when the base URL already ends in `/v1`, instead of building a doubled `/v1/v1/` path that failed. (#1400) - MongoDB: opening a collection no longer crashes when a document contains a NaN or infinite number. (#1418) - Opening a saved connection that fails now shows the detailed troubleshooting dialog with suggested fixes, the same one Test Connection shows, instead of a generic error alert. (#1425, #483) diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index cc64a0bde..bc430815e 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -744,6 +744,11 @@ final class MainContentCoordinator { guard let (tab, index) = tabManager.selectedTabAndIndex, !tab.execution.isExecuting else { return } + if tab.tabType == .table { + executeTableTabQueryDirectly() + return + } + let fullQuery = tab.content.query let sql: String From 22861a1d4f36b6acb71476674ca42a0ceb6ac4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Wed, 27 May 2026 16:23:57 +0700 Subject: [PATCH 3/3] style(sidebar): use native section header and loading row in the BigQuery dataset tree --- TablePro/Views/Sidebar/SidebarTreeView.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/TablePro/Views/Sidebar/SidebarTreeView.swift b/TablePro/Views/Sidebar/SidebarTreeView.swift index 73720abef..9cb90a4d1 100644 --- a/TablePro/Views/Sidebar/SidebarTreeView.swift +++ b/TablePro/Views/Sidebar/SidebarTreeView.swift @@ -83,20 +83,27 @@ struct SidebarTreeView: View { private func datasetContent(for schema: String) -> some View { switch schemaService.schemaState(for: connectionId, schema: schema) { case .idle, .loading: - ProgressView() - .controlSize(.small) - .frame(maxWidth: .infinity, alignment: .center) + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + Text("Loading tables\u{2026}") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.vertical, 4) case .failed(let message): Label(message, systemImage: "exclamationmark.triangle") .font(.caption) .foregroundStyle(.secondary) .lineLimit(2) + .padding(.vertical, 4) case .loaded: let tables = tablesToShow(for: schema) if tables.isEmpty { Text("No tables") .font(.caption) .foregroundStyle(.secondary) + .padding(.vertical, 4) } else { ForEach(tables) { table in tableRow(table) @@ -125,7 +132,7 @@ struct SidebarTreeView: View { } private func datasetHeader(_ schema: String) -> some View { - Label(schema, systemImage: "tablecells.badge.ellipsis") + Text(schema) .contextMenu { Button(String(localized: "Refresh")) { reloadTables(for: schema)