diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6b89d8e8..388ad4a16 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,9 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Claude Agent** provider, which runs AI chat through the Claude Code command line tool so you can use a Claude subscription instead of a metered API key. It answers from your schema when the MCP server is on.
- **Count exactly** next to an estimated row total, to replace the estimate with a real count when you want it.
- Reorder filter rows by dragging the grip on the left of each row, or with **Move Up** and **Move Down** in the row's right-click menu.
+- An **On Update** column in the Structure tab for MySQL and MariaDB, so a timestamp column can be set to update itself without writing the SQL by hand. (#2005)
### Fixed
+- Editing a MySQL or MariaDB column no longer drops its `ON UPDATE CURRENT_TIMESTAMP`. Saving a change to any other part of the column, even just its comment, silently removed the clause. (#2005)
+- A MySQL or MariaDB timestamp column that keeps fractional seconds now saves. Its default was written as text instead of an expression, so the change was rejected. (#2005)
+
- Quitting no longer loses unsaved SQL editor tabs. A window with no tabs loaded yet, such as one still waiting on its connection, could erase the saved tabs for that connection, so nothing came back on relaunch. Editors are also saved about a second after you stop typing instead of waiting up to 30 seconds, and a query over 500KB is kept in full rather than restored empty. (#1997)
- Opening a large MongoDB collection no longer hangs. Row counts that back the pagination display now give up after 5 seconds and leave the estimate in place instead of holding the tab.
- Stop now cancels a running MongoDB query on the server, rather than leaving it running until it finishes on its own.
diff --git a/Plugins/BeancountDriverPlugin/Info.plist b/Plugins/BeancountDriverPlugin/Info.plist
index 21019f559..8487d1977 100644
--- a/Plugins/BeancountDriverPlugin/Info.plist
+++ b/Plugins/BeancountDriverPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesDatabaseTypeIds
Beancount
diff --git a/Plugins/BigQueryDriverPlugin/Info.plist b/Plugins/BigQueryDriverPlugin/Info.plist
index 2ff08341c..2ea7e5581 100644
--- a/Plugins/BigQueryDriverPlugin/Info.plist
+++ b/Plugins/BigQueryDriverPlugin/Info.plist
@@ -5,6 +5,6 @@
TableProMinAppVersion
0.42.0
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/CSVExportPlugin/Info.plist b/Plugins/CSVExportPlugin/Info.plist
index 8cb037558..4729f4543 100644
--- a/Plugins/CSVExportPlugin/Info.plist
+++ b/Plugins/CSVExportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesExportFormatIds
csv
diff --git a/Plugins/CSVImportPlugin/Info.plist b/Plugins/CSVImportPlugin/Info.plist
index 14e54accd..87fdc215a 100644
--- a/Plugins/CSVImportPlugin/Info.plist
+++ b/Plugins/CSVImportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesImportFormatIds
csv
diff --git a/Plugins/CassandraDriverPlugin/Info.plist b/Plugins/CassandraDriverPlugin/Info.plist
index d05d1d492..d0dc3c30a 100644
--- a/Plugins/CassandraDriverPlugin/Info.plist
+++ b/Plugins/CassandraDriverPlugin/Info.plist
@@ -21,6 +21,6 @@
NSPrincipalClass
$(PRODUCT_MODULE_NAME).CassandraPlugin
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/ClickHouseDriverPlugin/Info.plist b/Plugins/ClickHouseDriverPlugin/Info.plist
index 0a1e1fc6e..0aac7f94b 100644
--- a/Plugins/ClickHouseDriverPlugin/Info.plist
+++ b/Plugins/ClickHouseDriverPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesDatabaseTypeIds
ClickHouse
diff --git a/Plugins/CloudflareD1DriverPlugin/Info.plist b/Plugins/CloudflareD1DriverPlugin/Info.plist
index 2ff08341c..2ea7e5581 100644
--- a/Plugins/CloudflareD1DriverPlugin/Info.plist
+++ b/Plugins/CloudflareD1DriverPlugin/Info.plist
@@ -5,6 +5,6 @@
TableProMinAppVersion
0.42.0
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/DuckDBDriverPlugin/Info.plist b/Plugins/DuckDBDriverPlugin/Info.plist
index 8171adf32..0811e81d5 100644
--- a/Plugins/DuckDBDriverPlugin/Info.plist
+++ b/Plugins/DuckDBDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/DynamoDBDriverPlugin/Info.plist b/Plugins/DynamoDBDriverPlugin/Info.plist
index 2ff08341c..2ea7e5581 100644
--- a/Plugins/DynamoDBDriverPlugin/Info.plist
+++ b/Plugins/DynamoDBDriverPlugin/Info.plist
@@ -5,6 +5,6 @@
TableProMinAppVersion
0.42.0
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/ElasticsearchDriverPlugin/Info.plist b/Plugins/ElasticsearchDriverPlugin/Info.plist
index d8d79de88..4aabbb7b7 100644
--- a/Plugins/ElasticsearchDriverPlugin/Info.plist
+++ b/Plugins/ElasticsearchDriverPlugin/Info.plist
@@ -5,6 +5,6 @@
TableProMinAppVersion
0.53.0
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/EtcdDriverPlugin/Info.plist b/Plugins/EtcdDriverPlugin/Info.plist
index 2ff08341c..2ea7e5581 100644
--- a/Plugins/EtcdDriverPlugin/Info.plist
+++ b/Plugins/EtcdDriverPlugin/Info.plist
@@ -5,6 +5,6 @@
TableProMinAppVersion
0.42.0
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/JSONExportPlugin/Info.plist b/Plugins/JSONExportPlugin/Info.plist
index d89bc9ebf..c05a94aa4 100644
--- a/Plugins/JSONExportPlugin/Info.plist
+++ b/Plugins/JSONExportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesExportFormatIds
json
diff --git a/Plugins/JSONImportPlugin/Info.plist b/Plugins/JSONImportPlugin/Info.plist
index 6174f79e9..b22c84da2 100644
--- a/Plugins/JSONImportPlugin/Info.plist
+++ b/Plugins/JSONImportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesImportFormatIds
json
diff --git a/Plugins/LibSQLDriverPlugin/Info.plist b/Plugins/LibSQLDriverPlugin/Info.plist
index 2ff08341c..2ea7e5581 100644
--- a/Plugins/LibSQLDriverPlugin/Info.plist
+++ b/Plugins/LibSQLDriverPlugin/Info.plist
@@ -5,6 +5,6 @@
TableProMinAppVersion
0.42.0
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/MQLExportPlugin/Info.plist b/Plugins/MQLExportPlugin/Info.plist
index 0f3791ad0..11a843bb0 100644
--- a/Plugins/MQLExportPlugin/Info.plist
+++ b/Plugins/MQLExportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesExportFormatIds
mql
diff --git a/Plugins/MSSQLDriverPlugin/Info.plist b/Plugins/MSSQLDriverPlugin/Info.plist
index 8171adf32..0811e81d5 100644
--- a/Plugins/MSSQLDriverPlugin/Info.plist
+++ b/Plugins/MSSQLDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/MongoDBDriverPlugin/Info.plist b/Plugins/MongoDBDriverPlugin/Info.plist
index 8171adf32..0811e81d5 100644
--- a/Plugins/MongoDBDriverPlugin/Info.plist
+++ b/Plugins/MongoDBDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/MySQLDriverPlugin/Info.plist b/Plugins/MySQLDriverPlugin/Info.plist
index dd7087e7a..7d1d32067 100644
--- a/Plugins/MySQLDriverPlugin/Info.plist
+++ b/Plugins/MySQLDriverPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesDatabaseTypeIds
MySQL
diff --git a/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift
new file mode 100644
index 000000000..d2a460859
--- /dev/null
+++ b/Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift
@@ -0,0 +1,85 @@
+//
+// MySQLColumnDefinitionSQL.swift
+// MySQLDriverPlugin
+//
+
+import Foundation
+import TableProPluginKit
+
+internal func mysqlQuoteIdentifier(_ name: String) -> String {
+ let escaped = name.replacingOccurrences(of: "`", with: "``")
+ return "`\(escaped)`"
+}
+
+internal func mysqlEscapeStringLiteral(_ value: String) -> String {
+ var result = value
+ result = result.replacingOccurrences(of: "\\", with: "\\\\")
+ result = result.replacingOccurrences(of: "'", with: "''")
+ result = result.replacingOccurrences(of: "\n", with: "\\n")
+ result = result.replacingOccurrences(of: "\r", with: "\\r")
+ result = result.replacingOccurrences(of: "\t", with: "\\t")
+ result = result.replacingOccurrences(of: "\0", with: "\\0")
+ result = result.replacingOccurrences(of: "\u{08}", with: "\\b")
+ result = result.replacingOccurrences(of: "\u{0C}", with: "\\f")
+ result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z")
+ return result
+}
+
+/// MySQL rejects a CURRENT_TIMESTAMP expression whose fractional-second precision differs
+/// from the column's own, so the precision is always taken from the declared type.
+internal func mysqlFractionalSecondsSuffix(forDataType dataType: String) -> String {
+ let upper = dataType.uppercased()
+ guard upper.hasPrefix("TIMESTAMP(") || upper.hasPrefix("DATETIME(") else { return "" }
+ guard let open = dataType.firstIndex(of: "("),
+ let close = dataType[open...].firstIndex(of: ")") else { return "" }
+ return String(dataType[open...close])
+}
+
+internal func mysqlCurrentTimestampExpression(_ value: String, dataType: String) -> String? {
+ let upper = value.uppercased()
+ guard upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()"
+ || upper.hasPrefix("CURRENT_TIMESTAMP(") else { return nil }
+ return "CURRENT_TIMESTAMP" + mysqlFractionalSecondsSuffix(forDataType: dataType)
+}
+
+internal func mysqlDefaultValueLiteral(_ value: String, dataType: String) -> String {
+ if let expression = mysqlCurrentTimestampExpression(value, dataType: dataType) { return expression }
+ if value.uppercased() == "NULL" || value.hasPrefix("'") { return value }
+ if Int64(value) != nil || Double(value) != nil { return value }
+ return "'\(mysqlEscapeStringLiteral(value))'"
+}
+
+internal func mysqlColumnAttributesSQL(_ column: PluginColumnDefinition) -> String {
+ var def = ""
+
+ if column.unsigned {
+ def += " UNSIGNED"
+ }
+ if let charset = column.charset, !charset.isEmpty {
+ def += " CHARACTER SET \(charset)"
+ }
+ if let collation = column.collation, !collation.isEmpty {
+ def += " COLLATE \(collation)"
+ }
+ def += column.isNullable ? " NULL" : " NOT NULL"
+
+ if let defaultValue = column.defaultValue {
+ def += " DEFAULT \(mysqlDefaultValueLiteral(defaultValue, dataType: column.dataType))"
+ }
+ if column.autoIncrement {
+ def += " AUTO_INCREMENT"
+ }
+ if let onUpdate = column.onUpdate,
+ let expression = mysqlCurrentTimestampExpression(onUpdate, dataType: column.dataType) {
+ def += " ON UPDATE \(expression)"
+ }
+ if let comment = column.comment, !comment.isEmpty {
+ def += " COMMENT '\(mysqlEscapeStringLiteral(comment))'"
+ }
+
+ return def
+}
+
+internal func mysqlColumnDefinitionSQL(_ column: PluginColumnDefinition) -> String {
+ "\(mysqlQuoteIdentifier(column.name)) \(column.dataType)" + mysqlColumnAttributesSQL(column)
+}
diff --git a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift
index 178fc19b3..6809e06ed 100644
--- a/Plugins/MySQLDriverPlugin/MySQLPlugin.swift
+++ b/Plugins/MySQLDriverPlugin/MySQLPlugin.swift
@@ -48,7 +48,7 @@ final class MySQLPlugin: NSObject, TableProPlugin, DriverPlugin {
]
static let structureColumnFields: [StructureColumnField] = [
- .name, .type, .nullable, .defaultValue, .autoIncrement, .comment, .charset, .collation
+ .name, .type, .nullable, .defaultValue, .onUpdate, .autoIncrement, .comment, .charset, .collation
]
static let sqlDialect: SQLDialectDescriptor? = SQLDialectDescriptor(
diff --git a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
index a780a3525..4f94dd3ec 100644
--- a/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
+++ b/Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift
@@ -42,22 +42,11 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
func quoteIdentifier(_ name: String) -> String {
- let escaped = name.replacingOccurrences(of: "`", with: "``")
- return "`\(escaped)`"
+ mysqlQuoteIdentifier(name)
}
func escapeStringLiteral(_ value: String) -> String {
- var result = value
- result = result.replacingOccurrences(of: "\\", with: "\\\\")
- result = result.replacingOccurrences(of: "'", with: "''")
- result = result.replacingOccurrences(of: "\n", with: "\\n")
- result = result.replacingOccurrences(of: "\r", with: "\\r")
- result = result.replacingOccurrences(of: "\t", with: "\\t")
- result = result.replacingOccurrences(of: "\0", with: "\\0")
- result = result.replacingOccurrences(of: "\u{08}", with: "\\b")
- result = result.replacingOccurrences(of: "\u{0C}", with: "\\f")
- result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z")
- return result
+ mysqlEscapeStringLiteral(value)
}
private static let tableNameRegex = try? NSRegularExpression(pattern: "(?i)\\bFROM\\s+[`\"']?([\\w]+)[`\"']?")
@@ -761,48 +750,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}
private func buildColumnDefinitionSQL(_ column: PluginColumnDefinition) -> String {
- var def = "\(quoteIdentifier(column.name)) \(column.dataType)"
-
- if column.unsigned {
- def += " UNSIGNED"
- }
- if let charset = column.charset, !charset.isEmpty {
- def += " CHARACTER SET \(charset)"
- }
- if let collation = column.collation, !collation.isEmpty {
- def += " COLLATE \(collation)"
- }
- if column.isNullable {
- def += " NULL"
- } else {
- def += " NOT NULL"
- }
- if let defaultValue = column.defaultValue {
- let upper = defaultValue.uppercased()
- if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()"
- || defaultValue.hasPrefix("'") {
- def += " DEFAULT \(defaultValue)"
- } else if Int64(defaultValue) != nil || Double(defaultValue) != nil {
- def += " DEFAULT \(defaultValue)"
- } else {
- def += " DEFAULT '\(escapeStringLiteral(defaultValue))'"
- }
- }
- if column.autoIncrement {
- def += " AUTO_INCREMENT"
- }
- if let onUpdate = column.onUpdate, !onUpdate.isEmpty {
- let upper = onUpdate.uppercased()
- if upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()"
- || upper.hasPrefix("CURRENT_TIMESTAMP(") {
- def += " ON UPDATE \(onUpdate)"
- }
- }
- if let comment = column.comment, !comment.isEmpty {
- def += " COMMENT '\(escapeStringLiteral(comment))'"
- }
-
- return def
+ mysqlColumnDefinitionSQL(column)
}
private func buildIndexDefinitionSQL(_ index: PluginIndexDefinition) -> String {
@@ -927,44 +875,7 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
let tableName = quoteIdentifier(table)
let colName = quoteIdentifier(column.name)
- var def = "\(column.dataType)"
- if column.unsigned {
- def += " UNSIGNED"
- }
- if let charset = column.charset, !charset.isEmpty {
- def += " CHARACTER SET \(charset)"
- }
- if let collation = column.collation, !collation.isEmpty {
- def += " COLLATE \(collation)"
- }
- if column.isNullable {
- def += " NULL"
- } else {
- def += " NOT NULL"
- }
- if let defaultValue = column.defaultValue {
- let upper = defaultValue.uppercased()
- if upper == "NULL" || upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()"
- || defaultValue.hasPrefix("'") {
- def += " DEFAULT \(defaultValue)"
- } else if Int64(defaultValue) != nil || Double(defaultValue) != nil {
- def += " DEFAULT \(defaultValue)"
- } else {
- def += " DEFAULT '\(escapeStringLiteral(defaultValue))'"
- }
- }
- if column.autoIncrement {
- def += " AUTO_INCREMENT"
- }
- if let onUpdate = column.onUpdate, !onUpdate.isEmpty {
- let upper = onUpdate.uppercased()
- if upper == "CURRENT_TIMESTAMP" || upper == "CURRENT_TIMESTAMP()" || upper.hasPrefix("CURRENT_TIMESTAMP(") {
- def += " ON UPDATE \(onUpdate)"
- }
- }
- if let comment = column.comment, !comment.isEmpty {
- def += " COMMENT '\(escapeStringLiteral(comment))'"
- }
+ let def = "\(column.dataType)" + mysqlColumnAttributesSQL(column)
let position: String
if let afterCol = afterColumn {
diff --git a/Plugins/OracleDriverPlugin/Info.plist b/Plugins/OracleDriverPlugin/Info.plist
index 8171adf32..0811e81d5 100644
--- a/Plugins/OracleDriverPlugin/Info.plist
+++ b/Plugins/OracleDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/PostgreSQLDriverPlugin/Info.plist b/Plugins/PostgreSQLDriverPlugin/Info.plist
index 4e4d8b50c..ba0cdb6ae 100644
--- a/Plugins/PostgreSQLDriverPlugin/Info.plist
+++ b/Plugins/PostgreSQLDriverPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesDatabaseTypeIds
PostgreSQL
diff --git a/Plugins/RedisDriverPlugin/Info.plist b/Plugins/RedisDriverPlugin/Info.plist
index 18a6523c2..d1491ce46 100644
--- a/Plugins/RedisDriverPlugin/Info.plist
+++ b/Plugins/RedisDriverPlugin/Info.plist
@@ -21,7 +21,7 @@
NSPrincipalClass
$(PRODUCT_MODULE_NAME).RedisPlugin
TableProPluginKitVersion
- 18
+ 19
TableProProvidesDatabaseTypeIds
Redis
diff --git a/Plugins/SQLExportPlugin/Info.plist b/Plugins/SQLExportPlugin/Info.plist
index 163d8f339..c6c8119ca 100644
--- a/Plugins/SQLExportPlugin/Info.plist
+++ b/Plugins/SQLExportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesExportFormatIds
sql
diff --git a/Plugins/SQLImportPlugin/Info.plist b/Plugins/SQLImportPlugin/Info.plist
index ade22e41d..69b0001d3 100644
--- a/Plugins/SQLImportPlugin/Info.plist
+++ b/Plugins/SQLImportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesImportFormatIds
sql
diff --git a/Plugins/SQLiteDriverPlugin/Info.plist b/Plugins/SQLiteDriverPlugin/Info.plist
index 40999845e..9467f73c2 100644
--- a/Plugins/SQLiteDriverPlugin/Info.plist
+++ b/Plugins/SQLiteDriverPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesDatabaseTypeIds
SQLite
diff --git a/Plugins/SnowflakeDriverPlugin/Info.plist b/Plugins/SnowflakeDriverPlugin/Info.plist
index d2e3be973..34dbfb803 100644
--- a/Plugins/SnowflakeDriverPlugin/Info.plist
+++ b/Plugins/SnowflakeDriverPlugin/Info.plist
@@ -5,6 +5,6 @@
TableProMinAppVersion
0.48.0
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/SurrealDBDriverPlugin/Info.plist b/Plugins/SurrealDBDriverPlugin/Info.plist
index d13ef0e55..aa408a600 100644
--- a/Plugins/SurrealDBDriverPlugin/Info.plist
+++ b/Plugins/SurrealDBDriverPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesDatabaseTypeIds
SurrealDB
diff --git a/Plugins/TableProPluginKit/StructureColumnField.swift b/Plugins/TableProPluginKit/StructureColumnField.swift
index 77fc036f0..f32ffe9c4 100644
--- a/Plugins/TableProPluginKit/StructureColumnField.swift
+++ b/Plugins/TableProPluginKit/StructureColumnField.swift
@@ -1,11 +1,11 @@
import Foundation
-@frozen
public enum StructureColumnField: String, Sendable, CaseIterable {
case name
case type
case nullable
case defaultValue
+ case onUpdate
case primaryKey
case autoIncrement
case comment
@@ -18,6 +18,7 @@ public enum StructureColumnField: String, Sendable, CaseIterable {
case .type: String(localized: "Type")
case .nullable: String(localized: "Nullable")
case .defaultValue: String(localized: "Default")
+ case .onUpdate: String(localized: "On Update")
case .primaryKey: String(localized: "Primary Key")
case .autoIncrement: String(localized: "Auto Inc")
case .comment: String(localized: "Comment")
diff --git a/Plugins/TeradataDriverPlugin/Info.plist b/Plugins/TeradataDriverPlugin/Info.plist
index 8171adf32..0811e81d5 100644
--- a/Plugins/TeradataDriverPlugin/Info.plist
+++ b/Plugins/TeradataDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/TrinoDriverPlugin/Info.plist b/Plugins/TrinoDriverPlugin/Info.plist
index 8171adf32..0811e81d5 100644
--- a/Plugins/TrinoDriverPlugin/Info.plist
+++ b/Plugins/TrinoDriverPlugin/Info.plist
@@ -3,6 +3,6 @@
TableProPluginKitVersion
- 18
+ 19
diff --git a/Plugins/XLSXExportPlugin/Info.plist b/Plugins/XLSXExportPlugin/Info.plist
index 87ce146ad..7194edd0f 100644
--- a/Plugins/XLSXExportPlugin/Info.plist
+++ b/Plugins/XLSXExportPlugin/Info.plist
@@ -3,7 +3,7 @@
TableProPluginKitVersion
- 18
+ 19
TableProProvidesExportFormatIds
xlsx
diff --git a/TablePro.xcodeproj/project.pbxproj b/TablePro.xcodeproj/project.pbxproj
index 7d919d7ce..d7631707d 100644
--- a/TablePro.xcodeproj/project.pbxproj
+++ b/TablePro.xcodeproj/project.pbxproj
@@ -537,6 +537,7 @@
5A865000E00000000 /* Exceptions for "Plugins/MySQLDriverPlugin" folder in "TableProTests" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
+ MySQLColumnDefinitionSQL.swift,
MySQLQueryTimeoutStatement.swift,
MySQLSocketTimeout.swift,
MySQLStatementClassification.swift,
diff --git a/TablePro/Core/Plugins/PluginManager.swift b/TablePro/Core/Plugins/PluginManager.swift
index d49944c56..ee93413a1 100644
--- a/TablePro/Core/Plugins/PluginManager.swift
+++ b/TablePro/Core/Plugins/PluginManager.swift
@@ -14,8 +14,8 @@ import TableProPluginKit
@MainActor @Observable
final class PluginManager {
static let shared = PluginManager()
- static let currentPluginKitVersion = 18
- static let minimumCompatiblePluginKitVersion = 18
+ static let currentPluginKitVersion = 19
+ static let minimumCompatiblePluginKitVersion = 19
static let currentInspectorKitVersion = 1
private static let disabledPluginsKey = "com.TablePro.disabledPlugins"
private static let legacyDisabledPluginsKey = "disabledPlugins"
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry.swift b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
index c6b01b67b..6ad8ed58a 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry.swift
@@ -541,7 +541,10 @@ final class PluginMetadataRegistry: @unchecked Sendable {
systemSchemaNames: [],
fileExtensions: [],
databaseGroupingStrategy: .byDatabase,
- structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment, .charset, .collation]
+ structureColumnFields: [
+ .name, .type, .nullable, .defaultValue, .onUpdate, .autoIncrement,
+ .comment, .charset, .collation
+ ]
),
editor: PluginMetadataSnapshot.EditorConfig(
sqlDialect: mysqlDialect,
@@ -593,7 +596,10 @@ final class PluginMetadataRegistry: @unchecked Sendable {
systemSchemaNames: [],
fileExtensions: [],
databaseGroupingStrategy: .byDatabase,
- structureColumnFields: [.name, .type, .nullable, .defaultValue, .autoIncrement, .comment, .charset, .collation]
+ structureColumnFields: [
+ .name, .type, .nullable, .defaultValue, .onUpdate, .autoIncrement,
+ .comment, .charset, .collation
+ ]
),
editor: PluginMetadataSnapshot.EditorConfig(
sqlDialect: mysqlDialect,
diff --git a/TablePro/Models/Schema/ColumnDefinition.swift b/TablePro/Models/Schema/ColumnDefinition.swift
index ab9ea395b..89c91eb00 100644
--- a/TablePro/Models/Schema/ColumnDefinition.swift
+++ b/TablePro/Models/Schema/ColumnDefinition.swift
@@ -25,6 +25,8 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable {
var isPrimaryKey: Bool
+ static let currentTimestampExpression = "CURRENT_TIMESTAMP"
+
/// Create a placeholder column for adding new columns
static func placeholder() -> EditableColumnDefinition {
EditableColumnDefinition(
@@ -63,13 +65,20 @@ struct EditableColumnDefinition: Hashable, Codable, Identifiable {
unsigned: columnInfo.dataType.contains("unsigned"),
comment: columnInfo.comment,
collation: columnInfo.collation,
- onUpdate: nil,
+ onUpdate: onUpdateExpression(fromExtra: columnInfo.extra),
charset: columnInfo.charset,
extra: columnInfo.extra,
isPrimaryKey: columnInfo.isPrimaryKey
)
}
+ /// Normalised to the bare expression: fractional-second precision is redundant with the
+ /// column's declared type and is re-derived when generating DDL.
+ private static func onUpdateExpression(fromExtra extra: String?) -> String? {
+ guard let extra, extra.lowercased().contains("on update") else { return nil }
+ return currentTimestampExpression
+ }
+
func toPlugin() -> PluginColumnDefinition {
PluginColumnDefinition(
name: name, dataType: dataType, isNullable: isNullable, defaultValue: defaultValue,
diff --git a/TablePro/Views/Structure/StructureEditingSupport.swift b/TablePro/Views/Structure/StructureEditingSupport.swift
index 00ce164e5..84ee8397b 100644
--- a/TablePro/Views/Structure/StructureEditingSupport.swift
+++ b/TablePro/Views/Structure/StructureEditingSupport.swift
@@ -31,6 +31,8 @@ enum StructureEditingSupport {
case .type: column.dataType = value
case .nullable: column.isNullable = parseBool(value) && !column.isPrimaryKey
case .defaultValue: column.defaultValue = value.isEmpty ? nil : value
+ case .onUpdate:
+ column.onUpdate = parseBool(value) ? EditableColumnDefinition.currentTimestampExpression : nil
case .primaryKey:
column.isPrimaryKey = parseBool(value)
if column.isPrimaryKey { column.isNullable = false }
@@ -38,6 +40,7 @@ enum StructureEditingSupport {
case .comment: column.comment = value.isEmpty ? nil : value
case .charset: column.charset = value.isEmpty ? nil : value
case .collation: column.collation = value.isEmpty ? nil : value
+ @unknown default: break
}
}
@@ -157,11 +160,13 @@ enum StructureEditingSupport {
case .type: return old.dataType != new.dataType
case .nullable: return old.isNullable != new.isNullable
case .defaultValue: return old.defaultValue != new.defaultValue
+ case .onUpdate: return old.onUpdate != new.onUpdate
case .primaryKey: return old.isPrimaryKey != new.isPrimaryKey
case .autoIncrement: return old.autoIncrement != new.autoIncrement
case .comment: return old.comment != new.comment
case .charset: return old.charset != new.charset
case .collation: return old.collation != new.collation
+ @unknown default: return false
}
}
}
diff --git a/TablePro/Views/Structure/StructureRowProvider.swift b/TablePro/Views/Structure/StructureRowProvider.swift
index 6eb2d59c7..a738ceef4 100644
--- a/TablePro/Views/Structure/StructureRowProvider.swift
+++ b/TablePro/Views/Structure/StructureRowProvider.swift
@@ -18,7 +18,12 @@ struct StructureSortDescriptor {
@MainActor
final class StructureRowProvider {
private static let canonicalFieldOrder: [StructureColumnField] = [
- .name, .type, .nullable, .defaultValue, .primaryKey, .autoIncrement, .comment, .charset, .collation
+ .name, .type, .nullable, .defaultValue, .onUpdate, .primaryKey, .autoIncrement,
+ .comment, .charset, .collation
+ ]
+
+ private static let booleanFields: [StructureColumnField] = [
+ .nullable, .primaryKey, .autoIncrement, .onUpdate
]
private let changeManager: StructureChangeManager
@@ -74,9 +79,10 @@ final class StructureRowProvider {
switch tab {
case .columns:
var result: Set = []
- if let i = orderedColumnFields.firstIndex(of: .nullable) { result.insert(i) }
- if let i = orderedColumnFields.firstIndex(of: .primaryKey) { result.insert(i) }
- if let i = orderedColumnFields.firstIndex(of: .autoIncrement) { result.insert(i) }
+ for field in Self.booleanFields {
+ guard let index = orderedColumnFields.firstIndex(of: field) else { continue }
+ result.insert(index)
+ }
return result
case .indexes:
return [3]
@@ -100,7 +106,7 @@ final class StructureRowProvider {
return [2: types, 3: Self.booleanOptions]
case .columns:
var result: [Int: [String]] = [:]
- for field in [StructureColumnField.nullable, .primaryKey, .autoIncrement] {
+ for field in Self.booleanFields {
guard let index = orderedColumnFields.firstIndex(of: field) else { continue }
result[index] = Self.booleanOptions
}
@@ -251,11 +257,13 @@ final class StructureRowProvider {
case .type: column.dataType
case .nullable: column.isNullable ? "YES" : "NO"
case .defaultValue: column.defaultValue ?? ""
+ case .onUpdate: column.onUpdate != nil ? "YES" : "NO"
case .primaryKey: column.isPrimaryKey ? "YES" : "NO"
case .autoIncrement: column.autoIncrement ? "YES" : "NO"
case .comment: column.comment ?? ""
case .charset: column.charset ?? ""
case .collation: column.collation ?? ""
+ @unknown default: nil
}
}
}
diff --git a/TableProTests/Models/Schema/ColumnDefinitionTests.swift b/TableProTests/Models/Schema/ColumnDefinitionTests.swift
index 798b822b1..2ed071c1d 100644
--- a/TableProTests/Models/Schema/ColumnDefinitionTests.swift
+++ b/TableProTests/Models/Schema/ColumnDefinitionTests.swift
@@ -207,5 +207,65 @@ struct ColumnDefinitionTests {
#expect(convertedBack.defaultValue == originalInfo.defaultValue)
#expect(convertedBack.extra == originalInfo.extra)
#expect(convertedBack.comment == originalInfo.comment)
+ #expect(editable.onUpdate == "CURRENT_TIMESTAMP")
+ }
+
+ // MARK: - On Update
+
+ @Test("Server-reported on-update timestamp survives a rebuild from the working column")
+ func onUpdateSurvivesRebuild() {
+ let original = ColumnInfo(
+ name: "updated_at",
+ dataType: "timestamp",
+ isNullable: false,
+ isPrimaryKey: false,
+ defaultValue: "CURRENT_TIMESTAMP",
+ extra: "on update CURRENT_TIMESTAMP",
+ charset: nil,
+ collation: nil,
+ comment: nil
+ )
+
+ var editable = EditableColumnDefinition.from(original)
+ editable.comment = "touched"
+ let rebuilt = EditableColumnDefinition.from(editable.toColumnInfo())
+
+ #expect(rebuilt.onUpdate == "CURRENT_TIMESTAMP")
+ #expect(editable.toPlugin().onUpdate == "CURRENT_TIMESTAMP")
+ }
+
+ @Test(
+ "On-update is parsed out of every EXTRA spelling the server uses",
+ arguments: [
+ (extra: String?.none, expected: String?.none),
+ (extra: "", expected: nil),
+ (extra: "auto_increment", expected: nil),
+ (extra: "DEFAULT_GENERATED", expected: nil),
+ (extra: "on update CURRENT_TIMESTAMP", expected: "CURRENT_TIMESTAMP"),
+ (extra: "ON UPDATE CURRENT_TIMESTAMP", expected: "CURRENT_TIMESTAMP"),
+ (extra: "on update CURRENT_TIMESTAMP(6)", expected: "CURRENT_TIMESTAMP"),
+ (extra: "DEFAULT_GENERATED on update CURRENT_TIMESTAMP", expected: "CURRENT_TIMESTAMP"),
+ (extra: "DEFAULT_GENERATED on update CURRENT_TIMESTAMP(3)", expected: "CURRENT_TIMESTAMP")
+ ]
+ )
+ func onUpdateParsing(extra: String?, expected: String?) {
+ let columnInfo = ColumnInfo(
+ name: "updated_at",
+ dataType: "timestamp",
+ isNullable: false,
+ isPrimaryKey: false,
+ defaultValue: nil,
+ extra: extra,
+ charset: nil,
+ collation: nil,
+ comment: nil
+ )
+
+ #expect(EditableColumnDefinition.from(columnInfo).onUpdate == expected)
+ }
+
+ @Test("A placeholder column carries no on-update attribute")
+ func placeholderHasNoOnUpdate() {
+ #expect(EditableColumnDefinition.placeholder().onUpdate == nil)
}
}
diff --git a/TableProTests/Plugins/MySQLColumnDefinitionSQLTests.swift b/TableProTests/Plugins/MySQLColumnDefinitionSQLTests.swift
new file mode 100644
index 000000000..75265078e
--- /dev/null
+++ b/TableProTests/Plugins/MySQLColumnDefinitionSQLTests.swift
@@ -0,0 +1,162 @@
+//
+// MySQLColumnDefinitionSQLTests.swift
+// TableProTests
+//
+// MySQL restates a column in full for MODIFY/CHANGE COLUMN, so every attribute the
+// clause builder omits is dropped by the server. These cover the attributes that a
+// round trip has to carry through an edit to an unrelated field.
+//
+
+import TableProPluginKit
+import Testing
+
+@Suite("MySQL Column Definition SQL")
+struct MySQLColumnDefinitionSQLTests {
+ private func timestampColumn(
+ dataType: String = "TIMESTAMP",
+ defaultValue: String? = nil,
+ onUpdate: String? = nil,
+ comment: String? = nil
+ ) -> PluginColumnDefinition {
+ PluginColumnDefinition(
+ name: "updated_at",
+ dataType: dataType,
+ isNullable: false,
+ defaultValue: defaultValue,
+ comment: comment,
+ onUpdate: onUpdate
+ )
+ }
+
+ // MARK: - On Update
+
+ @Test("On update renders for a timestamp column")
+ func onUpdateRenders() {
+ let sql = mysqlColumnDefinitionSQL(
+ timestampColumn(defaultValue: "CURRENT_TIMESTAMP", onUpdate: "CURRENT_TIMESTAMP")
+ )
+ #expect(sql.contains("DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"))
+ }
+
+ @Test("On update adopts the column's fractional-second precision")
+ func onUpdateDerivesPrecision() {
+ let sql = mysqlColumnDefinitionSQL(
+ timestampColumn(dataType: "TIMESTAMP(6)", onUpdate: "CURRENT_TIMESTAMP")
+ )
+ #expect(sql.contains("ON UPDATE CURRENT_TIMESTAMP(6)"))
+ }
+
+ @Test("On update precision is re-derived rather than trusted")
+ func onUpdateOverridesStalePrecision() {
+ let sql = mysqlColumnDefinitionSQL(
+ timestampColumn(dataType: "DATETIME(3)", onUpdate: "CURRENT_TIMESTAMP(6)")
+ )
+ #expect(sql.contains("ON UPDATE CURRENT_TIMESTAMP(3)"))
+ #expect(!sql.contains("CURRENT_TIMESTAMP(6)"))
+ }
+
+ @Test("An expression outside the whitelist is omitted, never emitted raw")
+ func onUpdateRejectsUnknownExpression() {
+ let sql = mysqlColumnDefinitionSQL(timestampColumn(onUpdate: "NOW()"))
+ #expect(!sql.contains("ON UPDATE"))
+ #expect(!sql.contains("NOW()"))
+ }
+
+ @Test("No on update attribute emits no clause")
+ func onUpdateAbsent() {
+ let sql = mysqlColumnDefinitionSQL(timestampColumn(defaultValue: "CURRENT_TIMESTAMP"))
+ #expect(!sql.contains("ON UPDATE"))
+ }
+
+ @Test("Editing an unrelated attribute keeps the on update clause")
+ func onUpdateSurvivesCommentEdit() {
+ let sql = mysqlColumnDefinitionSQL(
+ timestampColumn(
+ defaultValue: "CURRENT_TIMESTAMP", onUpdate: "CURRENT_TIMESTAMP", comment: "touched"
+ )
+ )
+ #expect(sql.contains("ON UPDATE CURRENT_TIMESTAMP"))
+ #expect(sql.contains("COMMENT 'touched'"))
+ }
+
+ // MARK: - Default Value
+
+ @Test("A fractional-second default is an expression, not a quoted literal")
+ func fractionalDefaultIsNotQuoted() {
+ let sql = mysqlColumnDefinitionSQL(
+ timestampColumn(dataType: "TIMESTAMP(6)", defaultValue: "CURRENT_TIMESTAMP(6)")
+ )
+ #expect(sql.contains("DEFAULT CURRENT_TIMESTAMP(6)"))
+ #expect(!sql.contains("'CURRENT_TIMESTAMP"))
+ }
+
+ @Test("A bare CURRENT_TIMESTAMP default is unquoted")
+ func bareDefaultIsNotQuoted() {
+ let sql = mysqlColumnDefinitionSQL(timestampColumn(defaultValue: "CURRENT_TIMESTAMP"))
+ #expect(sql.contains("DEFAULT CURRENT_TIMESTAMP"))
+ #expect(!sql.contains("'CURRENT_TIMESTAMP'"))
+ }
+
+ @Test("A non-expression default is still quoted and escaped")
+ func plainDefaultStaysQuoted() {
+ let column = PluginColumnDefinition(
+ name: "status", dataType: "VARCHAR(16)", isNullable: false, defaultValue: "it's active"
+ )
+ #expect(mysqlColumnDefinitionSQL(column).contains("DEFAULT 'it''s active'"))
+ }
+
+ @Test("A numeric default is unquoted")
+ func numericDefaultStaysUnquoted() {
+ let column = PluginColumnDefinition(
+ name: "qty", dataType: "INT", isNullable: false, defaultValue: "0"
+ )
+ #expect(mysqlColumnDefinitionSQL(column).contains("DEFAULT 0"))
+ }
+
+ // MARK: - Precision Extraction
+
+ @Test(
+ "Fractional-second precision comes from the declared type only",
+ arguments: [
+ (dataType: "TIMESTAMP", expected: ""),
+ (dataType: "TIMESTAMP(6)", expected: "(6)"),
+ (dataType: "timestamp(3)", expected: "(3)"),
+ (dataType: "DATETIME", expected: ""),
+ (dataType: "DATETIME(0)", expected: "(0)"),
+ (dataType: "VARCHAR(255)", expected: ""),
+ (dataType: "ENUM('a(1)','b')", expected: "")
+ ]
+ )
+ func precisionExtraction(dataType: String, expected: String) {
+ #expect(mysqlFractionalSecondsSuffix(forDataType: dataType) == expected)
+ }
+
+ // MARK: - Other Attributes
+
+ @Test("Charset, collation, unsigned, and auto increment all render")
+ func attributesRender() {
+ let column = PluginColumnDefinition(
+ name: "id",
+ dataType: "BIGINT",
+ isNullable: false,
+ autoIncrement: true,
+ unsigned: true,
+ charset: "utf8mb4",
+ collation: "utf8mb4_general_ci"
+ )
+
+ let sql = mysqlColumnDefinitionSQL(column)
+ #expect(sql.contains("`id` BIGINT"))
+ #expect(sql.contains("UNSIGNED"))
+ #expect(sql.contains("CHARACTER SET utf8mb4"))
+ #expect(sql.contains("COLLATE utf8mb4_general_ci"))
+ #expect(sql.contains("NOT NULL"))
+ #expect(sql.contains("AUTO_INCREMENT"))
+ }
+
+ @Test("A backtick in a column name is escaped")
+ func backtickEscaping() {
+ let column = PluginColumnDefinition(name: "col`name", dataType: "INT")
+ #expect(mysqlColumnDefinitionSQL(column).contains("`col``name`"))
+ }
+}
diff --git a/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift b/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift
new file mode 100644
index 000000000..7db34aaa6
--- /dev/null
+++ b/TableProTests/Views/Structure/StructureColumnFieldRegistrationTests.swift
@@ -0,0 +1,54 @@
+//
+// StructureColumnFieldRegistrationTests.swift
+// TableProTests
+//
+// MySQL and MariaDB share one plugin but are registered separately: MariaDB is an
+// additional type id, and a variant adopts the app's curated snapshot wholesale rather
+// than the one built from the plugin. A field added to only one of the declarations
+// therefore reaches only one of the two engines.
+//
+
+import Foundation
+@testable import TablePro
+import TableProPluginKit
+import Testing
+
+@MainActor @Suite("Structure column field registration")
+struct StructureColumnFieldRegistrationTests {
+ @Test("MySQL and MariaDB expose the same structure fields")
+ func mysqlAndMariaDBAgree() {
+ let mysql = PluginManager.shared.structureColumnFields(for: .mysql)
+ let mariadb = PluginManager.shared.structureColumnFields(for: .mariadb)
+ #expect(Set(mysql) == Set(mariadb))
+ }
+
+ @Test("MySQL and MariaDB both offer the on update field", arguments: [DatabaseType.mysql, .mariadb])
+ func onUpdateIsOffered(databaseType: DatabaseType) {
+ #expect(PluginManager.shared.structureColumnFields(for: databaseType).contains(.onUpdate))
+ }
+
+ @Test("On update is ordered next to the default it complements")
+ func onUpdateFollowsDefaultValue() {
+ let fields = StructureRowProvider.orderedFields(for: .mysql)
+ guard let defaultIndex = fields.firstIndex(of: .defaultValue),
+ let onUpdateIndex = fields.firstIndex(of: .onUpdate) else {
+ Issue.record("MySQL is missing the default or on update field")
+ return
+ }
+ #expect(onUpdateIndex == defaultIndex + 1)
+ }
+
+ @Test("Engines that do not support the attribute never offer it")
+ func onUpdateIsEngineScoped() {
+ for databaseType in [DatabaseType.postgresql, .sqlite, .clickhouse] {
+ #expect(!PluginManager.shared.structureColumnFields(for: databaseType).contains(.onUpdate))
+ }
+ }
+
+ @Test("Every declared field has a display name")
+ func everyFieldHasDisplayName() {
+ for field in StructureColumnField.allCases {
+ #expect(!field.displayName.isEmpty)
+ }
+ }
+}
diff --git a/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift b/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift
index 6f0339519..9653ddb64 100644
--- a/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift
+++ b/TableProTests/Views/Structure/StructureEditingSupportBooleanParsingTests.swift
@@ -19,6 +19,10 @@ struct StructureEditingSupportBooleanParsingTests {
.name, .type, .nullable, .defaultValue, .primaryKey, .autoIncrement, .comment
]
+ private static let mysqlOrderedFields: [StructureColumnField] = [
+ .name, .type, .nullable, .defaultValue, .onUpdate, .primaryKey, .autoIncrement, .comment
+ ]
+
private static let trueTokens = ["YES", "yes", "Yes", "TRUE", "true", "True", "1"]
private static let falseTokens = ["NO", "no", "FALSE", "false", "0", "", "maybe"]
@@ -113,6 +117,33 @@ struct StructureEditingSupportBooleanParsingTests {
#expect(column.autoIncrement)
}
+ // MARK: - On Update
+
+ @Test("On Update accepts every true token", arguments: trueTokens)
+ func onUpdateAcceptsEveryTrueToken(token: String) {
+ var column = makeColumn()
+ StructureEditingSupport.updateColumn(
+ &column,
+ at: Self.mysqlOrderedFields.firstIndex(of: .onUpdate) ?? -1,
+ with: token,
+ orderedFields: Self.mysqlOrderedFields
+ )
+ #expect(column.onUpdate == "CURRENT_TIMESTAMP")
+ }
+
+ @Test("On Update clears on a false token", arguments: falseTokens)
+ func onUpdateClears(token: String) {
+ var column = makeColumn()
+ column.onUpdate = "CURRENT_TIMESTAMP"
+ StructureEditingSupport.updateColumn(
+ &column,
+ at: Self.mysqlOrderedFields.firstIndex(of: .onUpdate) ?? -1,
+ with: token,
+ orderedFields: Self.mysqlOrderedFields
+ )
+ #expect(column.onUpdate == nil)
+ }
+
// MARK: - Primary key implies NOT NULL
@Test("Marking a column as the primary key clears Nullable")
diff --git a/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift b/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift
index ff40043e9..d243e75b1 100644
--- a/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift
+++ b/TableProTests/Views/Structure/StructureEditingSupportFieldDiffTests.swift
@@ -19,7 +19,7 @@ struct StructureEditingSupportFieldDiffTests {
// MARK: - Fixtures
private static let mysqlOrderedFields: [StructureColumnField] = [
- .name, .type, .nullable, .defaultValue, .primaryKey,
+ .name, .type, .nullable, .defaultValue, .onUpdate, .primaryKey,
.autoIncrement, .comment, .charset, .collation
]
@@ -111,8 +111,8 @@ struct StructureEditingSupportFieldDiffTests {
new: changed,
orderedFields: Self.mysqlOrderedFields
)
- // .type is at index 1, .comment is at index 6 in mysqlOrderedFields.
- #expect(result == [1, 6])
+ // .type is at index 1, .comment is at index 7 in mysqlOrderedFields.
+ #expect(result == [1, 7])
}
@Test("Diff respects orderedFields and skips fields not displayed by the database type")
@@ -131,7 +131,7 @@ struct StructureEditingSupportFieldDiffTests {
#expect(result.isEmpty)
}
- @Test("All nine StructureColumnField cases are diffable")
+ @Test("Every StructureColumnField case is diffable")
func columnEveryFieldDetected() {
let original = makeColumn(name: "a", dataType: "INT")
var changed = original
@@ -139,6 +139,7 @@ struct StructureEditingSupportFieldDiffTests {
changed.dataType = "BIGINT"
changed.isNullable.toggle()
changed.defaultValue = "0"
+ changed.onUpdate = "CURRENT_TIMESTAMP"
changed.isPrimaryKey.toggle()
changed.autoIncrement.toggle()
changed.comment = "x"
diff --git a/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift b/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift
index 97b389e40..6d1ee6fdd 100644
--- a/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift
+++ b/TableProTests/Views/Structure/StructureRowProviderBooleanOptionsTests.swift
@@ -41,7 +41,7 @@ struct StructureRowProviderBooleanOptionsTests {
changeManager: makeManager(),
tab: tab,
databaseType: .postgresql,
- additionalFields: [.primaryKey],
+ additionalFields: [.primaryKey, .onUpdate],
filterText: nil,
sortDescriptor: nil
)
@@ -52,7 +52,7 @@ struct StructureRowProviderBooleanOptionsTests {
let provider = makeProvider()
let options = provider.customDropdownOptions
- for field in [StructureColumnField.nullable, .primaryKey, .autoIncrement] {
+ for field in [StructureColumnField.nullable, .primaryKey, .autoIncrement, .onUpdate] {
guard let index = provider.orderedColumnFields.firstIndex(of: field) else {
Issue.record("Field \(field) missing from the columns tab")
continue
diff --git a/docs/features/table-structure.mdx b/docs/features/table-structure.mdx
index 22785f2cc..09a555609 100644
--- a/docs/features/table-structure.mdx
+++ b/docs/features/table-structure.mdx
@@ -22,6 +22,7 @@ Columns are edited inline in the grid:
- Setting **Primary Key** to YES sets **Nullable** to NO. A primary key column cannot hold NULL, so Nullable stays NO until you turn the primary key off.
- **Type** opens a picker with the database's types grouped by category. Search to filter, or type a parametric value such as `VARCHAR(255)` or `DECIMAL(10,2)` and press Return to use it as written.
- **Charset** and **Collation** columns appear for MySQL and MariaDB.
+- **On Update** appears for MySQL and MariaDB, next to **Default**. Set it to YES on a `TIMESTAMP` or `DATETIME` column to add `ON UPDATE CURRENT_TIMESTAMP`, so the column re-stamps itself on every write. TablePro matches the fractional-second precision to the column's own type, so a `TIMESTAMP(6)` column gets `ON UPDATE CURRENT_TIMESTAMP(6)`.