From ed3e4caf19952523d63b4037dbf4e61996a68567 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 28 Aug 2026 22:24:28 +0700 Subject: [PATCH 1/2] feat(sidebar): copy objects and duplicate a database across connections --- CHANGELOG.md | 3 + .../SQLStatementGenerator.swift | 29 +- .../Core/Compare/CompareMetadataService.swift | 10 +- TablePro/Core/Menu/DatabaseMenuBuilder.swift | 12 + .../Core/ObjectCopy/ObjectCopyCatalog.swift | 112 +++ .../ObjectCopy/ObjectCopyEligibility.swift | 110 +++ TablePro/Core/ObjectCopy/ObjectCopyPlan.swift | 193 +++++ .../Core/ObjectCopy/ObjectCopyPlanner.swift | 770 ++++++++++++++++++ .../Core/ObjectCopy/ObjectCopyProgress.swift | 58 ++ .../Core/ObjectCopy/ObjectCopyRequest.swift | 167 ++++ .../Core/ObjectCopy/ObjectCopyRowCopier.swift | 143 ++++ .../Core/ObjectCopy/ObjectCopyRunner.swift | 365 +++++++++ .../Core/ObjectCopy/ObjectCopySession.swift | 419 ++++++++++ ...itViewController+DatabaseMenuActions.swift | 8 + ...inSplitViewController+MenuValidation.swift | 8 + .../Operations/OperationCompletion.swift | 1 + .../Operations/OperationCompletionCopy.swift | 1 + ...ontentCommandActions+DatabaseObjects.swift | 50 ++ .../Views/Main/MainContentCoordinator.swift | 5 + TablePro/Views/Main/MainContentView.swift | 2 + .../ObjectCopy/CopyObjectsConfigureView.swift | 130 +++ .../ObjectCopy/CopyObjectsListView.swift | 94 +++ .../ObjectCopy/CopyObjectsProgressView.swift | 63 ++ .../ObjectCopy/CopyObjectsResultView.swift | 103 +++ .../ObjectCopy/CopyObjectsReviewView.swift | 150 ++++ .../Views/ObjectCopy/CopyObjectsSheet.swift | 145 ++++ .../Settings/NotificationsSettingsView.swift | 1 + ...abaseTreeOutlineCoordinator+Commands.swift | 21 + .../DatabaseTreeOutlineCoordinator+Menu.swift | 12 +- .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 50 ++ .../Sidebar/Menu/SidebarMenuCommand.swift | 5 + .../SQLStatementGeneratorSchemaTests.swift | 70 ++ .../Core/Menu/MainMenuBuilderTests.swift | 20 + .../ObjectCopyEligibilityTests.swift | 116 +++ .../ObjectCopyPlannerOrderingTests.swift | 239 ++++++ .../ObjectCopy/ObjectCopyRowCopierTests.swift | 270 ++++++ .../ObjectCopySelectQueryTests.swift | 69 ++ .../ObjectCopy/ObjectCopySessionTests.swift | 281 +++++++ .../Sidebar/DatabaseTreeMenuSpecTests.swift | 124 ++- TableProUITests/CopyObjectsUITests.swift | 75 ++ docs/docs.json | 1 + docs/features/copy-objects.mdx | 139 ++++ 42 files changed, 4632 insertions(+), 12 deletions(-) create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyPlan.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyProgress.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyRequest.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyRunner.swift create mode 100644 TablePro/Core/ObjectCopy/ObjectCopySession.swift create mode 100644 TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift create mode 100644 TablePro/Views/ObjectCopy/CopyObjectsListView.swift create mode 100644 TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift create mode 100644 TablePro/Views/ObjectCopy/CopyObjectsResultView.swift create mode 100644 TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift create mode 100644 TablePro/Views/ObjectCopy/CopyObjectsSheet.swift create mode 100644 TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift create mode 100644 TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift create mode 100644 TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift create mode 100644 TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift create mode 100644 TableProTests/Core/ObjectCopy/ObjectCopySelectQueryTests.swift create mode 100644 TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift create mode 100644 TableProUITests/CopyObjectsUITests.swift create mode 100644 docs/features/copy-objects.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf41545e..3bdbf6668 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JavaScript shell for MongoDB queries, with mongosh's `db` API, cursors, variables, functions and `print`. - Per-connection MongoDB shell state, so a variable or function survives from one statement to the next. - Cursor method autocomplete after `find()` and `aggregate()`. +- Copy To on a table, a view, a schema and a database row, writing to any connection. (#2487) +- Duplicate Database on the sidebar's database rows, with the engine's own character set and collation. (#2487) +- Structure, data or both, with the script shown before anything runs. (#2487) ### Changed diff --git a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift index ca2c3c5eb..b61d242c2 100644 --- a/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift +++ b/TablePro/Core/ChangeTracking/SQLStatementGenerator.swift @@ -28,6 +28,11 @@ struct SQLStatementGenerator { private static let logger = Logger(subsystem: "com.TablePro", category: "SQLStatementGenerator") let tableName: String + /// Written into every statement when set, so a generator can address a table outside whatever + /// schema the connection happens to be on. A grid edit leaves it nil and keeps the unqualified + /// name it has always produced; a copy between two databases sets it, because the table it + /// writes is not the one the driver is pointed at. + let schemaName: String? let columns: [String] let primaryKeyColumns: [String] /// Server-computed columns. They reject any written value, so they are @@ -39,6 +44,7 @@ struct SQLStatementGenerator { init( tableName: String, + schemaName: String? = nil, columns: [String], primaryKeyColumns: [String], databaseType: DatabaseType, @@ -48,6 +54,7 @@ struct SQLStatementGenerator { quoteIdentifier: ((String) -> String)? = nil ) throws { self.tableName = tableName + self.schemaName = schemaName?.isEmpty == true ? nil : schemaName self.columns = columns self.primaryKeyColumns = primaryKeyColumns self.generatedColumns = generatedColumns @@ -61,6 +68,12 @@ struct SQLStatementGenerator { } } + /// The table as every statement spells it. + var qualifiedTableName: String { + guard let schemaName else { return quoteIdentifierFn(tableName) } + return "\(quoteIdentifierFn(schemaName)).\(quoteIdentifierFn(tableName))" + } + private static func defaultParameterStyle(for databaseType: DatabaseType) -> ParameterStyle { PluginMetadataRegistry.shared.snapshot(for: databaseType)?.parameterStyle ?? .questionMark } @@ -189,7 +202,7 @@ struct SQLStatementGenerator { let placeholders = placeholderParts.joined(separator: ", ") let sql = - "INSERT INTO \(quoteIdentifierFn(tableName)) (\(columnList)) VALUES (\(placeholders))" + "INSERT INTO \(qualifiedTableName) (\(columnList)) VALUES (\(placeholders))" return ParameterizedStatement(sql: sql, parameters: bindParameters) } @@ -207,7 +220,7 @@ struct SQLStatementGenerator { }.joined(separator: ", ") let sql = - "INSERT INTO \(quoteIdentifierFn(tableName)) (\(columnList)) VALUES (\(placeholders))" + "INSERT INTO \(qualifiedTableName) (\(columnList)) VALUES (\(placeholders))" return ParameterizedStatement(sql: sql, parameters: bindParameters) } @@ -229,7 +242,7 @@ struct SQLStatementGenerator { }.joined(separator: ", ") let sql = - "INSERT INTO \(quoteIdentifierFn(tableName)) (\(columnList)) VALUES \(rowTuples)" + "INSERT INTO \(qualifiedTableName) (\(columnList)) VALUES \(rowTuples)" return ParameterizedStatement(sql: sql, parameters: bindParameters) } @@ -243,7 +256,7 @@ struct SQLStatementGenerator { } func deleteAllRowsStatement() -> String { - "DELETE FROM \(quoteIdentifierFn(tableName))" + "DELETE FROM \(qualifiedTableName)" } private func generateInsertSQLFromCellChanges(for change: RowChange) -> ParameterizedStatement? @@ -272,7 +285,7 @@ struct SQLStatementGenerator { }.joined(separator: ", ") let sql = - "INSERT INTO \(quoteIdentifierFn(tableName)) (\(columnNames)) VALUES (\(placeholders))" + "INSERT INTO \(qualifiedTableName) (\(columnNames)) VALUES (\(placeholders))" return ParameterizedStatement(sql: sql, parameters: parameters) } @@ -328,7 +341,7 @@ struct SQLStatementGenerator { let whereClause = conditions.joined(separator: " AND ") let sql = - "UPDATE \(quoteIdentifierFn(tableName)) SET \(setClauses) WHERE \(whereClause)" + "UPDATE \(qualifiedTableName) SET \(setClauses) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } else { guard let originalRow = change.originalRow else { @@ -355,7 +368,7 @@ struct SQLStatementGenerator { let whereClause = conditions.joined(separator: " AND ") let sql = - "UPDATE \(quoteIdentifierFn(tableName)) SET \(setClauses) WHERE \(whereClause)" + "UPDATE \(qualifiedTableName) SET \(setClauses) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } @@ -444,7 +457,7 @@ struct SQLStatementGenerator { } let whereClause = rowClauses.joined(separator: " OR ") - let sql = "DELETE FROM \(quoteIdentifierFn(tableName)) WHERE \(whereClause)" + let sql = "DELETE FROM \(qualifiedTableName) WHERE \(whereClause)" return ParameterizedStatement(sql: sql, parameters: parameters) } diff --git a/TablePro/Core/Compare/CompareMetadataService.swift b/TablePro/Core/Compare/CompareMetadataService.swift index 9b0824246..298b4e315 100644 --- a/TablePro/Core/Compare/CompareMetadataService.swift +++ b/TablePro/Core/Compare/CompareMetadataService.swift @@ -99,18 +99,26 @@ internal struct CompareMetadataService { /// One object's failure is that object's, not the comparison's. A single unreadable table used /// to abort the whole run, which is why `TableDiffResult.comparisonError` was read by the UI and /// written by nothing. + /// + /// `names` narrows the read to the objects the caller already knows it wants, matched without + /// regard to case because engines disagree on identifier folding. A comparison passes nil and + /// reads the whole scope; a copy of one table would otherwise pay four round trips for every + /// other table in the database. internal func tableReads( for endpoint: DatabaseEndpoint, connection: DatabaseConnection, - includeViews: Bool + includeViews: Bool, + names: Set? = nil ) async throws -> [TableStructureRead] { try await manager.ensureConnected(connection) let schema = endpoint.schema let concurrency = Self.metadataConcurrency(for: endpoint.databaseType) + let wanted = names.map { Set($0.map { $0.lowercased() }) } return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in guard let plugin = Self.pluginDriver(from: driver) else { return [] } let tables = try await plugin.fetchTables(schema: schema).filter { table in + guard wanted?.contains(table.name.lowercased()) ?? true else { return false } let kind = CompareTableKindClassifier.kind(of: table) return kind == .table || includeViews } diff --git a/TablePro/Core/Menu/DatabaseMenuBuilder.swift b/TablePro/Core/Menu/DatabaseMenuBuilder.swift index e9ddb376c..f22ebcbd7 100644 --- a/TablePro/Core/Menu/DatabaseMenuBuilder.swift +++ b/TablePro/Core/Menu/DatabaseMenuBuilder.swift @@ -46,6 +46,18 @@ enum DatabaseMenuBuilder { action: #selector(MainSplitViewController.createNewView(_:)) ), MenuItemFactory.separator, + /// The sidebar's own Copy To and Duplicate Database, mirrored so both are reachable + /// from the keyboard. The menu acts on the database being browsed, which is what a + /// command with no clicked row can mean. + MenuItemFactory.item( + String(localized: "Copy Objects To…"), + action: #selector(MainSplitViewController.copyObjectsToDatabase(_:)) + ), + MenuItemFactory.item( + String(localized: "Duplicate Database…"), + action: #selector(MainSplitViewController.duplicateCurrentDatabase(_:)) + ), + MenuItemFactory.separator, MenuItemFactory.item( String(localized: "Show Table Structure"), action: #selector(MainSplitViewController.showTableStructure(_:)) diff --git a/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift b/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift new file mode 100644 index 000000000..d6217020b --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift @@ -0,0 +1,112 @@ +// +// ObjectCopyCatalog.swift +// TablePro +// +// What a scope has to offer, by identity only. +// +// The sheet lists objects before the user has chosen anything, so this reads +// names rather than structures: `CompareMetadataService.tableReads` pays four +// round trips per table for columns, indexes, foreign keys and metadata, which +// is the right price to plan a copy and the wrong one to fill a checklist. +// +// A routine carries its argument signature and a trigger its table, because +// two overloads and two same-named triggers are two objects and the planner +// keys on that identity. +// + +import Foundation +import os +import TableProPluginKit + +@MainActor +internal struct ObjectCopyCatalog { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCopyCatalog") + + private let manager: DatabaseManager + + internal init(manager: DatabaseManager = .shared) { + self.manager = manager + } + + internal func objects( + in endpoint: DatabaseEndpoint, + connection: DatabaseConnection + ) async throws -> [ObjectCopySelection] { + try await manager.ensureConnected(connection) + let schema = endpoint.schema + return try await manager.withMetadataDriver(scope: endpoint.scope, workload: .bulk) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [] } + return try await Self.read(from: plugin, schema: schema) + } + } + + nonisolated private static func read( + from plugin: any PluginDatabaseDriver, + schema: String? + ) async throws -> [ObjectCopySelection] { + var found: [ObjectCopySelection] = [] + + let tables = try await plugin.fetchTables(schema: schema) + for table in tables where !CompareTableKindClassifier.isForeign(table) { + found.append(ObjectCopySelection( + kind: CompareTableKindClassifier.kind(of: table), + name: table.name, + schema: table.schema ?? schema + )) + } + + /// A driver that does not report routines or triggers answers with an empty list rather + /// than an error, and one that fails is not a reason to offer no tables either. + let routines = (try? await plugin.fetchRoutines(schema: schema)) ?? [] + for routine in routines { + found.append(ObjectCopySelection( + kind: routine.kind == .procedure ? .procedure : .function, + name: routine.name, + schema: routine.schema ?? schema, + signature: routine.argumentSignature + )) + } + + let triggers = (try? await plugin.fetchAllTriggers(schema: schema)) ?? [] + for trigger in triggers { + found.append(ObjectCopySelection( + kind: .trigger, + name: trigger.name, + schema: trigger.schema ?? schema, + owner: trigger.table + )) + } + return found + } + + /// The schemas a database-wide copy would have to cover, so the sheet can refuse rather than + /// carry one schema's objects and call it the database. + internal func schemas( + in endpoint: DatabaseEndpoint, + connection: DatabaseConnection + ) async throws -> [String] { + try await manager.ensureConnected(connection) + return try await manager.withMetadataDriver(scope: endpoint.scope) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver), plugin.supportsSchemas + else { return [] } + return try await plugin.fetchSchemas() + } + } + + /// The form the destination's `CREATE DATABASE` offers. + /// + /// Its absence is also the honest answer to "can this driver create a database at all": every + /// driver that implements `createDatabase` publishes one, and the ones that inherit the + /// protocol's throwing default publish nil. Keying Duplicate on `supportsDatabaseSwitching` + /// instead offered it on DuckDB, Trino and Teradata, where it reached that default. + internal func createDatabaseForm( + for endpoint: DatabaseEndpoint, + connection: DatabaseConnection + ) async throws -> CreateDatabaseFormSpec? { + try await manager.ensureConnected(connection) + let scope = DatabaseScope(connectionId: endpoint.connectionId, database: "", schema: nil) + return try await manager.withMetadataDriver(scope: scope) { driver in + try await driver.createDatabaseFormSpec() + } + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift new file mode 100644 index 000000000..b10ae26c7 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift @@ -0,0 +1,110 @@ +// +// ObjectCopyEligibility.swift +// TablePro +// +// Why a copy cannot run. +// +// The rules are pure so the sheet can say no at selection time rather than +// after the user has filled in a form and pressed Copy. What a driver can +// actually do is asked of the driver, never inferred: the first version keyed +// the menu on the editor language, which is `.sql` for DynamoDB's PartiQL and +// for Cassandra's CQL, so both offered a command that failed while planning. +// + +import Foundation +import TableProPluginKit + +internal enum ObjectCopyEligibility { + /// Whether this engine can take part at all. + /// + /// Both halves of a copy are SQL: the DDL comes from `generateCreateTableSQL` and the rows go + /// through `SQLStatementGenerator`, the same writer CSV and JSON import use. An engine whose + /// query language is not SQL has neither, so the commands are omitted rather than offered and + /// then refused. This is a necessary condition, not a sufficient one: the planner asks the + /// driver itself before generating anything. + internal static func supportsCopying(editorLanguage: EditorLanguage) -> Bool { + editorLanguage == .sql + } + + /// Whether the Duplicate Database command is worth offering at all. + /// + /// Deliberately optimistic, and paired with a refusal that is not. Whether a driver can create + /// a database is only knowable by asking it, which a contextual menu cannot do while it is + /// being built, so the menu answers from what it has and the sheet names the engine that + /// cannot when its create-database form comes back empty. That is the shape Compare & Sync + /// already uses for its own gate: a command the user might be able to run stays visible, and + /// choosing it explains what it needs. + internal static func mayOfferDuplicateDatabase( + editorLanguage: EditorLanguage, + supportsDatabaseSwitching: Bool, + isReadOnly: Bool + ) -> Bool { + supportsCopying(editorLanguage: editorLanguage) && supportsDatabaseSwitching && !isReadOnly + } + + /// The one refusal the user cannot work around by choosing differently, so it is checked first. + internal static func targetRefusal(_ target: DatabaseEndpoint) -> String? { + target.ineligibleAsTargetReason + } + + /// A copy stays inside one engine, whatever half of an object it carries. + /// + /// Structure cannot cross because column data types are driver-native strings. Data cannot + /// cross either, and the first version let it: the row writer emits `INSERT … VALUES` and a + /// MongoDB or Elasticsearch target parses neither, while a SQL Server `dbo` source handed a + /// MySQL target a schema that engine does not have. Comparing the two remains available in + /// Compare & Sync, which reads rather than writes. + internal static func engineRefusal(from source: DatabaseType, to target: DatabaseType) -> String? { + guard !CompareSyncEngineFamily.canGenerateStructureScript(from: source, to: target) else { return nil } + return String( + format: String(localized: "%1$@ cannot be copied to %2$@. Choose a target of the same type."), + source.rawValue, target.rawValue + ) + } + + /// A source object and a target object that are the same object. + /// + /// Copying a table onto itself either drops the rows it is about to read or doubles them, and + /// which of the two depends on a policy the user picked for every object at once. + internal static func sameObjectRefusal( + source: DatabaseEndpoint, + target: DatabaseEndpoint + ) -> String? { + guard source.id == target.id else { return nil } + return String(localized: "The source and the target are the same database. Choose a different target.") + } + + /// A database-wide copy on an engine that has schemas has to name one. + /// + /// `fetchTables(schema: nil)` resolves to the connection's current schema on PostgreSQL and + /// SQL Server, so a whole-database copy taken without a schema silently carried one schema's + /// objects and left every other schema behind while reporting success. + internal static func unscopedSchemaRefusal( + endpoint: DatabaseEndpoint, + supportsSchemas: Bool + ) -> String? { + guard supportsSchemas, (endpoint.schema ?? "").isEmpty else { return nil } + return String( + format: String(localized: "%@ keeps its objects in schemas. Copy one schema at a time."), + endpoint.databaseType.rawValue + ) + } + + /// 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 table it + /// names stays spelled the way the source spelled it. Running that against a different schema + /// either recreates the object pointing back at the source or, for a replacement, drops the + /// source's own object. Copying one is therefore only sound into the same schema name. + internal static func canCopyDefinition(sourceSchema: String?, targetSchema: String?) -> Bool { + let source = (sourceSchema ?? "").lowercased() + let target = (targetSchema ?? "").lowercased() + return source == target + } + + internal static var definitionSchemaRefusal: String { + String( + localized: "Its definition names the source's own schema, so it is only copied into a schema of the same name." + ) + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift new file mode 100644 index 000000000..42b8bb3a4 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift @@ -0,0 +1,193 @@ +// +// ObjectCopyPlan.swift +// TablePro +// +// What the run will do, resolved against both sides before anything is written. +// +// The DDL is spelled out because a user can read it and refuse. The rows are +// not: one INSERT per row is the cost this feature exists to avoid holding, so +// a table's data step carries the query it will walk and the columns it will +// write, and the rows arrive one batch at a time while the run is going. +// + +import Foundation + +/// One table's work: its DDL, and the read and write it will stream between. +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] + 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. + /// + /// 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. + internal let truncateStatements: [SyncStatement] + /// The columns written, source order, with generated columns already removed. Empty when this + /// step copies structure only. + internal let columns: [String] + internal let primaryKeyColumns: [String] + internal let sourceQuery: String + internal let targetTable: String + internal let targetSchema: String? + /// The driver's own estimate, for the progress bar. Nil where the driver has none. + internal let estimatedRows: Int? + internal let copiesData: Bool + /// True when a column this step writes is one the server may insist on generating itself. + internal let copiesIdentityColumn: Bool + /// Set when this table is in the plan but part of it cannot run, so the sheet can say why + /// before the user presses Copy. + internal let note: String? + + internal var id: String { selection.id } + + /// 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 qualifiedTargetName: String { + guard let targetSchema, !targetSchema.isEmpty else { return targetTable } + return "\(targetSchema).\(targetTable)" + } +} + +/// One view, routine or trigger: its definition is SQL text, so there is nothing to stream. +/// +/// The drop and the create are kept apart because they run in different phases and in opposite +/// orders: everything is torn down children first, then built parents first. +internal struct ObjectCopyDefinitionStep: Identifiable, Sendable { + internal let selection: ObjectCopySelection + internal let dropStatements: [SyncStatement] + internal let createStatements: [SyncStatement] + + internal var id: String { selection.id } + + internal var statements: [SyncStatement] { dropStatements + createStatements } + + /// A trigger fires on the rows the copy is about to write, so installing one before the data + /// phase makes the copy trip it: an audit trigger writes a second row for every row copied, + /// and a validating one rejects rows the source already holds. + internal var runsAfterData: Bool { selection.kind == .trigger } +} + +/// One object's statements for one phase, so a failure is attributed to the object that caused it. +internal struct ObjectCopyStatementGroup: Sendable { + internal let selection: ObjectCopySelection + internal let statements: [SyncStatement] + + internal init(_ selection: ObjectCopySelection, _ statements: [SyncStatement]) { + self.selection = selection + self.statements = statements + } +} + +/// An object left out, with the reason, so nothing disappears without saying so. +internal struct ObjectCopySkip: Identifiable, Sendable { + internal let selection: ObjectCopySelection + internal let reason: String + + internal var id: String { selection.id } +} + +internal struct ObjectCopyPlan: Sendable { + internal let request: ObjectCopyRequest + internal let createsDatabase: Bool + internal let tableSteps: [ObjectCopyTableStep] + internal let definitionSteps: [ObjectCopyDefinitionStep] + internal let skipped: [ObjectCopySkip] + + internal init( + request: ObjectCopyRequest, + createsDatabase: Bool, + tableSteps: [ObjectCopyTableStep], + definitionSteps: [ObjectCopyDefinitionStep], + skipped: [ObjectCopySkip] = [] + ) { + self.request = request + self.createsDatabase = createsDatabase + self.tableSteps = tableSteps + self.definitionSteps = definitionSteps + self.skipped = skipped + } + + /// Shown above the script. The engine cannot differ any more, so the one caveat left is the + /// one the copy cannot do anything about: a key column the server insists on generating + /// refuses the value the source holds, and the table's own error is the first the user sees. + internal var warnings: [String] { + guard dataSteps.contains(where: \.copiesIdentityColumn) else { return [] } + return [String( + localized: "Identity and auto-increment values are written as they are. A column the server generates always may refuse them." + )] + } + + /// Emptiness is about work, not about steps. A data-only copy into a table the target does not + /// have, and a structure-only copy set to add rows to a table it already has, both keep a step + /// that runs nothing: the review then showed an empty script and Copy reported success over + /// zero objects. + internal var isEmpty: Bool { + !createsDatabase && ddlStatements.isEmpty && dataSteps.isEmpty + } + + /// Everything the run writes that is not a row, in the order it writes it. + internal var ddlStatements: [SyncStatement] { + cleanupStatements + creationStatements + afterDataStatements + } + + /// Cleanup runs children first, so a foreign key is gone before the table it points at, and a + /// trigger before the table that owns it. Creation then runs parents first. Doing both in one + /// parent-first pass had every DROP rejected by the constraint below it. + internal var cleanupGroups: [ObjectCopyStatementGroup] { + definitionSteps.reversed().map { ObjectCopyStatementGroup($0.selection, $0.dropStatements) } + + tableSteps.reversed().map { ObjectCopyStatementGroup($0.selection, $0.dropStatements) } + } + + internal var creationGroups: [ObjectCopyStatementGroup] { + tableSteps.map { ObjectCopyStatementGroup($0.selection, $0.createStatements) } + + definitionSteps.filter { !$0.runsAfterData } + .map { ObjectCopyStatementGroup($0.selection, $0.createStatements) } + } + + /// The definitions held back until the rows are in. + internal var afterDataGroups: [ObjectCopyStatementGroup] { + definitionSteps.filter(\.runsAfterData) + .map { ObjectCopyStatementGroup($0.selection, $0.createStatements) } + } + + internal var cleanupStatements: [SyncStatement] { cleanupGroups.flatMap(\.statements) } + internal var creationStatements: [SyncStatement] { creationGroups.flatMap(\.statements) } + internal var afterDataStatements: [SyncStatement] { afterDataGroups.flatMap(\.statements) } + + internal var dataSteps: [ObjectCopyTableStep] { + tableSteps.filter(\.copiesData) + } + + /// The sum the progress bar counts against, in rows. A table the driver has no estimate for + /// contributes nothing, so the bar can only run ahead of itself, never behind. + internal var estimatedRowTotal: Int { + dataSteps.reduce(0) { $0 + ($1.estimatedRows ?? 0) } + } + + /// 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. + internal var scriptText: String { + var lines: [String] = [] + if case .newDatabase(_, let name, _) = request.destination { + lines.append(String(format: String(localized: "-- Create database %@"), name)) + } + lines += cleanupStatements.map(\.sql) + lines += creationStatements.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") } + lines.append("") + lines.append(String(localized: "-- Once the rows are in")) + lines += afterDataStatements.map(\.sql) + return lines.joined(separator: "\n") + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift new file mode 100644 index 000000000..9db4f1964 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -0,0 +1,770 @@ +// +// ObjectCopyPlanner.swift +// TablePro +// +// Resolves a request into the work the runner will do. +// +// Everything the plan needs is read here, while nothing is being written, so +// the sheet can show the DDL and every refusal before the user commits. The +// reads go through `CompareMetadataService`, which routes them through +// `DatabaseManager.withMetadataDriver` and so keeps them off the connection's +// live interactive driver. +// +// A driver is taken once per side rather than once per object: the target's +// DDL for every table is built inside one scoped call, because a copy of a +// hundred tables would otherwise take the session gate a hundred times. +// + +import Foundation +import TableProPluginKit + +@MainActor +internal struct ObjectCopyPlanner { + private let metadata: CompareMetadataService + private let catalog: ObjectCopyCatalog + private let manager: DatabaseManager + + internal init( + metadata: CompareMetadataService = CompareMetadataService(), + catalog: ObjectCopyCatalog = ObjectCopyCatalog(), + manager: DatabaseManager = .shared + ) { + self.metadata = metadata + self.catalog = catalog + self.manager = manager + } + + internal func plan(_ request: ObjectCopyRequest) async throws -> ObjectCopyPlan { + let connections = try resolveConnections(request) + try await manager.ensureConnected(connections.source) + try await manager.ensureConnected(connections.target) + try refuseUpFront(request) + + let names = Set(request.objects.map(\.name)) + let sourceReads = try await metadata.tableReads( + for: request.source, connection: connections.source, includeViews: true, names: names + ) + let targetReads = try await existingTargetReads(request, connection: connections.target, names: names) + let targetObjects = try await existingTargetObjects(request, connection: connections.target) + + var skipped: [ObjectCopySkip] = [] + let tableSteps = try await buildTableSteps( + request, sourceReads: sourceReads, targetReads: targetReads, skipped: &skipped + ) + let definitionSteps = try await buildDefinitionSteps( + request, + sourceReads: sourceReads, + targetObjects: targetObjects, + connection: connections.source, + skipped: &skipped + ) + + return ObjectCopyPlan( + request: request, + createsDatabase: request.destination.createsDatabase, + tableSteps: tableSteps, + definitionSteps: definitionSteps, + skipped: skipped + ) + } + + // MARK: - Refusals + + private struct Connections { + let source: DatabaseConnection + let target: DatabaseConnection + } + + private func resolveConnections(_ request: ObjectCopyRequest) throws -> Connections { + let saved = ConnectionStorage.shared.loadConnections() + guard let source = saved.first(where: { $0.id == request.source.connectionId }) else { + throw ObjectCopyError.refused(missingConnection(request.source)) + } + guard let target = saved.first(where: { $0.id == request.target.connectionId }) else { + throw ObjectCopyError.refused(missingConnection(request.target)) + } + return Connections(source: source, target: target) + } + + private func missingConnection(_ endpoint: DatabaseEndpoint) -> String { + String(format: String(localized: "%@ is no longer a saved connection."), endpoint.connectionName) + } + + private func refuseUpFront(_ request: ObjectCopyRequest) throws { + if let reason = ObjectCopyEligibility.targetRefusal(request.target) { + throw ObjectCopyError.refused(reason) + } + if !request.destination.createsDatabase, + let reason = ObjectCopyEligibility.sameObjectRefusal(source: request.source, target: request.target) { + throw ObjectCopyError.refused(reason) + } + if let reason = ObjectCopyEligibility.engineRefusal( + from: request.source.databaseType, to: request.target.databaseType + ) { + throw ObjectCopyError.refused(reason) + } + let supportsSchemas = PluginManager.shared.supportsSchemaSwitching(for: request.source.databaseType) + if let reason = ObjectCopyEligibility.unscopedSchemaRefusal( + endpoint: request.source, supportsSchemas: supportsSchemas + ) { + throw ObjectCopyError.refused(reason) + } + guard request.content.includesData else { return } + if let reason = CompareRowService(manager: manager) + .concurrentReadRefusal(source: request.source, target: request.target) { + throw ObjectCopyError.refused(reason) + } + } + + + /// A database this run is about to create holds nothing, and asking a driver about a database + /// that does not exist yet is an error rather than an empty answer. + private func existingTargetReads( + _ request: ObjectCopyRequest, + connection: DatabaseConnection, + names: Set + ) async throws -> [TableStructureRead] { + guard !request.destination.createsDatabase else { return [] } + return try await metadata.tableReads( + for: request.target, connection: connection, includeViews: true, names: names + ) + } + + /// What the target already has, by kind and name. + /// + /// `tableReads` lists tables and views and nothing else, so matching a routine or a trigger + /// against it always answered "not there": Skip would not skip one, Replace would not drop one + /// first, and the `CREATE` then failed with "already exists" against a target the user had + /// asked to leave alone. Keyed by kind as well as name, because a table and a trigger may share + /// one and only the trigger's own presence decides the trigger's step. + private func existingTargetObjects( + _ request: ObjectCopyRequest, + connection: DatabaseConnection + ) async throws -> Set { + guard !request.destination.createsDatabase else { return [] } + let found = try await catalog.objects(in: request.target, connection: connection) + return Set(found.map { Self.objectKey(for: $0) }) + } + + /// A materialized view and a view are one object to the engines that have both, and a + /// procedure and a function share a namespace on several, so the key folds those pairs. The + /// signature and the trigger's table stay in it, because two overloads and two same-named + /// triggers are two objects and only one of them may already be in the target. + nonisolated internal static func objectKey(for selection: ObjectCopySelection) -> String { + let family: String + switch selection.kind { + case .view, .materializedView: family = "view" + case .procedure, .function: family = "routine" + default: family = selection.kind.rawValue + } + return [family, selection.name, selection.signature ?? "", selection.owner ?? ""] + .map { $0.lowercased() } + .joined(separator: "\u{1F}") + } + + // MARK: - Tables + + private func buildTableSteps( + _ request: ObjectCopyRequest, + sourceReads: [TableStructureRead], + targetReads: [TableStructureRead], + skipped: inout [ObjectCopySkip] + ) async throws -> [ObjectCopyTableStep] { + var reads: [ObjectCopySelection: TableStructureRead] = [:] + for selection in request.tables { + guard let read = match(selection, in: sourceReads) else { + skipped.append(ObjectCopySkip(selection: selection, reason: Self.missingInSource)) + continue + } + guard read.snapshot != nil else { + skipped.append(ObjectCopySkip(selection: selection, reason: read.failure ?? Self.unreadable)) + continue + } + reads[selection] = read + } + guard !reads.isEmpty else { return [] } + + let sourceSchema = request.source.schema + var drafts: [ObjectCopyTableDraft] = [] + for selection in Self.orderedByDependency( + Array(reads.keys), reads: reads, effectiveSchema: sourceSchema + ) { + guard let read = reads[selection], let snapshot = read.snapshot else { continue } + let targetRead = match(selection, in: targetReads) + let existsInTarget = targetRead != nil + if existsInTarget, request.existingPolicy == .skip { + skipped.append(ObjectCopySkip(selection: selection, reason: Self.alreadyThere)) + continue + } + /// A table the target lists but cannot describe leaves the copy guessing which of its + /// columns are writable, so it is refused rather than written to blind. Replacing its + /// structure outright needs nothing from it and still goes ahead. + if existsInTarget, targetRead?.snapshot == nil, request.existingPolicy != .replace { + skipped.append(ObjectCopySkip( + selection: selection, reason: targetRead?.failure ?? Self.targetUnreadable + )) + continue + } + drafts.append(ObjectCopyTableDraft( + selection: selection, + snapshot: snapshot, + targetSnapshot: targetRead?.snapshot, + existsInTarget: existsInTarget, + request: request + )) + } + guard !drafts.isEmpty else { return [] } + + let sourceParts = try await readSourceParts(drafts, endpoint: request.source) + let ddl = try await buildTargetDDL(drafts, request: request) + return drafts.map { draft in + let parts = sourceParts[draft.selection.id] + let statements = ddl[draft.selection.id] ?? ObjectCopyTableDDL() + return ObjectCopyTableStep( + selection: draft.selection, + dropStatements: statements.drop, + createStatements: statements.create, + truncateStatements: statements.truncate, + columns: draft.targetColumns, + primaryKeyColumns: draft.snapshot.primaryKeyColumns, + sourceQuery: parts?.query ?? "", + targetTable: draft.targetTable, + targetSchema: draft.targetSchema, + estimatedRows: parts?.estimatedRows, + copiesData: draft.copiesData, + copiesIdentityColumn: draft.copiesIdentityColumn, + note: draft.note + ) + } + } + + private struct SourceParts: Sendable { + let query: String + let estimatedRows: Int? + } + + /// One scoped call for every table, because each `withMetadataDriver` either leases a pooled + /// connection or takes the session gate. + private func readSourceParts( + _ drafts: [ObjectCopyTableDraft], + endpoint: DatabaseEndpoint + ) async throws -> [String: SourceParts] { + /// 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. + let inputs = drafts.map { + (id: $0.selection.id, table: $0.snapshot.name, schema: $0.sourceSchema, columns: $0.sourceColumns) + } + 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] = [:] + 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 + ) + parts[input.id] = SourceParts(query: query, estimatedRows: estimate ?? nil) + } + return parts + } + } + + private func buildTargetDDL( + _ drafts: [ObjectCopyTableDraft], + request: ObjectCopyRequest + ) async throws -> [String: ObjectCopyTableDDL] { + let sourceSchema = request.source.schema + let inputs = drafts.map { + ObjectCopyDDLInput( + id: $0.selection.id, + snapshot: Self.retargeted( + $0.snapshot, from: sourceSchema, to: $0.targetSchema + ), + targetSchema: $0.targetSchema, + writesStructure: $0.writesStructure, + dropsFirst: $0.dropsFirst, + emptiesFirst: $0.emptiesFirst + ) + } + guard inputs.contains(where: { $0.writesStructure || $0.dropsFirst || $0.emptiesFirst }) + else { return [:] } + + return try await manager.withMetadataDriver(scope: targetScope(request)) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { + throw ObjectCopyError.refused(Self.noTargetDriver) + } + let builder = SchemaSyncScriptBuilder(targetDriver: plugin) + var result: [String: ObjectCopyTableDDL] = [:] + for input in inputs { + try Task.checkCancellation() + var ddl = ObjectCopyTableDDL() + if input.dropsFirst { + ddl.drop = try builder.build( + operations: [.dropTable(name: input.snapshot.name, schema: input.targetSchema)], + foreignKeysByTable: [:] + ) + } + if input.writesStructure { + ddl.create = try builder.build( + operations: [.createTable(input.snapshot)], foreignKeysByTable: [:] + ) + } + if input.emptiesFirst { + ddl.truncate = Self.emptyStatements( + table: input.snapshot.name, schema: input.targetSchema, driver: plugin + ) + } + result[input.id] = ddl + } + return result + } + } + + /// Points the snapshot's foreign keys at the copy rather than at the original. + /// + /// A snapshot carries each foreign key's `referencedSchema` as the source spelled it. Handed + /// unchanged to the target generator, the copied child either names a schema the target does + /// not have or, worse, keeps referencing the source's parent, so `prod_copy.orders` stayed + /// wired to `prod.customers` and the duplicate was never independent. A reference that names + /// the source's own schema is moved to the target's; one that names a third schema is left + /// alone, because that schema was not part of the copy. + nonisolated internal static func retargeted( + _ snapshot: TableStructureSnapshot, + from sourceSchema: String?, + to targetSchema: String? + ) -> TableStructureSnapshot { + let source = (sourceSchema ?? "").lowercased() + guard source != (targetSchema ?? "").lowercased() else { return snapshot } + let foreignKeys = snapshot.foreignKeys.map { key -> EditableForeignKeyDefinition in + let referenced = (key.referencedSchema ?? "").lowercased() + guard referenced.isEmpty || referenced == source else { return key } + var moved = key + moved.referencedSchema = targetSchema + return moved + } + return TableStructureSnapshot( + name: snapshot.name, + schema: targetSchema, + columns: snapshot.columns, + indexes: snapshot.indexes, + foreignKeys: foreignKeys, + engine: snapshot.engine, + charset: snapshot.charset, + collation: snapshot.collation + ) + } + + /// TRUNCATE where the engine has one, DELETE where it does not. Both are hazards, and both are + /// in the script the user reads before pressing Copy. + nonisolated private static func emptyStatements( + table: String, + schema: String?, + driver: any PluginDatabaseDriver + ) -> [SyncStatement] { + let qualified: String = { + guard let schema, !schema.isEmpty else { return driver.quoteIdentifier(table) } + return "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(table))" + }() + let sql = driver.truncateTableStatements(table: table, schema: schema, cascade: false)?.first + ?? "DELETE FROM \(qualified)" + return [SyncStatement( + sql: sql.hasSuffix(";") ? sql : sql + ";", + objectName: table, + summary: String(format: String(localized: "Empty %@ before copying"), table), + hazards: [SyncHazard( + kind: .dataLoss, + severity: .refusedByDefault, + explanation: String( + format: String(localized: "Every row already in %@ is removed."), table + ) + )] + )] + } + + // MARK: - Views, routines and triggers + + private func buildDefinitionSteps( + _ request: ObjectCopyRequest, + sourceReads: [TableStructureRead], + targetObjects: Set, + connection: DatabaseConnection, + skipped: inout [ObjectCopySkip] + ) async throws -> [ObjectCopyDefinitionStep] { + let selections = request.sourceDefinedObjects + guard !selections.isEmpty else { return [] } + guard request.content.includesStructure else { + skipped += selections.map { ObjectCopySkip(selection: $0, reason: Self.structureOnlyObject) } + return [] + } + + let targetSchema = request.target.schema + let definitions = try await sourceDefinitions(request, sourceReads: sourceReads, connection: connection) + var pending: [(selection: ObjectCopySelection, definition: String, replaces: Bool)] = [] + for selection in Self.orderedByKind(selections) { + /// Nothing here parses the definition, so every table it names keeps the source's own + /// qualification. Running that against another schema recreates the object pointing + /// back at the source, and for a replacement the generated DROP lands on the source's + /// own object. + guard ObjectCopyEligibility.canCopyDefinition( + sourceSchema: selection.schema ?? request.source.schema, targetSchema: targetSchema + ) else { + skipped.append(ObjectCopySkip( + selection: selection, reason: ObjectCopyEligibility.definitionSchemaRefusal + )) + continue + } + guard let definition = definitions[selection.id], + !definition.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + skipped.append(ObjectCopySkip(selection: selection, reason: Self.noDefinition)) + continue + } + let existsInTarget = targetObjects.contains(Self.objectKey(for: selection)) + /// Add rows promises the target's structure is kept, and these objects hold no rows at + /// all, so replacing one would be pure destruction with nothing to gain by it. + if existsInTarget, request.existingPolicy != .replace { + skipped.append(ObjectCopySkip(selection: selection, reason: Self.alreadyThere)) + continue + } + pending.append((selection, definition, existsInTarget)) + } + guard !pending.isEmpty else { return [] } + + let inputs = pending.map { item in + ( + id: item.selection.id, + result: CompareObjectResult( + identity: CompareObjectIdentity( + kind: item.selection.kind, + schema: targetSchema ?? item.selection.schema, + name: item.selection.name, + signature: item.selection.signature + ), + status: item.replaces ? .differs : .onlyInSource, + sourceDefinition: [item.definition] + ), + replaces: item.replaces + ) + } + let built = try await manager.withMetadataDriver(scope: targetScope(request)) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { + throw ObjectCopyError.refused(Self.noTargetDriver) + } + let builder = SourceObjectSyncBuilder(targetDriver: plugin) + var statements: [String: (drop: [SyncStatement], create: [SyncStatement])] = [:] + for input in inputs { + let drop = input.replaces ? builder.build(for: input.result, action: .drop) : [] + let create = builder.build(for: input.result, action: .create) + statements[input.id] = (drop, create) + } + return statements + } + + var steps: [ObjectCopyDefinitionStep] = [] + for item in pending { + guard let statements = built[item.selection.id], !statements.create.isEmpty else { + skipped.append(ObjectCopySkip(selection: item.selection, reason: Self.noDefinition)) + continue + } + steps.append(ObjectCopyDefinitionStep( + selection: item.selection, + dropStatements: statements.drop, + createStatements: statements.create + )) + } + return steps + } + + private func sourceDefinitions( + _ request: ObjectCopyRequest, + sourceReads: [TableStructureRead], + connection: DatabaseConnection + ) async throws -> [String: String] { + var definitions: [String: String] = [:] + let selections = request.sourceDefinedObjects + + let views = selections.filter { $0.kind == .view || $0.kind == .materializedView } + if !views.isEmpty { + let infos = sourceReads.map(\.table).filter { info in + views.contains { $0.name.lowercased() == info.name.lowercased() } + } + for read in try await metadata.viewDefinitions( + for: request.source, connection: connection, views: infos + ) { + guard let selection = views.first(where: { $0.name.lowercased() == read.name.lowercased() }) + else { continue } + definitions[selection.id] = read.source + } + } + + /// Matched on the argument signature as well as the name, because `f(integer)` and + /// `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) { + guard let selection = routines.first(where: { + $0.kind == read.kind + && $0.name.lowercased() == read.name.lowercased() + && ($0.signature ?? "") == (read.signature ?? "") + }) else { continue } + definitions[selection.id] = read.source + } + } + + /// Asked of the tables the selected triggers name, not of the tables the user happened to + /// select. Deriving the lookup from the table selection meant a trigger chosen on its own + /// had no table to be found under and was always reported as having no definition. + let triggers = selections.filter { $0.kind == .trigger } + 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) + ) { + guard let selection = triggers.first(where: { + $0.name.lowercased() == read.name.lowercased() + && ($0.owner.map { $0.lowercased() == (read.signature ?? "").lowercased() } ?? true) + }) + else { continue } + definitions[selection.id] = read.source + } + } + return definitions + } + + // MARK: - Ordering + + /// Parents before children, so a foreign key in a `CREATE TABLE` finds the table it points at. + /// A cycle keeps whatever order the sort settles on and fails at the server, which is the same + /// answer Compare & Sync gives it. + /// `effectiveSchema` is the scope the tables were read in, and it is load-bearing. + /// `fetchTables` returns `PluginTableInfo.schema == nil` on MySQL and PostgreSQL while their + /// foreign keys carry a non-nil `referencedSchema`, so nodes built from the table's own schema + /// were keyed `child` while the dependency named `public.parent`. No edge ever matched, every + /// table looked independent, and the sort fell through to alphabetical order: a child could be + /// created before its parent and the server rejected it. + nonisolated internal static func orderedByDependency( + _ selections: [ObjectCopySelection], + reads: [ObjectCopySelection: TableStructureRead], + effectiveSchema: String? + ) -> [ObjectCopySelection] { + guard selections.count > 1 else { return selections } + var foreignKeysByTable: [String: [PluginForeignKeyInfo]] = [:] + var nodes: [ForeignKeyTopologicalSort.Table] = [] + var bySortKey: [String: ObjectCopySelection] = [:] + for selection in selections { + guard let read = reads[selection] else { continue } + let schema = read.table.schema ?? selection.schema ?? effectiveSchema + let node = ForeignKeyTopologicalSort.Table(name: read.table.name, schema: schema) + nodes.append(node) + foreignKeysByTable[node.identifier] = read.foreignKeys + bySortKey[node.identifier] = selection + } + + var emitted: Set = [] + var result: [ObjectCopySelection] = [] + 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) + } + for selection in selections where !result.contains(selection) { + result.append(selection) + } + return result + } + + /// A view selects from a table, a routine calls a view, and a trigger hangs off a table, so the + /// kinds run in that order. Within a kind the user's own order is kept. + nonisolated internal static func orderedByKind(_ selections: [ObjectCopySelection]) -> [ObjectCopySelection] { + let rank: [CompareObjectKind: Int] = [ + .view: 0, .materializedView: 1, .function: 2, .procedure: 3, .trigger: 4 + ] + return selections.enumerated() + .sorted { lhs, rhs in + let left = rank[lhs.element.kind] ?? 9 + let right = rank[rhs.element.kind] ?? 9 + guard left == right else { return left < right } + return lhs.offset < rhs.offset + } + .map(\.element) + } + + // MARK: - Helpers + + /// A database that does not exist yet cannot be connected to, so planning for one asks the + /// server instead. The run switches to the real scope once `CREATE DATABASE` has succeeded. + private func targetScope(_ request: ObjectCopyRequest) -> DatabaseScope { + guard request.destination.createsDatabase else { return request.target.scope } + return DatabaseScope(connectionId: request.target.connectionId, database: "", schema: nil) + } + + private func match(_ selection: ObjectCopySelection, in reads: [TableStructureRead]) -> TableStructureRead? { + reads.first { $0.table.name.lowercased() == selection.name.lowercased() } + } + + /// Read from inside the scoped-driver closures, which run off the main actor. + nonisolated private static let missingInSource = String(localized: "Not found in the source.") + nonisolated private static let unreadable = String(localized: "Its structure could not be read.") + nonisolated private static let targetUnreadable = String( + localized: "The target has it, but its structure could not be read." + ) + nonisolated private static let alreadyThere = String(localized: "Already in the target.") + nonisolated private static let noDefinition = String( + localized: "The source reports no definition for it." + ) + nonisolated private static let structureOnlyObject = String( + localized: "Views, routines and triggers hold no rows, so a data-only copy leaves them out." + ) + nonisolated private static let noTargetDriver = String( + localized: "The target driver cannot generate statements." + ) + nonisolated private static let noSourceDriver = String(localized: "The source driver cannot be read.") +} + +// MARK: - Drafts + +/// One table's decisions, made before any driver is opened so the two scoped calls that follow can +/// each run over the whole list. +private struct ObjectCopyTableDraft { + let selection: ObjectCopySelection + let snapshot: TableStructureSnapshot + let sourceSchema: String? + let targetSchema: String? + let targetTable: String + /// Read with these, written with those. A case-insensitive match pairs two spellings of one + /// column, and each side has to be quoted the way its own server spells it. + let sourceColumns: [String] + let targetColumns: [String] + let writesStructure: Bool + let dropsFirst: Bool + let emptiesFirst: Bool + let copiesData: Bool + let copiesIdentityColumn: Bool + let note: String? + + init( + selection: ObjectCopySelection, + snapshot: TableStructureSnapshot, + targetSnapshot: TableStructureSnapshot?, + existsInTarget: Bool, + request: ObjectCopyRequest + ) { + self.selection = selection + self.snapshot = snapshot + self.sourceSchema = request.source.schema ?? snapshot.schema + /// Never the source's. A target endpoint that names no schema means the target driver's + /// own current scope, and inheriting the source's put a SQL Server `dbo` into a MySQL + /// INSERT, naming a database that engine does not have. + self.targetSchema = request.target.schema + + let keepsTargetStructure = existsInTarget && request.existingPolicy != .replace + 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. + self.targetTable = (writesStructure ? nil : targetSnapshot?.name) ?? snapshot.name + + let pairs = Self.writableColumnPairs( + snapshot: snapshot, + targetSnapshot: writesStructure ? nil : targetSnapshot + ) + self.sourceColumns = pairs.map(\.source) + self.targetColumns = pairs.map(\.target) + self.copiesData = request.content.includesData && !pairs.isEmpty && (writesStructure || existsInTarget) + + let written = Set(pairs.map { $0.source.lowercased() }) + self.copiesIdentityColumn = request.content.includesData && snapshot.columns.contains { + written.contains($0.name.lowercased()) && $0.autoIncrement + } + + if request.content.includesData, !writesStructure, !existsInTarget { + self.note = String( + localized: "The target has no table of this name, so the rows have nowhere to go." + ) + } else if request.content.includesData, pairs.isEmpty { + self.note = String(localized: "The source and the target share no writable column.") + } else { + self.note = nil + } + } + + /// The columns the copy writes, paired source spelling to target spelling. + /// + /// The source's own order, without the ones the server computes: an `INSERT` into a generated + /// column is rejected by every engine that has them. When the target's structure is not being + /// written the answer narrows to what both sides have, matched without regard to case, because + /// a column the target lacks cannot be written to and one it has that the source lacks keeps + /// its default. + static func writableColumnPairs( + snapshot: TableStructureSnapshot, + targetSnapshot: TableStructureSnapshot? + ) -> [(source: String, target: String)] { + let sourceColumns = snapshot.columns + .filter { $0.generationExpression == nil } + .map(\.name) + guard let targetSnapshot else { return sourceColumns.map { ($0, $0) } } + var targetByFoldedName: [String: String] = [:] + for column in targetSnapshot.columns where column.generationExpression == nil { + targetByFoldedName[column.name.lowercased()] = column.name + } + return sourceColumns.compactMap { name in + guard let target = targetByFoldedName[name.lowercased()] else { return nil } + return (name, target) + } + } +} + +private struct ObjectCopyDDLInput: Sendable { + let id: String + let snapshot: TableStructureSnapshot + let targetSchema: String? + let writesStructure: Bool + let dropsFirst: Bool + let emptiesFirst: Bool +} + +private struct ObjectCopyTableDDL: Sendable { + var drop: [SyncStatement] = [] + var create: [SyncStatement] = [] + var truncate: [SyncStatement] = [] +} + +internal enum ObjectCopyError: LocalizedError { + case refused(String) + + internal var errorDescription: String? { + switch self { + case .refused(let message): return message + } + } +} + +/// The read side of a table copy: the exact columns that will be written, in the order they will be +/// written, so the stream and the INSERT cannot drift apart. +internal enum ObjectCopySelectQuery { + internal static func build( + columns: [String], + table: String, + schema: String?, + driver: any PluginDatabaseDriver + ) -> String { + let list = columns.isEmpty + ? "*" + : columns.map { driver.quoteIdentifier($0) }.joined(separator: ", ") + return "SELECT \(list) FROM \(qualified(table, schema, driver))" + } + + private static func qualified(_ table: String, _ schema: String?, _ driver: any PluginDatabaseDriver) -> String { + guard let schema, !schema.isEmpty else { return driver.quoteIdentifier(table) } + return "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(table))" + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyProgress.swift b/TablePro/Core/ObjectCopy/ObjectCopyProgress.swift new file mode 100644 index 000000000..44e53729e --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyProgress.swift @@ -0,0 +1,58 @@ +// +// ObjectCopyProgress.swift +// TablePro +// +// The run's progress, reachable from the copy loop. +// +// A `Progress` is not `Sendable`, and the loop runs inside the scoped-driver +// closure rather than on the main actor, so it crosses in a lock-guarded box. +// That is the shape `PluginExportProgress` already uses for the same reason. +// + +import Foundation + +internal final class ObjectCopyProgress: @unchecked Sendable { + private let progress: Progress + private let lock = NSLock() + private var rows: Int = 0 + private var object: String = "" + + internal init(progress: Progress) { + self.progress = progress + } + + internal var isCancelled: Bool { progress.isCancelled } + + internal var rowsCopied: Int { + lock.lock() + defer { lock.unlock() } + return rows + } + + internal var currentObject: String { + lock.lock() + defer { lock.unlock() } + return object + } + + internal func startObject(_ name: String) { + lock.lock() + object = name + lock.unlock() + progress.localizedDescription = name + } + + /// `count` is the running total for the object being copied, not an increment, because the + /// copier already counts its own rows and a second counter would drift from it. + internal func setRowsForCurrentObject(_ count: Int, completedBefore: Int) { + lock.lock() + rows = completedBefore + count + let total = rows + lock.unlock() + progress.completedUnitCount = Int64(total) + } + + internal func setTotalRows(_ total: Int) { + progress.totalUnitCount = Int64(max(total, 0)) + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyRequest.swift b/TablePro/Core/ObjectCopy/ObjectCopyRequest.swift new file mode 100644 index 000000000..7187a81c7 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyRequest.swift @@ -0,0 +1,167 @@ +// +// ObjectCopyRequest.swift +// TablePro +// +// What the user asked to copy, where to, and how. +// +// A copy is not a comparison run against an empty target. Comparing would +// materialise one INSERT statement per row before anything ran, which is the +// cost this feature exists to avoid, so the request carries the objects +// directly and the runner streams their rows. +// + +import Foundation +import TableProPluginKit + +/// Which halves of an object take part. +internal enum ObjectCopyContent: String, CaseIterable, Hashable, Sendable { + case structure + case data + case structureAndData + + internal var includesStructure: Bool { self != .data } + internal var includesData: Bool { self != .structure } + + internal var displayName: String { + switch self { + case .structure: return String(localized: "Structure only") + case .data: return String(localized: "Data only") + case .structureAndData: return String(localized: "Structure and data") + } + } +} + +/// What to do about an object the target already has. +/// +/// There is no silent default here. Overwriting is a drop, and appending into a table whose rows +/// are already there duplicates them, so the choice is made before the run rather than guessed. +internal enum ObjectCopyExistingPolicy: String, CaseIterable, Hashable, Sendable { + case skip + case replace + case appendData + + internal var displayName: String { + switch self { + case .skip: return String(localized: "Skip it") + case .replace: return String(localized: "Replace it") + case .appendData: return String(localized: "Add rows to it") + } + } + + /// Only `replace` drops what the target already has. + internal var dropsTargetObject: Bool { self == .replace } +} + +/// Where the copy is written. +internal enum ObjectCopyDestination: Hashable, Sendable { + /// A database that is already there. The endpoint names it, including its schema where the + /// engine has schemas. + case existing(DatabaseEndpoint) + /// A database this run creates first, on the connection `base` reaches. `values` are the + /// answers to the driver's own `createDatabaseFormSpec`, so charset and collation are the + /// user's rather than the server's default. + case newDatabase(base: DatabaseEndpoint, name: String, values: [String: String]) + + /// The database the objects land in, which for a new database is the one about to be created. + internal var endpoint: DatabaseEndpoint { + switch self { + case .existing(let endpoint): return endpoint + case .newDatabase(let base, let name, _): return base.withDatabase(name) + } + } + + internal var createsDatabase: Bool { + guard case .newDatabase = self else { return false } + return true + } +} + +/// One object the user chose to copy. +/// +/// A name is not an identity. PostgreSQL and Oracle both allow `f(integer)` and `f(text)` at once, +/// and every engine with triggers allows the same trigger name on two tables. Keying on the name +/// alone gave those objects one `Hashable` value and one `id`, so the picker collapsed them into +/// one row and the planner's dictionaries kept whichever arrived last. +internal struct ObjectCopySelection: Hashable, Identifiable, Sendable { + internal let kind: CompareObjectKind + internal let name: String + internal let schema: String? + /// A routine's argument list, which is what tells two overloads apart. Nil for every kind that + /// cannot be overloaded. + internal let signature: String? + /// The table a trigger hangs off. Nil for everything else. + internal let owner: String? + + internal init( + kind: CompareObjectKind, + name: String, + schema: String?, + signature: String? = nil, + owner: String? = nil + ) { + self.kind = kind + self.name = name + self.schema = schema + self.signature = signature + self.owner = owner + } + + internal var id: String { + [kind.rawValue, schema ?? "", name, signature ?? "", owner ?? ""] + .map { $0.replacingOccurrences(of: "\u{1F}", with: "\u{1F}\u{1F}") } + .joined(separator: "\u{1F}") + } + + internal var qualifiedName: String { + guard let schema, !schema.isEmpty else { return name } + return "\(schema).\(name)" + } + + /// What the object list shows. A bare name is ambiguous exactly where the identity needed the + /// extra part, so the row carries it too. + internal var displayName: String { + if let signature, !signature.isEmpty { return "\(name)\(signature)" } + if let owner, !owner.isEmpty { + return String(format: String(localized: "%1$@ on %2$@"), name, owner) + } + return name + } +} + +internal struct ObjectCopyRequest: Sendable { + internal let source: DatabaseEndpoint + internal let destination: ObjectCopyDestination + internal let objects: [ObjectCopySelection] + internal let content: ObjectCopyContent + internal let existingPolicy: ObjectCopyExistingPolicy + internal let errorHandling: ImportErrorHandling + internal let wrapEachTableInTransaction: Bool + + internal init( + source: DatabaseEndpoint, + destination: ObjectCopyDestination, + objects: [ObjectCopySelection], + content: ObjectCopyContent, + existingPolicy: ObjectCopyExistingPolicy, + errorHandling: ImportErrorHandling = .stopAndRollback, + wrapEachTableInTransaction: Bool = true + ) { + self.source = source + self.destination = destination + self.objects = objects + self.content = content + self.existingPolicy = existingPolicy + self.errorHandling = errorHandling + self.wrapEachTableInTransaction = wrapEachTableInTransaction + } + + internal var target: DatabaseEndpoint { destination.endpoint } + + internal var tables: [ObjectCopySelection] { + objects.filter { $0.kind.carriesRows } + } + + internal var sourceDefinedObjects: [ObjectCopySelection] { + objects.filter { $0.kind.isSourceDefined } + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift b/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift new file mode 100644 index 000000000..aac1cdb79 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift @@ -0,0 +1,143 @@ +// +// ObjectCopyRowCopier.swift +// TablePro +// +// Moves one table's rows from the source driver to the target driver. +// +// The write side holds one batch at a time: the stream hands back rows as they +// arrive and each batch becomes one multi-row parameterized INSERT, so a table +// of ten million rows costs the same to write as a table of ten. Values cross +// as `PluginCellValue` rather than as SQL literals, which is what keeps a blob +// a blob and a timestamp a timestamp. +// +// The read side is only as incremental as the driver is. `streamRows` has a +// protocol default that calls `execute` and yields the whole result at once, +// which Dameng and Teradata still inherit, so on those two a large table is +// materialised before the first batch is written and Stop cannot land until +// it is. Every other SQL driver implements it for real. Export has the same +// property through the same call; a capability that lets this refuse rather +// than inherit would have to be declared by all 32 plugins. +// +// This is the write path CSV and JSON import already use, through the same +// `SQLStatementGenerator` and its per-engine bind-parameter ceiling. The one +// thing it adds is the schema, because the table being written is not the one +// the target driver is pointed at. +// + +import Foundation +import os +import TableProPluginKit + +internal struct ObjectCopyRowCopier: Sendable { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCopyRowCopier") + + /// A batch never carries more rows than this however narrow the table is. A single statement + /// holding 65,535 one-column rows parses slowly on every engine and cannot be cancelled + /// part-way, and the round-trip saving past a thousand rows is not measurable. + internal static let maximumBatchRows = 1_000 + + /// Oracle before 23c has no `INSERT … VALUES (…), (…)`, and the generic generator emits + /// exactly that. One row per statement is slower and is the only form those releases accept; + /// `INSERT ALL` and array binding both need work the driver does not expose today. + internal static func maximumBatchRows(for databaseType: DatabaseType) -> Int { + databaseType == .oracle ? 1 : maximumBatchRows + } + + internal struct Outcome: Sendable { + internal let inserted: Int + internal let cancelled: Bool + } + + internal let step: ObjectCopyTableStep + internal let targetDatabaseType: DatabaseType + + internal func copy( + from sourceDriver: any PluginDatabaseDriver, + to targetDriver: any PluginDatabaseDriver, + onProgress: @Sendable (Int) -> Void + ) async throws -> Outcome { + let generator = try makeGenerator(targetDriver: targetDriver) + let batchSize = Self.batchSize(columnCount: step.columns.count, generator: generator) + var stream = sourceDriver.streamRows(query: step.sourceQuery).makeAsyncIterator() + + var pending: [[PluginCellValue]] = [] + var inserted = 0 + + while let element = try await stream.next() { + if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } + guard case .rows(let rows) = element else { continue } + for row in rows { + pending.append(try aligned(row)) + guard pending.count >= batchSize else { continue } + inserted += try await flush(&pending, generator: generator, driver: targetDriver) + onProgress(inserted) + if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } + } + } + + if !pending.isEmpty { + inserted += try await flush(&pending, generator: generator, driver: targetDriver) + onProgress(inserted) + } + return Outcome(inserted: inserted, cancelled: false) + } + + // MARK: - Statements + + private func makeGenerator(targetDriver: any PluginDatabaseDriver) throws -> SQLStatementGenerator { + try SQLStatementGenerator( + tableName: step.targetTable, + schemaName: step.targetSchema, + columns: step.columns, + primaryKeyColumns: step.primaryKeyColumns, + databaseType: targetDatabaseType, + parameterStyle: targetDriver.parameterStyle, + quoteIdentifier: targetDriver.quoteIdentifier + ) + } + + private func flush( + _ rows: inout [[PluginCellValue]], + generator: SQLStatementGenerator, + driver: any PluginDatabaseDriver + ) async throws -> Int { + guard !rows.isEmpty else { return 0 } + let batch = rows + rows.removeAll(keepingCapacity: true) + guard let statement = generator.insertStatement(columns: step.columns, rows: batch) else { + throw ObjectCopyError.refused(String( + format: String(localized: "Could not build an INSERT for %@."), step.qualifiedTargetName + )) + } + /// The generator's own parameter array rather than the batch flattened again: the two + /// agree today, and taking the statement's own leaves no second place for the placeholder + /// order to be decided. The round trip is lossless because `PluginCellValue` is text, + /// bytes or null and `asAny` maps each to itself. + _ = try await driver.executeParameterized( + query: statement.sql, + parameters: statement.parameters.map(PluginDriverAdapter.cellValue(for:)) + ) + return batch.count + } + + /// The SELECT names its columns, so a row that arrives with a different width means the source + /// answered a different question from the one the plan asked. Writing it would put values in + /// the wrong columns, so it stops the table instead. + private func aligned(_ row: [PluginCellValue]) throws -> [PluginCellValue] { + guard row.count == step.columns.count else { + throw ObjectCopyError.refused(String( + format: String(localized: "%1$@ returned %2$lld values for %3$lld columns."), + step.selection.qualifiedName, row.count, step.columns.count + )) + } + return row + } + + /// Every value in the batch is one bind parameter, and each engine has its own ceiling on how + /// many a statement may carry: 32,766 on SQLite, 2,100 on SQL Server, 65,535 elsewhere. + internal static func batchSize(columnCount: Int, generator: SQLStatementGenerator) -> Int { + guard columnCount > 0 else { return 1 } + let rowCap = maximumBatchRows(for: generator.databaseType) + return max(1, min(rowCap, generator.maxBindParameters / columnCount)) + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift new file mode 100644 index 000000000..6592b578e --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift @@ -0,0 +1,365 @@ +// +// ObjectCopyRunner.swift +// TablePro +// +// Runs a plan: the database, then the DDL, then the rows. +// +// Authorization happens once for the whole copy through `ExecutionGate`, and +// every statement stays inside that call so the task-local receipt is bound +// for all of them. Asking twice, once for the DDL and once for the rows, would +// put two confirmations in front of one action the user already approved. +// +// Cancellation is cooperative between batches: a driver already blocked in a C +// call cannot be interrupted, so a cancelled run stops at the next batch +// boundary and reports what it had already written. +// + +import Foundation +import os +import TableProPluginKit + +internal struct ObjectCopyObjectOutcome: Identifiable, Sendable { + internal let selection: ObjectCopySelection + internal let rowsCopied: Int + internal let error: String? + + internal var id: String { selection.id } + internal var succeeded: Bool { error == nil } +} + +internal struct ObjectCopyRunResult: Sendable { + internal let outcomes: [ObjectCopyObjectOutcome] + internal let rowsCopied: Int + internal let cancelled: Bool + internal let createdDatabase: String? + + /// Counted by object rather than by outcome. A table copied with its structure and its rows + /// produces one outcome for each phase, and reporting "2 objects" for one table is how a + /// summary comes to overstate what the run did. + internal var failedCount: Int { + Set(outcomes.filter { $0.error != nil }.map(\.id)).count + } + + internal var succeededCount: Int { + let failed = Set(outcomes.filter { $0.error != nil }.map(\.id)) + return Set(outcomes.filter(\.succeeded).map(\.id)).subtracting(failed).count + } + + internal var firstError: String? { outcomes.compactMap(\.error).first } +} + +@MainActor +internal struct ObjectCopyRunner { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCopyRunner") + + private let manager: DatabaseManager + private let gate: ExecutionGate + + internal init(manager: DatabaseManager = .shared, gate: ExecutionGate = ExecutionGateProvider.shared) { + self.manager = manager + self.gate = gate + } + + internal func run(_ plan: ObjectCopyPlan, progress: ObjectCopyProgress) async throws -> ObjectCopyRunResult { + let request = plan.request + progress.setTotalRows(plan.estimatedRowTotal) + + let activity = ProcessInfo.processInfo.beginActivity( + options: [.userInitiated, .idleSystemSleepDisabled, .suddenTerminationDisabled], + reason: "Copying database objects" + ) + defer { ProcessInfo.processInfo.endActivity(activity) } + + return try await gate.authorizing(authorizationRequest(for: plan)) { + try await self.execute(plan, request: request, progress: progress) + } + } + + // MARK: - Phases + + private func execute( + _ plan: ObjectCopyPlan, + request: ObjectCopyRequest, + progress: ObjectCopyProgress + ) async throws -> ObjectCopyRunResult { + var createdDatabase: String? + if case .newDatabase(_, let name, let values) = request.destination { + try await createDatabase(named: name, values: values, on: request.target.connectionId) + createdDatabase = name + } + + var outcomes: [ObjectCopyObjectOutcome] = [] + var cancelled = false + var rowsCopied = 0 + + func finished() -> ObjectCopyRunResult { + ObjectCopyRunResult( + outcomes: outcomes, + rowsCopied: rowsCopied, + cancelled: cancelled, + createdDatabase: createdDatabase + ) + } + + /// Torn down before anything is built, and children first, so a foreign key is gone before + /// the table it points at and a trigger before the table that owns it. One parent-first + /// pass had every DROP rejected by the constraint below it. + for phase in [plan.cleanupGroups, plan.creationGroups] where !phase.isEmpty { + let result = try await runDDL(phase, request: request, progress: progress) + outcomes += result.outcomes + cancelled = cancelled || result.cancelled + if result.stopped { return finished() } + } + + if !cancelled, request.content.includesData { + let dataOutcomes = await runData(plan, request: request, progress: progress) + outcomes += dataOutcomes.outcomes + cancelled = cancelled || dataOutcomes.cancelled + rowsCopied = dataOutcomes.rowsCopied + if dataOutcomes.stopped { return finished() } + } + + /// A trigger fires on the rows the copy writes, so it goes in once they are in. Installed + /// with the rest of the DDL, duplicating a database with an audit trigger produced a + /// second audit row for every row copied. + if !cancelled, !plan.afterDataGroups.isEmpty { + let result = try await runDDL(plan.afterDataGroups, request: request, progress: progress) + outcomes += result.outcomes + cancelled = cancelled || result.cancelled + } + + return finished() + } + + private func createDatabase(named name: String, values: [String: String], on connectionId: UUID) async throws { + let scope = DatabaseScope(connectionId: connectionId, database: "", schema: nil) + try await manager.withMetadataDriver(scope: scope) { driver in + try await driver.createDatabase(CreateDatabaseRequest(name: name, values: values)) + } + } + + // MARK: - Structure + + private struct DDLResult { + var outcomes: [ObjectCopyObjectOutcome] = [] + var cancelled = false + /// True when the run must not go on to the rows, because the tables they need are missing. + var stopped = false + } + + private func runDDL( + _ groups: [ObjectCopyStatementGroup], + request: ObjectCopyRequest, + progress: ObjectCopyProgress + ) async throws -> DDLResult { + let runnable = groups.filter { !$0.statements.isEmpty } + guard !runnable.isEmpty else { return DDLResult() } + + let errorHandling = request.errorHandling + let scope = request.target.scope + return try await manager.withMetadataDriver(scope: scope) { driver in + var result = DDLResult() + for group in runnable { + if progress.isCancelled || Task.isCancelled { + result.cancelled = true + result.stopped = true + break + } + progress.startObject(group.selection.qualifiedName) + do { + /// Every statement runs, hazards included: a DROP is here because the user + /// chose Replace and then read it in the script, which is the consent Compare & + /// Sync collects per statement instead. + for statement in group.statements { + _ = try await driver.execute(query: statement.sql) + } + result.outcomes.append(ObjectCopyObjectOutcome( + selection: group.selection, rowsCopied: 0, error: nil + )) + } catch { + Self.logger.error( + "Copy DDL failed for \(group.selection.qualifiedName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + result.outcomes.append(ObjectCopyObjectOutcome( + selection: group.selection, rowsCopied: 0, error: error.localizedDescription + )) + guard errorHandling == .skipAndContinue else { + result.stopped = true + break + } + } + } + return result + } + } + + // MARK: - Data + + private struct DataResult { + var outcomes: [ObjectCopyObjectOutcome] = [] + var cancelled = false + var stopped = false + /// Only what was committed. A cancelled table rolls its rows back, so counting what the + /// copier inserted reported rows the target never kept. + var rowsCopied = 0 + } + + /// 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( + _ plan: ObjectCopyPlan, + request: ObjectCopyRequest, + progress: ObjectCopyProgress + ) async -> DataResult { + var result = DataResult() + let targetType = request.target.databaseType + + for step in plan.dataSteps { + if progress.isCancelled || Task.isCancelled { + result.cancelled = true + result.stopped = true + break + } + progress.startObject(step.qualifiedTargetName) + let wrapsInTransaction = request.wrapEachTableInTransaction + && request.errorHandling != .skipAndContinue + + do { + let outcome = try await copyRows( + step, + request: request, + targetType: targetType, + wrapsInTransaction: wrapsInTransaction, + completedBefore: result.rowsCopied, + progress: progress + ) + /// A cancelled table rolled its rows back, so it neither counts as copied nor + /// reads as an object that succeeded. + 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 { + /// Stop reaching the driver mid-stream is the user stopping, not the copy failing, + /// and the two produce different notifications. + result.cancelled = true + result.stopped = true + progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) + break + } catch { + Self.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 request.errorHandling == .skipAndContinue else { + result.stopped = true + break + } + } + } + return result + } + + private func copyRows( + _ step: ObjectCopyTableStep, + request: ObjectCopyRequest, + targetType: DatabaseType, + wrapsInTransaction: Bool, + completedBefore: Int, + progress: ObjectCopyProgress + ) async throws -> ObjectCopyRowCopier.Outcome { + let sourceScope = request.source.scope + let targetScope = request.target.scope + let copier = ObjectCopyRowCopier(step: step, targetDatabaseType: targetType) + let errorHandling = request.errorHandling + + /// The injected manager on both sides. Reaching for the singleton on the target bypassed + /// the caller's own connections and routing, which is the whole point of injecting one. + let manager = self.manager + return try await manager.withMetadataDriver(scope: sourceScope, workload: .bulk) { sourceDriver in + guard let sourcePlugin = CompareMetadataService.pluginDriver(from: sourceDriver) else { + throw ObjectCopyError.refused(Self.noSourceDriver) + } + return try await manager.withMetadataDriver( + scope: targetScope, workload: .bulk + ) { targetDriver in + guard let targetPlugin = CompareMetadataService.pluginDriver(from: targetDriver) else { + throw ObjectCopyError.refused(Self.noTargetDriver) + } + let usesTransaction = wrapsInTransaction && targetPlugin.supportsTransactions + if usesTransaction { + try await targetPlugin.beginTransaction(mode: .readWrite) + } + do { + /// Inside the transaction, never before it. Emptying the table in the DDL phase + /// meant a copy that failed or was stopped rolled the new rows back and left + /// the target's own rows deleted for good. + for statement in step.truncateStatements { + _ = try await targetPlugin.execute(query: statement.sql) + } + let outcome = try await copier.copy( + from: sourcePlugin, + to: targetPlugin + ) { rows in + progress.setRowsForCurrentObject(rows, completedBefore: completedBefore) + } + if usesTransaction { + if outcome.cancelled { + try? await targetPlugin.rollbackTransaction() + } else { + try await targetPlugin.commitTransaction() + } + } + return outcome + } catch { + if usesTransaction { + if errorHandling == .stopAndCommit { + try? await targetPlugin.commitTransaction() + } else { + try? await targetPlugin.rollbackTransaction() + } + } + throw error + } + } + } + } + + // MARK: - Authorization + + private func authorizationRequest(for plan: ObjectCopyPlan) -> OperationRequest { + let request = plan.request + return OperationRequest( + connectionId: request.target.connectionId, + databaseType: request.target.databaseType, + sql: Self.digest(of: plan), + kind: plan.ddlStatements.isEmpty ? .importData : .schemaMutation, + caller: .userInterface, + capabilities: [.mayWrite, .mayRunDestructive, .mayRunMultiStatement, .confirmationPreCleared], + operationDescription: String( + format: String(localized: "Copy %1$lld objects to %2$@"), + plan.tableSteps.count + plan.definitionSteps.count, + request.target.qualifiedDescription + ) + ) + } + + nonisolated private static func digest(of plan: ObjectCopyPlan) -> String { + let script = plan.scriptText + guard (script as NSString).length > digestCharacterLimit else { return script } + return (script as NSString).substring(to: digestCharacterLimit) + } + + nonisolated private static let digestCharacterLimit = 10_000 + nonisolated private static let noSourceDriver = String(localized: "The source driver cannot stream rows.") + nonisolated private static let noTargetDriver = String(localized: "The target driver cannot be written to.") +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/TablePro/Core/ObjectCopy/ObjectCopySession.swift new file mode 100644 index 000000000..77c4603c1 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -0,0 +1,419 @@ +// +// ObjectCopySession.swift +// TablePro +// +// The state of one copy, from the sheet opening to the result. +// +// It holds no driver and performs no I/O of its own: the catalog reads, the +// planner resolves and the runner writes, the same split `CompareSyncSession` +// keeps with `CompareRunner`. +// +// There are two steps rather than one because the issue asks for the script to +// be shown before anything runs, and a plan cannot be built without reaching +// both databases. Configuring is free; reviewing costs one round of reads. +// + +import Foundation +import Observation +import os +import TableProPluginKit + +/// Which of the sidebar's three commands opened the sheet. +internal enum ObjectCopyMode: Hashable, Sendable { + /// Copy the chosen objects into a database that already exists, anywhere. + case copyTo + /// Copy a whole database into a new one on the same connection. + case duplicateDatabase +} + +internal enum ObjectCopyStep: Hashable { + case configuring + case reviewing + case copying + case finished +} + +/// Whether the destination's create-database options have arrived. A failure is kept as a failure: +/// the request that follows needs the values, so "not loaded yet" and "cannot be loaded" are both +/// reasons to hold Continue rather than to send an empty request. +internal enum ObjectCopyFormState: Hashable { + case loading + case ready + case unsupported + case failed(String) +} + +@MainActor +@Observable +internal final class ObjectCopySession { + nonisolated private static let logger = Logger(subsystem: "com.TablePro", category: "ObjectCopySession") + + // MARK: - Fixed at launch + + internal let mode: ObjectCopyMode + internal let source: DatabaseEndpoint + internal let sourceConnection: DatabaseConnection + + // MARK: - Choices + + internal var target: DatabaseEndpoint? + internal var newDatabaseName = "" + internal var newDatabaseValues: [String: String] = [:] + internal var createDatabaseForm: CreateDatabaseFormSpec? + internal var createDatabaseFormState: ObjectCopyFormState = .loading + internal var content: ObjectCopyContent = .structureAndData + internal var existingPolicy: ObjectCopyExistingPolicy = .skip + internal var errorHandling: ImportErrorHandling = .stopAndRollback + internal var searchText = "" + + // MARK: - Catalog + + internal var availableObjects: [ObjectCopySelection] = [] + internal var selectedObjectIds: Set = [] + internal var isLoadingObjects = true + internal var catalogError: String? + + // MARK: - Run + + internal var step: ObjectCopyStep = .configuring + internal var plan: ObjectCopyPlan? + internal var progress: Progress? + internal var copiedRows = 0 + internal var currentObject = "" + internal var result: ObjectCopyRunResult? + internal var errorMessage: String? + @ObservationIgnored internal var runTask: Task? + + internal init( + mode: ObjectCopyMode, + source: DatabaseEndpoint, + sourceConnection: DatabaseConnection, + preselected: [ObjectCopySelection] + ) { + self.mode = mode + self.source = source + self.sourceConnection = sourceConnection + self.pendingPreselection = preselected + if mode == .duplicateDatabase { + self.newDatabaseName = Self.suggestedCopyName(for: source.databaseLabel) + } + } + + @ObservationIgnored private let pendingPreselection: [ObjectCopySelection] + + // MARK: - Naming + + /// The Finder's own convention for a duplicate, which is what a user expects to see prefilled. + internal static func suggestedCopyName(for name: String) -> String { + guard !name.isEmpty else { return "" } + return "\(name)_copy" + } + + // MARK: - Derived + + internal var title: String { + switch mode { + case .copyTo: return String(localized: "Copy To") + case .duplicateDatabase: return String(localized: "Duplicate Database") + } + } + + internal var selectedObjects: [ObjectCopySelection] { + availableObjects.filter { selectedObjectIds.contains($0.id) } + } + + internal var filteredObjects: [ObjectCopySelection] { + let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + guard !query.isEmpty else { return availableObjects } + return availableObjects.filter { $0.name.lowercased().contains(query) } + } + + internal var isBusy: Bool { + step == .copying + } + + /// Why Copy is unavailable, or nil when it is. Spelled as a reason rather than a bool so the + /// sheet can say what is missing instead of leaving a dead button. + internal var reviewDisabledReason: String? { + if selectedObjectIds.isEmpty { + return String(localized: "Choose at least one object to copy.") + } + switch mode { + case .copyTo: + guard let target else { return String(localized: "Choose where to copy to.") } + if let reason = ObjectCopyEligibility.targetRefusal(target) { return reason } + if let reason = ObjectCopyEligibility.sameObjectRefusal(source: source, target: target) { + return reason + } + if let reason = ObjectCopyEligibility.engineRefusal( + from: source.databaseType, to: target.databaseType + ) { + return reason + } + case .duplicateDatabase: + let trimmed = newDatabaseName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return String(localized: "Name the new database.") } + if trimmed.caseInsensitiveCompare(source.database) == .orderedSame { + return String(localized: "Choose a name the source does not already have.") + } + switch createDatabaseFormState { + case .loading: + return String(localized: "Reading this connection's database options…") + case .unsupported: + return String( + format: String(localized: "%@ cannot create a database."), source.databaseType.rawValue + ) + case .failed(let message): + return message + case .ready: + break + } + } + if let reason = ObjectCopyEligibility.unscopedSchemaRefusal( + endpoint: source, + supportsSchemas: PluginManager.shared.supportsSchemaSwitching(for: source.databaseType) + ) { + return reason + } + return nil + } + + internal var request: ObjectCopyRequest? { + guard reviewDisabledReason == nil else { return nil } + let destination: ObjectCopyDestination + switch mode { + case .copyTo: + guard let target else { return nil } + destination = .existing(target) + case .duplicateDatabase: + /// A field the form is hiding holds a stale answer rather than a chosen one, so it + /// never reaches `CREATE DATABASE`. + let values = createDatabaseForm.map { + CreateDatabaseFormRules.submissionValues(from: newDatabaseValues, spec: $0) + } ?? newDatabaseValues + destination = .newDatabase( + base: source, + name: newDatabaseName.trimmingCharacters(in: .whitespacesAndNewlines), + values: values + ) + } + return ObjectCopyRequest( + source: source, + destination: destination, + objects: selectedObjects, + content: content, + existingPolicy: existingPolicy, + errorHandling: errorHandling, + wrapEachTableInTransaction: true + ) + } + + // MARK: - Catalog + + internal func loadObjects(catalog: ObjectCopyCatalog = ObjectCopyCatalog()) async { + isLoadingObjects = true + catalogError = nil + defer { isLoadingObjects = false } + do { + let found = try await catalog.objects(in: source, connection: sourceConnection) + availableObjects = found.sorted { lhs, rhs in + guard lhs.kind == rhs.kind else { return lhs.kind.rawValue < rhs.kind.rawValue } + return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending + } + applyPreselection() + } catch { + catalogError = error.localizedDescription + availableObjects = [] + } + } + + /// A right-click on one table preselects that table. A right-click on a database preselects + /// everything in it, which is what "copy this database" means. + /// + /// Matched on kind and name rather than on the name alone, and never falling back to the whole + /// catalog: the first version preselected every object sharing a name with the clicked one, and + /// when it matched nothing it selected the entire database. Followed by Replace, that acted on + /// objects the user had not chosen. + private func applyPreselection() { + guard !pendingPreselection.isEmpty else { + selectedObjectIds = Set(availableObjects.map(\.id)) + return + } + let wanted = Set(pendingPreselection.map { Self.preselectionKey($0) }) + selectedObjectIds = Set( + availableObjects + .filter { wanted.contains(Self.preselectionKey($0)) } + .map(\.id) + ) + } + + /// The sidebar knows the kind and the name; it does not know a routine's argument list. So the + /// match uses what both sides can agree on and leaves everything else unselected. + private static func preselectionKey(_ selection: ObjectCopySelection) -> String { + "\(selection.kind.rawValue)\u{1F}\(selection.name.lowercased())" + } + + /// The catalog read is the only caller in the app, and it is the one thing a test cannot drive + /// without a live connection. + internal func applyPreselectionForTesting() { + applyPreselection() + } + + /// The form is not decoration. MySQL's `createDatabase` requires a character set, so starting a + /// duplicate before this answers, or after it failed, sent a request with no values that was + /// guaranteed to be refused. A nil spec is also how a driver says it cannot create a database + /// at all, which is what keeps Duplicate off DuckDB, Trino and Teradata. + internal func loadCreateDatabaseForm(catalog: ObjectCopyCatalog = ObjectCopyCatalog()) async { + guard mode == .duplicateDatabase, createDatabaseFormState == .loading else { return } + do { + guard let spec = try await catalog.createDatabaseForm(for: source, connection: sourceConnection) + else { + createDatabaseFormState = .unsupported + return + } + createDatabaseForm = spec + newDatabaseValues = CreateDatabaseFormRules.initialValues(for: spec) + createDatabaseFormState = .ready + } catch { + createDatabaseFormState = .failed(error.localizedDescription) + } + } + + internal func toggle(_ selection: ObjectCopySelection) { + if selectedObjectIds.contains(selection.id) { + selectedObjectIds.remove(selection.id) + } else { + selectedObjectIds.insert(selection.id) + } + } + + internal func selectAll() { + selectedObjectIds = Set(filteredObjects.map(\.id)) + } + + internal func selectNone() { + selectedObjectIds.subtract(Set(filteredObjects.map(\.id))) + } + + // MARK: - Review + + /// The step moves before the reads start, not after they finish. Leaving the sheet on its + /// configuring step while this ran let the user change the target and press Continue again, + /// and the plan that eventually arrived was the one built for the settings they had moved off. + internal func review(planner: ObjectCopyPlanner = ObjectCopyPlanner()) { + guard let request else { return } + errorMessage = nil + plan = nil + runTask?.cancel() + step = .reviewing + runTask = Task { [weak self] in + guard let self else { return } + do { + let built = try await planner.plan(request) + guard !Task.isCancelled else { return } + guard !built.isEmpty else { + self.errorMessage = String( + localized: "Nothing is left to copy once the target is taken into account." + ) + self.step = .configuring + return + } + self.plan = built + } catch is CancellationError { + return + } catch { + self.errorMessage = error.localizedDescription + self.step = .configuring + } + } + } + + internal func backToConfiguring() { + runTask?.cancel() + runTask = nil + plan = nil + errorMessage = nil + step = .configuring + } + + // MARK: - Run + + internal func start(runner: ObjectCopyRunner = ObjectCopyRunner()) { + guard let plan else { return } + errorMessage = nil + copiedRows = 0 + currentObject = "" + let runProgress = Progress(totalUnitCount: Int64(max(plan.estimatedRowTotal, 0))) + runProgress.isCancellable = true + progress = runProgress + step = .copying + + let reporter = ObjectCopyProgress(progress: runProgress) + runTask = Task { [weak self] in + guard let self else { return } + let started = ContinuousClock.Instant.now + do { + let outcome = try await runner.run(plan, progress: reporter) + self.result = outcome + self.step = .finished + self.report(outcome, plan: plan, startedAt: started) + } catch is CancellationError { + self.step = .reviewing + } catch { + self.errorMessage = error.localizedDescription + self.step = .reviewing + } + self.progress = nil + } + + observe(runProgress) + } + + internal func cancel() { + progress?.cancel() + runTask?.cancel() + } + + @ObservationIgnored private var observations: [NSKeyValueObservation] = [] + + private func observe(_ runProgress: Progress) { + observations.forEach { $0.invalidate() } + observations = [ + runProgress.observe(\.completedUnitCount) { [weak self] observed, _ in + let count = Int(observed.completedUnitCount) + Task { @MainActor [weak self] in self?.copiedRows = count } + }, + runProgress.observe(\.localizedDescription) { [weak self] observed, _ in + let name = observed.localizedDescription ?? "" + Task { @MainActor [weak self] in self?.currentObject = name } + } + ] + } + + deinit { + observations.forEach { $0.invalidate() } + } + + private func report(_ outcome: ObjectCopyRunResult, plan: ObjectCopyPlan, startedAt: ContinuousClock.Instant) { + let result: OperationOutcome + if outcome.cancelled { + result = .cancelled + } else if let failure = outcome.firstError { + result = .failed(reason: failure) + } else { + /// Rows only. `statementCount` reads out as "Ran N statements", and a copy's object + /// count is not a statement count: one table can be a DROP, a CREATE and a thousand + /// INSERTs. + result = .succeeded(OperationSummary(rowsAffected: outcome.rowsCopied)) + } + OperationCompletionReporter.shared.report(OperationCompletion( + kind: .objectCopy, + owner: .connection(plan.request.target.connectionId), + connectionId: plan.request.target.connectionId, + connectionName: plan.request.target.connectionName, + databaseName: plan.request.target.database, + elapsed: startedAt.duration(to: .now), + outcome: result + )) + } +} diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift index 6b88fb15a..e30c7988f 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+DatabaseMenuActions.swift @@ -54,6 +54,14 @@ extension MainSplitViewController { commandActions?.createDatabase() } + @objc func copyObjectsToDatabase(_ sender: Any?) { + commandActions?.copyObjectsToAnotherDatabase() + } + + @objc func duplicateCurrentDatabase(_ sender: Any?) { + commandActions?.duplicateCurrentDatabase() + } + @objc func showTableStructure(_ sender: Any?) { commandActions?.showTableStructure() } diff --git a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift index f6c1f178b..d82aec847 100644 --- a/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift +++ b/TablePro/Core/Services/Infrastructure/MainSplitViewController+MenuValidation.swift @@ -52,6 +52,8 @@ struct MenuValidationContext: Equatable { var canShowTableStructure = false var canEditViewDefinition = false var canCreateDatabase = false + var canCopyObjects = false + var canDuplicateDatabase = false var hasMaintenanceOperations = false var canUndo = false var canRedo = false @@ -190,6 +192,10 @@ extension MainSplitViewController: NSMenuItemValidation { return context.isConnected && !context.isReadOnly case #selector(createNewDatabase(_:)): return context.canCreateDatabase + case #selector(copyObjectsToDatabase(_:)): + return context.canCopyObjects + case #selector(duplicateCurrentDatabase(_:)): + return context.canDuplicateDatabase case #selector(showTableStructure(_:)): return context.isConnected && context.canShowTableStructure case #selector(editViewDefinition(_:)): @@ -273,6 +279,8 @@ extension MainSplitViewController: NSMenuItemValidation { canShowTableStructure: actions.canShowTableStructure, canEditViewDefinition: actions.canEditViewDefinition, canCreateDatabase: actions.canCreateDatabase, + canCopyObjects: actions.canCopyObjects, + canDuplicateDatabase: actions.canDuplicateDatabase, hasMaintenanceOperations: !actions.maintenanceOperations.isEmpty, canUndo: actions.canUndo, canRedo: actions.canRedo, diff --git a/TablePro/Core/Services/Operations/OperationCompletion.swift b/TablePro/Core/Services/Operations/OperationCompletion.swift index 2842d7fd1..7438a9a43 100644 --- a/TablePro/Core/Services/Operations/OperationCompletion.swift +++ b/TablePro/Core/Services/Operations/OperationCompletion.swift @@ -15,6 +15,7 @@ internal enum TrackedOperationKind: String, CaseIterable, Sendable { case schemaChange case dataImport case dataExport + case objectCopy case backup case fetchAll case mcpQuery diff --git a/TablePro/Core/Services/Operations/OperationCompletionCopy.swift b/TablePro/Core/Services/Operations/OperationCompletionCopy.swift index 81ac5be46..ce69ea87a 100644 --- a/TablePro/Core/Services/Operations/OperationCompletionCopy.swift +++ b/TablePro/Core/Services/Operations/OperationCompletionCopy.swift @@ -87,6 +87,7 @@ internal enum OperationCompletionCopy { case .backup: return String(localized: "Backup finished") case .dataImport: return String(localized: "Import finished") case .dataExport: return String(localized: "Export finished") + case .objectCopy: return String(localized: "Copy finished") case .query, .queryBatch, .fetchAll, .mcpQuery: return String(localized: "Finished") } } diff --git a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift index 255cf0c92..758954224 100644 --- a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift +++ b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift @@ -51,4 +51,54 @@ extension MainContentCommandActions { func createDatabase() { coordinator?.activeSheet = .createDatabase } + + /// The menu-bar mirrors of the sidebar's own commands. With no clicked row to carry, both act + /// on the database being browsed and preselect everything in it. + var canCopyObjects: Bool { + guard let coordinator else { return false } + return isConnected && ObjectCopyEligibility.supportsCopying( + editorLanguage: PluginManager.shared.editorLanguage(for: coordinator.connection.type) + ) + } + + var canDuplicateDatabase: Bool { + guard let coordinator else { return false } + return isConnected && ObjectCopyEligibility.mayOfferDuplicateDatabase( + editorLanguage: PluginManager.shared.editorLanguage(for: coordinator.connection.type), + supportsDatabaseSwitching: supportsContainerSwitching, + isReadOnly: isReadOnly + ) + } + + func copyObjectsToAnotherDatabase() { + coordinator?.openCopyObjects(mode: .copyTo, database: nil, schema: nil, objects: []) + } + + func duplicateCurrentDatabase() { + coordinator?.openCopyObjects(mode: .duplicateDatabase, database: nil, schema: nil, objects: []) + } +} + +@MainActor +internal extension MainContentCoordinator { + /// Opens Copy To or Duplicate Database on the row that was right-clicked. + /// + /// The database and the schema travel with the request rather than being read from the browsing + /// state, for the reason every other sidebar command carries its ref: the tree can show a + /// database the session is not currently on, and copying from the wrong one is silent. + func openCopyObjects( + mode: ObjectCopyMode, + database: String?, + schema: String?, + objects: [ObjectCopySelection] + ) { + let source = DatabaseEndpoint.from( + connection: connection, + database: database ?? browseDatabaseName, + schema: schema + ) + activeSheet = .copyObjects(ObjectCopyLaunchRequest( + mode: mode, source: source, preselected: objects + )) + } } diff --git a/TablePro/Views/Main/MainContentCoordinator.swift b/TablePro/Views/Main/MainContentCoordinator.swift index 857d36468..fcc0af267 100644 --- a/TablePro/Views/Main/MainContentCoordinator.swift +++ b/TablePro/Views/Main/MainContentCoordinator.swift @@ -59,6 +59,10 @@ enum ActiveSheet: Identifiable { /// This is the rule the sidebar's other destructive commands already keep by carrying their ref. case maintenance(operation: String, tableName: String, database: String?, schema: String?) case createDatabase + /// Copying carries the whole launch request, because the source database, the source schema + /// and the objects the user right-clicked are all part of what the sheet opens onto, and the + /// object browser may be pointed somewhere else by the time the sheet appears. + case copyObjects(ObjectCopyLaunchRequest) case rewind var id: String { @@ -73,6 +77,7 @@ enum ActiveSheet: Identifiable { case .maintenance(let operation, let tableName, let database, let schema): "maintenance-\(operation)-\(database ?? "")-\(schema ?? "")-\(tableName)" case .createDatabase: "createDatabase" + case .copyObjects(let launch): "copyObjects-\(launch.id)" case .rewind: "rewind" } } diff --git a/TablePro/Views/Main/MainContentView.swift b/TablePro/Views/Main/MainContentView.swift index ff8159660..1b3d859aa 100644 --- a/TablePro/Views/Main/MainContentView.swift +++ b/TablePro/Views/Main/MainContentView.swift @@ -185,6 +185,8 @@ struct MainContentView: View { Task { await coordinator.switchContainer(to: newDatabaseName) } } ) + case .copyObjects(let launch): + CopyObjectsSheet(launch: launch, connection: connection) case .exportDialog: let exportConnection = exportConnection ExportDialog( diff --git a/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift b/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift new file mode 100644 index 000000000..cb30eb14d --- /dev/null +++ b/TablePro/Views/ObjectCopy/CopyObjectsConfigureView.swift @@ -0,0 +1,130 @@ +// +// CopyObjectsConfigureView.swift +// TablePro +// +// The first step: where the copy goes, what it carries, and which objects. +// + +import SwiftUI + +internal struct CopyObjectsConfigureView: View { + @Bindable internal var session: ObjectCopySession + @Binding internal var isChoosingTarget: Bool + + internal var body: some View { + HStack(spacing: 0) { + settings + .frame(width: 320) + .padding(20) + Divider() + CopyObjectsListView(session: session) + .frame(maxWidth: .infinity) + } + .frame(maxHeight: .infinity) + } + + // MARK: - Settings + + private var settings: some View { + VStack(alignment: .leading, spacing: 18) { + destinationSection + contentSection + existingSection + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + @ViewBuilder + private var destinationSection: some View { + switch session.mode { + case .copyTo: + labelled(String(localized: "Copy to")) { + Button { + isChoosingTarget = true + } label: { + HStack { + Text(session.target?.qualifiedDescription ?? DatabaseEndpointSide.target.placeholderTitle) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 6) + Image(systemName: "chevron.up.chevron.down") + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .accessibilityIdentifier("copy-objects-target") + .popover(isPresented: $isChoosingTarget, arrowEdge: .bottom) { + DatabaseEndpointPicker( + side: .target, + current: session.target, + onPick: { session.target = $0 }, + dismiss: { isChoosingTarget = false } + ) + } + } + case .duplicateDatabase: + labelled(String(localized: "New database")) { + TextField(String(localized: "Name"), text: $session.newDatabaseName) + .textFieldStyle(.roundedBorder) + .accessibilityIdentifier("copy-objects-new-database-name") + } + if let spec = session.createDatabaseForm { + CreateDatabaseOptionsView(spec: spec, values: $session.newDatabaseValues) + } + } + } + + private var contentSection: some View { + labelled(String(localized: "Copy")) { + Picker("", selection: $session.content) { + ForEach(ObjectCopyContent.allCases, id: \.self) { content in + Text(content.displayName).tag(content) + } + } + .labelsHidden() + .pickerStyle(.radioGroup) + .accessibilityIdentifier("copy-objects-content") + } + } + + private var existingSection: some View { + labelled(String(localized: "If the object is already there")) { + Picker("", selection: $session.existingPolicy) { + ForEach(ObjectCopyExistingPolicy.allCases, id: \.self) { policy in + Text(policy.displayName).tag(policy) + } + } + .labelsHidden() + .accessibilityIdentifier("copy-objects-existing-policy") + Text(existingPolicyExplanation) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private var existingPolicyExplanation: String { + switch session.existingPolicy { + case .skip: + return String(localized: "The target keeps what it has and the object is left out.") + case .replace: + return session.content.includesStructure + ? String(localized: "The target's object is dropped and built again from the source.") + : String(localized: "The target's rows are removed before the source's are written.") + case .appendData: + return String(localized: "The target keeps its structure and its rows, and the source's rows are added.") + } + } + + @ViewBuilder + private func labelled(_ title: String, @ViewBuilder content: () -> some View) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.subheadline.weight(.medium)) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/TablePro/Views/ObjectCopy/CopyObjectsListView.swift b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift new file mode 100644 index 000000000..32dd5d07a --- /dev/null +++ b/TablePro/Views/ObjectCopy/CopyObjectsListView.swift @@ -0,0 +1,94 @@ +// +// CopyObjectsListView.swift +// TablePro +// +// The objects the source has, with the ones taking part ticked. +// + +import SwiftUI + +internal struct CopyObjectsListView: View { + @Bindable internal var session: ObjectCopySession + + internal var body: some View { + VStack(spacing: 0) { + toolbar + Divider() + list + } + } + + private var toolbar: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .foregroundStyle(.secondary) + TextField(String(localized: "Search"), text: $session.searchText) + .textFieldStyle(.plain) + .accessibilityIdentifier("copy-objects-search") + Spacer(minLength: 8) + Button(String(localized: "All")) { session.selectAll() } + .controlSize(.small) + Button(String(localized: "None")) { session.selectNone() } + .controlSize(.small) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + @ViewBuilder + private var list: some View { + if session.isLoadingObjects { + centred { + ProgressView().controlSize(.small) + Text("Reading the source…") + .font(.callout) + .foregroundStyle(.secondary) + } + } else if let message = session.catalogError { + ContentUnavailableView { + Label("Cannot Read the Source", systemImage: "exclamationmark.triangle") + } description: { + Text(message) + } actions: { + Button("Try Again") { Task { await session.loadObjects() } } + } + } else if session.filteredObjects.isEmpty { + ContentUnavailableView { + Label("Nothing to Copy", systemImage: "tray") + } description: { + Text("This database reports no objects.") + } + } else { + List(session.filteredObjects) { object in + row(object) + } + .listStyle(.inset) + .accessibilityIdentifier("copy-objects-list") + } + } + + private func row(_ object: ObjectCopySelection) -> some View { + Toggle(isOn: Binding( + get: { session.selectedObjectIds.contains(object.id) }, + set: { _ in session.toggle(object) } + )) { + HStack(spacing: 6) { + /// The signature or the owning table, not just the name: two overloads and two + /// same-named triggers are two rows and have to read as two. + Text(object.displayName) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Text(object.kind.displayName) + .font(.caption) + .foregroundStyle(.secondary) + } + } + .toggleStyle(.checkbox) + } + + private func centred(@ViewBuilder content: () -> some View) -> some View { + VStack(spacing: 8) { content() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift b/TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift new file mode 100644 index 000000000..f1eac467d --- /dev/null +++ b/TablePro/Views/ObjectCopy/CopyObjectsProgressView.swift @@ -0,0 +1,63 @@ +// +// CopyObjectsProgressView.swift +// TablePro +// +// What the copy is doing, and how far along it is. +// +// The bar is driven by an approximate row count, which several engines answer +// from statistics rather than by counting, so it is deliberately indeterminate +// when nothing usable came back rather than pretending to a precision it does +// not have. +// + +import SwiftUI + +internal struct CopyObjectsProgressView: View { + internal let session: ObjectCopySession + + internal var body: some View { + VStack(spacing: 16) { + Text(session.currentObject.isEmpty + ? String(localized: "Preparing…") + : session.currentObject) + .font(.headline) + .lineLimit(1) + .truncationMode(.middle) + + if let fraction { + ProgressView(value: fraction) + .progressViewStyle(.linear) + } else { + ProgressView() + .progressViewStyle(.linear) + } + + Text(rowsText) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + .frame(maxWidth: 420) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(24) + .accessibilityIdentifier("copy-objects-progress") + } + + private var fraction: Double? { + guard let total = session.plan?.estimatedRowTotal, total > 0 else { return nil } + return min(1, Double(session.copiedRows) / Double(total)) + } + + private var rowsText: String { + let copied = session.copiedRows.formatted(.number.grouping(.automatic)) + guard let total = session.plan?.estimatedRowTotal, total > 0 else { + let template = session.copiedRows == 1 + ? String(localized: "%@ row copied") + : String(localized: "%@ rows copied") + return String(format: template, copied) + } + return String( + format: String(localized: "%1$@ of about %2$@ rows"), + copied, total.formatted(.number.grouping(.automatic)) + ) + } +} diff --git a/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift new file mode 100644 index 000000000..dd8fb5395 --- /dev/null +++ b/TablePro/Views/ObjectCopy/CopyObjectsResultView.swift @@ -0,0 +1,103 @@ +// +// CopyObjectsResultView.swift +// TablePro +// +// What the copy actually wrote. +// +// A cancelled copy is neither a success nor a failure: the objects already +// written are still there, so the result says which, rather than reporting the +// whole run as one or the other. +// + +import SwiftUI + +internal struct CopyObjectsResultView: View { + internal let session: ObjectCopySession + + internal var body: some View { + if let result = session.result { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + headline(result) + if let database = result.createdDatabase { + Label( + String(format: String(localized: "Created the database %@."), database), + systemImage: "cylinder.split.1x2" + ) + .font(.callout) + .foregroundStyle(.secondary) + } + outcomes(result) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + .accessibilityIdentifier("copy-objects-result") + } else { + Color.clear + } + } + + private func headline(_ result: ObjectCopyRunResult) -> some View { + VStack(alignment: .leading, spacing: 6) { + Label(title(result), systemImage: symbol(result)) + .font(.headline) + .foregroundStyle(tint(result)) + Text(summary(result)) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + + private func title(_ result: ObjectCopyRunResult) -> String { + if result.cancelled { return String(localized: "Copy stopped") } + if result.failedCount > 0 { return String(localized: "Copy finished with errors") } + return String(localized: "Copy finished") + } + + private func symbol(_ result: ObjectCopyRunResult) -> String { + if result.cancelled { return "stop.circle" } + if result.failedCount > 0 { return "exclamationmark.triangle" } + return "checkmark.circle" + } + + private func tint(_ result: ObjectCopyRunResult) -> Color { + if result.cancelled { return .secondary } + return result.failedCount > 0 ? .orange : .green + } + + private func summary(_ result: ObjectCopyRunResult) -> String { + let objects = String( + format: result.succeededCount == 1 + ? String(localized: "%@ object") + : String(localized: "%@ objects"), + result.succeededCount.formatted(.number.grouping(.automatic)) + ) + let rows = String( + format: result.rowsCopied == 1 + ? String(localized: "%@ row") + : String(localized: "%@ rows"), + result.rowsCopied.formatted(.number.grouping(.automatic)) + ) + return String(format: String(localized: "%1$@, %2$@."), objects, rows) + } + + @ViewBuilder + private func outcomes(_ result: ObjectCopyRunResult) -> some View { + let failures = result.outcomes.filter { $0.error != nil } + 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 ?? "")") + .font(.callout) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } +} diff --git a/TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift b/TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift new file mode 100644 index 000000000..9ccaf3e12 --- /dev/null +++ b/TablePro/Views/ObjectCopy/CopyObjectsReviewView.swift @@ -0,0 +1,150 @@ +// +// CopyObjectsReviewView.swift +// TablePro +// +// The second step: what will run, before it runs. +// +// The DDL is shown verbatim. The rows are shown as the query each table will +// walk and the count it expects, because the INSERTs do not exist yet and +// never all exist at once. +// + +import SwiftUI + +internal struct CopyObjectsReviewView: View { + internal let session: ObjectCopySession + + internal var body: some View { + if let plan = session.plan { + HSplitView { + summary(plan) + .frame(minWidth: 260, idealWidth: 300) + script(plan) + .frame(minWidth: 320) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + VStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Reading both databases…") + .font(.callout) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + + // MARK: - Summary + + private func summary(_ plan: ObjectCopyPlan) -> some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + headline(plan) + ForEach(plan.warnings, id: \.self) { warning in + Label(warning, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + rowPlan(plan) + notes(plan) + skipped(plan) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(16) + } + } + + private func headline(_ plan: ObjectCopyPlan) -> some View { + VStack(alignment: .leading, spacing: 4) { + Text(String( + format: String(localized: "Writing to %@"), + plan.request.target.qualifiedDescription + )) + .font(.headline) + if plan.createsDatabase { + Text(String( + format: String(localized: "A new database named %@ is created first."), + plan.request.target.database + )) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + + @ViewBuilder + private func rowPlan(_ plan: ObjectCopyPlan) -> some View { + let steps = plan.dataSteps + if !steps.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Rows") + .font(.subheadline.weight(.medium)) + ForEach(steps) { step in + HStack(spacing: 8) { + Text(step.qualifiedTargetName) + .lineLimit(1) + .truncationMode(.middle) + Spacer(minLength: 8) + Text(rowEstimate(step)) + .font(.callout.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + } + } + } + + /// A driver that reports no estimate says so rather than showing a zero, which would read as an + /// empty table. + private func rowEstimate(_ step: ObjectCopyTableStep) -> String { + guard let rows = step.estimatedRows else { return String(localized: "Unknown") } + let template = rows == 1 + ? String(localized: "about %@ row") + : String(localized: "about %@ rows") + return String(format: template, rows.formatted(.number.grouping(.automatic))) + } + + @ViewBuilder + private func notes(_ plan: ObjectCopyPlan) -> some View { + let noted = plan.tableSteps.compactMap { step in step.note.map { (step.id, step.selection, $0) } } + if !noted.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Partly copied") + .font(.subheadline.weight(.medium)) + ForEach(noted, id: \.0) { _, selection, note in + Text(verbatim: "\(selection.displayName): \(note)") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + @ViewBuilder + private func skipped(_ plan: ObjectCopyPlan) -> some View { + if !plan.skipped.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Left out") + .font(.subheadline.weight(.medium)) + ForEach(plan.skipped) { skip in + Text(verbatim: "\(skip.selection.displayName): \(skip.reason)") + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + + // MARK: - Script + + /// The same read-only editor the import preview uses, so the script arrives with the user's + /// editor font, their theme and SQL highlighting rather than as plain text. + private func script(_ plan: ObjectCopyPlan) -> some View { + SQLCodePreview(text: .constant(plan.scriptText)) + .accessibilityIdentifier("copy-objects-script") + } +} diff --git a/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift b/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift new file mode 100644 index 000000000..dd11633e9 --- /dev/null +++ b/TablePro/Views/ObjectCopy/CopyObjectsSheet.swift @@ -0,0 +1,145 @@ +// +// CopyObjectsSheet.swift +// TablePro +// +// Copy To, and Duplicate Database, in one sheet. +// +// Two steps, not one: the issue asks for the script to be shown before +// anything runs, and the script cannot be built without reaching both +// databases. Configuring costs nothing, so it stays free to change; Continue +// is what pays for the reads, and Copy is what writes. +// + +import SwiftUI + +internal struct CopyObjectsSheet: View { + @Environment(\.dismiss) private var dismiss + + @State private var session: ObjectCopySession + @State private var isChoosingTarget = false + + internal init(launch: ObjectCopyLaunchRequest, connection: DatabaseConnection) { + _session = State(initialValue: ObjectCopySession( + mode: launch.mode, + source: launch.source, + sourceConnection: connection, + preselected: launch.preselected + )) + } + + internal var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider() + content + Divider() + footer + } + .frame(width: 760, height: 560) + .task { + await session.loadObjects() + await session.loadCreateDatabaseForm() + } + } + + // MARK: - Header + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + Text(session.title) + .font(.title3.weight(.semibold)) + Text(session.source.qualifiedDescription) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(20) + } + + // MARK: - Content + + @ViewBuilder + private var content: some View { + switch session.step { + case .configuring: + CopyObjectsConfigureView(session: session, isChoosingTarget: $isChoosingTarget) + case .reviewing: + CopyObjectsReviewView(session: session) + case .copying: + CopyObjectsProgressView(session: session) + case .finished: + CopyObjectsResultView(session: session) + } + } + + // MARK: - Footer + + @ViewBuilder + private var footer: some View { + DialogFooter { + statusText + } actions: { + actionButtons + } + .padding(20) + } + + @ViewBuilder + private var statusText: some View { + if let message = session.errorMessage { + Label(message, systemImage: "exclamationmark.triangle") + .font(.callout) + .foregroundStyle(.red) + .lineLimit(2) + } else if session.step == .configuring, let reason = session.reviewDisabledReason { + Text(reason) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + + @ViewBuilder + private var actionButtons: some View { + switch session.step { + case .configuring: + Button(String(localized: "Cancel"), role: .cancel) { dismiss() } + .keyboardShortcut(.cancelAction) + Button(String(localized: "Continue")) { session.review() } + .keyboardShortcut(.defaultAction) + .disabled(session.reviewDisabledReason != nil) + case .reviewing: + Button(String(localized: "Back")) { session.backToConfiguring() } + Button(String(localized: "Copy")) { session.start() } + .keyboardShortcut(.defaultAction) + .disabled(session.plan == nil) + case .copying: + Button(String(localized: "Stop")) { session.cancel() } + .keyboardShortcut(.cancelAction) + case .finished: + Button(String(localized: "Done")) { dismiss() } + .keyboardShortcut(.defaultAction) + } + } +} + +/// What the sidebar hands the sheet. +internal struct ObjectCopyLaunchRequest: Hashable, Identifiable, Sendable { + internal let mode: ObjectCopyMode + internal let source: DatabaseEndpoint + /// Empty means every object in the source, which is what a right-click on a database means. + internal let preselected: [ObjectCopySelection] + + internal init(mode: ObjectCopyMode, source: DatabaseEndpoint, preselected: [ObjectCopySelection] = []) { + self.mode = mode + self.source = source + self.preselected = preselected + } + + internal var id: String { + let names = preselected.map(\.id).sorted().joined(separator: ",") + return "\(mode)|\(source.id)|\(names)" + } +} diff --git a/TablePro/Views/Settings/NotificationsSettingsView.swift b/TablePro/Views/Settings/NotificationsSettingsView.swift index 06c22ddaf..ae5f31b6c 100644 --- a/TablePro/Views/Settings/NotificationsSettingsView.swift +++ b/TablePro/Views/Settings/NotificationsSettingsView.swift @@ -89,6 +89,7 @@ extension TrackedOperationKind { case .schemaChange: return String(localized: "Structure changes") case .dataImport: return String(localized: "Imports") case .dataExport: return String(localized: "Exports") + case .objectCopy: return String(localized: "Object copies") case .backup: return String(localized: "Backups") case .fetchAll: return String(localized: "Fetch all rows") case .mcpQuery: return String(localized: "AI and MCP queries") diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift index 450791e61..f83a7f7cc 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Commands.swift @@ -102,6 +102,27 @@ extension DatabaseTreeOutlineCoordinator { mainCoordinator?.openExportDialog(containers: targets) case .dropContainers(let targets): mainCoordinator?.requestContainerDrop(targets) + case .copyObjectsTo(let objects, let ref): + mainCoordinator?.openCopyObjects( + mode: .copyTo, + database: ref?.database, + schema: ref?.qualifyingSchema, + objects: objects + ) + case .copyContainerTo(let container): + mainCoordinator?.openCopyObjects( + mode: .copyTo, + database: container.database, + schema: container.kind == .schema ? container.schema : nil, + objects: [] + ) + case .duplicateDatabase(let container): + mainCoordinator?.openCopyObjects( + mode: .duplicateDatabase, + database: container.database, + schema: nil, + objects: [] + ) case .showAllTablesMetadata: mainCoordinator?.showAllTablesMetadata() case .refreshObjectKind(let kind): diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift index b9dcc2bc8..bec2fb90e 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator+Menu.swift @@ -74,7 +74,17 @@ extension DatabaseTreeOutlineCoordinator: NSMenuDelegate { rowSize: settings.sidebarRowSize, canFilterDatabases: PluginManager.shared.supportsDatabaseTree(for: databaseType) && sidebarState?.sidebarLayout == .tree, - hasDatabaseFilter: !(sidebarState?.databaseFilterSelected.isEmpty ?? true) + hasDatabaseFilter: !(sidebarState?.databaseFilterSelected.isEmpty ?? true), + /// Not gated on this connection's safe mode: a read-only connection is a valid source, + /// and the target picker is where a read-only target is refused. + canCopyObjects: ObjectCopyEligibility.supportsCopying( + editorLanguage: PluginManager.shared.editorLanguage(for: databaseType) + ), + canDuplicateDatabase: ObjectCopyEligibility.mayOfferDuplicateDatabase( + editorLanguage: PluginManager.shared.editorLanguage(for: databaseType), + supportsDatabaseSwitching: PluginManager.shared.supportsDatabaseSwitching(for: databaseType), + isReadOnly: mainCoordinator?.safeModeLevel.blocksAllWrites ?? false + ) ) } diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index e6f98ebc9..f26e9a75d 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -40,6 +40,11 @@ internal struct DatabaseTreeMenuContext { internal let rowSize: SidebarRowSizePreference internal var canFilterDatabases: Bool = false internal var hasDatabaseFilter: Bool = false + /// Copying reads the source and writes somewhere else, so it needs a driver that reports + /// structure and a target that is not this connection's read-only self. + internal var canCopyObjects: Bool = false + /// Duplicating means creating a database, which is the same test the New Database command uses. + internal var canDuplicateDatabase: Bool = false } internal enum DatabaseTreeMenuSpec { @@ -124,6 +129,16 @@ internal enum DatabaseTreeMenuSpec { items.append(.separator) items.append(.command(copyNamesTitle(count: names.count), .copyTableNames(names))) items.append(.command(String(localized: "Export…"), .exportTables(names: Set(names), ref: ref))) + if context.canCopyObjects { + /// Narrowed to the clicked row's own schema as well as its database. A copy names one + /// source scope, so a selection spanning two schemas would read one of them and either + /// drop the other's tables from the plan or map a same-named one to the wrong table. + let sameScope = targets.filter { $0.qualifyingSchema == ref.qualifyingSchema } + items.append(.command( + String(localized: "Copy To…"), + .copyObjectsTo(objects: copySelections(for: sameScope), ref: ref) + )) + } items.append(.command(String(localized: "View ER Diagram"), .showERDiagram)) if !context.isReadOnly, @@ -289,6 +304,11 @@ internal enum DatabaseTreeMenuSpec { items.append(.separator) items.append(.command(String(localized: "Export…"), .exportContainers(targets))) } + /// Both act on one container: a copy names one source and one target, and a duplicate + /// names one new database. A multi-selection would need a target per container. + if targets.count == 1 { + items += copyItems(clicked, context: context) + } let renameable = ObjectRenameEligibility.renameable(targets, context: context.renameEligibility) guard renameable != nil || !droppable.isEmpty else { return items } items.append(.separator) @@ -323,6 +343,36 @@ internal enum DatabaseTreeMenuSpec { return items } + /// Duplicate is offered on a database row alone: a schema is duplicated by copying it into a + /// schema that exists, which is what Copy To already does, and no engine creates one from a + /// `CREATE DATABASE`. + private static func copyItems( + _ clicked: DatabaseContainerRef, + context: DatabaseTreeMenuContext + ) -> [DatabaseTreeMenuItem] { + var items: [DatabaseTreeMenuItem] = [] + if context.canCopyObjects { + items.append(.command(String(localized: "Copy To…"), .copyContainerTo(clicked))) + } + if clicked.kind == .database, context.canDuplicateDatabase, !clicked.isSystem { + items.append(.command(String(localized: "Duplicate Database…"), .duplicateDatabase(clicked))) + } + guard !items.isEmpty else { return [] } + return [.separator] + items + } + + /// A table row's copy carries the whole selection the menu resolved, so right-clicking inside a + /// multi-selection copies every table in it rather than only the one under the pointer. + private static func copySelections(for targets: [DatabaseTreeTableRef]) -> [ObjectCopySelection] { + targets.map { target in + ObjectCopySelection( + kind: SidebarContextMenuLogic.isView(clickedTable: target.table) ? .view : .table, + name: target.table.name, + schema: target.qualifyingSchema + ) + } + } + private static func isActive(_ container: DatabaseContainerRef, context: DatabaseTreeMenuContext) -> Bool { switch container.kind { case .database: diff --git a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift index 66e7013f2..0c7c679e3 100644 --- a/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift +++ b/TablePro/Views/Sidebar/Menu/SidebarMenuCommand.swift @@ -48,6 +48,11 @@ internal enum SidebarMenuCommand: Equatable { case copyContainerNames([DatabaseContainerRef]) case exportContainers([DatabaseContainerRef]) case dropContainers([DatabaseContainerRef]) + /// Copy carries its objects rather than a scope, because the sheet needs to know what the user + /// right-clicked: one table preselects that table, a database preselects everything in it. + case copyObjectsTo(objects: [ObjectCopySelection], ref: DatabaseTreeTableRef?) + case copyContainerTo(DatabaseContainerRef) + case duplicateDatabase(DatabaseContainerRef) case showAllTablesMetadata case refreshObjectKind(SidebarObjectKind) case refreshContainerObjectKind(DatabaseTreeObjectGroup) diff --git a/TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift new file mode 100644 index 000000000..4f4280c18 --- /dev/null +++ b/TableProTests/Core/ChangeTracking/SQLStatementGeneratorSchemaTests.swift @@ -0,0 +1,70 @@ +// +// SQLStatementGeneratorSchemaTests.swift +// TableProTests +// +// The generator can now address a table outside the schema the connection is +// on, which is what a copy between two databases needs. A caller that names no +// schema keeps the unqualified name it has always produced. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class SQLStatementGeneratorSchemaTests: XCTestCase { + private func generator(schema: String?) throws -> SQLStatementGenerator { + try SQLStatementGenerator( + tableName: "orders", + schemaName: schema, + columns: ["id", "total"], + primaryKeyColumns: ["id"], + databaseType: .postgresql, + quoteIdentifier: { "\"\($0)\"" } + ) + } + + func testASchemaQualifiesTheInsert() throws { + let statement = try generator(schema: "sales") + .insertStatement(columns: ["id"], values: [.text("1")]) + + XCTAssertEqual(statement?.sql, "INSERT INTO \"sales\".\"orders\" (\"id\") VALUES ($1)") + } + + /// Every existing caller passes no schema, so nothing that worked before changes shape. + func testNoSchemaKeepsTheUnqualifiedName() throws { + let statement = try generator(schema: nil) + .insertStatement(columns: ["id"], values: [.text("1")]) + + XCTAssertEqual(statement?.sql, "INSERT INTO \"orders\" (\"id\") VALUES ($1)") + } + + func testAnEmptySchemaIsTreatedAsNoSchema() throws { + XCTAssertEqual(try generator(schema: "").qualifiedTableName, "\"orders\"") + } + + func testTheMultiRowInsertIsQualifiedToo() throws { + let statement = try generator(schema: "sales") + .insertStatement(columns: ["id", "total"], rows: [[.text("1"), .text("9")], [.text("2"), .text("8")]]) + + XCTAssertEqual( + statement?.sql, + "INSERT INTO \"sales\".\"orders\" (\"id\", \"total\") VALUES ($1, $2), ($3, $4)" + ) + } + + /// The copier hands the driver the batch flattened row-major, so the generator's own parameter + /// order has to match or values land in the wrong columns. + func testMultiRowParametersAreOrderedRowMajor() throws { + let statement = try generator(schema: nil) + .insertStatement(columns: ["id", "total"], rows: [[.text("1"), .text("9")], [.text("2"), .text("8")]]) + + XCTAssertEqual(statement?.parameters.map { $0 as? String }, ["1", "9", "2", "8"]) + } + + func testDeleteAllRowsIsQualified() throws { + XCTAssertEqual( + try generator(schema: "sales").deleteAllRowsStatement(), + "DELETE FROM \"sales\".\"orders\"" + ) + } +} diff --git a/TableProTests/Core/Menu/MainMenuBuilderTests.swift b/TableProTests/Core/Menu/MainMenuBuilderTests.swift index dab49231a..108ebcb2b 100644 --- a/TableProTests/Core/Menu/MainMenuBuilderTests.swift +++ b/TableProTests/Core/Menu/MainMenuBuilderTests.swift @@ -577,6 +577,26 @@ struct MainMenuValidationTests { #expect(enabled(#selector(MainSplitViewController.runMaintenanceOperation(_:)), context)) } + /// Both are sidebar commands first. They are mirrored here so the feature is reachable from + /// the keyboard, and they validate on the same facts the sidebar's own menu reads. + @Test("Copying is offered only on an engine that can copy") + func copyObjectsNeedsASQLEngine() { + var context = MenuValidationContext() + context.isConnected = true + #expect(!enabled(#selector(MainSplitViewController.copyObjectsToDatabase(_:)), context)) + context.canCopyObjects = true + #expect(enabled(#selector(MainSplitViewController.copyObjectsToDatabase(_:)), context)) + } + + @Test("Duplicate Database needs a driver that creates databases") + func duplicateDatabaseNeedsContainers() { + var context = MenuValidationContext() + context.isConnected = true + #expect(!enabled(#selector(MainSplitViewController.duplicateCurrentDatabase(_:)), context)) + context.canDuplicateDatabase = true + #expect(enabled(#selector(MainSplitViewController.duplicateCurrentDatabase(_:)), context)) + } + @Test("New Database needs a driver that switches containers") func createDatabaseNeedsContainerSupport() { var context = MenuValidationContext() diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift new file mode 100644 index 000000000..03343cf80 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -0,0 +1,116 @@ +// +// ObjectCopyEligibilityTests.swift +// TableProTests +// +// Every refusal a copy can make before it opens a driver. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class ObjectCopyEligibilityTests: XCTestCase { + private func endpoint( + _ database: String, + type: DatabaseType = .mysql, + schema: String? = nil, + safeMode: SafeModeLevel = .silent, + connectionId: UUID = UUID() + ) -> DatabaseEndpoint { + DatabaseEndpoint( + scope: DatabaseScope(connectionId: connectionId, database: database, schema: schema), + connectionName: "server", + databaseType: type, + safeModeLevel: safeMode, + color: .blue + ) + } + + func testAReadOnlyTargetIsRefused() { + XCTAssertNotNil(ObjectCopyEligibility.targetRefusal(endpoint("app", safeMode: .readOnly))) + XCTAssertNil(ObjectCopyEligibility.targetRefusal(endpoint("app"))) + } + + /// Both halves of a copy are refused across engines, not only structure. The row writer emits + /// `INSERT … VALUES`, which a MongoDB target cannot parse, and a SQL Server `dbo` source hands + /// a MySQL target a schema that engine does not have. + func testACopyStaysInsideOneEngine() { + XCTAssertNotNil(ObjectCopyEligibility.engineRefusal(from: .mysql, to: .postgresql)) + XCTAssertNotNil(ObjectCopyEligibility.engineRefusal(from: .mssql, to: .mysql)) + XCTAssertNil(ObjectCopyEligibility.engineRefusal(from: .mysql, to: .mysql)) + XCTAssertNil(ObjectCopyEligibility.engineRefusal(from: .mysql, to: .mariadb)) + } + + /// Copying a database onto itself either drops the rows it is about to read or doubles them. + func testTheSameScopeIsRefusedAsATarget() { + let connectionId = UUID() + let source = endpoint("app", connectionId: connectionId) + let same = endpoint("app", connectionId: connectionId) + let other = endpoint("app_copy", connectionId: connectionId) + + XCTAssertNotNil(ObjectCopyEligibility.sameObjectRefusal(source: source, target: same)) + XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal(source: source, target: other)) + } + + /// Two databases on one server are a valid pair, which is the case the endpoint type exists for. + func testTwoDatabasesOnOneConnectionAreAValidPair() { + let connectionId = UUID() + XCTAssertNil(ObjectCopyEligibility.sameObjectRefusal( + source: endpoint("prod", connectionId: connectionId), + target: endpoint("staging", connectionId: connectionId) + )) + } + + func testOnlySQLEnginesCanCopy() { + XCTAssertTrue(ObjectCopyEligibility.supportsCopying(editorLanguage: .sql)) + XCTAssertFalse(ObjectCopyEligibility.supportsCopying(editorLanguage: .javascript)) + XCTAssertFalse(ObjectCopyEligibility.supportsCopying(editorLanguage: .custom("mql"))) + } + + func testDuplicateIsOnlyOfferedWhereDatabasesExistAndCanBeWritten() { + XCTAssertTrue(ObjectCopyEligibility.mayOfferDuplicateDatabase( + editorLanguage: .sql, supportsDatabaseSwitching: true, isReadOnly: false + )) + XCTAssertFalse(ObjectCopyEligibility.mayOfferDuplicateDatabase( + editorLanguage: .sql, supportsDatabaseSwitching: false, isReadOnly: false + )) + XCTAssertFalse(ObjectCopyEligibility.mayOfferDuplicateDatabase( + editorLanguage: .sql, supportsDatabaseSwitching: true, isReadOnly: true + )) + XCTAssertFalse(ObjectCopyEligibility.mayOfferDuplicateDatabase( + editorLanguage: .javascript, supportsDatabaseSwitching: true, isReadOnly: false + )) + } + + // MARK: - Schemas + + /// `fetchTables(schema: nil)` resolves to the connection's current schema, so a whole-database + /// copy taken without one carried one schema and reported success over the rest. + func testASchemaAwareEngineMustNameASchema() { + XCTAssertNotNil(ObjectCopyEligibility.unscopedSchemaRefusal( + endpoint: endpoint("app", type: .postgresql), supportsSchemas: true + )) + XCTAssertNil(ObjectCopyEligibility.unscopedSchemaRefusal( + endpoint: endpoint("app", type: .postgresql, schema: "public"), supportsSchemas: true + )) + } + + func testAnEngineWithoutSchemasNeedsNone() { + XCTAssertNil(ObjectCopyEligibility.unscopedSchemaRefusal( + endpoint: endpoint("app"), supportsSchemas: false + )) + } + + // MARK: - Definitions + + /// Nothing parses the definition, so every table it names keeps the source's qualification. + /// Into another schema it would point back at the source, and a replacement's DROP would land + /// on the source's own object. + func testADefinitionOnlyCopiesIntoASchemaOfTheSameName() { + XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "public", targetSchema: "public")) + XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "Public", targetSchema: "public")) + XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceSchema: nil, targetSchema: nil)) + XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "sales", targetSchema: "archive")) + XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "sales", targetSchema: nil)) + } +} diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift new file mode 100644 index 000000000..9b5cd8633 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift @@ -0,0 +1,239 @@ +// +// ObjectCopyPlannerOrderingTests.swift +// TableProTests +// +// What order the plan runs in. A foreign key inside a CREATE TABLE names a +// table that has to exist already, and a view selects from a table. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +final class ObjectCopyPlannerOrderingTests: XCTestCase { + private func selection( + _ name: String, + kind: CompareObjectKind = .table, + schema: String? = "public", + signature: String? = nil, + owner: String? = nil + ) -> ObjectCopySelection { + ObjectCopySelection(kind: kind, name: name, schema: schema, signature: signature, owner: owner) + } + + /// `schema: nil` on the table is what MySQL and PostgreSQL actually report, and it is the case + /// the ordering used to get wrong. + private func read( + _ name: String, + tableSchema: String? = nil, + referencing parents: [String] = [], + referencedSchema: String? = "public" + ) -> TableStructureRead { + TableStructureRead( + table: PluginTableInfo(name: name, type: "TABLE", schema: tableSchema, comment: nil), + columns: [PluginColumnInfo(name: "id", dataType: "int")], + indexes: [], + foreignKeys: parents.map { parent in + PluginForeignKeyInfo( + name: "fk_\(name)_\(parent)", + column: "\(parent)_id", + referencedTable: parent, + referencedColumn: "id", + referencedSchema: referencedSchema + ) + }, + metadata: nil, + failure: nil + ) + } + + func testAParentIsCreatedBeforeItsChild() { + let orders = selection("orders") + let customers = selection("customers") + let reads: [ObjectCopySelection: TableStructureRead] = [ + orders: read("orders", referencing: ["customers"]), + customers: read("customers") + ] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [orders, customers], reads: reads, effectiveSchema: "public" + ) + + XCTAssertEqual(ordered.map(\.name), ["customers", "orders"]) + } + + /// The regression this guards: `fetchTables` reports no schema while the foreign key reports + /// one, so a node keyed `orders` never matched the dependency `public.customers`. Every edge + /// vanished and the sort fell through to alphabetical order, which puts the child first. + func testTheEffectiveSchemaIsUsedWhenTheTableReportsNone() { + let orders = selection("orders", schema: nil) + let customers = selection("customers", schema: nil) + let reads: [ObjectCopySelection: TableStructureRead] = [ + orders: read("orders", referencing: ["customers"], referencedSchema: "public"), + customers: read("customers") + ] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [orders, customers], reads: reads, effectiveSchema: "public" + ) + + XCTAssertEqual( + ordered.map(\.name), ["customers", "orders"], + "the edge has to survive the schema the table itself does not report" + ) + } + + /// A cycle cannot be ordered, and dropping one of its tables would be worse than letting the + /// server refuse the second CREATE, so every input still comes back exactly once. + func testACycleStillReturnsEveryTableOnce() { + let first = selection("a") + let second = selection("b") + let reads: [ObjectCopySelection: TableStructureRead] = [ + first: read("a", referencing: ["b"]), + second: read("b", referencing: ["a"]) + ] + + let ordered = ObjectCopyPlanner.orderedByDependency( + [first, second], reads: reads, effectiveSchema: "public" + ) + + XCTAssertEqual(Set(ordered.map(\.name)), ["a", "b"]) + XCTAssertEqual(ordered.count, 2) + } + + func testASingleTableIsReturnedUnchanged() { + let only = selection("orders") + XCTAssertEqual( + ObjectCopyPlanner.orderedByDependency( + [only], reads: [only: read("orders")], effectiveSchema: "public" + ).map(\.name), + ["orders"] + ) + } + + func testViewsRunBeforeRoutinesAndTriggersRunLast() { + let ordered = ObjectCopyPlanner.orderedByKind([ + selection("audit_trigger", kind: .trigger), + selection("total_sales", kind: .procedure), + selection("active_users", kind: .view), + selection("tax_rate", kind: .function) + ]) + + XCTAssertEqual(ordered.map(\.name), ["active_users", "tax_rate", "total_sales", "audit_trigger"]) + } + + func testTwoObjectsOfOneKindKeepTheirGivenOrder() { + let ordered = ObjectCopyPlanner.orderedByKind([ + selection("second", kind: .view), + selection("first", kind: .view) + ]) + + XCTAssertEqual(ordered.map(\.name), ["second", "first"]) + } + + // MARK: - Matching what the target already has + + /// Engines fold identifier case differently, and a target that already has the object has to + /// be recognised whichever way it spells it, or Skip does not skip and Replace does not drop. + func testAnExistingObjectMatchesWithoutRegardToCase() { + XCTAssertEqual( + ObjectCopyPlanner.objectKey(for: selection("Active_Users", kind: .view)), + ObjectCopyPlanner.objectKey(for: selection("active_users", kind: .view)) + ) + } + + /// A table and a trigger may share a name, and only the trigger's own presence decides the + /// trigger's step. + func testTwoKindsSharingANameAreDifferentObjects() { + XCTAssertNotEqual( + ObjectCopyPlanner.objectKey(for: selection("audit", kind: .table)), + ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "orders")) + ) + } + + /// A materialized view occupies a view's name, and a function occupies a procedure's on + /// several engines, so each pair is one object for the purpose of "is it already there". + func testViewsAndRoutinesFoldIntoOneFamilyEach() { + XCTAssertEqual( + ObjectCopyPlanner.objectKey(for: selection("sales", kind: .view)), + ObjectCopyPlanner.objectKey(for: selection("sales", kind: .materializedView)) + ) + XCTAssertEqual( + ObjectCopyPlanner.objectKey(for: selection("total", kind: .procedure)), + ObjectCopyPlanner.objectKey(for: selection("total", kind: .function)) + ) + } + + /// `f(integer)` and `f(text)` are two routines, and copying one must not be taken for the + /// other already being there. + func testTwoOverloadsAreTwoObjects() { + XCTAssertNotEqual( + ObjectCopyPlanner.objectKey(for: selection("f", kind: .function, signature: "(integer)")), + ObjectCopyPlanner.objectKey(for: selection("f", kind: .function, signature: "(text)")) + ) + } + + func testTwoTriggersOnDifferentTablesAreTwoObjects() { + XCTAssertNotEqual( + ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "orders")), + ObjectCopyPlanner.objectKey(for: selection("audit", kind: .trigger, owner: "customers")) + ) + } + + // MARK: - Retargeting foreign keys + + private func snapshot(referencedSchema: String?) -> TableStructureSnapshot { + TableStructureSnapshot( + name: "orders", + schema: "sales", + columns: [], + foreignKeys: [EditableForeignKeyDefinition( + id: UUID(), + name: "fk", + columns: ["customer_id"], + referencedTable: "customers", + referencedColumns: ["id"], + referencedSchema: referencedSchema, + onDelete: .noAction, + onUpdate: .noAction + )] + ) + } + + /// Left as it was, the copied child kept referencing the source's parent, so `prod_copy.orders` + /// stayed wired to `prod.customers` and the duplicate was never independent of its original. + func testAForeignKeyIntoTheSourceIsMovedToTheTarget() { + let moved = ObjectCopyPlanner.retargeted( + snapshot(referencedSchema: "sales"), from: "sales", to: "archive" + ) + + XCTAssertEqual(moved.schema, "archive") + XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "archive") + } + + /// A reference that names neither side's schema points at something the copy never touched. + func testAForeignKeyIntoAThirdSchemaIsLeftAlone() { + let moved = ObjectCopyPlanner.retargeted( + snapshot(referencedSchema: "reference"), from: "sales", to: "archive" + ) + + XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "reference") + } + + /// An unqualified reference means "my own schema", so it follows the table into the target. + func testAnUnqualifiedForeignKeyFollowsTheTable() { + let moved = ObjectCopyPlanner.retargeted( + snapshot(referencedSchema: nil), from: "sales", to: "archive" + ) + + XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "archive") + } + + func testASchemaThatDoesNotChangeLeavesTheSnapshotAlone() { + let moved = ObjectCopyPlanner.retargeted( + snapshot(referencedSchema: "sales"), from: "sales", to: "sales" + ) + + XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "sales") + } +} diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift new file mode 100644 index 000000000..cf239a448 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyRowCopierTests.swift @@ -0,0 +1,270 @@ +// +// ObjectCopyRowCopierTests.swift +// TableProTests +// +// The streaming write. Two things decide whether a copy is correct rather than +// merely finished: the batch never exceeds the engine's bind-parameter +// ceiling, and the parameters arrive in the order the placeholders expect. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +private final class CopyDriver: PluginDatabaseDriver, @unchecked Sendable { + var streamed: [[PluginCellValue]] = [] + var batchSize = 2 + var executedQueries: [String] = [] + var executedParameters: [[PluginCellValue]] = [] + var quote: (String) -> String = { "`\($0)`" } + var declaredParameterStyle: ParameterStyle = .questionMark + var transactionEvents: [String] = [] + + var parameterStyle: ParameterStyle { declaredParameterStyle } + + func connect() async throws {} + func disconnect() {} + + func execute(query: String) async throws -> PluginQueryResult { + executedQueries.append(query) + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult { + executedQueries.append(query) + executedParameters.append(parameters) + return PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + + func streamRows(query: String) -> AsyncThrowingStream { + let rows = streamed + let size = batchSize + return AsyncThrowingStream { continuation in + continuation.yield(.header(PluginStreamHeader(columns: ["id", "name"], columnTypeNames: []))) + var index = 0 + while index < rows.count { + let end = min(index + size, rows.count) + continuation.yield(.rows(Array(rows[index.. String { quote(name) } + + func beginTransaction() async throws { transactionEvents.append("begin") } + func commitTransaction() async throws { transactionEvents.append("commit") } + func rollbackTransaction() async throws { transactionEvents.append("rollback") } + + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +final class ObjectCopyRowCopierTests: XCTestCase { + private func step( + columns: [String] = ["id", "name"], + schema: String? = "public" + ) -> ObjectCopyTableStep { + ObjectCopyTableStep( + selection: ObjectCopySelection(kind: .table, name: "orders", schema: schema), + dropStatements: [], + createStatements: [], + truncateStatements: [], + columns: columns, + primaryKeyColumns: ["id"], + sourceQuery: "SELECT `id`, `name` FROM `public`.`orders`", + targetTable: "orders", + targetSchema: schema, + estimatedRows: nil, + copiesData: true, + copiesIdentityColumn: false, + note: nil + ) + } + + private func rows(_ count: Int) -> [[PluginCellValue]] { + (0.. PluginQueryResult { + PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0) + } + func quoteIdentifier(_ name: String) -> String { "\"\(name)\"" } + func fetchTables(schema: String?) async throws -> [PluginTableInfo] { [] } + func fetchColumns(table: String, schema: String?) async throws -> [PluginColumnInfo] { [] } + func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] { [] } + func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] { [] } + func fetchTableDDL(table: String, schema: String?) async throws -> String { "" } + func fetchViewDefinition(view: String, schema: String?) async throws -> String { "" } + func fetchTableMetadata(table: String, schema: String?) async throws -> PluginTableMetadata { + PluginTableMetadata(tableName: table) + } + func fetchDatabases() async throws -> [String] { [] } + func fetchDatabaseMetadata(_ database: String) async throws -> PluginDatabaseMetadata { + PluginDatabaseMetadata(name: database) + } +} + +final class ObjectCopySelectQueryTests: XCTestCase { + private let driver = QuotingDriver() + + func testColumnsAreNamedAndQuotedInOrder() { + XCTAssertEqual( + ObjectCopySelectQuery.build( + columns: ["id", "total"], table: "orders", schema: "public", driver: driver + ), + "SELECT \"id\", \"total\" FROM \"public\".\"orders\"" + ) + } + + func testAnUnqualifiedTableKeepsThePlainName() { + XCTAssertEqual( + ObjectCopySelectQuery.build(columns: ["id"], table: "orders", schema: nil, driver: driver), + "SELECT \"id\" FROM \"orders\"" + ) + } + + func testAnEmptySchemaIsTreatedAsNoSchema() { + XCTAssertEqual( + ObjectCopySelectQuery.build(columns: ["id"], table: "orders", schema: "", driver: driver), + "SELECT \"id\" FROM \"orders\"" + ) + } + + /// A structure-only step has no columns, and a star select is the honest fallback rather than + /// a `SELECT FROM`. + func testNoColumnsFallsBackToStar() { + XCTAssertEqual( + ObjectCopySelectQuery.build(columns: [], table: "orders", schema: nil, driver: driver), + "SELECT * FROM \"orders\"" + ) + } +} diff --git a/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift new file mode 100644 index 000000000..04f86f362 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopySessionTests.swift @@ -0,0 +1,281 @@ +// +// ObjectCopySessionTests.swift +// TableProTests +// +// What the sheet refuses, and what it builds when it does not. +// + +@testable import TablePro +import TableProPluginKit +import XCTest + +@MainActor +final class ObjectCopySessionTests: XCTestCase { + private let sourceConnectionId = UUID() + + private func connection(_ type: DatabaseType = .mysql) -> DatabaseConnection { + DatabaseConnection(id: sourceConnectionId, name: "Prod", type: type) + } + + private func endpoint( + _ database: String, + type: DatabaseType = .mysql, + safeMode: SafeModeLevel = .silent, + connectionId: UUID? = nil + ) -> DatabaseEndpoint { + DatabaseEndpoint( + scope: DatabaseScope( + connectionId: connectionId ?? sourceConnectionId, database: database, schema: nil + ), + connectionName: "Prod", + databaseType: type, + safeModeLevel: safeMode, + color: .blue + ) + } + + private func session( + mode: ObjectCopyMode = .copyTo, + preselected: [ObjectCopySelection] = [] + ) -> ObjectCopySession { + let session = ObjectCopySession( + mode: mode, + source: endpoint("shop"), + sourceConnection: connection(), + preselected: preselected + ) + session.availableObjects = [ + ObjectCopySelection(kind: .table, name: "orders", schema: nil), + ObjectCopySelection(kind: .table, name: "customers", schema: nil) + ] + session.selectedObjectIds = Set(session.availableObjects.map(\.id)) + /// Duplicate holds Continue until the destination's create-database options arrive, which + /// is what keeps it off an engine that cannot create one. These tests start past that. + session.createDatabaseFormState = .ready + return session + } + + // MARK: - Refusals + + func testCopyingNeedsAtLeastOneObject() { + let subject = session() + subject.selectedObjectIds = [] + + XCTAssertNotNil(subject.reviewDisabledReason) + XCTAssertNil(subject.request) + } + + func testCopyToNeedsATarget() { + XCTAssertNotNil(session().reviewDisabledReason) + } + + func testAReadOnlyTargetIsRefusedBeforeAnythingIsRead() { + let subject = session() + subject.target = endpoint("shop_copy", safeMode: .readOnly, connectionId: UUID()) + + XCTAssertNotNil(subject.reviewDisabledReason) + } + + func testCopyingIntoTheSourceIsRefused() { + let subject = session() + subject.target = endpoint("shop") + + XCTAssertNotNil(subject.reviewDisabledReason) + } + + func testAValidTargetProducesARequest() { + let subject = session() + subject.target = endpoint("shop_copy") + + XCTAssertNil(subject.reviewDisabledReason) + XCTAssertEqual(subject.request?.objects.count, 2) + XCTAssertEqual(subject.request?.target.database, "shop_copy") + } + + /// Neither half crosses engines. The row writer emits `INSERT … VALUES`, which a target of + /// another engine either cannot parse or reads against a namespace it does not have, so a + /// data-only copy is refused just as a structural one is. + func testNeitherHalfCrossesEngines() { + let subject = session() + subject.target = endpoint("analytics", type: .postgresql, connectionId: UUID()) + + subject.content = .structureAndData + XCTAssertNotNil(subject.reviewDisabledReason) + + subject.content = .data + XCTAssertNotNil(subject.reviewDisabledReason) + } + + // MARK: - Duplicate + + func testDuplicateSuggestsACopyName() { + XCTAssertEqual(ObjectCopySession.suggestedCopyName(for: "shop"), "shop_copy") + XCTAssertEqual(ObjectCopySession.suggestedCopyName(for: ""), "") + } + + func testDuplicateNeedsAName() { + let subject = session(mode: .duplicateDatabase) + subject.newDatabaseName = " " + + XCTAssertNotNil(subject.reviewDisabledReason) + } + + /// `CREATE DATABASE shop` against the database being read would fail, and a name differing only + /// in case is the same database on the engines that fold identifiers. + func testDuplicateRefusesTheSourcesOwnName() { + let subject = session(mode: .duplicateDatabase) + subject.newDatabaseName = "SHOP" + + XCTAssertNotNil(subject.reviewDisabledReason) + } + + func testDuplicateBuildsANewDatabaseDestination() { + let subject = session(mode: .duplicateDatabase) + subject.newDatabaseName = "shop_copy" + + guard case .newDatabase(_, let name, _) = subject.request?.destination else { + return XCTFail("Duplicate must create a database") + } + XCTAssertEqual(name, "shop_copy") + XCTAssertTrue(subject.request?.destination.createsDatabase ?? false) + XCTAssertEqual(subject.request?.target.database, "shop_copy") + } + + // MARK: - Selection + + func testSearchNarrowsTheListWithoutChangingTheSelection() { + let subject = session() + subject.searchText = "ord" + + XCTAssertEqual(subject.filteredObjects.map(\.name), ["orders"]) + XCTAssertEqual(subject.selectedObjects.count, 2) + } + + // MARK: - Create-database options + + /// MySQL's `createDatabase` needs a character set, so starting before the form answered sent a + /// request with no values that was guaranteed to be refused. + func testDuplicateWaitsForTheCreateDatabaseOptions() { + let subject = session(mode: .duplicateDatabase) + subject.newDatabaseName = "shop_copy" + subject.createDatabaseFormState = .loading + + XCTAssertNotNil(subject.reviewDisabledReason) + } + + /// A driver with no create-database form is one that cannot create a database, which is how + /// Duplicate stays off DuckDB, Trino and Teradata after the menu has optimistically shown it. + func testDuplicateRefusesAnEngineThatCannotCreateDatabases() { + let subject = session(mode: .duplicateDatabase) + subject.newDatabaseName = "shop_copy" + subject.createDatabaseFormState = .unsupported + + XCTAssertNotNil(subject.reviewDisabledReason) + } + + func testDuplicateSurfacesAFailedOptionsRead() { + let subject = session(mode: .duplicateDatabase) + subject.newDatabaseName = "shop_copy" + subject.createDatabaseFormState = .failed("Connection refused") + + XCTAssertEqual(subject.reviewDisabledReason, "Connection refused") + } + + /// None clears only what is on screen, so a filtered list cannot silently drop the objects the + /// filter is hiding. + func testNoneOnlyClearsTheFilteredObjects() { + let subject = session() + subject.searchText = "ord" + subject.selectNone() + + XCTAssertEqual(subject.selectedObjects.map(\.name), ["customers"]) + } + + /// The preselection is what the user right-clicked. Matching it on the name alone selected a + /// function and a trigger that happened to share the table's name, and matching nothing at all + /// selected the whole database: with Replace that acted on objects nobody chose. + func testPreselectionTakesOnlyTheClickedKindAndName() { + let subject = session(preselected: [ + ObjectCopySelection(kind: .table, name: "orders", schema: nil) + ]) + subject.availableObjects = [ + ObjectCopySelection(kind: .table, name: "orders", schema: nil), + ObjectCopySelection(kind: .trigger, name: "orders", schema: nil, owner: "orders"), + ObjectCopySelection(kind: .table, name: "customers", schema: nil) + ] + subject.applyPreselectionForTesting() + + XCTAssertEqual(subject.selectedObjects.map(\.kind), [.table]) + XCTAssertEqual(subject.selectedObjects.map(\.name), ["orders"]) + } + + func testAPreselectionThatMatchesNothingSelectsNothing() { + let subject = session(preselected: [ + ObjectCopySelection(kind: .table, name: "gone", schema: nil) + ]) + subject.availableObjects = [ + ObjectCopySelection(kind: .table, name: "orders", schema: nil), + ObjectCopySelection(kind: .table, name: "customers", schema: nil) + ] + subject.applyPreselectionForTesting() + + XCTAssertTrue( + subject.selectedObjectIds.isEmpty, + "an unmatched request must never fall back to the whole database" + ) + } + + /// A right-click on a database carries no preselection, and that is what "copy this database" + /// means. + func testNoPreselectionSelectsEverything() { + let subject = session(preselected: []) + subject.applyPreselectionForTesting() + + XCTAssertEqual(subject.selectedObjectIds.count, subject.availableObjects.count) + } + + func testTogglingFlipsOneObject() { + let subject = session() + guard let first = subject.availableObjects.first else { return XCTFail("no objects") } + + subject.toggle(first) + XCTAssertFalse(subject.selectedObjectIds.contains(first.id)) + + subject.toggle(first) + XCTAssertTrue(subject.selectedObjectIds.contains(first.id)) + } + + // MARK: - Content + + func testDataOnlyLeavesStructureOut() { + XCTAssertFalse(ObjectCopyContent.data.includesStructure) + XCTAssertTrue(ObjectCopyContent.data.includesData) + XCTAssertTrue(ObjectCopyContent.structure.includesStructure) + XCTAssertFalse(ObjectCopyContent.structure.includesData) + XCTAssertTrue(ObjectCopyContent.structureAndData.includesStructure) + XCTAssertTrue(ObjectCopyContent.structureAndData.includesData) + } + + func testOnlyReplaceDropsTheTargetsObject() { + XCTAssertTrue(ObjectCopyExistingPolicy.replace.dropsTargetObject) + XCTAssertFalse(ObjectCopyExistingPolicy.skip.dropsTargetObject) + XCTAssertFalse(ObjectCopyExistingPolicy.appendData.dropsTargetObject) + } + + func testTablesAndSourceDefinedObjectsAreSeparated() { + let request = ObjectCopyRequest( + source: endpoint("shop"), + destination: .existing(endpoint("shop_copy")), + objects: [ + ObjectCopySelection(kind: .table, name: "orders", schema: nil), + ObjectCopySelection(kind: .view, name: "active", schema: nil), + ObjectCopySelection(kind: .trigger, name: "audit", schema: nil) + ], + content: .structureAndData, + existingPolicy: .skip + ) + + XCTAssertEqual(request.tables.map(\.name), ["orders"]) + XCTAssertEqual(request.sourceDefinedObjects.map(\.name), ["active", "audit"]) + } +} diff --git a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift index 2ef6484fd..780dba16e 100644 --- a/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift +++ b/TableProTests/Views/Sidebar/DatabaseTreeMenuSpecTests.swift @@ -31,7 +31,9 @@ struct DatabaseTreeMenuSpecTests { canReachOtherDatabases: Bool = true, canFilterDatabases: Bool = false, hasDatabaseFilter: Bool = false, - supportsRename: Bool = true + supportsRename: Bool = true, + canCopyObjects: Bool = true, + canDuplicateDatabase: Bool = true ) -> DatabaseTreeMenuContext { DatabaseTreeMenuContext( clicked: clicked, @@ -72,7 +74,9 @@ struct DatabaseTreeMenuSpecTests { showObjectComments: false, rowSize: .matchSystem, canFilterDatabases: canFilterDatabases, - hasDatabaseFilter: hasDatabaseFilter + hasDatabaseFilter: hasDatabaseFilter, + canCopyObjects: canCopyObjects, + canDuplicateDatabase: canDuplicateDatabase ) } @@ -514,4 +518,120 @@ struct DatabaseTreeMenuSpecTests { #expect(!DatabaseTreeMenuSpec.items(for: context(clicked: kind, isReadOnly: true)).isEmpty) } } + + // MARK: - Copying + + private func databaseKind(_ name: String, isSystem: Bool = false) -> DatabaseTreeNode.Kind { + .database(DatabaseMetadata.minimal(name: name, isSystem: isSystem)) + } + + @Test("A table row offers Copy To") + func tableOffersCopyTo() { + let ref = tableRef("orders") + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(ref), selectedTables: [ref]) + )) + + #expect(issued.contains { command in + guard case .copyObjectsTo(let objects, _) = command else { return false } + return objects.map(\.name) == ["orders"] + }) + } + + /// A right-click inside a multi-selection acts on the whole selection, the same rule Export, + /// Truncate and Drop already keep. + @Test("Copy To on a multi-selection carries every table in it") + func copyToCarriesTheSelection() { + let orders = tableRef("orders") + let customers = tableRef("customers") + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(orders), selectedTables: [orders, customers]) + )) + + let names = issued.compactMap { command -> [String]? in + guard case .copyObjectsTo(let objects, _) = command else { return nil } + return objects.map(\.name).sorted() + } + #expect(names == [["customers", "orders"]]) + } + + /// A view holds rows a copy can read, so it takes part, but it is copied as its definition + /// rather than as columns. + @Test("A view row is offered as a view rather than as a table") + func viewIsOfferedAsAView() { + let ref = tableRef("active_users", type: .view) + let issued = commands(DatabaseTreeMenuSpec.items( + for: context(clicked: .table(ref), selectedTables: [ref]) + )) + + #expect(issued.contains { command in + guard case .copyObjectsTo(let objects, _) = command else { return false } + return objects.first?.kind == .view + }) + } + + @Test("An engine that cannot copy offers neither command") + func ineligibleEngineHidesCopying() { + let ref = tableRef("orders") + let tableCommands = commands(DatabaseTreeMenuSpec.items(for: context( + clicked: .table(ref), selectedTables: [ref], canCopyObjects: false + ))) + let databaseCommands = commands(DatabaseTreeMenuSpec.items(for: context( + clicked: databaseKind("app"), + selectedContainers: [.database("app")], + canCopyObjects: false, + canDuplicateDatabase: false + ))) + + #expect(!tableCommands.contains { if case .copyObjectsTo = $0 { return true } else { return false } }) + #expect(!databaseCommands.contains { if case .copyContainerTo = $0 { return true } else { return false } }) + #expect(!databaseCommands.contains { if case .duplicateDatabase = $0 { return true } else { return false } }) + } + + @Test("A database row offers Copy To and Duplicate Database") + func databaseOffersCopyAndDuplicate() { + let issued = commands(DatabaseTreeMenuSpec.items(for: context( + clicked: databaseKind("app"), selectedContainers: [.database("app")] + ))) + + #expect(issued.contains(.copyContainerTo(.database("app")))) + #expect(issued.contains(.duplicateDatabase(.database("app")))) + } + + /// `CREATE DATABASE information_schema` is not a thing anyone wants offered. + @Test("A system database is not offered for duplication") + func systemDatabaseIsNotDuplicated() { + let issued = commands(DatabaseTreeMenuSpec.items(for: context( + clicked: databaseKind("information_schema", isSystem: true), + selectedContainers: [.database("information_schema", isSystem: true)] + ))) + + #expect(!issued.contains { if case .duplicateDatabase = $0 { return true } else { return false } }) + } + + /// No engine creates a schema from a `CREATE DATABASE`, so a schema is copied into one that + /// already exists rather than duplicated. + @Test("A schema row offers Copy To but not Duplicate Database") + func schemaOffersCopyOnly() { + let schema = DatabaseContainerRef.schema(database: "app", schema: "sales") + let issued = commands(DatabaseTreeMenuSpec.items(for: context( + clicked: .schema(database: "app", schema: "sales"), selectedContainers: [schema] + ))) + + #expect(issued.contains(.copyContainerTo(schema))) + #expect(!issued.contains { if case .duplicateDatabase = $0 { return true } else { return false } }) + } + + /// A copy names one source and one target, so two databases selected at once would need a + /// target each. + @Test("A multi-container selection offers neither copy command") + func multipleContainersHideCopying() { + let issued = commands(DatabaseTreeMenuSpec.items(for: context( + clicked: databaseKind("app"), + selectedContainers: [.database("app"), .database("archive")] + ))) + + #expect(!issued.contains { if case .copyContainerTo = $0 { return true } else { return false } }) + #expect(!issued.contains { if case .duplicateDatabase = $0 { return true } else { return false } }) + } } diff --git a/TableProUITests/CopyObjectsUITests.swift b/TableProUITests/CopyObjectsUITests.swift new file mode 100644 index 000000000..76b1a3091 --- /dev/null +++ b/TableProUITests/CopyObjectsUITests.swift @@ -0,0 +1,75 @@ +import XCTest + +/// Issue #2487. Copy To has to be reachable from the object browser's own contextual menu, and the +/// sheet it opens has to name the source and refuse to continue before a target is chosen. +/// +/// The sample database is SQLite, which has one database and no `CREATE DATABASE`, so Duplicate +/// Database is deliberately not exercised here: it needs a server this runner does not have. Its +/// contract is asserted without one in `DatabaseTreeMenuSpecTests` and `ObjectCopySessionTests`. +final class CopyObjectsUITests: UITestCase { + private let copyToTitle = "Copy To…" + + func testCopyToIsOnATableRowsContextualMenu() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + + openContextMenu(onRow: "Album", in: window, of: app) + + let item = app.menuItems[copyToTitle] + XCTAssertTrue( + item.waitToExist(timeout: 10), + "Copy To must be reachable from a table row's contextual menu" + ) + dismissMenu(in: app) + } + + func testChoosingCopyToOpensTheSheetNamingTheSource() throws { + let app = try launchWithSampleDatabase() + let window = try readyWindow(of: app) + + openContextMenu(onRow: "Album", in: window, of: app) + let item = app.menuItems[copyToTitle] + XCTAssertTrue(item.waitToExist(timeout: 10)) + item.click() + + let objects = window.descendants(matching: .any) + .matching(identifier: "copy-objects-list").firstMatch + let target = window.descendants(matching: .any) + .matching(identifier: "copy-objects-target").firstMatch + XCTAssertTrue( + waitForPredicate(timeout: 20) { objects.exists || target.exists }, + "Copy To must open a sheet offering the objects and a target" + ) + + /// Nothing has been written and nothing can be: the sheet is still on its first step, so + /// Cancel is the whole interaction under test. + let cancel = window.buttons["Cancel"].firstMatch + if cancel.waitToExist(timeout: 10) { + cancel.click() + } + } + + // MARK: - Helpers + + private func readyWindow(of app: XCUIApplication) throws -> XCUIElement { + let window = app.windows.firstMatch + XCTAssertTrue(window.waitToExist(timeout: 30)) + XCTAssertTrue( + waitForPredicate(timeout: 30) { window.outlines.firstMatch.outlineRows.count > 1 }, + "The object browser must list the sample database's tables" + ) + return window + } + + /// The tree's rows are hosted cells that AppKit reports as disabled, so the menu is raised + /// through a coordinate rather than through the element. + private func openContextMenu(onRow name: String, in window: XCUIElement, of app: XCUIApplication) { + let row = objectBrowserRow(name, in: window) + XCTAssertTrue(row.waitToExist(timeout: 20), "The object browser must list \(name)") + row.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).rightClick() + } + + private func dismissMenu(in app: XCUIApplication) { + app.typeKey(.escape, modifierFlags: []) + } +} diff --git a/docs/docs.json b/docs/docs.json index b8aab7c85..7d10ba71d 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -216,6 +216,7 @@ "icon": "arrow-right-arrow-left", "pages": [ "features/import-export", + "features/copy-objects", "features/backup-restore", "features/compare-sync" ] diff --git a/docs/features/copy-objects.mdx b/docs/features/copy-objects.mdx new file mode 100644 index 000000000..07cc4e97b --- /dev/null +++ b/docs/features/copy-objects.mdx @@ -0,0 +1,139 @@ +--- +title: Copy and duplicate +description: Copy tables and databases to another database or another connection, structure, data, or both +--- + +Right-click what you want and pick where it goes. Rows stream from one connection to the other in +batches, so a table larger than memory copies at the same cost as a small one, and nothing is +written until you have read the script. + +## Opening it + + + + A table, a view, or a multiple selection of them offers **Copy To…**. A database row offers + **Copy To…** and **Duplicate Database…**. A schema row offers **Copy To…**. + + **Database > Copy Objects To…** and **Database > Duplicate Database…** reach the same sheet + from the keyboard, acting on the database being browsed. + + + **Copy To…** opens a picker that walks connection, then database, then schema, the same picker + Compare & Sync uses. **Duplicate Database…** asks for a name instead and creates the database on + the connection you right-clicked. + + + Structure only, data only, or both. Tick the objects taking part. + + + **Continue** reads both databases and shows the DDL that will run, the rows each table expects, + and anything left out. **Copy** is the first thing that writes. + + + +## What each object carries + +| Kind | Structure | Data | +|---|---|---| +| Tables | Columns, primary key, indexes, foreign keys, storage engine and collation | Every row, streamed in batches | +| Views, materialized views | The source's definition | None to carry | +| Procedures, functions | The source's definition | None to carry | +| Triggers | The source's definition | None to carry | + +A data-only copy leaves views, routines and triggers out and says so in the review step: they hold +no rows. + +Generated and computed columns are dropped from the write. The server recomputes them, and every +engine that has them rejects an `INSERT` that names one. + +## When the target already has the object + +Chosen once, before the run, and applied to every object in it. + +| Choice | Structure and data | Data only | +|---|---|---| +| **Skip it** | The object is left out | The object is left out | +| **Replace it** | The target's object is dropped and built again | The target's rows are removed first | +| **Add rows to it** | The target keeps its structure, rows are appended | Rows are appended | + +**Add rows to it** writes only the columns both sides have, matched without regard to case. A column +the target does not have is not written; one the source does not have keeps its default. + +Pick **Skip it** unless you are refreshing a copy you made earlier. It is the only one of the three +that cannot lose anything already in the target. + +## One engine, one schema + +A copy stays inside one engine. Column data types are the driver's own strings and the row writer +emits that driver's own SQL, so neither structure nor data crosses from MySQL to PostgreSQL. The +sheet refuses before it reads anything. MySQL and MariaDB count as one engine. + +On PostgreSQL, SQL Server and the other engines that keep objects in schemas, copy one schema at a +time. A whole-database copy taken without a schema reads only the connection's current one, so the +sheet asks for a schema instead of carrying part of the database and calling it the whole. + +Views, routines and triggers copy only into a schema of the same name. Their definition is the +source's own SQL text and nothing rewrites the tables it names, so into a differently named schema +it would point back at the source. They are left out with the reason shown. + +## Ordering and foreign keys + +Everything is torn down children first, so a foreign key is gone before the table it points at, then +built parents first. Rows are copied after that, and triggers go in last: a trigger installed before +the rows fires on the copy itself. + +A copied foreign key is repointed at the copy. A key that referenced the source's own schema +references the target's afterwards, so the duplicate stands on its own; one that referenced a third +schema is left as it was. + +Two tables that reference each other cannot be ordered. The second `CREATE TABLE` fails, its error +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. + +A stopped copy reports only what was committed. The table it was in the middle of counts as neither +copied nor failed. + +## Duplicating a database + +**Duplicate Database…** creates the new database with the character set and collation the engine +offers, then copies every object into it. The name is prefilled with the source name plus `_copy`. + +Available on engines that have databases to create. SQLite, DuckDB and the other single-file engines +have none, so the command does not appear on them. + +## Limitations + +Copying is SQL only. MongoDB, Redis, DynamoDB, Elasticsearch, Kafka, etcd and SurrealDB have no +`CREATE TABLE` and no row writer to copy through, so neither command appears on them. + +Duplicate Database needs a driver that creates one. Where it does not, the command still appears and +the sheet names the engine that cannot rather than failing part way in. + +Identity and auto-increment values are written as they are. On SQL Server and on PostgreSQL columns +declared `GENERATED ALWAYS AS IDENTITY`, the server refuses an explicit value and the table's error +appears in the result. Copy the structure, then the data with the identity column removed from the +target, or use **Add rows to it** against a table whose key is plain. + +PostgreSQL columns declared `SERIAL` carry a default that names a sequence. The sequence is not +copied, so those tables need theirs created first. + +Two databases on one connection can be copied between only where the driver opens a second +connection of its own. DuckDB and PGlite hold their database inside the driver instance, so the +sheet refuses and names the reason. + +Dameng and Teradata read a table whole before the first batch is written, because their drivers do +not stream rows. On those two, a table larger than memory is a table this cannot copy, and **Stop** +waits for the read. + +Read-only connections are dimmed in the target picker with the reason shown. + +## Related + +- [Compare & Sync](/features/compare-sync) for bringing two databases that both already exist into + line, statement by statement +- [Import and export](/features/import-export) for moving data through a file From 37e67d11df6710ed53127155a6bac308cd55aefd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 28 Aug 2026 22:43:23 +0700 Subject: [PATCH 2/2] fix(sidebar): model the namespace an engine qualifies copied objects with --- CHANGELOG.md | 4 +- .../Core/Compare/TableStructureSnapshot.swift | 16 + .../Core/ObjectCopy/ObjectCopyCatalog.swift | 34 +- .../ObjectCopy/ObjectCopyEligibility.swift | 50 +-- .../Core/ObjectCopy/ObjectCopyNamespace.swift | 53 +++ TablePro/Core/ObjectCopy/ObjectCopyPlan.swift | 15 +- .../Core/ObjectCopy/ObjectCopyPlanner.swift | 356 +++++++++++++----- .../Core/ObjectCopy/ObjectCopyRowCopier.swift | 10 +- .../Core/ObjectCopy/ObjectCopyRunner.swift | 182 ++++++--- .../Core/ObjectCopy/ObjectCopySession.swift | 11 +- ...ontentCommandActions+DatabaseObjects.swift | 4 +- .../Sidebar/Menu/DatabaseTreeMenuSpec.swift | 26 +- .../ObjectCopyEligibilityTests.swift | 77 ++-- .../ObjectCopyPlannerOrderingTests.swift | 68 +++- docs/customization/notifications.mdx | 1 + docs/features/copy-objects.mdx | 24 +- 16 files changed, 709 insertions(+), 222 deletions(-) create mode 100644 TablePro/Core/ObjectCopy/ObjectCopyNamespace.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 3bdbf6668..0cf3ee409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,9 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - JavaScript shell for MongoDB queries, with mongosh's `db` API, cursors, variables, functions and `print`. - Per-connection MongoDB shell state, so a variable or function survives from one statement to the next. - Cursor method autocomplete after `find()` and `aggregate()`. -- Copy To on a table, a view, a schema and a database row, writing to any connection. (#2487) -- Duplicate Database on the sidebar's database rows, with the engine's own character set and collation. (#2487) -- Structure, data or both, with the script shown before anything runs. (#2487) +- Copy To and Duplicate Database in the sidebar and the Database menu, carrying structure, data or both to any connection. (#2487) ### Changed diff --git a/TablePro/Core/Compare/TableStructureSnapshot.swift b/TablePro/Core/Compare/TableStructureSnapshot.swift index 68e66e2ca..fae961690 100644 --- a/TablePro/Core/Compare/TableStructureSnapshot.swift +++ b/TablePro/Core/Compare/TableStructureSnapshot.swift @@ -50,6 +50,22 @@ internal struct TableStructureSnapshot: Hashable { guard let schema, !schema.isEmpty else { return name } return "\(schema).\(name)" } + + /// The same table, said to live somewhere else. A copy reads one namespace and writes another, + /// and the DDL it generates has to name the one it is writing. + internal func placed(in schema: String?) -> TableStructureSnapshot { + guard schema != self.schema else { return self } + return TableStructureSnapshot( + name: name, + schema: schema, + columns: columns, + indexes: indexes, + foreignKeys: foreignKeys, + engine: engine, + charset: charset, + collation: collation + ) + } } internal extension TableStructureSnapshot { diff --git a/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift b/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift index d6217020b..7c26c2c2b 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift @@ -28,16 +28,42 @@ internal struct ObjectCopyCatalog { self.manager = manager } + /// Every object in the endpoint's scope, and on a schema-aware engine that names no schema, + /// every object in every schema of the database. + /// + /// `fetchTables(schema: nil)` resolves to the connection's current schema on PostgreSQL and + /// SQL Server, so a whole-database copy taken that way carried one schema and reported success + /// over the rest. Each selection keeps the schema it was found in, which is what lets the + /// planner read and write them one namespace at a time. internal func objects( in endpoint: DatabaseEndpoint, connection: DatabaseConnection ) async throws -> [ObjectCopySelection] { try await manager.ensureConnected(connection) - let schema = endpoint.schema - return try await manager.withMetadataDriver(scope: endpoint.scope, workload: .bulk) { driver in - guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [] } - return try await Self.read(from: plugin, schema: schema) + let scopes = try await namespaces(of: endpoint, connection: connection) + var found: [ObjectCopySelection] = [] + for scope in scopes { + found += try await manager.withMetadataDriver( + scope: endpoint.withSchema(scope).scope, workload: .bulk + ) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { return [] } + return try await Self.read(from: plugin, schema: scope) + } } + return found + } + + /// The schemas one read has to cover. A single nil is "whatever the connection is on", which is + /// the right answer for every engine without schemas and for a scope that already names one. + internal func namespaces( + of endpoint: DatabaseEndpoint, + connection: DatabaseConnection + ) async throws -> [String?] { + guard PluginManager.shared.supportsSchemaSwitching(for: endpoint.databaseType), + (endpoint.schema ?? "").isEmpty + else { return [endpoint.schema?.nilIfEmpty] } + let found = try await schemas(in: endpoint, connection: connection) + return found.isEmpty ? [nil] : found.map { $0 } } nonisolated private static func read( diff --git a/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift index b10ae26c7..2da181e77 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyEligibility.swift @@ -74,37 +74,37 @@ internal enum ObjectCopyEligibility { return String(localized: "The source and the target are the same database. Choose a different target.") } - /// A database-wide copy on an engine that has schemas has to name one. - /// - /// `fetchTables(schema: nil)` resolves to the connection's current schema on PostgreSQL and - /// SQL Server, so a whole-database copy taken without a schema silently carried one schema's - /// objects and left every other schema behind while reporting success. - internal static func unscopedSchemaRefusal( - endpoint: DatabaseEndpoint, - supportsSchemas: Bool - ) -> String? { - guard supportsSchemas, (endpoint.schema ?? "").isEmpty else { return nil } - return String( - format: String(localized: "%@ keeps its objects in schemas. Copy one schema at a time."), - endpoint.databaseType.rawValue - ) - } - /// 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 table it - /// names stays spelled the way the source spelled it. Running that against a different schema + /// Its definition is the source's own SQL text, and nothing here parses it, so every object it + /// names stays qualified the way the source qualified it. Run against a different namespace it /// either recreates the object pointing back at the source or, for a replacement, drops the - /// source's own object. Copying one is therefore only sound into the same schema name. - internal static func canCopyDefinition(sourceSchema: String?, targetSchema: String?) -> Bool { - let source = (sourceSchema ?? "").lowercased() - let target = (targetSchema ?? "").lowercased() - return source == target + /// source's own. Copying one is sound only where both sides share a namespace: the same schema + /// name on PostgreSQL, which a duplicate keeps, and never across two MySQL databases, whose + /// DDL carries the database name. + internal static func canCopyDefinition(sourceNamespace: String?, targetNamespace: String?) -> Bool { + ObjectCopyNamespace.isSame(sourceNamespace, targetNamespace) } - internal static var definitionSchemaRefusal: String { + internal static var definitionNamespaceRefusal: String { String( - localized: "Its definition names the source's own schema, so it is only copied into a schema of the same name." + localized: "Its definition names the source's own database or schema, so it is only copied where that name is the same." ) } + + /// A definition the driver reports as a bare body rather than as a statement. + /// + /// ClickHouse, Oracle, Dameng and BigQuery answer `fetchViewDefinition` with the view's SELECT, + /// not its `CREATE`. Executing that runs a read, which the runner would then report as the view + /// copied, after Replace had already dropped the target's. + internal static func isExecutableDefinition(_ definition: String) -> Bool { + definition + .trimmingCharacters(in: .whitespacesAndNewlines) + .uppercased() + .hasPrefix("CREATE") + } + + internal static var definitionNotExecutableRefusal: String { + String(localized: "This driver reports its body rather than a statement that recreates it.") + } } diff --git a/TablePro/Core/ObjectCopy/ObjectCopyNamespace.swift b/TablePro/Core/ObjectCopy/ObjectCopyNamespace.swift new file mode 100644 index 000000000..9ca60d965 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyNamespace.swift @@ -0,0 +1,53 @@ +// +// ObjectCopyNamespace.swift +// TablePro +// +// The name an engine qualifies its objects with. +// +// Not the same question as "which schema is selected". MySQL and MariaDB have +// no schemas at all, yet `information_schema` reports the database in the +// schema column, so their foreign keys, routines and triggers come back +// qualified by the database name and their DDL is written that way. Reading +// `endpoint.schema` there answers nil, which silently dropped every foreign +// key edge from the dependency sort and made the same-namespace test that +// guards definition copying compare nil against nil on two different +// databases. +// + +import Foundation +import TableProPluginKit + +internal enum ObjectCopyNamespace { + /// What this engine calls the namespace of the objects at `endpoint`. + /// + /// A schema where the engine has schemas, the database where it has databases but no schemas, + /// and nothing at all where it has neither. Derived from the two capabilities the plugin + /// registry already publishes rather than from a list of engine names, so a driver added later + /// answers without being enumerated here. + internal static func name( + for endpoint: DatabaseEndpoint, + supportsSchemas: Bool, + supportsDatabases: Bool + ) -> String? { + if supportsSchemas { return endpoint.schema?.nilIfEmpty } + guard supportsDatabases else { return nil } + return endpoint.database.nilIfEmpty + } + + /// Whether two endpoints put their objects in the same namespace, which is what decides + /// whether a definition written against the source resolves the same way in the target. + internal static func isSame(_ lhs: String?, _ rhs: String?) -> Bool { + (lhs ?? "").lowercased() == (rhs ?? "").lowercased() + } +} + +@MainActor +internal extension ObjectCopyNamespace { + static func name(for endpoint: DatabaseEndpoint) -> String? { + name( + for: endpoint, + supportsSchemas: PluginManager.shared.supportsSchemaSwitching(for: endpoint.databaseType), + supportsDatabases: PluginManager.shared.supportsDatabaseSwitching(for: endpoint.databaseType) + ) + } +} diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift index 42b8bb3a4..f0e634e7c 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift @@ -69,7 +69,9 @@ internal struct ObjectCopyDefinitionStep: Identifiable, Sendable { /// A trigger fires on the rows the copy is about to write, so installing one before the data /// phase makes the copy trip it: an audit trigger writes a second row for every row copied, /// and a validating one rejects rows the source already holds. - internal var runsAfterData: Bool { selection.kind == .trigger } + internal var runsAfterData: Bool { + selection.kind == .trigger || selection.kind == .materializedView + } } /// One object's statements for one phase, so a failure is attributed to the object that caused it. @@ -149,7 +151,16 @@ internal struct ObjectCopyPlan: Sendable { .map { ObjectCopyStatementGroup($0.selection, $0.createStatements) } } - /// The definitions held back until the rows are in. + /// Emptying the tables a data-only replace appends to, children first so a foreign key holds. + internal var clearGroups: [ObjectCopyStatementGroup] { + tableSteps.reversed() + .filter { !$0.truncateStatements.isEmpty } + .map { ObjectCopyStatementGroup($0.selection, $0.truncateStatements) } + } + + /// 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. internal var afterDataGroups: [ObjectCopyStatementGroup] { definitionSteps.filter(\.runsAfterData) .map { ObjectCopyStatementGroup($0.selection, $0.createStatements) } diff --git a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift index 9db4f1964..1518955aa 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -40,24 +40,48 @@ internal struct ObjectCopyPlanner { try await manager.ensureConnected(connections.target) try refuseUpFront(request) - let names = Set(request.objects.map(\.name)) - let sourceReads = try await metadata.tableReads( - for: request.source, connection: connections.source, includeViews: true, names: names - ) - let targetReads = try await existingTargetReads(request, connection: connections.target, names: names) - let targetObjects = try await existingTargetObjects(request, connection: connections.target) - + /// One pass per source namespace. A database-level copy on PostgreSQL spans every schema, + /// and each schema's tables have to be read, ordered and written in their own scope: one + /// read against a nil schema answers only whatever the connection is currently on. var skipped: [ObjectCopySkip] = [] - let tableSteps = try await buildTableSteps( - request, sourceReads: sourceReads, targetReads: targetReads, skipped: &skipped - ) - let definitionSteps = try await buildDefinitionSteps( - request, - sourceReads: sourceReads, - targetObjects: targetObjects, - connection: connections.source, - skipped: &skipped - ) + var tableSteps: [ObjectCopyTableStep] = [] + var definitionSteps: [ObjectCopyDefinitionStep] = [] + + 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)) + + let sourceReads = try await metadata.tableReads( + for: sourceEndpoint, connection: connections.source, includeViews: true, names: names + ) + let targetReads = try await existingTargetReads( + request, endpoint: targetEndpoint, connection: connections.target, names: names + ) + let targetObjects = try await existingTargetObjects( + request, endpoint: targetEndpoint, connection: connections.target + ) + + tableSteps += try await buildTableSteps( + request, + scope: scope, + sourceEndpoint: sourceEndpoint, + targetEndpoint: targetEndpoint, + sourceReads: sourceReads, + targetReads: targetReads, + skipped: &skipped + ) + definitionSteps += try await buildDefinitionSteps( + request, + scope: scope, + sourceEndpoint: sourceEndpoint, + targetEndpoint: targetEndpoint, + sourceReads: sourceReads, + targetObjects: targetObjects, + connection: connections.source, + skipped: &skipped + ) + } return ObjectCopyPlan( request: request, @@ -68,6 +92,30 @@ internal struct ObjectCopyPlanner { ) } + /// The selected objects grouped by the namespace they were found in. + internal struct Scope { + internal let namespace: String? + internal let objects: [ObjectCopySelection] + + /// Where this namespace's objects land. A duplicate keeps every schema name, so its + /// objects go into a schema of the same name in the new database; a copy to a chosen + /// target puts them all in the schema that was chosen. + internal func targetNamespace(for request: ObjectCopyRequest) -> String? { + request.destination.createsDatabase ? namespace : request.target.schema + } + } + + nonisolated internal static func scopes(of request: ObjectCopyRequest) -> [Scope] { + var order: [String] = [] + var grouped: [String: [ObjectCopySelection]] = [:] + for object in request.objects { + let key = object.schema ?? "" + if grouped[key] == nil { order.append(key) } + grouped[key, default: []].append(object) + } + return order.map { Scope(namespace: $0.isEmpty ? nil : $0, objects: grouped[$0] ?? []) } + } + // MARK: - Refusals private struct Connections { @@ -103,12 +151,6 @@ internal struct ObjectCopyPlanner { ) { throw ObjectCopyError.refused(reason) } - let supportsSchemas = PluginManager.shared.supportsSchemaSwitching(for: request.source.databaseType) - if let reason = ObjectCopyEligibility.unscopedSchemaRefusal( - endpoint: request.source, supportsSchemas: supportsSchemas - ) { - throw ObjectCopyError.refused(reason) - } guard request.content.includesData else { return } if let reason = CompareRowService(manager: manager) .concurrentReadRefusal(source: request.source, target: request.target) { @@ -121,12 +163,13 @@ internal struct ObjectCopyPlanner { /// that does not exist yet is an error rather than an empty answer. private func existingTargetReads( _ request: ObjectCopyRequest, + endpoint: DatabaseEndpoint, connection: DatabaseConnection, names: Set ) async throws -> [TableStructureRead] { guard !request.destination.createsDatabase else { return [] } return try await metadata.tableReads( - for: request.target, connection: connection, includeViews: true, names: names + for: endpoint, connection: connection, includeViews: true, names: names ) } @@ -139,11 +182,15 @@ internal struct ObjectCopyPlanner { /// one and only the trigger's own presence decides the trigger's step. private func existingTargetObjects( _ request: ObjectCopyRequest, + endpoint: DatabaseEndpoint, connection: DatabaseConnection - ) async throws -> Set { - guard !request.destination.createsDatabase else { return [] } - let found = try await catalog.objects(in: request.target, connection: connection) - return Set(found.map { Self.objectKey(for: $0) }) + ) async throws -> [String: ObjectCopySelection] { + guard !request.destination.createsDatabase else { return [:] } + let found = try await catalog.objects(in: endpoint, connection: connection) + return Dictionary( + found.map { (Self.objectKey(for: $0), $0) }, + uniquingKeysWith: { first, _ in first } + ) } /// A materialized view and a view are one object to the engines that have both, and a @@ -166,12 +213,15 @@ internal struct ObjectCopyPlanner { private func buildTableSteps( _ request: ObjectCopyRequest, + scope: Scope, + sourceEndpoint: DatabaseEndpoint, + targetEndpoint: DatabaseEndpoint, sourceReads: [TableStructureRead], targetReads: [TableStructureRead], skipped: inout [ObjectCopySkip] ) async throws -> [ObjectCopyTableStep] { var reads: [ObjectCopySelection: TableStructureRead] = [:] - for selection in request.tables { + for selection in scope.objects.filter({ $0.kind.carriesRows }) { guard let read = match(selection, in: sourceReads) else { skipped.append(ObjectCopySkip(selection: selection, reason: Self.missingInSource)) continue @@ -184,10 +234,14 @@ internal struct ObjectCopyPlanner { } guard !reads.isEmpty else { return [] } - let sourceSchema = request.source.schema + /// The engine's own namespace, not the endpoint's schema. MySQL reports no schema on a + /// table while its foreign keys carry the database name, so ordering by the schema alone + /// matched no edge and fell through to alphabetical order. + let sourceNamespace = ObjectCopyNamespace.name(for: sourceEndpoint) + let targetNamespace = ObjectCopyNamespace.name(for: targetEndpoint) var drafts: [ObjectCopyTableDraft] = [] for selection in Self.orderedByDependency( - Array(reads.keys), reads: reads, effectiveSchema: sourceSchema + Array(reads.keys), reads: reads, effectiveSchema: sourceNamespace ) { guard let read = reads[selection], let snapshot = read.snapshot else { continue } let targetRead = match(selection, in: targetReads) @@ -207,16 +261,25 @@ internal struct ObjectCopyPlanner { } drafts.append(ObjectCopyTableDraft( selection: selection, + read: read, snapshot: snapshot, targetSnapshot: targetRead?.snapshot, existsInTarget: existsInTarget, + sourceSchema: sourceEndpoint.schema ?? read.table.schema, + targetSchema: targetEndpoint.schema, request: request )) } guard !drafts.isEmpty else { return [] } - let sourceParts = try await readSourceParts(drafts, endpoint: request.source) - let ddl = try await buildTargetDDL(drafts, request: request) + let sourceParts = try await readSourceParts(drafts, endpoint: sourceEndpoint) + let ddl = try await buildTargetDDL( + drafts, + request: request, + targetEndpoint: targetEndpoint, + sourceNamespace: sourceNamespace, + targetNamespace: targetNamespace + ) return drafts.map { draft in let parts = sourceParts[draft.selection.id] let statements = ddl[draft.selection.id] ?? ObjectCopyTableDDL() @@ -252,9 +315,13 @@ 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. - let inputs = drafts.map { + /// 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) } + guard !inputs.isEmpty 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) @@ -276,25 +343,32 @@ internal struct ObjectCopyPlanner { private func buildTargetDDL( _ drafts: [ObjectCopyTableDraft], - request: ObjectCopyRequest + request: ObjectCopyRequest, + targetEndpoint: DatabaseEndpoint, + sourceNamespace: String?, + targetNamespace: String? ) async throws -> [String: ObjectCopyTableDDL] { - let sourceSchema = request.source.schema let inputs = drafts.map { ObjectCopyDDLInput( id: $0.selection.id, snapshot: Self.retargeted( - $0.snapshot, from: sourceSchema, to: $0.targetSchema + $0.snapshot, from: sourceNamespace, to: targetNamespace, schema: $0.targetSchema ), targetSchema: $0.targetSchema, writesStructure: $0.writesStructure, dropsFirst: $0.dropsFirst, - emptiesFirst: $0.emptiesFirst + emptiesFirst: $0.emptiesFirst, + /// Rolling back is only promised where the run wraps the table in a transaction, + /// and TRUNCATE commits implicitly on engines that offer it without transactional + /// DDL, so a promise of rollback has to be kept with DELETE. + clearsWithDelete: request.wrapEachTableInTransaction + && request.errorHandling != .skipAndContinue ) } guard inputs.contains(where: { $0.writesStructure || $0.dropsFirst || $0.emptiesFirst }) else { return [:] } - return try await manager.withMetadataDriver(scope: targetScope(request)) { driver in + return try await manager.withMetadataDriver(scope: targetScope(request, endpoint: targetEndpoint)) { driver in guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { throw ObjectCopyError.refused(Self.noTargetDriver) } @@ -304,9 +378,11 @@ internal struct ObjectCopyPlanner { try Task.checkCancellation() var ddl = ObjectCopyTableDDL() if input.dropsFirst { - ddl.drop = try builder.build( - operations: [.dropTable(name: input.snapshot.name, schema: input.targetSchema)], - foreignKeysByTable: [:] + ddl.drop = Self.dropStatements( + table: input.snapshot.name, + schema: input.targetSchema, + builder: builder, + driver: plugin ) } if input.writesStructure { @@ -316,7 +392,10 @@ internal struct ObjectCopyPlanner { } if input.emptiesFirst { ddl.truncate = Self.emptyStatements( - table: input.snapshot.name, schema: input.targetSchema, driver: plugin + table: input.snapshot.name, + schema: input.targetSchema, + prefersDelete: input.clearsWithDelete, + driver: plugin ) } result[input.id] = ddl @@ -325,6 +404,40 @@ internal struct ObjectCopyPlanner { } } + /// The DROP a replacement needs, whatever the driver offers. + /// + /// `dropObjectStatement` has a protocol default of nil that MySQL, PostgreSQL, SQL Server, + /// SQLite, Oracle, DuckDB and Trino all inherit, so the builder produced no drop at all and the + /// CREATE that followed ran against the table that was still there. Replace was unusable on + /// every core engine. A quoted `DROP TABLE` is the fallback, built with the driver's own + /// quoting. + nonisolated private static func dropStatements( + table: String, + schema: String?, + builder: SchemaSyncScriptBuilder, + driver: any PluginDatabaseDriver + ) -> [SyncStatement] { + let generated = (try? builder.build( + operations: [.dropTable(name: table, schema: schema)], foreignKeysByTable: [:] + )) ?? [] + guard generated.isEmpty else { return generated } + return [SyncStatement( + sql: "DROP TABLE \(qualified(table, schema, driver));", + objectName: table, + summary: String(format: String(localized: "Drop table %@"), table), + hazards: SyncSafetyClassifier().hazards(forDropping: table) + )] + } + + nonisolated private static func qualified( + _ name: String, + _ schema: String?, + _ driver: any PluginDatabaseDriver + ) -> String { + guard let schema, !schema.isEmpty else { return driver.quoteIdentifier(name) } + return "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(name))" + } + /// Points the snapshot's foreign keys at the copy rather than at the original. /// /// A snapshot carries each foreign key's `referencedSchema` as the source spelled it. Handed @@ -336,11 +449,13 @@ internal struct ObjectCopyPlanner { nonisolated internal static func retargeted( _ snapshot: TableStructureSnapshot, from sourceSchema: String?, - to targetSchema: String? + to targetSchema: String?, + schema: String? = nil ) -> TableStructureSnapshot { + let placedSchema = schema ?? targetSchema let source = (sourceSchema ?? "").lowercased() - guard source != (targetSchema ?? "").lowercased() else { return snapshot } let foreignKeys = snapshot.foreignKeys.map { key -> EditableForeignKeyDefinition in + guard source != (targetSchema ?? "").lowercased() else { return key } let referenced = (key.referencedSchema ?? "").lowercased() guard referenced.isEmpty || referenced == source else { return key } var moved = key @@ -349,7 +464,7 @@ internal struct ObjectCopyPlanner { } return TableStructureSnapshot( name: snapshot.name, - schema: targetSchema, + schema: placedSchema, columns: snapshot.columns, indexes: snapshot.indexes, foreignKeys: foreignKeys, @@ -359,19 +474,22 @@ internal struct ObjectCopyPlanner { ) } - /// TRUNCATE where the engine has one, DELETE where it does not. Both are hazards, and both are - /// in the script the user reads before pressing Copy. + /// What empties a table before its rows are written. + /// + /// DELETE whenever the run promises to roll the table back. TRUNCATE commits implicitly on the + /// engines that offer it without transactional DDL, so a copy that failed afterwards rolled the + /// new rows back and left the target's own gone for good. nonisolated private static func emptyStatements( table: String, schema: String?, + prefersDelete: Bool, driver: any PluginDatabaseDriver ) -> [SyncStatement] { - let qualified: String = { - guard let schema, !schema.isEmpty else { return driver.quoteIdentifier(table) } - return "\(driver.quoteIdentifier(schema)).\(driver.quoteIdentifier(table))" - }() - let sql = driver.truncateTableStatements(table: table, schema: schema, cascade: false)?.first - ?? "DELETE FROM \(qualified)" + let qualified = qualified(table, schema, driver) + let truncate = prefersDelete + ? nil + : driver.truncateTableStatements(table: table, schema: schema, cascade: false)?.first + let sql = truncate ?? "DELETE FROM \(qualified)" return [SyncStatement( sql: sql.hasSuffix(";") ? sql : sql + ";", objectName: table, @@ -390,31 +508,35 @@ internal struct ObjectCopyPlanner { private func buildDefinitionSteps( _ request: ObjectCopyRequest, + scope: Scope, + sourceEndpoint: DatabaseEndpoint, + targetEndpoint: DatabaseEndpoint, sourceReads: [TableStructureRead], - targetObjects: Set, + targetObjects: [String: ObjectCopySelection], connection: DatabaseConnection, skipped: inout [ObjectCopySkip] ) async throws -> [ObjectCopyDefinitionStep] { - let selections = request.sourceDefinedObjects + let selections = scope.objects.filter { $0.kind.isSourceDefined } guard !selections.isEmpty else { return [] } guard request.content.includesStructure else { skipped += selections.map { ObjectCopySkip(selection: $0, reason: Self.structureOnlyObject) } return [] } - let targetSchema = request.target.schema - let definitions = try await sourceDefinitions(request, sourceReads: sourceReads, connection: connection) - var pending: [(selection: ObjectCopySelection, definition: String, replaces: Bool)] = [] - for selection in Self.orderedByKind(selections) { - /// Nothing here parses the definition, so every table it names keeps the source's own - /// qualification. Running that against another schema recreates the object pointing - /// back at the source, and for a replacement the generated DROP lands on the source's - /// own object. + let targetSchema = targetEndpoint.schema + let sourceNamespace = ObjectCopyNamespace.name(for: sourceEndpoint) + let targetNamespace = ObjectCopyNamespace.name(for: targetEndpoint) + let definitions = try await sourceDefinitions( + request, scope: scope, sourceEndpoint: sourceEndpoint, + 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( - sourceSchema: selection.schema ?? request.source.schema, targetSchema: targetSchema + sourceNamespace: sourceNamespace, targetNamespace: targetNamespace ) else { skipped.append(ObjectCopySkip( - selection: selection, reason: ObjectCopyEligibility.definitionSchemaRefusal + selection: selection, reason: ObjectCopyEligibility.definitionNamespaceRefusal )) continue } @@ -423,42 +545,62 @@ internal struct ObjectCopyPlanner { skipped.append(ObjectCopySkip(selection: selection, reason: Self.noDefinition)) continue } - let existsInTarget = targetObjects.contains(Self.objectKey(for: selection)) + guard ObjectCopyEligibility.isExecutableDefinition(definition) else { + skipped.append(ObjectCopySkip( + selection: selection, reason: ObjectCopyEligibility.definitionNotExecutableRefusal + )) + continue + } + let existing = targetObjects[Self.objectKey(for: selection)] /// Add rows promises the target's structure is kept, and these objects hold no rows at /// all, so replacing one would be pure destruction with nothing to gain by it. - if existsInTarget, request.existingPolicy != .replace { + if existing != nil, request.existingPolicy != .replace { skipped.append(ObjectCopySkip(selection: selection, reason: Self.alreadyThere)) continue } - pending.append((selection, definition, existsInTarget)) + pending.append((selection, definition, existing)) } guard !pending.isEmpty else { return [] } let inputs = pending.map { item in ( id: item.selection.id, - result: CompareObjectResult( + create: CompareObjectResult( identity: CompareObjectIdentity( kind: item.selection.kind, schema: targetSchema ?? item.selection.schema, name: item.selection.name, signature: item.selection.signature ), - status: item.replaces ? .differs : .onlyInSource, + status: .onlyInSource, sourceDefinition: [item.definition] ), - replaces: item.replaces + /// Dropped as the kind the target actually holds. A source view over a target + /// materialized view emitted `DROP VIEW`, which those engines refuse. + drop: item.target.map { target in + CompareObjectResult( + identity: CompareObjectIdentity( + kind: target.kind, + schema: targetSchema ?? target.schema, + name: target.name, + signature: target.signature + ), + status: .onlyInTarget + ) + } ) } - let built = try await manager.withMetadataDriver(scope: targetScope(request)) { driver in + let built = try await manager.withMetadataDriver( + scope: targetScope(request, endpoint: targetEndpoint) + ) { driver in guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { throw ObjectCopyError.refused(Self.noTargetDriver) } let builder = SourceObjectSyncBuilder(targetDriver: plugin) var statements: [String: (drop: [SyncStatement], create: [SyncStatement])] = [:] for input in inputs { - let drop = input.replaces ? builder.build(for: input.result, action: .drop) : [] - let create = builder.build(for: input.result, action: .create) + let drop = input.drop.map { builder.build(for: $0, action: .drop) } ?? [] + let create = builder.build(for: input.create, action: .create) statements[input.id] = (drop, create) } return statements @@ -481,11 +623,22 @@ internal struct ObjectCopyPlanner { private func sourceDefinitions( _ request: ObjectCopyRequest, + scope: Scope, + sourceEndpoint: DatabaseEndpoint, sourceReads: [TableStructureRead], connection: DatabaseConnection ) async throws -> [String: String] { var definitions: [String: String] = [:] - let selections = request.sourceDefinedObjects + 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 { @@ -598,13 +751,21 @@ internal struct ObjectCopyPlanner { /// A database that does not exist yet cannot be connected to, so planning for one asks the /// server instead. The run switches to the real scope once `CREATE DATABASE` has succeeded. - private func targetScope(_ request: ObjectCopyRequest) -> DatabaseScope { - guard request.destination.createsDatabase else { return request.target.scope } - return DatabaseScope(connectionId: request.target.connectionId, database: "", schema: nil) + private func targetScope( + _ request: ObjectCopyRequest, + endpoint: DatabaseEndpoint + ) -> DatabaseScope { + guard request.destination.createsDatabase else { return endpoint.scope } + return DatabaseScope(connectionId: endpoint.connectionId, database: "", schema: nil) } + /// Exact spelling first, and a folded match only when it is unambiguous. PostgreSQL allows + /// quoted `Orders` and `orders` side by side, and folding first resolved both selections to + /// whichever the driver happened to list first. private func match(_ selection: ObjectCopySelection, in reads: [TableStructureRead]) -> TableStructureRead? { - reads.first { $0.table.name.lowercased() == selection.name.lowercased() } + if let exact = reads.first(where: { $0.table.name == selection.name }) { return exact } + let folded = reads.filter { $0.table.name.lowercased() == selection.name.lowercased() } + return folded.count == 1 ? folded[0] : nil } /// Read from inside the scoped-driver closures, which run off the main actor. @@ -649,18 +810,21 @@ private struct ObjectCopyTableDraft { init( selection: ObjectCopySelection, + read: TableStructureRead, snapshot: TableStructureSnapshot, targetSnapshot: TableStructureSnapshot?, existsInTarget: Bool, + sourceSchema: String?, + targetSchema: String?, request: ObjectCopyRequest ) { self.selection = selection self.snapshot = snapshot - self.sourceSchema = request.source.schema ?? snapshot.schema + self.sourceSchema = sourceSchema /// Never the source's. A target endpoint that names no schema means the target driver's /// own current scope, and inheriting the source's put a SQL Server `dbo` into a MySQL /// INSERT, naming a database that engine does not have. - self.targetSchema = request.target.schema + self.targetSchema = targetSchema let keepsTargetStructure = existsInTarget && request.existingPolicy != .replace let writesStructure = request.content.includesStructure && !keepsTargetStructure @@ -673,7 +837,12 @@ private struct ObjectCopyTableDraft { /// pairs `Orders` with `orders` and the INSERT has to quote the one that exists. self.targetTable = (writesStructure ? nil : targetSnapshot?.name) ?? snapshot.name + /// Read from the driver's own columns rather than from the snapshot. SQL Server computed + /// columns and ClickHouse ALIAS columns set `isGenerated` with no expression, and + /// PostgreSQL reports identity through `identityKind`; the snapshot conversion keeps + /// neither, so those columns looked ordinary and writable. let pairs = Self.writableColumnPairs( + columns: read.columns, snapshot: snapshot, targetSnapshot: writesStructure ? nil : targetSnapshot ) @@ -682,8 +851,8 @@ private struct ObjectCopyTableDraft { self.copiesData = request.content.includesData && !pairs.isEmpty && (writesStructure || existsInTarget) let written = Set(pairs.map { $0.source.lowercased() }) - self.copiesIdentityColumn = request.content.includesData && snapshot.columns.contains { - written.contains($0.name.lowercased()) && $0.autoIncrement + self.copiesIdentityColumn = request.content.includesData && read.columns.contains { + written.contains($0.name.lowercased()) && ($0.isIdentity || $0.extra?.lowercased().contains("auto_increment") == true) } if request.content.includesData, !writesStructure, !existsInTarget { @@ -705,20 +874,28 @@ private struct ObjectCopyTableDraft { /// a column the target lacks cannot be written to and one it has that the source lacks keeps /// its default. static func writableColumnPairs( + columns: [PluginColumnInfo], snapshot: TableStructureSnapshot, targetSnapshot: TableStructureSnapshot? ) -> [(source: String, target: String)] { + let generated = Set(columns.filter(\.isGenerated).map { $0.name.lowercased() }) let sourceColumns = snapshot.columns - .filter { $0.generationExpression == nil } + .filter { $0.generationExpression == nil && !generated.contains($0.name.lowercased()) } .map(\.name) guard let targetSnapshot else { return sourceColumns.map { ($0, $0) } } - var targetByFoldedName: [String: String] = [:] + + /// Exact spellings first. PostgreSQL allows quoted `Orders` and `orders` in one schema, so + /// folding case unconditionally resolved either to whichever row came back first. + var exact: [String: String] = [:] + var folded: [String: [String]] = [:] for column in targetSnapshot.columns where column.generationExpression == nil { - targetByFoldedName[column.name.lowercased()] = column.name + exact[column.name] = column.name + folded[column.name.lowercased(), default: []].append(column.name) } return sourceColumns.compactMap { name in - guard let target = targetByFoldedName[name.lowercased()] else { return nil } - return (name, target) + if let target = exact[name] { return (name, target) } + guard let candidates = folded[name.lowercased()], candidates.count == 1 else { return nil } + return (name, candidates[0]) } } } @@ -730,6 +907,7 @@ private struct ObjectCopyDDLInput: Sendable { let writesStructure: Bool let dropsFirst: Bool let emptiesFirst: Bool + let clearsWithDelete: Bool } private struct ObjectCopyTableDDL: Sendable { diff --git a/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift b/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift index aac1cdb79..a428cbef7 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift @@ -46,6 +46,9 @@ internal struct ObjectCopyRowCopier: Sendable { internal struct Outcome: Sendable { internal let inserted: Int internal let cancelled: Bool + /// What is in the target whatever happens next. Zero where the caller holds a transaction + /// it can roll back, and every flushed batch where it does not. + internal var committed: Int = 0 } internal let step: ObjectCopyTableStep @@ -75,11 +78,16 @@ internal struct ObjectCopyRowCopier: Sendable { } } + /// Checked again here. Cancellation can reach an `AsyncThrowingStream` as an ordinary end + /// of stream, or land after its last element, and the loop then falls out with rows still + /// pending: writing them committed a batch the user had already stopped and reported the + /// table as copied. + if Task.isCancelled { return Outcome(inserted: inserted, cancelled: true) } if !pending.isEmpty { inserted += try await flush(&pending, generator: generator, driver: targetDriver) onProgress(inserted) } - return Outcome(inserted: inserted, cancelled: false) + return Outcome(inserted: inserted, cancelled: Task.isCancelled) } // MARK: - Statements diff --git a/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift index 6592b578e..94a7185d3 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift @@ -104,13 +104,29 @@ internal struct ObjectCopyRunner { /// Torn down before anything is built, and children first, so a foreign key is gone before /// the table it points at and a trigger before the table that owns it. One parent-first /// pass had every DROP rejected by the constraint below it. - for phase in [plan.cleanupGroups, plan.creationGroups] where !phase.isEmpty { - let result = try await runDDL(phase, request: request, progress: progress) + /// + /// Both phases run under one `withMetadataDriver` call and, where the engine has them, + /// 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 { + let result = try await runStructure(plan, request: request, progress: progress) outcomes += result.outcomes cancelled = cancelled || result.cancelled if result.stopped { return finished() } } + /// Every table the copy will append to is emptied before any of them is filled, children + /// 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 { + let result = try await runDDL(plan.clearGroups, request: request, progress: progress) + outcomes += result.outcomes.filter { $0.error != nil } + cancelled = cancelled || result.cancelled + if result.stopped { return finished() } + } + if !cancelled, request.content.includesData { let dataOutcomes = await runData(plan, request: request, progress: progress) outcomes += dataOutcomes.outcomes @@ -147,52 +163,111 @@ internal struct ObjectCopyRunner { var stopped = false } - private func runDDL( - _ groups: [ObjectCopyStatementGroup], + /// Every drop and every create, in one scoped call, wrapped where the engine allows it. + /// + /// A replacement is destructive only in the moment between its DROP and its CREATE, so the two + /// have to be one unit as far as the engine can make them: a transaction where DDL is + /// transactional, and otherwise at least one connection lease with foreign key checks off so + /// the ordering cannot fail half way. + private func runStructure( + _ plan: ObjectCopyPlan, request: ObjectCopyRequest, progress: ObjectCopyProgress ) async throws -> DDLResult { - let runnable = groups.filter { !$0.statements.isEmpty } - guard !runnable.isEmpty else { return DDLResult() } - + let groups = plan.cleanupGroups + plan.creationGroups let errorHandling = request.errorHandling let scope = request.target.scope + let hasCleanup = !plan.cleanupGroups.isEmpty + return try await manager.withMetadataDriver(scope: scope) { driver in - var result = DDLResult() - for group in runnable { - if progress.isCancelled || Task.isCancelled { - result.cancelled = true - result.stopped = true - break + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { + throw ObjectCopyError.refused(Self.noTargetDriver) + } + let usesTransaction = hasCleanup && plugin.supportsTransactionalDDL + let relaxesForeignKeys = hasCleanup && !usesTransaction + if usesTransaction { try await plugin.beginTransaction(mode: .readWrite) } + if relaxesForeignKeys { + for statement in plugin.foreignKeyDisableStatements() ?? [] { + _ = try? await plugin.execute(query: statement) } - progress.startObject(group.selection.qualifiedName) - do { - /// Every statement runs, hazards included: a DROP is here because the user - /// chose Replace and then read it in the script, which is the consent Compare & - /// Sync collects per statement instead. - for statement in group.statements { - _ = try await driver.execute(query: statement.sql) - } - result.outcomes.append(ObjectCopyObjectOutcome( - selection: group.selection, rowsCopied: 0, error: nil - )) - } catch { - Self.logger.error( - "Copy DDL failed for \(group.selection.qualifiedName, privacy: .public): \(error.localizedDescription, privacy: .public)" - ) - result.outcomes.append(ObjectCopyObjectOutcome( - selection: group.selection, rowsCopied: 0, error: error.localizedDescription - )) - guard errorHandling == .skipAndContinue else { - result.stopped = true - break - } + } + + let result = await Self.execute( + groups, on: plugin, errorHandling: errorHandling, progress: progress + ) + + if relaxesForeignKeys { + for statement in plugin.foreignKeyEnableStatements() ?? [] { + _ = try? await plugin.execute(query: statement) + } + } + if usesTransaction { + if result.stopped { + try? await plugin.rollbackTransaction() + } else { + try await plugin.commitTransaction() } } return result } } + nonisolated private static func execute( + _ groups: [ObjectCopyStatementGroup], + on driver: any PluginDatabaseDriver, + errorHandling: ImportErrorHandling, + progress: ObjectCopyProgress + ) async -> DDLResult { + var result = DDLResult() + for group in groups where !group.statements.isEmpty { + if progress.isCancelled || Task.isCancelled { + result.cancelled = true + result.stopped = true + break + } + progress.startObject(group.selection.qualifiedName) + do { + for statement in group.statements { + _ = try await driver.execute(query: statement.sql) + } + result.outcomes.append(ObjectCopyObjectOutcome( + selection: group.selection, rowsCopied: 0, error: nil + )) + } catch { + logger.error( + "Copy DDL failed for \(group.selection.qualifiedName, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + result.outcomes.append(ObjectCopyObjectOutcome( + selection: group.selection, rowsCopied: 0, error: error.localizedDescription + )) + guard errorHandling == .skipAndContinue else { + result.stopped = true + break + } + } + } + return result + } + + private func runDDL( + _ groups: [ObjectCopyStatementGroup], + request: ObjectCopyRequest, + progress: ObjectCopyProgress + ) async throws -> DDLResult { + let runnable = groups.filter { !$0.statements.isEmpty } + guard !runnable.isEmpty else { return DDLResult() } + + let errorHandling = request.errorHandling + return try await manager.withMetadataDriver(scope: request.target.scope) { driver in + guard let plugin = CompareMetadataService.pluginDriver(from: driver) else { + throw ObjectCopyError.refused(Self.noTargetDriver) + } + return await Self.execute( + runnable, on: plugin, errorHandling: errorHandling, progress: progress + ) + } + } + // MARK: - Data private struct DataResult { @@ -234,11 +309,19 @@ internal struct ObjectCopyRunner { completedBefore: result.rowsCopied, progress: progress ) - /// A cancelled table rolled its rows back, so it neither counts as copied nor - /// reads as an object that succeeded. + /// A cancelled table that rolled back neither counts as copied nor reads as an + /// object that succeeded. On a target without transactions nothing rolled back, so + /// the batches already flushed are in the target and saying otherwise hides them + /// from a user about to retry and double the rows. guard !outcome.cancelled else { result.cancelled = true result.stopped = true + if outcome.committed > 0 { + result.rowsCopied += outcome.committed + result.outcomes.append(ObjectCopyObjectOutcome( + selection: step.selection, rowsCopied: outcome.committed, error: nil + )) + } progress.setRowsForCurrentObject(0, completedBefore: result.rowsCopied) break } @@ -300,24 +383,25 @@ internal struct ObjectCopyRunner { try await targetPlugin.beginTransaction(mode: .readWrite) } do { - /// Inside the transaction, never before it. Emptying the table in the DDL phase - /// meant a copy that failed or was stopped rolled the new rows back and left - /// the target's own rows deleted for good. - for statement in step.truncateStatements { - _ = try await targetPlugin.execute(query: statement.sql) - } let outcome = try await copier.copy( from: sourcePlugin, to: targetPlugin ) { rows in progress.setRowsForCurrentObject(rows, completedBefore: completedBefore) } - if usesTransaction { - if outcome.cancelled { - try? await targetPlugin.rollbackTransaction() - } else { - try await targetPlugin.commitTransaction() - } + guard usesTransaction else { + /// Nothing to roll back, so every batch already flushed is in the target + /// whether the user stopped or not. + return ObjectCopyRowCopier.Outcome( + inserted: outcome.inserted, + cancelled: outcome.cancelled, + committed: outcome.inserted + ) + } + if outcome.cancelled { + try? await targetPlugin.rollbackTransaction() + } else { + try await targetPlugin.commitTransaction() } return outcome } catch { diff --git a/TablePro/Core/ObjectCopy/ObjectCopySession.swift b/TablePro/Core/ObjectCopy/ObjectCopySession.swift index 77c4603c1..b77b9e530 100644 --- a/TablePro/Core/ObjectCopy/ObjectCopySession.swift +++ b/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -169,12 +169,6 @@ internal final class ObjectCopySession { break } } - if let reason = ObjectCopyEligibility.unscopedSchemaRefusal( - endpoint: source, - supportsSchemas: PluginManager.shared.supportsSchemaSwitching(for: source.databaseType) - ) { - return reason - } return nil } @@ -287,8 +281,11 @@ internal final class ObjectCopySession { } } + /// Adds what is on screen rather than replacing the whole selection, so a search that is hiding + /// already-ticked objects cannot silently untick them. None subtracts only the visible ones for + /// the same reason. internal func selectAll() { - selectedObjectIds = Set(filteredObjects.map(\.id)) + selectedObjectIds.formUnion(Set(filteredObjects.map(\.id))) } internal func selectNone() { diff --git a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift index 758954224..635524e5b 100644 --- a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift +++ b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift @@ -65,7 +65,9 @@ extension MainContentCommandActions { guard let coordinator else { return false } return isConnected && ObjectCopyEligibility.mayOfferDuplicateDatabase( editorLanguage: PluginManager.shared.editorLanguage(for: coordinator.connection.type), - supportsDatabaseSwitching: supportsContainerSwitching, + supportsDatabaseSwitching: PluginManager.shared.supportsDatabaseSwitching( + for: coordinator.connection.type + ), isReadOnly: isReadOnly ) } diff --git a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift index f26e9a75d..97ad31907 100644 --- a/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift +++ b/TablePro/Views/Sidebar/Menu/DatabaseTreeMenuSpec.swift @@ -257,6 +257,10 @@ internal enum DatabaseTreeMenuSpec { schema: schema, isSystem: context.systemSchemas.contains(schema) ) + /// Oracle, Snowflake, Trino, Dameng and BigQuery draw their schemas here rather than as + /// container rows, and several of them need a schema-scoped source, so leaving Copy To on + /// the container path alone put it out of reach on exactly the engines that require it. + items += copyItems(ref, context: context) guard let renameable = ObjectRenameEligibility.renameable([ref], context: context.renameEligibility) else { return items } items.append(.separator) @@ -363,13 +367,23 @@ internal enum DatabaseTreeMenuSpec { /// A table row's copy carries the whole selection the menu resolved, so right-clicking inside a /// multi-selection copies every table in it rather than only the one under the pointer. + /// Switched over the row's own type rather than asked whether it is a view. A materialized view + /// answered no and was encoded as a table, which the catalog lists as `.materializedView`, so + /// the preselection matched nothing and the sheet opened empty. A foreign table is a proxy for + /// rows on another server and the catalog drops it, so it is not offered. private static func copySelections(for targets: [DatabaseTreeTableRef]) -> [ObjectCopySelection] { - targets.map { target in - ObjectCopySelection( - kind: SidebarContextMenuLogic.isView(clickedTable: target.table) ? .view : .table, - name: target.table.name, - schema: target.qualifyingSchema - ) + targets.compactMap { target in + guard let kind = copyKind(for: target.table.type) else { return nil } + return ObjectCopySelection(kind: kind, name: target.table.name, schema: target.qualifyingSchema) + } + } + + private static func copyKind(for type: TableInfo.TableType) -> CompareObjectKind? { + switch type { + case .table, .partitionedTable: return .table + case .view: return .view + case .materializedView: return .materializedView + case .foreignTable, .systemTable, .externalTable: return nil } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift index 03343cf80..546c1daf5 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -82,35 +82,68 @@ final class ObjectCopyEligibilityTests: XCTestCase { )) } - // MARK: - Schemas + // MARK: - Namespaces - /// `fetchTables(schema: nil)` resolves to the connection's current schema, so a whole-database - /// copy taken without one carried one schema and reported success over the rest. - func testASchemaAwareEngineMustNameASchema() { - XCTAssertNotNil(ObjectCopyEligibility.unscopedSchemaRefusal( - endpoint: endpoint("app", type: .postgresql), supportsSchemas: true - )) - XCTAssertNil(ObjectCopyEligibility.unscopedSchemaRefusal( - endpoint: endpoint("app", type: .postgresql, schema: "public"), supportsSchemas: true - )) + /// The name an engine qualifies its objects with, which is not always the selected schema. + /// MySQL has no schemas and reports the database in the schema column, so its foreign keys and + /// routines come back qualified by the database name. + func testTheNamespaceIsTheSchemaWhereSchemasExist() { + XCTAssertEqual( + ObjectCopyNamespace.name( + for: endpoint("app", type: .postgresql, schema: "sales"), + supportsSchemas: true, + supportsDatabases: true + ), + "sales" + ) + } + + func testTheNamespaceIsTheDatabaseWhereSchemasDoNot() { + XCTAssertEqual( + ObjectCopyNamespace.name( + for: endpoint("shop"), supportsSchemas: false, supportsDatabases: true + ), + "shop" + ) } - func testAnEngineWithoutSchemasNeedsNone() { - XCTAssertNil(ObjectCopyEligibility.unscopedSchemaRefusal( - endpoint: endpoint("app"), supportsSchemas: false + /// SQLite has one unnamed container, so nothing is qualified and two files compare equal. + func testAnEngineWithNeitherHasNoNamespace() { + XCTAssertNil(ObjectCopyNamespace.name( + for: endpoint("chinook.sqlite", type: .sqlite), + supportsSchemas: false, + supportsDatabases: false )) } // MARK: - Definitions - /// Nothing parses the definition, so every table it names keeps the source's qualification. - /// Into another schema it would point back at the source, and a replacement's DROP would land - /// on the source's own object. - func testADefinitionOnlyCopiesIntoASchemaOfTheSameName() { - XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "public", targetSchema: "public")) - XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "Public", targetSchema: "public")) - XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceSchema: nil, targetSchema: nil)) - XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "sales", targetSchema: "archive")) - XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition(sourceSchema: "sales", targetSchema: nil)) + /// Nothing parses the definition, so every object it names keeps the source's qualification. + /// A duplicate keeps the schema name, which is why one is copyable; two MySQL databases are + /// two namespaces, which is why one is not. + func testADefinitionOnlyCopiesWithinOneNamespace() { + XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition( + sourceNamespace: "public", targetNamespace: "public" + )) + XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition( + sourceNamespace: "Public", targetNamespace: "public" + )) + XCTAssertTrue(ObjectCopyEligibility.canCopyDefinition(sourceNamespace: nil, targetNamespace: nil)) + XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition( + sourceNamespace: "shop", targetNamespace: "shop_copy" + )) + XCTAssertFalse(ObjectCopyEligibility.canCopyDefinition( + sourceNamespace: "sales", targetNamespace: nil + )) + } + + /// ClickHouse, Oracle, Dameng and BigQuery answer with the view's SELECT rather than its + /// CREATE. Running that is a read the runner would report as the view copied, after Replace + /// had already dropped the target's. + func testABareBodyIsNotAnExecutableDefinition() { + XCTAssertTrue(ObjectCopyEligibility.isExecutableDefinition("CREATE VIEW v AS SELECT 1")) + XCTAssertTrue(ObjectCopyEligibility.isExecutableDefinition("\n create or replace view v AS SELECT 1")) + XCTAssertFalse(ObjectCopyEligibility.isExecutableDefinition("SELECT id, name FROM orders")) + XCTAssertFalse(ObjectCopyEligibility.isExecutableDefinition(" ")) } } diff --git a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift index 9b5cd8633..093195625 100644 --- a/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift @@ -180,6 +180,66 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { ) } + // MARK: - Namespace scopes + + private func request( + _ objects: [ObjectCopySelection], + duplicates: Bool = false + ) -> ObjectCopyRequest { + let source = DatabaseEndpoint( + scope: DatabaseScope(connectionId: UUID(), database: "shop", schema: nil), + connectionName: "server", + databaseType: .postgresql, + safeModeLevel: .silent, + color: .blue + ) + return ObjectCopyRequest( + source: source, + destination: duplicates + ? .newDatabase(base: source, name: "shop_copy", values: [:]) + : .existing(source.withDatabase("other").withSchema("archive")), + objects: objects, + content: .structureAndData, + existingPolicy: .skip + ) + } + + /// A database-level copy on PostgreSQL spans every schema, and each has to be read and written + /// in its own scope: one read against a nil schema answers only whatever the connection is on. + func testObjectsAreGroupedByTheSchemaTheyWereFoundIn() { + let scopes = ObjectCopyPlanner.scopes(of: request([ + selection("orders", schema: "sales"), + selection("audit", schema: "logging"), + selection("customers", schema: "sales") + ])) + + XCTAssertEqual(scopes.map(\.namespace), ["sales", "logging"]) + XCTAssertEqual(scopes.first?.objects.map(\.name), ["orders", "customers"]) + } + + /// A duplicate keeps every schema name, so each schema's objects land in a schema of the same + /// name in the new database. A copy to a chosen target puts them all in the schema chosen. + func testADuplicateKeepsEachSchemaNameAndACopyDoesNot() { + let objects = [selection("orders", schema: "sales")] + let duplicate = ObjectCopyPlanner.scopes(of: request(objects, duplicates: true)) + let copy = ObjectCopyPlanner.scopes(of: request(objects)) + + XCTAssertEqual( + duplicate.first?.targetNamespace(for: request(objects, duplicates: true)), "sales" + ) + XCTAssertEqual(copy.first?.targetNamespace(for: request(objects)), "archive") + } + + func testAnEngineWithoutSchemasIsOneScope() { + let scopes = ObjectCopyPlanner.scopes(of: request([ + selection("orders", schema: nil), + selection("customers", schema: nil) + ])) + + XCTAssertEqual(scopes.count, 1) + XCTAssertNil(scopes.first?.namespace) + } + // MARK: - Retargeting foreign keys private func snapshot(referencedSchema: String?) -> TableStructureSnapshot { @@ -204,7 +264,7 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { /// stayed wired to `prod.customers` and the duplicate was never independent of its original. func testAForeignKeyIntoTheSourceIsMovedToTheTarget() { let moved = ObjectCopyPlanner.retargeted( - snapshot(referencedSchema: "sales"), from: "sales", to: "archive" + snapshot(referencedSchema: "sales"), from: "sales", to: "archive", schema: "archive" ) XCTAssertEqual(moved.schema, "archive") @@ -214,7 +274,7 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { /// A reference that names neither side's schema points at something the copy never touched. func testAForeignKeyIntoAThirdSchemaIsLeftAlone() { let moved = ObjectCopyPlanner.retargeted( - snapshot(referencedSchema: "reference"), from: "sales", to: "archive" + snapshot(referencedSchema: "reference"), from: "sales", to: "archive", schema: "archive" ) XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "reference") @@ -223,7 +283,7 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { /// An unqualified reference means "my own schema", so it follows the table into the target. func testAnUnqualifiedForeignKeyFollowsTheTable() { let moved = ObjectCopyPlanner.retargeted( - snapshot(referencedSchema: nil), from: "sales", to: "archive" + snapshot(referencedSchema: nil), from: "sales", to: "archive", schema: "archive" ) XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "archive") @@ -231,7 +291,7 @@ final class ObjectCopyPlannerOrderingTests: XCTestCase { func testASchemaThatDoesNotChangeLeavesTheSnapshotAlone() { let moved = ObjectCopyPlanner.retargeted( - snapshot(referencedSchema: "sales"), from: "sales", to: "sales" + snapshot(referencedSchema: "sales"), from: "sales", to: "sales", schema: "sales" ) XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "sales") diff --git a/docs/customization/notifications.mdx b/docs/customization/notifications.mdx index b2af210cc..15235f989 100644 --- a/docs/customization/notifications.mdx +++ b/docs/customization/notifications.mdx @@ -22,6 +22,7 @@ You only get told when the result is somewhere you are not. That means TablePro | **Structure changes** | On | Applying table structure changes | | **Imports** | On | File imports | | **Exports** | On | Exports, including streaming exports | +| **Object copies** | On | Copy To and Duplicate Database | | **Backups** | On | Database dumps | | **Fetch all rows** | On | Loading every row of a truncated result | | **AI and MCP queries** | On | Queries an AI assistant or MCP client runs | diff --git a/docs/features/copy-objects.mdx b/docs/features/copy-objects.mdx index 07cc4e97b..f6b3c1aeb 100644 --- a/docs/features/copy-objects.mdx +++ b/docs/features/copy-objects.mdx @@ -62,25 +62,31 @@ the target does not have is not written; one the source does not have keeps its Pick **Skip it** unless you are refreshing a copy you made earlier. It is the only one of the three that cannot lose anything already in the target. -## One engine, one schema +## One engine, and the namespace rule A copy stays inside one engine. Column data types are the driver's own strings and the row writer emits that driver's own SQL, so neither structure nor data crosses from MySQL to PostgreSQL. The sheet refuses before it reads anything. MySQL and MariaDB count as one engine. -On PostgreSQL, SQL Server and the other engines that keep objects in schemas, copy one schema at a -time. A whole-database copy taken without a schema reads only the connection's current one, so the -sheet asks for a schema instead of carrying part of the database and calling it the whole. +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. -Views, routines and triggers copy only into a schema of the same name. Their definition is the -source's own SQL text and nothing rewrites the tables it names, so into a differently named schema -it would point back at the source. They are left out with the reason shown. +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 +own SQL text and nothing rewrites the objects it names, so anywhere else it would point back at the +source. They are left out with the reason shown, which is why duplicating a MySQL database carries +its tables and not its views. + +A driver that answers with a view's `SELECT` rather than its `CREATE`, which ClickHouse, Oracle, +Dameng and BigQuery do, has that view left out for the same reason. ## Ordering and foreign keys Everything is torn down children first, so a foreign key is gone before the table it points at, then -built parents first. Rows are copied after that, and triggers go in last: a trigger installed before -the rows fires on the copy itself. +built parents first, both in one pass so a stop between them cannot leave objects dropped with +nothing put back. Rows are copied after that. Triggers and materialized views go in last: a trigger +installed before the rows fires on the copy itself, and a materialized view is filled at the moment +it is created. A copied foreign key is repointed at the copy. A key that referenced the source's own schema references the target's afterwards, so the duplicate stands on its own; one that referenced a third