Skip to content

Commit 38802cb

Browse files
authored
feat(datagrid): reorder table columns by dragging on every engine that can, and say why on the ones that cannot (#2586)
* feat(datagrid): offer the column reorder drag only where the engine can reorder, and add ClickHouse and Oracle * feat(plugins): reorder columns by rebuilding the table on PostgreSQL, SQLite, libSQL, Turso and Cloudflare D1 * fix(plugins): keep a generated column generated when it is moved, and copy an identity column into the rebuilt table * fix(datagrid): withhold the column drag on a rearranged list, and refuse to rebuild a virtual table * fix(plugin-postgresql): name the views that will stop a column rebuild before it runs * fix(plugins): run a column reorder on the tab's own scope, authorize it once, and stop the rebuild losing table state
1 parent 99bf8fb commit 38802cb

47 files changed

Lines changed: 2276 additions & 196 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Tab rows in Settings > General > Tabs, wrapping the strip instead of scrolling it. (#2438)
1818
- Autoscrolling while dragging a tab, so a tab can be moved past the run currently on screen. (#2438)
1919
- Move Tab to New Window on a tab's right-click menu, and by dragging a tab out of the strip. (#2438)
20+
- Column reorder by dragging on ClickHouse and Oracle. (#2479)
21+
- Column reorder on PostgreSQL, SQLite, libSQL, Turso and Cloudflare D1, through a table rebuild shown before anything runs. (#2479)
2022
- Recognition of SQLite and DuckDB databases by their contents, whatever they are named. (#2476)
2123
- `.parquet` files in Finder's Open With, read through DuckDB. (#2476)
2224
- Prompt to install the driver a file needs, before the file opens. (#2476)
@@ -27,6 +29,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2729
- MongoDB editor diagnostics report JavaScript syntax errors rather than unsupported method names.
2830
- Editor tab presses handled by AppKit rather than SwiftUI gestures. (#2438)
2931
- Connection-first labels with the database or schema on a second line in the connections strip. (#2550)
32+
- Column reorder withheld, with the reason on the row number, where the engine cannot change column order. (#2479)
3033
- File > Open File… as an app command over every file TablePro reads. (#2476)
3134

3235
### Fixed

Plugins/ClickHouseDriverPlugin/ClickHousePlugin.swift

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,52 @@ final class ClickHousePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
827827
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
828828
}
829829

830+
/// `ALTER TABLE … MODIFY COLUMN name type FIRST | AFTER other`.
831+
///
832+
/// Measured against 26.8: the type is mandatory (`MODIFY COLUMN c AFTER b` is a syntax error),
833+
/// and naming it alone is enough. `MODIFY COLUMN` changes only the properties the statement
834+
/// spells out, so the default, comment, codec, TTL and the MATERIALIZED, ALIAS and EPHEMERAL
835+
/// kinds all survive a move, and the statement rewrites metadata without starting a mutation.
836+
/// Restating the full definition instead would rewrite a MATERIALIZED column as a DEFAULT one
837+
/// and requote every expression default.
838+
///
839+
/// The type comes from the server rather than from the caller's definition, because it has to
840+
/// be the stored type down to the `Nullable(…)` wrapper and a round trip is cheaper than a
841+
/// column that comes back with a different type than it went in with.
842+
func generateColumnReorderPlan(
843+
table: String,
844+
schema: String?,
845+
columns: [PluginColumnDefinition],
846+
desiredOrder: [String]
847+
) async throws -> PluginColumnReorderPlan? {
848+
let storedTypes = try await fetchStoredColumnTypes(table: table)
849+
let currentOrder = storedTypes.map(\.name)
850+
let statements = PluginColumnReorderPlanner
851+
.moves(from: currentOrder, to: desiredOrder)
852+
.compactMap { move -> String? in
853+
guard let type = storedTypes.first(where: { $0.name == move.column })?.type else { return nil }
854+
let position = move.afterColumn.map { "AFTER \(quoteIdentifier($0))" } ?? "FIRST"
855+
return "ALTER TABLE \(quoteIdentifier(table)) "
856+
+ "MODIFY COLUMN \(quoteIdentifier(move.column)) \(type) \(position)"
857+
}
858+
guard !statements.isEmpty else { return nil }
859+
return PluginColumnReorderPlan(statements: statements, cost: .metadataOnly)
860+
}
861+
862+
private func fetchStoredColumnTypes(table: String) async throws -> [(name: String, type: String)] {
863+
let escapedTable = table.replacingOccurrences(of: "'", with: "''")
864+
let result = try await execute(query: """
865+
SELECT name, type
866+
FROM system.columns
867+
WHERE database = currentDatabase() AND table = '\(escapedTable)'
868+
ORDER BY position
869+
""")
870+
return result.rows.compactMap { row in
871+
guard let name = row[safe: 0]?.asText, let type = row[safe: 1]?.asText else { return nil }
872+
return (name, type)
873+
}
874+
}
875+
830876
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
831877
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")
832878
let indexType = index.indexType ?? "minmax"

Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,32 @@ final class CloudflareD1PluginDriver: PluginDatabaseDriver, @unchecked Sendable
714714
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
715715
}
716716

717+
/// SQLite has no positional `ALTER`, so the order changes by rebuilding the table, using the
718+
/// shared recipe every SQLite-derived driver follows.
719+
///
720+
/// Never run by TablePro. D1 answers each statement over its own HTTP request, so nothing can
721+
/// hold the rebuild's transaction open across them, and a half-applied rebuild is data loss.
722+
func generateColumnReorderPlan(
723+
table: String,
724+
schema: String?,
725+
columns: [PluginColumnDefinition],
726+
desiredOrder: [String]
727+
) async throws -> PluginColumnReorderPlan? {
728+
try await SQLiteColumnReorderPlanner.plan(
729+
tableName: table,
730+
desiredOrder: desiredOrder,
731+
isRunnable: false,
732+
execute: { try await self.execute(query: $0) }
733+
)
734+
}
735+
736+
func columnReorderSchemaFingerprint(table: String, schema: String?) async throws -> String? {
737+
try await SQLiteColumnReorderPlanner.schemaFingerprint(
738+
tableName: table,
739+
execute: { try await self.execute(query: $0) }
740+
)
741+
}
742+
717743
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
718744
let uniqueStr = index.isUnique ? "UNIQUE " : ""
719745
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")

Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,33 @@ final class LibSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
730730
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
731731
}
732732

733+
/// SQLite has no positional `ALTER`, so the order changes by rebuilding the table, using the
734+
/// shared recipe every SQLite-derived driver follows.
735+
///
736+
/// Runnable only in local mode. A local database is a real SQLite handle that holds a
737+
/// transaction across statements, which is what makes the rebuild atomic; over HTTP each
738+
/// statement is its own request and the script has to be handed to the user instead.
739+
func generateColumnReorderPlan(
740+
table: String,
741+
schema: String?,
742+
columns: [PluginColumnDefinition],
743+
desiredOrder: [String]
744+
) async throws -> PluginColumnReorderPlan? {
745+
try await SQLiteColumnReorderPlanner.plan(
746+
tableName: table,
747+
desiredOrder: desiredOrder,
748+
isRunnable: isLocalMode,
749+
execute: { try await self.execute(query: $0) }
750+
)
751+
}
752+
753+
func columnReorderSchemaFingerprint(table: String, schema: String?) async throws -> String? {
754+
try await SQLiteColumnReorderPlanner.schemaFingerprint(
755+
tableName: table,
756+
execute: { try await self.execute(query: $0) }
757+
)
758+
}
759+
733760
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
734761
let uniqueStr = index.isUnique ? "UNIQUE " : ""
735762
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")

Plugins/MySQLDriverPlugin/MySQLColumnDefinitionSQL.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,18 @@ internal func mysqlColumnDefinitionSQL(_ column: PluginColumnDefinition) -> Stri
8888
// A generated column takes the expression in place of the ordinary default and auto-increment
8989
// attributes, and MySQL rejects most of them alongside it. The keyword is spelled out because
9090
// both MySQL and MariaDB default to VIRTUAL.
91+
// Charset and collation belong to the type, so they come before the expression. Leaving them
92+
// out reset a generated string column to the table defaults, because MODIFY COLUMN replaces
93+
// the whole definition and a reorder restates it.
9194
let kind = (column.generationKind ?? .virtual).rawValue
92-
var definition = "\(name) \(column.dataType) GENERATED ALWAYS AS (\(expression)) \(kind)"
95+
var definition = "\(name) \(column.dataType)"
96+
if let charset = column.charset, !charset.isEmpty {
97+
definition += " CHARACTER SET \(charset)"
98+
}
99+
if let collation = column.collation, !collation.isEmpty {
100+
definition += " COLLATE \(collation)"
101+
}
102+
definition += " GENERATED ALWAYS AS (\(expression)) \(kind)"
93103
if !column.isNullable { definition += " NOT NULL" }
94104
if let comment = column.comment, !comment.isEmpty {
95105
definition += " COMMENT '\(mysqlEscapeStringLiteral(comment))'"

Plugins/MySQLDriverPlugin/MySQLPluginDriver.swift

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -944,18 +944,31 @@ final class MySQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
944944

945945
func generateMoveColumnSQL(table: String, column: PluginColumnDefinition, afterColumn: String?) -> String? {
946946
let tableName = quoteIdentifier(table)
947-
let colName = quoteIdentifier(column.name)
948-
949-
let def = "\(column.dataType)" + mysqlColumnAttributesSQL(column)
950-
951-
let position: String
952-
if let afterCol = afterColumn {
953-
position = "AFTER \(quoteIdentifier(afterCol))"
954-
} else {
955-
position = "FIRST"
956-
}
957-
958-
return "ALTER TABLE \(tableName) MODIFY COLUMN \(colName) \(def) \(position)"
947+
let position = afterColumn.map { "AFTER \(quoteIdentifier($0))" } ?? "FIRST"
948+
/// The same builder `ADD COLUMN` uses, rather than the attribute list alone. `MODIFY`
949+
/// replaces the whole definition, and the attribute list does not carry
950+
/// `GENERATED ALWAYS AS`, so moving a generated column with it dropped the expression and
951+
/// left a plain column of stored defaults behind.
952+
return "ALTER TABLE \(tableName) MODIFY COLUMN \(buildColumnDefinitionSQL(column)) \(position)"
953+
}
954+
955+
/// `MODIFY COLUMN` replaces the whole definition, so every move restates the column in full.
956+
/// Restating only the type is what drops charset, collation and `ON UPDATE`.
957+
func generateColumnReorderPlan(
958+
table: String,
959+
schema: String?,
960+
columns: [PluginColumnDefinition],
961+
desiredOrder: [String]
962+
) async throws -> PluginColumnReorderPlan? {
963+
let byName = Dictionary(columns.map { ($0.name, $0) }, uniquingKeysWith: { first, _ in first })
964+
let statements = PluginColumnReorderPlanner
965+
.moves(from: columns.map(\.name), to: desiredOrder)
966+
.compactMap { move -> String? in
967+
guard let column = byName[move.column] else { return nil }
968+
return generateMoveColumnSQL(table: table, column: column, afterColumn: move.afterColumn)
969+
}
970+
guard !statements.isEmpty else { return nil }
971+
return PluginColumnReorderPlan(statements: statements, cost: .metadataOnly)
959972
}
960973

961974
// MARK: - View Templates

Plugins/OracleDriverPlugin/OraclePlugin.swift

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1034,6 +1034,48 @@ final class OraclePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
10341034
"ALTER TABLE \(oracleQualifiedTable(table)) DROP COLUMN \(quoteIdentifier(columnName))"
10351035
}
10361036

1037+
/// Oracle has no positional clause, but making a column invisible and visible again moves it to
1038+
/// the end of the visible order, so any order is reachable by appending the right suffix.
1039+
///
1040+
/// Measured against Oracle Free 23: the cycle works on the primary key, on an identity column
1041+
/// and on a virtual column; the rows, the default, the NOT NULL, the comment, the identity
1042+
/// sequence, the constraints, the indexes and the foreign keys pointing at the table all
1043+
/// survive it, and no data is read or written. Needs 12.1, where invisible columns arrived; an
1044+
/// older server rejects the statement and the error is reported as it is.
1045+
///
1046+
/// The two halves of a cycle are separate statements because Oracle commits each DDL on its
1047+
/// own, so a column is invisible for the width of one statement. Cycling one column at a time
1048+
/// keeps that window as small as it can be.
1049+
func generateColumnReorderPlan(
1050+
table: String,
1051+
schema: String?,
1052+
columns: [PluginColumnDefinition],
1053+
desiredOrder: [String]
1054+
) async throws -> PluginColumnReorderPlan? {
1055+
let qt = oracleQualifiedTable(table)
1056+
let currentOrder = try await fetchColumns(table: table, schema: schema).map(\.name)
1057+
let cycled = PluginColumnReorderPlanner.appendCycle(from: currentOrder, to: desiredOrder)
1058+
let statements = cycled.flatMap { column -> [String] in
1059+
let quoted = quoteIdentifier(column)
1060+
return [
1061+
"ALTER TABLE \(qt) MODIFY (\(quoted) INVISIBLE)",
1062+
"ALTER TABLE \(qt) MODIFY (\(quoted) VISIBLE)"
1063+
]
1064+
}
1065+
guard !statements.isEmpty else { return nil }
1066+
1067+
/// Oracle commits each DDL statement on its own, so there is no transaction to roll back:
1068+
/// a cycle whose `VISIBLE` half fails, on a dropped connection or a server error, leaves
1069+
/// that column hidden for good. Every cycled column gets a compensating `VISIBLE` that the
1070+
/// executor runs on any mid-plan failure, which is idempotent on a column that is already
1071+
/// visible and puts back the one that is not.
1072+
return PluginColumnReorderPlan(
1073+
statements: statements,
1074+
compensation: cycled.map { "ALTER TABLE \(qt) MODIFY (\(quoteIdentifier($0)) VISIBLE)" },
1075+
cost: .metadataOnly
1076+
)
1077+
}
1078+
10371079
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
10381080
oracleIndexDefinition(index, qualifiedTable: oracleQualifiedTable(table))
10391081
}

0 commit comments

Comments
 (0)