diff --git a/CHANGELOG.md b/CHANGELOG.md
index c450e44e7..375bfe18c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -46,6 +46,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
+- PostgreSQL identifier quoting in a SQL export of query results from any other engine. (#2630)
+- Backslashes left unescaped in a SQL export of query results, silently altering the values.
+- `DROP ... CASCADE` written for engines that reject it, SQLite and SQL Server among them.
+- `DROP TABLE` naming the source table in a query-results export that writes no `CREATE TABLE`.
+- Snowflake string literals escaped without their backslashes, in the editor and in exports.
- Restore Dump overwriting a database with no confirmation.
- Stopping an import applying at once, while stopping an export or a backup asks first.
- Progress bar stuck at zero and an "N/0 rows" label for the whole of a streaming query export.
diff --git a/Plugins/SQLExportPlugin/SQLExportPlugin.swift b/Plugins/SQLExportPlugin/SQLExportPlugin.swift
index 9e6153d4e..c58931be6 100644
--- a/Plugins/SQLExportPlugin/SQLExportPlugin.swift
+++ b/Plugins/SQLExportPlugin/SQLExportPlugin.swift
@@ -358,6 +358,12 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send
try writer.write("\n")
}
+ /// `CASCADE` is not portable: PostgreSQL drops dependent objects with it, SQLite and SQL Server
+ /// have no such clause and reject the statement, and MySQL parses it and does nothing.
+ private func cascadeClause(_ dataSource: any PluginExportDataSource) -> String {
+ dataSource.supportsCascadeDrop ? " CASCADE" : ""
+ }
+
/// The engine spells its own DROP for the kinds where dialects disagree: PostgreSQL's
/// `DROP TRIGGER` takes an `ON
` clause where MySQL's does not, and MySQL has no
/// `DROP ROUTINE` at all. Only the table-shaped kinds, which every SQL engine spells the same
@@ -377,7 +383,7 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send
case .trigger, .event, .routine:
return "\(keyword) IF EXISTS \(dataSource.quoteIdentifier(object.name));"
default:
- return "\(keyword) IF EXISTS \(ref) CASCADE;"
+ return "\(keyword) IF EXISTS \(ref)\(cascadeClause(dataSource));"
}
}
@@ -396,7 +402,8 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send
for seq in sequences where !emittedSequenceNames.contains(seq.name) {
emittedSequenceNames.insert(seq.name)
let quotedName = "\"\(seq.name.replacingOccurrences(of: "\"", with: "\"\""))\""
- try writer.write("DROP SEQUENCE IF EXISTS \(quotedName) CASCADE;\n")
+ try writer.write(
+ "DROP SEQUENCE IF EXISTS \(quotedName)\(cascadeClause(dataSource));\n")
try writer.write("\(seq.ddl)\n\n")
}
} catch {
@@ -409,7 +416,8 @@ final class SQLExportPlugin: ExportFormatPlugin, SettablePlugin, @unchecked Send
for enumType in enumTypes where !emittedTypeNames.contains(enumType.name) {
emittedTypeNames.insert(enumType.name)
let quotedName = "\"\(enumType.name.replacingOccurrences(of: "\"", with: "\"\""))\""
- try writer.write("DROP TYPE IF EXISTS \(quotedName) CASCADE;\n")
+ try writer.write(
+ "DROP TYPE IF EXISTS \(quotedName)\(cascadeClause(dataSource));\n")
let quotedLabels = enumType.labels.map { "'\(dataSource.escapeStringLiteral($0))'" }
try writer.write("CREATE TYPE \(quotedName) AS ENUM (\(quotedLabels.joined(separator: ", ")));\n\n")
}
diff --git a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift
index 55a1b4c35..9dfee3f48 100644
--- a/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift
+++ b/Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift
@@ -265,6 +265,7 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin {
booleanLiteralStyle: .truefalse,
likeEscapeStyle: .explicit,
paginationStyle: .limit,
+ requiresBackslashEscaping: true,
caseSensitivityStyle: .ilikeOperator
)
diff --git a/Plugins/TableProPluginKit/PluginExportDataSource.swift b/Plugins/TableProPluginKit/PluginExportDataSource.swift
index 7213fa427..6dfe3dabb 100644
--- a/Plugins/TableProPluginKit/PluginExportDataSource.swift
+++ b/Plugins/TableProPluginKit/PluginExportDataSource.swift
@@ -26,6 +26,12 @@ public protocol PluginExportDataSource: AnyObject, Sendable {
/// that share a name. Empty on an engine with no principal management.
func fetchGrantStatements(principal: String, host: String?) async throws -> [String]
+ /// Whether this engine accepts a `CASCADE` clause on a `DROP`. PostgreSQL takes it and drops
+ /// dependent objects with it; SQLite, SQL Server, ClickHouse and Trino have no such clause and
+ /// reject the statement outright; MySQL parses it and does nothing. Defaults to false, which is
+ /// the answer that is never a syntax error.
+ var supportsCascadeDrop: Bool { get }
+
/// The engine's own DROP for an object. Only the driver knows that PostgreSQL's `DROP TRIGGER`
/// takes an `ON ` clause and MySQL's does not, or that MySQL has no `DROP ROUTINE` at
/// all. Nil means the caller should fall back to its own generic shape.
@@ -55,6 +61,8 @@ public extension PluginExportDataSource {
func fetchGrantStatements(principal: String, host: String?) async throws -> [String] { [] }
+ var supportsCascadeDrop: Bool { false }
+
func dropStatement(for object: PluginExportTable) -> String? { nil }
func streamRows(for object: PluginExportTable) -> AsyncThrowingStream {
diff --git a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift
index 643688ef7..01004f0a9 100644
--- a/TablePro/Core/Plugins/ExportDataSourceAdapter.swift
+++ b/TablePro/Core/Plugins/ExportDataSourceAdapter.swift
@@ -15,7 +15,15 @@ final class ExportDataSourceAdapter: PluginExportDataSource, @unchecked Sendable
private static let logger = Logger(subsystem: "com.TablePro", category: "ExportDataSourceAdapter")
+ /// The same capability the sidebar's drop prompt reads, so the engines whose dumps carry a
+ /// `CASCADE` are exactly the engines that offer the user a Cascade checkbox. Resolved once at
+ /// construction, on the main actor, because the registry lives there and this is asked for from
+ /// the export plugin's own thread.
+ let supportsCascadeDrop: Bool
+
init(driver: DatabaseDriver, databaseType: DatabaseType) {
+ self.supportsCascadeDrop = PluginMetadataRegistry.shared
+ .snapshot(for: databaseType)?.capabilities.supportsCascadeDrop ?? false
self.driver = driver
self.dbType = databaseType
self.databaseTypeId = databaseType.rawValue
diff --git a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift
index ad0da9f99..8e2ba5426 100644
--- a/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift
+++ b/TablePro/Core/Plugins/PluginMetadataRegistry+CloudDefaults.swift
@@ -482,6 +482,7 @@ extension PluginMetadataRegistry {
booleanLiteralStyle: .truefalse,
likeEscapeStyle: .explicit,
paginationStyle: .limit,
+ requiresBackslashEscaping: true,
caseSensitivityStyle: .ilikeOperator
),
statementCompletions: [
diff --git a/TablePro/Core/Plugins/QueryResultExportDataSource.swift b/TablePro/Core/Plugins/QueryResultExportDataSource.swift
index 8d29a0686..8c115bb61 100644
--- a/TablePro/Core/Plugins/QueryResultExportDataSource.swift
+++ b/TablePro/Core/Plugins/QueryResultExportDataSource.swift
@@ -13,16 +13,40 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send
private let columns: [String]
private let columnTypeNames: [String]
private let rows: [[PluginCellValue]]
- private let driver: DatabaseDriver?
+
+ /// How this engine quotes an identifier and escapes a literal, resolved once from the driver
+ /// when there is one and from the engine's declared dialect when there is not. There is no
+ /// third answer: writing ANSI for an engine that is not ANSI produces a dump that either will
+ /// not parse or, worse, parses and rewrites the data.
+ private let quoteIdentifierFn: (String) -> String
+ private let escapeStringFn: (String) -> String
private static let logger = Logger(subsystem: "com.TablePro", category: "QueryResultExportDataSource")
init(tableRows: TableRows, databaseType: DatabaseType, driver: DatabaseDriver?) {
self.databaseTypeId = databaseType.rawValue
- self.driver = driver
self.columns = tableRows.columns
self.columnTypeNames = tableRows.columnTypes.map { $0.rawType ?? "" }
self.rows = tableRows.rows.map { row in Array(row.values) }
+
+ if let driver {
+ self.quoteIdentifierFn = { driver.quoteIdentifier($0) }
+ self.escapeStringFn = { driver.escapeStringLiteral($0) }
+ return
+ }
+ /// `resolveSQLDialect` reads the metadata snapshot through `snapshot(for:)`, which remaps a
+ /// variant onto the engine it is a variant of. An engine with no SQL dialect at all
+ /// (MongoDB, Redis) reaches this only through a format that writes no SQL, so ANSI is a
+ /// harmless answer there rather than a wrong one.
+ guard let dialect = try? resolveSQLDialect(for: databaseType) else {
+ Self.logger.warning(
+ "No SQL dialect for \(databaseType.rawValue, privacy: .public), quoting as ANSI")
+ self.quoteIdentifierFn = SQLEscaping.quoteIdentifier
+ self.escapeStringFn = SQLEscaping.escapeStringLiteral
+ return
+ }
+ self.quoteIdentifierFn = quoteIdentifierFromDialect(dialect)
+ self.escapeStringFn = escapeStringLiteralFromDialect(dialect)
}
func streamRows(table: String, databaseName: String) -> AsyncThrowingStream {
@@ -47,17 +71,11 @@ final class QueryResultExportDataSource: PluginExportDataSource, @unchecked Send
}
func quoteIdentifier(_ identifier: String) -> String {
- if let driver {
- return driver.quoteIdentifier(identifier)
- }
- return SQLEscaping.quoteIdentifier(identifier)
+ quoteIdentifierFn(identifier)
}
func escapeStringLiteral(_ value: String) -> String {
- if let driver {
- return driver.escapeStringLiteral(value)
- }
- return SQLEscaping.escapeStringLiteral(value)
+ escapeStringFn(value)
}
func fetchTableDDL(table: String, databaseName: String) async throws -> String {
diff --git a/TablePro/Core/Services/Export/ExportService.swift b/TablePro/Core/Services/Export/ExportService.swift
index 31b0b4fa4..9b1ede026 100644
--- a/TablePro/Core/Services/Export/ExportService.swift
+++ b/TablePro/Core/Services/Export/ExportService.swift
@@ -69,12 +69,35 @@ final class ExportService {
self.databaseType = databaseType
}
- /// Convenience initializer for query results export (no driver needed).
- init(databaseType: DatabaseType) {
- self.driver = nil
+ /// Rows already in memory still need the engine that produced them: a SQL export has to quote
+ /// identifiers and escape literals the way that engine reads them back. The driver is asked for
+ /// nothing but those two pure functions here, so an installed handle is enough and no lease is
+ /// taken. It is optional only because a connection can be gone by the time the sheet runs, and
+ /// the formats that carry no SQL still export fine without one.
+ init(queryResultsDriver driver: DatabaseDriver?, databaseType: DatabaseType) {
+ self.driver = driver
self.databaseType = databaseType
}
+ /// The one table a query export writes. `QueryExportOptions` says why it carries no structure.
+ private static func queryResultExportTable(
+ named name: String,
+ plugin: any ExportFormatPlugin
+ ) -> PluginExportTable {
+ let optionValues = QueryExportOptions.dataOnly(
+ columns: type(of: plugin).perTableOptionColumns,
+ defaults: plugin.defaultTableOptionValues()
+ )
+ return PluginExportTable(
+ name: name,
+ databaseName: "",
+ tableType: "query",
+ optionValues: optionValues,
+ schema: nil,
+ kind: .table
+ )
+ }
+
// MARK: - Cancellation
var isCancelled: Bool = false
@@ -249,14 +272,7 @@ final class ExportService {
}
defer { descObservation.invalidate() }
- let exportTable = PluginExportTable(
- name: config.fileName,
- databaseName: "",
- tableType: "query",
- optionValues: plugin.defaultTableOptionValues(),
- schema: nil,
- kind: .table
- )
+ let exportTable = Self.queryResultExportTable(named: config.fileName, plugin: plugin)
let result: ExportFormatResult
do {
@@ -317,14 +333,7 @@ final class ExportService {
}
defer { observation.invalidate() }
- let exportTable = PluginExportTable(
- name: config.fileName,
- databaseName: "",
- tableType: "query",
- optionValues: plugin.defaultTableOptionValues(),
- schema: nil,
- kind: .table
- )
+ let exportTable = Self.queryResultExportTable(named: config.fileName, plugin: plugin)
await suppressStatementTimeout(on: driver)
let result: ExportFormatResult
diff --git a/TablePro/Core/Services/Export/QueryExportOptions.swift b/TablePro/Core/Services/Export/QueryExportOptions.swift
new file mode 100644
index 000000000..0ab1553b6
--- /dev/null
+++ b/TablePro/Core/Services/Export/QueryExportOptions.swift
@@ -0,0 +1,39 @@
+//
+// QueryExportOptions.swift
+// TablePro
+//
+
+import Foundation
+import TableProPluginKit
+
+/// Which per-object options a query export may ask for.
+///
+/// A result set has no schema behind it. `fetchTableDDL` returns an empty string on both query data
+/// sources, so asking for structure produces no `CREATE` at all, and leaving drop on beside it
+/// wrote `DROP TABLE ` into a dump that then never recreated it. The name is the
+/// real table's whenever the export came from a table tab, so the dump destroyed what it claimed to
+/// copy.
+internal enum QueryExportOptions {
+ /// The option columns a source with no DDL must not be asked for.
+ private static let schemaColumnIds: Set = ["structure", "drop"]
+
+ /// Cleared by column id rather than by position. The ids are per format and the positions do
+ /// not line up: SQL export declares `[structure, drop, data]` while MQL export declares
+ /// `[drop, indexes, data]`, so clearing index 0 and 1 would turn off MQL's indexes and leave
+ /// its drop on, which is the opposite of what is wanted.
+ ///
+ /// A defaults array that is not the length of the column list is discarded whole rather than
+ /// read element-wise, because a mismatched array is exactly the misalignment this is here to
+ /// avoid. `ExportObjectItem.normalized(forOptionColumnCount:defaultOptionValues:)` already
+ /// treats one that way.
+ internal static func dataOnly(
+ columns: [PluginExportOptionColumn],
+ defaults: [Bool]
+ ) -> [Bool] {
+ let aligned = defaults.count == columns.count ? defaults : []
+ return columns.enumerated().map { index, column -> Bool in
+ guard !schemaColumnIds.contains(column.id) else { return false }
+ return aligned.indices.contains(index) ? aligned[index] : column.defaultValue
+ }
+ }
+}
diff --git a/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift
index e04a51f93..bfc9941a9 100644
--- a/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift
+++ b/TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift
@@ -20,7 +20,7 @@ enum SQLDialectError: Error, LocalizedError {
}
}
-func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor) -> (String) -> String {
+func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor) -> @Sendable (String) -> String {
let q = dialect.identifierQuote
if q == "[" {
return { name in
@@ -34,6 +34,37 @@ func quoteIdentifierFromDialect(_ dialect: SQLDialectDescriptor) -> (String) ->
}
}
+/// The body of a single-quoted literal, escaped the way the engine reads it back.
+///
+/// MySQL, MariaDB, ClickHouse and Snowflake treat a backslash as an escape inside a literal, so a
+/// value holding one has to double it. Writing ANSI rules for those engines does not fail: it
+/// silently rewrites the data, and `C:\temp\next` comes back with a tab and a newline in it.
+///
+/// The escaped set matches what the MySQL driver itself writes, character for character, so a dump
+/// taken with a driver and one taken without are the same file. `\u{1A}` is the one that matters
+/// beyond tidiness: a raw SUB byte truncates a dump fed to the Windows `mysql` client, which is why
+/// `mysqldump` writes `\Z`.
+///
+/// Dameng is the one engine a static descriptor cannot answer for, because it detects its own
+/// escaping at connect time and its descriptor carries the pre-detection default. Ask its driver,
+/// which is what every path holding one already does.
+func escapeStringLiteralFromDialect(_ dialect: SQLDialectDescriptor) -> @Sendable (String) -> String {
+ guard dialect.requiresBackslashEscaping else { return SQLEscaping.escapeStringLiteral }
+ return { value in
+ var result = value
+ result = result.replacingOccurrences(of: "\\", with: "\\\\")
+ result = result.replacingOccurrences(of: "'", with: "''")
+ result = result.replacingOccurrences(of: "\n", with: "\\n")
+ result = result.replacingOccurrences(of: "\r", with: "\\r")
+ result = result.replacingOccurrences(of: "\t", with: "\\t")
+ result = result.replacingOccurrences(of: "\0", with: "\\0")
+ result = result.replacingOccurrences(of: "\u{08}", with: "\\b")
+ result = result.replacingOccurrences(of: "\u{0C}", with: "\\f")
+ result = result.replacingOccurrences(of: "\u{1A}", with: "\\Z")
+ return result
+ }
+}
+
func resolveSQLDialect(
for databaseType: DatabaseType,
explicit: SQLDialectDescriptor? = nil
diff --git a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
index ccca8d187..2614db22a 100644
--- a/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
+++ b/TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
@@ -38,24 +38,11 @@ internal struct SQLRowToStatementConverter {
let resolvedDialect = try resolveSQLDialect(for: databaseType, explicit: dialect)
self.quoteIdentifierFn = quoteIdentifier ?? quoteIdentifierFromDialect(resolvedDialect)
- self.escapeStringFn = escapeStringLiteral ?? Self.defaultEscapeFunction(dialect: resolvedDialect)
+ self.escapeStringFn = escapeStringLiteral ?? escapeStringLiteralFromDialect(resolvedDialect)
}
private static let maxRows = 50_000
- private static func defaultEscapeFunction(dialect: SQLDialectDescriptor) -> (String) -> String {
- if dialect.requiresBackslashEscaping {
- return { value in
- var result = value
- result = result.replacingOccurrences(of: "\\", with: "\\\\")
- result = result.replacingOccurrences(of: "'", with: "''")
- result = result.replacingOccurrences(of: "\0", with: "\\0")
- return result
- }
- }
- return SQLEscaping.escapeStringLiteral
- }
-
internal func generateInserts(rows: [[PluginCellValue]]) -> String {
let capped = rows.prefix(Self.maxRows)
let quotedTable = quoteColumn(tableName)
diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift
index 2b2559bc3..f173e4208 100644
--- a/TablePro/Views/Export/ExportDialog.swift
+++ b/TablePro/Views/Export/ExportDialog.swift
@@ -1100,7 +1100,10 @@ struct ExportDialog: View {
try await runStreamingExport(on: driver, query: query, to: url)
}
case .queryResults(_, let tableRows, _):
- let service = ExportService(databaseType: connection.type)
+ let service = ExportService(
+ queryResultsDriver: DatabaseManager.shared.driver(for: connection.id),
+ databaseType: connection.type
+ )
exportService = service
try await service.exportQueryResults(tableRows: tableRows, config: config, to: url)
default:
diff --git a/TableProTests/Helpers/SQLExportHarness.swift b/TableProTests/Helpers/SQLExportHarness.swift
new file mode 100644
index 000000000..bff366ff4
--- /dev/null
+++ b/TableProTests/Helpers/SQLExportHarness.swift
@@ -0,0 +1,46 @@
+//
+// SQLExportHarness.swift
+// TableProTests
+//
+
+import Foundation
+import TableProPluginKit
+
+@testable import TablePro
+
+/// Runs a real SQL export and hands back the dump it wrote.
+///
+/// `SQLExportPlugin.settings` has a `didSet` that writes to the app's own `UserDefaults`, so the
+/// capture, reset and restore around an export is a read-modify-write over state every suite
+/// shares. Swift Testing runs suites in parallel, so two of them doing that by hand interleave:
+/// one restores what the other captured, and the developer's real export settings are left at
+/// whatever the loser wrote. A leaked gzip flag also makes the dump unreadable as text, which
+/// reads as a flaky assertion rather than as the cross-suite write it is.
+///
+/// One actor owns that window, so every export harness in the target queues behind it.
+internal actor SQLExportHarness {
+ internal static let shared = SQLExportHarness()
+
+ internal func dump(
+ tables: [PluginExportTable],
+ dataSource: any PluginExportDataSource
+ ) async throws -> (text: String, result: ExportFormatResult) {
+ let plugin = SQLExportPlugin()
+ let storedSettings = plugin.settings
+ plugin.settings = SQLExportOptions()
+ let destination = FileManager.default.temporaryDirectory
+ .appendingPathComponent("\(UUID().uuidString).sql")
+ defer {
+ plugin.settings = storedSettings
+ try? FileManager.default.removeItem(at: destination)
+ }
+
+ let result = try await plugin.export(
+ tables: tables,
+ dataSource: dataSource,
+ destination: destination,
+ progress: PluginExportProgress(progress: Progress(totalUnitCount: 1))
+ )
+ return (try String(contentsOf: destination, encoding: .utf8), result)
+ }
+}
diff --git a/TableProTests/Plugins/SQLExportDialectTests.swift b/TableProTests/Plugins/SQLExportDialectTests.swift
new file mode 100644
index 000000000..89459fbcb
--- /dev/null
+++ b/TableProTests/Plugins/SQLExportDialectTests.swift
@@ -0,0 +1,255 @@
+//
+// SQLExportDialectTests.swift
+// TableProTests
+//
+
+import Foundation
+import TableProPluginKit
+import Testing
+
+@testable import TablePro
+
+/// What a SQL export writes has to be what the source engine reads back.
+///
+/// The expected strings here are not a preference. Each was run against a live MariaDB 12.3 and
+/// sqlite3 while fixing #2630, and what each engine accepted is recorded beside it. CI reaches
+/// neither engine, so these tests are the record of that measurement.
+@Suite("SQL export dialect")
+struct SQLExportDialectTests {
+
+ private func dialect(for type: DatabaseType) -> SQLDialectDescriptor? {
+ try? resolveSQLDialect(for: type)
+ }
+
+ // MARK: - Identifier quoting
+
+ /// Measured: MariaDB rejects `INSERT INTO "fields" ("id") VALUES (1)` with ERROR 1064, because
+ /// outside ANSI_QUOTES a double-quoted token is a string literal and not an identifier. That is
+ /// the reported bug: the query-results export had no driver and fell back to ANSI quoting.
+ @Test("A MySQL dialect quotes with backticks, not double quotes")
+ func mysqlQuotesWithBackticks() throws {
+ let quote = quoteIdentifierFromDialect(try #require(dialect(for: .mysql)))
+ #expect(quote("fields") == "`fields`")
+ #expect(quote("fields") != "\"fields\"")
+ }
+
+ @Test("A PostgreSQL dialect quotes with double quotes")
+ func postgresQuotesWithDoubleQuotes() throws {
+ let quote = quoteIdentifierFromDialect(try #require(dialect(for: .postgresql)))
+ #expect(quote("fields") == "\"fields\"")
+ }
+
+ /// SQL Server opens with `[` and closes with `]`, so a naive quote + name + quote produces
+ /// `[fields[`. Reading `identifierQuote` raw gets this wrong; the helper carries the case.
+ @Test("SQL Server brackets close with the other character")
+ func sqlServerUsesBrackets() throws {
+ let quote = quoteIdentifierFromDialect(try #require(dialect(for: .mssql)))
+ #expect(quote("fields") == "[fields]")
+ #expect(quote("we]ird") == "[we]]ird]")
+ }
+
+ @Test("Every engine escapes its own quote character inside an identifier")
+ func embeddedQuotesAreEscaped() throws {
+ let mysql = quoteIdentifierFromDialect(try #require(dialect(for: .mysql)))
+ #expect(mysql("we`ird") == "`we``ird`")
+
+ let postgres = quoteIdentifierFromDialect(try #require(dialect(for: .postgresql)))
+ #expect(postgres("we\"ird") == "\"we\"\"ird\"")
+ }
+
+ // MARK: - Literal escaping
+
+ /// Measured: `C:\temp\next` written with ANSI rules re-imports into MariaDB as hex
+ /// 433A09656D700A657874, ten bytes rather than twelve, because `\t` became a tab and `\n` a
+ /// newline. Silent corruption, which is why the export must never guess this.
+ @Test("A MySQL dialect doubles a backslash so the value survives the round trip")
+ func mysqlEscapesBackslashes() throws {
+ let escape = escapeStringLiteralFromDialect(try #require(dialect(for: .mysql)))
+ #expect(escape("C:\\temp\\next") == "C:\\\\temp\\\\next")
+ #expect(escape("O'Brien") == "O''Brien")
+ }
+
+ /// A dump taken without a driver has to be the same file as one taken with it. The MySQL
+ /// driver escapes nine characters; escaping only the backslash and the quote left a raw
+ /// `\u{1A}` in the output, which truncates a dump fed to the Windows `mysql` client.
+ @Test("The dialect escaper writes what the MySQL driver writes")
+ func mysqlEscaperMatchesTheDriver() throws {
+ let escape = escapeStringLiteralFromDialect(try #require(dialect(for: .mysql)))
+ #expect(escape("a\nb") == "a\\nb")
+ #expect(escape("a\tb") == "a\\tb")
+ #expect(escape("a\rb") == "a\\rb")
+ #expect(escape("a\u{1A}b") == "a\\Zb")
+ #expect(escape("a\u{08}b") == "a\\bb")
+ #expect(escape("a\u{0C}b") == "a\\fb")
+ }
+
+ /// PostgreSQL reads a backslash literally in a standard-conforming string, so doubling it
+ /// there would insert a second backslash into the data.
+ @Test("A PostgreSQL dialect leaves a backslash alone")
+ func postgresLeavesBackslashesAlone() throws {
+ let escape = escapeStringLiteralFromDialect(try #require(dialect(for: .postgresql)))
+ #expect(escape("C:\\temp\\next") == "C:\\temp\\next")
+ #expect(escape("O'Brien") == "O''Brien")
+ }
+
+ /// Snowflake's driver declares `requiresBackslashEscapingInLiterals` and neither of its
+ /// descriptors did, so the driver-backed and driver-free paths escaped the same value
+ /// differently.
+ ///
+ /// This reads the curated snapshot, because plugins never load under XCTest. The declaration
+ /// that matters in the shipping app is the plugin's own, in `SnowflakePlugin.swift`:
+ /// `buildMetadataSnapshot` takes `editor.sqlDialect` from `driverType.sqlDialect` and replaces
+ /// the curated one outright once the plugin loads. Both now declare it.
+ @Test("Snowflake's descriptor agrees with its driver about backslashes")
+ func snowflakeDescriptorDeclaresBackslashEscaping() throws {
+ let descriptor = try #require(dialect(for: DatabaseType(rawValue: "Snowflake")))
+ #expect(descriptor.requiresBackslashEscaping)
+ }
+}
+
+/// A result set has no schema, so a query export must write neither `CREATE` nor `DROP`.
+@Suite("Query export options")
+struct QueryExportOptionsTests {
+
+ private func column(_ id: String, _ label: String) -> PluginExportOptionColumn {
+ PluginExportOptionColumn(id: id, label: label, width: 44)
+ }
+
+ /// `fetchTableDDL` returns "" for both query data sources, so structure produces no CREATE.
+ /// Leaving drop on beside it wrote `DROP TABLE ` into a dump that never
+ /// recreated it, and the name is the real table's whenever the export came from a table tab.
+ @Test("A SQL query export writes data only, never structure or drop")
+ func sqlQueryExportIsDataOnly() {
+ let columns = [column("structure", "Structure"), column("drop", "Drop"), column("data", "Data")]
+ #expect(
+ QueryExportOptions.dataOnly(columns: columns, defaults: [true, true, true])
+ == [false, false, true])
+ }
+
+ /// The ids are per format and the positions do not line up: MQL declares
+ /// `[drop, indexes, data]`, so clearing index 0 and 1 would turn off its indexes and leave its
+ /// drop on, which is the opposite of what is wanted.
+ @Test("Clearing is keyed by column id, because the positions differ per format")
+ func mqlQueryExportKeepsIndexesAndClearsDrop() {
+ let columns = [column("drop", "Drop"), column("indexes", "Indexes"), column("data", "Data")]
+ #expect(
+ QueryExportOptions.dataOnly(columns: columns, defaults: [true, true, true])
+ == [false, true, true])
+ }
+
+ @Test("A format with no per-object options is left alone")
+ func formatWithoutOptions() {
+ #expect(QueryExportOptions.dataOnly(columns: [], defaults: []).isEmpty)
+ }
+
+ /// A plugin whose defaults are shorter than its columns falls back to each column's own
+ /// default rather than reading off the end.
+ @Test("A short defaults array falls back to the column's own default")
+ func shortDefaultsFallBack() {
+ let columns = [column("structure", "Structure"), column("data", "Data")]
+ #expect(QueryExportOptions.dataOnly(columns: columns, defaults: []) == [false, true])
+ }
+}
+
+/// `DROP ... CASCADE` follows the engine, because it is not portable.
+///
+/// Measured: MariaDB accepts it and the manual says it does nothing ("permitted to make porting
+/// easier"); sqlite3 rejects `DROP TABLE IF EXISTS "fields" CASCADE;` outright with
+/// `near "CASCADE": syntax error`. The clause was previously emitted for every engine.
+@Suite("SQL export drop clause")
+struct SQLExportDropClauseTests {
+
+ private final class StubExportDataSource: PluginExportDataSource, @unchecked Sendable {
+ let databaseTypeId: String
+ let supportsCascadeDrop: Bool
+
+ init(databaseTypeId: String, supportsCascadeDrop: Bool) {
+ self.databaseTypeId = databaseTypeId
+ self.supportsCascadeDrop = supportsCascadeDrop
+ }
+
+ func streamRows(table: String, databaseName: String) -> AsyncThrowingStream {
+ AsyncThrowingStream { continuation in
+ continuation.yield(.header(PluginStreamHeader(columns: ["id"], columnTypeNames: ["INTEGER"])))
+ continuation.yield(.rows([[.text("1")]]))
+ continuation.finish()
+ }
+ }
+
+ func fetchTableDDL(table: String, databaseName: String) async throws -> String {
+ "CREATE TABLE \(table) (id INTEGER)"
+ }
+
+ func execute(query: String) async throws -> PluginQueryResult {
+ PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
+ }
+
+ func quoteIdentifier(_ identifier: String) -> String {
+ "`\(identifier.replacingOccurrences(of: "`", with: "``"))`"
+ }
+
+ func escapeStringLiteral(_ value: String) -> String {
+ value.replacingOccurrences(of: "'", with: "''")
+ }
+
+ func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { nil }
+ }
+
+ /// Declares no capability at all, so what it answers is the protocol extension's default.
+ private final class SilentExportDataSource: PluginExportDataSource, @unchecked Sendable {
+ let databaseTypeId = "SQLite"
+
+ func streamRows(table: String, databaseName: String) -> AsyncThrowingStream {
+ AsyncThrowingStream { $0.finish() }
+ }
+
+ func fetchTableDDL(table: String, databaseName: String) async throws -> String { "" }
+
+ func execute(query: String) async throws -> PluginQueryResult {
+ PluginQueryResult(columns: [], columnTypeNames: [], rows: [], rowsAffected: 0, executionTime: 0)
+ }
+
+ func quoteIdentifier(_ identifier: String) -> String { identifier }
+ func escapeStringLiteral(_ value: String) -> String { value }
+ func fetchApproximateRowCount(table: String, databaseName: String) async throws -> Int? { nil }
+ }
+
+ private func dump(databaseTypeId: String, supportsCascadeDrop: Bool) async throws -> String {
+ try await SQLExportHarness.shared.dump(
+ tables: [
+ PluginExportTable(
+ name: "fields",
+ databaseName: "",
+ tableType: "table",
+ optionValues: [true, true, true],
+ schema: nil
+ )
+ ],
+ dataSource: StubExportDataSource(
+ databaseTypeId: databaseTypeId, supportsCascadeDrop: supportsCascadeDrop)
+ ).text
+ }
+
+ @Test("An engine that does not take CASCADE gets a plain DROP")
+ func engineWithoutCascade() async throws {
+ let sql = try await dump(databaseTypeId: "MySQL", supportsCascadeDrop: false)
+ #expect(sql.contains("DROP TABLE IF EXISTS `fields`;"))
+ #expect(!sql.contains("CASCADE"))
+ }
+
+ /// PostgreSQL is the engine the clause was written for: it drops dependent views with it.
+ @Test("An engine that takes CASCADE keeps it")
+ func engineWithCascade() async throws {
+ let sql = try await dump(databaseTypeId: "PostgreSQL", supportsCascadeDrop: true)
+ #expect(sql.contains("DROP TABLE IF EXISTS `fields` CASCADE;"))
+ }
+
+ /// The default is the answer that is never a syntax error. This asserts the protocol
+ /// extension's own value, so flipping that default fails here rather than shipping a dump
+ /// SQLite refuses.
+ @Test("A data source that declares nothing gets no CASCADE")
+ func defaultIsNoCascade() {
+ let source: any PluginExportDataSource = SilentExportDataSource()
+ #expect(!source.supportsCascadeDrop)
+ }
+}
diff --git a/TableProTests/Plugins/SQLExportForeignKeyOrderTests.swift b/TableProTests/Plugins/SQLExportForeignKeyOrderTests.swift
index cafa2ecd7..1995f5e54 100644
--- a/TableProTests/Plugins/SQLExportForeignKeyOrderTests.swift
+++ b/TableProTests/Plugins/SQLExportForeignKeyOrderTests.swift
@@ -75,26 +75,8 @@ struct SQLExportForeignKeyOrderTests {
tables: [PluginExportTable],
dataSource: StubExportDataSource
) async throws -> (dump: String, result: ExportFormatResult) {
- let plugin = SQLExportPlugin()
- /// The plugin loads its settings from the app's own defaults, so a developer who has
- /// turned gzip on would otherwise get a compressed file the assertions cannot read.
- let storedSettings = plugin.settings
- plugin.settings = SQLExportOptions()
- let destination = FileManager.default.temporaryDirectory
- .appendingPathComponent("\(UUID().uuidString).sql")
- defer {
- plugin.settings = storedSettings
- try? FileManager.default.removeItem(at: destination)
- }
-
- let result = try await plugin.export(
- tables: tables,
- dataSource: dataSource,
- destination: destination,
- progress: PluginExportProgress(progress: Progress(totalUnitCount: 1))
- )
- let dump = try String(contentsOf: destination, encoding: .utf8)
- return (dump, result)
+ let output = try await SQLExportHarness.shared.dump(tables: tables, dataSource: dataSource)
+ return (output.text, output.result)
}
private func createOrder(in dump: String, of tables: [String]) -> [String] {
diff --git a/docs/features/import-export.mdx b/docs/features/import-export.mdx
index ad60048e7..946704f20 100644
--- a/docs/features/import-export.mdx
+++ b/docs/features/import-export.mdx
@@ -66,6 +66,8 @@ A whole-table export streams from the database at constant memory, with no row-c
**Export Results…** writes what the tab holds in memory, which is the output of the query that filled it. The filter bar and a sorted header are part of that query, and so is column visibility on a table tab: hiding a column re-queries without it, keeping only the primary key and anything being sorted on. Two things are not part of it. A column value filter narrows the loaded rows in the grid afterwards, so it never reaches the file. And a truncated result with more rows behind it is re-run and streamed in full rather than written as far as it got.
+As SQL, a results export writes `INSERT` statements only. A result set is the output of a query rather than a table, so there is no schema to recreate and nothing to drop: the **Structure** and **Drop** options belong to the toolbar export, which reads real tables. Identifiers are quoted and values escaped the way the source engine reads them, so the file imports back into it.
+
### Formats