Skip to content
Open
Show file tree
Hide file tree
Changes from all 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