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