diff --git a/CHANGELOG.md b/CHANGELOG.md index 56f52c07d..e6770bf31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Per-tab database picker in the query editor toolbar. Each SQL tab can target its own database without clearing other tabs. - Single-clicking a table in the sidebar tree opens it in the current tab; double-clicking opens it in a new tab. +- Table and column comments from the database now show in the UI. The sidebar shows a table's comment in dimmed text after its name, the data grid column header tooltip includes the column comment, and the table inspector shows the table comment. Toggle from View > Show Object Comments. Available for MySQL and PostgreSQL. (#1771) ### Changed diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift index e92826be0..fadb612d5 100644 --- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift +++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift @@ -188,13 +188,20 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable { // MARK: - Schema Operations func fetchTables(schema: String?) async throws -> [PluginTableInfo] { - let result = try await execute(query: "SHOW FULL TABLES") + let query = """ + SELECT TABLE_NAME, TABLE_TYPE, TABLE_COMMENT + FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() + """ + let result = try await execute(query: query) return result.rows.compactMap { row -> PluginTableInfo? in guard let name = row[safe: 0]?.asText else { return nil } let typeStr = (row[safe: 1]?.asText) ?? "BASE TABLE" - let type = typeStr.contains("VIEW") ? "VIEW" : "TABLE" - return PluginTableInfo(name: name, type: type) + let isView = typeStr.contains("VIEW") + let type = isView ? "VIEW" : "TABLE" + let comment = isView ? nil : row[safe: 2]?.asText?.nilIfEmpty + return PluginTableInfo(name: name, type: type, comment: comment) }.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index ed4601d27..8381c07d1 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -16,6 +16,7 @@ final 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 var catalogPresence: PostgreSQLCatalogPresence? @@ -154,22 +155,22 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { func fetchTables(schema: String?) async throws -> [PluginTableInfo] { let schemaLiteral = escapeLiteral(schema ?? core.currentSchema) - let query = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: schemaLiteral, - includeMaterializedViews: includesMaterializedViews(), - includeForeignTables: includesForeignTables() - ) + func query(includeOptionalCatalogs: Bool, includeComments: Bool) -> String { + PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: schemaLiteral, + includeMaterializedViews: includeOptionalCatalogs && includesMaterializedViews(), + includeForeignTables: includeOptionalCatalogs && includesForeignTables(), + includeComments: includeComments + ) + } let result: PluginQueryResult do { - result = try await execute(query: query) + result = try await execute(query: query(includeOptionalCatalogs: true, includeComments: true)) } catch let error as LibPQPluginError where error.sqlState == Self.undefinedTableSQLState { - let baseQuery = PostgreSQLSchemaQueries.fetchTables( - schemaLiteral: schemaLiteral, - includeMaterializedViews: false, - includeForeignTables: false - ) - result = try await execute(query: baseQuery) + 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)) } return result.rows.compactMap { row -> PluginTableInfo? in @@ -182,7 +183,8 @@ final class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable { case "VIEW": type = "VIEW" default: type = "TABLE" } - return PluginTableInfo(name: name, type: type) + let comment = row[safe: 2]?.asText?.nilIfEmpty + return PluginTableInfo(name: name, type: type, comment: comment) } } diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift index 5e1be9490..90e189070 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift @@ -72,25 +72,37 @@ enum PostgreSQLSchemaQueries { /// `pg_foreign_table`, which some PostgreSQL-compatible engines do not /// implement; the caller passes `false` when those catalogs are absent so /// the whole query does not fail with `relation does not exist`. + /// + /// `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. static func fetchTables( schemaLiteral: String, includeMaterializedViews: Bool, - includeForeignTables: Bool + includeForeignTables: Bool, + includeComments: Bool = true ) -> String { + func commentColumn(_ expression: String) -> String { + includeComments ? expression : "NULL::text" + } + var unions: [String] = [ """ - SELECT table_name, table_type FROM information_schema.tables - WHERE table_schema = '\(schemaLiteral)' - AND table_type IN ('BASE TABLE', 'VIEW') + SELECT t.table_name, t.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 + WHERE t.table_schema = '\(schemaLiteral)' + AND t.table_type IN ('BASE TABLE', 'VIEW') """ ] if includeMaterializedViews { unions.append( """ - SELECT matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type - FROM pg_matviews - WHERE schemaname = '\(schemaLiteral)' + SELECT m.matviewname AS table_name, 'MATERIALIZED VIEW' AS table_type, + \(commentColumn("obj_description(to_regclass(quote_ident(m.schemaname) || '.' || quote_ident(m.matviewname)), 'pg_class')")) AS table_comment + FROM pg_matviews m + WHERE m.schemaname = '\(schemaLiteral)' """ ) } @@ -98,7 +110,8 @@ enum PostgreSQLSchemaQueries { if includeForeignTables { unions.append( """ - SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type + SELECT c.relname AS table_name, 'FOREIGN TABLE' AS table_type, + \(commentColumn("obj_description(c.oid, 'pg_class')")) AS table_comment FROM pg_foreign_table ft JOIN pg_class c ON c.oid = ft.ftrelid JOIN pg_namespace n ON n.oid = c.relnamespace diff --git a/Plugins/TableProPluginKit/PluginTableInfo.swift b/Plugins/TableProPluginKit/PluginTableInfo.swift index fdc87b4cf..32c47d944 100644 --- a/Plugins/TableProPluginKit/PluginTableInfo.swift +++ b/Plugins/TableProPluginKit/PluginTableInfo.swift @@ -5,7 +5,23 @@ public struct PluginTableInfo: Codable, Sendable { public let type: String public let rowCount: Int? public let schema: String? + public let comment: String? + public init( + name: String, + type: String = "TABLE", + rowCount: Int? = nil, + schema: String? = nil, + comment: String? + ) { + self.name = name + self.type = type + self.rowCount = rowCount + self.schema = schema + self.comment = comment + } + + @_disfavoredOverload public init( name: String, type: String = "TABLE", @@ -16,5 +32,6 @@ public struct PluginTableInfo: Codable, Sendable { self.type = type self.rowCount = rowCount self.schema = schema + self.comment = nil } } diff --git a/Plugins/TableProPluginKit/StringExtension.swift b/Plugins/TableProPluginKit/StringExtension.swift new file mode 100644 index 000000000..bf43b5299 --- /dev/null +++ b/Plugins/TableProPluginKit/StringExtension.swift @@ -0,0 +1,7 @@ +import Foundation + +public extension String { + var nilIfEmpty: String? { + isEmpty ? nil : self + } +} diff --git a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift index e8c8db7ed..44ffedb09 100644 --- a/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift +++ b/TablePro/Core/Coordinators/QueryExecutionCoordinator+Helpers.swift @@ -79,6 +79,7 @@ extension QueryExecutionCoordinator { var columnDefaults: [String: String?] = [:] var columnForeignKeys: [String: ForeignKeyInfo] = [:] var columnNullable: [String: Bool] = [:] + var columnComments: [String: String] = [:] for (index, colType) in columnTypes.enumerated() { if case .enumType(_, let values) = colType, let vals = values, index < columns.count { columnEnumValues[columns[index]] = vals @@ -91,6 +92,7 @@ extension QueryExecutionCoordinator { columnDefaults = metadata.columnDefaults columnForeignKeys = metadata.columnForeignKeys ?? [:] columnNullable = metadata.columnNullable + columnComments = metadata.columnComments foreignKeysFetched = metadata.columnForeignKeys != nil for (col, vals) in metadata.columnEnumValues { columnEnumValues[col] = vals @@ -100,6 +102,7 @@ extension QueryExecutionCoordinator { columnDefaults = existing.columnDefaults columnForeignKeys = existing.columnForeignKeys columnNullable = existing.columnNullable + columnComments = existing.columnComments foreignKeysFetched = existing.foreignKeysFetched for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil { columnEnumValues[col] = vals @@ -114,6 +117,7 @@ extension QueryExecutionCoordinator { columnForeignKeys: columnForeignKeys, columnEnumValues: columnEnumValues, columnNullable: columnNullable, + columnComments: columnComments, foreignKeysFetched: foreignKeysFetched ) parent.setActiveTableRows(newTableRows, for: existingTabId) @@ -333,7 +337,8 @@ extension QueryExecutionCoordinator { rows.updateDisplayMetadata( columnDefaults: parsed.columnDefaults, columnForeignKeys: parsed.columnForeignKeys, - columnNullable: parsed.columnNullable + columnNullable: parsed.columnNullable, + columnComments: parsed.columnComments ) } diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index aab1e408d..a2e3e39f2 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -194,7 +194,8 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable { name: table.name, type: tableType, rowCount: table.rowCount, - schema: table.schema ?? schemaFallback + schema: table.schema ?? schemaFallback, + comment: table.comment ) } diff --git a/TablePro/Core/Services/Query/QueryExecutor.swift b/TablePro/Core/Services/Query/QueryExecutor.swift index c605b49f1..3e1a65e5a 100644 --- a/TablePro/Core/Services/Query/QueryExecutor.swift +++ b/TablePro/Core/Services/Query/QueryExecutor.swift @@ -28,6 +28,7 @@ struct ParsedSchemaMetadata { let primaryKeyColumns: [String] let approximateRowCount: Int? let columnEnumValues: [String: [String]] + let columnComments: [String: String] } @MainActor @@ -169,10 +170,14 @@ final class QueryExecutor { fks = byColumn } var enumValues: [String: [String]] = [:] + var comments: [String: String] = [:] for col in schema.columns { if let values = col.allowedValues, !values.isEmpty { enumValues[col.name] = values } + if let comment = col.comment?.nilIfEmpty { + comments[col.name] = comment + } } return ParsedSchemaMetadata( columnDefaults: defaults, @@ -180,7 +185,8 @@ final class QueryExecutor { columnNullable: nullable, primaryKeyColumns: schema.columns.filter { $0.isPrimaryKey }.map(\.name), approximateRowCount: schema.approximateRowCount, - columnEnumValues: enumValues + columnEnumValues: enumValues, + columnComments: comments ) } @@ -200,7 +206,8 @@ final class QueryExecutor { columnNullable: nullable, primaryKeyColumns: primaryKeys, approximateRowCount: nil, - columnEnumValues: [:] + columnEnumValues: [:], + columnComments: [:] ) } diff --git a/TablePro/Models/Query/QueryResult.swift b/TablePro/Models/Query/QueryResult.swift index 57472d715..8665bceae 100644 --- a/TablePro/Models/Query/QueryResult.swift +++ b/TablePro/Models/Query/QueryResult.swift @@ -91,6 +91,7 @@ struct TableInfo: Identifiable, Hashable, Sendable { let type: TableType let rowCount: Int? let schema: String? + let comment: String? enum TableType: String, Sendable { case table = "TABLE" @@ -100,11 +101,12 @@ struct TableInfo: Identifiable, Hashable, Sendable { case systemTable = "SYSTEM TABLE" } - init(name: String, type: TableType, rowCount: Int?, schema: String? = nil) { + init(name: String, type: TableType, rowCount: Int?, schema: String? = nil, comment: String? = nil) { self.name = name self.type = type self.rowCount = rowCount self.schema = schema + self.comment = comment } static func == (lhs: TableInfo, rhs: TableInfo) -> Bool { diff --git a/TablePro/Models/Query/TableRows.swift b/TablePro/Models/Query/TableRows.swift index f6c329728..899f4780e 100644 --- a/TablePro/Models/Query/TableRows.swift +++ b/TablePro/Models/Query/TableRows.swift @@ -15,6 +15,7 @@ struct TableRows: Sendable { var columnForeignKeys: [String: ForeignKeyInfo] var columnEnumValues: [String: [String]] var columnNullable: [String: Bool] + var columnComments: [String: String] var foreignKeysFetched: Bool init( @@ -25,6 +26,7 @@ struct TableRows: Sendable { columnForeignKeys: [String: ForeignKeyInfo] = [:], columnEnumValues: [String: [String]] = [:], columnNullable: [String: Bool] = [:], + columnComments: [String: String] = [:], foreignKeysFetched: Bool = false ) { self.rows = rows @@ -35,6 +37,7 @@ struct TableRows: Sendable { self.columnForeignKeys = columnForeignKeys self.columnEnumValues = columnEnumValues self.columnNullable = columnNullable + self.columnComments = columnComments self.foreignKeysFetched = foreignKeysFetched } @@ -158,7 +161,8 @@ struct TableRows: Sendable { columnDefaults: [String: String?]? = nil, columnForeignKeys: [String: ForeignKeyInfo]? = nil, columnEnumValues: [String: [String]]? = nil, - columnNullable: [String: Bool]? = nil + columnNullable: [String: Bool]? = nil, + columnComments: [String: String]? = nil ) -> Delta { var didChange = false if let columnTypes, columnTypes != self.columnTypes { @@ -184,6 +188,10 @@ struct TableRows: Sendable { self.columnNullable = columnNullable didChange = true } + if let columnComments, columnComments != self.columnComments { + self.columnComments = columnComments + didChange = true + } return didChange ? .columnsReplaced : .none } @@ -195,6 +203,7 @@ struct TableRows: Sendable { columnForeignKeys: [String: ForeignKeyInfo] = [:], columnEnumValues: [String: [String]] = [:], columnNullable: [String: Bool] = [:], + columnComments: [String: String] = [:], foreignKeysFetched: Bool = false ) -> TableRows { var rows = ContiguousArray() @@ -211,6 +220,7 @@ struct TableRows: Sendable { columnForeignKeys: columnForeignKeys, columnEnumValues: columnEnumValues, columnNullable: columnNullable, + columnComments: columnComments, foreignKeysFetched: foreignKeysFetched ) } diff --git a/TablePro/Models/Settings/GeneralSettings.swift b/TablePro/Models/Settings/GeneralSettings.swift index babe1fd53..999f2b74d 100644 --- a/TablePro/Models/Settings/GeneralSettings.swift +++ b/TablePro/Models/Settings/GeneralSettings.swift @@ -63,12 +63,16 @@ struct GeneralSettings: Codable, Equatable { /// Whether to share anonymous usage analytics var shareAnalytics: Bool + /// Whether to show database object comments in the sidebar and data grid headers + var showObjectComments: Bool + static let `default` = GeneralSettings( startupBehavior: .reopenLast, language: .system, automaticallyCheckForUpdates: true, queryTimeoutSeconds: 60, - shareAnalytics: true + shareAnalytics: true, + showObjectComments: true ) init( @@ -76,13 +80,15 @@ struct GeneralSettings: Codable, Equatable { language: AppLanguage = .system, automaticallyCheckForUpdates: Bool = true, queryTimeoutSeconds: Int = 60, - shareAnalytics: Bool = true + shareAnalytics: Bool = true, + showObjectComments: Bool = true ) { self.startupBehavior = startupBehavior self.language = language self.automaticallyCheckForUpdates = automaticallyCheckForUpdates self.queryTimeoutSeconds = queryTimeoutSeconds self.shareAnalytics = shareAnalytics + self.showObjectComments = showObjectComments } init(from decoder: Decoder) throws { @@ -92,5 +98,6 @@ struct GeneralSettings: Codable, Equatable { automaticallyCheckForUpdates = try container.decodeIfPresent(Bool.self, forKey: .automaticallyCheckForUpdates) ?? true queryTimeoutSeconds = try container.decodeIfPresent(Int.self, forKey: .queryTimeoutSeconds) ?? 60 shareAnalytics = try container.decodeIfPresent(Bool.self, forKey: .shareAnalytics) ?? true + showObjectComments = try container.decodeIfPresent(Bool.self, forKey: .showObjectComments) ?? true } } diff --git a/TablePro/TableProApp.swift b/TablePro/TableProApp.swift index 35234b825..1ac21f65a 100644 --- a/TablePro/TableProApp.swift +++ b/TablePro/TableProApp.swift @@ -150,6 +150,13 @@ struct AppMenuCommands: Commands { ) } + private var showObjectCommentsBinding: Binding { + Binding( + get: { settingsManager.general.showObjectComments }, + set: { settingsManager.general.showObjectComments = $0 } + ) + } + private func shortcut(for action: ShortcutAction) -> KeyboardShortcut? { settingsManager.keyboard.keyboardShortcut(for: action) } @@ -603,6 +610,8 @@ struct AppMenuCommands: Commands { .pickerStyle(.inline) .disabled(!(actions?.canSwitchSidebarLayout ?? false)) + Toggle(String(localized: "Show Object Comments"), isOn: showObjectCommentsBinding) + Divider() Button("Toggle Filters") { diff --git a/TablePro/Views/Results/DataGridColumnPool.swift b/TablePro/Views/Results/DataGridColumnPool.swift index d6e81741e..7432d6e4a 100644 --- a/TablePro/Views/Results/DataGridColumnPool.swift +++ b/TablePro/Views/Results/DataGridColumnPool.swift @@ -28,6 +28,7 @@ final class DataGridColumnPool { tableView: NSTableView, schema: ColumnIdentitySchema, columnTypes: [ColumnType], + columnComments: [String: String] = [:], savedLayout: ColumnLayoutState?, isEditable: Bool, hiddenColumnNames: Set, @@ -52,6 +53,7 @@ final class DataGridColumnPool { column, name: columnName, columnType: slot < columnTypes.count ? columnTypes[slot] : nil, + comment: columnComments[columnName], width: resolvedWidth, isEditable: isEditable ) @@ -177,6 +179,7 @@ final class DataGridColumnPool { _ column: NSTableColumn, name: String, columnType: ColumnType?, + comment: String?, width: CGFloat, isEditable: Bool ) { @@ -187,12 +190,15 @@ final class DataGridColumnPool { column.headerCell = cell } - let tooltip: String + var tooltip: String if let typeName = columnType?.rawType ?? columnType?.displayName { tooltip = "\(name) (\(typeName))" } else { tooltip = name } + if let comment, !comment.isEmpty { + tooltip += "\n\(comment)" + } if column.headerToolTip != tooltip { column.headerToolTip = tooltip } diff --git a/TablePro/Views/Results/DataGridUpdateSnapshot.swift b/TablePro/Views/Results/DataGridUpdateSnapshot.swift index 575835904..133ffd02d 100644 --- a/TablePro/Views/Results/DataGridUpdateSnapshot.swift +++ b/TablePro/Views/Results/DataGridUpdateSnapshot.swift @@ -20,4 +20,5 @@ struct DataGridUpdateSnapshot: Equatable { let rowHeight: CGFloat let alternatingRows: Bool let reloadVersion: Int + let showObjectComments: Bool } diff --git a/TablePro/Views/Results/DataGridView.swift b/TablePro/Views/Results/DataGridView.swift index 5ac88e547..e29267c74 100644 --- a/TablePro/Views/Results/DataGridView.swift +++ b/TablePro/Views/Results/DataGridView.swift @@ -166,7 +166,8 @@ struct DataGridView: NSViewRepresentable { hasMoveDelegate: delegate != nil, rowHeight: rowHeight, alternatingRows: alternatingRows, - reloadVersion: changeManager.reloadVersion + reloadVersion: changeManager.reloadVersion, + showObjectComments: AppSettingsManager.shared.general.showObjectComments ) if snapshot != coordinator.lastUpdateSnapshot { @@ -305,10 +306,14 @@ struct DataGridView: NSViewRepresentable { tableRows: TableRows, savedLayout: ColumnLayoutState? ) { + let columnComments = AppSettingsManager.shared.general.showObjectComments + ? tableRows.columnComments + : [:] coordinator.columnPool.reconcile( tableView: tableView, schema: coordinator.identitySchema, columnTypes: tableRows.columnTypes, + columnComments: columnComments, savedLayout: savedLayout, isEditable: isEditable, hiddenColumnNames: configuration.hiddenColumns, diff --git a/TablePro/Views/RightSidebar/RightSidebarView.swift b/TablePro/Views/RightSidebar/RightSidebarView.swift index 90495798b..8a1ef0633 100644 --- a/TablePro/Views/RightSidebar/RightSidebarView.swift +++ b/TablePro/Views/RightSidebar/RightSidebarView.swift @@ -65,6 +65,17 @@ struct RightSidebarView: View { private func tableInfoContent(_ metadata: TableMetadata) -> some View { Form { + if AppSettingsManager.shared.general.showObjectComments, + let comment = metadata.comment, !comment.isEmpty { + Section { + Text(comment) + .fixedSize(horizontal: false, vertical: true) + .textSelection(.enabled) + } header: { + Text("COMMENT") + } + } + Section { LabeledContent( String(localized: "Data Size"), diff --git a/TablePro/Views/Sidebar/TableRowView.swift b/TablePro/Views/Sidebar/TableRowView.swift index d4f10de94..a5ad08a4e 100644 --- a/TablePro/Views/Sidebar/TableRowView.swift +++ b/TablePro/Views/Sidebar/TableRowView.swift @@ -49,6 +49,13 @@ struct TableRow: View { @State private var isHovered = false + private var visibleComment: String? { + guard AppSettingsManager.shared.general.showObjectComments, + let comment = table.comment, !comment.isEmpty + else { return nil } + return comment + } + @ViewBuilder private var pendingStateBadge: some View { if isPendingDelete { @@ -65,8 +72,19 @@ struct TableRow: View { var body: some View { HStack(spacing: 6) { Label { - Text(table.name) - .lineLimit(1) + HStack(spacing: 6) { + Text(table.name) + .lineLimit(1) + .layoutPriority(1) + if let visibleComment { + Text(visibleComment) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + .help(visibleComment) + } + } } icon: { Image(systemName: TableRowLogic.iconName(for: table.type)) .sidebarTint(Color.accentColor) diff --git a/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift b/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift new file mode 100644 index 000000000..55d8673d8 --- /dev/null +++ b/TableProTests/Core/Services/Query/ParseSchemaMetadataTests.swift @@ -0,0 +1,49 @@ +import Foundation +import Testing +@testable import TablePro + +@Suite("QueryExecutor.parseSchemaMetadata - column comments") +struct ParseSchemaMetadataTests { + private func column(_ name: String, comment: String?) -> ColumnInfo { + ColumnInfo( + name: name, + dataType: "TEXT", + isNullable: true, + isPrimaryKey: false, + comment: comment + ) + } + + @Test("Populates columnComments from non-empty ColumnInfo comments") + func populatesComments() { + let schema = FetchedTableSchema( + columns: [column("id", comment: "Primary key"), column("name", comment: "Display name")], + foreignKeys: nil, + approximateRowCount: nil + ) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.columnComments == ["id": "Primary key", "name": "Display name"]) + } + + @Test("Excludes nil and empty comments") + func excludesNilAndEmpty() { + let schema = FetchedTableSchema( + columns: [column("id", comment: nil), column("name", comment: ""), column("email", comment: "Contact")], + foreignKeys: nil, + approximateRowCount: nil + ) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.columnComments == ["email": "Contact"]) + } + + @Test("Returns an empty dictionary when no column has a comment") + func emptyWhenNoComments() { + let schema = FetchedTableSchema( + columns: [column("id", comment: nil), column("name", comment: nil)], + foreignKeys: nil, + approximateRowCount: nil + ) + let parsed = QueryExecutor.parseSchemaMetadata(schema) + #expect(parsed.columnComments.isEmpty) + } +} diff --git a/TableProTests/Models/Query/TableRowsTests.swift b/TableProTests/Models/Query/TableRowsTests.swift index 51cf0c3e6..45a16850b 100644 --- a/TableProTests/Models/Query/TableRowsTests.swift +++ b/TableProTests/Models/Query/TableRowsTests.swift @@ -575,6 +575,33 @@ struct TableRowsMetadataTests { ) #expect(delta == .none) } + + @Test("updateDisplayMetadata stores columnComments and reports columnsReplaced") + func updateDisplayMetadataStoresComments() { + var table = Self.makeTable() + let delta = table.updateDisplayMetadata(columnComments: ["c1": "Primary key"]) + #expect(delta == .columnsReplaced) + #expect(table.columnComments == ["c1": "Primary key"]) + } + + @Test("updateDisplayMetadata returns Delta.none when columnComments are unchanged") + func updateDisplayMetadataUnchangedCommentsIsNoOp() { + var table = Self.makeTable() + _ = table.updateDisplayMetadata(columnComments: ["c1": "Primary key"]) + let delta = table.updateDisplayMetadata(columnComments: ["c1": "Primary key"]) + #expect(delta == .none) + } + + @Test("Factory preserves columnComments") + func factoryPreservesComments() { + let table = TableRows.from( + queryRows: [["a"]], + columns: ["c1"], + columnTypes: [.text(rawType: nil)], + columnComments: ["c1": "A note"] + ) + #expect(table.columnComments == ["c1": "A note"]) + } } @Suite("TableRows - metadata preservation regression") diff --git a/TableProTests/Models/TableInfoTests.swift b/TableProTests/Models/TableInfoTests.swift index 65843b951..e0670de29 100644 --- a/TableProTests/Models/TableInfoTests.swift +++ b/TableProTests/Models/TableInfoTests.swift @@ -151,6 +151,31 @@ struct TableInfoTests { #expect(selected.contains(lookup)) } + // MARK: - Comment does not affect identity + + @Test("Comment does not affect equality") + func testCommentDoesNotAffectEquality() { + let a = TableInfo(name: "users", type: .table, rowCount: nil, comment: "Account records") + let b = TableInfo(name: "users", type: .table, rowCount: nil, comment: nil) + #expect(a == b) + } + + @Test("Comment does not affect hash") + func testCommentDoesNotAffectHash() { + let a = TableInfo(name: "users", type: .table, rowCount: nil, comment: "Account records") + let b = TableInfo(name: "users", type: .table, rowCount: nil, comment: "Something else") + #expect(a.hashValue == b.hashValue) + } + + @Test("Set deduplication ignores comment") + func testSetDeduplicationIgnoresComment() { + let a = TableInfo(name: "orders", type: .table, rowCount: nil, comment: "First") + let b = TableInfo(name: "orders", type: .table, rowCount: nil, comment: "Second") + var set: Set = [a] + set.insert(b) + #expect(set.count == 1) + } + @Test("Subtracting sets works correctly") func testSetSubtraction() { let all: Set = [ diff --git a/TableProTests/Plugins/PluginTableInfoTests.swift b/TableProTests/Plugins/PluginTableInfoTests.swift new file mode 100644 index 000000000..91f353276 --- /dev/null +++ b/TableProTests/Plugins/PluginTableInfoTests.swift @@ -0,0 +1,39 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PluginTableInfo") +struct PluginTableInfoTests { + @Test("Init without comment leaves it nil") + func initWithoutComment() { + let info = PluginTableInfo(name: "users", type: "TABLE") + #expect(info.comment == nil) + } + + @Test("Init with comment stores it") + func initWithComment() { + let info = PluginTableInfo(name: "users", type: "TABLE", comment: "Account records") + #expect(info.comment == "Account records") + } + + @Test("comment round-trips through JSON encoding") + func commentRoundTrip() throws { + let original = PluginTableInfo(name: "orders", type: "TABLE", comment: "Customer orders") + let data = try JSONEncoder().encode(original) + let decoded = try JSONDecoder().decode(PluginTableInfo.self, from: data) + #expect(decoded.comment == "Customer orders") + } + + @Test("decoding a payload without comment keeps it nil for forward compatibility") + func legacyPayloadDecodesToNilComment() throws { + let legacyJson = """ + { + "name": "users", + "type": "TABLE" + } + """.data(using: .utf8)! + let decoded = try JSONDecoder().decode(PluginTableInfo.self, from: legacyJson) + #expect(decoded.comment == nil) + #expect(decoded.name == "users") + } +} diff --git a/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift new file mode 100644 index 000000000..c6ae9687a --- /dev/null +++ b/TableProTests/Plugins/PostgreSQLFetchTablesCommentTests.swift @@ -0,0 +1,55 @@ +import Foundation +import TableProPluginKit +import Testing + +@Suite("PostgreSQLSchemaQueries.fetchTables comments") +struct PostgreSQLFetchTablesCommentTests { + @Test("Base query selects the table comment via obj_description") + func baseQuerySelectsComment() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: false, + includeForeignTables: false + ) + #expect(query.contains("table_comment")) + #expect(query.contains("obj_description")) + } + + @Test("Base query does not reference pg_class/pg_namespace so the portability fallback stays minimal") + func baseQueryStaysPortable() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: false, + includeForeignTables: false + ) + #expect(!query.contains("pg_catalog.pg_class")) + #expect(!query.contains("pg_catalog.pg_namespace")) + } + + @Test("Every union branch projects a comment column so columns stay aligned") + func allBranchesProjectComment() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: true, + includeForeignTables: true + ) + let commentColumns = query.components(separatedBy: "AS table_comment").count - 1 + let branches = query.components(separatedBy: "UNION ALL").count + #expect(commentColumns == branches) + } + + @Test("Comment-free fallback omits obj_description but keeps the aligned comment column") + func commentFreeFallbackOmitsObjDescription() { + let query = PostgreSQLSchemaQueries.fetchTables( + schemaLiteral: "public", + includeMaterializedViews: true, + includeForeignTables: true, + includeComments: false + ) + #expect(!query.contains("obj_description")) + #expect(!query.contains("to_regclass")) + let commentColumns = query.components(separatedBy: "AS table_comment").count - 1 + let branches = query.components(separatedBy: "UNION ALL").count + #expect(commentColumns == branches) + } +} diff --git a/docs/databases/mysql.mdx b/docs/databases/mysql.mdx index 446fcf7ba..7095f78b6 100644 --- a/docs/databases/mysql.mdx +++ b/docs/databases/mysql.mdx @@ -75,7 +75,11 @@ Same driver for both. MySQL 8.0 defaults to `caching_sha2_password` auth (vs Mar ## Features -Sidebar shows all accessible databases, tables, structure (columns, indexes, foreign keys), and DDL. Switch databases with **Cmd+K**. Full MySQL syntax support for queries: +Sidebar shows all accessible databases, tables, structure (columns, indexes, foreign keys), and DDL. Switch databases with **Cmd+K**. + +Table and column comments are shown in the UI: the sidebar shows a table's comment in dimmed text after its name (full text on hover), and the data grid column header tooltip includes the column comment. Turn this off from **View > Show Object Comments**. + +Full MySQL syntax support for queries: ```sql -- Select with JSON diff --git a/docs/databases/postgresql.mdx b/docs/databases/postgresql.mdx index 051f5d9ef..d9f0f0174 100644 --- a/docs/databases/postgresql.mdx +++ b/docs/databases/postgresql.mdx @@ -71,7 +71,11 @@ The database role must be set up for IAM auth (`GRANT rds_iam TO "user"`). Conne ## Features -Sidebar displays all accessible schemas and tables. Switch databases/schemas with **Cmd+K**. Table info shows structure (columns, indexes, constraints) and DDL. Full PostgreSQL syntax support: +Sidebar displays all accessible schemas and tables. Switch databases/schemas with **Cmd+K**. Table info shows structure (columns, indexes, constraints) and DDL. + +Table and column comments are shown in the UI: the sidebar shows a table's comment in dimmed text after its name (full text on hover), and the data grid column header tooltip includes the column comment. Turn this off from **View > Show Object Comments**. + +Full PostgreSQL syntax support: ```sql -- JSONB queries