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) {
|
||||
|
||||
Reference in New Issue
Block a user