diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bf41545e..0cf3ee409 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +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 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/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/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/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..7c26c2c2b --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift @@ -0,0 +1,138 @@ +// +// 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 + } + + /// 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 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( + 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..2da181e77 --- /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.") + } + + /// Whether a view, routine or trigger can be copied as it stands. + /// + /// Its definition is the source's own SQL text, and nothing here parses it, so every object it + /// 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. 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 definitionNamespaceRefusal: String { + String( + 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 new file mode 100644 index 000000000..f0e634e7c --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlan.swift @@ -0,0 +1,204 @@ +// +// 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 || selection.kind == .materializedView + } +} + +/// 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) } + } + + /// 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) } + } + + 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..1518955aa --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyPlanner.swift @@ -0,0 +1,948 @@ +// +// 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) + + /// 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] = [] + 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, + createsDatabase: request.destination.createsDatabase, + tableSteps: tableSteps, + definitionSteps: definitionSteps, + skipped: skipped + ) + } + + /// 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 { + 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) + } + 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, + endpoint: DatabaseEndpoint, + connection: DatabaseConnection, + names: Set + ) async throws -> [TableStructureRead] { + guard !request.destination.createsDatabase else { return [] } + return try await metadata.tableReads( + for: endpoint, 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, + endpoint: DatabaseEndpoint, + connection: DatabaseConnection + ) 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 + /// 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, + scope: Scope, + sourceEndpoint: DatabaseEndpoint, + targetEndpoint: DatabaseEndpoint, + sourceReads: [TableStructureRead], + targetReads: [TableStructureRead], + skipped: inout [ObjectCopySkip] + ) async throws -> [ObjectCopyTableStep] { + var reads: [ObjectCopySelection: TableStructureRead] = [:] + 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 + } + guard read.snapshot != nil else { + skipped.append(ObjectCopySkip(selection: selection, reason: read.failure ?? Self.unreadable)) + continue + } + reads[selection] = read + } + guard !reads.isEmpty else { return [] } + + /// 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: sourceNamespace + ) { + 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, + 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: 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() + 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. + /// 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) + } + 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, + targetEndpoint: DatabaseEndpoint, + sourceNamespace: String?, + targetNamespace: String? + ) async throws -> [String: ObjectCopyTableDDL] { + let inputs = drafts.map { + ObjectCopyDDLInput( + id: $0.selection.id, + snapshot: Self.retargeted( + $0.snapshot, from: sourceNamespace, to: targetNamespace, schema: $0.targetSchema + ), + targetSchema: $0.targetSchema, + writesStructure: $0.writesStructure, + dropsFirst: $0.dropsFirst, + 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, endpoint: targetEndpoint)) { 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 = Self.dropStatements( + table: input.snapshot.name, + schema: input.targetSchema, + builder: builder, + driver: plugin + ) + } + 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, + prefersDelete: input.clearsWithDelete, + driver: plugin + ) + } + result[input.id] = ddl + } + return result + } + } + + /// 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 + /// 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?, + schema: String? = nil + ) -> TableStructureSnapshot { + let placedSchema = schema ?? targetSchema + let source = (sourceSchema ?? "").lowercased() + 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 + moved.referencedSchema = targetSchema + return moved + } + return TableStructureSnapshot( + name: snapshot.name, + schema: placedSchema, + columns: snapshot.columns, + indexes: snapshot.indexes, + foreignKeys: foreignKeys, + engine: snapshot.engine, + charset: snapshot.charset, + collation: snapshot.collation + ) + } + + /// 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 = 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, + 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, + scope: Scope, + sourceEndpoint: DatabaseEndpoint, + targetEndpoint: DatabaseEndpoint, + sourceReads: [TableStructureRead], + targetObjects: [String: ObjectCopySelection], + connection: DatabaseConnection, + skipped: inout [ObjectCopySkip] + ) async throws -> [ObjectCopyDefinitionStep] { + 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 = 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( + sourceNamespace: sourceNamespace, targetNamespace: targetNamespace + ) else { + skipped.append(ObjectCopySkip( + selection: selection, reason: ObjectCopyEligibility.definitionNamespaceRefusal + )) + continue + } + guard let definition = definitions[selection.id], + !definition.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + skipped.append(ObjectCopySkip(selection: selection, reason: Self.noDefinition)) + continue + } + 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 existing != nil, request.existingPolicy != .replace { + skipped.append(ObjectCopySkip(selection: selection, reason: Self.alreadyThere)) + continue + } + pending.append((selection, definition, existing)) + } + guard !pending.isEmpty else { return [] } + + let inputs = pending.map { item in + ( + id: item.selection.id, + create: CompareObjectResult( + identity: CompareObjectIdentity( + kind: item.selection.kind, + schema: targetSchema ?? item.selection.schema, + name: item.selection.name, + signature: item.selection.signature + ), + status: .onlyInSource, + sourceDefinition: [item.definition] + ), + /// 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, 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.drop.map { builder.build(for: $0, action: .drop) } ?? [] + let create = builder.build(for: input.create, 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, + scope: Scope, + sourceEndpoint: DatabaseEndpoint, + sourceReads: [TableStructureRead], + connection: DatabaseConnection + ) async throws -> [String: String] { + var definitions: [String: String] = [:] + let selections = scope.objects.filter { $0.kind.isSourceDefined } + let request = ObjectCopyRequest( + source: sourceEndpoint, + destination: request.destination, + objects: selections, + content: request.content, + existingPolicy: request.existingPolicy, + errorHandling: request.errorHandling, + wrapEachTableInTransaction: request.wrapEachTableInTransaction + ) + + let views = selections.filter { $0.kind == .view || $0.kind == .materializedView } + if !views.isEmpty { + 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, + 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? { + 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. + 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, + read: TableStructureRead, + snapshot: TableStructureSnapshot, + targetSnapshot: TableStructureSnapshot?, + existsInTarget: Bool, + sourceSchema: String?, + targetSchema: String?, + request: ObjectCopyRequest + ) { + self.selection = selection + self.snapshot = snapshot + 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 = targetSchema + + 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 + + /// 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 + ) + 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 && read.columns.contains { + written.contains($0.name.lowercased()) && ($0.isIdentity || $0.extra?.lowercased().contains("auto_increment") == true) + } + + 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( + 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 && !generated.contains($0.name.lowercased()) } + .map(\.name) + guard let targetSnapshot else { return sourceColumns.map { ($0, $0) } } + + /// 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 { + exact[column.name] = column.name + folded[column.name.lowercased(), default: []].append(column.name) + } + return sourceColumns.compactMap { name in + if let target = exact[name] { return (name, target) } + guard let candidates = folded[name.lowercased()], candidates.count == 1 else { return nil } + return (name, candidates[0]) + } + } +} + +private struct ObjectCopyDDLInput: Sendable { + let id: String + let snapshot: TableStructureSnapshot + let targetSchema: String? + let writesStructure: Bool + let dropsFirst: Bool + let emptiesFirst: Bool + let clearsWithDelete: 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..a428cbef7 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyRowCopier.swift @@ -0,0 +1,151 @@ +// +// 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 + /// 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 + 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) } + } + } + + /// 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: Task.isCancelled) + } + + // 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..94a7185d3 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopyRunner.swift @@ -0,0 +1,449 @@ +// +// 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. + /// + /// 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 + 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 + } + + /// 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 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 + 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) + } + } + + 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 { + 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 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 + } + 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 { + let outcome = try await copier.copy( + from: sourcePlugin, + to: targetPlugin + ) { rows in + progress.setRowsForCurrentObject(rows, completedBefore: completedBefore) + } + 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 { + 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..b77b9e530 --- /dev/null +++ b/TablePro/Core/ObjectCopy/ObjectCopySession.swift @@ -0,0 +1,416 @@ +// +// 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 + } + } + 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) + } + } + + /// 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.formUnion(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..635524e5b 100644 --- a/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift +++ b/TablePro/Views/Main/MainContentCommandActions+DatabaseObjects.swift @@ -51,4 +51,56 @@ 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: PluginManager.shared.supportsDatabaseSwitching( + for: coordinator.connection.type + ), + 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..97ad31907 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, @@ -242,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) @@ -289,6 +308,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 +347,46 @@ 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. + /// 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.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 + } + } + 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..546c1daf5 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyEligibilityTests.swift @@ -0,0 +1,149 @@ +// +// 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: - Namespaces + + /// 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" + ) + } + + /// 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 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 new file mode 100644 index 000000000..093195625 --- /dev/null +++ b/TableProTests/Core/ObjectCopy/ObjectCopyPlannerOrderingTests.swift @@ -0,0 +1,299 @@ +// +// 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: - 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 { + 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", schema: "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", schema: "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", schema: "archive" + ) + + XCTAssertEqual(moved.foreignKeys.first?.referencedSchema, "archive") + } + + func testASchemaThatDoesNotChangeLeavesTheSnapshotAlone() { + let moved = ObjectCopyPlanner.retargeted( + snapshot(referencedSchema: "sales"), from: "sales", to: "sales", schema: "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/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/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..f6b3c1aeb --- /dev/null +++ b/docs/features/copy-objects.mdx @@ -0,0 +1,145 @@ +--- +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, 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. + +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 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, 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 +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