|
| 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 | +} |
0 commit comments