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

### Added

- Select several databases or schemas in the sidebar tree and act on them at once: drop, refresh, copy names, or export. Shift-click and Cmd-click extend the selection.
- The database switcher takes a multiple selection too, with the same actions on the right-click menu.
- Drop Schema for PostgreSQL, SQL Server and SurrealDB.
- SSL settings on mobile for MySQL, PostgreSQL and Redis: a mode picker plus CA, client certificate and client key. Import a PEM file, paste one, or use a PKCS#12 file. (#2083)
- Legacy UUID Encoding on a MongoDB connection, so binary UUIDs written by the Java, C# or Python drivers read as UUIDs instead of hex. Filters, edits and MQL exports write the same bytes back. (#2086)

Expand Down Expand Up @@ -44,6 +47,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Right-clicking a table that is not part of the current selection now acts on that table. It used to act on the selected tables instead, including for Delete.
- Database icons and the current-database checkmark stay visible on a selected row in the database switcher. They were drawn in the accent colour, which vanished against the accent-coloured selection.
- The sidebar drops a database from the tree as soon as you drop it on the server, instead of listing it until you reconnect.
- Refreshing the database list keeps the current list on screen while it reloads, and keeps it if the reload fails.
- MQL export writes a MongoDB `_id` as `ObjectId("...")` and a date as `ISODate("...")`, so running the script inserts the same types back instead of strings. A value nested inside a subdocument is still exported as a string.
- MQL export keeps a binary value's BSON subtype and writes it as a `BinData(...)` constructor, so running the script inserts the same bytes back. It wrote Extended JSON that mongosh reads as a plain object, and stamped every value as subtype 0. (#2086)
- MongoDB no longer prints a binary field nested inside a document as a UUID when it is not one, or labels a UUID with the wrong byte order. (#2086)
Expand Down
1 change: 1 addition & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ final class MSSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
)

static let supportsDropDatabase = true
static let supportsDropSchema = true
static let supportsTriggers = true
static let supportsTriggerEditing = true

Expand Down
5 changes: 5 additions & 0 deletions Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -569,6 +569,11 @@ extension MSSQLPluginDriver {
_ = try await execute(query: "DROP DATABASE \(quotedName)")
}

func dropSchema(name: String) async throws {
let quotedName = "[\(name.replacingOccurrences(of: "]", with: "]]"))]"
_ = try await execute(query: "DROP SCHEMA \(quotedName)")
}

// MARK: - All Tables Metadata

