The second file contains a SHA-256 checksum of the SQL backup.
This allows the application to detect whether the backup file has changed or become corrupted. 2. Verify Backup button The Import/Export window now includes Verify Backup…. Select an exported SQL file, and the application calculates its checksum and compares it with the accompanying checksum file. 3. Automatic verification before import When importing an SQL file, WorkbenchReplacement checks for an accompanying .sha256 file. If one exists, its checksum must match before SQL execution begins. Third-party SQL files without checksum files can still be imported. 4. Large-file handling Checksum calculation reads files in 1 MB blocks rather than loading the entire file into memory. This reduces memory usage when verifying large backups.
This commit is contained in:
@@ -1681,7 +1681,7 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
))
|
||||
}
|
||||
}
|
||||
Text("Data export requires a non-null unique index on every selected table. Row counts are checked, but exports are not transaction-consistent: avoid writes during export.")
|
||||
Text("InnoDB data is exported using a consistent snapshot and a single streamed query per table. Tables without unique indexes are supported.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -1871,6 +1871,11 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
showingImportConfirmation = true
|
||||
}
|
||||
|
||||
// Export operations use a distinct authenticated session so their snapshot
|
||||
// cannot be affected by SQL editor queries or administration refreshes.
|
||||
@State private var exportSession: ConnectionProfile?
|
||||
@State private var exportPassword = ""
|
||||
|
||||
@MainActor
|
||||
private func execute(_ statement: String) async throws -> QueryResult {
|
||||
guard let profile = selectedProfile else {
|
||||
@@ -1880,9 +1885,9 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
}
|
||||
|
||||
return try await NativeMySQLRunner.run(
|
||||
profile: profile,
|
||||
profile: exportSession ?? profile,
|
||||
database: statement == "SHOW DATABASES" ? "" : database,
|
||||
password: PasswordStore.read(profile.id),
|
||||
password: exportSession == nil ? PasswordStore.read(profile.id) : exportPassword,
|
||||
sql: statement
|
||||
)
|
||||
}
|
||||
@@ -1958,6 +1963,16 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
? availableViews.filter { selectedViews.contains($0) }
|
||||
: []
|
||||
guard !selectedTables.isEmpty || !views.isEmpty || (exportStructure && (includeTriggers || includeRoutines)) else { return }
|
||||
guard let activeProfile = selectedProfile else {
|
||||
status = "Export failed: No connection selected"
|
||||
return
|
||||
}
|
||||
// Clone the connection profile with a fresh identity; the session pool
|
||||
// then creates a separate socket, independent of the editor session.
|
||||
var dedicatedProfile = activeProfile
|
||||
dedicatedProfile.id = UUID()
|
||||
exportPassword = PasswordStore.read(activeProfile.id)
|
||||
exportSession = dedicatedProfile
|
||||
isWorking = true
|
||||
progress = 0
|
||||
let tables = availableTables.filter { selectedTables.contains($0) }
|
||||
@@ -1966,10 +1981,31 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
defer {
|
||||
try? handle?.close()
|
||||
try? FileManager.default.removeItem(at: tempURL)
|
||||
NativeMySQLRunner.disconnect(profileID: dedicatedProfile.id)
|
||||
exportSession = nil
|
||||
exportPassword = ""
|
||||
isWorking = false
|
||||
operationTask = nil
|
||||
}
|
||||
do {
|
||||
// A consistent InnoDB snapshot requires every SELECT to execute on
|
||||
// this SAME connection. MyISAM/MEMORY cannot participate.
|
||||
for table in tables where exportData {
|
||||
let engine = try await execute("""
|
||||
SELECT ENGINE FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
||||
AND TABLE_NAME = \(sqlLiteral(table))
|
||||
AND TABLE_TYPE = 'BASE TABLE'
|
||||
""")
|
||||
guard let name = engine.rows.first?.first,
|
||||
name.uppercased() == "INNODB" else {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Consistent data export requires InnoDB. Table \(table) uses \(engine.rows.first?.first ?? "an unknown engine"). Export its structure only."
|
||||
)
|
||||
}
|
||||
}
|
||||
_ = try await execute("SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ")
|
||||
_ = try await execute("START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY")
|
||||
guard FileManager.default.createFile(atPath: tempURL.path, contents: nil) else {
|
||||
throw WorkbenchTransferError.message("Cannot create temporary export file")
|
||||
}
|
||||
@@ -2013,9 +2049,9 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
}
|
||||
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
|
||||
}.joined(separator: ", ")
|
||||
// Phase 7: require a non-null unique key for deterministic
|
||||
// pagination. Without one, LIMIT/OFFSET can silently
|
||||
// duplicate or omit rows even on an idle server.
|
||||
// A single streamed SELECT keeps every row in the same
|
||||
// InnoDB read view and works without a primary/unique key.
|
||||
// No OFFSET, no 100,000-row cap, no full result buffering.
|
||||
let indexes = try await execute("""
|
||||
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
||||
FROM information_schema.STATISTICS
|
||||
@@ -2043,46 +2079,39 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
}
|
||||
grouped[name, default: []].append(indexRow[1])
|
||||
}
|
||||
guard let indexName = indexOrder.first(where: { name in
|
||||
let usableIndex = indexOrder.first { name in
|
||||
guard let columns = grouped[name], !columns.isEmpty else { return false }
|
||||
return columns.allSatisfy { !nullableColumns.contains($0) }
|
||||
}), let keyColumns = grouped[indexName] else {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Cannot safely export data for \(table): no non-null unique index. " +
|
||||
"Add a primary key or export structure only."
|
||||
)
|
||||
}
|
||||
let orderBy = keyColumns.map(quoteIdentifier).joined(separator: ", ")
|
||||
let beforeCount = try await execute("SELECT COUNT(*) FROM \(qualified)")
|
||||
guard let expectedText = beforeCount.rows.first?.first,
|
||||
let expectedCount = Int64(expectedText) else {
|
||||
let orderBy = usableIndex.flatMap { grouped[$0] }?
|
||||
.map(quoteIdentifier).joined(separator: ", ")
|
||||
let countResult = try await execute("SELECT COUNT(*) FROM \(qualified)")
|
||||
guard let countText = countResult.rows.first?.first,
|
||||
let expectedCount = Int64(countText) else {
|
||||
throw WorkbenchTransferError.message("Cannot count rows in \(table)")
|
||||
}
|
||||
try write("-- Export ordering: \(indexName) (\(orderBy)); expected rows: \(expectedCount)\n")
|
||||
let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) ORDER BY \(orderBy) LIMIT 250 OFFSET "
|
||||
var offset = 0
|
||||
while true {
|
||||
try Task.checkCancellation()
|
||||
let batch = try await execute(statement + String(offset))
|
||||
if batch.rows.isEmpty { break }
|
||||
for row in batch.rows {
|
||||
guard let literalRow = row.first else { continue }
|
||||
try write("INSERT INTO \(qualified) (\(names)) VALUES \(literalRow);\n")
|
||||
}
|
||||
offset += batch.rows.count
|
||||
status = "Exporting \(table): \(offset) rows"
|
||||
if batch.rows.count < 250 { break }
|
||||
try write("-- Streamed snapshot: \(expectedCount) rows; order: \(usableIndex ?? "server scan")\n")
|
||||
let select = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified)" +
|
||||
(orderBy.map { " ORDER BY " + $0 } ?? "")
|
||||
let outputHandle = handle!
|
||||
let prefix = Data("INSERT INTO \(qualified) (\(names)) VALUES ".utf8)
|
||||
let suffix = Data(";\n".utf8)
|
||||
let written = try await NativeMySQLRunner.stream(
|
||||
profile: dedicatedProfile,
|
||||
database: database,
|
||||
password: exportPassword,
|
||||
sql: select
|
||||
) { literal in
|
||||
try outputHandle.write(contentsOf: prefix)
|
||||
try outputHandle.write(contentsOf: Data(literal.utf8))
|
||||
try outputHandle.write(contentsOf: suffix)
|
||||
}
|
||||
let afterCount = try await execute("SELECT COUNT(*) FROM \(qualified)")
|
||||
guard let afterText = afterCount.rows.first?.first,
|
||||
let finalCount = Int64(afterText),
|
||||
Int64(offset) == expectedCount,
|
||||
finalCount == expectedCount else {
|
||||
guard written == expectedCount else {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Table \(table) changed during export or pagination was incomplete. " +
|
||||
"The temporary backup has been discarded."
|
||||
"Table \(table): expected \(expectedCount) rows, streamed \(written). Backup discarded."
|
||||
)
|
||||
}
|
||||
status = "Exported \(table): \(written) rows"
|
||||
}
|
||||
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
|
||||
}
|
||||
@@ -2156,6 +2185,8 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
}
|
||||
}
|
||||
try Task.checkCancellation()
|
||||
// Release the read view before finalizing the export file.
|
||||
_ = try await execute("COMMIT")
|
||||
try handle?.close()
|
||||
handle = nil
|
||||
if FileManager.default.fileExists(atPath: url.path) {
|
||||
|
||||
@@ -287,6 +287,35 @@ final class MySQLWireConnection {
|
||||
private var hostIsLoopback = false
|
||||
func markHost(_ host: String) { hostIsLoopback = ["localhost", "127.0.0.1", "::1"].contains(host.lowercased()) }
|
||||
|
||||
// Streams result rows without the query editor's 100,000-row limit.
|
||||
// The callback is invoked on the session's serial background queue.
|
||||
func streamFirstColumn(_ sql: String, onRow: (String) throws -> Void) throws -> Int64 {
|
||||
sequence = 0
|
||||
try send([0x03] + Array(sql.utf8))
|
||||
let first = try readPacket()
|
||||
try checkError(first)
|
||||
guard first.first != 0x00 else { return 0 }
|
||||
var cursor = ByteCursor(bytes: first)
|
||||
guard let columnCount = try cursor.lenInt(), columnCount == 1 else {
|
||||
throw MySQLWireError(message: "Export query must return exactly one column")
|
||||
}
|
||||
_ = try readPacket() // Column metadata
|
||||
_ = try readPacket() // Column EOF
|
||||
var count: Int64 = 0
|
||||
while true {
|
||||
let packet = try readPacket()
|
||||
try checkError(packet)
|
||||
if packet.first == 0xfe && packet.count < 9 { break }
|
||||
var row = ByteCursor(bytes: packet)
|
||||
guard let value = try row.lenString() else {
|
||||
throw MySQLWireError(message: "Unexpected NULL export row")
|
||||
}
|
||||
try onRow(value)
|
||||
count += 1
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func query(_ sql: String) throws -> QueryResult {
|
||||
sequence = 0
|
||||
try send([0x03] + Array(sql.utf8))
|
||||
@@ -367,6 +396,23 @@ private final class PersistentMySQLSessions {
|
||||
}
|
||||
}
|
||||
|
||||
func stream(profile: ConnectionProfile, database: String,
|
||||
password: String, sql: String,
|
||||
onRow: (String) throws -> Void) throws -> Int64 {
|
||||
try queue.sync {
|
||||
guard let entry = connections[profile.id],
|
||||
entry.profile == profile, entry.password == password else {
|
||||
throw MySQLWireError(message: "Export session is no longer connected")
|
||||
}
|
||||
do {
|
||||
return try entry.connection.streamFirstColumn(sql, onRow: onRow)
|
||||
} catch {
|
||||
connections.removeValue(forKey: profile.id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(profileID: UUID) {
|
||||
queue.sync { _ = connections.removeValue(forKey: profileID) }
|
||||
}
|
||||
@@ -382,6 +428,20 @@ enum NativeMySQLRunner {
|
||||
}.value
|
||||
}
|
||||
|
||||
static func stream(profile: ConnectionProfile, database: String,
|
||||
password: String, sql: String,
|
||||
onRow: @escaping (String) throws -> Void) async throws -> Int64 {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
try PersistentMySQLSessions.shared.stream(
|
||||
profile: profile, database: database, password: password,
|
||||
sql: sql, onRow: { row in
|
||||
try Task<Never, Never>.checkCancellation()
|
||||
try onRow(row)
|
||||
}
|
||||
)
|
||||
}.value
|
||||
}
|
||||
|
||||
static func disconnect(profileID: UUID) {
|
||||
PersistentMySQLSessions.shared.disconnect(profileID: profileID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user