Skip to content

Commit 16d56df

Browse files
authored
fix(coordinator): compose structure and create table previews on the scope save runs on (#3071)
1 parent 0328f8f commit 16d56df

22 files changed

Lines changed: 1520 additions & 262 deletions

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
364364
- Table opened in one database shown in Open Quickly's Recent in every other database, and opened there.
365365
- Open Quickly's Recent split between the Connections scope and the other scopes, each showing about half.
366366
- MySQL and MariaDB column defaults on iPhone and iPad missing for DEFAULT NULL, and string defaults shown unquoted.
367+
- Structure and Create Table SQL Preview disagreeing with Save on the schema, primary key name or a SQLite foreign key.
368+
- Row import creating its new table in another schema than its rows, and PGlite primary key changes failing to save.
367369

368370
### Security
369371

‎TablePro/Core/Database/DatabaseManager+Schema.swift‎

Lines changed: 5 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -13,42 +13,21 @@ import TableProPluginKit
1313
// MARK: - Schema Changes
1414

1515
extension DatabaseManager {
16-
/// Execute schema changes (ALTER TABLE, CREATE INDEX, etc.) in a transaction of their own,
16+
/// Execute schema statements (ALTER TABLE, CREATE INDEX, etc.) in a transaction of their own,
1717
/// on the schema change route rather than the session driver a query tab may have left
1818
/// mid-transaction. The connection, database and schema all come from the editing tab's
1919
/// own scope, never from ambient session state that another window or tab can move.
2020
///
21-
/// Authorization sits between two scoped blocks rather than inside one: it awaits a
22-
/// confirmation sheet and Touch ID, and holding the connection's driver gate across a
23-
/// human prompt would freeze every other tab on that connection.
21+
/// Authorization sits outside the scoped block: it awaits a confirmation sheet and Touch ID,
22+
/// and holding the connection's driver gate across a human prompt would freeze every other
23+
/// tab on that connection.
2424
func executeSchemaChanges(
25-
tableName: String,
26-
changes: [SchemaChange],
25+
_ statements: [SchemaStatement],
2726
databaseType: DatabaseType,
2827
scope: DatabaseScope
2928
) async throws {
3029
let route = schemaChangeRoute(for: scope)
3130

32-
let statements = try await withScopedDriver(
33-
scope: scope, route: route, cancellation: .untracked
34-
) { driver in
35-
let pkConstraintName = await Self.fetchPrimaryKeyConstraintName(
36-
tableName: tableName,
37-
databaseType: databaseType,
38-
changes: changes,
39-
driver: driver
40-
)
41-
guard let resolvedPluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else {
42-
throw DatabaseError.unsupportedOperation
43-
}
44-
let generator = SchemaStatementGenerator(
45-
tableName: tableName,
46-
primaryKeyConstraintName: pkConstraintName,
47-
pluginDriver: resolvedPluginDriver
48-
)
49-
return try generator.generate(changes: changes)
50-
}
51-
5231
let combinedSQL = statements.map(\.sql).joined(separator: "\n")
5332
let schemaKind: OperationKind =
5433
QueryClassifier.classifyTier(combinedSQL, databaseType: databaseType) == .destructive
@@ -229,59 +208,4 @@ extension DatabaseManager {
229208
.changed(CatalogChange(connectionId: scope.connectionId, database: scope.database, kinds: .tables))
230209
)
231210
}
232-
233-
/// Query the actual primary key constraint name for PostgreSQL.
234-
/// Returns nil if the database is not PostgreSQL, no PK modification is pending,
235-
/// or the query fails (caller falls back to `{table}_pkey` convention).
236-
private static func fetchPrimaryKeyConstraintName(
237-
tableName: String,
238-
databaseType: DatabaseType,
239-
changes: [SchemaChange],
240-
driver: DatabaseDriver
241-
) async -> String? {
242-
// Only needed for PostgreSQL PK modifications
243-
guard databaseType == .postgresql || databaseType == .redshift
244-
|| databaseType == .cockroachdb || databaseType == .duckdb else { return nil }
245-
guard
246-
changes.contains(where: {
247-
if case .modifyPrimaryKey = $0 { return true }
248-
return false
249-
})
250-
else {
251-
return nil
252-
}
253-
254-
let escapedTable = tableName.replacingOccurrences(of: "'", with: "''")
255-
let schema: String
256-
if let schemaDriver = driver as? SchemaSwitchable,
257-
let escaped = schemaDriver.escapedSchema {
258-
schema = escaped
259-
} else {
260-
schema = "public"
261-
}
262-
let query = """
263-
SELECT con.conname
264-
FROM pg_constraint con
265-
JOIN pg_class rel ON rel.oid = con.conrelid
266-
JOIN pg_namespace nsp ON nsp.oid = rel.relnamespace
267-
WHERE rel.relname = '\(escapedTable)'
268-
AND nsp.nspname = '\(schema)'
269-
AND con.contype = 'p'
270-
LIMIT 1
271-
"""
272-
273-
do {
274-
let result = try await driver.execute(query: query)
275-
if let row = result.rows.first, let name = row[0].asText, !name.isEmpty {
276-
return name
277-
}
278-
} catch {
279-
// Query failed - fall back to convention in SchemaStatementGenerator
280-
Self.logger.warning(
281-
"Failed to query PK constraint name for '\(tableName)': \(error.localizedDescription)"
282-
)
283-
}
284-
285-
return nil
286-
}
287211
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
//
2+
// DatabaseManager+SchemaComposition.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import TableProPluginKit
8+
import TableProSQLGrammar
9+
10+
extension DatabaseManager {
11+
func withSchemaComposer<T: Sendable>(
12+
scope: DatabaseScope,
13+
route: ScopedDriverRoute,
14+
_ body: @Sendable @escaping (DatabaseDriver, any PluginDatabaseDriver) async throws -> T
15+
) async throws -> T {
16+
try await withScopedDriver(scope: scope, route: route, cancellation: .untracked) { driver in
17+
guard let pluginDriver = (driver as? PluginDriverAdapter)?.schemaPluginDriver else {
18+
throw DatabaseError.unsupportedOperation
19+
}
20+
return try await body(driver, pluginDriver)
21+
}
22+
}
23+
24+
func schemaChangeStatements(
25+
tableName: String,
26+
changes: [SchemaChange],
27+
scope: DatabaseScope
28+
) async throws -> [SchemaStatement] {
29+
try await withSchemaComposer(scope: scope, route: schemaChangeRoute(for: scope)) { driver, pluginDriver in
30+
let constraintName = await PrimaryKeyConstraintLookup.constraintName(
31+
tableName: tableName,
32+
changes: changes,
33+
driver: driver
34+
)
35+
return try SchemaStatementGenerator(
36+
tableName: tableName,
37+
primaryKeyConstraintName: constraintName,
38+
pluginDriver: pluginDriver
39+
).generate(changes: changes)
40+
}
41+
}
42+
43+
func createTableStatements(
44+
plan: CreateTablePlan,
45+
scope: DatabaseScope
46+
) async throws -> CreateTableStatements {
47+
guard plan.definition != nil else {
48+
return CreateTableStatements(statements: [], issues: plan.issues, tableName: nil)
49+
}
50+
return try await withSchemaComposer(scope: scope, route: schemaChangeRoute(for: scope)) { _, pluginDriver in
51+
await MainActor.run {
52+
CreateTableStatementComposer.compose(plan: plan, driver: pluginDriver)
53+
}
54+
}
55+
}
56+
57+
func createTableStatements(
58+
definition: PluginCreateTableDefinition,
59+
scope: DatabaseScope,
60+
route: ScopedDriverRoute
61+
) async throws -> [String] {
62+
try await withSchemaComposer(scope: scope, route: route) { _, pluginDriver in
63+
(pluginDriver.generateCreateTableStatements(definition: definition) ?? [])
64+
.map { StatementBlank.trimming($0) }
65+
.filter { !$0.isEmpty }
66+
}
67+
}
68+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
//
2+
// PrimaryKeyConstraintLookup.swift
3+
// TablePro
4+
//
5+
6+
import Foundation
7+
import os
8+
import TableProPluginKit
9+
10+
enum PrimaryKeyConstraintLookup {
11+
private static let logger = Logger(subsystem: "com.TablePro", category: "PrimaryKeyConstraintLookup")
12+
13+
static func constraintName(
14+
tableName: String,
15+
changes: [SchemaChange],
16+
driver: DatabaseDriver
17+
) async -> String? {
18+
guard changes.contains(where: dropsExistingPrimaryKey) else { return nil }
19+
guard let schema = (driver as? SchemaSwitchable)?.escapedSchema else { return nil }
20+
21+
let query = """
22+
SELECT CONSTRAINT_NAME
23+
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS
24+
WHERE TABLE_SCHEMA = '\(schema)'
25+
AND TABLE_NAME = '\(driver.escapeStringLiteral(tableName))'
26+
AND CONSTRAINT_TYPE = 'PRIMARY KEY'
27+
"""
28+
29+
do {
30+
let result = try await driver.execute(query: query)
31+
guard let row = result.rows.first, let name = row.first?.asText, !name.isEmpty else { return nil }
32+
return name
33+
} catch {
34+
logger.warning(
35+
"Primary key constraint name lookup failed: \(error.publicLogShape, privacy: .public)"
36+
)
37+
return nil
38+
}
39+
}
40+
41+
private static func dropsExistingPrimaryKey(_ change: SchemaChange) -> Bool {
42+
guard case .modifyPrimaryKey(let old, _) = change else { return false }
43+
return !old.isEmpty
44+
}
45+
}

‎TablePro/Core/Services/Export/ImportService.swift‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ final class ImportService: ObservableObject {
5252
from url: URL,
5353
formatId: String,
5454
encoding: String.Encoding,
55+
scope: DatabaseScope,
5556
decompressedURL: URL? = nil,
5657
ownsDecompressedFile: Bool = false,
5758
knownStatementCount: Int? = nil,
@@ -62,12 +63,6 @@ final class ImportService: ObservableObject {
6263
throw PluginImportError.importFailed("Import format '\(formatId)' not found")
6364
}
6465

65-
/// The scope the driver is already on, not the connection's saved default: a tab may have
66-
/// moved it, and on an engine that reconnects to change database, pinning somewhere else
67-
/// would refuse the import outright.
68-
guard let scope = DatabaseManager.shared.browseScope(for: connection.id) else {
69-
throw DatabaseError.notConnected
70-
}
7166
let route = DatabaseManager.shared.executionRoute(for: scope)
7267

7368
state = ImportState(isImporting: true)

‎TablePro/Models/Schema/TableRebuildReviewRequest.swift‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@ import TableProPluginKit
1313
/// first, and what the rebuild cannot carry over is named beside it.
1414
@MainActor
1515
struct TableRebuildReviewRequest: Identifiable {
16+
struct Action {
17+
/// What the confirming button says, which is the only thing that differs between a reorder
18+
/// and a constraint change: both recreate the table, and the user is reading the same script.
19+
let title: String
20+
let perform: () async -> Void
21+
}
22+
1623
let id = UUID()
1724
let tableName: String
1825

@@ -24,17 +31,17 @@ struct TableRebuildReviewRequest: Identifiable {
2431

2532
let plan: PluginColumnReorderPlan
2633

27-
/// What the confirming button says, which is the only thing that differs between a reorder and
28-
/// a constraint change: both recreate the table, and the user is reading the same script.
29-
let actionTitle: String
30-
31-
let perform: () async -> Void
34+
let action: Action?
3235

3336
var warning: String? {
3437
plan.caveats.isEmpty ? nil : plan.caveats.joined(separator: " ")
3538
}
3639

3740
var isRunnable: Bool { plan.isRunnable }
3841

42+
var runnableAction: Action? {
43+
isRunnable ? action : nil
44+
}
45+
3946
var scriptStatements: [String] { plan.scriptStatements }
4047
}

‎TablePro/Views/Import/ImportDialog.swift‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,10 +484,17 @@ struct ImportDialog: View {
484484

485485
importTask = Task {
486486
do {
487+
/// The scope the driver is already on, not the connection's saved default: a tab may
488+
/// have moved it, and on an engine that reconnects to change database, pinning
489+
/// somewhere else would refuse the import outright.
490+
guard let scope = DatabaseManager.shared.browseScope(for: connection.id) else {
491+
throw DatabaseError.notConnected
492+
}
487493
let result = try await service.importFile(
488494
from: url,
489495
formatId: selectedFormatId,
490496
encoding: selectedEncoding.encoding,
497+
scope: scope,
491498
decompressedURL: decompressedURL,
492499
ownsDecompressedFile: ownsDecompressedFile,
493500
knownStatementCount: statementCount > 0 ? statementCount : nil

0 commit comments

Comments
 (0)