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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 11 additions & 3 deletions Plugins/SQLExportPlugin/SQLExportPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,12 @@
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 <table>` 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
Expand All @@ -377,7 +383,7 @@
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));"
}
}

Expand All @@ -396,7 +402,8 @@
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 {
Expand All @@ -409,7 +416,8 @@
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")
}
Expand Down Expand Up @@ -729,7 +737,7 @@
switch element {
case .header(let header):
columns = header.columns
columnTypeNames = header.columnTypeNames ?? []

Check warning on line 740 in Plugins/SQLExportPlugin/SQLExportPlugin.swift

View workflow job for this annotation

GitHub Actions / Build for testing

left side of nil coalescing operator '??' has non-optional type '[String]', so the right side is never used
case .rows(let rows):
for row in rows {
rowBatch.append(row)
Expand Down
1 change: 1 addition & 0 deletions Plugins/SnowflakeDriverPlugin/SnowflakePlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ final class SnowflakePlugin: NSObject, TableProPlugin, DriverPlugin {
booleanLiteralStyle: .truefalse,
likeEscapeStyle: .explicit,
paginationStyle: .limit,
requiresBackslashEscaping: true,
caseSensitivityStyle: .ilikeOperator
)

Expand Down
8 changes: 8 additions & 0 deletions Plugins/TableProPluginKit/PluginExportDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <table>` 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.
Expand Down Expand Up @@ -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<PluginStreamElement, Error> {
Expand Down
8 changes: 8 additions & 0 deletions TablePro/Core/Plugins/ExportDataSourceAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,7 @@ extension PluginMetadataRegistry {
booleanLiteralStyle: .truefalse,
likeEscapeStyle: .explicit,
paginationStyle: .limit,
requiresBackslashEscaping: true,
caseSensitivityStyle: .ilikeOperator
),
statementCompletions: [
Expand Down
38 changes: 28 additions & 10 deletions TablePro/Core/Plugins/QueryResultExportDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<PluginStreamElement, Error> {
Expand All @@ -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 {
Expand Down
47 changes: 28 additions & 19 deletions TablePro/Core/Services/Export/ExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down
39 changes: 39 additions & 0 deletions TablePro/Core/Services/Export/QueryExportOptions.swift
Original file line number Diff line number Diff line change
@@ -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 <the source 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<String> = ["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
}
}
}
33 changes: 32 additions & 1 deletion TablePro/Core/Utilities/SQL/DialectQuoteHelper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 1 addition & 14 deletions TablePro/Core/Utilities/SQL/SQLRowToStatementConverter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion TablePro/Views/Export/ExportDialog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading
Loading