From 486f54afb4c060c2a3735e0755e9635dae76037a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 17 Jul 2026 01:21:08 +0700 Subject: [PATCH] fix(sidebar): check preselected tables and apply default options in the export sheet --- CHANGELOG.md | 1 + TablePro/Models/Export/ExportModels.swift | 41 ++++++++++ TablePro/Views/Export/ExportDialog.swift | 74 +++++++++++------- .../Views/Export/ExportTableTreeView.swift | 64 +++++++-------- TableProTests/Models/ExportModelsTests.swift | 77 +++++++++++++++++++ 5 files changed, 193 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf4c4565d..765f08e55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/TablePro/Models/Export/ExportModels.swift b/TablePro/Models/Export/ExportModels.swift index 87763dc5e..2800fc29d 100644 --- a/TablePro/Models/Export/ExportModels.swift +++ b/TablePro/Models/Export/ExportModels.swift @@ -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 + } + } +} diff --git a/TablePro/Views/Export/ExportDialog.swift b/TablePro/Views/Export/ExportDialog.swift index 8314ee9c0..50c6d6fef 100644 --- a/TablePro/Views/Export/ExportDialog.swift +++ b/TablePro/Views/Export/ExportDialog.swift @@ -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 { @@ -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 @@ -565,25 +569,34 @@ struct ExportDialog: View { tables: tableItems, isExpanded: true ) - databaseItems = [item] + databaseItems = [item].normalizingOptionValues( + optionColumnCount: currentOptionColumnCount, + defaultOptionValues: currentDefaultOptionValues + ) 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] = [] @@ -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 { @@ -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: @@ -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 { @@ -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 { @@ -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 } diff --git a/TablePro/Views/Export/ExportTableTreeView.swift b/TablePro/Views/Export/ExportTableTreeView.swift index ad798fe01..635b7fd86 100644 --- a/TablePro/Views/Export/ExportTableTreeView.swift +++ b/TablePro/Views/Export/ExportTableTreeView.swift @@ -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 { @@ -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 } } ) @@ -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) @@ -143,7 +146,7 @@ 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 } @@ -151,30 +154,19 @@ struct ExportTableTreeView: View { } private func toggleGenericOptions(_ table: Binding) { - 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) { - 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) } } } diff --git a/TableProTests/Models/ExportModelsTests.swift b/TableProTests/Models/ExportModelsTests.swift index 0826a1f63..294249906 100644 --- a/TableProTests/Models/ExportModelsTests.swift +++ b/TableProTests/Models/ExportModelsTests.swift @@ -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] }) + } }