Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
13 changes: 10 additions & 3 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
}

Expand Down
3 changes: 2 additions & 1 deletion Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,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)
}
}

Expand Down
18 changes: 11 additions & 7 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,26 +79,30 @@ enum PostgreSQLSchemaQueries {
) -> String {
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,
obj_description(to_regclass(quote_ident(t.table_schema) || '.' || quote_ident(t.table_name)), 'pg_class') AS table_comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid to_regclass in the base table query

For PostgreSQL 9.1–9.3 connections, this base fetchTables query now fails before any tables are listed because to_regclass(text) is only available starting in PostgreSQL 9.4, while this driver still treats 9.1/9.3 catalogs as supported via PostgreSQLCapabilities (hasForeignTablesCatalog at 90_100 and hasMaterializedViewsCatalog at 90_300). In that environment, opening a schema will error instead of showing tables; use a pg_class/pg_namespace join or gate this expression by server version.

Useful? React with 👍 / 👎.

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,
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)'
"""
)
}

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,
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
Expand Down
17 changes: 17 additions & 0 deletions Plugins/TableProPluginKit/PluginTableInfo.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -16,5 +32,6 @@ public struct PluginTableInfo: Codable, Sendable {
self.type = type
self.rowCount = rowCount
self.schema = schema
self.comment = nil
}
}
7 changes: 7 additions & 0 deletions Plugins/TableProPluginKit/StringExtension.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import Foundation

public extension String {
var nilIfEmpty: String? {
isEmpty ? nil : self
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -100,6 +102,7 @@ extension QueryExecutionCoordinator {
columnDefaults = existing.columnDefaults
columnForeignKeys = existing.columnForeignKeys
columnNullable = existing.columnNullable
columnComments = existing.columnComments

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Clear comments when schema metadata is absent

When a new result has no parsed schema metadata (for example, after previously displaying a commented table in the same tab and then running SELECT 1 AS id), this copies the prior result's columnComments into the new TableRows. The grid keys header tooltips by column name, so any matching alias inherits a stale comment from the previous table even though the result is not that object. Keep comments only when the new result is known to be for the same table/schema; otherwise clear them like fresh metadata.

Useful? React with 👍 / 👎.

foreignKeysFetched = existing.foreignKeysFetched
for (col, vals) in existing.columnEnumValues where columnEnumValues[col] == nil {
columnEnumValues[col] = vals
Expand All @@ -114,6 +117,7 @@ extension QueryExecutionCoordinator {
columnForeignKeys: columnForeignKeys,
columnEnumValues: columnEnumValues,
columnNullable: columnNullable,
columnComments: columnComments,
foreignKeysFetched: foreignKeysFetched
)
parent.setActiveTableRows(newTableRows, for: existingTabId)
Expand Down Expand Up @@ -333,7 +337,8 @@ extension QueryExecutionCoordinator {
rows.updateDisplayMetadata(
columnDefaults: parsed.columnDefaults,
columnForeignKeys: parsed.columnForeignKeys,
columnNullable: parsed.columnNullable
columnNullable: parsed.columnNullable,
columnComments: parsed.columnComments
)
}

Expand Down
3 changes: 2 additions & 1 deletion TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down
11 changes: 9 additions & 2 deletions TablePro/Core/Services/Query/QueryExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ struct ParsedSchemaMetadata {
let primaryKeyColumns: [String]
let approximateRowCount: Int?
let columnEnumValues: [String: [String]]
let columnComments: [String: String]
}

@MainActor
Expand Down Expand Up @@ -169,18 +170,23 @@ 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,
columnForeignKeys: fks,
columnNullable: nullable,
primaryKeyColumns: schema.columns.filter { $0.isPrimaryKey }.map(\.name),
approximateRowCount: schema.approximateRowCount,
columnEnumValues: enumValues
columnEnumValues: enumValues,
columnComments: comments
)
}

Expand All @@ -200,7 +206,8 @@ final class QueryExecutor {
columnNullable: nullable,
primaryKeyColumns: primaryKeys,
approximateRowCount: nil,
columnEnumValues: [:]
columnEnumValues: [:],
columnComments: [:]
)
}

Expand Down
4 changes: 3 additions & 1 deletion TablePro/Models/Query/QueryResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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 {
Expand Down
12 changes: 11 additions & 1 deletion TablePro/Models/Query/TableRows.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -35,6 +37,7 @@ struct TableRows: Sendable {
self.columnForeignKeys = columnForeignKeys
self.columnEnumValues = columnEnumValues
self.columnNullable = columnNullable
self.columnComments = columnComments
self.foreignKeysFetched = foreignKeysFetched
}

Expand Down Expand Up @@ -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 {
Expand All @@ -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
}

Expand All @@ -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<Row>()
Expand All @@ -211,6 +220,7 @@ struct TableRows: Sendable {
columnForeignKeys: columnForeignKeys,
columnEnumValues: columnEnumValues,
columnNullable: columnNullable,
columnComments: columnComments,
foreignKeysFetched: foreignKeysFetched
)
}
Expand Down
11 changes: 9 additions & 2 deletions TablePro/Models/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,26 +63,32 @@ 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(
startupBehavior: StartupBehavior = .reopenLast,
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 {
Expand All @@ -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
}
}
9 changes: 9 additions & 0 deletions TablePro/TableProApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ struct AppMenuCommands: Commands {
)
}

private var showObjectCommentsBinding: Binding<Bool> {
Binding(
get: { settingsManager.general.showObjectComments },
set: { settingsManager.general.showObjectComments = $0 }
)
}

private func shortcut(for action: ShortcutAction) -> KeyboardShortcut? {
settingsManager.keyboard.keyboardShortcut(for: action)
}
Expand Down Expand Up @@ -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") {
Expand Down
Loading
Loading