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("Triggers and routines require SHOW CREATE privileges. This exporter does not provide transaction-consistent snapshots or guaranteed row ordering.")
|
||||
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.")
|
||||
.font(.caption).foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
@@ -2013,7 +2013,53 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
}
|
||||
return "IF(\(col) IS NULL, 'NULL', QUOTE(\(col)))"
|
||||
}.joined(separator: ", ")
|
||||
let statement = "SELECT CONCAT('(', CONCAT_WS(',', \(expressions)), ')') FROM \(qualified) LIMIT 250 OFFSET "
|
||||
// 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.
|
||||
let indexes = try await execute("""
|
||||
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX
|
||||
FROM information_schema.STATISTICS
|
||||
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
||||
AND TABLE_NAME = \(sqlLiteral(table))
|
||||
AND NON_UNIQUE = 0
|
||||
ORDER BY CASE WHEN INDEX_NAME = 'PRIMARY' THEN 0 ELSE 1 END,
|
||||
INDEX_NAME, SEQ_IN_INDEX
|
||||
""")
|
||||
let nullable = try await execute("""
|
||||
SELECT COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = \(sqlLiteral(database))
|
||||
AND TABLE_NAME = \(sqlLiteral(table))
|
||||
AND IS_NULLABLE = 'YES'
|
||||
""")
|
||||
let nullableColumns = Set(nullable.rows.compactMap { $0.first })
|
||||
var grouped: [String: [String]] = [:]
|
||||
var indexOrder: [String] = []
|
||||
for indexRow in indexes.rows where indexRow.count >= 2 {
|
||||
let name = indexRow[0]
|
||||
if grouped[name] == nil {
|
||||
grouped[name] = []
|
||||
indexOrder.append(name)
|
||||
}
|
||||
grouped[name, default: []].append(indexRow[1])
|
||||
}
|
||||
guard let indexName = indexOrder.first(where: { 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 {
|
||||
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()
|
||||
@@ -2027,6 +2073,16 @@ struct WorkbenchDatabaseTransferView: View {
|
||||
status = "Exporting \(table): \(offset) rows"
|
||||
if batch.rows.count < 250 { break }
|
||||
}
|
||||
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 {
|
||||
throw WorkbenchTransferError.message(
|
||||
"Table \(table) changed during export or pagination was incomplete. " +
|
||||
"The temporary backup has been discarded."
|
||||
)
|
||||
}
|
||||
}
|
||||
progress = Double(index + 1) / Double(max(tables.count + views.count, 1))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user