diff --git a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift index 15de11ad1..a216a22f6 100644 --- a/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift +++ b/Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift @@ -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]? { diff --git a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift index 21fabf430..762816040 100644 --- a/Plugins/TableProPluginKit/PluginDatabaseDriver.swift +++ b/Plugins/TableProPluginKit/PluginDatabaseDriver.swift @@ -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]? @@ -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 } diff --git a/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift index 2da181e77..79cab20ed 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift @@ -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 diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift index eb73adb92..1c7ec8d18 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift @@ -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 { @@ -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. @@ -104,6 +108,13 @@ 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( @@ -111,12 +122,14 @@ internal struct ObjectCopyPlan: Sendable { 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 } @@ -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 @@ -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. @@ -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") } diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift index 7312b1d10..2d586328b 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -46,11 +46,19 @@ internal struct ObjectCopyPlanner { var skipped: [ObjectCopySkip] = [] var tableSteps: [ObjectCopyTableStep] = [] var definitionSteps: [ObjectCopyDefinitionStep] = [] + var targetNamespaces: [String] = [] + /// The target catalog is one read per distinct target endpoint, not one per source scope. + /// A copy into a chosen target resolves every scope to the same endpoint, so a database + /// with twelve schemas read the same catalog twelve times. + var targetObjectsByEndpoint: [String: [String: ObjectCopySelection]] = [:] for scope in Self.scopes(of: request) { let names = Set(scope.objects.map(\.name)) let sourceEndpoint = request.source.withSchema(scope.namespace) let targetEndpoint = request.target.withSchema(scope.targetNamespace(for: request)) + if let namespace = targetEndpoint.schema?.nilIfEmpty, !targetNamespaces.contains(namespace) { + targetNamespaces.append(namespace) + } let sourceReads = try await metadata.tableReads( for: sourceEndpoint, connection: connections.source, includeViews: true, names: names @@ -58,9 +66,19 @@ internal struct ObjectCopyPlanner { let targetReads = try await existingTargetReads( request, endpoint: targetEndpoint, connection: connections.target, names: names ) - let targetObjects = try await existingTargetObjects( - request, endpoint: targetEndpoint, connection: connections.target - ) + var targetObjects: [String: ObjectCopySelection] = [:] + /// Only the definition steps read it, and only when structure takes part, so a + /// data-only copy never pays for a catalog it would discard unread. + if request.content.includesStructure { + if let cached = targetObjectsByEndpoint[targetEndpoint.id] { + targetObjects = cached + } else { + targetObjects = try await existingTargetObjects( + request, endpoint: targetEndpoint, connection: connections.target + ) + targetObjectsByEndpoint[targetEndpoint.id] = targetObjects + } + } tableSteps += try await buildTableSteps( request, @@ -88,10 +106,43 @@ internal struct ObjectCopyPlanner { createsDatabase: request.destination.createsDatabase, tableSteps: tableSteps, definitionSteps: definitionSteps, + schemaStatements: try await buildSchemaStatements(request, namespaces: targetNamespaces), skipped: skipped ) } + /// The schemas a duplicated database needs before its first `CREATE TABLE` names one. + /// + /// `CREATE DATABASE` gives the new database whatever schema its engine gives it, and a + /// duplicate keeps every source schema name, so a PostgreSQL database with a `sales` schema + /// produced `CREATE TABLE "sales"."invoices"` against a database that had only `public`. The + /// structure phase then rolled back with the database already created, leaving a duplicate that + /// held nothing and could not be retried without deleting it first. + /// + /// Only for a run that creates the database: copying into one the user chose means its schemas + /// are the user's to make, and creating one silently would put objects somewhere they did not + /// ask for. A driver with no single statement for it answers nil and the copy is left as it + /// was, rather than being handed DDL the server would reject. + private func buildSchemaStatements( + _ request: ObjectCopyRequest, + namespaces: [String] + ) async throws -> [SyncStatement] { + guard request.destination.createsDatabase, !namespaces.isEmpty else { return [] } + return try await manager.withMetadataDriver( + scope: targetScope(request, endpoint: request.target) + ) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [] } + return namespaces.compactMap { name in + guard let sql = plugin.createSchemaStatement(name: name) else { return nil } + return SyncStatement( + sql: sql.hasSuffix(";") ? sql : sql + ";", + objectName: name, + summary: String(format: String(localized: "Create schema %@"), name) + ) + } + } + } + /// The selected objects grouped by the namespace they were found in. internal struct Scope { internal let namespace: String? @@ -239,9 +290,13 @@ internal struct ObjectCopyPlanner { /// matched no edge and fell through to alphabetical order. let sourceNamespace = ObjectCopyNamespace.name(for: sourceEndpoint) let targetNamespace = ObjectCopyNamespace.name(for: targetEndpoint) + /// Seeded from the user's own order, not from `reads.keys`. Swift seeds Dictionary hashing + /// per process, so taking the keys gave the tables with no foreign key between them a + /// different tie-break on every launch: the approved script, the progress order and the + /// outcome list were all shuffled differently for the same copy. var drafts: [ObjectCopyTableDraft] = [] for selection in Self.orderedByDependency( - Array(reads.keys), reads: reads, effectiveSchema: sourceNamespace + scope.objects.filter { reads[$0] != nil }, reads: reads, effectiveSchema: sourceNamespace ) { guard let read = reads[selection], let snapshot = read.snapshot else { continue } let targetRead = match(selection, in: targetReads) @@ -504,8 +559,10 @@ internal struct ObjectCopyPlanner { ) -> TableStructureSnapshot { let placedSchema = schema ?? targetSchema let source = (sourceSchema ?? "").lowercased() - let foreignKeys = snapshot.foreignKeys.map { key -> EditableForeignKeyDefinition in - guard source != (targetSchema ?? "").lowercased() else { return key } + /// Hoisted out of the map: it names neither the key nor the snapshot, so a copy that stays + /// in one namespace has nothing to move and can say so once instead of per foreign key. + let movesReferences = source != (targetSchema ?? "").lowercased() + let foreignKeys = !movesReferences ? snapshot.foreignKeys : snapshot.foreignKeys.map { key in let referenced = (key.referencedSchema ?? "").lowercased() guard referenced.isEmpty || referenced == source else { return key } var moved = key @@ -576,20 +633,29 @@ internal struct ObjectCopyPlanner { let targetSchema = targetEndpoint.schema let sourceNamespace = ObjectCopyNamespace.name(for: sourceEndpoint) let targetNamespace = ObjectCopyNamespace.name(for: targetEndpoint) + + /// Asked once for the whole scope, and before anything is read. It depends only on the two + /// namespaces, so a cross-namespace copy rejected every object anyway: asking per object + /// first fetched every view body, routine body and trigger body from the source and then + /// discarded all of them, and wrote one identical skip row per object where the scope has + /// one reason. + guard ObjectCopyEligibility.canCopyDefinition( + sourceNamespace: sourceNamespace, targetNamespace: targetNamespace + ) else { + skipped += selections.map { + ObjectCopySkip(selection: $0, reason: ObjectCopyEligibility.definitionNamespaceRefusal) + } + return [] + } + let definitions = try await sourceDefinitions( - request, scope: scope, sourceEndpoint: sourceEndpoint, - sourceReads: sourceReads, connection: connection + sourceEndpoint: sourceEndpoint, + selections: selections, + sourceReads: sourceReads, + connection: connection ) var pending: [(selection: ObjectCopySelection, definition: String, target: ObjectCopySelection?)] = [] - for selection in Self.orderedByKind(scope.objects.filter({ $0.kind.isSourceDefined })) { - guard ObjectCopyEligibility.canCopyDefinition( - sourceNamespace: sourceNamespace, targetNamespace: targetNamespace - ) else { - skipped.append(ObjectCopySkip( - selection: selection, reason: ObjectCopyEligibility.definitionNamespaceRefusal - )) - continue - } + for selection in Self.orderedByKind(selections) { guard let definition = definitions[selection.id], !definition.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { skipped.append(ObjectCopySkip(selection: selection, reason: Self.noDefinition)) @@ -675,23 +741,12 @@ internal struct ObjectCopyPlanner { } private func sourceDefinitions( - _ request: ObjectCopyRequest, - scope: Scope, sourceEndpoint: DatabaseEndpoint, + selections: [ObjectCopySelection], sourceReads: [TableStructureRead], connection: DatabaseConnection ) async throws -> [String: String] { var definitions: [String: String] = [:] - let selections = scope.objects.filter { $0.kind.isSourceDefined } - let request = ObjectCopyRequest( - source: sourceEndpoint, - destination: request.destination, - objects: selections, - content: request.content, - existingPolicy: request.existingPolicy, - errorHandling: request.errorHandling, - wrapEachTableInTransaction: request.wrapEachTableInTransaction - ) let views = selections.filter { $0.kind == .view || $0.kind == .materializedView } if !views.isEmpty { @@ -699,7 +754,7 @@ internal struct ObjectCopyPlanner { views.contains { $0.name.lowercased() == info.name.lowercased() } } for read in try await metadata.viewDefinitions( - for: request.source, connection: connection, views: infos + for: sourceEndpoint, connection: connection, views: infos ) { guard let selection = views.first(where: { $0.name.lowercased() == read.name.lowercased() }) else { continue } @@ -711,7 +766,7 @@ internal struct ObjectCopyPlanner { /// `f(text)` are two routines and copying one must not carry the other's body. let routines = selections.filter { $0.kind == .procedure || $0.kind == .function } if !routines.isEmpty { - for read in try await metadata.routineReads(for: request.source, connection: connection) { + for read in try await metadata.routineReads(for: sourceEndpoint, connection: connection) { guard let selection = routines.first(where: { $0.kind == read.kind && $0.name.lowercased() == read.name.lowercased() @@ -728,7 +783,7 @@ internal struct ObjectCopyPlanner { if !triggers.isEmpty { let owners = Set(triggers.compactMap(\.owner)).union(sourceReads.map(\.table.name)) for read in try await metadata.triggerReads( - for: request.source, connection: connection, tables: Array(owners) + for: sourceEndpoint, connection: connection, tables: Array(owners) ) { guard let selection = triggers.first(where: { $0.name.lowercased() == read.name.lowercased() @@ -772,13 +827,17 @@ internal struct ObjectCopyPlanner { var emitted: Set = [] var result: [ObjectCopySelection] = [] + var placed: Set = [] for node in ForeignKeyTopologicalSort.ordered( nodes, foreignKeysByTable: foreignKeysByTable, childrenFirst: false ) where emitted.insert(node.identifier).inserted { guard let selection = bySortKey[node.identifier] else { continue } result.append(selection) + placed.insert(selection) } - for selection in selections where !result.contains(selection) { + /// Membership against a set rather than the array being built. A database-wide copy of 500 + /// tables ran 125,000 equality checks here for a tail that is usually empty. + for selection in selections where !placed.contains(selection) { result.append(selection) } return result @@ -883,8 +942,6 @@ private struct ObjectCopyTableDraft { let writesStructure = request.content.includesStructure && !keepsTargetStructure self.writesStructure = writesStructure self.dropsFirst = writesStructure && existsInTarget - /// A data-only replace has no DROP and CREATE to clear the table, so it is emptied instead. - self.emptiesFirst = existsInTarget && request.existingPolicy == .replace && !writesStructure /// The target's own name when it already has the table, because a case-insensitive match /// pairs `Orders` with `orders` and the INSERT has to quote the one that exists. @@ -901,7 +958,18 @@ private struct ObjectCopyTableDraft { ) self.sourceColumns = pairs.map(\.source) self.targetColumns = pairs.map(\.target) - self.copiesData = request.content.includesData && !pairs.isEmpty && (writesStructure || existsInTarget) + let copiesData = request.content.includesData && !pairs.isEmpty && (writesStructure || existsInTarget) + self.copiesData = copiesData + + /// A data-only replace has no DROP and CREATE to clear the table, so it is emptied instead, + /// and only where rows are going back into it. Emptying without that condition deleted + /// every row of a table whose columns the target does not share, and then wrote nothing: + /// the step was dropped from the data phase for having no writable column while its DELETE + /// stayed in the clear phase, and the review said only that the two sides shared no column. + self.emptiesFirst = copiesData + && existsInTarget + && request.existingPolicy == .replace + && !writesStructure let written = Set(pairs.map { $0.source.lowercased() }) self.copiesIdentityColumn = request.content.includesData && read.columns.contains { diff --git a/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift index 94a7185d3..29dadb450 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift @@ -46,6 +46,24 @@ internal struct ObjectCopyRunResult: Sendable { } internal var firstError: String? { outcomes.compactMap(\.error).first } + + /// Every failure, each with an identity of its own. + /// + /// One object fails once per phase it reaches, so a table whose `CREATE` failed under Skip and + /// Continue fails again in the data phase with "relation does not exist", and both outcomes + /// carry the selection's id. Listed by that id a SwiftUI `ForEach` saw a duplicate identifier + /// and dropped one of the two messages the user needs. The position is what tells them apart, + /// which is also the order they happened in. + internal var failures: [ObjectCopyFailure] { + outcomes.enumerated() + .filter { $0.element.error != nil } + .map { ObjectCopyFailure(id: $0.offset, outcome: $0.element) } + } +} + +internal struct ObjectCopyFailure: Identifiable, Sendable { + internal let id: Int + internal let outcome: ObjectCopyObjectOutcome } @MainActor @@ -109,7 +127,7 @@ internal struct ObjectCopyRunner { /// with foreign key checks off and a transaction around them. Split across two calls, a /// Stop between them or a failing first CREATE left every later object dropped with /// nothing put back. - if !plan.cleanupGroups.isEmpty || !plan.creationGroups.isEmpty { + if !plan.schemaStatements.isEmpty || !plan.cleanupGroups.isEmpty || !plan.creationGroups.isEmpty { let result = try await runStructure(plan, request: request, progress: progress) outcomes += result.outcomes cancelled = cancelled || result.cancelled @@ -120,7 +138,11 @@ internal struct ObjectCopyRunner { /// first. Clearing each one immediately before its own rows meant the first parent DELETE /// met child rows that were still there, and a cascading key took rows out of tables the /// user had not selected. - if !cancelled, !plan.clearGroups.isEmpty { + /// + /// A run that promises a rollback empties them inside the data phase's own transaction + /// instead, because a DELETE that has already committed cannot be taken back by anything + /// that fails afterwards. Only a run that promises nothing clears here. + if !cancelled, !plan.clearGroups.isEmpty, !plan.clearsInsideDataTransaction { let result = try await runDDL(plan.clearGroups, request: request, progress: progress) outcomes += result.outcomes.filter { $0.error != nil } cancelled = cancelled || result.cancelled @@ -128,7 +150,7 @@ internal struct ObjectCopyRunner { } if !cancelled, request.content.includesData { - let dataOutcomes = await runData(plan, request: request, progress: progress) + let dataOutcomes = try await runData(plan, request: request, progress: progress) outcomes += dataOutcomes.outcomes cancelled = cancelled || dataOutcomes.cancelled rowsCopied = dataOutcomes.rowsCopied @@ -178,6 +200,7 @@ internal struct ObjectCopyRunner { let errorHandling = request.errorHandling let scope = request.target.scope let hasCleanup = !plan.cleanupGroups.isEmpty + let schemaStatements = plan.schemaStatements return try await manager.withMetadataDriver(scope: scope) { driver in guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { @@ -192,6 +215,13 @@ internal struct ObjectCopyRunner { } } + /// Ahead of everything, and it throws rather than being attributed to an object: no + /// table, view or routine can be created if the schema naming it is not there, so + /// carrying on would report every one of them as its own failure for one cause. + for statement in schemaStatements { + _ = try await plugin.execute(query: statement.sql) + } + let result = await Self.execute( groups, on: plugin, errorHandling: errorHandling, progress: progress ) @@ -201,17 +231,27 @@ internal struct ObjectCopyRunner { _ = try? await plugin.execute(query: statement) } } - if usesTransaction { - if result.stopped { - try? await plugin.rollbackTransaction() - } else { - try await plugin.commitTransaction() - } + guard usesTransaction else { return result } + guard result.stopped else { + try await plugin.commitTransaction() + return result } - return result + try? await plugin.rollbackTransaction() + return Self.withoutSuccesses(result) } } + /// What is left of a phase whose transaction was rolled back: the failures, and nothing else. + /// + /// Each object appended its own outcome as it ran, so a phase that stopped on the eighth table + /// carried seven successes into a result the rollback had already undone. The summary then + /// reported seven objects copied over a target where nothing was written. + nonisolated private static func withoutSuccesses(_ result: DDLResult) -> DDLResult { + var rolled = result + rolled.outcomes = result.outcomes.filter { $0.error != nil } + return rolled + } + nonisolated private static func execute( _ groups: [ObjectCopyStatementGroup], on driver: any PluginDatabaseDriver, @@ -279,10 +319,164 @@ internal struct ObjectCopyRunner { var rowsCopied = 0 } + private func runData( + _ plan: ObjectCopyPlan, + request: ObjectCopyRequest, + progress: ObjectCopyProgress + ) async throws -> DataResult { + guard plan.clearsInsideDataTransaction else { + return await runIndependentTables(plan, request: request, progress: progress) + } + return try await runClearedTables(plan, request: request, progress: progress) + } + + /// The clear and every row it makes room for, in one transaction on the target. + /// + /// Emptying a table is reversible only while the transaction that emptied it is still open, so + /// the clears cannot run in a phase of their own once the run has promised a rollback. They + /// still all run before any table is filled, which is what keeps a cascading foreign key from + /// taking rows out of a table the user never selected. + /// + /// One transaction for every table is the price of that promise, and it is charged only here: + /// a copy with nothing to empty keeps its per-table transactions, where one table's failure + /// leaves the tables already copied alone. + private func runClearedTables( + _ plan: ObjectCopyPlan, + request: ObjectCopyRequest, + progress: ObjectCopyProgress + ) async throws -> DataResult { + let manager = self.manager + let clearGroups = plan.clearGroups + let steps = plan.dataSteps + let targetType = request.target.databaseType + let sourceScope = request.source.scope + let errorHandling = request.errorHandling + + /// A lease this phase cannot take is a run-level failure with no object to pin it on, and + /// it is thrown for the same reason the DDL phases throw it: swallowed into a stopped + /// result it left the sheet reporting a copy that did nothing and saying nothing about why. + return try await manager.withMetadataDriver( + scope: request.target.scope, workload: .bulk + ) { targetDriver in + guard let targetPlugin = CompareMetadataService.pluginDriver(from: targetDriver) else { + throw ObjectCopyError.refused(Self.noTargetDriver) + } + let usesTransaction = targetPlugin.supportsTransactions + if usesTransaction { try await targetPlugin.beginTransaction(mode: .readWrite) } + + var result = DataResult() + let cleared = await Self.execute( + clearGroups, on: targetPlugin, errorHandling: errorHandling, progress: progress + ) + result.outcomes += cleared.outcomes.filter { $0.error != nil } + result.cancelled = cleared.cancelled + result.stopped = cleared.stopped + + if !result.stopped { + let copied = await Self.stream( + steps, + from: sourceScope, + into: targetPlugin, + manager: manager, + targetType: targetType, + errorHandling: errorHandling, + progress: progress + ) + result.outcomes += copied.outcomes + result.cancelled = result.cancelled || copied.cancelled + result.stopped = copied.stopped + result.rowsCopied = copied.rowsCopied + } + + guard usesTransaction else { return result } + guard result.stopped, errorHandling != .stopAndCommit else { + /// A commit that fails leaves the transaction open, and the clears are inside it, + /// so it is rolled back rather than left for the lease to decide. + do { + try await targetPlugin.commitTransaction() + } catch { + try? await targetPlugin.rollbackTransaction() + throw error + } + return result + } + try? await targetPlugin.rollbackTransaction() + /// The target is back where it started, rows and all, so nothing may be reported + /// as copied and no table may be reported as done. + result.outcomes = result.outcomes.filter { $0.error != nil } + result.rowsCopied = 0 + return result + } + } + + /// Every table streamed into a target driver the caller already holds, so one transaction can + /// span all of them. The source is leased per table, because only the target's lease has to + /// outlive the loop. + nonisolated private static func stream( + _ steps: [ObjectCopyTableStep], + from sourceScope: DatabaseScope, + into targetPlugin: any PluginDatabaseDriver, + manager: DatabaseManager, + targetType: DatabaseType, + errorHandling: ImportErrorHandling, + progress: ObjectCopyProgress + ) async -> DataResult { + var result = DataResult() + for step in steps { + if progress.isCancelled || Task.isCancelled { + result.cancelled = true + result.stopped = true + break + } + progress.startObject(step.qualifiedTargetName) + let copier = ObjectCopyRowCopier(step: step, targetDatabaseType: targetType) + let completedBefore = result.rowsCopied + do { + let outcome = try await manager.withMetadataDriver( + scope: sourceScope, workload: .bulk + ) { sourceDriver in + guard let sourcePlugin = CompareMetadataService.pluginDriver(from: sourceDriver) else { + throw ObjectCopyError.refused(noSourceDriver) + } + return try await copier.copy(from: sourcePlugin, to: targetPlugin) { rows in + progress.setRowsForCurrentObject(rows, completedBefore: completedBefore) + } + } + guard !outcome.cancelled else { + result.cancelled = true + result.stopped = true + progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) + break + } + result.rowsCopied += outcome.inserted + result.outcomes.append(ObjectCopyObjectOutcome( + selection: step.selection, rowsCopied: outcome.inserted, error: nil + )) + } catch is CancellationError { + result.cancelled = true + result.stopped = true + progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) + break + } catch { + logger.error( + "Copy rows failed for \(step.qualifiedTargetName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + result.outcomes.append(ObjectCopyObjectOutcome( + selection: step.selection, rowsCopied: 0, error: error.localizedDescription + )) + guard errorHandling == .skipAndContinue else { + result.stopped = true + break + } + } + } + return result + } + /// Each table is its own unit of work, so one that fails leaves the ones already copied alone. /// Both scopes are open at once, which the planner has already established is safe: two scopes /// on one connection are refused unless both route to the pool. - private func runData( + private func runIndependentTables( _ plan: ObjectCopyPlan, request: ObjectCopyRequest, progress: ObjectCopyProgress diff --git a/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/TablePro/Core/ObjectCopy/ObjectCopySession.swift index b77b9e530..da4113970 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopySession.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -274,11 +274,17 @@ internal final class ObjectCopySession { } internal func toggle(_ selection: ObjectCopySelection) { - if selectedObjectIds.contains(selection.id) { + setSelected(selection, !selectedObjectIds.contains(selection.id)) + } + + /// What a checkbox writes. Stating the value rather than inverting the stored one is what keeps + /// a repeated write of the value already held from changing anything. + internal func setSelected(_ selection: ObjectCopySelection, _ isSelected: Bool) { + guard isSelected else { selectedObjectIds.remove(selection.id) - } else { - selectedObjectIds.insert(selection.id) + return } + selectedObjectIds.insert(selection.id) } /// Adds what is on screen rather than replacing the whole selection, so a search that is hiding diff --git a/TablePro/Views/ObjectCopy/CopyObjectsListView.swift b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift index 32dd5d07a..453d9f6ca 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsListView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift @@ -68,9 +68,13 @@ internal struct CopyObjectsListView: View { } private func row(_ object: ObjectCopySelection) -> some View { + /// The value SwiftUI hands the setter, never an unconditional flip. A binding that inverts + /// whatever it is written turns any write of the value it already holds into a change, so + /// a re-render, an accessibility `setValue`, or a second delivery while the list diffs + /// under a search keystroke silently took an object out of the copy or put one in. Toggle(isOn: Binding( get: { session.selectedObjectIds.contains(object.id) }, - set: { _ in session.toggle(object) } + set: { session.setSelected(object, $0) } )) { HStack(spacing: 6) { /// The signature or the owning table, not just the name: two overloads and two diff --git a/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift index dd8fb5395..dfd3a721f 100644 --- a/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift +++ b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift @@ -85,13 +85,13 @@ internal struct CopyObjectsResultView: View { @ViewBuilder private func outcomes(_ result: ObjectCopyRunResult) -> some View { - let failures = result.outcomes.filter { $0.error != nil } + let failures = result.failures if !failures.isEmpty { VStack(alignment: .leading, spacing: 6) { Text("Errors") .font(.subheadline.weight(.medium)) - ForEach(failures) { outcome in - Text(verbatim: "\(outcome.selection.qualifiedName): \(outcome.error ?? "")") + ForEach(failures) { failure in + Text(verbatim: "\(failure.outcome.selection.qualifiedName): \(failure.outcome.error ?? "")") .font(.callout) .foregroundStyle(.secondary) .textSelection(.enabled) diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift index 546c1daf5..2225072be 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -61,6 +61,51 @@ final class ObjectCopyEligibilityTests: XCTestCase { )) } + /// Right-clicking a PostgreSQL database gives a source with no schema, and choosing that same + /// database's `public` as the target used to pass every refusal because the two endpoint ids + /// differ. The planner then resolved both sides to `public` and dropped each table before + /// streaming from the table it had just emptied. + func testAScopedTargetInsideAnUnscopedSourceIsRefused() { + let connectionId = UUID() + let wholeDatabase = endpoint("app", type: .postgresql, connectionId: connectionId) + let oneSchema = endpoint("app", type: .postgresql, schema: "public", connectionId: connectionId) + + XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal(source: wholeDatabase, target: oneSchema)) + XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal(source: oneSchema, target: wholeDatabase)) + } + + func testTheSameSchemaIsRefusedWhateverItsCase() { + let connectionId = UUID() + XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal( + source: endpoint("app", type: .postgresql, schema: "Sales", connectionId: connectionId), + target: endpoint("app", type: .postgresql, schema: "sales", connectionId: connectionId) + )) + } + + /// The case the refusal must not swallow: two schemas of one database are a valid pair, and + /// copying between them is the whole point of a schema-scoped endpoint. + func testTwoSchemasOfOneDatabaseAreAValidPair() { + let connectionId = UUID() + XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal( + source: endpoint("app", type: .postgresql, schema: "sales", connectionId: connectionId), + target: endpoint("app", type: .postgresql, schema: "archive", connectionId: connectionId) + )) + XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal( + source: endpoint("app", type: .postgresql, schema: "public", connectionId: connectionId), + target: endpoint("app_copy", type: .postgresql, schema: "public", connectionId: connectionId) + )) + } + + /// An empty schema is how a scope with none is spelled, so it has to read as absent rather than + /// as a schema literally named "". + func testAnEmptySchemaCountsAsNoSchema() { + let connectionId = UUID() + XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal( + source: endpoint("app", type: .postgresql, schema: "", connectionId: connectionId), + target: endpoint("app", type: .postgresql, schema: "public", connectionId: connectionId) + )) + } + func testOnlySQLEnginesCanCopy() { XCTAssertTrue(ObjectCopyEligibility.supportsCopying(editorLanguage: .sql)) XCTAssertFalse(ObjectCopyEligibility.supportsCopying(editorLanguage: .javascript)) diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift new file mode 100644 index 000000000..a0db03761 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlanTests.swift @@ -0,0 +1,179 @@ +// +// ObjectCopyPlanTests.swift +// TableProTests +// +// What the plan promises the runner and the review pane, which is where +// emptying a table and refilling it have to agree. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class ObjectCopyPlanTests: XCTestCase { + private func endpoint( + _ database: String, + schema: String? = nil, + connectionId: UUID = UUID() + ) -> DatabaseEndpoint { + DatabaseEndpoint( + scope: DatabaseScope(connectionId: connectionId, database: database, schema: schema), + connectionName: "server", + databaseType: .postgresql, + safeModeLevel: .silent, + color: .blue + ) + } + + private func request( + content: ObjectCopyContent = .data, + existingPolicy: ObjectCopyExistingPolicy = .replace, + errorHandling: ImportErrorHandling = .stopAndRollback, + wrapEachTableInTransaction: Bool = true, + destination: ObjectCopyDestination? = nil + ) -> ObjectCopyRequest { + ObjectCopyRequest( + source: endpoint("app"), + destination: destination ?? .existing(endpoint("staging")), + objects: [], + content: content, + existingPolicy: existingPolicy, + errorHandling: errorHandling, + wrapEachTableInTransaction: wrapEachTableInTransaction + ) + } + + private func statement(_ sql: String, _ object: String) -> SyncStatement { + SyncStatement(sql: sql, objectName: object, summary: sql) + } + + private func step( + _ name: String, + copiesData: Bool = true, + truncates: Bool = true + ) -> ObjectCopyTableStep { + ObjectCopyTableStep( + selection: ObjectCopySelection(kind: .table, name: name, schema: "public"), + dropStatements: [], + sequenceStatements: [], + createStatements: [], + truncateStatements: truncates ? [statement("DELETE FROM \(name);", name)] : [], + columns: copiesData ? ["id"] : [], + primaryKeyColumns: ["id"], + sourceQuery: "SELECT \"id\" FROM \"\(name)\"", + targetTable: name, + targetSchema: "public", + estimatedRows: nil, + copiesData: copiesData, + copiesIdentityColumn: false, + note: nil + ) + } + + private func plan( + _ steps: [ObjectCopyTableStep], + request: ObjectCopyRequest? = nil, + schemaStatements: [SyncStatement] = [] + ) -> ObjectCopyPlan { + ObjectCopyPlan( + request: request ?? self.request(), + createsDatabase: false, + tableSteps: steps, + definitionSteps: [], + schemaStatements: schemaStatements + ) + } + + /// The defect this suite exists for. A table with no writable column in common is not in + /// `dataSteps`, so clearing it deleted every row the target had and wrote nothing back, while + /// the review said only that the two sides shared no writable column. + func testATableThatCopiesNoRowsIsNeverEmptied() { + let copied = step("orders") + let unwritable = step("legacy_audit", copiesData: false) + + let cleared = plan([copied, unwritable]).clearGroups.map(\.selection.name) + + XCTAssertEqual(cleared, ["orders"]) + } + + /// Children first, so the first parent DELETE does not meet child rows a cascading key would + /// take out of a table the user never selected. + func testTablesAreEmptiedChildrenFirst() { + let cleared = plan([step("customers"), step("orders")]).clearGroups.map(\.selection.name) + + XCTAssertEqual(cleared, ["orders", "customers"]) + } + + /// 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. + func testAPromisedRollbackKeepsTheClearsInsideTheDataTransaction() { + XCTAssertTrue(plan([step("orders")]).clearsInsideDataTransaction) + } + + func testARunThatPromisesNoRollbackClearsAhead() { + XCTAssertFalse(plan( + [step("orders")], + request: request(errorHandling: .skipAndContinue) + ).clearsInsideDataTransaction) + XCTAssertFalse(plan( + [step("orders")], + request: request(wrapEachTableInTransaction: false) + ).clearsInsideDataTransaction) + } + + /// Nothing to empty means nothing to promise, and the per-table transactions stay. + func testACopyWithNothingToEmptyKeepsItsPerTableTransactions() { + XCTAssertFalse(plan([step("orders", truncates: false)]).clearsInsideDataTransaction) + } + + /// The script is what the user approves, so it has to be in the order the run uses. Shown + /// against each table instead, it said the parent was emptied after the child had been filled. + func testTheScriptEmptiesEveryTableBeforeItReadsAny() { + let script = plan([step("customers"), step("orders")]).scriptText + let lines = script.components(separatedBy: "\n").filter { !$0.isEmpty } + + guard let lastDelete = lines.lastIndex(where: { $0.hasPrefix("DELETE FROM") }), + let firstSelect = lines.firstIndex(where: { $0.hasPrefix("SELECT") }) else { + return XCTFail("the script named neither the clears nor the reads:\n\(script)") + } + XCTAssertLessThan(lastDelete, firstSelect) + /// Children first there too, or the script and the run disagree about the cascade. + XCTAssertEqual( + lines.filter { $0.hasPrefix("DELETE FROM") }, + ["DELETE FROM orders;", "DELETE FROM customers;"] + ) + } + + /// A new database carries only whatever schema its engine gives it, so the rest are created + /// before the first `CREATE TABLE` names one. + func testSchemaStatementsLeadTheScriptAndTheDDL() { + let create = statement("CREATE SCHEMA IF NOT EXISTS \"sales\";", "sales") + let built = plan([step("orders", truncates: false)], schemaStatements: [create]) + + XCTAssertEqual(built.ddlStatements.first?.sql, create.sql) + XCTAssertTrue(built.scriptText.hasPrefix(create.sql)) + } + + /// One object fails once per phase it reaches, so a table whose CREATE failed under Skip and + /// Continue fails again in the data phase. Listed by the selection's id a SwiftUI `ForEach` saw + /// a duplicate identifier and dropped one of the two messages. + func testEveryFailureKeepsAnIdentityOfItsOwn() { + let selection = ObjectCopySelection(kind: .table, name: "orders", schema: "public") + let result = ObjectCopyRunResult( + outcomes: [ + ObjectCopyObjectOutcome(selection: selection, rowsCopied: 0, error: "syntax error"), + ObjectCopyObjectOutcome(selection: selection, rowsCopied: 0, error: "does not exist") + ], + rowsCopied: 0, + cancelled: false, + createdDatabase: nil + ) + + let failures = result.failures + XCTAssertEqual(failures.count, 2) + XCTAssertEqual(Set(failures.map(\.id)).count, 2) + XCTAssertEqual(failures.map(\.outcome.error), ["syntax error", "does not exist"]) + /// The summary still counts the object once, which is the reason the ids had to collide. + XCTAssertEqual(result.failedCount, 1) + } +} diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift index 093195625..8315ef8a3 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift @@ -296,4 +296,66 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "sales") } + + /// A table the sort could not place is appended in the caller's own order, which is why the + /// caller has to hand one in. Seeded from `reads.keys` the tail came out in whatever order + /// Swift's per-process hash seed gave the dictionary that launch, so the same copy produced a + /// different approved script, progress order and outcome list from one run to the next. + func testTablesTheSortCannotPlaceFollowTheOrderTheyWereGivenIn() { + let placed = selection("customers") + let names = ["zulu", "alpha", "mike"] + let unread = names.map { selection($0) } + let reads: [ObjectCopySelection: TableStructureRead] = [placed: read("customers")] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [placed] + unread, reads: reads, effectiveSchema: "public" + ) + + XCTAssertEqual(ordered.map(\.name), ["customers"] + names) + } + + /// The same selections in the same order answer the same way every time, whatever order the + /// reads were built in. + func testTheOrderDoesNotDependOnHowTheReadsWereBuilt() { + let names = ["zulu", "alpha", "mike", "bravo", "yankee"] + let selections = names.map { selection($0) } + var forwards: [ObjectCopySelection: TableStructureRead] = [:] + for (selection, name) in zip(selections, names) { forwards[selection] = read(name) } + var backwards: [ObjectCopySelection: TableStructureRead] = [:] + for (selection, name) in zip(selections, names).reversed() { backwards[selection] = read(name) } + + let first = ObjectCopyPlanner.orderedByDependency( + selections, reads: forwards, effectiveSchema: "public" + ) + let second = ObjectCopyPlanner.orderedByDependency( + selections, reads: backwards, effectiveSchema: "public" + ) + + XCTAssertEqual(first.map(\.name), second.map(\.name)) + XCTAssertEqual(Set(first.map(\.name)), Set(names)) + XCTAssertEqual(first.count, names.count) + } + + /// The tie-break holds while a real dependency still moves the tables it names. + func testTheGivenOrderYieldsToAForeignKey() { + let orders = selection("orders") + let customers = selection("customers") + let audit = selection("audit") + let reads: [ObjectCopySelection: TableStructureRead] = [ + orders: read("orders", referencing: ["customers"]), + customers: read("customers"), + audit: read("audit") + ] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [orders, audit, customers], reads: reads, effectiveSchema: "public" + ) + + guard let parent = ordered.firstIndex(of: customers), + let child = ordered.firstIndex(of: orders) else { + return XCTFail("the sort dropped a table") + } + XCTAssertLessThan(parent, child) + XCTAssertEqual(ordered.count, 3) + } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift index 04f86f362..6673a8eec 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift @@ -245,6 +245,22 @@ final class ObjectCopySessionTests: XCTestCase { XCTAssertTrue(subject.selectedObjectIds.contains(first.id)) } + /// What the checkbox writes, and the reason it states a value instead of inverting one. A + /// binding that flips whatever it is written turns a re-render, an accessibility `setValue`, or + /// a second delivery during list diffing into a change the user never made. + func testSettingTheSameValueTwiceChangesNothing() { + let subject = session() + guard let first = subject.availableObjects.first else { return XCTFail("no objects") } + + subject.setSelected(first, false) + subject.setSelected(first, false) + XCTAssertFalse(subject.selectedObjectIds.contains(first.id)) + + subject.setSelected(first, true) + subject.setSelected(first, true) + XCTAssertTrue(subject.selectedObjectIds.contains(first.id)) + } + // MARK: - Content func testDataOnlyLeavesStructureOut() { diff --git a/docs/features/copy-objects.mdx b/docs/features/copy-objects.mdx index f6b3c1aeb..93b569d32 100644 --- a/docs/features/copy-objects.mdx +++ b/docs/features/copy-objects.mdx @@ -69,7 +69,10 @@ emits that driver's own SQL, so neither structure nor data crosses from MySQL to sheet refuses before it reads anything. MySQL and MariaDB count as one engine. A database-level copy covers every schema. Each schema's objects are read and written in their own -scope, and a duplicate recreates each of them under the same name in the new database. +scope, and a duplicate recreates each of them under the same name in the new database. A new +database arrives with only the schema its engine gives it, so a duplicate creates the rest before +its first table. PostgreSQL does this; on an engine where a schema is not something one statement +can make, such as Oracle, where it is a user, the schemas have to be there first. Views, routines and triggers copy only where both sides share a namespace, which is the schema on PostgreSQL and SQL Server and the database on MySQL and MariaDB. Their definition is the source's @@ -98,8 +101,13 @@ appears in the result, and the rest of the copy is unaffected. ## Stopping a copy **Stop** takes effect at the next batch boundary. Each table is its own transaction where the engine -supports one, so the table being copied rolls back, along with the emptying that Replace does, and -the tables already finished stay. A driver blocked inside a network call finishes that batch first. +supports one, so the table being copied rolls back and the tables already finished stay. A driver +blocked inside a network call finishes that batch first. + +A Replace that removes the target's rows is the exception: every table is emptied before any of them +is filled, so that a cascading foreign key cannot reach a table you did not select, and the whole +data phase is then one transaction. Stopping it, or a failure under **Stop and roll back**, puts +every row back. **Skip and continue** promises no rollback, so there the clearing stands. A stopped copy reports only what was committed. The table it was in the middle of counts as neither copied nor failed.