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. + + + A grouped form listing name, schema, owner and tablespace above a comment field holding several lines of JSON + A grouped form listing name, schema, owner and tablespace above a comment field holding several lines of JSON + + +**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