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

### Fixed

- Fixed tables picked in the sidebar showing up unchecked and getting silently skipped when exporting to SQL or MQL. The export sheet now checks them and applies the format's default per-table options, so Export works right away.
- Fixed ClickHouse queries showing only a success message with no result table. TablePro guessed whether a query returns data from its first word, which failed for queries starting with a comment or with SELECT on its own line. It now reads what the server actually returned, so any query that produces a result set shows the grid, including empty results and queries with their own FORMAT clause. (#1886)
- Fixed ClickHouse INSERT statements always reporting 0 rows affected. (#1886)
- Fixed ClickHouse query exports writing an empty file when the query started with a comment. (#1886)
Expand Down
41 changes: 41 additions & 0 deletions TablePro/Models/Export/ExportModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,3 +110,44 @@ struct ExportDatabaseItem: Identifiable {
tables.filter { $0.isSelected }
}
}

extension ExportTableItem {
func normalized(forOptionColumnCount optionColumnCount: Int, defaultOptionValues: [Bool]) -> ExportTableItem {
guard optionColumnCount > 0 else { return self }
let fallback = defaultOptionValues.count == optionColumnCount
? defaultOptionValues
: Array(repeating: true, count: optionColumnCount)
var normalizedItem = self
if normalizedItem.optionValues.count != optionColumnCount {
normalizedItem.optionValues = fallback
}
if normalizedItem.isSelected, !normalizedItem.optionValues.contains(true) {
normalizedItem.optionValues = fallback
}
return normalizedItem
}
}

extension [ExportDatabaseItem] {
func normalizingOptionValues(optionColumnCount: Int, defaultOptionValues: [Bool]) -> [ExportDatabaseItem] {
map { database in
var normalizedDatabase = database
normalizedDatabase.tables = database.tables.map {
$0.normalized(forOptionColumnCount: optionColumnCount, defaultOptionValues: defaultOptionValues)
}
return normalizedDatabase
}
}

func resettingOptionValues(to values: [Bool]) -> [ExportDatabaseItem] {
map { database in
var resetDatabase = database
resetDatabase.tables = database.tables.map { table in
var resetTable = table
resetTable.optionValues = values
return resetTable
}
return resetDatabase
}
}
}
74 changes: 46 additions & 28 deletions TablePro/Views/Export/ExportDialog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,15 @@ struct ExportDialog: View {
PluginManager.shared.exportPlugin(forFormat: config.formatId)
}

private var currentOptionColumnCount: Int {
guard let plugin = currentPlugin else { return 0 }
return type(of: plugin).perTableOptionColumns.count
}

private var currentDefaultOptionValues: [Bool] {
currentPlugin?.defaultTableOptionValues() ?? []
}

// MARK: - Layout Constants

private var leftPanelWidth: CGFloat {
Expand Down Expand Up @@ -515,12 +524,7 @@ struct ExportDialog: View {
}

private func resetOptionValues() {
let defaults = currentPlugin?.defaultTableOptionValues() ?? []
for dbIndex in databaseItems.indices {
for tableIndex in databaseItems[dbIndex].tables.indices {
databaseItems[dbIndex].tables[tableIndex].optionValues = defaults
}
}
databaseItems = databaseItems.resettingOptionValues(to: currentDefaultOptionValues)
}

// MARK: - Actions
Expand Down Expand Up @@ -565,25 +569,34 @@ struct ExportDialog: View {
tables: tableItems,
isExpanded: true
)
databaseItems = [item]
databaseItems = [item].normalizingOptionValues(
optionColumnCount: currentOptionColumnCount,
defaultOptionValues: currentDefaultOptionValues
Comment on lines +572 to +574

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid enabling SQL export before schema names are loaded

When exporting from a schema-scoped connection such as PostgreSQL or Oracle before loadDatabaseItems() finishes, this normalization makes the instant sidebar rows exportable even though they were built just above with databaseName = connection.database; the SQL export path treats databaseName as the schema qualifier, while the later metadata path replaces it with the actual schema. A fast click on Export can therefore generate or query database.table instead of schema.table, or fail on a missing relation. Keep these rows disabled until schema-qualified items are loaded, or initialize them from table.schema.

Useful? React with 👍 / 👎.

)
isLoading = false
}

/// Build a lookup of user-toggled selection state from current `databaseItems`.
private func currentSelectionState() -> [String: Bool] {
var state: [String: Bool] = [:]
for db in databaseItems {
for table in db.tables {
state["\(db.name).\(table.name)"] = table.isSelected
private struct ExportRowSnapshot {
let isSelected: Bool
let optionValues: [Bool]
}

private func priorRowSnapshots() -> [String: ExportRowSnapshot] {
var snapshots: [String: ExportRowSnapshot] = [:]
for database in databaseItems {
for table in database.tables {
snapshots["\(database.name).\(table.name)"] = ExportRowSnapshot(
isSelected: table.isSelected,
optionValues: table.optionValues
)
}
}
return state
return snapshots
}

@MainActor
private func loadDatabaseItems() async {
// Snapshot user-toggled selections before replacing items
let priorSelections = currentSelectionState()
let priorRows = priorRowSnapshots()

do {
var items: [ExportDatabaseItem] = []
Expand All @@ -600,14 +613,15 @@ struct ExportDialog: View {
let tables = try await fetchTablesForSchema(schema)
let isDefaultSchema = schema.caseInsensitiveCompare(defaultSchema) == .orderedSame
let tableItems = tables.map { table in
let key = "\(schema).\(table.name)"
let selected = priorSelections[key]
let priorRow = priorRows["\(schema).\(table.name)"]
let selected = priorRow?.isSelected
?? (isDefaultSchema && preselectedTables.contains(table.name))
return ExportTableItem(
name: table.name,
databaseName: schema,
type: table.type,
isSelected: selected
isSelected: selected,
optionValues: priorRow?.optionValues ?? []
)
}
if !tableItems.isEmpty {
Expand All @@ -627,7 +641,7 @@ struct ExportDialog: View {
let fallbackName = PluginManager.shared.defaultGroupName(for: dbType)
let dbItem = try await buildFlatDatabaseItem(
name: connection.database.isEmpty ? fallbackName : connection.database,
priorSelections: priorSelections
priorRows: priorRows
)
if let dbItem { items.append(dbItem) }
case .byDatabase:
Expand All @@ -638,14 +652,15 @@ struct ExportDialog: View {
let tables = try await fetchTablesForDatabase(dbName)
let isCurrentDB = dbName == connection.database
let tableItems = tables.map { table in
let key = "\(dbName).\(table.name)"
let selected = priorSelections[key]
let priorRow = priorRows["\(dbName).\(table.name)"]
let selected = priorRow?.isSelected
?? (isCurrentDB && preselectedTables.contains(table.name))
return ExportTableItem(
name: table.name,
databaseName: dbName,
type: table.type,
isSelected: selected
isSelected: selected,
optionValues: priorRow?.optionValues ?? []
)
}
if !tableItems.isEmpty {
Expand All @@ -663,7 +678,10 @@ struct ExportDialog: View {
}
}

databaseItems = items
databaseItems = items.normalizingOptionValues(
optionColumnCount: currentOptionColumnCount,
defaultOptionValues: currentDefaultOptionValues
)
isLoading = false

if preselectedTables.count == 1, let first = preselectedTables.first {
Expand All @@ -683,19 +701,19 @@ struct ExportDialog: View {

private func buildFlatDatabaseItem(
name: String,
priorSelections: [String: Bool] = [:]
priorRows: [String: ExportRowSnapshot] = [:]
) async throws -> ExportDatabaseItem? {
let tables = try await DatabaseManager.shared.withMetadataDriver(connectionId: connection.id, workload: .bulk) { driver in
try await driver.fetchTables()
}
let tableItems = tables.map { table in
let key = "\(name).\(table.name)"
let selected = priorSelections[key] ?? preselectedTables.contains(table.name)
let priorRow = priorRows["\(name).\(table.name)"]
return ExportTableItem(
name: table.name,
databaseName: "",
type: table.type,
isSelected: selected
isSelected: priorRow?.isSelected ?? preselectedTables.contains(table.name),
optionValues: priorRow?.optionValues ?? []
)
}
guard !tableItems.isEmpty else { return nil }
Expand Down
64 changes: 28 additions & 36 deletions TablePro/Views/Export/ExportTableTreeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ struct ExportTableTreeView: View {
PluginManager.shared.exportPlugin(forFormat: formatId)
}

private var defaultOptionValues: [Bool] {
currentPlugin?.defaultTableOptionValues() ?? []
}

var body: some View {
VStack(spacing: 0) {
List {
Expand Down Expand Up @@ -55,14 +59,15 @@ struct ExportTableTreeView: View {
action: {
let newState = !database.allSelected
for index in allTables.wrappedValue.indices {
allTables[index].isSelected.wrappedValue = newState
if newState && !optionColumns.isEmpty {
if allTables[index].wrappedValue.optionValues.isEmpty ||
!allTables[index].wrappedValue.optionValues.contains(true) {
let defaults = currentPlugin?.defaultTableOptionValues() ?? Array(repeating: true, count: optionColumns.count)
allTables[index].optionValues.wrappedValue = defaults
}
var updated = allTables[index].wrappedValue
updated.isSelected = newState
if newState {
updated = updated.normalized(
forOptionColumnCount: optionColumns.count,
defaultOptionValues: defaultOptionValues
)
}
allTables[index].wrappedValue = updated
}
}
)
Expand Down Expand Up @@ -120,14 +125,12 @@ struct ExportTableTreeView: View {
ForEach(Array(optionColumns.enumerated()), id: \.element.id) { colIndex, column in
Toggle(column.label, isOn: Binding(
get: {
guard colIndex < table.wrappedValue.optionValues.count else { return true }
return table.optionValues[colIndex].wrappedValue
table.wrappedValue.optionValues[safe: colIndex] ?? column.defaultValue
},
set: { newValue in
ensureOptionValues(table)
guard table.wrappedValue.optionValues.indices.contains(colIndex) else { return }
table.optionValues[colIndex].wrappedValue = newValue
let anyTrue = table.wrappedValue.optionValues.contains(true)
table.isSelected.wrappedValue = anyTrue
table.isSelected.wrappedValue = table.wrappedValue.optionValues.contains(true)
}
))
.toggleStyle(.checkbox)
Expand All @@ -143,38 +146,27 @@ struct ExportTableTreeView: View {
// MARK: - Generic Option Helpers

private func genericCheckboxState(_ table: ExportTableItem) -> TristateCheckbox.State {
if !table.isSelected || table.optionValues.isEmpty { return .unchecked }
if !table.isSelected { return .unchecked }
let trueCount = table.optionValues.count(where: { $0 })
if trueCount == 0 { return .unchecked }
if trueCount == table.optionValues.count { return .checked }
return .mixed
}

private func toggleGenericOptions(_ table: Binding<ExportTableItem>) {
ensureOptionValues(table)
if !table.wrappedValue.isSelected {
table.isSelected.wrappedValue = true
if !table.wrappedValue.optionValues.contains(true) {
for i in table.wrappedValue.optionValues.indices {
table.optionValues[i].wrappedValue = true
}
}
} else {
let allChecked = table.wrappedValue.optionValues.allSatisfy { $0 }
if allChecked {
table.isSelected.wrappedValue = false
} else {
for i in table.wrappedValue.optionValues.indices {
table.optionValues[i].wrappedValue = true
}
}
guard table.wrappedValue.isSelected else {
var updated = table.wrappedValue
updated.isSelected = true
table.wrappedValue = updated.normalized(
forOptionColumnCount: optionColumns.count,
defaultOptionValues: defaultOptionValues
)
return
}
}

private func ensureOptionValues(_ table: Binding<ExportTableItem>) {
if table.wrappedValue.optionValues.count < optionColumns.count {
let defaults = currentPlugin?.defaultTableOptionValues() ?? Array(repeating: true, count: optionColumns.count)
table.optionValues.wrappedValue = defaults
if table.wrappedValue.optionValues.allSatisfy({ $0 }) {
table.isSelected.wrappedValue = false
} else {
table.optionValues.wrappedValue = Array(repeating: true, count: optionColumns.count)
}
}
}
77 changes: 77 additions & 0 deletions TableProTests/Models/ExportModelsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,81 @@ struct ExportModelsTests {
let table = ExportTableItem(name: "users", type: .table, isSelected: true, optionValues: [true, false, true])
#expect(table.optionValues == [true, false, true])
}

@Test("Normalizing a preselected table with empty option values applies plugin defaults")
func normalizedMaterializesDefaultsForPreselectedTable() {
let table = ExportTableItem(name: "users", type: .table, isSelected: true)
let normalized = table.normalized(forOptionColumnCount: 3, defaultOptionValues: [true, true, true])
#expect(normalized.optionValues == [true, true, true])
#expect(normalized.isSelected)
}

@Test("Normalizing preserves a partial option selection")
func normalizedPreservesPartialSelection() {
let table = ExportTableItem(name: "users", type: .table, isSelected: true, optionValues: [true, false, true])
let normalized = table.normalized(forOptionColumnCount: 3, defaultOptionValues: [true, true, true])
#expect(normalized.optionValues == [true, false, true])
}

@Test("Normalizing a selected table with all-false option values re-applies defaults")
func normalizedRepairsAllFalseSelection() {
let table = ExportTableItem(name: "users", type: .table, isSelected: true, optionValues: [false, false, false])
let normalized = table.normalized(forOptionColumnCount: 3, defaultOptionValues: [true, true, true])
#expect(normalized.optionValues == [true, true, true])
}

@Test("Normalizing an unselected table with all-false option values leaves them alone")
func normalizedLeavesUnselectedAllFalseAlone() {
let table = ExportTableItem(name: "users", type: .table, isSelected: false, optionValues: [false, false, false])
let normalized = table.normalized(forOptionColumnCount: 3, defaultOptionValues: [true, true, true])
#expect(normalized.optionValues == [false, false, false])
}

@Test("Normalizing an unselected table with empty option values still fixes the shape")
func normalizedFixesShapeForUnselectedTable() {
let table = ExportTableItem(name: "users", type: .table, isSelected: false)
let normalized = table.normalized(forOptionColumnCount: 3, defaultOptionValues: [true, true, true])
#expect(normalized.optionValues.count == 3)
}

@Test("Normalizing with zero option columns leaves option values untouched")
func normalizedNoOpForFormatsWithoutOptionColumns() {
let table = ExportTableItem(name: "users", type: .table, isSelected: true)
let normalized = table.normalized(forOptionColumnCount: 0, defaultOptionValues: [])
#expect(normalized.optionValues.isEmpty)
}

@Test("Normalizing falls back to all-true when plugin defaults have the wrong length")
func normalizedFallsBackWhenDefaultsMismatched() {
let table = ExportTableItem(name: "users", type: .table, isSelected: true)
let normalized = table.normalized(forOptionColumnCount: 3, defaultOptionValues: [true])
#expect(normalized.optionValues == [true, true, true])
}

@Test("Normalizing database items materializes every preselected table and preserves identity")
func normalizingDatabaseItemsMatchesPreselectionFlow() {
let tables = [
ExportTableItem(name: "users", type: .table, isSelected: true),
ExportTableItem(name: "posts", type: .table, isSelected: true),
ExportTableItem(name: "logs", type: .table, isSelected: false),
]
let original = [ExportDatabaseItem(name: "app_db", tables: tables)]
let normalized = original.normalizingOptionValues(optionColumnCount: 3, defaultOptionValues: [true, true, true])
#expect(normalized[0].id == original[0].id)
#expect(normalized[0].tables[0].id == original[0].tables[0].id)
let preselected = normalized[0].tables.filter(\.isSelected)
#expect(preselected.count == 2)
#expect(preselected.allSatisfy { $0.optionValues.contains(true) })
}

@Test("Resetting option values overwrites every table regardless of prior content")
func resettingOptionValuesOverwritesAll() {
let tables = [
ExportTableItem(name: "users", type: .table, isSelected: true, optionValues: [true, false, true]),
ExportTableItem(name: "posts", type: .table, isSelected: false, optionValues: [false, false, false]),
]
let original = [ExportDatabaseItem(name: "app_db", tables: tables)]
let reset = original.resettingOptionValues(to: [true, true, true])
#expect(reset[0].tables.allSatisfy { $0.optionValues == [true, true, true] })
}
}
Loading