diff --git a/CHANGELOG.md b/CHANGELOG.md index 388ad4a16..5a339f3d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift index 0df635f5b..fdbf2c7fb 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPlugin.swift @@ -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 = """ @@ -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)) } } diff --git a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift index d3def2c7b..ac8e3b400 100644 --- a/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift +++ b/Plugins/MSSQLDriverPlugin/MSSQLPluginDriver+Schema.swift @@ -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 @@ -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) } } @@ -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 = """ @@ -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 } @@ -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 = """ @@ -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)) @@ -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) @@ -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))" } diff --git a/TablePro/Core/Database/DatabaseManager+Sessions.swift b/TablePro/Core/Database/DatabaseManager+Sessions.swift index d5d9da1e1..b0fc3ad9c 100644 --- a/TablePro/Core/Database/DatabaseManager+Sessions.swift +++ b/TablePro/Core/Database/DatabaseManager+Sessions.swift @@ -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 } } } @@ -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 { diff --git a/TablePro/Core/Database/DatabaseManager.swift b/TablePro/Core/Database/DatabaseManager.swift index abc147a15..83263bdc8 100644 --- a/TablePro/Core/Database/DatabaseManager.swift +++ b/TablePro/Core/Database/DatabaseManager.swift @@ -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 diff --git a/TablePro/Core/Plugins/PluginDriverAdapter.swift b/TablePro/Core/Plugins/PluginDriverAdapter.swift index 205d5519f..8da643942 100644 --- a/TablePro/Core/Plugins/PluginDriverAdapter.swift +++ b/TablePro/Core/Plugins/PluginDriverAdapter.swift @@ -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] { diff --git a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift index f2cb32924..c6f90f057 100644 --- a/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift +++ b/TablePro/Views/Main/Extensions/MainContentCoordinator+Navigation.swift @@ -17,6 +17,7 @@ extension MainContentCoordinator { func openTableTab( _ table: TableInfo, + schema: String? = nil, showStructure: Bool = false, forceNonPreview: Bool = false, activateGridFocus: Bool = false, @@ -24,7 +25,7 @@ extension MainContentCoordinator { ) { openTableTab( table.name, - schema: table.schema, + schema: schema ?? table.schema, showStructure: showStructure, isView: table.type == .view, forceNonPreview: forceNonPreview, @@ -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) diff --git a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift index c34631dc6..e0629e455 100644 --- a/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift +++ b/TablePro/Views/Sidebar/DatabaseTreeOutlineCoordinator.swift @@ -488,7 +488,12 @@ 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 + ) } } @@ -496,11 +501,17 @@ final class DatabaseTreeOutlineCoordinator: NSObject { 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) { @@ -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) } } diff --git a/TableProTests/Core/Database/DatabaseManagerTests.swift b/TableProTests/Core/Database/DatabaseManagerTests.swift index 565a8e6dd..783db0bef 100644 --- a/TableProTests/Core/Database/DatabaseManagerTests.swift +++ b/TableProTests/Core/Database/DatabaseManagerTests.swift @@ -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 { @@ -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" @@ -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) } } diff --git a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift index fced40ede..db52a9fee 100644 --- a/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift +++ b/TableProTests/Core/Plugins/PluginDriverAdapterTableTypeMappingTests.swift @@ -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] { @@ -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) diff --git a/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift b/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift index f8b691b05..026d35061 100644 --- a/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift +++ b/TableProTests/Views/Main/TableTabSchemaResolutionTests.swift @@ -125,3 +125,86 @@ struct TableTabSchemaResolutionTests { #expect(resolved == false) } } + +/// A table tab must carry the schema the row was listed under. SQL Server has no +/// session-level schema, so a tab that opens without one queries an unqualified +/// name and the server answers "Invalid object name" (#2004). +@Suite("TableTabListingSchema") +@MainActor +struct TableTabListingSchemaTests { + private func withCoordinator( + sessionSchema: String?, + _ body: (MainContentCoordinator, QueryTabManager) -> Void + ) { + let connection = TestFixtures.makeConnection(database: "AppDb", type: .mssql) + let driver = MockDatabaseDriver(connection: connection) + var session = ConnectionSession(connection: connection, driver: driver) + session.currentSchema = sessionSchema + DatabaseManager.shared.injectSession(session, for: connection.id) + defer { DatabaseManager.shared.removeSession(for: connection.id) } + + let tabManager = QueryTabManager() + let coordinator = MainContentCoordinator( + connection: connection, + tabManager: tabManager, + changeManager: DataChangeManager(), + toolbarState: ConnectionToolbarState() + ) + defer { coordinator.teardown() } + + body(coordinator, tabManager) + } + + private func makeTable(schema: String?) -> TableInfo { + TableInfo(name: "def_encounter", type: .table, rowCount: nil, schema: schema) + } + + @Test("An explicit schema wins over the listed table's own schema") + func explicitSchemaWins() { + withCoordinator(sessionSchema: "dbo") { coordinator, tabManager in + coordinator.openTableTab(makeTable(schema: "stale"), schema: "custom") + #expect(tabManager.tabs.first?.tableContext.schemaName == "custom") + } + } + + @Test("The listed table's schema is used when the caller gives none") + func tableSchemaIsUsed() { + withCoordinator(sessionSchema: "dbo") { coordinator, tabManager in + coordinator.openTableTab(makeTable(schema: "custom")) + #expect(tabManager.tabs.first?.tableContext.schemaName == "custom") + } + } + + @Test("The session schema is the last resort, not the first") + func sessionSchemaIsLastResort() { + withCoordinator(sessionSchema: "custom") { coordinator, tabManager in + coordinator.openTableTab(makeTable(schema: nil)) + #expect(tabManager.tabs.first?.tableContext.schemaName == "custom") + } + } + + @Test("A blank schema on the listed table does not reach the tab") + func blankTableSchemaFallsBack() { + withCoordinator(sessionSchema: "custom") { coordinator, tabManager in + coordinator.openTableTab(makeTable(schema: "")) + #expect(tabManager.tabs.first?.tableContext.schemaName == "custom") + } + } + + @Test("A blank session schema leaves the tab without one instead of an empty qualifier") + func blankSessionSchemaStaysAbsent() { + withCoordinator(sessionSchema: "") { coordinator, tabManager in + coordinator.openTableTab(makeTable(schema: nil)) + #expect(tabManager.tabs.first?.tableContext.schemaName == nil) + } + } + + @Test("A table outside the default schema opens a schema-qualified query") + func queryIsSchemaQualified() { + withCoordinator(sessionSchema: "dbo") { coordinator, tabManager in + coordinator.openTableTab(makeTable(schema: "custom")) + let query = tabManager.tabs.first?.content.query ?? "" + #expect(query.contains("[custom].[def_encounter]")) + } + } +} diff --git a/docs/databases/mssql.mdx b/docs/databases/mssql.mdx index d2566e20e..082da319c 100644 --- a/docs/databases/mssql.mdx +++ b/docs/databases/mssql.mdx @@ -83,7 +83,7 @@ See [Connection URL Reference](/databases/connection-urls) for all parameters. ## Databases and Schemas -The sidebar groups tables by schema and hides the built-in role schemas (`db_owner`, `guest`, and the rest). Switch the active database with **Cmd+K**; switches happen in place, no reconnect. Pick the active schema from the schema menu in the sidebar. `master`, `tempdb`, `model`, and `msdb` are marked as system databases. +The sidebar nests tables under their schema and hides the built-in role schemas (`db_owner`, `guest`, and the rest). Switch the active database with **Cmd+K**; switches happen in place, no reconnect. Click a schema in the sidebar to make it active, or set a starting schema in the connection's **Schema** field. Opening a table always queries it in the schema it is listed under, so tables outside `dbo` work without switching first. `master`, `tempdb`, `model`, and `msdb` are marked as system databases. ## Features