Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Parse error on any MongoDB filter written in shell syntax, such as `db.orders.find({status: 1})`.
- MongoDB `.sort()` and `.projection()` silently ignored when written with unquoted keys.
- Compare & Sync unable to drop an overloaded PostgreSQL routine, or any trigger.
- PostgreSQL sequence DDL naming the schema it was read from, in SQL export and the structure editor.

## [0.69.0] - 2026-08-27

Expand Down
21 changes: 19 additions & 2 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,19 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
"DROP TRIGGER IF EXISTS \(quoteIdentifier(name)) ON \(qualifiedTable(table, schema: schema))"
}

/// PostgreSQL allows `f(integer)` and `f(text)` in one schema, so a drop that names only `f`
/// is ambiguous and the server refuses it.
func generateDropRoutineSQL(
name: String,
signature: String?,
schema: String?,
isFunction: Bool
) -> String? {
let keyword = isFunction ? "FUNCTION" : "PROCEDURE"
let arguments = (signature ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
return "DROP \(keyword) IF EXISTS \(qualifiedTable(name, schema: schema))\(arguments)"
}

var providesBulkForeignKeyFetch: Bool { true }

func fetchAllForeignKeys(schema: String?) async throws -> [String: [PluginForeignKeyInfo]] {
Expand Down Expand Up @@ -757,13 +770,17 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
let cycle = row[5].asText == "t" ? " CYCLE" : ""
let lastValue = row.count > 6 ? row[6].asText : nil
let quotedSeqName = quoteIdentifier(seqName)
let escapedSchemaForLiteral = escapeStringLiteral(schemaName)
let escapedSeqForLiteral = escapeStringLiteral(seqName)
var ddl = "CREATE SEQUENCE \(quotedSeqName) INCREMENT BY \(incrementBy)"
+ " MINVALUE \(minVal) MAXVALUE \(maxVal)"
+ " START WITH \(startVal)\(cycle);"
/// Unqualified, so it names the sequence the line above created rather than the one it
/// was read from. `setval` takes a `regclass`, which resolves through `search_path`, and
/// the `CREATE SEQUENCE` beside it is already schema-relative. Spelling the source's own
/// schema here made the pair disagree: run against another schema it repositioned the
/// original sequence, and against another database it named one that was not there.
if let last = lastValue, !last.isEmpty, Int64(last) != nil {
ddl += "\nSELECT pg_catalog.setval('\"\(escapedSchemaForLiteral)\".\"\(escapedSeqForLiteral)\"', \(last), true);"
ddl += "\nSELECT pg_catalog.setval('\"\(escapedSeqForLiteral)\"', \(last), true);"
}
return (name: seqName, ddl: ddl)
}
Expand Down
13 changes: 13 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
func createTriggerTemplate(table: String, schema: String?) -> String?
func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String?
func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String?
func generateDropRoutineSQL(name: String, signature: String?, schema: String?, isFunction: Bool) -> String?
var triggerEditUsesReplace: Bool { get }
var supportsTransactionalDDL: Bool { get }

Expand Down Expand Up @@ -315,6 +316,18 @@ public extension PluginDatabaseDriver {
func createTriggerTemplate(table: String, schema: String?) -> String? { nil }
func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? { nil }
func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? { nil }

/// How this engine drops a routine, given that only some of them accept an argument list.
///
/// PostgreSQL requires one to tell `f(integer)` from `f(text)`, and MySQL rejects one outright,
/// so a caller cannot spell this itself. Returning nil means the caller's own qualified
/// `DROP FUNCTION schema.name` is right for this engine.
func generateDropRoutineSQL(
name: String,
signature: String?,
schema: String?,
isFunction: Bool
) -> String? { nil }
var triggerEditUsesReplace: Bool { false }
var supportsTransactionalDDL: Bool { false }

Expand Down
27 changes: 25 additions & 2 deletions TablePro/Core/Compare/SourceObjectSyncBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,9 +52,9 @@ internal struct SourceObjectSyncBuilder {

private func dropStatements(for result: CompareObjectResult, isReplacement: Bool) -> [SyncStatement] {
guard let keyword = dropKeyword(for: result.identity.kind) else { return [] }
let name = qualified(result.identity)
let sql = dialectDrop(for: result.identity) ?? "DROP \(keyword) \(qualified(result.identity))"
return [SyncStatement(
sql: "DROP \(keyword) \(name);",
sql: terminated(sql),
objectName: result.identity.displayName,
summary: isReplacement
? String(
Expand All @@ -69,6 +69,29 @@ internal struct SourceObjectSyncBuilder {
)]
}

/// A routine and a trigger are not addressed by name alone on every engine. PostgreSQL needs an
/// overloaded routine's argument list and spells a trigger drop `DROP TRIGGER name ON table`,
/// while MySQL rejects the argument list and takes no `ON`. Only the driver knows which, so the
/// bare qualified name is the fallback rather than the rule.
private func dialectDrop(for identity: CompareObjectIdentity) -> String? {
switch identity.kind {
case .procedure, .function:
return targetDriver.generateDropRoutineSQL(
name: identity.name,
signature: identity.signature,
schema: identity.schema,
isFunction: identity.kind == .function
)
case .trigger:
guard let table = identity.signature, !table.isEmpty else { return nil }
return targetDriver.generateDropTriggerSQL(
name: identity.name, table: table, schema: identity.schema
)
case .view, .materializedView, .table, .sequence:
return nil
}
}

private func dropKeyword(for kind: CompareObjectKind) -> String? {
switch kind {
case .view: return "VIEW"
Expand Down
10 changes: 8 additions & 2 deletions TablePro/Core/ObjectCopy/ObjectCopyPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ internal struct ObjectCopyTableStep: Identifiable, Sendable {
internal let selection: ObjectCopySelection
/// Runs first, and only when the user chose to replace a table the target already has.
internal let dropStatements: [SyncStatement]
/// The sequences this table's own defaults name, created before it.
///
/// A PostgreSQL `SERIAL` column's default is `nextval('seq'::regclass)`. Copied without its
/// sequence, the table either refuses to be created or arrives with a default pointing at
/// nothing, and the first insert fails.
internal let sequenceStatements: [SyncStatement]
internal let createStatements: [SyncStatement]
/// Empties a table the copy is about to append to, for a data-only replace where there is no
/// DROP and CREATE to clear it.
Expand Down Expand Up @@ -45,7 +51,7 @@ internal struct ObjectCopyTableStep: Identifiable, Sendable {

/// What the DDL phase runs for this table. The truncate is deliberately absent: it belongs to
/// the data phase's transaction.
internal var ddl: [SyncStatement] { dropStatements + createStatements }
internal var ddl: [SyncStatement] { dropStatements + sequenceStatements + createStatements }

internal var qualifiedTargetName: String {
guard let targetSchema, !targetSchema.isEmpty else { return targetTable }
Expand Down Expand Up @@ -146,7 +152,7 @@ internal struct ObjectCopyPlan: Sendable {
}

internal var creationGroups: [ObjectCopyStatementGroup] {
tableSteps.map { ObjectCopyStatementGroup($0.selection, $0.createStatements) }
tableSteps.map { ObjectCopyStatementGroup($0.selection, $0.sequenceStatements + $0.createStatements) }
+ definitionSteps.filter { !$0.runsAfterData }
.map { ObjectCopyStatementGroup($0.selection, $0.createStatements) }
}
Expand Down
79 changes: 66 additions & 13 deletions TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,9 @@ internal struct ObjectCopyPlanner {
return ObjectCopyTableStep(
selection: draft.selection,
dropStatements: statements.drop,
sequenceStatements: Self.sequenceStatements(
parts?.sequences ?? [], table: draft.targetTable
),
createStatements: statements.create,
truncateStatements: statements.truncate,
columns: draft.targetColumns,
Expand All @@ -304,6 +307,7 @@ internal struct ObjectCopyPlanner {
private struct SourceParts: Sendable {
let query: String
let estimatedRows: Int?
let sequences: [String]
}

/// One scoped call for every table, because each `withMetadataDriver` either leases a pooled
Expand All @@ -315,32 +319,78 @@ internal struct ObjectCopyPlanner {
/// The source's own spellings, which are not always the target's: a case-insensitive
/// match can pair `Orders.UserID` with `orders.userid`, and quoting the source's spelling
/// into the target's INSERT names a column that engine does not have.
/// Only the tables whose rows are actually copied. Teradata implements the row estimate as
/// `SELECT COUNT(*)`, so preparing every draft made reviewing a structure-only copy scan
/// every table it named.
let inputs = drafts.filter(\.copiesData).map {
(id: $0.selection.id, table: $0.snapshot.name, schema: $0.sourceSchema, columns: $0.sourceColumns)
let inputs = drafts.map {
(
id: $0.selection.id,
table: $0.snapshot.name,
schema: $0.sourceSchema,
columns: $0.sourceColumns,
copiesData: $0.copiesData,
writesStructure: $0.writesStructure
)
}
guard !inputs.isEmpty else { return [:] }
guard inputs.contains(where: { $0.copiesData || $0.writesStructure }) else { return [:] }
return try await manager.withMetadataDriver(scope: endpoint.scope, workload: .bulk) { driver in
guard let plugin = CompareMetadataService.pluginDriver(from: driver) else {
throw ObjectCopyError.refused(Self.noSourceDriver)
}
var parts: [String: SourceParts] = [:]
/// A sequence several tables default from is created once, under the first of them,
/// which the dependency order has already put ahead of the rest.
var claimedSequences: Set<String> = []
for input in inputs {
try Task.checkCancellation()
let query = ObjectCopySelectQuery.build(
columns: input.columns, table: input.table, schema: input.schema, driver: plugin
)
let estimate = try? await plugin.fetchApproximateRowCount(
table: input.table, schema: input.schema
var query = ""
var estimatedRows: Int?
/// Only the tables whose rows are actually copied. Teradata implements the row
/// estimate as `SELECT COUNT(*)`, so preparing every draft made reviewing a
/// structure-only copy scan every table it named.
if input.copiesData {
query = ObjectCopySelectQuery.build(
columns: input.columns, table: input.table, schema: input.schema, driver: plugin
)
estimatedRows = (try? await plugin.fetchApproximateRowCount(
table: input.table, schema: input.schema
)) ?? nil
}
var sequences: [String] = []
if input.writesStructure {
let found = (try? await plugin.fetchDependentSequences(
table: input.table, schema: input.schema
)) ?? []
for sequence in found
where claimedSequences.insert(sequence.name.lowercased()).inserted {
sequences += SQLStatementScanner.allStatements(in: sequence.ddl)
}
}
parts[input.id] = SourceParts(
query: query, estimatedRows: estimatedRows, sequences: sequences
)
parts[input.id] = SourceParts(query: query, estimatedRows: estimate ?? nil)
}
return parts
}
}

/// A copied table's default names its sequence, so the sequence has to be there before the
/// `CREATE TABLE` runs.
///
/// PostgreSQL renders a `SERIAL` column as `integer ... DEFAULT nextval('orders_id_seq')`, and
/// the driver keeps that text verbatim. Copied without the sequence it names, the table either
/// fails to be created at all or, where the source's own sequence happens to be reachable, is
/// created sharing it, so the copy and the original hand out the same keys.
nonisolated internal static func sequenceStatements(
_ sql: [String],
table: String
) -> [SyncStatement] {
sql.map { statement in
SyncStatement(
sql: statement.hasSuffix(";") ? statement : statement + ";",
objectName: table,
summary: String(format: String(localized: "Create the sequences %@ defaults from"), table)
)
}
}

private func buildTargetDDL(
_ drafts: [ObjectCopyTableDraft],
request: ObjectCopyRequest,
Expand Down Expand Up @@ -583,7 +633,10 @@ internal struct ObjectCopyPlanner {
kind: target.kind,
schema: targetSchema ?? target.schema,
name: target.name,
signature: target.signature
/// A trigger's owning table travels in the same slot a routine's
/// argument list does, which is the shape `triggerReads` already uses,
/// and is what lets PostgreSQL spell `DROP TRIGGER name ON table`.
signature: target.signature ?? target.owner
),
status: .onlyInTarget
)
Expand Down
Loading
Loading