diff --git a/CHANGELOG.md b/CHANGELOG.md
index fac3bf2fc..70690b9d4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438)
- Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438)
- Move Tab to New Window on a tab's right-click menu, and by dragging a tab out of the strip. (#2438)
+- Properties tab in the structure editor, with the table's owner, tablespace, storage and timestamps. (#2555)
+- Editable table comment on the Properties tab for MySQL, MariaDB, PostgreSQL and PGlite. (#2555)
### Changed
diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift
index 9ce414fc7..6e5380a80 100644
--- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift
+++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift
@@ -111,6 +111,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsCheckConstraints = true
static let supportsCheckConstraintEditing = true
static let supportsGeneratedColumns = true
+ static let supportsTableComment = true
func createDriver(config: DriverConnectionConfig) -> any PluginDatabaseDriver {
MySQLPluginDriver(config: config)
diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
index 297f2a505..012deff03 100644
--- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
+++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
@@ -607,25 +607,27 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
return PluginTableMetadata(tableName: table)
}
- let engine = row[safe: 1]?.asText
- let rowCount = (row[safe: 4]?.asText).flatMap { Int64($0) }
- let dataSize = (row[safe: 6]?.asText).flatMap { Int64($0) }
- let indexSize = (row[safe: 8]?.asText).flatMap { Int64($0) }
- let comment = row[safe: 17]?.asText
+ let status = MySQLTableStatus(row: row)
let totalSize: Int64? = {
- guard let data = dataSize, let index = indexSize else { return nil }
+ guard let data = status.dataSize, let index = status.indexSize else { return nil }
return data + index
}()
return PluginTableMetadata(
tableName: table,
- dataSize: dataSize,
- indexSize: indexSize,
+ dataSize: status.dataSize,
+ indexSize: status.indexSize,
totalSize: totalSize,
- rowCount: rowCount,
- comment: comment?.isEmpty == true ? nil : comment,
- engine: engine
+ avgRowLength: status.avgRowLength,
+ rowCount: status.rowCount,
+ comment: status.comment,
+ engine: status.engine,
+ collation: status.collation,
+ createTime: status.createTime,
+ updateTime: status.updateTime,
+ attributes: status.attributes,
+ commentIsReadOnly: status.commentIsReadOnly
)
}
@@ -899,6 +901,12 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
}
+ /// MySQL has no way to unset a table comment, so clearing one writes the empty string, which is
+ /// what `information_schema` reports for a table that never had one.
+ func generateSetTableCommentSQL(table: String, comment: String?) -> String? {
+ "ALTER TABLE \(quoteIdentifier(table)) COMMENT = '\(escapeStringLiteral(comment ?? ""))'"
+ }
+
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
"ALTER TABLE \(quoteIdentifier(table)) ADD \(buildIndexDefinitionSQL(index))"
}
diff --git a/Plugins/MySQLDriverPlugin/MySQLTableStatus.swift b/Plugins/MySQLDriverPlugin/MySQLTableStatus.swift
new file mode 100644
index 000000000..08193410e
--- /dev/null
+++ b/Plugins/MySQLDriverPlugin/MySQLTableStatus.swift
@@ -0,0 +1,91 @@
+//
+// MySQLTableStatus.swift
+// MySQLDriverPlugin
+//
+// Reads one `SHOW TABLE STATUS` row by its documented column order.
+//
+
+import Foundation
+import TableProPluginKit
+
+/// `SHOW TABLE STATUS` answers positionally, and the app used to read four of its eighteen columns
+/// by hand-written index. Naming every column the Properties tab shows keeps those indexes in one
+/// place instead of scattering more of them through the driver.
+struct MySQLTableStatus {
+ let engine: String?
+ let rowFormat: String?
+ let rowCount: Int64?
+ let avgRowLength: Int64?
+ let dataSize: Int64?
+ let indexSize: Int64?
+ let autoIncrement: Int64?
+ let createTime: Date?
+ let updateTime: Date?
+ let collation: String?
+ let createOptions: String?
+ let comment: String?
+
+ init(row: [PluginCellValue]) {
+ engine = Self.text(row, 1)
+ rowFormat = Self.text(row, 3)
+ rowCount = Self.number(row, 4)
+ avgRowLength = Self.number(row, 5)
+ dataSize = Self.number(row, 6)
+ indexSize = Self.number(row, 8)
+ autoIncrement = Self.number(row, 10)
+ createTime = Self.timestamp(row, 11)
+ updateTime = Self.timestamp(row, 12)
+ collation = Self.text(row, 14)
+ createOptions = Self.text(row, 16)
+ comment = Self.text(row, 17)
+ }
+
+ /// `SHOW TABLE STATUS` answers for a view with every storage column NULL, `Engine` included,
+ /// and reports the literal `VIEW` where a table's comment would be. MySQL has no `COMMENT` form
+ /// for a view, so a row that names no engine is treated as one and its comment stays read-only.
+ var commentIsReadOnly: Bool {
+ engine == nil
+ }
+
+ var attributes: [PluginObjectAttribute] {
+ var result: [PluginObjectAttribute] = []
+ if let rowFormat {
+ result.append(PluginObjectAttribute(label: String(localized: "Row Format"), value: rowFormat))
+ }
+ if let autoIncrement {
+ result.append(
+ PluginObjectAttribute(label: String(localized: "Auto Increment"), value: String(autoIncrement))
+ )
+ }
+ if let createOptions {
+ result.append(PluginObjectAttribute(label: String(localized: "Options"), value: createOptions))
+ }
+ return result
+ }
+
+ private static func text(_ row: [PluginCellValue], _ index: Int) -> String? {
+ guard let value = row[safe: index]?.asText, !value.isEmpty else { return nil }
+ return value
+ }
+
+ private static func number(_ row: [PluginCellValue], _ index: Int) -> Int64? {
+ text(row, index).flatMap { Int64($0) }
+ }
+
+ /// MySQL sends these as `YYYY-MM-DD HH:MM:SS` in the session time zone and carries no offset,
+ /// so the instant they name cannot be recovered from the string alone. Reading them in the
+ /// client's zone is deliberate: the app formats the `Date` back in that same zone, so what the
+ /// user reads is the wall clock the server reported. Fixing the formatter to UTC would be a
+ /// guess at the server's zone and would shift every displayed timestamp by that guess.
+ private static let timestampFormatter: DateFormatter = {
+ let formatter = DateFormatter()
+ formatter.locale = Locale(identifier: "en_US_POSIX")
+ formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
+ return formatter
+ }()
+
+ private static func timestamp(_ row: [PluginCellValue], _ index: Int) -> Date? {
+ guard let value = text(row, index) else { return nil }
+ return timestampFormatter.date(from: value)
+ }
+}
diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift
index c44b598e2..f4fc2ff44 100644
--- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift
@@ -12,6 +12,23 @@ public enum PostgreSQLObjectQueries {
value.replacingOccurrences(of: "'", with: "''")
}
+ /// A literal that means the same thing whatever `standard_conforming_strings` is set to.
+ ///
+ /// An ordinary `'...'` literal only needs its apostrophes doubled while that setting is on.
+ /// With it off, PostgreSQL reads backslash escapes inside one, so a backslash placed in front
+ /// of a doubled apostrophe consumes the first half and the second half closes the literal, and
+ /// whatever follows is parsed as SQL. A dollar-quoted body is not scanned for escapes at all,
+ /// so the setting cannot change what it means. The tag grows until it does not occur in the
+ /// value, which is the only way the body can end early.
+ public static func dollarQuoted(_ value: String) -> String {
+ let body = value.replacingOccurrences(of: "\0", with: "")
+ var tag = "tablepro"
+ while body.contains("$\(tag)$") {
+ tag += "_"
+ }
+ return "$\(tag)$\(body)$\(tag)$"
+ }
+
/// `prokind` arrived in PostgreSQL 11, which is also the first release with procedures.
public static let prokindMinimumServerVersion: Int32 = 110_000
diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
index 66c7a5d67..e8fafb0a0 100644
--- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
@@ -97,6 +97,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let supportsCheckConstraints = true
static let supportsCheckConstraintEditing = true
static let supportsGeneratedColumns = true
+ static let supportsTableComment = true
static let sqlDialect: SQLDialectDescriptor? = PostgreSQLDialect.descriptor
diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
index a216a22f6..1c0ee9917 100644
--- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
@@ -626,9 +626,16 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
pg_table_size(c.oid) AS data_size,
pg_indexes_size(c.oid) AS index_size,
c.reltuples::bigint AS row_count,
- obj_description(c.oid, 'pg_class') AS comment
+ obj_description(c.oid, 'pg_class') AS comment,
+ pg_get_userbyid(c.relowner) AS owner,
+ COALESCE(t.spcname, dt.spcname) AS tablespace,
+ c.relpersistence,
+ c.relkind
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
+ LEFT JOIN pg_tablespace t ON t.oid = c.reltablespace
+ LEFT JOIN pg_database d ON d.datname = current_database()
+ LEFT JOIN pg_tablespace dt ON dt.oid = d.dattablespace
WHERE c.relname = '\(escapeLiteral(table))'
AND n.nspname = '\(schemaLiteral)'
"""
@@ -642,6 +649,7 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
let indexSize = row.count > 2 ? Int64(row[2].asText ?? "0") : nil
let rowCount = row.count > 3 ? Int64(row[3].asText ?? "0") : nil
let comment = row.count > 4 ? row[4].asText : nil
+ let relkind = row.count > 8 ? row[8].asText : nil
return PluginTableMetadata(
tableName: table,
@@ -650,7 +658,14 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
totalSize: totalSize,
rowCount: rowCount,
comment: comment?.isEmpty == true ? nil : comment,
- engine: "PostgreSQL"
+ engine: "PostgreSQL",
+ attributes: PostgreSQLTableAttributes.build(
+ owner: row.count > 5 ? row[5].asText : nil,
+ tablespace: row.count > 6 ? row[6].asText : nil,
+ persistence: row.count > 7 ? row[7].asText : nil,
+ relkind: relkind
+ ),
+ commentIsReadOnly: PostgreSQLTableAttributes.commentIsReadOnly(relkind: relkind)
)
}
@@ -1276,6 +1291,15 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
"ALTER TABLE \(qualifiedTableName(table)) DROP COLUMN \(quoteIdentifier(columnName))"
}
+ /// Dollar-quoted rather than `'...'`: a comment is arbitrary user text, and an ordinary literal
+ /// changes meaning with `standard_conforming_strings`.
+ func generateSetTableCommentSQL(table: String, comment: String?) -> String? {
+ guard let comment, !comment.isEmpty else {
+ return "COMMENT ON TABLE \(qualifiedTableName(table)) IS NULL"
+ }
+ return "COMMENT ON TABLE \(qualifiedTableName(table)) IS \(PostgreSQLObjectQueries.dollarQuoted(comment))"
+ }
+
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
pgIndexDefinition(index, qualifiedTable: qualifiedTableName(table))
}
diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableAttributes.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableAttributes.swift
new file mode 100644
index 000000000..a08dd38d8
--- /dev/null
+++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLTableAttributes.swift
@@ -0,0 +1,67 @@
+//
+// PostgreSQLTableAttributes.swift
+// PostgreSQLDriverPlugin
+//
+// The labelled properties the Properties tab shows for a PostgreSQL table.
+//
+
+import Foundation
+import TableProPluginKit
+
+/// The schema is deliberately absent: the app already knows which schema the tab is bound to and
+/// labels it itself, so naming it here would print the same row twice.
+enum PostgreSQLTableAttributes {
+ static func build(
+ owner: String?,
+ tablespace: String?,
+ persistence: String?,
+ relkind: String?
+ ) -> [PluginObjectAttribute] {
+ var attributes: [PluginObjectAttribute] = []
+ if let owner, !owner.isEmpty {
+ attributes.append(PluginObjectAttribute(label: String(localized: "Owner"), value: owner))
+ }
+ if let tablespace, !tablespace.isEmpty {
+ attributes.append(PluginObjectAttribute(label: String(localized: "Tablespace"), value: tablespace))
+ }
+ if let label = persistenceLabel(persistence) {
+ attributes.append(PluginObjectAttribute(label: String(localized: "Persistence"), value: label))
+ }
+ if let label = relkindLabel(relkind) {
+ attributes.append(PluginObjectAttribute(label: String(localized: "Kind"), value: label))
+ }
+ return attributes
+ }
+
+ /// `COMMENT ON TABLE` is refused on anything that is not an ordinary or partitioned table, and
+ /// PostgreSQL spells the rest with their own keywords (`VIEW`, `MATERIALIZED VIEW`,
+ /// `FOREIGN TABLE`). The app cannot tell those apart from a table, so the relation itself says
+ /// so here. An unreadable `relkind` is treated as read-only rather than guessed at.
+ static func commentIsReadOnly(relkind: String?) -> Bool {
+ guard let relkind else { return true }
+ return relkind != "r" && relkind != "p"
+ }
+
+ /// `pg_class.relpersistence`, documented as p (permanent), u (unlogged) and t (temporary).
+ private static func persistenceLabel(_ value: String?) -> String? {
+ switch value {
+ case "p": String(localized: "Permanent")
+ case "u": String(localized: "Unlogged")
+ case "t": String(localized: "Temporary")
+ default: nil
+ }
+ }
+
+ /// `pg_class.relkind`. An ordinary table is the assumption already, so only a relation that
+ /// differs from it is named, and an unfamiliar kind is left off rather than reported as a
+ /// single letter.
+ private static func relkindLabel(_ value: String?) -> String? {
+ switch value {
+ case "p": String(localized: "Partitioned table")
+ case "v": String(localized: "View")
+ case "m": String(localized: "Materialized view")
+ case "f": String(localized: "Foreign table")
+ default: nil
+ }
+ }
+}
diff --git a/Plugins/TableProPluginKit/DriverPlugin.swift b/Plugins/TableProPluginKit/DriverPlugin.swift
index 7a18be51e..737e27016 100644
--- a/Plugins/TableProPluginKit/DriverPlugin.swift
+++ b/Plugins/TableProPluginKit/DriverPlugin.swift
@@ -78,6 +78,11 @@ public protocol DriverPlugin: TableProPlugin {
static var supportsAddIndex: Bool { get }
static var supportsDropIndex: Bool { get }
static var supportsModifyPrimaryKey: Bool { get }
+
+ /// Whether the engine stores a comment on a table that can be written back. False by default so
+ /// a driver that has not implemented `generateSetTableCommentSQL` presents its comment read-only
+ /// instead of staging an edit no statement can carry.
+ static var supportsTableComment: Bool { get }
}
public extension DriverPlugin {
@@ -170,4 +175,5 @@ public extension DriverPlugin {
static var supportsAddIndex: Bool { true }
static var supportsDropIndex: Bool { true }
static var supportsModifyPrimaryKey: Bool { true }
+ static var supportsTableComment: Bool { false }
}
diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
index 762816040..b7fc1cb78 100644
--- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
+++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift
@@ -209,6 +209,10 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String?
func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String?
+ /// A nil `comment` clears the table's comment. Returning nil means the engine has none, which
+ /// is what keeps the Properties tab's comment field read-only there.
+ func generateSetTableCommentSQL(table: String, comment: String?) -> String?
+
// Definition SQL for clipboard copy (optional — return nil if not supported)
func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String?
func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String?
@@ -534,6 +538,7 @@ public extension PluginDatabaseDriver {
func generateModifyPrimaryKeySQL(table: String, oldColumns: [String], newColumns: [String], constraintName: String?) -> [String]? { nil }
func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? { nil }
func generateCreateTableSQL(definition: PluginCreateTableDefinition) -> String? { nil }
+ func generateSetTableCommentSQL(table: String, comment: String?) -> String? { nil }
func generateColumnDefinitionSQL(column: PluginColumnDefinition) -> String? { nil }
func generateIndexDefinitionSQL(index: PluginIndexDefinition, tableName: String?) -> String? { nil }
diff --git a/Plugins/TableProPluginKit/PluginTableMetadata.swift b/Plugins/TableProPluginKit/PluginTableMetadata.swift
index 0cd007e81..8bb642e82 100644
--- a/Plugins/TableProPluginKit/PluginTableMetadata.swift
+++ b/Plugins/TableProPluginKit/PluginTableMetadata.swift
@@ -13,6 +13,53 @@ public struct PluginTableMetadata: Codable, Sendable {
public let createTime: Date?
public let updateTime: Date?
+ /// Per-engine properties the app does not model: owner, tablespace, persistence, row format.
+ /// The driver names and orders them, and they are rendered verbatim.
+ public let attributes: [PluginObjectAttribute]
+
+ /// Whether the comment on *this* relation can be written back, as opposed to whether the engine
+ /// has table comments at all, which is the separate per-engine gate.
+ ///
+ /// True unless a driver says otherwise, so a driver that cannot establish the relation kind, and
+ /// an already-built plugin that predates this field, both present the comment read-only rather
+ /// than offering an edit that resolves to the wrong `COMMENT ON` keyword. PostgreSQL lowers it
+ /// from `pg_class.relkind`, which is the only thing separating an ordinary table from a view, a
+ /// materialized view or a foreign table once a tab has been opened on it.
+ public let commentIsReadOnly: Bool
+
+ public init(
+ tableName: String,
+ dataSize: Int64? = nil,
+ indexSize: Int64? = nil,
+ totalSize: Int64? = nil,
+ avgRowLength: Int64? = nil,
+ rowCount: Int64? = nil,
+ comment: String? = nil,
+ engine: String? = nil,
+ collation: String? = nil,
+ createTime: Date? = nil,
+ updateTime: Date? = nil,
+ attributes: [PluginObjectAttribute],
+ commentIsReadOnly: Bool = true
+ ) {
+ self.tableName = tableName
+ self.dataSize = dataSize
+ self.indexSize = indexSize
+ self.totalSize = totalSize
+ self.avgRowLength = avgRowLength
+ self.rowCount = rowCount
+ self.comment = comment
+ self.engine = engine
+ self.collation = collation
+ self.createTime = createTime
+ self.updateTime = updateTime
+ self.attributes = attributes
+ self.commentIsReadOnly = commentIsReadOnly
+ }
+
+ /// Kept at its exact original signature. Adding `attributes:` to it would replace the mangled
+ /// symbol and break every plugin already built against it.
+ @_disfavoredOverload
public init(
tableName: String,
dataSize: Int64? = nil,
@@ -37,5 +84,26 @@ public struct PluginTableMetadata: Codable, Sendable {
self.collation = collation
self.createTime = createTime
self.updateTime = updateTime
+ self.attributes = []
+ self.commentIsReadOnly = true
+ }
+
+ /// Written out because Swift's synthesized `Decodable` does not fall back to an initializer's
+ /// default value: a payload encoded before `attributes` existed throws `keyNotFound` instead.
+ public init(from decoder: any Decoder) throws {
+ let container = try decoder.container(keyedBy: CodingKeys.self)
+ tableName = try container.decode(String.self, forKey: .tableName)
+ dataSize = try container.decodeIfPresent(Int64.self, forKey: .dataSize)
+ indexSize = try container.decodeIfPresent(Int64.self, forKey: .indexSize)
+ totalSize = try container.decodeIfPresent(Int64.self, forKey: .totalSize)
+ avgRowLength = try container.decodeIfPresent(Int64.self, forKey: .avgRowLength)
+ rowCount = try container.decodeIfPresent(Int64.self, forKey: .rowCount)
+ comment = try container.decodeIfPresent(String.self, forKey: .comment)
+ engine = try container.decodeIfPresent(String.self, forKey: .engine)
+ collation = try container.decodeIfPresent(String.self, forKey: .collation)
+ createTime = try container.decodeIfPresent(Date.self, forKey: .createTime)
+ updateTime = try container.decodeIfPresent(Date.self, forKey: .updateTime)
+ attributes = try container.decodeIfPresent([PluginObjectAttribute].self, forKey: .attributes) ?? []
+ commentIsReadOnly = try container.decodeIfPresent(Bool.self, forKey: .commentIsReadOnly) ?? true
}
}
diff --git a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift
index 25e83de1e..a6bf4bf22 100644
--- a/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift
+++ b/TablePro/Core/Compare/SchemaSyncScriptBuilder.swift
@@ -151,7 +151,7 @@ internal struct SchemaSyncScriptBuilder {
internal enum SchemaChangeOrdering {
internal static func sorted(_ changes: [SchemaChange]) -> [SchemaChange] {
- var buckets: [[SchemaChange]] = Array(repeating: [], count: 10)
+ var buckets: [[SchemaChange]] = Array(repeating: [], count: 11)
for change in changes {
buckets[bucket(for: change)].append(change)
}
@@ -170,6 +170,7 @@ internal enum SchemaChangeOrdering {
case .addIndex: return 7
case .addForeignKey: return 8
case .addCheckConstraint: return 9
+ case .modifyTableComment: return 10
}
}
}
diff --git a/TablePro/Core/Compare/SyncSafetyClassifier.swift b/TablePro/Core/Compare/SyncSafetyClassifier.swift
index 9456768f9..350bf1708 100644
--- a/TablePro/Core/Compare/SyncSafetyClassifier.swift
+++ b/TablePro/Core/Compare/SyncSafetyClassifier.swift
@@ -69,7 +69,8 @@ internal struct SyncSafetyClassifier {
constraint.name
)
)]
- case .deleteForeignKey, .addForeignKey, .modifyForeignKey, .addColumn, .addIndex, .modifyIndex:
+ case .deleteForeignKey, .addForeignKey, .modifyForeignKey, .addColumn, .addIndex, .modifyIndex,
+ .modifyTableComment:
return []
}
}
diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift
index df823305d..87bf10adb 100644
--- a/TablePro/Core/Plugins/PluginDriverAdapter.swift
+++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift
@@ -410,7 +410,9 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable, DatabaseRepor
engine: pluginMeta.engine,
collation: pluginMeta.collation,
createTime: pluginMeta.createTime,
- updateTime: pluginMeta.updateTime
+ updateTime: pluginMeta.updateTime,
+ attributes: pluginMeta.attributes.map(ObjectAttribute.init),
+ commentIsReadOnly: pluginMeta.commentIsReadOnly
)
}
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift
index fbb9ba7b5..5e9af0488 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CuratedDefaults.swift
@@ -241,6 +241,7 @@ extension PluginMetadataRegistry {
supportsRenameDatabase: false,
supportsRenameSchema: false,
supportsRenameColumn: true,
+ supportsTableComment: true,
supportsTriggers: true,
supportsTriggerEditing: true,
supportsCheckConstraints: true,
@@ -305,6 +306,7 @@ extension PluginMetadataRegistry {
supportsRenameDatabase: false,
supportsRenameSchema: false,
supportsRenameColumn: true,
+ supportsTableComment: true,
supportsTriggers: true,
supportsTriggerEditing: true,
supportsCheckConstraints: true,
@@ -371,6 +373,7 @@ extension PluginMetadataRegistry {
supportsRenameSchema: true,
supportsDropSchema: true,
supportsRenameColumn: true,
+ supportsTableComment: true,
supportsTriggers: true,
supportsTriggerEditing: true,
supportsCheckConstraints: true,
@@ -570,6 +573,7 @@ extension PluginMetadataRegistry {
supportsRenameSchema: true,
supportsDropSchema: true,
supportsRenameColumn: true,
+ supportsTableComment: true,
supportsTriggers: true,
supportsTriggerEditing: true,
supportsCheckConstraints: true,
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
index 1c3b991e8..0c9050a56 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+RegistryDefaults.swift
@@ -839,219 +839,6 @@ extension PluginMetadataRegistry {
tagline: String(localized: "Plain-text accounting ledgers")
)
)),
- ("Cassandra", PluginMetadataSnapshot(
- displayName: "Cassandra / ScyllaDB", iconName: "cassandra-icon", defaultPort: 9_042,
- requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true,
- isDownloadable: true, primaryUrlScheme: "cassandra", parameterStyle: .questionMark,
- navigationModel: .standard, explainVariants: [], pathFieldRole: .database,
- supportsHealthMonitor: true, urlSchemes: ["cassandra", "cql", "scylladb", "scylla"],
- postConnectActions: [],
- brandColorHex: "#26A0D8",
- queryLanguageName: "CQL", editorLanguage: .sql,
- connectionMode: .network, supportsDatabaseSwitching: true,
- supportsColumnReorder: false,
- capabilities: PluginMetadataSnapshot.CapabilityFlags(
- supportsSchemaSwitching: false,
- supportsImport: false,
- supportsExport: true,
- supportsSSH: true,
- supportsSSL: true,
- supportsCascadeDrop: false,
- supportsForeignKeyDisable: false,
- supportsReadOnlyMode: true,
- supportsQueryProgress: false,
- requiresReconnectForDatabaseSwitch: false,
- supportsDropDatabase: true,
- supportsModifyColumn: false,
- supportsAddIndex: false,
- supportsDropIndex: false,
- supportsModifyPrimaryKey: false,
- supportsOpportunisticTLS: false,
- supportsClientKeyPassphrase: true
- ),
- schema: PluginMetadataSnapshot.SchemaInfo(
- defaultSchemaName: "public",
- defaultGroupName: "default",
- tableEntityName: "Tables",
- containerEntityName: "Keyspace",
- defaultPrimaryKeyColumn: nil,
- immutableColumns: [],
- systemDatabaseNames: [
- "system", "system_schema", "system_auth",
- "system_distributed", "system_traces", "system_virtual_schema"
- ],
- systemSchemaNames: [],
- fileExtensions: [],
- databaseGroupingStrategy: .byDatabase,
- structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
- ),
- editor: PluginMetadataSnapshot.EditorConfig(
- sqlDialect: cassandraDialect,
- statementCompletions: [],
- columnTypesByCategory: cassandraColumnTypes
- ),
- connection: PluginMetadataSnapshot.ConnectionConfig(
- additionalConnectionFields: [
- ConnectionField(
- id: "sslCaCertPath",
- label: "CA Certificate",
- placeholder: "/path/to/ca-cert.pem",
- section: .advanced
- )
- ],
- category: .wideColumn,
- tagline: String(localized: "Distributed wide-column store")
- )
- )),
- ("ScyllaDB", PluginMetadataSnapshot(
- displayName: "ScyllaDB", iconName: "scylladb-icon", defaultPort: 9_042,
- requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true,
- isDownloadable: true, primaryUrlScheme: "scylladb", parameterStyle: .questionMark,
- navigationModel: .standard, explainVariants: [], pathFieldRole: .database,
- supportsHealthMonitor: true, urlSchemes: ["scylladb", "scylla"],
- postConnectActions: [],
- brandColorHex: "#6B2EE3",
- queryLanguageName: "CQL", editorLanguage: .sql,
- connectionMode: .network, supportsDatabaseSwitching: true,
- supportsColumnReorder: false,
- capabilities: PluginMetadataSnapshot.CapabilityFlags(
- supportsSchemaSwitching: false,
- supportsImport: false,
- supportsExport: true,
- supportsSSH: true,
- supportsSSL: true,
- supportsCascadeDrop: false,
- supportsForeignKeyDisable: false,
- supportsReadOnlyMode: true,
- supportsQueryProgress: false,
- requiresReconnectForDatabaseSwitch: false,
- supportsDropDatabase: true,
- supportsModifyColumn: false,
- supportsAddIndex: false,
- supportsDropIndex: false,
- supportsModifyPrimaryKey: false,
- supportsOpportunisticTLS: false,
- supportsClientKeyPassphrase: true
- ),
- schema: PluginMetadataSnapshot.SchemaInfo(
- defaultSchemaName: "public",
- defaultGroupName: "default",
- tableEntityName: "Tables",
- containerEntityName: "Keyspace",
- defaultPrimaryKeyColumn: nil,
- immutableColumns: [],
- systemDatabaseNames: [
- "system", "system_schema", "system_auth",
- "system_distributed", "system_traces", "system_virtual_schema"
- ],
- systemSchemaNames: [],
- fileExtensions: [],
- databaseGroupingStrategy: .byDatabase,
- structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
- ),
- editor: PluginMetadataSnapshot.EditorConfig(
- sqlDialect: cassandraDialect,
- statementCompletions: [],
- columnTypesByCategory: cassandraColumnTypes
- ),
- connection: PluginMetadataSnapshot.ConnectionConfig(
- additionalConnectionFields: [
- ConnectionField(
- id: "sslCaCertPath",
- label: "CA Certificate",
- placeholder: "/path/to/ca-cert.pem",
- section: .advanced
- )
- ],
- category: .wideColumn,
- tagline: String(localized: "C++ rewrite of Cassandra, faster")
- )
- )),
- ("etcd", PluginMetadataSnapshot(
- displayName: "etcd", iconName: "etcd-icon", defaultPort: 2_379,
- requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false,
- isDownloadable: true, primaryUrlScheme: "etcd", parameterStyle: .questionMark,
- navigationModel: .standard, explainVariants: [], pathFieldRole: .database,
- supportsHealthMonitor: true, urlSchemes: ["etcd", "etcds"], postConnectActions: [],
- brandColorHex: "#419EDA",
- queryLanguageName: "etcdctl", editorLanguage: .bash,
- connectionMode: .network, supportsDatabaseSwitching: false,
- supportsColumnReorder: false,
- capabilities: PluginMetadataSnapshot.CapabilityFlags(
- supportsSchemaSwitching: false,
- supportsImport: false,
- supportsExport: true,
- supportsSSH: true,
- supportsSSL: true,
- supportsCascadeDrop: false,
- supportsForeignKeyDisable: false,
- supportsReadOnlyMode: false,
- supportsQueryProgress: false,
- requiresReconnectForDatabaseSwitch: false,
- supportsDropDatabase: false,
- supportsOpportunisticTLS: false
- ),
- schema: PluginMetadataSnapshot.SchemaInfo(
- defaultSchemaName: "public",
- defaultGroupName: "main",
- tableEntityName: "Keys",
- containerEntityName: "Database",
- defaultPrimaryKeyColumn: "Key",
- immutableColumns: ["Version", "ModRevision", "CreateRevision"],
- systemDatabaseNames: [],
- systemSchemaNames: [],
- fileExtensions: [],
- databaseGroupingStrategy: .flat,
- structureColumnFields: [.name, .type, .nullable]
- ),
- editor: PluginMetadataSnapshot.EditorConfig(
- sqlDialect: nil,
- statementCompletions: etcdCompletions,
- columnTypesByCategory: ["String": ["string"]]
- ),
- connection: PluginMetadataSnapshot.ConnectionConfig(
- additionalConnectionFields: [
- ConnectionField(
- id: "etcdKeyPrefix",
- label: String(localized: "Key Prefix Root"),
- placeholder: "/",
- section: .advanced
- ),
- ConnectionField(
- id: "etcdTlsMode",
- label: String(localized: "TLS Mode"),
- fieldType: .dropdown(options: [
- .init(value: "Disabled", label: "Disabled"),
- .init(value: "Required", label: String(localized: "Required (skip verify)")),
- .init(value: "VerifyCA", label: String(localized: "Verify CA")),
- .init(value: "VerifyIdentity", label: String(localized: "Verify Identity")),
- ]),
- section: .advanced
- ),
- ConnectionField(
- id: "etcdCaCertPath",
- label: String(localized: "CA Certificate"),
- placeholder: "/path/to/ca.pem",
- section: .advanced
- ),
- ConnectionField(
- id: "etcdClientCertPath",
- label: String(localized: "Client Certificate"),
- placeholder: "/path/to/client.pem",
- section: .advanced
- ),
- ConnectionField(
- id: "etcdClientKeyPath",
- label: String(localized: "Client Key"),
- placeholder: "/path/to/client-key.pem",
- section: .advanced
- ),
- ],
- category: .coordination,
- tagline: String(localized: "Distributed key-value store for service discovery"),
- hidesBuiltInDatabase: true
- )
- )),
("Cloudflare D1", PluginMetadataSnapshot(
displayName: "Cloudflare D1", iconName: "cloudflare-d1-icon", defaultPort: 0,
requiresAuthentication: true, supportsForeignKeys: true, supportsSchemaEditing: false,
@@ -1201,7 +988,12 @@ extension PluginMetadataRegistry {
tagline: String(localized: "Distributed SQLite by Turso")
)
)),
- ] + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes)
+ ] + wideColumnPluginDefaults(
+ cassandraDialect: cassandraDialect,
+ cassandraColumnTypes: cassandraColumnTypes,
+ etcdCompletions: etcdCompletions
+ )
+ + tursoPluginDefaults(dialect: d1Dialect, columnTypes: d1ColumnTypes)
+ cloudPluginDefaults() + elasticsearchPluginDefaults() + surrealDBPluginDefaults()
+ kafkaPluginDefaults()
}
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+WideColumnDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+WideColumnDefaults.swift
new file mode 100644
index 000000000..c8c118643
--- /dev/null
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+WideColumnDefaults.swift
@@ -0,0 +1,235 @@
+//
+// PluginMetadataRegistry+WideColumnDefaults.swift
+// TablePro
+//
+
+import Foundation
+import TableProPluginKit
+
+/// Cassandra, ScyllaDB and etcd, split out of `registryPluginDefaults` so that file stays inside the
+/// 1200-line limit. Cassandra and ScyllaDB are one driver behind two type ids, and etcd ships in the
+/// same family of non-relational stores.
+extension PluginMetadataRegistry {
+ // swiftlint:disable:next function_body_length
+ func wideColumnPluginDefaults(
+ cassandraDialect: SQLDialectDescriptor,
+ cassandraColumnTypes: [String: [String]],
+ etcdCompletions: [CompletionEntry]
+ ) -> [(typeId: String, snapshot: PluginMetadataSnapshot)] {
+ [
+ ("Cassandra", PluginMetadataSnapshot(
+ displayName: "Cassandra / ScyllaDB", iconName: "cassandra-icon", defaultPort: 9_042,
+ requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true,
+ isDownloadable: true, primaryUrlScheme: "cassandra", parameterStyle: .questionMark,
+ navigationModel: .standard, explainVariants: [], pathFieldRole: .database,
+ supportsHealthMonitor: true, urlSchemes: ["cassandra", "cql", "scylladb", "scylla"],
+ postConnectActions: [],
+ brandColorHex: "#26A0D8",
+ queryLanguageName: "CQL", editorLanguage: .sql,
+ connectionMode: .network, supportsDatabaseSwitching: true,
+ supportsColumnReorder: false,
+ capabilities: PluginMetadataSnapshot.CapabilityFlags(
+ supportsSchemaSwitching: false,
+ supportsImport: false,
+ supportsExport: true,
+ supportsSSH: true,
+ supportsSSL: true,
+ supportsCascadeDrop: false,
+ supportsForeignKeyDisable: false,
+ supportsReadOnlyMode: true,
+ supportsQueryProgress: false,
+ requiresReconnectForDatabaseSwitch: false,
+ supportsDropDatabase: true,
+ supportsModifyColumn: false,
+ supportsAddIndex: false,
+ supportsDropIndex: false,
+ supportsModifyPrimaryKey: false,
+ supportsOpportunisticTLS: false,
+ supportsClientKeyPassphrase: true
+ ),
+ schema: PluginMetadataSnapshot.SchemaInfo(
+ defaultSchemaName: "public",
+ defaultGroupName: "default",
+ tableEntityName: "Tables",
+ containerEntityName: "Keyspace",
+ defaultPrimaryKeyColumn: nil,
+ immutableColumns: [],
+ systemDatabaseNames: [
+ "system", "system_schema", "system_auth",
+ "system_distributed", "system_traces", "system_virtual_schema"
+ ],
+ systemSchemaNames: [],
+ fileExtensions: [],
+ databaseGroupingStrategy: .byDatabase,
+ structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
+ ),
+ editor: PluginMetadataSnapshot.EditorConfig(
+ sqlDialect: cassandraDialect,
+ statementCompletions: [],
+ columnTypesByCategory: cassandraColumnTypes
+ ),
+ connection: PluginMetadataSnapshot.ConnectionConfig(
+ additionalConnectionFields: [
+ ConnectionField(
+ id: "sslCaCertPath",
+ label: "CA Certificate",
+ placeholder: "/path/to/ca-cert.pem",
+ section: .advanced
+ )
+ ],
+ category: .wideColumn,
+ tagline: String(localized: "Distributed wide-column store")
+ )
+ )),
+ ("ScyllaDB", PluginMetadataSnapshot(
+ displayName: "ScyllaDB", iconName: "scylladb-icon", defaultPort: 9_042,
+ requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: true,
+ isDownloadable: true, primaryUrlScheme: "scylladb", parameterStyle: .questionMark,
+ navigationModel: .standard, explainVariants: [], pathFieldRole: .database,
+ supportsHealthMonitor: true, urlSchemes: ["scylladb", "scylla"],
+ postConnectActions: [],
+ brandColorHex: "#6B2EE3",
+ queryLanguageName: "CQL", editorLanguage: .sql,
+ connectionMode: .network, supportsDatabaseSwitching: true,
+ supportsColumnReorder: false,
+ capabilities: PluginMetadataSnapshot.CapabilityFlags(
+ supportsSchemaSwitching: false,
+ supportsImport: false,
+ supportsExport: true,
+ supportsSSH: true,
+ supportsSSL: true,
+ supportsCascadeDrop: false,
+ supportsForeignKeyDisable: false,
+ supportsReadOnlyMode: true,
+ supportsQueryProgress: false,
+ requiresReconnectForDatabaseSwitch: false,
+ supportsDropDatabase: true,
+ supportsModifyColumn: false,
+ supportsAddIndex: false,
+ supportsDropIndex: false,
+ supportsModifyPrimaryKey: false,
+ supportsOpportunisticTLS: false,
+ supportsClientKeyPassphrase: true
+ ),
+ schema: PluginMetadataSnapshot.SchemaInfo(
+ defaultSchemaName: "public",
+ defaultGroupName: "default",
+ tableEntityName: "Tables",
+ containerEntityName: "Keyspace",
+ defaultPrimaryKeyColumn: nil,
+ immutableColumns: [],
+ systemDatabaseNames: [
+ "system", "system_schema", "system_auth",
+ "system_distributed", "system_traces", "system_virtual_schema"
+ ],
+ systemSchemaNames: [],
+ fileExtensions: [],
+ databaseGroupingStrategy: .byDatabase,
+ structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment]
+ ),
+ editor: PluginMetadataSnapshot.EditorConfig(
+ sqlDialect: cassandraDialect,
+ statementCompletions: [],
+ columnTypesByCategory: cassandraColumnTypes
+ ),
+ connection: PluginMetadataSnapshot.ConnectionConfig(
+ additionalConnectionFields: [
+ ConnectionField(
+ id: "sslCaCertPath",
+ label: "CA Certificate",
+ placeholder: "/path/to/ca-cert.pem",
+ section: .advanced
+ )
+ ],
+ category: .wideColumn,
+ tagline: String(localized: "C++ rewrite of Cassandra, faster")
+ )
+ )),
+ ("etcd", PluginMetadataSnapshot(
+ displayName: "etcd", iconName: "etcd-icon", defaultPort: 2_379,
+ requiresAuthentication: false, supportsForeignKeys: false, supportsSchemaEditing: false,
+ isDownloadable: true, primaryUrlScheme: "etcd", parameterStyle: .questionMark,
+ navigationModel: .standard, explainVariants: [], pathFieldRole: .database,
+ supportsHealthMonitor: true, urlSchemes: ["etcd", "etcds"], postConnectActions: [],
+ brandColorHex: "#419EDA",
+ queryLanguageName: "etcdctl", editorLanguage: .bash,
+ connectionMode: .network, supportsDatabaseSwitching: false,
+ supportsColumnReorder: false,
+ capabilities: PluginMetadataSnapshot.CapabilityFlags(
+ supportsSchemaSwitching: false,
+ supportsImport: false,
+ supportsExport: true,
+ supportsSSH: true,
+ supportsSSL: true,
+ supportsCascadeDrop: false,
+ supportsForeignKeyDisable: false,
+ supportsReadOnlyMode: false,
+ supportsQueryProgress: false,
+ requiresReconnectForDatabaseSwitch: false,
+ supportsDropDatabase: false,
+ supportsOpportunisticTLS: false
+ ),
+ schema: PluginMetadataSnapshot.SchemaInfo(
+ defaultSchemaName: "public",
+ defaultGroupName: "main",
+ tableEntityName: "Keys",
+ containerEntityName: "Database",
+ defaultPrimaryKeyColumn: "Key",
+ immutableColumns: ["Version", "ModRevision", "CreateRevision"],
+ systemDatabaseNames: [],
+ systemSchemaNames: [],
+ fileExtensions: [],
+ databaseGroupingStrategy: .flat,
+ structureColumnFields: [.name, .type, .nullable]
+ ),
+ editor: PluginMetadataSnapshot.EditorConfig(
+ sqlDialect: nil,
+ statementCompletions: etcdCompletions,
+ columnTypesByCategory: ["String": ["string"]]
+ ),
+ connection: PluginMetadataSnapshot.ConnectionConfig(
+ additionalConnectionFields: [
+ ConnectionField(
+ id: "etcdKeyPrefix",
+ label: String(localized: "Key Prefix Root"),
+ placeholder: "/",
+ section: .advanced
+ ),
+ ConnectionField(
+ id: "etcdTlsMode",
+ label: String(localized: "TLS Mode"),
+ fieldType: .dropdown(options: [
+ .init(value: "Disabled", label: "Disabled"),
+ .init(value: "Required", label: String(localized: "Required (skip verify)")),
+ .init(value: "VerifyCA", label: String(localized: "Verify CA")),
+ .init(value: "VerifyIdentity", label: String(localized: "Verify Identity")),
+ ]),
+ section: .advanced
+ ),
+ ConnectionField(
+ id: "etcdCaCertPath",
+ label: String(localized: "CA Certificate"),
+ placeholder: "/path/to/ca.pem",
+ section: .advanced
+ ),
+ ConnectionField(
+ id: "etcdClientCertPath",
+ label: String(localized: "Client Certificate"),
+ placeholder: "/path/to/client.pem",
+ section: .advanced
+ ),
+ ConnectionField(
+ id: "etcdClientKeyPath",
+ label: String(localized: "Client Key"),
+ placeholder: "/path/to/client-key.pem",
+ section: .advanced
+ ),
+ ],
+ category: .coordination,
+ tagline: String(localized: "Distributed key-value store for service discovery"),
+ hidesBuiltInDatabase: true
+ )
+ )),
+ ]
+ }
+}
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
index 01f6f10ab..14a173597 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
@@ -60,6 +60,7 @@ struct PluginMetadataSnapshot: Sendable {
var supportsAddIndex: Bool = true
var supportsDropIndex: Bool = true
var supportsModifyPrimaryKey: Bool = true
+ var supportsTableComment: Bool = false
var supportsTriggers: Bool = false
var supportsTriggerEditing: Bool = false
var supportsCheckConstraints: Bool = false
@@ -581,6 +582,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsAddIndex: driverType.supportsAddIndex,
supportsDropIndex: driverType.supportsDropIndex,
supportsModifyPrimaryKey: driverType.supportsModifyPrimaryKey,
+ supportsTableComment: driverType.supportsTableComment,
supportsTriggers: driverType.supportsTriggers,
supportsTriggerEditing: driverType.supportsTriggerEditing,
supportsCheckConstraints: driverType.supportsCheckConstraints,
diff --git a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift
index 3c2528d2f..0349187f2 100644
--- a/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift
+++ b/TablePro/Core/SchemaTracking/SchemaStatementGenerator.swift
@@ -87,6 +87,7 @@ struct SchemaStatementGenerator {
var indexAdds: [SchemaChange] = []
var fkAdds: [SchemaChange] = []
var constraintAdds: [SchemaChange] = []
+ var commentChanges: [SchemaChange] = []
for change in changes {
switch change {
@@ -121,11 +122,14 @@ struct SchemaStatementGenerator {
indexAdds.append(change)
case .addForeignKey:
fkAdds.append(change)
+ case .modifyTableComment:
+ commentChanges.append(change)
}
}
return constraintDeletes + constraintModifies + fkDeletes + indexDeletes + columnDeletes
+ columnModifies + columnAdds + pkChanges + indexAdds + fkAdds + constraintAdds
+ + commentChanges
}
// MARK: - Statement Generation
@@ -158,9 +162,20 @@ struct SchemaStatementGenerator {
return generateModifyCheckConstraint(old: old, new: new)
case .deleteCheckConstraint(let constraint):
return generateDeleteCheckConstraint(constraint).map { [$0] } ?? []
+ case .modifyTableComment(_, let new):
+ return generateModifyTableComment(new).map { [$0] } ?? []
}
}
+ // MARK: - Table Comment
+
+ private func generateModifyTableComment(_ comment: String?) -> SchemaStatement? {
+ guard let sql = pluginDriver.generateSetTableCommentSQL(table: tableName, comment: comment) else {
+ return nil
+ }
+ return SchemaStatement(sql: sql, description: "Change table comment", isDestructive: false)
+ }
+
// MARK: - Column Operations
private func generateAddColumn(_ column: EditableColumnDefinition) -> SchemaStatement? {
diff --git a/TablePro/Core/SchemaTracking/StructureChangeManager.swift b/TablePro/Core/SchemaTracking/StructureChangeManager.swift
index a64f38ba0..b72e33815 100644
--- a/TablePro/Core/SchemaTracking/StructureChangeManager.swift
+++ b/TablePro/Core/SchemaTracking/StructureChangeManager.swift
@@ -26,12 +26,18 @@ final class StructureChangeManager: ChangeManaging {
private(set) var currentCheckConstraints: [EditableCheckConstraintDefinition] = []
private(set) var currentPrimaryKey: [String] = []
+ /// Normalised to the empty string, never nil. A table with no comment and a table whose comment
+ /// is empty are the same table on every engine that has one, and keeping both spellings would
+ /// stage a change the moment the field is focused and left alone.
+ private(set) var currentTableComment: String = ""
+
// Working state (includes uncommitted changes + placeholders)
var workingColumns: [EditableColumnDefinition] = []
var workingIndexes: [EditableIndexDefinition] = []
var workingForeignKeys: [EditableForeignKeyDefinition] = []
var workingCheckConstraints: [EditableCheckConstraintDefinition] = []
var workingPrimaryKey: [String] = []
+ private(set) var workingTableComment: String = ""
var tableName: String?
@@ -141,6 +147,59 @@ final class StructureChangeManager: ChangeManaging {
workingForeignKeys = currentForeignKeys
workingCheckConstraints = currentCheckConstraints
workingPrimaryKey = currentPrimaryKey
+ workingTableComment = currentTableComment
+ }
+
+ /// Adopted separately from `loadSchema` because the table's comment arrives from a different
+ /// query than its columns. Folding it into `loadSchema` would mean re-baselining the whole
+ /// editor when the metadata lands, which discards every staged ALTER.
+ ///
+ /// What the user typed is left alone: they are mid-edit, and a refresh that means to replace it
+ /// goes through `discardChanges` first. The staged change is re-cut against the new baseline
+ /// even so, because it carries the value the server is being asked to move away from, and a
+ /// baseline that has caught up with the edit means there is nothing left to write.
+ func setTableCommentBaseline(_ comment: String?) {
+ currentTableComment = comment ?? ""
+ guard pendingChanges[.tableComment] != nil else {
+ workingTableComment = currentTableComment
+ return
+ }
+ restageTableComment()
+ }
+
+ /// One undo step per editing episode, not per keystroke. The comment field writes on every
+ /// character, and a per-character entry would fill all 100 levels of undo from one paragraph and
+ /// evict the column edits staged beside it. The text view keeps its own undo while it has focus.
+ ///
+ /// Replaying an undo or a redo always registers, and registers the value being replaced rather
+ /// than the baseline. Coalescing there instead would skip the registration that becomes the
+ /// redo, because a staged change is exactly what an undo is undoing, and Cmd+Shift+Z would do
+ /// nothing after a comment edit.
+ func setTableComment(_ comment: String) {
+ guard comment != workingTableComment else { return }
+ let isReplaying = undoManager.isUndoing || undoManager.isRedoing
+ if isReplaying || pendingChanges[.tableComment] == nil {
+ let inverse = isReplaying ? workingTableComment : currentTableComment
+ registerUndo(String(localized: "Change Table Comment")) { target in
+ target.setTableComment(inverse)
+ }
+ }
+ workingTableComment = comment
+ restageTableComment()
+ }
+
+ private func restageTableComment() {
+ if workingTableComment == currentTableComment {
+ pendingChanges.removeValue(forKey: .tableComment)
+ untrackChangeKey(.tableComment)
+ } else {
+ pendingChanges[.tableComment] = .modifyTableComment(
+ old: currentTableComment.isEmpty ? nil : currentTableComment,
+ new: workingTableComment.isEmpty ? nil : workingTableComment
+ )
+ trackChangeKey(.tableComment)
+ }
+ validate()
}
private func trackChangeKey(_ key: SchemaChangeIdentifier) {
@@ -397,7 +456,7 @@ final class StructureChangeManager: ChangeManaging {
case .checkConstraints:
guard row < workingCheckConstraints.count else { return }
key = .checkConstraint(workingCheckConstraints[row].id)
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return
}
guard pendingChanges[key]?.isDelete == true else { return }
@@ -663,7 +722,7 @@ final class StructureChangeManager: ChangeManaging {
return rowState(at: row, using: Self.foreignKeyOperations)
case .checkConstraints:
return rowState(at: row, using: Self.checkConstraintOperations)
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return (false, false)
}
}
diff --git a/TablePro/Models/Connection/DatabaseConnection.swift b/TablePro/Models/Connection/DatabaseConnection.swift
index 92ecf4a7b..91e92608a 100644
--- a/TablePro/Models/Connection/DatabaseConnection.swift
+++ b/TablePro/Models/Connection/DatabaseConnection.swift
@@ -275,6 +275,10 @@ extension DatabaseType {
var supportsModifyPrimaryKey: Bool {
PluginMetadataRegistry.shared.snapshot(for: self)?.capabilities.supportsModifyPrimaryKey ?? true
}
+
+ var supportsTableComment: Bool {
+ PluginMetadataRegistry.shared.snapshot(for: self)?.capabilities.supportsTableComment ?? false
+ }
}
// MARK: - External Access
diff --git a/TablePro/Models/Database/TableMetadata.swift b/TablePro/Models/Database/TableMetadata.swift
index 288defb5d..706a49d83 100644
--- a/TablePro/Models/Database/TableMetadata.swift
+++ b/TablePro/Models/Database/TableMetadata.swift
@@ -8,7 +8,7 @@
import Foundation
/// Represents table-level metadata fetched from database
-struct TableMetadata {
+struct TableMetadata: Sendable {
let tableName: String
let dataSize: Int64?
let indexSize: Int64?
@@ -21,6 +21,14 @@ struct TableMetadata {
let createTime: Date?
let updateTime: Date?
+ /// Whatever else the driver wants to name: owner, tablespace, persistence, row format. The app
+ /// models none of it, so a new engine adds a property without a change here.
+ var attributes: [ObjectAttribute] = []
+
+ /// The driver's answer for this relation, not for the engine, and true unless a driver lowers
+ /// it: a driver that cannot establish the relation kind offers no edit at all.
+ var commentIsReadOnly: Bool = true
+
/// Format a size in bytes to human readable format
static func formatSize(_ bytes: Int64?) -> String {
guard let bytes = bytes else { return "—" }
diff --git a/TablePro/Models/Schema/SchemaChange.swift b/TablePro/Models/Schema/SchemaChange.swift
index 37d482b68..5e2207e6e 100644
--- a/TablePro/Models/Schema/SchemaChange.swift
+++ b/TablePro/Models/Schema/SchemaChange.swift
@@ -28,6 +28,8 @@ enum SchemaChange: Hashable, Equatable {
case modifyPrimaryKey(old: [String], new: [String])
+ case modifyTableComment(old: String?, new: String?)
+
/// Whether this change is a deletion
var isDelete: Bool {
switch self {
@@ -99,6 +101,8 @@ enum SchemaChange: Hashable, Equatable {
return "Delete check constraint '\(constraint.name)'"
case .modifyPrimaryKey(let old, let new):
return "Change primary key from [\(old.joined(separator: ", "))] to [\(new.joined(separator: ", "))]"
+ case .modifyTableComment:
+ return "Change table comment"
}
}
}
@@ -110,4 +114,5 @@ enum SchemaChangeIdentifier: Hashable {
case foreignKey(UUID)
case checkConstraint(UUID)
case primaryKey
+ case tableComment
}
diff --git a/TablePro/Models/Schema/StructureTab.swift b/TablePro/Models/Schema/StructureTab.swift
index 53aba1a11..331532903 100644
--- a/TablePro/Models/Schema/StructureTab.swift
+++ b/TablePro/Models/Schema/StructureTab.swift
@@ -9,6 +9,7 @@ import Foundation
/// Tab selection for structure view
enum StructureTab: String, CaseIterable, Hashable {
+ case properties
case columns
case indexes
case foreignKeys
@@ -19,6 +20,7 @@ enum StructureTab: String, CaseIterable, Hashable {
var displayName: String {
switch self {
+ case .properties: String(localized: "Properties")
case .columns: String(localized: "Columns")
case .indexes: String(localized: "Indexes")
case .foreignKeys: String(localized: "Foreign Keys")
diff --git a/TablePro/Views/Main/Child/MainEditorContentView.swift b/TablePro/Views/Main/Child/MainEditorContentView.swift
index 62bad06d3..74ee8719c 100644
--- a/TablePro/Views/Main/Child/MainEditorContentView.swift
+++ b/TablePro/Views/Main/Child/MainEditorContentView.swift
@@ -664,7 +664,8 @@ struct MainEditorContentView: View {
connection: connection,
databaseName: scope?.database ?? "",
schemaName: scope?.schema,
- tableName: tableName
+ tableName: tableName,
+ isView: tab.tableContext.isView
)
}
}
diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift
index 80c629424..ab725a4e6 100644
--- a/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift
+++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+SchemaLoading.swift
@@ -140,6 +140,36 @@ extension MainContentCoordinator {
return entry.tableName == tableName && entry.lastExecutedAt == tab.execution.lastExecutedAt
}
+ /// Replaces what the Table Info panel holds for a table whose metadata changed without a query.
+ ///
+ /// `hasCurrentTableMetadata` stamps its cache with the tab's last execution, which is the right
+ /// test while only a query can move the numbers. Editing a table's comment moves one of them
+ /// without executing anything, so the panel would go on showing the comment the save replaced.
+ /// The fresh value is written in rather than the stale one dropped, because dropping it leaves
+ /// the panel empty until the tab is executed again.
+ ///
+ /// Matched on the whole scope, never on the bare name. The cache is keyed by tab and two tabs
+ /// can hold an `orders` in different schemas or different databases, so saving one would
+ /// otherwise write its owner, sizes, row count and comment over the other, under a stamp that
+ /// still reads as current.
+ func adoptTableMetadata(_ metadata: TableMetadata, in scope: DatabaseScope) {
+ for tab in tabManager.tabs {
+ guard tab.tableContext.tableName == metadata.tableName,
+ self.scope(for: tab) == scope,
+ let entry = tableMetadataCache[tab.id],
+ entry.tableName == metadata.tableName
+ else { continue }
+ tableMetadataCache[tab.id] = TableMetadataCacheEntry(
+ tableName: entry.tableName,
+ lastExecutedAt: entry.lastExecutedAt,
+ metadata: metadata
+ )
+ if tabManager.selectedTabId == tab.id {
+ tableMetadata = metadata
+ }
+ }
+ }
+
func loadTableMetadata(tableName: String, for tab: QueryTab) async {
if let entry = tableMetadataCache[tab.id],
entry.tableName == tableName,
diff --git a/TablePro/Views/Structure/StructureEditingSession.swift b/TablePro/Views/Structure/StructureEditingSession.swift
index f8919440c..9d1e935e0 100644
--- a/TablePro/Views/Structure/StructureEditingSession.swift
+++ b/TablePro/Views/Structure/StructureEditingSession.swift
@@ -43,6 +43,11 @@ internal final class StructureEditingSession {
internal let schemaName: String?
internal let tableName: String
+ /// Whether this tab was opened on a view rather than a table. The Properties tab reads it to
+ /// keep the comment read-only: clearing or setting one on a view is `COMMENT ON VIEW`, and the
+ /// tab carries no object type finer than this flag to tell a view from a materialized one.
+ internal let isView: Bool
+
internal let changeManager = StructureChangeManager()
/// Built here, not seeded into the view's `@State`. `State(wrappedValue:)` runs only the first
@@ -63,6 +68,11 @@ internal final class StructureEditingSession {
internal var checkConstraints: [CheckConstraintInfo] = []
internal var triggers: [TriggerInfo] = []
internal var ddlStatement: String = ""
+ internal var tableMetadata: TableMetadata?
+
+ /// Held apart from the view's `errorMessage`, which replaces every sub-tab. A catalog the
+ /// properties query cannot read stops that one tab, not the whole structure editor.
+ internal var tableMetadataError: String?
internal var tabData = StructureTabDataState()
/// Where the user was. Held here rather than in the view because two tabs on one table are two
@@ -104,13 +114,15 @@ internal final class StructureEditingSession {
connection: DatabaseConnection,
databaseName: String,
schemaName: String?,
- tableName: String
+ tableName: String,
+ isView: Bool = false
) {
self.identity = identity
self.connection = connection
self.databaseName = databaseName
self.schemaName = schemaName
self.tableName = tableName
+ self.isView = isView
gridDelegate = StructureGridDelegate(
structureChangeManager: changeManager,
selectedTab: .columns,
diff --git a/TablePro/Views/Structure/StructureGridDelegate.swift b/TablePro/Views/Structure/StructureGridDelegate.swift
index 1d9539faf..0551d6acd 100644
--- a/TablePro/Views/Structure/StructureGridDelegate.swift
+++ b/TablePro/Views/Structure/StructureGridDelegate.swift
@@ -101,7 +101,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
StructureEditingSupport.updateCheckConstraint(&constraint, at: column, with: newValue ?? "")
structureChangeManager.updateCheckConstraint(id: constraint.id, with: constraint)
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
break
}
@@ -170,7 +170,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
structureChangeManager.deleteCheckConstraint(id: constraint.id)
}
}
- case .parts, .ddl, .triggers:
+ case .properties, .parts, .ddl, .triggers:
onSelectedRowsChanged?([])
return
}
@@ -222,7 +222,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
guard row < structureChangeManager.workingCheckConstraints.count else { continue }
copiedItems.append(structureChangeManager.workingCheckConstraints[row])
}
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
break
}
@@ -315,7 +315,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
structureChangeManager.addCheckConstraint(item.withNewIdentity())
}
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
break
}
}
@@ -350,7 +350,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
case .checkConstraints:
guard connection.type.supportsCheckConstraintEditing else { return }
structureChangeManager.addNewCheckConstraint()
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
break
}
}
@@ -404,7 +404,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
guard let original = structureChangeManager.currentCheckConstraints
.first(where: { $0.id == working.id }) else { return [] }
return StructureEditingSupport.checkConstraintModifiedIndices(old: original, new: working)
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return []
}
}
@@ -498,7 +498,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
case .checkConstraints:
guard connection.type.supportsCheckConstraintEditing else { return nil }
label = String(localized: "Add Check Constraint")
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return nil
}
@@ -551,7 +551,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
let constraint = structureChangeManager.workingCheckConstraints[row]
let quoted = driver.quoteIdentifier(constraint.name)
definitions.append("CONSTRAINT \(quoted) CHECK (\(constraint.expression))")
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
break
}
}
@@ -640,7 +640,7 @@ final class StructureGridDelegate: DataGridViewDelegate {
row < structureChangeManager.workingCheckConstraints.count else { continue }
let copy = structureChangeManager.workingCheckConstraints[row]
structureChangeManager.addCheckConstraint(copy.withNewIdentity())
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
break
}
}
diff --git a/TablePro/Views/Structure/StructureInspectorRowBuilder.swift b/TablePro/Views/Structure/StructureInspectorRowBuilder.swift
index 77fd82254..e9f9fb590 100644
--- a/TablePro/Views/Structure/StructureInspectorRowBuilder.swift
+++ b/TablePro/Views/Structure/StructureInspectorRowBuilder.swift
@@ -20,7 +20,7 @@ internal enum StructureInspectorRowBuilder {
switch tab {
case .columns, .indexes, .foreignKeys, .checkConstraints:
break
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return nil
}
diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift
index e898c22e3..4092e1432 100644
--- a/TablePro/Views/Structure/StructureRowProvider.swift
+++ b/TablePro/Views/Structure/StructureRowProvider.swift
@@ -72,7 +72,7 @@ final class StructureRowProvider {
String(localized: "Expression"),
String(localized: "Columns")
]
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return []
}
}
@@ -94,7 +94,7 @@ final class StructureRowProvider {
return [3]
case .foreignKeys, .checkConstraints:
return []
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return []
}
}
@@ -120,7 +120,7 @@ final class StructureRowProvider {
result[index] = Self.generationOptions
}
return result
- case .checkConstraints, .ddl, .parts, .triggers:
+ case .properties, .checkConstraints, .ddl, .parts, .triggers:
return [:]
}
}
@@ -138,7 +138,7 @@ final class StructureRowProvider {
case .columns:
if let i = orderedColumnFields.firstIndex(of: .type) { return [i] }
return []
- case .indexes, .foreignKeys, .checkConstraints, .ddl, .parts, .triggers:
+ case .properties, .indexes, .foreignKeys, .checkConstraints, .ddl, .parts, .triggers:
return []
}
}
@@ -234,7 +234,7 @@ final class StructureRowProvider {
return nil
}
return Self.row(for: original)
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return nil
}
}
@@ -268,7 +268,7 @@ final class StructureRowProvider {
return changeManager.workingCheckConstraints.enumerated().map { index, constraint in
IndexedRow(sourceIndex: index, row: row(for: constraint))
}
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return []
}
}
diff --git a/TablePro/Views/Structure/TablePropertiesView.swift b/TablePro/Views/Structure/TablePropertiesView.swift
new file mode 100644
index 000000000..4cfee3752
--- /dev/null
+++ b/TablePro/Views/Structure/TablePropertiesView.swift
@@ -0,0 +1,166 @@
+//
+// TablePropertiesView.swift
+// TablePro
+//
+
+import SwiftUI
+
+/// The table's own identity, its driver-supplied properties, and its comment.
+///
+/// The comment is the only editable field, and it stages into the same `StructureChangeManager` the
+/// Columns grid writes to, so it saves, discards and undoes through the controls the rest of the
+/// Structure tab already uses.
+struct TablePropertiesView: View {
+ let tableName: String
+ let schemaName: String?
+ let databaseName: String
+ let metadata: TableMetadata?
+ let loadError: String?
+ let isLoading: Bool
+ let isView: Bool
+ let isCommentEditable: Bool
+ let comment: String
+ let onCommentChange: (String) -> Void
+
+ private var themeEngine: ThemeEngine { ThemeEngine.shared }
+
+ var body: some View {
+ if let loadError, metadata == nil {
+ ContentUnavailableView(
+ String(localized: "Properties Unavailable"),
+ systemImage: "exclamationmark.triangle",
+ description: Text(loadError)
+ )
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else if isLoading, metadata == nil {
+ ProgressView()
+ .controlSize(.small)
+ .frame(maxWidth: .infinity, maxHeight: .infinity)
+ } else {
+ form
+ }
+ }
+
+ private var form: some View {
+ Form {
+ Section {
+ LabeledContent(String(localized: "Name"), value: tableName)
+ if let schemaName, !schemaName.isEmpty {
+ LabeledContent(String(localized: "Schema"), value: schemaName)
+ }
+ if !databaseName.isEmpty {
+ LabeledContent(String(localized: "Database"), value: databaseName)
+ }
+ ForEach(metadata?.attributes ?? []) { attribute in
+ LabeledContent(attribute.label, value: attribute.value)
+ }
+ } header: {
+ Text("GENERAL")
+ }
+
+ commentSection
+
+ if let metadata {
+ statisticsSection(metadata)
+ storageSection(metadata)
+ timestampsSection(metadata)
+ }
+ }
+ .formStyle(.grouped)
+ .textSelection(.enabled)
+ }
+
+ /// A comment the engine cannot store, and an empty one, are the same absence, so the read-only
+ /// case falls back to a line of text rather than an empty box nothing can ever fill.
+ @ViewBuilder
+ private var commentSection: some View {
+ Section {
+ if !isCommentEditable, comment.isEmpty {
+ Text(unavailableCommentMessage)
+ .foregroundStyle(.secondary)
+ } else {
+ TextValueEditor(
+ text: Binding(get: { comment }, set: onCommentChange),
+ isEditable: isCommentEditable,
+ font: themeEngine.valueFont,
+ borderType: .bezelBorder
+ )
+ .frame(minHeight: 140)
+ .accessibilityIdentifier("table-comment-editor")
+ .accessibilityLabel(Text("Table comment"))
+ }
+ } header: {
+ Text("COMMENT")
+ }
+ }
+
+ /// Two different absences. The engine has no comment to store at all, or it has one for tables
+ /// and spells this relation's with a keyword the structure editor cannot ask for.
+ private var unavailableCommentMessage: String {
+ isView || metadata?.commentIsReadOnly == true
+ ? String(localized: "This object's comment is read-only. Run COMMENT ON in the editor to change it.")
+ : String(localized: "This database does not store a comment on a table.")
+ }
+
+ @ViewBuilder
+ private func statisticsSection(_ metadata: TableMetadata) -> some View {
+ if metadata.rowCount != nil || metadata.avgRowLength != nil {
+ Section {
+ if let rows = metadata.rowCount {
+ LabeledContent(String(localized: "Rows"), value: rows.formatted())
+ }
+ if let avgLength = metadata.avgRowLength {
+ LabeledContent(
+ String(localized: "Avg Row"),
+ value: TableMetadata.formatSize(avgLength))
+ }
+ } header: {
+ Text("STATISTICS")
+ }
+ }
+ }
+
+ @ViewBuilder
+ private func storageSection(_ metadata: TableMetadata) -> some View {
+ Section {
+ LabeledContent(
+ String(localized: "Data Size"),
+ value: TableMetadata.formatSize(metadata.dataSize))
+ LabeledContent(
+ String(localized: "Index Size"),
+ value: TableMetadata.formatSize(metadata.indexSize))
+ LabeledContent(
+ String(localized: "Total Size"),
+ value: TableMetadata.formatSize(metadata.totalSize))
+ if let engine = metadata.engine {
+ LabeledContent(String(localized: "Engine"), value: engine)
+ }
+ if let collation = metadata.collation {
+ LabeledContent(String(localized: "Collation"), value: collation)
+ .help(collation)
+ }
+ } header: {
+ Text("STORAGE")
+ }
+ }
+
+ @ViewBuilder
+ private func timestampsSection(_ metadata: TableMetadata) -> some View {
+ if metadata.createTime != nil || metadata.updateTime != nil {
+ Section {
+ if let created = metadata.createTime {
+ LabeledContent(
+ String(localized: "Created"),
+ value: created.formatted(date: .numeric, time: .shortened))
+ }
+ if let updated = metadata.updateTime {
+ LabeledContent(
+ String(localized: "Updated"),
+ value: updated.formatted(date: .numeric, time: .shortened))
+ }
+ } header: {
+ Text("TIMESTAMPS")
+ }
+ }
+ }
+}
diff --git a/TablePro/Views/Structure/TableStructureLoader.swift b/TablePro/Views/Structure/TableStructureLoader.swift
index bb4469f9d..ddd9d8cf1 100644
--- a/TablePro/Views/Structure/TableStructureLoader.swift
+++ b/TablePro/Views/Structure/TableStructureLoader.swift
@@ -59,6 +59,11 @@ struct TableStructureLoader {
return try await perform { try await $0.fetchTriggers(table: table) }
}
+ func metadata() async throws -> TableMetadata {
+ let table = tableName
+ return try await perform { try await $0.fetchTableMetadata(tableName: table) }
+ }
+
func coreTabs(includingForeignKeys: Bool) async throws -> CoreTabs {
let table = tableName
return try await perform { driver in
diff --git a/TablePro/Views/Structure/TableStructureView+DataLoading.swift b/TablePro/Views/Structure/TableStructureView+DataLoading.swift
index 8116aab9f..c28760788 100644
--- a/TablePro/Views/Structure/TableStructureView+DataLoading.swift
+++ b/TablePro/Views/Structure/TableStructureView+DataLoading.swift
@@ -59,6 +59,25 @@ extension TableStructureView {
func fetchTabData(_ tab: StructureTab) async {
do {
switch tab {
+ /// Kept off `errorMessage`, which replaces the whole structure editor: a catalog this
+ /// one query cannot read must not take Columns, Indexes and DDL down with it.
+ ///
+ /// A failure keeps the last good snapshot and returns without marking the tab fetched,
+ /// so the next visit tries again instead of leaving a table that was reading correctly
+ /// stuck on an error, which is the rule the rest of the app's caches already follow.
+ case .properties:
+ do {
+ let metadata = try await structureLoader.metadata()
+ session.tableMetadata = metadata
+ session.tableMetadataError = nil
+ structureChangeManager.setTableCommentBaseline(metadata.comment)
+ } catch {
+ Self.logger.error(
+ "Failed to load table properties: \(error.localizedDescription, privacy: .public)"
+ )
+ session.tableMetadataError = error.localizedDescription
+ return
+ }
case .columns:
columns = try await structureLoader.columns()
case .indexes:
@@ -189,6 +208,12 @@ extension TableStructureView {
tabData.markAllStale()
partsReloadToken += 1
await reloadCoreTabs()
+ /// Only for a tab that has already paid for it. Several drivers answer `fetchTableMetadata`
+ /// with a full scan, so refreshing Properties for a table nobody opened it on would put that
+ /// scan on every Cmd+R.
+ if tabData.hasData(.properties) {
+ await fetchTabData(.properties)
+ }
if selectedTab == .ddl {
await fetchTabData(.ddl)
}
diff --git a/TablePro/Views/Structure/TableStructureView+Schema.swift b/TablePro/Views/Structure/TableStructureView+Schema.swift
index 74704b3c3..bbffaa98a 100644
--- a/TablePro/Views/Structure/TableStructureView+Schema.swift
+++ b/TablePro/Views/Structure/TableStructureView+Schema.swift
@@ -56,6 +56,19 @@ extension TableStructureView {
func refreshAfterApply() async {
isReloadingAfterSave = true
await reloadCoreTabs()
+ /// Ahead of `loadSchemaForEditing`, and unconditional: a comment change can be saved from
+ /// any sub-tab, and the baseline it was staged against is stale the moment it lands. Leaving
+ /// it to the next visit would show the old comment back in the field until then.
+ /// Only where Properties was opened, which is the only way a comment can have been staged.
+ /// The refetched value is handed to the inspector too: a comment moves without a query, and
+ /// the Table Info panel's cache is stamped with the tab's last execution, so nothing else
+ /// would ever tell it what the save changed.
+ if tabData.hasData(.properties) {
+ await fetchTabData(.properties)
+ if let metadata = session.tableMetadata {
+ coordinator?.adoptTableMetadata(metadata, in: scope)
+ }
+ }
loadSchemaForEditing()
await loadTabDataIfNeeded(selectedTab)
diff --git a/TablePro/Views/Structure/TableStructureView.swift b/TablePro/Views/Structure/TableStructureView.swift
index 20bdb121e..cb5ba77e8 100644
--- a/TablePro/Views/Structure/TableStructureView.swift
+++ b/TablePro/Views/Structure/TableStructureView.swift
@@ -257,6 +257,19 @@ struct TableStructureView: View {
// MARK: - Toolbar
+ /// A view's comment is `COMMENT ON VIEW` on the engines that have one, and a structure tab knows
+ /// only that its object is a view, not whether it is a materialized one, so views stay read-only
+ /// rather than being offered an edit that would run the wrong statement.
+ private var isTableCommentEditable: Bool {
+ guard connection.type.supportsSchemaEditing, connection.type.supportsTableComment else { return false }
+ /// Properties can be selected while the opening fetch is still working through the other
+ /// sub-tabs, and `loadSchemaForEditing` at the end of it re-baselines the change manager,
+ /// which drops every staged edit including a comment typed in the meantime.
+ guard !isInitialLoading, !isReloadingAfterSave else { return false }
+ guard let metadata = session.tableMetadata else { return false }
+ return !session.isView && !metadata.commentIsReadOnly
+ }
+
private var availableTabs: [StructureTab] {
var tabs = StructureTab.allCases
if !connection.type.supportsForeignKeys {
@@ -318,7 +331,7 @@ struct TableStructureView: View {
case .indexes: return connection.type.supportsAddIndex
case .foreignKeys: return connection.type.supportsForeignKeys
case .checkConstraints: return connection.type.supportsCheckConstraintEditing
- case .ddl, .parts, .triggers: return false
+ case .properties, .ddl, .parts, .triggers: return false
}
}
@@ -329,7 +342,7 @@ struct TableStructureView: View {
case .indexes: return connection.type.supportsDropIndex
case .foreignKeys: return connection.type.supportsForeignKeys
case .checkConstraints: return connection.type.supportsCheckConstraintEditing
- case .ddl, .parts, .triggers: return false
+ case .properties, .ddl, .parts, .triggers: return false
}
}
@@ -343,7 +356,7 @@ struct TableStructureView: View {
return (String(localized: "Add Foreign Key"), String(localized: "Remove Foreign Key"))
case .checkConstraints:
return (String(localized: "Add Check Constraint"), String(localized: "Remove Check Constraint"))
- case .ddl, .parts, .triggers:
+ case .properties, .ddl, .parts, .triggers:
return nil
}
}
@@ -362,7 +375,7 @@ struct TableStructureView: View {
case .foreignKeys: return foreignKeys.count
case .triggers: return triggers.count
case .checkConstraints: return checkConstraints.count
- case .ddl, .parts: return nil
+ case .properties, .ddl, .parts: return nil
}
}
@@ -380,6 +393,19 @@ struct TableStructureView: View {
@ViewBuilder
private var tabContent: some View {
switch selectedTab {
+ case .properties:
+ TablePropertiesView(
+ tableName: tableName,
+ schemaName: schemaName,
+ databaseName: databaseName,
+ metadata: session.tableMetadata,
+ loadError: session.tableMetadataError,
+ isLoading: !tabData.hasData(.properties),
+ isView: session.isView,
+ isCommentEditable: isTableCommentEditable,
+ comment: structureChangeManager.workingTableComment,
+ onCommentChange: { structureChangeManager.setTableComment($0) }
+ )
case .columns:
structureGrid
case .indexes:
diff --git a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift
index 6a1b12080..da608cbe8 100644
--- a/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift
+++ b/TableProTests/Core/SchemaTracking/SchemaStatementGeneratorPluginTests.swift
@@ -21,6 +21,7 @@ private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable
var addForeignKeyHandler: ((String, PluginForeignKeyDefinition) -> String?)?
var dropForeignKeyHandler: ((String, String) -> String?)?
var modifyPrimaryKeyHandler: ((String, [String], [String]) -> [String]?)?
+ var setTableCommentHandler: ((String, String?) -> String?)?
// MARK: - DDL Schema Generation
@@ -56,6 +57,10 @@ private final class MockPluginDriver: PluginDatabaseDriver, @unchecked Sendable
modifyPrimaryKeyHandler?(table, oldColumns, newColumns)
}
+ func generateSetTableCommentSQL(table: String, comment: String?) -> String? {
+ setTableCommentHandler?(table, comment)
+ }
+
// MARK: - Required Protocol Stubs
func connect() async throws {}
@@ -492,4 +497,64 @@ struct SchemaStatementGeneratorPluginTests {
#expect(stmts.isEmpty)
}
+
+ // MARK: - Table Comment
+
+ @Test("Table comment throws when the engine has none")
+ func tableCommentThrowsWhenNil() throws {
+ let mock = MockPluginDriver()
+ let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock)
+
+ #expect(throws: (any Error).self) {
+ _ = try generator.generate(changes: [.modifyTableComment(old: nil, new: "hello")])
+ }
+ }
+
+ @Test("Table comment uses plugin SQL and is not destructive")
+ func tableCommentPluginOverride() throws {
+ let mock = MockPluginDriver()
+ mock.setTableCommentHandler = { table, comment in
+ "COMMENT ON TABLE \(table) IS '\(comment ?? "")'"
+ }
+
+ let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock)
+ let stmts = try generator.generate(changes: [.modifyTableComment(old: nil, new: "hello")])
+
+ #expect(stmts.count == 1)
+ #expect(stmts[0].sql == "COMMENT ON TABLE users IS 'hello';")
+ #expect(stmts[0].isDestructive == false)
+ }
+
+ @Test("Clearing a table comment passes nil to the plugin")
+ func tableCommentClearPassesNil() throws {
+ let mock = MockPluginDriver()
+ var received: String??
+ mock.setTableCommentHandler = { _, comment in
+ received = .some(comment)
+ return "COMMENT ON TABLE users IS NULL"
+ }
+
+ let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock)
+ _ = try generator.generate(changes: [.modifyTableComment(old: "old", new: nil)])
+
+ #expect(received == .some(nil))
+ }
+
+ /// A comment cannot reference a column, so it is safe last and only last: run before a column
+ /// drop it would still succeed, but the ordering that matters is that nothing waits on it.
+ @Test("Table comment is ordered after every structural change")
+ func tableCommentOrderedLast() throws {
+ let mock = MockPluginDriver()
+ mock.setTableCommentHandler = { _, _ in "SET COMMENT" }
+ mock.addColumnHandler = { table, col in "ALTER TABLE \(table) ADD \(col.name)" }
+
+ let generator = SchemaStatementGenerator(tableName: "users", pluginDriver: mock)
+ let stmts = try generator.generate(changes: [
+ .modifyTableComment(old: nil, new: "hello"),
+ .addColumn(makeColumn())
+ ])
+
+ #expect(stmts.count == 2)
+ #expect(stmts[1].sql == "SET COMMENT;")
+ }
}
diff --git a/TableProTests/Core/SchemaTracking/StructureChangeManagerTableCommentTests.swift b/TableProTests/Core/SchemaTracking/StructureChangeManagerTableCommentTests.swift
new file mode 100644
index 000000000..bb8df4515
--- /dev/null
+++ b/TableProTests/Core/SchemaTracking/StructureChangeManagerTableCommentTests.swift
@@ -0,0 +1,183 @@
+//
+// StructureChangeManagerTableCommentTests.swift
+// TableProTests
+//
+// Staging, undo and baselining for the table comment the Properties tab edits.
+//
+
+import Foundation
+import TableProPluginKit
+import Testing
+@testable import TablePro
+
+@Suite("Structure Change Manager Table Comment")
+struct StructureChangeManagerTableCommentTests {
+
+ @MainActor private func makeManager(baseline: String?) -> StructureChangeManager {
+ let manager = StructureChangeManager()
+ manager.loadSchema(
+ tableName: "users",
+ columns: [],
+ indexes: [],
+ foreignKeys: [],
+ primaryKey: []
+ )
+ manager.setTableCommentBaseline(baseline)
+ return manager
+ }
+
+ @Test("A missing comment baselines to the empty string")
+ @MainActor func nilBaselineIsEmpty() {
+ let manager = makeManager(baseline: nil)
+
+ #expect(manager.currentTableComment == "")
+ #expect(manager.workingTableComment == "")
+ #expect(manager.hasChanges == false)
+ }
+
+ @Test("Editing the comment stages one change")
+ @MainActor func editStagesChange() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("new")
+
+ #expect(manager.workingTableComment == "new")
+ #expect(manager.hasChanges)
+ #expect(manager.getChangesArray() == [.modifyTableComment(old: "old", new: "new")])
+ }
+
+ @Test("Clearing the comment stages a nil replacement")
+ @MainActor func clearStagesNil() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("")
+
+ #expect(manager.getChangesArray() == [.modifyTableComment(old: "old", new: nil)])
+ }
+
+ @Test("Typing back to the baseline drops the staged change")
+ @MainActor func returningToBaselineClearsChange() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("new")
+ manager.setTableComment("old")
+
+ #expect(manager.hasChanges == false)
+ #expect(manager.getChangesArray().isEmpty)
+ }
+
+ @Test("Undo reverts the comment and its staged change")
+ @MainActor func undoRevertsComment() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("new")
+
+ #expect(manager.canUndo)
+ manager.undo()
+
+ #expect(manager.workingTableComment == "old")
+ #expect(manager.hasChanges == false)
+ }
+
+ /// The field writes on every character, so a per-keystroke entry would fill all 100 levels of
+ /// undo from one paragraph and evict the column edits staged beside it.
+ @Test("Typing many characters leaves one undo step")
+ @MainActor func typingCoalescesIntoOneUndoStep() {
+ let manager = makeManager(baseline: "")
+ for text in ["h", "he", "hel", "hell", "hello"] {
+ manager.setTableComment(text)
+ }
+
+ manager.undo()
+
+ #expect(manager.workingTableComment == "")
+ #expect(manager.hasChanges == false)
+ #expect(manager.canUndo == false)
+ }
+
+ @Test("Redo puts the comment back")
+ @MainActor func redoRestoresComment() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("new")
+ manager.undo()
+
+ #expect(manager.canRedo)
+ manager.redo()
+
+ #expect(manager.workingTableComment == "new")
+ #expect(manager.getChangesArray() == [.modifyTableComment(old: "old", new: "new")])
+ }
+
+ @Test("Undo and redo alternate more than once")
+ @MainActor func undoRedoAlternates() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("new")
+
+ for _ in 0..<3 {
+ manager.undo()
+ #expect(manager.workingTableComment == "old")
+ manager.redo()
+ #expect(manager.workingTableComment == "new")
+ }
+
+ #expect(manager.hasChanges)
+ }
+
+ @Test("Discarding reverts the comment to its baseline")
+ @MainActor func discardRevertsComment() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("new")
+ manager.discardChanges()
+
+ #expect(manager.workingTableComment == "old")
+ #expect(manager.hasChanges == false)
+ }
+
+ /// A fresh baseline arrives after every save and every refresh, and it must not overwrite what
+ /// the user is typing. Only an unstaged field follows the database.
+ @Test("A new baseline leaves a staged edit alone")
+ @MainActor func baselineDoesNotClobberStagedEdit() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("mine")
+ manager.setTableCommentBaseline("theirs")
+
+ #expect(manager.workingTableComment == "mine")
+ #expect(manager.getChangesArray() == [.modifyTableComment(old: "theirs", new: "mine")])
+ }
+
+ /// A refresh that finds the server already holding what the user typed leaves nothing to write,
+ /// so the staged change goes rather than saving a statement that changes nothing.
+ @Test("A baseline that catches up with the edit drops the staged change")
+ @MainActor func baselineMatchingEditDropsChange() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("mine")
+ manager.setTableCommentBaseline("mine")
+
+ #expect(manager.workingTableComment == "mine")
+ #expect(manager.hasChanges == false)
+ }
+
+ @Test("A new baseline moves an unstaged field")
+ @MainActor func baselineMovesUnstagedField() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableCommentBaseline("fresh")
+
+ #expect(manager.workingTableComment == "fresh")
+ #expect(manager.hasChanges == false)
+ }
+
+ /// `loadSchema` is the "adopt a new baseline" entry point and clears every staged edit. The
+ /// comment has to travel with the rest rather than surviving as an orphan pending change.
+ @Test("Reloading the schema resets the comment with everything else")
+ @MainActor func loadSchemaResetsComment() {
+ let manager = makeManager(baseline: "old")
+ manager.setTableComment("new")
+
+ manager.loadSchema(
+ tableName: "users",
+ columns: [],
+ indexes: [],
+ foreignKeys: [],
+ primaryKey: []
+ )
+
+ #expect(manager.workingTableComment == "old")
+ #expect(manager.hasChanges == false)
+ }
+}
diff --git a/TableProTests/Plugins/TablePropertyAttributeTests.swift b/TableProTests/Plugins/TablePropertyAttributeTests.swift
new file mode 100644
index 000000000..36af18546
--- /dev/null
+++ b/TableProTests/Plugins/TablePropertyAttributeTests.swift
@@ -0,0 +1,266 @@
+//
+// TablePropertyAttributeTests.swift
+// TableProTests
+//
+// The driver-supplied properties the Properties tab renders verbatim.
+//
+
+import Foundation
+import TableProPluginKit
+import Testing
+@testable import TablePro
+
+@Suite("Plugin Table Metadata Coding")
+struct PluginTableMetadataCodingTests {
+
+ /// Swift's synthesized `Decodable` does not fall back to an initializer's default value, so a
+ /// payload written before `attributes` existed throws `keyNotFound` unless the decoder spells
+ /// the fallback out. Measured, not assumed.
+ @Test("Decodes a payload written before attributes existed")
+ func decodesWithoutAttributes() throws {
+ let json = Data(#"{"tableName":"orders","comment":"hi"}"#.utf8)
+
+ let metadata = try JSONDecoder().decode(PluginTableMetadata.self, from: json)
+
+ #expect(metadata.tableName == "orders")
+ #expect(metadata.comment == "hi")
+ #expect(metadata.attributes.isEmpty)
+ #expect(metadata.commentIsReadOnly)
+ }
+
+ @Test("Round-trips the attributes it was given")
+ func roundTripsAttributes() throws {
+ let original = PluginTableMetadata(
+ tableName: "orders",
+ attributes: [PluginObjectAttribute(label: "Owner", value: "app")]
+ )
+
+ let decoded = try JSONDecoder().decode(
+ PluginTableMetadata.self, from: JSONEncoder().encode(original))
+
+ #expect(decoded.attributes == original.attributes)
+ }
+
+ /// The pre-`attributes` initializer keeps its exact signature so already-built plugins keep
+ /// their symbol. It has to stay callable, and it has to produce an empty attribute list.
+ @Test("The original initializer still compiles and reports no attributes")
+ func originalInitializerStillWorks() {
+ let metadata = PluginTableMetadata(tableName: "orders", engine: "InnoDB")
+
+ #expect(metadata.engine == "InnoDB")
+ #expect(metadata.attributes.isEmpty)
+ #expect(metadata.commentIsReadOnly)
+ }
+}
+
+@Suite("PostgreSQL Comment Literal")
+struct PostgreSQLDollarQuotedLiteralTests {
+
+ @Test("Wraps the value in a dollar-quoted body")
+ func wrapsValue() {
+ #expect(PostgreSQLObjectQueries.dollarQuoted("hello") == "$tablepro$hello$tablepro$")
+ }
+
+ /// The whole point of dollar quoting: neither an apostrophe nor a backslash is scanned inside
+ /// the body, so `standard_conforming_strings` cannot change where the literal ends.
+ @Test("Leaves quotes and backslashes exactly as typed")
+ func leavesEscapesAlone() {
+ let payload = #"\'; DROP TABLE users; --"#
+
+ let literal = PostgreSQLObjectQueries.dollarQuoted(payload)
+
+ #expect(literal == "$tablepro$\(payload)$tablepro$")
+ #expect(literal.hasSuffix("$tablepro$"))
+ }
+
+ /// A body holding the tag would close the literal early, which is the one way dollar quoting
+ /// can be escaped from.
+ @Test("Grows the tag until the body cannot close it")
+ func growsTagOnCollision() {
+ let literal = PostgreSQLObjectQueries.dollarQuoted("a $tablepro$ b")
+
+ #expect(literal == "$tablepro_$a $tablepro$ b$tablepro_$")
+ }
+
+ @Test("Grows the tag again when the body holds the grown one too")
+ func growsTagRepeatedly() {
+ let literal = PostgreSQLObjectQueries.dollarQuoted("$tablepro$ $tablepro_$")
+
+ #expect(literal.hasPrefix("$tablepro__$"))
+ #expect(literal.hasSuffix("$tablepro__$"))
+ }
+
+ /// PostgreSQL rejects a NUL in any text value, dollar-quoted or not.
+ @Test("Drops a NUL the server would refuse")
+ func dropsNul() {
+ #expect(PostgreSQLObjectQueries.dollarQuoted("a\0b") == "$tablepro$ab$tablepro$")
+ }
+}
+
+@Suite("PostgreSQL Table Attributes")
+struct PostgreSQLTableAttributeTests {
+
+ /// An ordinary table is the assumption, so naming its kind adds a row that changes nothing.
+ @Test("Names owner, tablespace and persistence, and leaves an ordinary table unlabelled")
+ func fullSet() {
+ let attributes = PostgreSQLTableAttributes.build(
+ owner: "app",
+ tablespace: "pg_default",
+ persistence: "p",
+ relkind: "r"
+ )
+
+ #expect(attributes.map(\.value) == ["app", "pg_default", "Permanent"])
+ }
+
+ @Test("Omits every property the catalog left blank")
+ func blanksOmitted() {
+ let attributes = PostgreSQLTableAttributes.build(
+ owner: nil,
+ tablespace: "",
+ persistence: nil,
+ relkind: nil
+ )
+
+ #expect(attributes.isEmpty)
+ }
+
+ /// An unfamiliar `relkind` is left off rather than shown as a raw letter, so a future relation
+ /// kind reads as absent instead of as a one-character property.
+ @Test("Drops an unrecognised relkind and persistence")
+ func unknownCodesDropped() {
+ let attributes = PostgreSQLTableAttributes.build(
+ owner: "app",
+ tablespace: nil,
+ persistence: "z",
+ relkind: "I"
+ )
+
+ #expect(attributes.map(\.value) == ["app"])
+ }
+
+ /// The tab already labels the schema it was opened on, so a second copy under the driver's own
+ /// name would print the same value twice.
+ @Test("Never names the schema")
+ func schemaNeverNamed() {
+ let attributes = PostgreSQLTableAttributes.build(
+ owner: "app",
+ tablespace: "pg_default",
+ persistence: "p",
+ relkind: "p"
+ )
+
+ #expect(attributes.contains { $0.label == "Schema" } == false)
+ }
+
+ /// `COMMENT ON TABLE` is refused on anything else, and each of the rest has its own keyword the
+ /// app has no way to ask for, so the relation itself has to say the comment is read-only.
+ @Test("Only an ordinary or partitioned table takes a writable comment", arguments: [
+ ("r", false), ("p", false), ("v", true), ("m", true), ("f", true), ("I", true)
+ ])
+ func commentWritability(relkind: String, readOnly: Bool) {
+ #expect(PostgreSQLTableAttributes.commentIsReadOnly(relkind: relkind) == readOnly)
+ }
+
+ @Test("An unreadable relkind is treated as read-only")
+ func missingRelkindIsReadOnly() {
+ #expect(PostgreSQLTableAttributes.commentIsReadOnly(relkind: nil))
+ }
+
+ @Test("Names an unlogged partitioned table by both codes")
+ func unloggedPartitioned() {
+ let attributes = PostgreSQLTableAttributes.build(
+ owner: nil,
+ tablespace: nil,
+ persistence: "u",
+ relkind: "p"
+ )
+
+ #expect(attributes.map(\.value) == ["Unlogged", "Partitioned table"])
+ }
+}
+
+@Suite("MySQL Table Status")
+struct MySQLTableStatusTests {
+
+ /// `SHOW TABLE STATUS` in the documented column order: Name, Engine, Version, Row_format, Rows,
+ /// Avg_row_length, Data_length, Max_data_length, Index_length, Data_free, Auto_increment,
+ /// Create_time, Update_time, Check_time, Collation, Checksum, Create_options, Comment.
+ private func statusRow(
+ comment: String = "orders",
+ collation: String = "utf8mb4_unicode_ci"
+ ) -> [PluginCellValue] {
+ [
+ .text("orders"), .text("InnoDB"), .text("10"), .text("Dynamic"), .text("42"),
+ .text("128"), .text("16384"), .text("0"), .text("32768"), .text("0"), .text("99"),
+ .text("2026-08-29 10:11:12"), .text("2026-08-29 11:12:13"), .null,
+ .text(collation), .null, .text("row_format=DYNAMIC"), .text(comment)
+ ]
+ }
+
+ @Test("Reads every column the Properties tab shows")
+ func readsAllColumns() {
+ let status = MySQLTableStatus(row: statusRow())
+
+ #expect(status.engine == "InnoDB")
+ #expect(status.rowFormat == "Dynamic")
+ #expect(status.rowCount == 42)
+ #expect(status.avgRowLength == 128)
+ #expect(status.dataSize == 16_384)
+ #expect(status.indexSize == 32_768)
+ #expect(status.autoIncrement == 99)
+ #expect(status.collation == "utf8mb4_unicode_ci")
+ #expect(status.comment == "orders")
+ }
+
+ @Test("Parses the create and update timestamps")
+ func parsesTimestamps() {
+ let status = MySQLTableStatus(row: statusRow())
+
+ var components = DateComponents()
+ components.year = 2_026
+ components.month = 8
+ components.day = 29
+ components.hour = 10
+ components.minute = 11
+ components.second = 12
+ let expected = Calendar.current.date(from: components)
+
+ #expect(status.createTime == expected)
+ #expect(status.updateTime != nil)
+ }
+
+ /// MySQL reports no comment as the empty string, and an empty string here would stage a change
+ /// against a table that never had one.
+ @Test("An empty comment reads as absent")
+ func emptyCommentIsNil() {
+ let status = MySQLTableStatus(row: statusRow(comment: ""))
+
+ #expect(status.comment == nil)
+ }
+
+ @Test("A short row reads every missing column as absent")
+ func shortRowIsSafe() {
+ let status = MySQLTableStatus(row: [.text("orders"), .text("InnoDB")])
+
+ #expect(status.engine == "InnoDB")
+ #expect(status.comment == nil)
+ #expect(status.rowCount == nil)
+ #expect(status.createTime == nil)
+ }
+
+ /// `SHOW TABLE STATUS` answers for a view with every storage column NULL, so a row that names
+ /// no engine is a view and MySQL has no `COMMENT` form for one.
+ @Test("A row with no engine is a view and keeps its comment read-only")
+ func viewCommentIsReadOnly() {
+ #expect(MySQLTableStatus(row: [.text("orders"), .null]).commentIsReadOnly)
+ #expect(MySQLTableStatus(row: statusRow()).commentIsReadOnly == false)
+ }
+
+ @Test("Publishes row format, auto increment and create options as attributes")
+ func attributes() {
+ let status = MySQLTableStatus(row: statusRow())
+
+ #expect(status.attributes.map(\.value) == ["Dynamic", "99", "row_format=DYNAMIC"])
+ }
+}
diff --git a/TableProUITests/StructurePropertiesTabUITests.swift b/TableProUITests/StructurePropertiesTabUITests.swift
new file mode 100644
index 000000000..3565867c1
--- /dev/null
+++ b/TableProUITests/StructurePropertiesTabUITests.swift
@@ -0,0 +1,92 @@
+//
+// StructurePropertiesTabUITests.swift
+// TableProUITests
+//
+
+import XCTest
+
+final class StructurePropertiesTabUITests: UITestCase {
+ /// Properties is the first segment of the picker, so the failure it guards against is the whole
+ /// tab missing or landing somewhere else in the run.
+ func testPropertiesTabIsFirstAndOpens() throws {
+ let app = try launchWithSampleDatabase()
+ let window = app.windows.firstMatch
+
+ let row = objectBrowserRow("Album", in: window)
+ XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album")
+ clickAtCenter(row)
+
+ showStructure(in: window)
+
+ let picker = window.radioGroups["structure-tab-picker"].firstMatch
+ XCTAssertTrue(picker.waitToExist(timeout: 20), "The structure editor must offer its sub-tabs")
+
+ let properties = subTab(named: "Properties", in: window)
+ XCTAssertTrue(properties.waitToExist(timeout: 20), "Properties must reach the picker")
+ XCTAssertEqual(
+ picker.radioButtons.firstMatch.label,
+ properties.label,
+ "Properties is the leading segment"
+ )
+
+ properties.click()
+ XCTAssertTrue(
+ waitForPredicate(timeout: 10) { isSelected(properties) },
+ "Selecting Properties moves the picker to it"
+ )
+
+ XCTAssertTrue(
+ window.staticTexts["Album"].waitToExist(timeout: 20),
+ "The Properties tab names the table it was opened on"
+ )
+ }
+
+ /// SQLite stores no comment on a table, which is what makes it the deterministic case: the field
+ /// must not offer an edit that no statement can carry.
+ func testCommentIsReadOnlyWhereTheEngineHasNone() throws {
+ let app = try launchWithSampleDatabase()
+ let window = app.windows.firstMatch
+
+ let row = objectBrowserRow("Album", in: window)
+ XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list Album")
+ clickAtCenter(row)
+
+ showStructure(in: window)
+
+ let properties = subTab(named: "Properties", in: window)
+ XCTAssertTrue(properties.waitToExist(timeout: 20), "Properties must reach the picker")
+ properties.click()
+
+ XCTAssertTrue(
+ window.staticTexts["This database does not store a comment on a table."]
+ .waitToExist(timeout: 20),
+ "SQLite has no table comment, so the field reports that instead of offering an editor"
+ )
+ XCTAssertFalse(
+ window.textViews["table-comment-editor"].exists,
+ "No comment editor is mounted where the engine has no comment to write"
+ )
+ }
+
+ /// A radio button in a hosted picker reports `AXSelected` as nil and answers with `AXValue`
+ /// instead, so `isSelected` reads as false however the picker is set.
+ private func isSelected(_ segment: XCUIElement) -> Bool {
+ (segment.value as? NSNumber)?.intValue == 1
+ }
+
+ private func showStructure(in window: XCUIElement) {
+ let modePicker = window.radioGroups["results-view-mode-picker"].firstMatch
+ XCTAssertTrue(modePicker.waitToExist(timeout: 20), "The result must expose its view modes")
+ let structure = modePicker.radioButtons["Structure"].firstMatch
+ XCTAssertTrue(structure.waitToExist(timeout: 20), "Structure must be one of them")
+ structure.click()
+ }
+
+ /// The sub-tab labels carry item counts, so they are matched by prefix rather than exactly.
+ private func subTab(named name: String, in window: XCUIElement) -> XCUIElement {
+ window.radioGroups["structure-tab-picker"].firstMatch
+ .radioButtons
+ .matching(NSPredicate(format: "label BEGINSWITH %@", name))
+ .firstMatch
+ }
+}
diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx
index 5a8c5a7f8..72cc1e75e 100644
--- a/docs/features/table-structure.mdx
+++ b/docs/features/table-structure.mdx
@@ -14,7 +14,22 @@ This is a DDL editor with a grid in front of it. Rename a column, add an index,
Open a table and switch the result view to **Structure**, or right-click it in the sidebar and choose **Show Structure**.
-The tabs are **Columns**, **Indexes**, **Foreign Keys**, **Constraints**, **Triggers**, **DDL**, and **Parts** (ClickHouse only); the first five carry item counts. A tab the engine has no concept of is hidden: ClickHouse has no Foreign Keys, Redshift no Triggers, Redis no Constraints. Every grid has a filter field, and clicking a header sorts.
+The tabs are **Properties**, **Columns**, **Indexes**, **Foreign Keys**, **Constraints**, **Triggers**, **DDL**, and **Parts** (ClickHouse only); Columns through Triggers carry item counts. A tab the engine has no concept of is hidden: ClickHouse has no Foreign Keys, Redshift no Triggers, Redis no Constraints. Every grid has a filter field, and clicking a header sorts.
+
+## Properties tab
+
+The table's own row in the catalog: name, schema, size on disk, row count, and the comment. What sits between them is the driver's choice, so PostgreSQL names an owner, a tablespace and a persistence where MySQL names a row format and the next auto-increment value.
+
+
+
+
+
+
+**Comment** is the only editable field. It scrolls rather than clipping, so a long one reads and edits in place. An edit queues with the rest of the structure changes and reaches the server on **Save Changes**.
+
+
+Comment editing is available for MySQL, MariaDB, PostgreSQL and PGlite, and on those engines for an ordinary or partitioned table. Everywhere else, and on a view, a materialized view or a foreign table, the comment is read-only.
+
## Columns tab
@@ -141,6 +156,8 @@ MongoDB structure is read-only, and inferred from the collection's first 200 doc
- **Changing a primary key on an existing table** works on MySQL, MariaDB, PostgreSQL, PGlite, SQL Server, DuckDB, Snowflake, and Dameng. Elsewhere the dropdown accepts the edit and the save produces nothing for it: rebuild the table by hand.
- **Check constraints and generated columns** have no field in the grid. Both show in the DDL tab; change them by running DDL yourself.
+- **A view, a materialized view or a foreign table** keeps a read-only comment on the Properties tab. Each takes its own `COMMENT ON` keyword: run it in the editor.
+- **Renaming a table** is not on the Properties tab. It happens in the sidebar row: see [Table Operations](/features/table-operations).
- **SQLite**: a column can be added, dropped, and renamed. No other column change generates SQL.
- **Cassandra / ScyllaDB**: add and drop column only, no index editing, no visual table creation.
- **Redshift, CockroachDB, BigQuery, Elasticsearch, SurrealDB, Beancount**: structure is read-only.
diff --git a/docs/images/structure-properties-dark.png b/docs/images/structure-properties-dark.png
new file mode 100644
index 000000000..849cf2cd8
Binary files /dev/null and b/docs/images/structure-properties-dark.png differ
diff --git a/docs/images/structure-properties.png b/docs/images/structure-properties.png
new file mode 100644
index 000000000..8b3ed4b75
Binary files /dev/null and b/docs/images/structure-properties.png differ
diff --git a/project.yml b/project.yml
index cd83991c4..94ae28c02 100644
--- a/project.yml
+++ b/project.yml
@@ -422,6 +422,7 @@ targets:
- Plugins/MySQLDriverPlugin/MySQLServerVersion.swift
- Plugins/MySQLDriverPlugin/MySQLSocketTimeout.swift
- Plugins/MySQLDriverPlugin/MySQLStatementClassification.swift
+ - Plugins/MySQLDriverPlugin/MySQLTableStatus.swift
- Plugins/MySQLDriverPlugin/MySQLTransactionStatement.swift
- Plugins/SQLiteDriverPlugin/SQLiteCheckConstraintParser.swift
- Plugins/MSSQLDriverPlugin/MSSQLCheckConstraintDefinition.swift
@@ -434,6 +435,7 @@ targets:
- Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift
- Plugins/PostgreSQLDriverPlugin/PostgreSQLSchemaQueries.swift
- Plugins/PostgreSQLDriverPlugin/PostgreSQLSystemDatabases.swift
+ - Plugins/PostgreSQLDriverPlugin/PostgreSQLTableAttributes.swift
- Plugins/PostgreSQLDriverPlugin/PostgreSQLTableListingLadder.swift
- Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift
- Plugins/PostgreSQLDriverPlugin/RedshiftExternalSchemaQueries.swift