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
6 changes: 6 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,12 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
["SET session_replication_role = DEFAULT"]
}

/// A duplicated database arrives with `public` alone, so every other schema its tables are
/// qualified with has to be made before the first `CREATE TABLE` names one.
func createSchemaStatement(name: String) -> String? {
"CREATE SCHEMA IF NOT EXISTS \(quoteIdentifier(name))"
}

// MARK: - Maintenance

func supportedMaintenanceOperations() -> [String]? {
Expand Down
10 changes: 10 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,15 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
func foreignKeyDisableStatements() -> [String]?
func foreignKeyEnableStatements() -> [String]?

/// Creates a schema, for a copy that has just created the database it goes in.
///
/// A new database carries only whatever schema its engine gives it, so duplicating one that
/// groups its objects into several means creating the rest before any of their tables. Return
/// nil where the engine has no schemas, or where a schema is not something a statement can
/// make: on Oracle it is a user, and on SQL Server it needs its own batch. Callers leave those
/// namespaces out and say so rather than emitting DDL the server will reject.
func createSchemaStatement(name: String) -> String?

// Maintenance operations (optional — return nil if not supported)
func supportedMaintenanceOperations() -> [String]?
func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]?
Expand Down Expand Up @@ -534,6 +543,7 @@ public extension PluginDatabaseDriver {
func dropObjectStatement(name: String, objectType: String, schema: String?, cascade: Bool) -> String? { nil }
func foreignKeyDisableStatements() -> [String]? { nil }
func foreignKeyEnableStatements() -> [String]? { nil }
func createSchemaStatement(name: String) -> String? { nil }

func supportedMaintenanceOperations() -> [String]? { nil }
func maintenanceStatements(operation: String, table: String?, schema: String?, options: [String: String]) -> [String]? { nil }
Expand Down
24 changes: 23 additions & 1 deletion TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,32 @@ internal enum ObjectCopyEligibility {
source: DatabaseEndpoint,
target: DatabaseEndpoint
) -> String? {
guard source.id == target.id else { return nil }
guard sharesObjectSpace(source, target) else { return nil }
return String(localized: "The source and the target are the same database. Choose a different target.")
}

/// Whether the two sides can resolve to one set of objects.
///
/// Not `DatabaseEndpoint.id`, which spells the schema into the identity and so answered "these
/// are different" for a database-scoped source and a schema-scoped target that name the same
/// objects. Right-clicking a PostgreSQL database gives a source with no schema; choosing that
/// same database's `public` as the target then passed every refusal, and the planner went on to
/// resolve both sides to `public` and drop each table before streaming from the table it had
/// just emptied.
///
/// An endpoint that names no schema stands for whichever schema its objects turn out to be in,
/// so it overlaps every schema of its database rather than none of them. The database itself is
/// compared as spelled, which is what the identity already did: engines disagree about whether
/// a database name folds case, and this rule is not the place to decide that.
private static func sharesObjectSpace(_ source: DatabaseEndpoint, _ target: DatabaseEndpoint) -> Bool {
guard source.connectionId == target.connectionId, source.database == target.database else {
return false
}
guard let sourceSchema = source.schema?.nilIfEmpty,
let targetSchema = target.schema?.nilIfEmpty else { return true }
return ObjectCopyNamespace.isSame(sourceSchema, targetSchema)
}

/// Whether a view, routine or trigger can be copied as it stands.
///
/// Its definition is the source's own SQL text, and nothing here parses it, so every object it
Expand Down
53 changes: 47 additions & 6 deletions TablePro/Core/ObjectCopy/ObjectCopyPlan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
//

import Foundation
import TableProPluginKit

/// One table's work: its DDL, and the read and write it will stream between.
internal struct ObjectCopyTableStep: Identifiable, Sendable {
Expand All @@ -27,9 +28,12 @@ internal struct ObjectCopyTableStep: Identifiable, Sendable {
/// 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.
///
/// Not part of the DDL phase: it runs inside the same transaction as this table's rows, so a
/// copy that fails or is stopped puts the target's own rows back. Run ahead of the transaction
/// it deleted them for good while rolling only the new rows back.
/// Not part of the DDL phase. Every clear runs before any table is filled, because clearing
/// each table immediately before its own rows let the first parent DELETE meet child rows that
/// were still there and a cascading key took rows out of tables the user never selected. But
/// the clears belong inside the data phase's own transaction, not in a phase of their own:
/// run ahead of it they deleted the target's rows for good while a later failure rolled only
/// the new rows back. `ObjectCopyPlan.clearsInsideDataTransaction` is where those two meet.
internal let truncateStatements: [SyncStatement]
/// The columns written, source order, with generated columns already removed. Empty when this
/// step copies structure only.
Expand Down Expand Up @@ -104,19 +108,28 @@ internal struct ObjectCopyPlan: Sendable {
internal let createsDatabase: Bool
internal let tableSteps: [ObjectCopyTableStep]
internal let definitionSteps: [ObjectCopyDefinitionStep]
/// The schemas a duplicated database needs before its first table, one statement each.
///
/// A new database arrives with whatever schema its engine gives it and nothing else, so
/// `CREATE TABLE "sales"."invoices"` names a schema that is not there. These run ahead of every
/// other statement in the structure phase and belong to no selection: nothing can be created if
/// the schema it goes in could not be.
internal let schemaStatements: [SyncStatement]
internal let skipped: [ObjectCopySkip]

internal init(
request: ObjectCopyRequest,
createsDatabase: Bool,
tableSteps: [ObjectCopyTableStep],
definitionSteps: [ObjectCopyDefinitionStep],
schemaStatements: [SyncStatement] = [],
skipped: [ObjectCopySkip] = []
) {
self.request = request
self.createsDatabase = createsDatabase
self.tableSteps = tableSteps
self.definitionSteps = definitionSteps
self.schemaStatements = schemaStatements
self.skipped = skipped
}

Expand All @@ -140,7 +153,7 @@ internal struct ObjectCopyPlan: Sendable {

/// Everything the run writes that is not a row, in the order it writes it.
internal var ddlStatements: [SyncStatement] {
cleanupStatements + creationStatements + afterDataStatements
schemaStatements + cleanupStatements + creationStatements + afterDataStatements
}

/// Cleanup runs children first, so a foreign key is gone before the table it points at, and a
Expand All @@ -158,12 +171,29 @@ internal struct ObjectCopyPlan: Sendable {
}

/// Emptying the tables a data-only replace appends to, children first so a foreign key holds.
///
/// Only a table whose rows are going back in. A step that copies no data is not in `dataSteps`,
/// so clearing it deletes every row the target had and writes nothing in their place: the
/// review said only that the two sides shared no writable column, and the run reported success.
internal var clearGroups: [ObjectCopyStatementGroup] {
tableSteps.reversed()
.filter { !$0.truncateStatements.isEmpty }
.filter { $0.copiesData && !$0.truncateStatements.isEmpty }
.map { ObjectCopyStatementGroup($0.selection, $0.truncateStatements) }
}

/// Whether the clears run inside the data phase's transaction rather than ahead of it.
///
/// Emptying a table is reversible only while the transaction that emptied it is still open, so
/// a run that promises a rollback cannot put its DELETEs in a phase of their own: the first
/// failure afterwards leaves the target's own rows gone with nothing written in their place.
/// A run that promises nothing keeps them in a phase of their own, where one table's failure
/// does not reach the tables already copied.
internal var clearsInsideDataTransaction: Bool {
!clearGroups.isEmpty
&& request.wrapEachTableInTransaction
&& request.errorHandling != .skipAndContinue
}

/// The definitions held back until the rows are in: a trigger fires on the copy itself, and a
/// materialized view is filled at the moment it is created, so one built over an empty table
/// stays empty.
Expand All @@ -188,17 +218,28 @@ internal struct ObjectCopyPlan: Sendable {

/// What the user reads before pressing Copy. The DDL verbatim, then one line per table naming
/// what its data step will walk, because the INSERTs themselves do not exist yet.
///
/// The clears are one block ahead of every data step, which is the order the runner uses. Shown
/// against each table instead, the script said the parent was emptied after the child had been
/// filled, and a user reasoning about a cascade from what they were asked to approve reached
/// the opposite conclusion from what the run would do.
internal var scriptText: String {
var lines: [String] = []
if case .newDatabase(_, let name, _) = request.destination {
lines.append(String(format: String(localized: "-- Create database %@"), name))
}
lines += schemaStatements.map(\.sql)
lines += cleanupStatements.map(\.sql)
lines += creationStatements.map(\.sql)
let clears = clearGroups.flatMap(\.statements)
if !clears.isEmpty {
lines.append("")
lines.append(String(localized: "-- Empty the tables the rows go into"))
lines += clears.map(\.sql)
}
for step in dataSteps {
lines.append("")
lines.append(String(format: String(localized: "-- Copy rows into %@"), step.qualifiedTargetName))
lines += step.truncateStatements.map(\.sql)
lines.append(step.sourceQuery + ";")
}
guard !afterDataStatements.isEmpty else { return lines.joined(separator: "\n") }
Expand Down
Loading
Loading