Skip to content

Commit d9e5173

Browse files
committed
feat(plugins): reorder columns by rebuilding the table on PostgreSQL, SQLite, libSQL, Turso and Cloudflare D1
1 parent 5331169 commit d9e5173

12 files changed

Lines changed: 845 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
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)
2020
- 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)
2122

2223
### Changed
2324

Plugins/CloudflareD1DriverPlugin/CloudflareD1PluginDriver.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -714,6 +714,26 @@ 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+
/// Not run by TablePro. The rebuild holds one transaction across several statements and this
721+
/// driver's transport gives no guarantee that they reach the same session, so the script is
722+
/// handed over for the user to run where they can see it through.
723+
func generateColumnReorderPlan(
724+
table: String,
725+
schema: String?,
726+
columns: [PluginColumnDefinition],
727+
desiredOrder: [String]
728+
) async throws -> PluginColumnReorderPlan? {
729+
try await SQLiteColumnReorderPlanner.plan(
730+
tableName: table,
731+
desiredOrder: desiredOrder,
732+
isRunnable: false,
733+
execute: { try await self.execute(query: $0) }
734+
)
735+
}
736+
717737
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
718738
let uniqueStr = index.isUnique ? "UNIQUE " : ""
719739
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")

Plugins/LibSQLDriverPlugin/LibSQLPluginDriver.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -730,6 +730,26 @@ 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+
/// Not run by TablePro. The rebuild holds one transaction across several statements and this
737+
/// driver's transport gives no guarantee that they reach the same session, so the script is
738+
/// handed over for the user to run where they can see it through.
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: false,
749+
execute: { try await self.execute(query: $0) }
750+
)
751+
}
752+
733753
func generateAddIndexSQL(table: String, index: PluginIndexDefinition) -> String? {
734754
let uniqueStr = index.isUnique ? "UNIQUE " : ""
735755
let cols = index.columns.map { quoteIdentifier($0) }.joined(separator: ", ")
Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
//
2+
// PostgreSQLPluginDriver+ColumnReorder.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
9+
extension PostgreSQLPluginDriver {
10+
/// PostgreSQL stores column order as `pg_attribute.attnum` and offers nothing that changes it,
11+
/// so the order changes by recreating the table and copying its rows.
12+
///
13+
/// TablePro writes the script and does not run it. The catalog describes the table's columns,
14+
/// constraints, indexes, triggers and comments, all through server functions that produce
15+
/// canonical text, but it does not hand back everything a table can carry: the caveats name
16+
/// what a rebuild leaves behind. Running that behind a button would report success over a lost
17+
/// grant or a policy that no longer applies, so the script goes to the user instead.
18+
func generateColumnReorderPlan(
19+
table: String,
20+
schema: String?,
21+
columns: [PluginColumnDefinition],
22+
desiredOrder: [String]
23+
) async throws -> PluginColumnReorderPlan? {
24+
let resolvedSchema = schema ?? core.currentSchema
25+
let parts = try await fetchRebuildParts(table: table, schema: resolvedSchema)
26+
guard !parts.columnDefinitions.isEmpty else { return nil }
27+
guard parts.columnNames != desiredOrder,
28+
Set(parts.columnNames) == Set(desiredOrder),
29+
parts.columnNames.count == desiredOrder.count else { return nil }
30+
31+
let qualified = "\(quoteIdentifier(resolvedSchema)).\(quoteIdentifier(table))"
32+
let staging = "\(quoteIdentifier(resolvedSchema)).\(quoteIdentifier("\(table)_tablepro_reorder"))"
33+
let copyList = parts.copyableColumns.map { quoteIdentifier($0) }.joined(separator: ", ")
34+
35+
let body = desiredOrder.compactMap { parts.columnDefinitions[$0] }
36+
37+
/// The order here is the whole difficulty, and every step of it was measured against
38+
/// PostgreSQL 17. The old table is renamed rather than dropped, so a foreign key in another
39+
/// table keeps pointing at real rows while the copy runs. But a rename moves nothing else:
40+
/// the staging table still owns every index name and every constraint name the original
41+
/// had, and both live in the schema rather than on the table. Declaring the constraints
42+
/// inside the `CREATE TABLE` therefore silently renames them, which shipped as `x_pkey1`,
43+
/// `x_a_b_key1` and `x_c_check1`; creating an index before the staging table goes fails
44+
/// outright with "relation already exists". So nothing that carries a name is created until
45+
/// the staging table is dropped, and the staging table cannot be dropped until every
46+
/// inbound foreign key has let go of it.
47+
var statements = ["BEGIN"]
48+
statements.append("ALTER TABLE \(qualified) RENAME TO \(quoteIdentifier("\(table)_tablepro_reorder"))")
49+
statements.append("CREATE TABLE \(qualified) (\n " + body.joined(separator: ",\n ") + "\n)")
50+
statements.append("INSERT INTO \(qualified) (\(copyList)) SELECT \(copyList) FROM \(staging)")
51+
statements.append(contentsOf: parts.identityResets(qualified: qualified, quote: quoteIdentifier))
52+
statements.append(contentsOf: parts.inboundForeignKeyDrops)
53+
statements.append("DROP TABLE \(staging)")
54+
statements.append(contentsOf: parts.tableConstraints.map { "ALTER TABLE \(qualified) ADD \($0)" })
55+
statements.append(contentsOf: parts.outboundForeignKeys.map { "ALTER TABLE \(qualified) ADD \($0)" })
56+
statements.append(contentsOf: parts.inboundForeignKeyAdds)
57+
statements.append(contentsOf: parts.indexes)
58+
statements.append(contentsOf: parts.triggers)
59+
statements.append(contentsOf: parts.comments)
60+
statements.append("COMMIT")
61+
62+
return PluginColumnReorderPlan(
63+
statements: statements,
64+
rollbackStatements: ["ROLLBACK"],
65+
cost: .tableRebuild,
66+
caveats: [
67+
String(localized: "Grants, row-level security policies, publications, extended statistics, partitioning and table inheritance are not carried over."),
68+
String(localized: "A column collation that differs from its type default is not reproduced."),
69+
String(localized: "A sequence owned by a serial column is dropped with the old table; an identity column is reset to its current maximum.")
70+
],
71+
isRunnable: false
72+
)
73+
}
74+
75+
private struct RebuildParts {
76+
var columnNames: [String] = []
77+
var columnDefinitions: [String: String] = [:]
78+
var copyableColumns: [String] = []
79+
var identityColumns: [String] = []
80+
var tableConstraints: [String] = []
81+
var outboundForeignKeys: [String] = []
82+
var inboundForeignKeyDrops: [String] = []
83+
var inboundForeignKeyAdds: [String] = []
84+
var indexes: [String] = []
85+
var triggers: [String] = []
86+
var comments: [String] = []
87+
88+
/// A new identity column starts its sequence at one, so it is wound forward to the rows the
89+
/// copy just wrote. Without this the next insert collides with an existing key.
90+
func identityResets(qualified: String, quote: (String) -> String) -> [String] {
91+
identityColumns.map { column in
92+
let quoted = quote(column)
93+
return """
94+
SELECT setval(
95+
pg_get_serial_sequence('\(qualified)', '\(column.replacingOccurrences(of: "'", with: "''"))'),
96+
GREATEST(COALESCE((SELECT MAX(\(quoted)) FROM \(qualified)), 0), 1),
97+
true
98+
)
99+
"""
100+
}
101+
}
102+
}
103+
104+
private func fetchRebuildParts(table: String, schema: String) async throws -> RebuildParts {
105+
let safeTable = escapeLiteral(table)
106+
let safeSchema = escapeLiteral(schema)
107+
let caps = versionedCapabilities
108+
var parts = RebuildParts()
109+
110+
let identityClause = caps.hasIdentityColumns ? """
111+
CASE
112+
WHEN a.attidentity = 'a' THEN ' GENERATED ALWAYS AS IDENTITY'
113+
WHEN a.attidentity = 'd' THEN ' GENERATED BY DEFAULT AS IDENTITY'
114+
ELSE ''
115+
END ||
116+
""" : ""
117+
let generatedClause = caps.hasGeneratedColumns ? """
118+
CASE
119+
WHEN a.attgenerated = 's' THEN ' GENERATED ALWAYS AS (' || pg_get_expr(d.adbin, d.adrelid) || ') STORED'
120+
ELSE ''
121+
END ||
122+
""" : ""
123+
let defaultGuard = [
124+
caps.hasIdentityColumns ? "AND a.attidentity = ''" : "",
125+
caps.hasGeneratedColumns ? "AND a.attgenerated = ''" : ""
126+
].filter { !$0.isEmpty }.joined(separator: " ")
127+
let identityFlag = caps.hasIdentityColumns ? "a.attidentity <> ''" : "false"
128+
let generatedFlag = caps.hasGeneratedColumns ? "a.attgenerated <> ''" : "false"
129+
130+
let columnRows = try await execute(query: """
131+
SELECT
132+
a.attname,
133+
quote_ident(a.attname) || ' ' || format_type(a.atttypid, a.atttypmod) ||
134+
\(identityClause)
135+
\(generatedClause)
136+
CASE WHEN a.attnotnull THEN ' NOT NULL' ELSE '' END ||
137+
CASE
138+
WHEN a.atthasdef \(defaultGuard)
139+
THEN ' DEFAULT ' || pg_get_expr(d.adbin, d.adrelid)
140+
ELSE ''
141+
END,
142+
\(identityFlag),
143+
\(generatedFlag)
144+
FROM pg_attribute a
145+
JOIN pg_class c ON c.oid = a.attrelid
146+
JOIN pg_namespace n ON n.oid = c.relnamespace
147+
LEFT JOIN pg_attrdef d ON d.adrelid = c.oid AND d.adnum = a.attnum
148+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)'
149+
AND a.attnum > 0 AND NOT a.attisdropped
150+
ORDER BY a.attnum
151+
""").rows
152+
153+
for row in columnRows {
154+
guard let name = row[safe: 0]?.asText, let definition = row[safe: 1]?.asText else { continue }
155+
parts.columnNames.append(name)
156+
parts.columnDefinitions[name] = definition
157+
if isTrue(row[safe: 2]?.asText) { parts.identityColumns.append(name) }
158+
/// A generated column is computed, never written, so `INSERT` refuses it by name.
159+
if !isTrue(row[safe: 3]?.asText) { parts.copyableColumns.append(name) }
160+
}
161+
162+
/// Named, and added after the staging table is gone. Declared inline instead, PostgreSQL
163+
/// finds the name already taken and quietly picks another.
164+
parts.tableConstraints = try await textRows("""
165+
SELECT 'CONSTRAINT ' || quote_ident(con.conname) || ' ' || pg_get_constraintdef(con.oid, true)
166+
FROM pg_constraint con
167+
JOIN pg_class c ON c.oid = con.conrelid
168+
JOIN pg_namespace n ON n.oid = c.relnamespace
169+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)'
170+
AND con.contype IN ('p', 'u', 'c')
171+
ORDER BY CASE con.contype WHEN 'p' THEN 0 WHEN 'u' THEN 1 ELSE 2 END, con.conname
172+
""")
173+
174+
parts.outboundForeignKeys = try await textRows("""
175+
SELECT 'CONSTRAINT ' || quote_ident(con.conname) || ' ' || pg_get_constraintdef(con.oid, true)
176+
FROM pg_constraint con
177+
JOIN pg_class c ON c.oid = con.conrelid
178+
JOIN pg_namespace n ON n.oid = c.relnamespace
179+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' AND con.contype = 'f'
180+
ORDER BY con.conname
181+
""")
182+
183+
/// A key in another table follows the rename, so it now points at the staging table and is
184+
/// the only thing keeping it alive. Dropping every one is what lets the staging table go;
185+
/// re-adding them against the rebuilt table happens once its primary key is back.
186+
let inboundClause = """
187+
FROM pg_constraint con
188+
JOIN pg_class c ON c.oid = con.confrelid
189+
JOIN pg_namespace n ON n.oid = c.relnamespace
190+
JOIN pg_class c2 ON c2.oid = con.conrelid
191+
JOIN pg_namespace n2 ON n2.oid = c2.relnamespace
192+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' AND con.contype = 'f'
193+
ORDER BY con.conname
194+
"""
195+
parts.inboundForeignKeyDrops = try await textRows("""
196+
SELECT 'ALTER TABLE ' || quote_ident(n2.nspname) || '.' || quote_ident(c2.relname)
197+
|| ' DROP CONSTRAINT ' || quote_ident(con.conname)
198+
\(inboundClause)
199+
""")
200+
parts.inboundForeignKeyAdds = try await textRows("""
201+
SELECT 'ALTER TABLE ' || quote_ident(n2.nspname) || '.' || quote_ident(c2.relname)
202+
|| ' ADD CONSTRAINT ' || quote_ident(con.conname) || ' ' || pg_get_constraintdef(con.oid, true)
203+
\(inboundClause)
204+
""")
205+
206+
/// The indexes a constraint owns come back with the constraint, so listing them again would
207+
/// fail on a duplicate name.
208+
parts.indexes = try await textRows("""
209+
SELECT indexdef FROM pg_indexes
210+
WHERE tablename = '\(safeTable)' AND schemaname = '\(safeSchema)'
211+
AND indexname NOT IN (
212+
SELECT con.conname FROM pg_constraint con
213+
JOIN pg_class c ON c.oid = con.conrelid
214+
JOIN pg_namespace n ON n.oid = c.relnamespace
215+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)'
216+
)
217+
ORDER BY indexname
218+
""")
219+
220+
parts.triggers = try await textRows("""
221+
SELECT pg_get_triggerdef(t.oid, true)
222+
FROM pg_trigger t
223+
JOIN pg_class c ON c.oid = t.tgrelid
224+
JOIN pg_namespace n ON n.oid = c.relnamespace
225+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)' AND NOT t.tgisinternal
226+
ORDER BY t.tgname
227+
""")
228+
229+
parts.comments = try await textRows("""
230+
SELECT 'COMMENT ON TABLE ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname)
231+
|| ' IS ' || quote_literal(obj_description(c.oid, 'pg_class'))
232+
FROM pg_class c
233+
JOIN pg_namespace n ON n.oid = c.relnamespace
234+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)'
235+
AND obj_description(c.oid, 'pg_class') IS NOT NULL
236+
UNION ALL
237+
SELECT 'COMMENT ON COLUMN ' || quote_ident(n.nspname) || '.' || quote_ident(c.relname)
238+
|| '.' || quote_ident(a.attname)
239+
|| ' IS ' || quote_literal(col_description(c.oid, a.attnum))
240+
FROM pg_attribute a
241+
JOIN pg_class c ON c.oid = a.attrelid
242+
JOIN pg_namespace n ON n.oid = c.relnamespace
243+
WHERE c.relname = '\(safeTable)' AND n.nspname = '\(safeSchema)'
244+
AND a.attnum > 0 AND NOT a.attisdropped
245+
AND col_description(c.oid, a.attnum) IS NOT NULL
246+
""")
247+
248+
return parts
249+
}
250+
251+
private func textRows(_ query: String) async throws -> [String] {
252+
try await execute(query: query).rows.compactMap { $0[safe: 0]?.asText }
253+
}
254+
255+
/// libpq reports a boolean as `t` on the text protocol and the driver may hand it back either
256+
/// way, so both spellings are accepted rather than one being assumed.
257+
private func isTrue(_ value: String?) -> Bool {
258+
guard let value else { return false }
259+
return value == "t" || value.lowercased() == "true"
260+
}
261+
}

Plugins/SQLiteDriverPlugin/SQLitePlugin.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1217,6 +1217,26 @@ final class SQLitePluginDriver: PluginDatabaseDriver, @unchecked Sendable {
12171217
"ALTER TABLE \(quoteIdentifier(table)) DROP COLUMN \(quoteIdentifier(columnName))"
12181218
}
12191219

1220+
/// SQLite has no positional `ALTER`, so the order changes by rebuilding the table.
1221+
///
1222+
/// The new table is written by moving the original column definitions as text inside the
1223+
/// statement SQLite stored, so a `CHECK`, a `COLLATE`, a `GENERATED ALWAYS AS` and a `DEFAULT`
1224+
/// with a comma in it all come through untouched. Re-rendering them from `PRAGMA table_info`
1225+
/// would lose every one, because the pragma does not report them.
1226+
func generateColumnReorderPlan(
1227+
table: String,
1228+
schema: String?,
1229+
columns: [PluginColumnDefinition],
1230+
desiredOrder: [String]
1231+
) async throws -> PluginColumnReorderPlan? {
1232+
try await SQLiteColumnReorderPlanner.plan(
1233+
tableName: table,
1234+
desiredOrder: desiredOrder,
1235+
isRunnable: true,
1236+
execute: { try await self.execute(query: $0) }
1237+
)
1238+
}
1239+
12201240
/// ADD/DROP CONSTRAINT arrived in SQLite 3.53.0. Returning nil below that version makes
12211241
/// `SchemaStatementGenerator` refuse the change with "Unsupported schema operation" rather than
12221242
/// sending a statement the linked library cannot parse.

0 commit comments

Comments
 (0)