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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- Export, Transfer and Server-Side Export sheets resize, and the export object tree takes the extra room. (#2618)
- Cancel sits beside the action button in the export, transfer and server-side export footers.
- PluginKit ABI 21. Every registry plugin needs rebuilding before or with this release.
- Query Insights ranks on the time the database spent rather than on elapsed time. (#2503)
- Export summary reports the warnings an export produced, instead of a bare "Export completed". (#2517)

### Fixed

- 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.
- Checkboxes, pickers and text fields across the import and export sheets unnamed to VoiceOver.
- Import plugin options kept after cancelling the row importer, `Delete existing rows` among them.
- Escape not dismissing the export and import result alerts.
- A stopped import closing its progress sheet without saying what had already run.
- A failed restore leaving a partly-restored database without saying so.
- Oracle server-side export reported as written when the Data Pump job had only been started.
- Import stuck on a file that will not parse, with the parser's message as placeholder text.
- Backup save panel and password warning landing on whatever window was frontmost.
- DuckDB aggregate, JSON and Parquet functions failing on a Mac that cannot reach `extensions.duckdb.org`. (#2626)
- Last line of a helper process's output lost when it exits right after writing it.
- Structure and trigger edits committing or rolling back a transaction left open in a query tab on the same connection.
Expand Down
8 changes: 4 additions & 4 deletions Plugins/CSVExportPlugin/CSVExportOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct CSVExportOptionsView: View {

VStack(alignment: .leading, spacing: 10) {
optionRow(String(localized: "Delimiter", bundle: .main)) {
Picker("", selection: $plugin.settings.delimiter) {
Picker(String(localized: "Delimiter", bundle: .main), selection: $plugin.settings.delimiter) {
ForEach(CSVDelimiter.allCases) { delimiter in
Text(delimiter.displayName).tag(delimiter)
}
Expand All @@ -41,7 +41,7 @@ struct CSVExportOptionsView: View {
}

optionRow(String(localized: "Quote", bundle: .main)) {
Picker("", selection: $plugin.settings.quoteHandling) {
Picker(String(localized: "Quote", bundle: .main), selection: $plugin.settings.quoteHandling) {
ForEach(CSVQuoteHandling.allCases) { handling in
Text(handling.rawValue).tag(handling)
}
Expand All @@ -52,7 +52,7 @@ struct CSVExportOptionsView: View {
}

optionRow(String(localized: "Line break", bundle: .main)) {
Picker("", selection: $plugin.settings.lineBreak) {
Picker(String(localized: "Line break", bundle: .main), selection: $plugin.settings.lineBreak) {
ForEach(CSVLineBreak.allCases) { lineBreak in
Text(lineBreak.rawValue).tag(lineBreak)
}
Expand All @@ -63,7 +63,7 @@ struct CSVExportOptionsView: View {
}

optionRow(String(localized: "Decimal", bundle: .main)) {
Picker("", selection: $plugin.settings.decimalFormat) {
Picker(String(localized: "Decimal", bundle: .main), selection: $plugin.settings.decimalFormat) {
ForEach(CSVDecimalFormat.allCases) { format in
Text(format.rawValue).tag(format)
}
Expand Down
8 changes: 4 additions & 4 deletions Plugins/CSVImportPlugin/CSVImportOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct CSVImportOptionsView: View {
GridRow {
Text("Delimiter:")
.gridColumnAlignment(.trailing)
Picker("", selection: Bindable(plugin).settings.delimiter) {
Picker(String(localized: "Delimiter", bundle: .main), selection: Bindable(plugin).settings.delimiter) {
Text("Auto-detect").tag(CSVImportOptions.Delimiter.auto)
Text("Comma (,)").tag(CSVImportOptions.Delimiter.comma)
Text("Semicolon (;)").tag(CSVImportOptions.Delimiter.semicolon)
Expand All @@ -29,7 +29,7 @@ struct CSVImportOptionsView: View {

GridRow {
Text("Quote character:")
Picker("", selection: Bindable(plugin).settings.quoteCharacter) {
Picker(String(localized: "Quote character", bundle: .main), selection: Bindable(plugin).settings.quoteCharacter) {
Text("Double quote (\")").tag(CSVImportOptions.QuoteCharacter.doubleQuote)
Text("Single quote (')").tag(CSVImportOptions.QuoteCharacter.singleQuote)
}
Expand All @@ -40,7 +40,7 @@ struct CSVImportOptionsView: View {

GridRow {
Text("Encoding:")
Picker("", selection: Bindable(plugin).settings.encoding) {
Picker(String(localized: "Encoding", bundle: .main), selection: Bindable(plugin).settings.encoding) {
Text("Auto-detect").tag(CSVImportOptions.TextEncoding.auto)
Text("UTF-8").tag(CSVImportOptions.TextEncoding.utf8)
Text("ISO Latin 1").tag(CSVImportOptions.TextEncoding.isoLatin1)
Expand All @@ -53,7 +53,7 @@ struct CSVImportOptionsView: View {

GridRow {
Text("On error:")
Picker("", selection: Bindable(plugin).settings.errorHandling) {
Picker(String(localized: "On error", bundle: .main), selection: Bindable(plugin).settings.errorHandling) {
Text("Stop and Rollback").tag(ImportErrorHandling.stopAndRollback)
Text("Stop and Commit").tag(ImportErrorHandling.stopAndCommit)
Text("Skip and Continue").tag(ImportErrorHandling.skipAndContinue)
Expand Down
2 changes: 1 addition & 1 deletion Plugins/JSONExportPlugin/JSONExportOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct JSONExportOptionsView: View {

Spacer()

Picker("", selection: $plugin.settings.layout) {
Picker(String(localized: "Layout", bundle: .main), selection: $plugin.settings.layout) {
ForEach(JSONExportLayout.allCases) { layout in
Text(layout.label).tag(layout)
}
Expand Down
2 changes: 1 addition & 1 deletion Plugins/MQLExportPlugin/MQLExportOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ struct MQLExportOptionsView: View {

Spacer()

Picker("", selection: $plugin.settings.batchSize) {
Picker(String(localized: "Rows per insertMany", bundle: .main), selection: $plugin.settings.batchSize) {
ForEach(Self.batchSizeOptions, id: \.self) { size in
Text("\(size)")
.tag(size)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ struct MarkdownExportOptionsView: View {
VStack(alignment: .leading, spacing: 8) {
Toggle("Align columns", isOn: $plugin.settings.alignsColumns)
.toggleStyle(.checkbox)
.help("Pads cells so the columns line up in the raw text. Widths come from the header and the first 200 rows")
.help("Pads cells so the columns line up in the raw text. Widths come from the header and the first 200 rows.")

Toggle("Write each table's name as a heading", isOn: $plugin.settings.includesTableNames)
.toggleStyle(.checkbox)
Expand Down
6 changes: 3 additions & 3 deletions Plugins/ParquetExportPlugin/ParquetExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ public enum ParquetCompression: String, Codable, CaseIterable, Sendable, Identif

public var label: String {
switch self {
case .snappy: return String(localized: "Snappy")
case .zstd: return String(localized: "Zstd")
case .gzip: return String(localized: "Gzip")
case .snappy: return "Snappy"
case .zstd: return "Zstd"
case .gzip: return "Gzip"
case .uncompressed: return String(localized: "None")
}
}
Expand Down
6 changes: 3 additions & 3 deletions Plugins/ParquetExportPlugin/ParquetExportOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ struct ParquetExportOptionsView: View {
HStack {
Text("Compression")
Spacer()
Picker("", selection: $plugin.settings.compression) {
Picker(String(localized: "Compression", bundle: .main), selection: $plugin.settings.compression) {
ForEach(ParquetCompression.allCases) { compression in
Text(compression.label).tag(compression)
}
Expand All @@ -24,12 +24,12 @@ struct ParquetExportOptionsView: View {
.labelsHidden()
.frame(width: 130)
}
.help("Snappy is what every Parquet reader supports. Zstd is smaller and needs a reader built with it")
.help("Snappy is what every Parquet reader supports. Zstd is smaller and needs a reader built with it.")

HStack {
Text("Rows per group")
Spacer()
Picker("", selection: $plugin.settings.rowGroupSize) {
Picker(String(localized: "Rows per group", bundle: .main), selection: $plugin.settings.rowGroupSize) {
ForEach(Self.rowGroupSizes, id: \.self) { size in
Text(size.formatted()).tag(size)
}
Expand Down
6 changes: 3 additions & 3 deletions Plugins/SQLExportPlugin/SQLExportOptionsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ struct SQLExportOptionsView: View {

Spacer()

Picker("", selection: $plugin.settings.batchSize) {
Picker(String(localized: "Rows per INSERT", bundle: .main), selection: $plugin.settings.batchSize) {
ForEach(Self.batchSizeOptions, id: \.self) { size in
Text(size == 1 ? String(localized: "1 (no batching)", bundle: .main) : "\(size)")
.tag(size)
Expand All @@ -77,7 +77,7 @@ struct SQLExportOptionsView: View {

Spacer()

Picker("", selection: $plugin.settings.insertMode) {
Picker(String(localized: "On existing rows", bundle: .main), selection: $plugin.settings.insertMode) {
ForEach(SQLExportInsertMode.allCases) { mode in
Text(mode.label).tag(mode)
}
Expand All @@ -94,7 +94,7 @@ struct SQLExportOptionsView: View {

Spacer()

Picker("", selection: $plugin.settings.splitSizeMegabytes) {
Picker(String(localized: "Split every", bundle: .main), selection: $plugin.settings.splitSizeMegabytes) {
ForEach(Self.splitSizeOptions, id: \.self) { size in
Text(size == 0 ? String(localized: "One file", bundle: .main) : "\(size) MB")
.tag(size)
Expand Down
9 changes: 6 additions & 3 deletions TablePro/Core/Database/NativeDumpService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ enum NativeDumpState: Equatable {
case running(database: String, fileURL: URL, bytesProcessed: Int64, totalBytes: Int64?)
case cancelling
case finished(database: String, fileURL: URL, bytesProcessed: Int64)
case failed(message: String)
/// A restore that fails part way through has already replayed some of the dump, and the
/// target is left in whatever state that reached. A backup writes only to its own file, which
/// is removed, so nothing of the user's is touched.
case failed(message: String, targetMayBeModified: Bool)
case cancelled
}

Expand Down Expand Up @@ -206,7 +209,7 @@ final class NativeDumpService {
return (name, path)
}).first else {
throw NativeDumpError.binaryNotFound(
name: candidates.joined(separator: String(localized: " or ")),
name: candidates.formatted(.list(type: .or)),
installHint: descriptor.installHint
)
}
Expand Down Expand Up @@ -397,7 +400,7 @@ final class NativeDumpService {
let summary = result.stderr.isEmpty
? String(format: String(localized: "Process exited with code %d"), Int(result.exitCode))
: result.stderr
setState(.failed(message: summary))
setState(.failed(message: summary, targetMayBeModified: kind == .restore))
Self.logger.error("\(self.kind == .backup ? "pg_dump" : "pg_restore", privacy: .public) failed code=\(result.exitCode) db=\(database, privacy: .public) stderr=\(result.stderr)")
}

Expand Down
39 changes: 26 additions & 13 deletions TablePro/Core/Database/ServerSideExport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ enum ServerSideExport {
) -> String? {
switch request.destination {
case .oracleDirectory(let directory):
return oracleStatement(request, directory: directory)
return oracleStatement(request, directory: directory, escape: escapeLiteral)
case .snowflakeStage(let stage):
return snowflakeStatement(request, stage: stage, quote: quoteIdentifier, escape: escapeLiteral)
case .googleCloudStorage(let uri):
Expand All @@ -109,32 +109,45 @@ enum ServerSideExport {
/// caller is told where rather than handed anything.
private static func oracleStatement(
_ request: Request,
directory: String
directory: String,
escape: (String) -> String
) -> String? {
guard !directory.isEmpty else { return nil }
let dumpFile = "\(sanitizedFileStem(request.table)).dmp"
let logFile = "\(sanitizedFileStem(request.table)).log"
let stem = sanitizedFileStem(request.table)
let directoryLiteral = escape(directory.uppercased())
let tableFilter = nestedLiteral(request.table.uppercased(), escape: escape)
return """
DECLARE
handle NUMBER;
BEGIN
handle := DBMS_DATAPUMP.OPEN('EXPORT', 'TABLE', NULL, '\(sanitizedFileStem(request.table))');
DBMS_DATAPUMP.ADD_FILE(handle, '\(dumpFile)', '\(directory.uppercased())');
DBMS_DATAPUMP.ADD_FILE(handle, '\(logFile)', '\(directory.uppercased())', NULL,
handle := DBMS_DATAPUMP.OPEN('EXPORT', 'TABLE', NULL, '\(escape(stem))');
DBMS_DATAPUMP.ADD_FILE(handle, '\(escape(stem)).dmp', '\(directoryLiteral)');
DBMS_DATAPUMP.ADD_FILE(handle, '\(escape(stem)).log', '\(directoryLiteral)', NULL,
DBMS_DATAPUMP.KU$_FILE_TYPE_LOG_FILE);
DBMS_DATAPUMP.METADATA_FILTER(handle, 'NAME_EXPR', 'IN (''\(request.table.uppercased())'')');
DBMS_DATAPUMP.METADATA_FILTER(handle, 'SCHEMA_EXPR', 'IN (''\(schemaFilter(request))'')');
DBMS_DATAPUMP.METADATA_FILTER(handle, 'NAME_EXPR', 'IN (''\(tableFilter)'')');
DBMS_DATAPUMP.METADATA_FILTER(handle, 'SCHEMA_EXPR', \(schemaFilterExpression(request, escape: escape)));
DBMS_DATAPUMP.START_JOB(handle);
DBMS_DATAPUMP.DETACH(handle);
END;
"""
}

/// Data Pump filters by schema separately from table, so an unqualified request exports from
/// whatever schema the session is in, which is what `USER` names.
private static func schemaFilter(_ request: Request) -> String {
guard let schema = request.schema, !schema.isEmpty else { return "'' || USER || ''" }
return schema.uppercased()
/// whatever schema the session is in, which is what `USER` names. `USER` is concatenated in
/// PL/SQL rather than written inside a literal, because a literal cannot hold an identifier.
private static func schemaFilterExpression(_ request: Request, escape: (String) -> String) -> String {
guard let schema = request.schema, !schema.isEmpty else {
return "'IN (''' || USER || ''')'"
}
return "'IN (''\(nestedLiteral(schema.uppercased(), escape: escape))'')'"
}

/// A value written inside a literal that is itself inside a literal, which is what Data Pump's
/// `NAME_EXPR` and `SCHEMA_EXPR` are. A quote has to survive both levels, so it is escaped
/// twice. An Oracle identifier may legally hold one, and left raw it closes the outer literal
/// early and hands the rest of the name to the parser as PL/SQL.
private static func nestedLiteral(_ value: String, escape: (String) -> String) -> String {
escape(escape(value))
}

// MARK: - Snowflake
Expand Down
53 changes: 53 additions & 0 deletions TablePro/Core/Services/Export/ExportFormatCatalog.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
//
// ExportFormatCatalog.swift
// TablePro
//

import Foundation
import TableProPluginKit

/// How the export dialog orders and describes the formats it offers.
///
/// A format that is not listed here is still shown: it sorts after the curated ones by display
/// name and describes itself from its own `pluginDescription`. A registry format installed after
/// this app was built would otherwise tie with every other unknown format at the end of the list
/// and show no description at all, which is how Parquet shipped.
internal enum ExportFormatCatalog {
private static let displayOrder = [
"csv", "json", "sql", "xlsx", "md", "html", "xml", "parquet", "mql"
]

internal static func sorted(_ plugins: [any ExportFormatPlugin]) -> [any ExportFormatPlugin] {
plugins.sorted { first, second in
let firstRank = rank(of: type(of: first).formatId)
let secondRank = rank(of: type(of: second).formatId)
guard firstRank == secondRank else { return firstRank < secondRank }
return type(of: first).formatDisplayName
.localizedCaseInsensitiveCompare(type(of: second).formatDisplayName) == .orderedAscending
}
}

internal static func description(for plugin: any ExportFormatPlugin) -> String {
let pluginType = type(of: plugin)
return curatedDescription(for: pluginType.formatId) ?? pluginType.pluginDescription
}

private static func rank(of formatId: String) -> Int {
displayOrder.firstIndex(of: formatId) ?? displayOrder.count
}

private static func curatedDescription(for formatId: String) -> String? {
switch formatId {
case "csv": String(localized: "Comma-separated values. Compatible with Excel and most tools.")
case "json": String(localized: "Structured data format. Ideal for APIs and web applications.")
case "sql": String(localized: "SQL INSERT statements. Use to recreate data in another database.")
case "xlsx": String(localized: "Excel spreadsheet with formatting support.")
case "md": String(localized: "Markdown tables. Paste into a README, an issue or a wiki.")
case "html": String(localized: "An HTML table. Open in a browser or paste into a page.")
case "xml": String(localized: "One element per row. Use where a parser expects XML.")
case "parquet": String(localized: "Columnar format. Read by DuckDB, Spark, pandas and BigQuery.")
case "mql": String(localized: "MongoDB query language. Use to import into MongoDB.")
default: nil
}
}
}
Loading
Loading