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

### Fixed

- ClickHouse **Drop Partition** and **Detach Partition** acting on the sidebar's database instead of the table on screen, and running without the connection's destructive-statement confirmation.
- Import listing one database's tables and mapping their columns while the rows went to another.
- **New Table** created in the database the sidebar moved to rather than the one its own tab names.
- **Show All Tables** listing whichever database a cross-database tab last left the connection on.
- Sidebar **Refresh** reloading the object list from a container the user is not browsing.
- Wrong export row total where the objects picked span more than one database.
- Snowflake foreign keys into another database opening the current database's same-named table.
- Wrong database read, and written, by a connection whose startup commands select one of their own.
- **None** in the foreign key picker's Label menu forgotten on reopen.
Expand Down
21 changes: 16 additions & 5 deletions TablePro/Core/Services/Export/ExportService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,11 +138,11 @@ final class ExportService {
throw ExportError.notConnected
}

state.totalRows = await fetchTotalRowCount(
for: objects.filter { $0.kind.carriesRows }, driver: driver)

let dataSource = ExportDataSourceAdapter(driver: driver, databaseType: databaseType)

state.totalRows = await fetchTotalRowCount(
for: objects.filter { $0.kind.carriesRows }, driver: driver, dataSource: dataSource)

let nsProgress = Progress(totalUnitCount: Int64(state.totalRows))
let progress = PluginExportProgress(progress: nsProgress)
currentProgress = progress
Expand Down Expand Up @@ -381,7 +381,15 @@ final class ExportService {
)
}

