Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `EXPLAIN ANALYSE` treated as a plain `EXPLAIN` that does not run its statement.
- Safe Mode level lost when importing a connection file from Mac to iOS or from iOS to Mac.
- Undo and Redo in a tab with unsaved edits replaying another tab's changes against the wrong rows.
- Import sheet creating, emptying or filling tables in another database after a database switch in another window.
- Import sheet discarding a new table's column edits when a CSV or Excel option changes.
- Table Transfer emptying a destination table, then failing, when two source columns map to one column.
- MongoDB, Elasticsearch, Typesense and SurrealDB saving a long array or object shortened for display as the cut text.
- MongoDB refusing text like `[DRAFT] Chapter one...` as a value shortened for display.
Expand Down
15 changes: 9 additions & 6 deletions TablePro/Core/Services/Export/ImportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ final class ImportService: ObservableObject {
QueryHistoryRecordRequest(
query: "-- Import from \(url.lastPathComponent) (\(progress.processedStatements) statements before failure)",
connectionId: connection.id,
databaseName: DatabaseManager.shared.browseDatabaseName(for: connection),
databaseName: scope.database,
databaseType: connection.type,
source: .dataImport,
executionTime: Date().timeIntervalSince(startedAt),
Expand All @@ -170,7 +170,7 @@ final class ImportService: ObservableObject {
)

reportImportFinished(
.failed(reason: error.localizedDescription), connection: connection, startedAt: operationStart
.failed(reason: error.localizedDescription), in: scope, startedAt: operationStart
)
throw error
}
Expand All @@ -184,7 +184,7 @@ final class ImportService: ObservableObject {
QueryHistoryRecordRequest(
query: "-- Import from \(url.lastPathComponent) (\(result.executedStatements) statements)",
connectionId: connection.id,
databaseName: DatabaseManager.shared.browseDatabaseName(for: connection),
databaseName: scope.database,
databaseType: connection.type,
source: .dataImport,
executionTime: result.executionTime,
Expand All @@ -195,7 +195,7 @@ final class ImportService: ObservableObject {

reportImportFinished(
.succeeded(OperationSummary(statementCount: result.executedStatements)),
connection: connection,
in: scope,
startedAt: operationStart
)

Expand Down Expand Up @@ -227,9 +227,12 @@ final class ImportService: ObservableObject {

/// An import the user cancelled reports nothing, matching what history already does with one
/// and for the same reason: they stopped it, so they know.
///
/// Named after the scope the import ran in, as its history row is. The browse database read when
/// the import ends is wherever another window moved the connection in the meantime.
private func reportImportFinished(
_ outcome: OperationOutcome,
connection: DatabaseConnection,
in scope: DatabaseScope,
startedAt: ContinuousClock.Instant
) {
OperationCompletionReporter.shared.report(
Expand All @@ -238,7 +241,7 @@ final class ImportService: ObservableObject {
owner: .connection(connection.id),
connectionId: connection.id,
connectionName: connection.name,
databaseName: DatabaseManager.shared.browseDatabaseName(for: connection),
databaseName: scope.database,
elapsed: startedAt.duration(to: .now),
outcome: outcome
)
Expand Down
30 changes: 23 additions & 7 deletions TablePro/Core/Services/Export/NewTableImportPlanner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,31 @@ enum NewTableImportPlan: Equatable {
/// the table as it stands would write those rows a second time. The table is only ever reused when
/// this sheet created it in this session with exactly the columns being asked for now, which is the
/// one case where clearing it can lose nothing but the sheet's own failed attempt.
enum NewTableImportPlanner {
static func plan(
forTable tableName: String,
createTableSQL: String,
alreadyCreated: [String: String]
) -> NewTableImportPlan {
guard let previousSQL = alreadyCreated[tableName] else {
///
/// A table is known by its database and schema as well as its name. Keyed by name alone, a table the
/// sheet made in one database vouched for a same-named table in another, and the retry cleared that
/// one with `DELETE FROM`.
struct NewTableImportPlanner {
private var createStatements: [TableScope: String] = [:]

func plan(forTable table: TableScope, createTableSQL: String) -> NewTableImportPlan {
guard let previousSQL = createStatements[table] else {
return .create
}
return previousSQL == createTableSQL ? .reuseAfterClearing : .nameTakenWithDifferentColumns
}

func created(_ table: TableScope) -> Bool {
createStatements[table] != nil
}

func createdTableNames(in scope: DatabaseScope) -> [String] {
createStatements.keys
.filter { $0 == TableScope(table: $0.table, in: scope) }
.map(\.table)
}

mutating func recordCreated(_ table: TableScope, createTableSQL: String) {
createStatements[table] = createTableSQL
}
}
4 changes: 4 additions & 0 deletions TablePro/Core/Storage/Preferences/TableScope.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ struct TableScope: Hashable, Codable, Sendable {
self.table = table
}

init(table: String, in scope: DatabaseScope) {
self.init(connectionId: scope.connectionId, database: scope.database, schema: scope.schema, table: table)
}

var storageComponent: String {
Self.encode([connectionId.uuidString, database ?? "", schema ?? "", table])
}
Expand Down
206 changes: 206 additions & 0 deletions TablePro/Views/Import/NewTableDraft.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
//
// NewTableDraft.swift
// TablePro
//

import Foundation
import TableProPluginKit

/// What a row import makes of one field of the file in the table it creates.
internal struct NewTableColumnSettings: Equatable {
internal var include: Bool
internal var name: String
internal var type: String
internal var isPrimaryKey: Bool
internal var isNullable: Bool
internal var defaultValue: String

/// Every field, under its own name and with the type its values suggest, nullable, with no key
/// and no default.
internal static func proposed(name: String, type: String) -> NewTableColumnSettings {
NewTableColumnSettings(
include: true,
name: name,
type: type,
isPrimaryKey: false,
isNullable: true,
defaultValue: ""
)
}
}

/// What the user set on one column, each setting nil until a write changes it. A read never touches
/// it, so a setting stays the user's even when a later read proposes the same value.
internal struct NewTableColumnEdits: Equatable {
internal var include: Bool?
internal var name: String?
internal var type: String?
internal var isPrimaryKey: Bool?
internal var isNullable: Bool?
internal var defaultValue: String?

internal var isEmpty: Bool {
self == NewTableColumnEdits()
}

internal func applied(to proposal: NewTableColumnSettings) -> NewTableColumnSettings {
NewTableColumnSettings(
include: include ?? proposal.include,
name: name ?? proposal.name,
type: type ?? proposal.type,
isPrimaryKey: isPrimaryKey ?? proposal.isPrimaryKey,
isNullable: isNullable ?? proposal.isNullable,
defaultValue: defaultValue ?? proposal.defaultValue
)
}

/// A type differing only in case is not a change: the type menu offers the dialect's own
/// spelling of the type on show, and choosing it again writes that spelling back.
internal func recording(
_ settings: NewTableColumnSettings,
over shown: NewTableColumnSettings
) -> NewTableColumnEdits {
NewTableColumnEdits(
include: settings.include == shown.include ? include : settings.include,
name: settings.name == shown.name ? name : settings.name,
type: settings.type.caseInsensitiveCompare(shown.type) == .orderedSame ? type : settings.type,
isPrimaryKey: settings.isPrimaryKey == shown.isPrimaryKey ? isPrimaryKey : settings.isPrimaryKey,
isNullable: settings.isNullable == shown.isNullable ? isNullable : settings.isNullable,
defaultValue: settings.defaultValue == shown.defaultValue ? defaultValue : settings.defaultValue
)
}
}

/// One field of the file and the column the user is making of it.
internal struct NewTableColumn: Identifiable {
internal let field: PluginImportField

/// What the latest read proposes for this field.
internal let proposal: NewTableColumnSettings
internal private(set) var edits: NewTableColumnEdits

internal init(
field: PluginImportField,
proposal: NewTableColumnSettings,
edits: NewTableColumnEdits = NewTableColumnEdits()
) {
self.field = field
self.proposal = proposal
self.edits = edits
}

internal var id: String { field.name }

internal var settings: NewTableColumnSettings {
get { edits.applied(to: proposal) }
set { edits = edits.recording(newValue, over: settings) }
}
}

internal enum NewTableColumnProblem: Equatable {
case unnamedColumn
case duplicateName
}

/// The table a row import creates: the fields of the file, what the sheet proposed for each, and
/// what the user changed.
///
/// Changing a parsing option reads the file again, and rebuilding the columns from that read threw
/// away every rename, type, key, nullability, default and exclusion the user had set. Each field
/// keeps the settings the user set, and each setting left alone follows the read, the way the
/// sheet's table name keeps what the user typed over its own suggestion.
internal struct NewTableDraft {
internal var columns: [NewTableColumn] = []

/// A wrong delimiter reads other fields for one read, and correcting it brings these back.
private var setAsideEdits: [String: NewTableColumnEdits] = [:]

internal mutating func load(
fields: [PluginImportField],
proposingType proposedType: (PluginImportFieldType) -> String
) {
var edits = setAsideEdits
for column in columns {
edits[column.id] = column.edits
}
columns = fields.map { field in
NewTableColumn(
field: field,
proposal: .proposed(name: field.name, type: proposedType(field.inferredType)),
edits: edits.removeValue(forKey: field.name) ?? NewTableColumnEdits()
)
}
setAsideEdits = edits.filter { !$0.value.isEmpty }
}

internal var includesEveryColumn: Bool {
!columns.isEmpty && columns.allSatisfy(\.settings.include)
}

internal mutating func setAllIncluded(_ include: Bool) {
for index in columns.indices {
columns[index].settings.include = include
}
}

/// Every field of the file in file order, the ones left out of the table included.
internal var fields: [String] {
columns.map(\.field.name)
}

/// The column each field is written to, for every included field whose column has a name.
internal var columnMapping: [String: String] {
var mapping: [String: String] = [:]
for column in namedColumns {
mapping[column.field.name] = column.settings.name
}
return mapping
}

internal var hasNamedColumn: Bool {
!namedColumns.isEmpty
}

internal var problem: NewTableColumnProblem? {
let names = columns
.filter(\.settings.include)
.map { $0.settings.name.trimmingCharacters(in: .whitespaces).lowercased() }
if names.contains(where: \.isEmpty) {
return .unnamedColumn
}
return Set(names).count == names.count ? nil : .duplicateName
}

/// Nil when no included column has both a name and a type, which leaves nothing to create.
internal func definition(tableName: String) -> PluginCreateTableDefinition? {
let included = namedColumns
.map(\.settings)
.filter { !$0.type.trimmingCharacters(in: .whitespaces).isEmpty }
guard !included.isEmpty else { return nil }
return PluginCreateTableDefinition(
tableName: tableName,
columns: included.map { column in
PluginColumnDefinition(
name: column.name,
dataType: column.type,
isNullable: column.isNullable,
defaultValue: column.defaultValue.isEmpty ? nil : column.defaultValue,
isPrimaryKey: column.isPrimaryKey,
autoIncrement: false,
comment: nil,
unsigned: false,
onUpdate: nil,
charset: nil,
collation: nil
)
},
primaryKeyColumns: included.filter(\.isPrimaryKey).map(\.name)
)
}

private var namedColumns: [NewTableColumn] {
columns.filter {
$0.settings.include && !$0.settings.name.trimmingCharacters(in: .whitespaces).isEmpty
}
}
}
Loading
Loading