Skip to content
Merged
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
28 changes: 15 additions & 13 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand Down Expand Up @@ -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))

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 Keep optional catalogs when comments are unavailable

Fresh evidence in the current revision is that the new undefined-function fallback disables includeOptionalCatalogs as well as comments. On PostgreSQL versions that still support these catalogs here (e.g. pg_foreign_table from 9.1 and pg_matviews from 9.3) but do not have to_regclass until 9.4, the first commented query raises 42883 and this fallback silently drops foreign tables/materialized views from the sidebar instead of only omitting comments.

Useful? React with 👍 / 👎.

}

return result.rows.compactMap { row -> PluginTableInfo? in
Expand All @@ -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)
}
}

Expand Down
29 changes: 21 additions & 8 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -72,33 +72,46 @@ 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)'
"""
)
}

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
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
}
}
Loading
Loading