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
2 changes: 1 addition & 1 deletion TablePro/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ struct ContentView: View {
tableOperationOptions: sessionTableOperationOptionsBinding,
databaseType: currentSession.connection.type,
connectionId: currentSession.connection.id,
schemaProvider: MainContentCoordinator.schemaProvider(for: currentSession.connection.id)
schemaProvider: SchemaProviderRegistry.shared.provider(for: currentSession.connection.id)
)
}
.navigationSplitViewColumnWidth(min: 200, ideal: 250, max: 600)
Expand Down
25 changes: 1 addition & 24 deletions TablePro/Core/ChangeTracking/SQLStatementGenerator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,6 @@ struct ParameterizedStatement {
struct SQLStatementGenerator {
private static let logger = Logger(subsystem: "com.TablePro", category: "SQLStatementGenerator")

/// Known SQL function expressions that should not be quoted/parameterized
private static let sqlFunctionExpressions: Set<String> = [
"NOW()",
"CURRENT_TIMESTAMP()",
"CURRENT_TIMESTAMP",
"CURDATE()",
"CURTIME()",
"UTC_TIMESTAMP()",
"UTC_DATE()",
"UTC_TIME()",
"LOCALTIME()",
"LOCALTIME",
"LOCALTIMESTAMP()",
"LOCALTIMESTAMP",
"SYSDATE()",
"UNIX_TIMESTAMP()",
"CURRENT_DATE()",
"CURRENT_DATE",
"CURRENT_TIME()",
"CURRENT_TIME",
]

let tableName: String
let columns: [String]
let primaryKeyColumn: String?
Expand Down Expand Up @@ -382,7 +360,6 @@ struct SQLStatementGenerator {

/// Check if a string is a SQL function expression that should not be quoted
private func isSQLFunctionExpression(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespaces).uppercased()
return Self.sqlFunctionExpressions.contains(trimmed)
SQLEscaping.isTemporalFunction(value)
}
}
2 changes: 1 addition & 1 deletion TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ final class DatabaseManager {
activeSessions.removeValue(forKey: sessionId)

// Clean up shared schema cache for this connection
MainContentCoordinator.clearSharedSchema(for: sessionId)
SchemaProviderRegistry.shared.clear(for: sessionId)

// Clean up shared sidebar state for this connection
SharedSidebarState.removeConnection(sessionId)
Expand Down
14 changes: 14 additions & 0 deletions TablePro/Core/Database/SQLEscaping.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,20 @@ enum SQLEscaping {
}
}

/// Known SQL temporal function expressions that should not be quoted/parameterized.
/// Canonical source — used by SQLStatementGenerator and sidebar save logic.
static let temporalFunctionExpressions: Set<String> = [
"NOW()", "CURRENT_TIMESTAMP()", "CURRENT_TIMESTAMP",
"CURDATE()", "CURTIME()", "UTC_TIMESTAMP()", "UTC_DATE()", "UTC_TIME()",
"LOCALTIME()", "LOCALTIME", "LOCALTIMESTAMP()", "LOCALTIMESTAMP",
"SYSDATE()", "UNIX_TIMESTAMP()", "CURRENT_DATE()", "CURRENT_DATE",
"CURRENT_TIME()", "CURRENT_TIME",
]

static func isTemporalFunction(_ value: String) -> Bool {
temporalFunctionExpressions.contains(value.trimmingCharacters(in: .whitespaces).uppercased())
}

/// Escape wildcards in LIKE patterns while preserving intentional wildcards
///
/// This is useful when building LIKE clauses where the search term should be treated literally.
Expand Down
170 changes: 170 additions & 0 deletions TablePro/Core/Services/ExportService+CSV.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
//
// ExportService+CSV.swift
// TablePro
//

import Foundation

extension ExportService {
func exportToCSV(
tables: [ExportTableItem],
config: ExportConfiguration,
to url: URL
) async throws {
// Create file and get handle for streaming writes
let fileHandle = try createFileHandle(at: url)
defer { closeFileHandle(fileHandle) }

let lineBreak = config.csvOptions.lineBreak.value

for (index, table) in tables.enumerated() {
try checkCancellation()

state.currentTableIndex = index + 1
state.currentTable = table.qualifiedName

// Add table header comment if multiple tables
// Sanitize name to prevent newlines from breaking the comment line
if tables.count > 1 {
let sanitizedName = sanitizeForSQLComment(table.qualifiedName)
try fileHandle.write(contentsOf: "# Table: \(sanitizedName)\n".toUTF8Data())
}

let batchSize = 10_000
var offset = 0
var isFirstBatch = true

while true {
try checkCancellation()
try Task.checkCancellation()

let result = try await fetchBatch(for: table, offset: offset, limit: batchSize)

// No more rows to process
if result.rows.isEmpty {
break
}

// Stream CSV content for this batch directly to file
// Only include headers on the first batch to avoid duplication
var batchOptions = config.csvOptions
if !isFirstBatch {
batchOptions.includeFieldNames = false
}

try await writeCSVContentWithProgress(
columns: result.columns,
rows: result.rows,
options: batchOptions,
to: fileHandle
)

isFirstBatch = false
offset += batchSize
}
if index < tables.count - 1 {
try fileHandle.write(contentsOf: "\(lineBreak)\(lineBreak)".toUTF8Data())
}
}

try checkCancellation()
state.progress = 1.0
}

private func writeCSVContentWithProgress(
columns: [String],
rows: [[String?]],
options: CSVExportOptions,
to fileHandle: FileHandle
) async throws {
let delimiter = options.delimiter.actualValue
let lineBreak = options.lineBreak.value

// Header row
if options.includeFieldNames {
let headerLine = columns
.map { escapeCSVField($0, options: options) }
.joined(separator: delimiter)
try fileHandle.write(contentsOf: (headerLine + lineBreak).toUTF8Data())
}

// Data rows with progress tracking - stream directly to file
for row in rows {
try checkCancellation()

let rowLine = row.map { value -> String in
guard let val = value else {
return options.convertNullToEmpty ? "" : "NULL"
}

var processed = val

// Check for line breaks BEFORE converting them (for quote detection)
let hadLineBreaks = val.contains("\n") || val.contains("\r")

// Convert line breaks to space
if options.convertLineBreakToSpace {
processed = processed
.replacingOccurrences(of: "\r\n", with: " ")
.replacingOccurrences(of: "\r", with: " ")
.replacingOccurrences(of: "\n", with: " ")
}

// Handle decimal format
if options.decimalFormat == .comma {
let range = NSRange(processed.startIndex..., in: processed)
if Self.decimalFormatRegex.firstMatch(in: processed, range: range) != nil {
processed = processed.replacingOccurrences(of: ".", with: ",")
}
}

return escapeCSVField(processed, options: options, originalHadLineBreaks: hadLineBreaks)
}.joined(separator: delimiter)

// Write row directly to file
try fileHandle.write(contentsOf: (rowLine + lineBreak).toUTF8Data())

// Update progress (throttled)
await incrementProgress()
}

// Ensure final count is shown
await finalizeTableProgress()
}

private func escapeCSVField(_ field: String, options: CSVExportOptions, originalHadLineBreaks: Bool = false) -> String {
var processed = field

// Sanitize formula-like prefixes to prevent CSV formula injection
// Values starting with these characters can be executed as formulas in Excel/LibreOffice
if options.sanitizeFormulas {
let dangerousPrefixes: [Character] = ["=", "+", "-", "@", "\t", "\r"]
if let first = processed.first, dangerousPrefixes.contains(first) {
// Prefix with single quote - Excel/LibreOffice treats this as text
processed = "'" + processed
}
}

switch options.quoteHandling {
case .always:
let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
case .never:
return processed
case .asNeeded:
// Check current content for special characters, OR if original had line breaks
// (important when convertLineBreakToSpace is enabled - original line breaks
// mean the field should still be quoted even after conversion to spaces)
let needsQuotes = processed.contains(options.delimiter.actualValue) ||
processed.contains("\"") ||
processed.contains("\n") ||
processed.contains("\r") ||
originalHadLineBreaks
if needsQuotes {
let escaped = processed.replacingOccurrences(of: "\"", with: "\"\"")
return "\"\(escaped)\""
}
return processed
}
}
}
Loading