Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

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

Expand Down
1 change: 1 addition & 0 deletions Plugins/MySQLDriverPlugin/MySQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
30 changes: 19 additions & 11 deletions Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
}

Expand Down Expand Up @@ -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))"
}
Expand Down
91 changes: 91 additions & 0 deletions Plugins/MySQLDriverPlugin/MySQLTableStatus.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
17 changes: 17 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLObjectQueries.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 26 additions & 2 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)'
"""
Expand All @@ -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,
Expand All @@ -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)
)
}

Expand Down Expand Up @@ -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))
}
Expand Down
67 changes: 67 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLTableAttributes.swift
Original file line number Diff line number Diff line change
@@ -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
}
}
}
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 }
}
5 changes: 5 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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 }
Expand Down
Loading
Loading