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

### Fixed

- Opening a SQL Server table or view that lives outside the default schema no longer fails with "Invalid object name". A table now keeps the schema it was listed under, and switching database no longer leaves the sidebar and the query on different schemas. (#2004)
- Editing a MySQL or MariaDB column no longer drops its `ON UPDATE CURRENT_TIMESTAMP`. Saving a change to any other part of the column, even just its comment, silently removed the clause. (#2005)
- A MySQL or MariaDB timestamp column that keeps fractional seconds now saves. Its default was written as text instead of an expression, so the change was rejected. (#2005)

- Quitting no longer loses unsaved SQL editor tabs. A window with no tabs loaded yet, such as one still waiting on its connection, could erase the saved tabs for that connection, so nothing came back on relaunch. Editors are also saved about a second after you stop typing instead of waiting up to 30 seconds, and a query over 500KB is kept in full rather than restored empty. (#1997)
- Opening a large MongoDB collection no longer hangs. Row counts that back the pagination display now give up after 5 seconds and leave the estimate in place instead of holding the tab.
- Stop now cancels a running MongoDB query on the server, rather than leaving it running until it finishes on its own.
Expand Down
10 changes: 7 additions & 3 deletions Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
}

func fetchApproximateRowCount(table: String, schema: String?) async throws -> Int? {
let esc = (schema ?? _currentSchema).replacingOccurrences(of: "'", with: "''")
let esc = effectiveSchemaEscaped(schema)
let escapedTable = table.replacingOccurrences(of: "'", with: "''")
let objectName = "[\(esc)].[\(escapedTable)]"
let sql = """
Expand Down Expand Up @@ -816,9 +816,13 @@ final class MSSQLPluginDriver: PluginDatabaseDriver, @unchecked Sendable {
value.replacingOccurrences(of: "'", with: "''")
}

func effectiveSchema(_ schema: String?) -> String {
guard let schema, !schema.isEmpty else { return _currentSchema }
return schema
}

func effectiveSchemaEscaped(_ schema: String?) -> String {
let raw = schema ?? _currentSchema
return raw.replacingOccurrences(of: "'", with: "''")
MSSQLSchemaQueries.escape(effectiveSchema(schema))
}

}
Expand Down
17 changes: 9 additions & 8 deletions Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ extension MSSQLPluginDriver {
// MARK: - Schema Operations

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
let esc = effectiveSchemaEscaped(schema)
let resolved = effectiveSchema(schema)
let esc = MSSQLSchemaQueries.escape(resolved)
let sql = """
SELECT t.TABLE_NAME, t.TABLE_TYPE
FROM INFORMATION_SCHEMA.TABLES t
Expand All @@ -25,7 +26,7 @@ extension MSSQLPluginDriver {
guard let name = row[safe: 0]?.asText else { return nil }
let rawType = row[safe: 1]?.asText
let tableType = (rawType == "VIEW") ? "VIEW" : "TABLE"
return PluginTableInfo(name: name, type: tableType)
return PluginTableInfo(name: name, type: tableType, schema: resolved)
}
}

Expand Down Expand Up @@ -128,7 +129,7 @@ extension MSSQLPluginDriver {
}

func fetchIndexes(table: String, schema: String?) async throws -> [PluginIndexInfo] {
let esc = (schema ?? _currentSchema).replacingOccurrences(of: "]", with: "]]")
let esc = MSSQLSchemaQueries.escapeBracket(effectiveSchema(schema))
let bracketedTable = table.replacingOccurrences(of: "]", with: "]]")
let bracketedFull = "[\(esc)].[\(bracketedTable)]"
let sql = """
Expand Down Expand Up @@ -166,7 +167,7 @@ extension MSSQLPluginDriver {
}

func fetchForeignKeys(table: String, schema: String?) async throws -> [PluginForeignKeyInfo] {
let sql = MSSQLSchemaQueries.foreignKeys(schema: schema ?? _currentSchema, table: table)
let sql = MSSQLSchemaQueries.foreignKeys(schema: effectiveSchema(schema), table: table)
let result = try await execute(query: sql)
return result.rows.compactMap { row -> PluginForeignKeyInfo? in
guard let parsed = MSSQLSchemaQueries.parseForeignKeyRow(row.map { $0.asText }) else { return nil }
Expand All @@ -181,7 +182,7 @@ extension MSSQLPluginDriver {
}

func fetchTriggers(table: String, schema: String?) async throws -> [PluginTriggerInfo] {
let esc = (schema ?? _currentSchema).replacingOccurrences(of: "]", with: "]]")
let esc = MSSQLSchemaQueries.escapeBracket(effectiveSchema(schema))
let bracketedTable = table.replacingOccurrences(of: "]", with: "]]")
let bracketedFull = "[\(esc)].[\(bracketedTable)]"
let sql = """
Expand Down Expand Up @@ -227,7 +228,7 @@ extension MSSQLPluginDriver {
var supportsTransactionalDDL: Bool { true }

func createTriggerTemplate(table: String, schema: String?) -> String? {
let resolved = schema ?? _currentSchema
let resolved = effectiveSchema(schema)
return """
CREATE OR ALTER TRIGGER \(quoteIdentifier("trigger_name"))
ON \(quoteIdentifier(resolved)).\(quoteIdentifier(table))
Expand All @@ -241,7 +242,7 @@ extension MSSQLPluginDriver {
}

func fetchTriggerDefinition(name: String, table: String, schema: String?) async throws -> String? {
let esc = (schema ?? _currentSchema).replacingOccurrences(of: "]", with: "]]")
let esc = MSSQLSchemaQueries.escapeBracket(effectiveSchema(schema))
let bracketedName = name.replacingOccurrences(of: "]", with: "]]")
let sql = "SELECT OBJECT_DEFINITION(OBJECT_ID('[\(esc)].[\(bracketedName)]'))"
let result = try await execute(query: sql)
Expand All @@ -253,7 +254,7 @@ extension MSSQLPluginDriver {
}

func generateDropTriggerSQL(name: String, table: String, schema: String?) -> String? {
let resolved = schema ?? _currentSchema
let resolved = effectiveSchema(schema)
return "DROP TRIGGER \(quoteIdentifier(resolved)).\(quoteIdentifier(name))"
}

Expand Down
20 changes: 19 additions & 1 deletion TablePro/Core/Database/DatabaseManager+Sessions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,13 @@ extension DatabaseManager {
} else if let adapter = driver as? PluginDriverAdapter {
try await adapter.switchDatabase(to: database)
let grouping = pm?.schema.databaseGroupingStrategy ?? .byDatabase
if grouping == .bySchema {
await resetSchema(on: adapter, to: pm?.schema.defaultSchemaName)
}
updateSession(connectionId) { session in
session.currentDatabase = database
if grouping == .bySchema {
session.currentSchema = pm?.schema.defaultSchemaName
session.currentSchema = adapter.currentSchema
}
}
}
Expand All @@ -296,6 +299,21 @@ extension DatabaseManager {
}
}

/// Moves the driver to the engine's default schema after a database switch.
/// Writing the session's schema without moving the driver leaves object listings
/// (driver schema) and table queries (session schema) on different schemas.
private func resetSchema(on driver: any SchemaSwitchable, to defaultSchemaName: String?) async {
guard let defaultSchemaName, !defaultSchemaName.isEmpty else { return }
guard driver.currentSchema != defaultSchemaName else { return }
do {
try await driver.switchSchema(to: defaultSchemaName)
} catch {
Self.logger.warning(
"Failed to reset schema to '\(defaultSchemaName, privacy: .public)' after a database switch: \(error.localizedDescription, privacy: .public)"
)
}
}

func switchSchema(to schema: String, for connectionId: UUID) async throws {
guard let driver = driver(for: connectionId),
let schemaDriver = driver as? SchemaSwitchable else {
Expand Down
12 changes: 9 additions & 3 deletions TablePro/Core/Database/DatabaseManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,10 +94,16 @@ final class DatabaseManager {
}

/// Authoritative schema for a table identity when the caller has no explicit
/// schema. Explicit schemas pass through unchanged; nil resolves to the live
/// session's current schema and stays nil for schema-less engines.
/// schema. Explicit schemas pass through unchanged; a blank or missing schema
/// resolves to the live session's current schema and stays nil for schema-less
/// engines. A blank name never reaches a query builder: engines that qualify
/// object names treat it as "no schema" and emit an unqualified name.
func resolvedSchemaName(_ schemaName: String?, for connectionId: UUID) -> String? {
schemaName ?? activeSessions[connectionId]?.currentSchema
if let schemaName, !schemaName.isEmpty { return schemaName }
guard let sessionSchema = activeSessions[connectionId]?.currentSchema, !sessionSchema.isEmpty else {
return nil
}
return sessionSchema
}

/// Current connection status
Expand Down
3 changes: 1 addition & 2 deletions TablePro/Core/Plugins/PluginDriverAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,8 +171,7 @@ final class PluginDriverAdapter: DatabaseDriver, SchemaSwitchable {
// MARK: - Schema Operations

func fetchTables() async throws -> [TableInfo] {
let pluginTables = try await pluginDriver.fetchTables(schema: pluginDriver.currentSchema)
return pluginTables.map { mapPluginTable($0, schemaFallback: nil) }
try await fetchTables(schema: nil)
}

func fetchTables(schema: String?) async throws -> [TableInfo] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,15 @@ extension MainContentCoordinator {

func openTableTab(
_ table: TableInfo,
schema: String? = nil,
showStructure: Bool = false,
forceNonPreview: Bool = false,
activateGridFocus: Bool = false,
forceNewWindowTab: Bool = false
) {
openTableTab(
table.name,
schema: table.schema,
schema: schema ?? table.schema,
showStructure: showStructure,
isView: table.type == .view,
forceNonPreview: forceNonPreview,
Expand Down Expand Up @@ -377,6 +378,7 @@ extension MainContentCoordinator {

do {
try await DatabaseManager.shared.switchDatabase(to: database, for: connectionId, persist: persist)
toolbarState.currentSchema = DatabaseManager.shared.session(for: connectionId)?.currentSchema

await SchemaService.shared.invalidate(connectionId: connectionId)

Expand Down
25 changes: 18 additions & 7 deletions TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -488,19 +488,30 @@ final class DatabaseTreeOutlineCoordinator: NSObject {
private func open(_ ref: DatabaseTreeTableRef, activateGridFocus: Bool, forceNewWindowTab: Bool = false) {
Task { @MainActor in
await activate(ref)
mainCoordinator?.openTableTab(ref.table, activateGridFocus: activateGridFocus, forceNewWindowTab: forceNewWindowTab)
mainCoordinator?.openTableTab(
ref.table,
schema: ref.schema,
activateGridFocus: activateGridFocus,
forceNewWindowTab: forceNewWindowTab
)
}
}

private func activate(_ ref: DatabaseTreeTableRef) async {
if ref.database != activeDatabase {
await mainCoordinator?.switchDatabase(to: ref.database)
}
if let schema = ref.schema,
schema != mainCoordinator?.toolbarState.currentSchema,
PluginManager.shared.supportsSchemaSwitching(for: databaseType) {
await mainCoordinator?.switchSchema(to: schema)
}
guard let schema = ref.schema,
PluginManager.shared.supportsSchemaSwitching(for: databaseType),
schema != sessionSchema else { return }
await mainCoordinator?.switchSchema(to: schema)
}

/// The live session schema, not the window's toolbar mirror. A database switch
/// moves the session schema without touching the toolbar, so comparing against
/// the toolbar skips the switch exactly when the session needs it.
private var sessionSchema: String? {
DatabaseManager.shared.session(for: connectionId)?.currentSchema
}

private func setActiveDatabase(_ database: String) {
Expand All @@ -513,7 +524,7 @@ final class DatabaseTreeOutlineCoordinator: NSObject {
if database != activeDatabase {
await mainCoordinator?.switchDatabase(to: database)
}
if schema != mainCoordinator?.toolbarState.currentSchema {
if schema != sessionSchema {
await mainCoordinator?.switchSchema(to: schema)
}
}
Expand Down
54 changes: 52 additions & 2 deletions TableProTests/Core/Database/DatabaseManagerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,28 @@ struct DatabaseManagerSessionTests {

#expect(DatabaseManager.shared.resolvedSchemaName(nil, for: connection.id) == nil)
}

@Test("resolvedSchemaName treats a blank explicit schema as absent")
func resolvedSchemaNameTreatsBlankExplicitSchemaAsAbsent() {
let connection = TestFixtures.makeConnection()
var session = ConnectionSession(connection: connection)
session.currentSchema = "custom"
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

#expect(DatabaseManager.shared.resolvedSchemaName("", for: connection.id) == "custom")
}

@Test("resolvedSchemaName returns nil rather than a blank session schema")
func resolvedSchemaNameRejectsBlankSessionSchema() {
let connection = TestFixtures.makeConnection()
var session = ConnectionSession(connection: connection)
session.currentSchema = ""
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

#expect(DatabaseManager.shared.resolvedSchemaName(nil, for: connection.id) == nil)
}
}

private class DatabaseSwitchBaseDriver {
Expand Down Expand Up @@ -100,19 +122,31 @@ private class DatabaseSwitchBaseDriver {

private final class DatabaseSwitchingDriver: DatabaseSwitchBaseDriver, PluginDatabaseDriver {
private(set) var switchedDatabases: [String] = []
private var schema: String?

override var currentSchema: String? { schema }

init(currentSchema: String? = nil) {
self.schema = currentSchema
super.init()
}

func switchDatabase(to database: String) async throws {
switchedDatabases.append(database)
}

func switchSchema(to schema: String) async throws {
self.schema = schema
}
}

@Suite("DatabaseManager database switch")
@MainActor
struct DatabaseManagerDatabaseSwitchTests {
@Test("bySchema engines reset the session schema to the plugin default")
@Test("bySchema engines move the driver to the plugin default and record what it is using")
func bySchemaSwitchResetsSchemaToDefault() async throws {
let connection = TestFixtures.makeConnection(type: .mssql)
let pluginDriver = DatabaseSwitchingDriver()
let pluginDriver = DatabaseSwitchingDriver(currentSchema: "sales")
let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver)
var session = ConnectionSession(connection: connection, driver: adapter)
session.currentSchema = "sales"
Expand All @@ -125,5 +159,21 @@ struct DatabaseManagerDatabaseSwitchTests {
#expect(pluginDriver.switchedDatabases == ["other_db"])
#expect(updated?.currentDatabase == "other_db")
#expect(updated?.currentSchema == "dbo")
#expect(pluginDriver.currentSchema == "dbo")
}

@Test("A database switch never leaves the session and the driver on different schemas")
func sessionSchemaMatchesDriverAfterSwitch() async throws {
let connection = TestFixtures.makeConnection(type: .mssql)
let pluginDriver = DatabaseSwitchingDriver(currentSchema: "custom")
let adapter = PluginDriverAdapter(connection: connection, pluginDriver: pluginDriver)
var session = ConnectionSession(connection: connection, driver: adapter)
session.currentSchema = "custom"
DatabaseManager.shared.injectSession(session, for: connection.id)
defer { DatabaseManager.shared.removeSession(for: connection.id) }

try await DatabaseManager.shared.switchDatabase(to: "other_db", for: connection.id, persist: false)

#expect(DatabaseManager.shared.session(for: connection.id)?.currentSchema == adapter.currentSchema)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,22 @@ import TableProPluginKit
import Testing

private final class StubTableTypeDriver: PluginDatabaseDriver {
var supportsSchemas: Bool { false }
var stubbedSupportsSchemas = false
var stubbedCurrentSchema: String?

var supportsSchemas: Bool { stubbedSupportsSchemas }
var supportsTransactions: Bool { false }
var currentSchema: String? { nil }
var currentSchema: String? { stubbedCurrentSchema }
var serverVersion: String? { nil }

var stubbedTables: [PluginTableInfo] = []
var stubbedPartitions: [PluginTableInfo] = []
private(set) var requestedPartitionTable: String?
private(set) var requestedTableSchema: String??

func fetchTables(schema: String?) async throws -> [PluginTableInfo] {
stubbedTables
requestedTableSchema = .some(schema)
return stubbedTables
}

func fetchPartitions(table: String, schema: String?) async throws -> [PluginTableInfo] {
Expand Down Expand Up @@ -230,8 +235,22 @@ struct PluginDriverAdapterTableTypeMappingTests {
#expect(tables.first?.schema == "audit")
}

@Test("fetchTables() preserves nil schema (no fallback to currentSchema)")
func defaultFetchPreservesNilSchema() async throws {
@Test("fetchTables() stamps the schema the rows were actually read from")
func defaultFetchStampsCurrentSchema() async throws {
let driver = StubTableTypeDriver()
driver.stubbedSupportsSchemas = true
driver.stubbedCurrentSchema = "custom"
driver.stubbedTables = [PluginTableInfo(name: "def_encounter", type: "TABLE")]
let adapter = makeAdapter(driver: driver)

let tables = try await adapter.fetchTables()

#expect(driver.requestedTableSchema == .some("custom"))
#expect(tables.first?.schema == "custom")
}

@Test("fetchTables() stays schema-less for an engine without schemas")
func defaultFetchStaysSchemaLess() async throws {
let driver = StubTableTypeDriver()
driver.stubbedTables = [PluginTableInfo(name: "users", type: "TABLE")]
let adapter = makeAdapter(driver: driver)
Expand Down
Loading
Loading