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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
29 changes: 21 additions & 8 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +44,7 @@ struct SQLStatementGenerator {

init(
tableName: String,
schemaName: String? = nil,
columns: [String],
primaryKeyColumns: [String],
databaseType: DatabaseType,
Expand All @@ -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
Expand All @@ -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
}
Expand Down Expand Up @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand All @@ -243,7 +256,7 @@ struct SQLStatementGenerator {
}

func deleteAllRowsStatement() -> String {
"DELETE FROM \(quoteIdentifierFn(tableName))"
"DELETE FROM \(qualifiedTableName)"
}

private func generateInsertSQLFromCellChanges(for change: RowChange) -> ParameterizedStatement?
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
}
Expand Down Expand Up @@ -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)
}

Expand Down
10 changes: 9 additions & 1 deletion TablePro/Core/Compare/CompareMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>? = 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
}
Expand Down
16 changes: 16 additions & 0 deletions TablePro/Core/Compare/TableStructureSnapshot.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
12 changes: 12 additions & 0 deletions TablePro/Core/Menu/DatabaseMenuBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(_:))
Expand Down
138 changes: 138 additions & 0 deletions TablePro/Core/ObjectCopy/ObjectCopyCatalog.swift
Original file line number Diff line number Diff line change
@@ -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()
}
}
}
Loading
Loading