func allTablesMetadataSQL(schema: String?) -> String? {
Expand Down
1 change: 1 addition & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ final class PostgreSQLPlugin: NSObject, TableProPlugin, DriverPlugin {
static let requiresReconnectForDatabaseSwitch = true
static let parameterStyle: ParameterStyle = .dollar
static let supportsDropDatabase = true
static let supportsDropSchema = true
static let supportsTriggers = true
static let supportsTriggerEditing = true

Expand Down
4 changes: 4 additions & 0 deletions Plugins/PostgreSQLDriverPlugin/PostgreSQLPluginDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,10 @@ class PostgreSQLPluginDriver: LibPQBackedDriver, @unchecked Sendable {
_ = try await execute(query: "DROP DATABASE \(quoteIdentifier(name))")
}

func dropSchema(name: String) async throws {
_ = try await execute(query: "DROP SCHEMA \(quoteIdentifier(name)) CASCADE")
}

private struct Template1Defaults {
let collate: String
let ctype: String
Expand Down
1 change: 1 addition & 0 deletions Plugins/SurrealDBDriverPlugin/SurrealDBPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ final class SurrealDBPlugin: NSObject, TableProPlugin, DriverPlugin {
static let defaultPrimaryKeyColumn: String? = "id"
static let immutableColumns: [String] = ["id"]
static let supportsDropDatabase = true
static let supportsDropSchema = true
static let postConnectActions: [PostConnectAction] = [.selectSchemaFromLastSession]

static let structureColumnFields: [StructureColumnField] = [.name, .type, .nullable]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ extension SurrealDBPluginDriver {
_ = try await run(statement, scope: SurrealScope(namespace: nil, database: nil))
}

func dropSchema(name: String) async throws {
let statement = "REMOVE DATABASE " + SurrealQL.quoteIdentifier(name) + ";"
_ = try await run(statement, scope: SurrealScope(namespace: currentNamespace, database: nil))
}

// MARK: - Tables

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
Expand Down
2 changes: 2 additions & 0 deletions Plugins/TableProPluginKit/DriverPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ public protocol DriverPlugin: TableProPlugin {
static var postConnectActions: [PostConnectAction] { get }
static var parameterStyle: ParameterStyle { get }
static var supportsDropDatabase: Bool { get }
static var supportsDropSchema: Bool { get }

static var supportsAddColumn: Bool { get }
static var supportsModifyColumn: Bool { get }
Expand Down Expand Up @@ -136,6 +137,7 @@ public extension DriverPlugin {
static var isDownloadable: Bool { false }
static var postConnectActions: [PostConnectAction] { [] }
static var supportsDropDatabase: Bool { false }
static var supportsDropSchema: Bool { false }

static var supportsAddColumn: Bool { true }
static var supportsModifyColumn: Bool { true }
Expand Down
6 changes: 6 additions & 0 deletions Plugins/TableProPluginKit/PluginDatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ public protocol PluginDatabaseDriver: AnyObject, Sendable {
func createDatabaseFormSpec() async throws -> PluginCreateDatabaseFormSpec?
func createDatabase(_ request: PluginCreateDatabaseRequest) async throws
func dropDatabase(name: String) async throws
func dropSchema(name: String) async throws
func executeParameterized(query: String, parameters: [PluginCellValue]) async throws -> PluginQueryResult

// Session contexts (optional, switchable session dimensions such as a warehouse or role)
Expand Down Expand Up @@ -328,6 +329,11 @@ public extension PluginDatabaseDriver {
userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"])
}

func dropSchema(name: String) async throws {
throw NSError(domain: "PluginDatabaseDriver", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Drop schema is not supported by this driver"])
}

func switchDatabase(to database: String) async throws {
throw NSError(
domain: "TableProPluginKit",
Expand Down
7 changes: 7 additions & 0 deletions TablePro/Core/Database/DatabaseDriver.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,8 @@ protocol DatabaseDriver: AnyObject, Sendable {

func dropDatabase(name: String) async throws

func dropSchema(name: String) async throws

func fetchSessionContexts() async throws -> [PluginSessionContext]?

func switchSessionContext(id: String, to value: String) async throws
Expand Down Expand Up @@ -301,6 +303,11 @@ extension DatabaseDriver {
userInfo: [NSLocalizedDescriptionKey: "Drop database is not supported by this driver"])
}

func dropSchema(name: String) async throws {
throw NSError(domain: "DatabaseDriver", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Drop schema is not supported by this driver"])
}

func createDatabaseFormSpec() async throws -> CreateDatabaseFormSpec? { nil }

func fetchSessionContexts() async throws -> [PluginSessionContext]? { nil }
Expand Down
4 changes: 4 additions & 0 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -464,6 +464,10 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
try await pluginDriver.dropDatabase(name: name)
}

func dropSchema(name: String) async throws {
try await pluginDriver.dropSchema(name: name)
}

func fetchSessionContexts() async throws -> [PluginSessionContext]? {
try await pluginDriver.fetchSessionContexts()
}
Expand Down
9 changes: 9 additions & 0 deletions TablePro/Core/Plugins/PluginManager+Registration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,10 @@ extension PluginManager {
.schema.schemaEntityName ?? "Schema"
}

func schemaEntityNamePlural(for databaseType: DatabaseType) -> String {
schemaEntityName(for: databaseType) + "s"
}

func supportsCascadeDrop(for databaseType: DatabaseType) -> Bool {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
.capabilities.supportsCascadeDrop ?? false
Expand Down Expand Up @@ -491,6 +495,11 @@ extension PluginManager {
.capabilities.supportsDropDatabase ?? false
}

func supportsDropSchema(for databaseType: DatabaseType) -> Bool {
PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId)?
.capabilities.supportsDropSchema ?? false
}

func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle {
guard let snapshot = PluginMetadataRegistry.shared.snapshot(forTypeId: databaseType.pluginTypeId) else {
return .limit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,7 @@ extension PluginMetadataRegistry {
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: true,
supportsDropSchema: true,
supportsRenameColumn: true,
defaultSSLMode: .preferred
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ extension PluginMetadataRegistry {
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: false,
supportsDropDatabase: true,
supportsDropSchema: true,
supportsOpportunisticTLS: false
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down
6 changes: 6 additions & 0 deletions TablePro/Core/Plugins/PluginMetadataRegistry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ struct PluginMetadataSnapshot: Sendable {
let requiresReconnectForDatabaseSwitch: Bool
let supportsDropDatabase: Bool
// `var` with defaults so existing call sites compile without passing these fields
var supportsDropSchema: Bool = false
var supportsAddColumn: Bool = true
var supportsModifyColumn: Bool = true
var supportsDropColumn: Bool = true
Expand Down Expand Up @@ -642,6 +643,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: true,
supportsDropDatabase: true,
supportsDropSchema: true,
supportsRenameColumn: true,
supportsTriggers: true,
supportsTriggerEditing: true,
Expand Down Expand Up @@ -695,6 +697,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: true,
supportsDropDatabase: true,
supportsDropSchema: true,
defaultSSLMode: .preferred
),
schema: PluginMetadataSnapshot.SchemaInfo(
Expand Down Expand Up @@ -749,6 +752,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: true,
supportsDropDatabase: true,
supportsDropSchema: true,
supportsAddColumn: false,
supportsModifyColumn: false,
supportsDropColumn: false,
Expand Down Expand Up @@ -805,6 +809,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsQueryProgress: false,
requiresReconnectForDatabaseSwitch: true,
supportsDropDatabase: true,
supportsDropSchema: true,
supportsRenameColumn: true,
supportsTriggers: true,
supportsTriggerEditing: true,
Expand Down Expand Up @@ -1105,6 +1110,7 @@ final class PluginMetadataRegistry: @unchecked Sendable {
supportsQueryProgress: driverType.supportsQueryProgress,
requiresReconnectForDatabaseSwitch: driverType.requiresReconnectForDatabaseSwitch,
supportsDropDatabase: driverType.supportsDropDatabase,
supportsDropSchema: driverType.supportsDropSchema,
supportsAddColumn: driverType.supportsAddColumn,
supportsModifyColumn: driverType.supportsModifyColumn,
supportsDropColumn: driverType.supportsDropColumn,
Expand Down
72 changes: 55 additions & 17 deletions TablePro/Core/Services/Query/DatabaseTreeMetadataService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,15 +98,8 @@ final class DatabaseTreeMetadataService {
case .idle, .failed: break
}
databaseList[connectionId] = .loading
let systemNames = Set(PluginManager.shared.systemDatabaseNames(for: databaseType))
do {
let list = try await databaseDedup.execute(key: connectionId) { [self] in
try await withDriver(connectionId: connectionId, database: nil) { driver in
try await driver.fetchDatabases().sorted().map {
DatabaseMetadata.minimal(name: $0, isSystem: systemNames.contains($0))
}
}
}
let list = try await fetchDatabaseList(connectionId: connectionId, databaseType: databaseType)
databaseList[connectionId] = .loaded(list)
} catch is CancellationError {
if case .loading = databaseList[connectionId] { databaseList[connectionId] = .idle }
Expand All @@ -116,6 +109,17 @@ final class DatabaseTreeMetadataService {
}
}

private func fetchDatabaseList(connectionId: UUID, databaseType: DatabaseType) async throws -> [DatabaseMetadata] {
let systemNames = Set(PluginManager.shared.systemDatabaseNames(for: databaseType))
return try await databaseDedup.execute(key: connectionId) { [self] in
try await withDriver(connectionId: connectionId, database: nil) { driver in
try await driver.fetchDatabases().sorted().map {
DatabaseMetadata.minimal(name: $0, isSystem: systemNames.contains($0))
}
}
}
}

func loadSchemas(connectionId: UUID, database: String) async {
guard isConnected(connectionId) else { return }
let key = DatabaseKey(connectionId: connectionId, database: database)
Expand All @@ -125,11 +129,7 @@ final class DatabaseTreeMetadataService {
}
schemaList[key] = .loading
do {
let list = try await schemaDedup.execute(key: key) { [self] in
try await withDriver(connectionId: connectionId, database: database) { driver in
try await driver.fetchSchemas()
}
}
let list = try await fetchSchemaList(connectionId: connectionId, database: database, key: key)
schemaList[key] = .loaded(list)
} catch is CancellationError {
if case .loading = schemaList[key] { schemaList[key] = .idle }
Expand All @@ -139,6 +139,14 @@ final class DatabaseTreeMetadataService {
}
}

private func fetchSchemaList(connectionId: UUID, database: String, key: DatabaseKey) async throws -> [String] {
try await schemaDedup.execute(key: key) { [self] in
try await withDriver(connectionId: connectionId, database: database) { driver in
try await driver.fetchSchemas()
}
}
}

func loadTables(connectionId: UUID, database: String, schema: String?) async {
guard isConnected(connectionId) else { return }
let key = Self.objectsKey(connectionId: connectionId, database: database, schema: schema)
Expand Down Expand Up @@ -226,17 +234,47 @@ final class DatabaseTreeMetadataService {

// MARK: - Refresh

/// Fetches first and commits over the old list, so a refresh never empties the tree
/// and a failed refresh keeps the databases already on screen.
func refreshDatabases(connectionId: UUID, databaseType: DatabaseType) async {
await databaseDedup.cancel(key: connectionId)
databaseList.removeValue(forKey: connectionId)
await loadDatabases(connectionId: connectionId, databaseType: databaseType)
guard case .loaded = databaseListState(for: connectionId) else {
databaseList.removeValue(forKey: connectionId)
await loadDatabases(connectionId: connectionId, databaseType: databaseType)
return
}
guard isConnected(connectionId) else { return }
do {
databaseList[connectionId] = .loaded(
try await fetchDatabaseList(connectionId: connectionId, databaseType: databaseType)
)
} catch is CancellationError {
} catch {
Self.logger.warning(
"databases refresh failed connId=\(connectionId, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
}
}

func refreshSchemas(connectionId: UUID, database: String) async {
let key = DatabaseKey(connectionId: connectionId, database: database)
await schemaDedup.cancel(key: key)
schemaList.removeValue(forKey: key)
await loadSchemas(connectionId: connectionId, database: database)
guard case .loaded = schemaList[key] ?? .idle else {
schemaList.removeValue(forKey: key)
await loadSchemas(connectionId: connectionId, database: database)
return
}
guard isConnected(connectionId) else { return }
do {
schemaList[key] = .loaded(
try await fetchSchemaList(connectionId: connectionId, database: database, key: key)
)
} catch is CancellationError {
} catch {
Self.logger.warning(
"schemas refresh failed db=\(database, privacy: .public) error=\(error.localizedDescription, privacy: .public)"
)
}
}

func refreshObjects(connectionId: UUID, database: String, schema: String?) async {
Expand Down
34 changes: 34 additions & 0 deletions TablePro/Models/Database/ContainerDropEligibility.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//
// ContainerDropEligibility.swift
// TablePro
//

import Foundation

enum ContainerDropEligibility {
struct Context {
let activeDatabase: String?
let activeSchema: String?
let supportsDropDatabase: Bool
let supportsDropSchema: Bool
let isReadOnly: Bool
}

static func droppable(_ targets: [DatabaseContainerRef], context: Context) -> [DatabaseContainerRef] {
guard !context.isReadOnly else { return [] }
return targets.filter { isDroppable($0, context: context) }
}

private static func isDroppable(_ target: DatabaseContainerRef, context: Context) -> Bool {
guard !target.isSystem else { return false }
switch target.kind {
case .database:
guard context.supportsDropDatabase else { return false }
return target.database != context.activeDatabase
case .schema:
guard context.supportsDropSchema else { return false }
guard target.database == context.activeDatabase else { return true }
return target.schema != context.activeSchema
}
}
}
Loading
Loading