private func fetchTotalRowCount(for tables: [ExportObjectItem], driver: DatabaseDriver) async -> Int {
/// The non-SQL count goes through the data source, which knows the container each object was
/// listed under. Asking the driver directly answers about whichever one it is leased to, so an
/// export spanning two databases counted one of them twice and reported a total no progress bar
/// could reach.
private func fetchTotalRowCount(
for tables: [ExportObjectItem],
driver: DatabaseDriver,
dataSource: ExportDataSourceAdapter
) async -> Int {
guard !tables.isEmpty else { return 0 }

var total = 0
Expand All @@ -390,7 +398,10 @@ final class ExportService {
if PluginManager.shared.editorLanguage(for: databaseType) != .sql {
for table in tables {
do {
if let count = try await driver.fetchApproximateRowCount(table: table.name) {
let count = try await dataSource.fetchApproximateRowCount(
table: table.name, databaseName: table.databaseName
)
if let count {
total += count
}
} catch {
Expand Down
24 changes: 17 additions & 7 deletions TablePro/Core/Services/Query/SchemaService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -597,22 +597,32 @@ final class SchemaService {
resumeRefreshWaiters(connectionId)
}

/// Leased rather than handed the session driver, which is wherever a tab's execution last
/// pinned it. The object list follows the browse cursor, so reading from the shared handle
/// refreshed the sidebar with a container the user was not browsing.
func refresh(connectionId: UUID) async {
guard let session = DatabaseManager.shared.activeSessions[connectionId],
let driver = session.driver else {
let scope = DatabaseManager.shared.browseScope(for: connectionId) else {
markLoadFailed(
connectionId: connectionId,
message: String(localized: "The connection is not available. Reconnect and try again.")
)
return
}
await prepareForReload(connectionId: connectionId)
await reload(
connectionId: connectionId,
driver: driver,
connection: session.connection,
scope: DatabaseManager.shared.browseScope(for: connectionId)
)
let connection = session.connection
do {
try await DatabaseManager.shared.withMetadataDriver(scope: scope, workload: .bulk) { [self] driver in
await reload(
connectionId: connectionId,
driver: driver,
connection: connection,
scope: scope
)
}
} catch {
markLoadFailed(connectionId: connectionId, message: error.localizedDescription)
}
}

func markLoadFailed(connectionId: UUID, message: String) {
Expand Down
77 changes: 77 additions & 0 deletions TablePro/Models/Schema/ClickHousePartStatements.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
//
// ClickHousePartStatements.swift
// TablePro
//
// The statements the Parts tab issues, built from the tab's own container.
//

import Foundation

/// ClickHouse carries the database as a request parameter rather than in the session, and the app
/// moves that parameter whenever any tab runs somewhere else. An unqualified name therefore names
/// whichever database the connection was last pinned to, which for `DROP PARTITION` is data loss in
/// a database the user was not looking at.
internal enum ClickHousePartStatements {
internal static func qualifiedName(
database: String,
table: String,
quote: (String) -> String
) -> String {
guard !database.isEmpty else { return quote(table) }
return "\(quote(database)).\(quote(table))"
}

internal static func optimize(
database: String,
table: String,
quote: (String) -> String
) -> String {
"OPTIMIZE TABLE \(qualifiedName(database: database, table: table, quote: quote)) FINAL"
}

internal static func dropPartition(
database: String,
table: String,
partition: String,
quote: (String) -> String,
escape: (String) -> String
) -> String {
let name = qualifiedName(database: database, table: table, quote: quote)
return "ALTER TABLE \(name) DROP PARTITION '\(escape(partition))'"
}

internal static func detachPartition(
database: String,
table: String,
partition: String,
quote: (String) -> String,
escape: (String) -> String
) -> String {
let name = qualifiedName(database: database, table: table, quote: quote)
return "ALTER TABLE \(name) DETACH PARTITION '\(escape(partition))'"
}

/// A connection saved with no database of its own has none to name, and ClickHouse then resolves
/// an unqualified request against its own configured default, which `currentDatabase()` reports.
/// Comparing against an empty string instead matches nothing and empties the tab.
private static func databasePredicate(_ database: String, escape: (String) -> String) -> String {
guard !database.isEmpty else { return "currentDatabase()" }
return "'\(escape(database))'"
}

/// Filtered on the named database rather than `currentDatabase()`, which answers with the
/// request parameter and so listed another database's parts under this table's name.
internal static func parts(
database: String,
table: String,
escape: (String) -> String
) -> String {
"""
SELECT partition, name, rows, bytes_on_disk,
toString(modification_time) AS mod_time, active
FROM system.parts
WHERE database = \(databasePredicate(database, escape: escape)) AND table = '\(escape(table))'
ORDER BY partition, name
"""
}
}
22 changes: 17 additions & 5 deletions TablePro/Views/Import/RowImportSheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -660,18 +660,26 @@ struct RowImportSheet: View {
/// Both failures used to leave an empty list and say nothing, so the destination picker offered
/// "Select a table…" and nothing else with no way to tell an empty database from an unreachable
/// one, and no way to ask again.
///
/// Read through the browse scope, which is what the import itself writes to. The shared session
/// driver is wherever a tab's execution last pinned it and nothing puts it back, so reading from
/// it listed one database's tables and mapped their columns while the rows went to another's.
@MainActor
private func loadTables() async {
guard !isLoadingTables else { return }
isLoadingTables = true
defer { isLoadingTables = false }
guard let driver = DatabaseManager.shared.driver(for: connection.id) else {
guard DatabaseManager.shared.browseScope(for: connection.id) != nil else {
catalogNameKeys = nil
tableListError = String(localized: "This connection is not open.")
return
}
do {
databaseObjects = try await driver.fetchTables()
databaseObjects = try await DatabaseManager.shared.withBrowseMetadataDriver(
connectionId: connection.id
) { driver in
try await driver.fetchTables()
}
catalogNameKeys = NewTableNaming.comparisonKeys(for: databaseObjects.map(\.name))
tableListError = nil
suggestNewTableName()
Expand Down Expand Up @@ -749,13 +757,17 @@ struct RowImportSheet: View {

@MainActor
private func loadExistingContext(table: String) async {
guard let driver = DatabaseManager.shared.driver(for: connection.id),
let plugin = currentPlugin else { return }
guard let plugin = currentPlugin,
DatabaseManager.shared.browseScope(for: connection.id) != nil else { return }
isLoadingContext = true
loadError = nil
defer { isLoadingContext = false }
do {
let columns = try await driver.fetchColumns(table: table).map(\.name)
let columns = try await DatabaseManager.shared.withBrowseMetadataDriver(
connectionId: connection.id
) { driver in
try await driver.fetchColumns(table: table)
}.map(\.name)
let fields = try await Self.detectFields(plugin: plugin, at: fileURL, targetTable: table)
targetColumns = columns
mappings = fields.map { field in
Expand Down
1 change: 1 addition & 0 deletions TablePro/Views/Main/Child/MainEditorContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -631,6 +631,7 @@ struct MainEditorContentView: View {
if let draft = coordinator.createTableDrafts[tab.id] {
CreateTableView(
connection: connection,
scope: structureScope(for: tab),
coordinator: coordinator,
selectionState: selectionState,
draft: draft
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -450,8 +450,23 @@ extension MainContentCoordinator {

// SQL databases: delegate to plugin driver
guard let driver = DatabaseManager.shared.driver(for: connectionId) else { return nil }
let schema = (driver as? SchemaSwitchable)?.escapedSchema
return (driver as? PluginDriverAdapter)?.allTablesMetadataSQL(schema: schema)
return (driver as? PluginDriverAdapter)?.allTablesMetadataSQL(schema: allTablesContainer(driver))
}

/// The container this listing is about, named rather than left to the driver.
///
/// A schema-less engine answers an unnamed container with whatever database the shared driver
/// was last pinned to, which a cross-database tab moves and nothing restores, so the listing
/// described a database the user was not browsing.
private func allTablesContainer(_ driver: DatabaseDriver) -> String? {
switch EngineNamespaceSlot(databaseType: connection.type) {
case .schema:
return (driver as? SchemaSwitchable)?.escapedSchema
case .database:
return browseDatabaseName.nilIfEmpty
case .unqualified:
return nil
}
}

// MARK: - Database Switching
Expand Down
Loading
Loading