From b77db213d1d6f6ab20f483836ece12c7234ad87f Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:37:04 +0100 Subject: [PATCH 01/23] feat(linux-editor): name the tab's database and schema in the editor bar --- linux/crates/app/src/ui/editor/imp.rs | 4 + linux/crates/app/src/ui/editor/mod.rs | 2 + linux/crates/app/src/ui/editor/tab_scope.rs | 100 ++++++++++++++++++++ linux/crates/app/src/ui/editor/tests.rs | 53 +++++++++++ linux/data/resources/ui/sql-editor.ui | 25 +++++ linux/po/POTFILES.in | 1 + 6 files changed, 185 insertions(+) create mode 100644 linux/crates/app/src/ui/editor/tab_scope.rs diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index bc9ccfddf1..c89247faa3 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -29,6 +29,10 @@ pub struct RunningScript { #[derive(Default, gtk4::CompositeTemplate)] #[template(resource = "/app/tablepro/TablePro/ui/sql-editor.ui")] pub struct SqlEditor { + #[template_child] + pub scope_box: TemplateChild, + #[template_child] + pub scope_label: TemplateChild, #[template_child] pub cursor_info: TemplateChild, #[template_child] diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 25bfae0b0b..15c3a498fc 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -26,6 +26,7 @@ mod sidebar_policy; mod significant_tokens; mod sql_file; mod statement_cursor; +mod tab_scope; #[cfg(test)] mod tests; mod transaction_dirty; @@ -152,6 +153,7 @@ impl SqlEditor { editor.install_drop_target(); editor.connect_buttons(); editor.connect_grid(); + editor.show_scope(); editor } diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs new file mode 100644 index 0000000000..752b9c22d3 --- /dev/null +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -0,0 +1,100 @@ +//! The database the tab works on, named in the editor bar as the macOS +//! editor names it (`QueryContainerPicker`): the database, then the schema +//! an unqualified name resolves to where the engine nests schemas in a +//! database. A file's name is already in the window title and the sidebar. + +use gtk4::glib; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; + +use super::SqlEditor; +use crate::i18n::gettext_f; + +/// The bar's text, dot-qualified as SQL and the sidebar spell a qualified +/// object, and what a screen reader says for it. `None` with no database. +pub(super) fn scope_text(database: Option<&str>, default_schema: Option<&str>) -> Option<(String, String)> { + let database = database.filter(|database| !database.is_empty())?; + // Where the engine's schemas are its databases, as on MySQL, the + // default one is the database itself and adds nothing. + match default_schema.filter(|schema| !schema.is_empty() && *schema != database) { + Some(schema) => Some(( + format!("{database}.{schema}"), + gettext_f( + "{database}, schema {schema}", + &[("database", database), ("schema", schema)], + ), + )), + None => Some((database.to_owned(), database.to_owned())), + } +} + +impl SqlEditor { + /// Name the tab's database now, and its schema once the catalog has + /// listed the schemas. + pub(super) fn show_scope(&self) { + let Some((session, tasks)) = self + .deps() + .and_then(|deps| deps.session.get().map(|session| (session, deps.tasks.clone()))) + else { + self.set_scope(None); + return; + }; + let database = session.database().map(str::to_owned); + self.set_scope(scope_text(database.as_deref(), None)); + if database.is_none() { + return; + } + // The catalog reads on the runtime, as completion's reads do. + let reading = tasks.spawn_task(async move { + session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }) + }); + let editor = self.downgrade(); + glib::spawn_future_local(async move { + let default = reading.await.ok().flatten(); + if let Some(editor) = editor.upgrade() { + editor.set_scope(scope_text(database.as_deref(), default.as_deref())); + } + }); + } + + fn set_scope(&self, scope: Option<(String, String)>) { + let imp = self.imp(); + let shown = scope.is_some(); + if let Some((text, spoken)) = scope { + imp.scope_label.set_label(&text); + imp.scope_box + .update_property(&[gtk4::accessible::Property::Label(&spoken)]); + imp.scope_box.set_tooltip_text(Some(&spoken)); + } + imp.scope_box.set_visible(shown); + } +} + +#[cfg(test)] +mod tests { + use super::scope_text; + + #[test] + fn the_scope_names_the_database_then_a_schema_of_its_own() { + assert_eq!( + scope_text(Some("shop"), Some("public")), + Some(("shop.public".to_owned(), "shop, schema public".to_owned())) + ); + assert_eq!( + scope_text(Some("shop"), Some("shop")), + Some(("shop".to_owned(), "shop".to_owned())), + "a schema that is the database" + ); + assert_eq!( + scope_text(Some("shop"), None), + Some(("shop".to_owned(), "shop".to_owned())) + ); + assert_eq!(scope_text(None, Some("main")), None, "a file"); + assert_eq!(scope_text(Some(""), None), None); + } +} diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 6e4ffb6e2e..ff881962d7 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2618,3 +2618,56 @@ fn a_command_editor_has_none_of_the_sql_tools() { "the document's words and the engine's own snippets complete" ); } + +/// The bar names the tab's database and its default schema, as the +/// macOS editor bar does. +#[gtk4::test] +fn the_bar_names_the_tabs_database_and_schema() { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = Arc::new(FakeEditorSession::new(answering())); + let mut request = FakeConnector::network_request("db.example", 5432).expect("a request"); + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = Some("shop".to_owned()); + } + let mut script = ConnectionScript::default(); + let editor_session: Arc = fake; + script.open_editor_session.always(FakeReply::ok(editor_session)); + let (pending, _) = FakeConnector::pending_session(request, script); + let session = Session::start( + pending, + tablepro_session::SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), + tasks: runtime.tasks(), + prompter: None, + }, + ); + let schema = |name: &str, is_default: bool| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + }; + session + .catalog() + .commit(vec![schema("public", true), schema("audit", false)], Vec::new()); + let active = ActiveSession::new(); + active.replace(session); + let settings = MemorySettings::new(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: None, + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active, + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + snippets: crate::test_support::builtin_snippets_only(), + }); + + let label = editor.imp().scope_label.get(); + crate::test_support::wait_until(Duration::from_secs(5), || label.label() == "shop.public") + .unwrap_or_else(|_| panic!("the bar says {:?}", label.label())); + assert!(editor.imp().scope_box.is_visible()); +} diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index 17eba7d890..b343a5e633 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -92,6 +92,31 @@ 8 8 8 + + + + false + 6 + 4 + label + + + + drive-harddisk-symbolic + presentation + + + + + middle + presentation + + + + true diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index ba56cfe121..9b169697bc 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -352,6 +352,7 @@ crates/app/src/ui/editor/sidebar_policy.rs crates/app/src/ui/editor/significant_tokens.rs crates/app/src/ui/editor/sql_file.rs crates/app/src/ui/editor/statement_cursor.rs +crates/app/src/ui/editor/tab_scope.rs crates/app/src/ui/editor/tests.rs crates/app/src/ui/editor/transaction_dirty.rs crates/app/src/ui/editor/vim_mode.rs From f5c2d42c06e53564504bdf5c6affdc673e5eaf4c Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:37:14 +0100 Subject: [PATCH 02/23] docs(linux-editor): add the changelog entry --- linux/changelog.d/3896.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3896.added.md diff --git a/linux/changelog.d/3896.added.md b/linux/changelog.d/3896.added.md new file mode 100644 index 0000000000..207d0fefd8 --- /dev/null +++ b/linux/changelog.d/3896.added.md @@ -0,0 +1 @@ +The SQL editor's bar names the database and schema the tab works on. From 48a216de82312dae297d063769ae990a371cde6c Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:22 +0100 Subject: [PATCH 03/23] feat(linux-editor): switch a query tab to another MySQL or MariaDB database from the editor bar --- .../app/src/services/connection_deletion.rs | 6 +- .../app/src/ui/assistant/schema_reader.rs | 1 + .../src/ui/editor/completion/catalog_read.rs | 11 +- .../completion/sql_completion_provider.rs | 9 +- .../completion/sql_completion_provider_imp.rs | 2 + .../app/src/ui/editor/editor_session_slot.rs | 98 ++++++- linux/crates/app/src/ui/editor/imp.rs | 6 + linux/crates/app/src/ui/editor/tab_scope.rs | 257 ++++++++++++++++-- linux/crates/app/src/ui/editor/tests.rs | 72 +++++ .../app/src/ui/error_text/unsupported.rs | 2 + .../app/src/ui/workspace/tab_factory.rs | 9 +- linux/crates/app/src/ui/workspace/tab_spec.rs | 2 + .../app/src/ui/workspace/tests/assistant.rs | 1 + .../app/src/ui/workspace/tests/close_tab.rs | 2 + .../app/src/ui/workspace/tests/tab_title.rs | 2 + .../src/ui/workspace/workspace_persistence.rs | 2 + .../app/src/ui/workspace/workspace_tabs.rs | 5 +- linux/crates/app/src/workspace/tab_record.rs | 6 +- .../app/src/workspace/workspace_record.rs | 4 + .../src/workspace/workspace_record_builder.rs | 1 + .../app/src/workspace/workspace_store.rs | 2 + linux/crates/core/src/connection.rs | 9 + linux/crates/core/src/unsupported_feature.rs | 3 + linux/crates/drivers/mysql/src/connection.rs | 8 + linux/crates/drivers/mysql/src/metadata.rs | 35 +++ .../mysql/tests/integration/metadata.rs | 36 ++- .../crates/test-support/src/call_counters.rs | 1 + .../test-support/src/connection_script.rs | 2 + .../test-support/src/fake_connection.rs | 13 + linux/data/resources/ui/sql-editor.ui | 60 +++- 30 files changed, 616 insertions(+), 51 deletions(-) diff --git a/linux/crates/app/src/services/connection_deletion.rs b/linux/crates/app/src/services/connection_deletion.rs index 8eaa79c90f..8344ae13eb 100644 --- a/linux/crates/app/src/services/connection_deletion.rs +++ b/linux/crates/app/src/services/connection_deletion.rs @@ -248,7 +248,11 @@ mod tests { &key, WorkspaceRecord { active_tab: Some(0), - tabs: vec![TabRecord::Editor { draft, file: None }], + tabs: vec![TabRecord::Editor { + draft, + file: None, + database: None, + }], }, ) .expect("scheduled"); diff --git a/linux/crates/app/src/ui/assistant/schema_reader.rs b/linux/crates/app/src/ui/assistant/schema_reader.rs index c694bc6dfd..3096018fc5 100644 --- a/linux/crates/app/src/ui/assistant/schema_reader.rs +++ b/linux/crates/app/src/ui/assistant/schema_reader.rs @@ -39,6 +39,7 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> CompletionScope::General { tables_in_statement: named, }, + None, ) .await; let schema = session.schemas().await.ok().and_then(|schemas| { diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs index 1c2eede515..2153a4adea 100644 --- a/linux/crates/app/src/ui/editor/completion/catalog_read.rs +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -17,8 +17,15 @@ pub struct CatalogAnswer { /// Read what `scope` offers. The catalog shares each read with every /// other view and keeps what it read, so typing does not reach the /// server again; a list that cannot be read is left out. -pub async fn read(session: Arc, scope: CompletionScope) -> CatalogAnswer { - let defaults = listed(session.objects(ObjectScope::DefaultSchema).await); +/// +/// `home` is the database the tab was moved to from the editor bar, where +/// an unqualified name now resolves; `None` keeps the connection's own. +pub async fn read(session: Arc, scope: CompletionScope, home: Option) -> CatalogAnswer { + let home = match home { + Some(database) => ObjectScope::Schema(database), + None => ObjectScope::DefaultSchema, + }; + let defaults = listed(session.objects(home).await); match scope { CompletionScope::Objects { schema: None } => CatalogAnswer { schemas: Some(listed(session.schemas().await)), diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs index 9daab4c91b..0d17dd37bc 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -44,6 +44,12 @@ pub struct ProviderDeps { } impl SqlCompletionProvider { + /// Resolve unqualified names in `database`, the tab's own when the + /// editor bar moved it off the connection's. + pub fn set_home(&self, database: Option) { + self.imp().home.replace(database); + } + pub fn new(session: ActiveSession, tasks: Tasks, snippets: Rc) -> Self { let provider: Self = glib::Object::new(); let _ = provider.imp().deps.set(ProviderDeps { @@ -67,11 +73,12 @@ impl SqlCompletionProvider { let ranking = match (request.session.clone(), tasks.clone()) { (Some(session), Some(tasks)) if request.grammar.is_sql() => { let fallback = request.clone(); + let home = self.imp().home.borrow().clone(); let ranked_for = needle.clone(); let blocking = tasks.clone(); tasks .spawn_task(async move { - let answer = catalog_read::read(session, request.scope.clone()).await; + let answer = catalog_read::read(session, request.scope.clone(), home).await; blocking .spawn_blocking_task(move || Ranking::new(request.candidates(&answer).into(), &ranked_for)) .await diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs index 9ceb7307b7..ea5265cdcd 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs @@ -19,6 +19,8 @@ const PRIORITY: i32 = 100; #[derive(Default)] pub struct SqlCompletionProvider { pub deps: OnceCell, + /// The database the tab was moved to, where unqualified names resolve. + pub home: std::cell::RefCell>, } #[glib::object_subclass] diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 5e18a149e8..361edfb694 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -1,7 +1,11 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; use std::time::Duration; -use tablepro_core::{EditorSession, EditorSessionOptions, EditorSessionState, TransactionState}; +use async_trait::async_trait; +use tablepro_core::{ + EditorSession, EditorSessionOptions, EditorSessionState, ResultEvent, ResultSink, RunOptions, SinkFlow, + TransactionState, UserSql, +}; use tablepro_session::{OperationError, Session}; use tokio_util::sync::CancellationToken; @@ -28,6 +32,15 @@ struct SlotState { /// told yet. rolled_back: bool, closed: bool, + /// The database the tab was moved to from the editor bar. Every + /// session the tab opens or holds is moved there before anything else. + database: Option, +} + +/// A database a tab was moved to. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TabDatabase { + pub name: String, } struct OpenEditor { @@ -37,6 +50,8 @@ struct OpenEditor { editor: Arc, /// Where the transaction stood after the last run that reported it. transaction: Option, + /// The tab's database this session was moved to, if any. + moved_to: Option, } enum Lookup { @@ -80,7 +95,7 @@ impl EditorSessionSlot { cancel: &CancellationToken, ) -> Result, AcquireError> { let stale = match self.lookup(session) { - Lookup::Ready(editor) => return Ok(editor), + Lookup::Ready(editor) => return self.move_to_database(session, editor, cancel).await, Lookup::Closed => return Err(AcquireError::Cancelled), Lookup::RolledBack { stale } => { if let Some(stale) = stale { @@ -96,7 +111,7 @@ impl EditorSessionSlot { let options = EditorSessionOptions { statement_timeout }; match session.open_editor_session(options, cancel).await { Ok(editor) => match self.adopt(session, editor.clone()) { - Ok(()) => Ok(editor), + Ok(()) => self.move_to_database(session, editor, cancel).await, Err(orphan) => { close_quietly(orphan, close_timeout).await; Err(AcquireError::Cancelled) @@ -110,6 +125,72 @@ impl EditorSessionSlot { } } + /// Move the tab's sessions to `database` from the next run on. + pub fn set_database(&self, database: Option) { + lock(&self.state).database = database; + } + + /// The tab's database, when it was moved off the connection's own. + pub fn database(&self) -> Option { + lock(&self.state).database.clone() + } + + /// Run the engine's `USE` for the tab's database on `editor` unless it + /// ran there already. Built now, when the session's dialect is known: + /// a tab restored before the connection came up named only the name. + async fn move_to_database( + &self, + session: &Arc, + editor: Arc, + cancel: &CancellationToken, + ) -> Result, AcquireError> { + let wanted = { + let state = lock(&self.state); + let moved = state + .open + .as_ref() + .filter(|open| Arc::ptr_eq(&open.editor, &editor)) + .and_then(|open| open.moved_to.clone()); + state + .database + .clone() + .filter(|database| moved.as_ref() != Some(&database.name)) + }; + let Some(database) = wanted else { + return Ok(editor); + }; + let Some(dialect) = session.current_dialect() else { + return Err(AcquireError::Failed(crate::i18n::gettext_f( + "Could not switch this tab to the database “{database}”", + &[("database", &database.name)], + ))); + }; + let statement = format!("USE {}", dialect.quote_identifier(&database.name)); + let ran = editor + .run( + UserSql::new(statement.as_str()), + RunOptions::new(cancel.clone()), + Box::new(Discard), + ) + .await; + match ran { + Ok(summary) if summary.succeeded() => { + if let Some(open) = lock(&self.state) + .open + .as_mut() + .filter(|open| Arc::ptr_eq(&open.editor, &editor)) + { + open.moved_to = Some(database.name); + } + Ok(editor) + } + _ => Err(AcquireError::Failed(crate::i18n::gettext_f( + "Could not switch this tab to the database “{database}”", + &[("database", &database.name)], + ))), + } + } + fn lookup(&self, owner: &Arc) -> Lookup { let mut state = lock(&self.state); if state.closed { @@ -168,6 +249,7 @@ impl EditorSessionSlot { owner: Arc::downgrade(owner), editor, transaction: None, + moved_to: None, }); Ok(()) } @@ -229,6 +311,16 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } +/// Takes what a `USE` sends back, which is nothing the tab shows. +struct Discard; + +#[async_trait] +impl ResultSink for Discard { + async fn accept(&mut self, _event: ResultEvent) -> SinkFlow { + SinkFlow::Continue + } +} + #[cfg(test)] pub(super) mod tests { use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index c89247faa3..19a4382fc7 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -34,6 +34,10 @@ pub struct SqlEditor { #[template_child] pub scope_label: TemplateChild, #[template_child] + pub scope_button: TemplateChild, + #[template_child] + pub scope_button_label: TemplateChild, + #[template_child] pub cursor_info: TemplateChild, #[template_child] pub menu_button: TemplateChild, @@ -60,6 +64,8 @@ pub struct SqlEditor { /// This tab's own connection, which keeps its transaction and /// temporary tables between runs. pub slot: Arc, + /// The tab's own actions: the database menu's. + pub tab_actions: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 752b9c22d3..9993e8ef33 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -1,14 +1,27 @@ //! The database the tab works on, named in the editor bar as the macOS //! editor names it (`QueryContainerPicker`): the database, then the schema //! an unqualified name resolves to where the engine nests schemas in a -//! database. A file's name is already in the window title and the sidebar. +//! database. Where the server has other databases and the connection may +//! write, the name is a menu of them, the user's first and the engine's own +//! after, and picking one moves the tab there. A file's name is already in +//! the window title and the sidebar. + +use std::sync::Arc; -use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; +use tablepro_core::meta::SchemaInfo; +use tablepro_session::Session; +use tablepro_session::operation::OperationKind; +use tablepro_session::runtime::Tasks; use super::SqlEditor; -use crate::i18n::gettext_f; +use super::editor_session_slot::TabDatabase; +use crate::i18n::{gettext, gettext_f}; + +/// The action a database menu item names, with the database as target. +const SWITCH_ACTION: &str = "database"; /// The bar's text, dot-qualified as SQL and the sidebar spell a qualified /// object, and what a screen reader says for it. `None` with no database. @@ -28,10 +41,33 @@ pub(super) fn scope_text(database: Option<&str>, default_schema: Option<&str>) - } } +/// The menu's sections as macOS orders them: the user's databases, then +/// the engine's own after a separator. `None` when there is no other +/// database to move to. +pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { + if databases.len() < 2 { + return None; + } + let menu = gio::Menu::new(); + for system in [false, true] { + let section = gio::Menu::new(); + for database in databases.iter().filter(|database| database.is_system == system) { + let item = gio::MenuItem::new(Some(&database.name), None); + item.set_action_and_target_value(Some(&format!("tab.{SWITCH_ACTION}")), Some(&database.name.to_variant())); + section.append_item(&item); + } + if section.n_items() > 0 { + menu.append_section(None, §ion); + } + } + Some(menu) +} + impl SqlEditor { - /// Name the tab's database now, and its schema once the catalog has - /// listed the schemas. + /// Name the tab's database now, its schema once the catalog has listed + /// the schemas, and offer the server's others once they are listed. pub(super) fn show_scope(&self) { + self.install_database_action(); let Some((session, tasks)) = self .deps() .and_then(|deps| deps.session.get().map(|session| (session, deps.tasks.clone()))) @@ -39,45 +75,180 @@ impl SqlEditor { self.set_scope(None); return; }; + if let Some(moved) = self.imp().slot.database() { + self.set_scope(scope_text(Some(&moved.name), None)); + self.list_databases(&session, &tasks, &moved.name); + return; + } let database = session.database().map(str::to_owned); self.set_scope(scope_text(database.as_deref(), None)); - if database.is_none() { + let Some(database) = database else { return; - } + }; + self.list_databases(&session, &tasks, &database); // The catalog reads on the runtime, as completion's reads do. - let reading = tasks.spawn_task(async move { - session.schemas().await.ok().and_then(|schemas| { - schemas - .iter() - .find(|schema| schema.is_default) - .map(|schema| schema.name.clone()) + let reading = { + let session = session.clone(); + tasks.spawn_task(async move { + session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }) }) - }); + }; let editor = self.downgrade(); glib::spawn_future_local(async move { let default = reading.await.ok().flatten(); + if let Some(editor) = editor.upgrade() + && editor.imp().slot.database().is_none() + { + editor.set_scope(scope_text(Some(&database), default.as_deref())); + } + }); + } + + /// Ask the server for its databases. A connection that may not write + /// keeps the label, as on macOS, and so does an engine that cannot + /// list them. + fn list_databases(&self, session: &Arc, tasks: &Tasks, current: &str) { + if session.read_only() { + return; + } + let listing = { + let session = session.clone(); + tasks.spawn_task(async move { + session + .run(OperationKind::Metadata, |lease, options| async move { + Ok(lease.list_databases(options).await?) + }) + .await + }) + }; + let editor = self.downgrade(); + let current = current.to_owned(); + glib::spawn_future_local(async move { + let databases = match listing.await { + Ok(Ok(databases)) => databases, + Ok(Err(error)) => { + tracing::debug!(%error, "the server's databases could not be listed for the editor bar"); + return; + } + Err(failure) => { + tracing::debug!(%failure, "listing the server's databases failed"); + return; + } + }; if let Some(editor) = editor.upgrade() { - editor.set_scope(scope_text(database.as_deref(), default.as_deref())); + editor.offer_databases(&databases, ¤t); } }); } - fn set_scope(&self, scope: Option<(String, String)>) { + fn offer_databases(&self, databases: &[SchemaInfo], current: &str) { + let Some(menu) = database_menu(databases) else { + return; + }; let imp = self.imp(); - let shown = scope.is_some(); - if let Some((text, spoken)) = scope { - imp.scope_label.set_label(&text); - imp.scope_box - .update_property(&[gtk4::accessible::Property::Label(&spoken)]); - imp.scope_box.set_tooltip_text(Some(&spoken)); + imp.scope_button.set_menu_model(Some(&menu)); + let current = imp + .slot + .database() + .map_or_else(|| current.to_owned(), |moved| moved.name); + if let Some(action) = self.database_action() { + action.set_state(¤t.to_variant()); } - imp.scope_box.set_visible(shown); + imp.scope_box.set_visible(false); + imp.scope_button.set_visible(true); + } + + fn install_database_action(&self) { + if self.database_action().is_some() { + return; + } + let action = gio::SimpleAction::new_stateful(SWITCH_ACTION, Some(glib::VariantTy::STRING), &"".to_variant()); + let editor = self.downgrade(); + action.connect_change_state(move |action, value| { + let (Some(editor), Some(name)) = (editor.upgrade(), value.and_then(|value| value.str())) else { + return; + }; + if action.state().as_ref().and_then(|state| state.str()) == Some(name) { + return; + } + action.set_state(&name.to_variant()); + editor.move_to_database(name); + }); + let group = gio::SimpleActionGroup::new(); + group.add_action(&action); + self.insert_action_group("tab", Some(&group)); + self.imp().tab_actions.replace(Some(group)); + } + + fn database_action(&self) -> Option { + self.imp() + .tab_actions + .borrow() + .as_ref()? + .lookup_action(SWITCH_ACTION) + .and_downcast() + } + + /// Point the tab at `name`: its sessions run the engine's `USE` before + /// the next statement, completion resolves unqualified names there, + /// and the bar names it. The schema goes with the old database, since + /// a schema only means something inside the database that holds it. + pub(crate) fn move_to_database(&self, name: &str) { + self.imp() + .slot + .set_database(Some(TabDatabase { name: name.to_owned() })); + if let Some(action) = self.database_action() { + action.set_state(&name.to_variant()); + } + for provider in self.imp().completion_providers.borrow().iter() { + if let Some(catalog) = provider.downcast_ref::() { + catalog.set_home(Some(name.to_owned())); + } + } + self.set_scope(scope_text(Some(name), None)); + } + + /// The database the editor bar moved the tab to, which a restore + /// moves it to again. + pub(crate) fn tab_database(&self) -> Option { + self.imp().slot.database().map(|database| database.name) + } + + fn set_scope(&self, scope: Option<(String, String)>) { + let imp = self.imp(); + let switchable = imp.scope_button.menu_model().is_some(); + let Some((text, spoken)) = scope else { + imp.scope_box.set_visible(false); + imp.scope_button.set_visible(false); + return; + }; + imp.scope_label.set_label(&text); + imp.scope_button_label.set_label(&text); + imp.scope_box + .update_property(&[gtk4::accessible::Property::Label(&spoken)]); + imp.scope_box.set_tooltip_text(Some(&spoken)); + imp.scope_button + .update_property(&[gtk4::accessible::Property::Label(&spoken)]); + imp.scope_button + .update_property(&[gtk4::accessible::Property::Description(&gettext("Switch Database"))]); + imp.scope_button.set_tooltip_text(Some(&gettext("Switch Database"))); + imp.scope_box.set_visible(!switchable); + imp.scope_button.set_visible(switchable); } } #[cfg(test)] mod tests { - use super::scope_text; + use gtk4::gio; + use gtk4::prelude::*; + use tablepro_core::meta::SchemaInfo; + + use super::{database_menu, scope_text}; #[test] fn the_scope_names_the_database_then_a_schema_of_its_own() { @@ -97,4 +268,42 @@ mod tests { assert_eq!(scope_text(None, Some("main")), None, "a file"); assert_eq!(scope_text(Some(""), None), None); } + + fn database(name: &str, is_system: bool) -> SchemaInfo { + SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system, + } + } + + fn labels(menu: &gio::Menu, section: i32) -> Vec { + let section = menu.item_link(section, "section").expect("a section"); + (0..section.n_items()) + .filter_map(|item| { + section + .item_attribute_value(item, "label", None) + .and_then(|label| label.str().map(str::to_owned)) + }) + .collect() + } + + #[test] + fn the_menu_lists_the_users_databases_then_the_engines_own() { + let menu = database_menu(&[ + database("mysql", true), + database("shop", false), + database("analytics", false), + database("information_schema", true), + ]) + .expect("a menu"); + + assert_eq!(menu.n_items(), 2, "two sections"); + assert_eq!(labels(&menu, 0), ["shop", "analytics"]); + assert_eq!(labels(&menu, 1), ["mysql", "information_schema"]); + assert!( + database_menu(&[database("shop", false)]).is_none(), + "nothing to move to" + ); + } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index ff881962d7..1a42688a49 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2671,3 +2671,75 @@ fn the_bar_names_the_tabs_database_and_schema() { .unwrap_or_else(|_| panic!("the bar says {:?}", label.label())); assert!(editor.imp().scope_box.is_visible()); } + +/// The bar is a menu of the server's databases; picking one moves the +/// tab there, so the next run sends the engine's `USE` before the query. +#[gtk4::test] +fn picking_a_database_moves_the_tab_and_its_next_run() { + crate::register_test_resources(); + let runtime = crate::test_support::test_runtime(); + let fake = Arc::new(FakeEditorSession::new(answering())); + let mut request = FakeConnector::network_request("db.example", 3306).expect("a request"); + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = Some("shop".to_owned()); + } + let database = |name: &str, is_default: bool, is_system: bool| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default, + is_system, + }; + let mut script = ConnectionScript::default(); + script.list_databases.always(FakeReply::ok(vec![ + database("analytics", false, false), + database("shop", true, false), + database("mysql", false, true), + ])); + let editor_session: Arc = fake.clone(); + script.open_editor_session.always(FakeReply::ok(editor_session)); + let (pending, _) = FakeConnector::pending_session(request, script); + let session = Session::start( + pending, + tablepro_session::SessionDeps { + connector: Arc::new(FakeConnector::new([])), + credentials: Arc::new(FakeCredentialSource::new()), + deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), + tasks: runtime.tasks(), + prompter: None, + }, + ); + let active = ActiveSession::new(); + active.replace(session); + let settings = MemorySettings::new(); + let editor = SqlEditor::new(SqlEditorInit { + initial_query: Some("SELECT 1".to_owned()), + settings: settings.get().clone(), + history: None, + tasks: runtime.tasks(), + session: active.clone(), + hold: Rc::new(tablepro_session::runtime::CountingHold::new()), + close_timeout: Duration::from_secs(5), + snippets: crate::test_support::builtin_snippets_only(), + }); + let button = editor.imp().scope_button.get(); + crate::test_support::wait_until(Duration::from_secs(5), || button.is_visible()).expect("the database menu"); + + WidgetExt::activate_action(&editor, "tab.database", Some(&"analytics".to_variant())).expect("tab.database"); + assert_eq!(editor.imp().scope_button_label.label(), "analytics"); + + let harness = Harness { + editor, + fake: fake.clone(), + history: None, + active, + settings, + runtime, + }; + run_and_wait(&harness); + let sent = sent(&fake); + assert!( + sent.first() + .is_some_and(|first| first.starts_with("USE ") && first.contains("analytics")), + "{sent:?}" + ); + assert_eq!(sent.last().map(String::as_str), Some("SELECT 1")); +} diff --git a/linux/crates/app/src/ui/error_text/unsupported.rs b/linux/crates/app/src/ui/error_text/unsupported.rs index cd7aae5416..c61ae79ef1 100644 --- a/linux/crates/app/src/ui/error_text/unsupported.rs +++ b/linux/crates/app/src/ui/error_text/unsupported.rs @@ -47,6 +47,7 @@ pub(super) fn unsupported_message(feature: UnsupportedFeature) -> String { UnsupportedFeature::Tables => gettext("This database holds keys, not tables."), UnsupportedFeature::Keys => gettext("This database holds tables, not keys."), UnsupportedFeature::CharacterSets => gettext("This database has no character sets to pick from."), + UnsupportedFeature::DatabaseSwitching => gettext("This connection cannot switch to another database."), UnsupportedFeature::EditorCommand { command } => gettext_f( "{command} cannot run in the editor: it turns the connection into a stream the editor cannot show.", &[("command", command)], @@ -84,6 +85,7 @@ mod tests { UnsupportedFeature::Tables, UnsupportedFeature::Keys, UnsupportedFeature::CharacterSets, + UnsupportedFeature::DatabaseSwitching, UnsupportedFeature::EditorCommand { command: "MONITOR" }, ]; diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 021e835596..533ae82559 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -100,7 +100,8 @@ impl TabFactory { initial_query, draft, file, - } => self.editor(id, initial_query, draft, file), + database, + } => self.editor(id, initial_query, draft, file, database), TabSpec::Table { table, page, filter } => self.table( id, TableOpening { @@ -128,7 +129,7 @@ impl TabFactory { /// The overview's new-tab button needs its page back at once. pub(super) fn create_editor(&self) -> adw::TabPage { let id = Uuid::new_v4(); - let slot = self.editor(id, None, None, None); + let slot = self.editor(id, None, None, None, None); self.insert(id, slot) } @@ -167,6 +168,7 @@ impl TabFactory { initial_query: Option, draft: Option, file: Option, + database: Option, ) -> WorkspaceTab { let parts = &self.parts; // Such a tab shows the editor's sample query, which names nothing. @@ -181,6 +183,9 @@ impl TabFactory { close_timeout: parts.context.close_timeout(), snippets: parts.snippets.clone(), }); + if let Some(database) = database { + editor.move_to_database(&database); + } connect_editor(&editor, id, &parts.input, &parts.toasts); editor.attach_assistant(parts.assistant.clone()); // After the signals are connected, so the workspace marks a tab diff --git a/linux/crates/app/src/ui/workspace/tab_spec.rs b/linux/crates/app/src/ui/workspace/tab_spec.rs index 3872f9995c..ad6bdbf233 100644 --- a/linux/crates/app/src/ui/workspace/tab_spec.rs +++ b/linux/crates/app/src/ui/workspace/tab_spec.rs @@ -16,6 +16,8 @@ pub(crate) enum TabSpec { draft: Option, /// The `.sql` file a restored or reopened tab is saved to. file: Option, + /// The database the editor bar moved a restored tab to. + database: Option, }, Table { table: TableRef, diff --git a/linux/crates/app/src/ui/workspace/tests/assistant.rs b/linux/crates/app/src/ui/workspace/tests/assistant.rs index 49da7d47d9..10c9a07f6a 100644 --- a/linux/crates/app/src/ui/workspace/tests/assistant.rs +++ b/linux/crates/app/src/ui/workspace/tests/assistant.rs @@ -93,6 +93,7 @@ fn insert_goes_to_the_editor_in_front_or_a_new_one() { initial_query: Some("-- ".to_owned()), draft: None, file: None, + database: None, }) }); input.deliver(WorkspaceInput::InsertFromAssistant("SELECT 2;".to_owned())); diff --git a/linux/crates/app/src/ui/workspace/tests/close_tab.rs b/linux/crates/app/src/ui/workspace/tests/close_tab.rs index d26cb97d06..39bb3ad8a1 100644 --- a/linux/crates/app/src/ui/workspace/tests/close_tab.rs +++ b/linux/crates/app/src/ui/workspace/tests/close_tab.rs @@ -189,6 +189,7 @@ fn editor_on_file(test: &TestWindow, path: &std::path::Path) -> (Uuid, crate::ui uri: gtk::gio::File::for_path(path).uri().to_string(), modified: true, }), + database: None, }) }); let id = workspace.selected_tab().expect("the editor in front"); @@ -287,6 +288,7 @@ fn an_editor_asking_to_close_closes_its_own_tab_not_the_one_in_front() { initial_query: Some(query.to_owned()), draft: None, file: None, + database: None, }) }); let page = shell_of(&workspace) diff --git a/linux/crates/app/src/ui/workspace/tests/tab_title.rs b/linux/crates/app/src/ui/workspace/tests/tab_title.rs index ee27913289..92cd1ed168 100644 --- a/linux/crates/app/src/ui/workspace/tests/tab_title.rs +++ b/linux/crates/app/src/ui/workspace/tests/tab_title.rs @@ -184,6 +184,7 @@ fn restored_workspace_selects_saved_active_tab() { TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, orders, ], @@ -229,6 +230,7 @@ fn a_restored_editor_tab_keeps_its_file_and_its_unsaved_changes() { uri: gio::File::for_path(&path).uri().to_string(), modified: true, }), + database: None, }], }, ) diff --git a/linux/crates/app/src/ui/workspace/workspace_persistence.rs b/linux/crates/app/src/ui/workspace/workspace_persistence.rs index 5eabf1fddf..ec2000f63e 100644 --- a/linux/crates/app/src/ui/workspace/workspace_persistence.rs +++ b/linux/crates/app/src/ui/workspace/workspace_persistence.rs @@ -191,6 +191,7 @@ fn tab_record(tab: &WorkspaceTab) -> Option { WorkspaceTab::Editor(slot) => Some(TabRecord::Editor { draft: slot.draft, file: super::workspace_tabs::editor_file_record(&slot.editor), + database: slot.editor.tab_database(), }), WorkspaceTab::Structure(slot) => match slot.mode { // A draft for a table that does not exist yet has nothing to @@ -504,6 +505,7 @@ mod tests { TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, ], }; diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index cf2de5f6e5..6fa5140a74 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -131,10 +131,11 @@ impl WorkspaceModel { TabRecord::Diagram { schema, places } => TabSpec::Diagram { schema, places }, // Its text is on disk already, or there is none to keep, so // nothing is written until the user types. - TabRecord::Editor { draft, file } => TabSpec::Editor { + TabRecord::Editor { draft, file, database } => TabSpec::Editor { initial_query: Some(drafts.remove(&draft).unwrap_or_default()), draft: Some(draft), file, + database, }, TabRecord::Key { key } => TabSpec::Key { key }, }; @@ -188,6 +189,7 @@ impl WorkspaceModel { initial_query, draft: None, file: None, + database: None, }); } @@ -699,6 +701,7 @@ impl WorkspaceModel { initial_query: initial, draft: None, file, + database: None, }); } ClosedTabDescriptor::Table { diff --git a/linux/crates/app/src/workspace/tab_record.rs b/linux/crates/app/src/workspace/tab_record.rs index fa4b714d69..6717b8b47c 100644 --- a/linux/crates/app/src/workspace/tab_record.rs +++ b/linux/crates/app/src/workspace/tab_record.rs @@ -44,6 +44,10 @@ pub enum TabRecord { draft: DraftId, #[serde(default, skip_serializing_if = "Option::is_none")] file: Option, + /// The database the editor bar moved the tab to. `None` keeps the + /// connection's own, as in a record written before tabs kept one. + #[serde(default, skip_serializing_if = "Option::is_none")] + database: Option, }, /// A key-value engine's key, by the name the sidebar lists it as. Key { @@ -106,7 +110,7 @@ impl TabRecord { } Self::Structure { schema, table } => (!table.is_empty()).then_some(Self::Structure { schema, table }), Self::Diagram { schema, places } => Some(Self::Diagram { schema, places }), - Self::Editor { draft, file } => Some(Self::Editor { draft, file }), + Self::Editor { draft, file, database } => Some(Self::Editor { draft, file, database }), // A key's name can be empty, as Redis lets one be. Self::Key { key } => Some(Self::Key { key }), } diff --git a/linux/crates/app/src/workspace/workspace_record.rs b/linux/crates/app/src/workspace/workspace_record.rs index a433588b51..06f994c9e6 100644 --- a/linux/crates/app/src/workspace/workspace_record.rs +++ b/linux/crates/app/src/workspace/workspace_record.rs @@ -120,6 +120,7 @@ mod tests { TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, TabRecord::Diagram { schema: Some("sales".to_owned()), @@ -182,6 +183,7 @@ mod tests { TabRecord::Editor { draft: DraftId::from_uuid(draft), file: None, + database: None, }, TabRecord::Structure { schema: Some("sales".to_owned()), @@ -231,6 +233,7 @@ mod tests { TabRecord::Editor { draft: DraftId::from_uuid(draft), file: None, + database: None, }, TabRecord::Structure { schema: Some("sales".to_owned()), @@ -262,6 +265,7 @@ mod tests { 0 => TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }, _ => table(&format!("t{index}")), }) diff --git a/linux/crates/app/src/workspace/workspace_record_builder.rs b/linux/crates/app/src/workspace/workspace_record_builder.rs index 7e81155c91..0fa4922ef7 100644 --- a/linux/crates/app/src/workspace/workspace_record_builder.rs +++ b/linux/crates/app/src/workspace/workspace_record_builder.rs @@ -67,6 +67,7 @@ mod tests { page(Some(TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, })), page(None), ]; diff --git a/linux/crates/app/src/workspace/workspace_store.rs b/linux/crates/app/src/workspace/workspace_store.rs index fd01fedd2a..954e1e52ba 100644 --- a/linux/crates/app/src/workspace/workspace_store.rs +++ b/linux/crates/app/src/workspace/workspace_store.rs @@ -536,6 +536,7 @@ mod tests { tabs: vec![TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }], }; @@ -684,6 +685,7 @@ mod tests { .map(|_| TabRecord::Editor { draft: DraftId::new(), file: None, + database: None, }) .collect(), }; diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index 32b3f44884..c16484fab6 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -103,6 +103,15 @@ pub trait Connection: Send + Sync { }) } + /// The server's databases a query tab can switch to, the connection's + /// own marked as the default, and the engine's own marked as system. + /// An engine whose connection is to one database keeps this default. + async fn list_databases(&self, _options: CallOptions) -> Result, DriverError> { + Err(DriverError::Unsupported { + feature: UnsupportedFeature::DatabaseSwitching, + }) + } + async fn row_identity(&self, table: &TableRef, options: CallOptions) -> Result; async fn table_estimate(&self, table: &TableRef, options: CallOptions) -> Result; diff --git a/linux/crates/core/src/unsupported_feature.rs b/linux/crates/core/src/unsupported_feature.rs index 6eff4bed47..d42e81a6b2 100644 --- a/linux/crates/core/src/unsupported_feature.rs +++ b/linux/crates/core/src/unsupported_feature.rs @@ -48,6 +48,8 @@ pub enum UnsupportedFeature { /// A list of character sets and collations to pick a column's or a /// table's from. CharacterSets, + /// Another of the server's databases for a query tab. + DatabaseSwitching, /// A command the editor cannot run, because it stops the one reply /// per command the editor reads, such as `SUBSCRIBE`. EditorCommand { @@ -79,6 +81,7 @@ impl fmt::Display for UnsupportedFeature { Self::Tables => f.write_str("tables"), Self::Keys => f.write_str("keys"), Self::CharacterSets => f.write_str("character sets"), + Self::DatabaseSwitching => f.write_str("database switching"), Self::EditorCommand { command } => write!(f, "{command} in the editor"), } } diff --git a/linux/crates/drivers/mysql/src/connection.rs b/linux/crates/drivers/mysql/src/connection.rs index 5137000983..3a83c808f0 100644 --- a/linux/crates/drivers/mysql/src/connection.rs +++ b/linux/crates/drivers/mysql/src/connection.rs @@ -271,6 +271,14 @@ impl Connection for MysqlConnection { .await } + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.read( + options, + read_op(|connection, flavour| Box::pin(metadata::list_databases(connection, flavour))), + ) + .await + } + async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError> { let scope = scope.clone(); self.read( diff --git a/linux/crates/drivers/mysql/src/metadata.rs b/linux/crates/drivers/mysql/src/metadata.rs index 2392af170e..0899312eb0 100644 --- a/linux/crates/drivers/mysql/src/metadata.rs +++ b/linux/crates/drivers/mysql/src/metadata.rs @@ -85,6 +85,41 @@ pub(crate) async fn list_schemas( .collect()) } +/// The databases MySQL and MariaDB keep for themselves. +const SYSTEM_DATABASES: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"]; + +/// Every database the login can see, the connection's own the default. +pub(crate) async fn list_databases( + connection: &mut MySqlConnection, + flavour: MySqlFlavour, +) -> Result, DriverError> { + let current = list_schemas(connection, flavour) + .await? + .into_iter() + .next() + .map(|schema| schema.name); + let rows = catalog_rows( + connection, + flavour, + "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA ORDER BY SCHEMA_NAME", + "SCHEMATA", + &[], + ) + .await?; + let mut databases = Vec::with_capacity(rows.len()); + for row in rows { + let Some(name) = row.text(0)? else { + continue; + }; + databases.push(SchemaInfo { + is_default: current.as_deref() == Some(name.as_str()), + is_system: SYSTEM_DATABASES.iter().any(|system| name.eq_ignore_ascii_case(system)), + name, + }); + } + Ok(databases) +} + const TABLES_IN_SCHEMA_SQL: &str = "\ SELECT TABLE_SCHEMA, TABLE_NAME, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) ORDER BY TABLE_NAME"; diff --git a/linux/crates/drivers/mysql/tests/integration/metadata.rs b/linux/crates/drivers/mysql/tests/integration/metadata.rs index e5c0b7123c..41581b0d72 100644 --- a/linux/crates/drivers/mysql/tests/integration/metadata.rs +++ b/linux/crates/drivers/mysql/tests/integration/metadata.rs @@ -8,7 +8,7 @@ use tablepro_core::statement::StatementBuilder; use tablepro_core::{Connection, RowLimit, Value}; use tablepro_test_fixtures::MysqlFlavour; -use crate::support::{CALL, FLAVOURS, connect, rows, setup, start, table, within}; +use crate::support::{CALL, FLAVOURS, connect, connect_root, rows, setup, start, table, within}; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] @@ -496,3 +496,37 @@ async fn insensitive_filter_matches_mixed_case() { assert_eq!(insensitive, ["Alpha"]); connection.close().await.expect("a close"); } + +/// The databases a query tab can move to: the login's own marked as the +/// default, another one it can see, and the server's own marked as +/// system, which the editor bar lists after the user's. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn the_servers_databases_are_listed_for_a_query_tab() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let root = connect_root(&fixture).await; + let grant = format!( + "GRANT ALL ON analytics.* TO '{}'@'%'", + fixture.password_credentials().username + ); + setup( + root.as_ref(), + &["CREATE DATABASE IF NOT EXISTS analytics", grant.as_str()], + ) + .await; + root.close().await.expect("a close"); + let connection = connect(&fixture, false).await; + + let databases = connection.list_databases(within(CALL)).await.expect("the databases"); + + let find = |name: &str| databases.iter().find(|database| database.name == name); + let own = find("tablepro").expect("the login's database"); + assert!(own.is_default && !own.is_system, "{flavour:?}: {own:?}"); + let other = find("analytics").expect("another database"); + assert!(!other.is_default && !other.is_system, "{flavour:?}: {other:?}"); + let system = find("information_schema").expect("information_schema"); + assert!(system.is_system, "{flavour:?}: {system:?}"); + connection.close().await.expect("a close"); + } +} diff --git a/linux/crates/test-support/src/call_counters.rs b/linux/crates/test-support/src/call_counters.rs index 9bf60773b7..9c8683e484 100644 --- a/linux/crates/test-support/src/call_counters.rs +++ b/linux/crates/test-support/src/call_counters.rs @@ -7,6 +7,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; pub struct CallCounters { pub probe: Arc, pub list_schemas: Arc, + pub list_databases: Arc, pub list_objects: Arc, pub fetch_columns: Arc, pub fetch_indexes: Arc, diff --git a/linux/crates/test-support/src/connection_script.rs b/linux/crates/test-support/src/connection_script.rs index c47947a703..920bc05b24 100644 --- a/linux/crates/test-support/src/connection_script.rs +++ b/linux/crates/test-support/src/connection_script.rs @@ -25,6 +25,7 @@ pub struct ConnectionScript { pub close: CloseBehaviour, pub probe: Replies>, pub list_schemas: Replies>>, + pub list_databases: Replies>>, pub list_objects: Replies>>, pub fetch_columns: Replies>>, pub fetch_indexes: Replies>>, @@ -59,6 +60,7 @@ impl Default for ConnectionScript { close: CloseBehaviour::Immediate, probe: Replies::default(), list_schemas: Replies::default(), + list_databases: Replies::default(), list_objects: Replies::default(), fetch_columns: Replies::default(), fetch_indexes: Replies::default(), diff --git a/linux/crates/test-support/src/fake_connection.rs b/linux/crates/test-support/src/fake_connection.rs index dcb16a3637..22f2ebee53 100644 --- a/linux/crates/test-support/src/fake_connection.rs +++ b/linux/crates/test-support/src/fake_connection.rs @@ -189,6 +189,19 @@ impl Connection for FakeConnection { .await } + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + let counter = &self.counters.list_databases; + self.answer( + "list_databases", + counter, + |s| &mut s.list_databases, + None, + &options, + TimeoutPhase::Statement, + ) + .await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { let counter = &self.counters.list_schemas; self.answer( diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index b343a5e633..8268c1403a 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -94,25 +94,55 @@ 8 - - false - 6 - 4 - label - + names it: a label where there is nothing to switch to, + else a menu of the server's databases. --> + - - drive-harddisk-symbolic - presentation + + false + 6 + 4 + label + + + + drive-harddisk-symbolic + presentation + + + + + middle + presentation + + - - middle - presentation + + false + + + + 6 + + + drive-harddisk-symbolic + presentation + + + + + middle + presentation + + + + From 7dd1bc72998afdb1580186da8d117c3ed8fad3e8 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 09:54:49 +0100 Subject: [PATCH 04/23] docs(linux-editor): add the changelog entry --- linux/changelog.d/3897.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3897.added.md diff --git a/linux/changelog.d/3897.added.md b/linux/changelog.d/3897.added.md new file mode 100644 index 0000000000..07ce4b367e --- /dev/null +++ b/linux/changelog.d/3897.added.md @@ -0,0 +1 @@ +On MySQL and MariaDB, the SQL editor's database name is a menu that moves the tab to another of the server's databases. From 35be6ed24ea9c18a9f59810bde7ecabd9aa864e1 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:02:20 +0100 Subject: [PATCH 05/23] feat(linux-editor): switch a query tab's database on SQL Server, ClickHouse, Cassandra and MongoDB --- .../app/src/ui/editor/editor_session_slot.rs | 2 +- linux/crates/core/src/dialect/sql_dialect.rs | 6 +++++ .../drivers/cassandra/src/connection.rs | 6 +++++ .../drivers/clickhouse/src/connection.rs | 6 +++++ .../crates/drivers/mongodb/src/connection.rs | 6 +++++ linux/crates/drivers/mongodb/src/dialect.rs | 10 ++++++++ linux/crates/drivers/mssql/src/connection.rs | 8 +++++++ linux/crates/drivers/mssql/src/dialect.rs | 5 ++++ linux/crates/drivers/mssql/src/metadata.rs | 23 ++++++++++++++++++ .../mssql/tests/integration/metadata.rs | 24 +++++++++++++++++++ 10 files changed, 95 insertions(+), 1 deletion(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 361edfb694..47c9098207 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -165,7 +165,7 @@ impl EditorSessionSlot { &[("database", &database.name)], ))); }; - let statement = format!("USE {}", dialect.quote_identifier(&database.name)); + let statement = dialect.use_database(&database.name); let ran = editor .run( UserSql::new(statement.as_str()), diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index 79b7e00948..6ffacbd454 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -31,6 +31,12 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { /// whatever it contains. fn quote_identifier(&self, name: &str) -> String; + /// The statement that moves an editor session to the server's database + /// `name`, which a query tab runs before its next statement. + fn use_database(&self, name: &str) -> String { + format!("USE {}", self.quote_identifier(name)) + } + /// Bind one value, giving back the SQL that names it. /// /// `ordinal` is the number of parameters the statement already diff --git a/linux/crates/drivers/cassandra/src/connection.rs b/linux/crates/drivers/cassandra/src/connection.rs index 1d1fe242df..599e39f40d 100644 --- a/linux/crates/drivers/cassandra/src/connection.rs +++ b/linux/crates/drivers/cassandra/src/connection.rs @@ -243,6 +243,12 @@ impl Connection for CqlConnection { outcome } + /// The schemas this engine lists are the server's databases, which a + /// query tab can move between. + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.list_schemas(options).await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { let default = self.endpoint.keyspace().map(str::to_owned); self.read(options, move |link, _| async move { diff --git a/linux/crates/drivers/clickhouse/src/connection.rs b/linux/crates/drivers/clickhouse/src/connection.rs index a80b148d3e..abe7c291e4 100644 --- a/linux/crates/drivers/clickhouse/src/connection.rs +++ b/linux/crates/drivers/clickhouse/src/connection.rs @@ -261,6 +261,12 @@ impl Connection for ClickHouseConnection { outcome } + /// The schemas this engine lists are the server's databases, which a + /// query tab can move between. + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.list_schemas(options).await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { self.workspace_read(options, |calls| async move { metadata::list_schemas(&calls).await }) .await diff --git a/linux/crates/drivers/mongodb/src/connection.rs b/linux/crates/drivers/mongodb/src/connection.rs index 170848f1d5..a5d55fccf7 100644 --- a/linux/crates/drivers/mongodb/src/connection.rs +++ b/linux/crates/drivers/mongodb/src/connection.rs @@ -282,6 +282,12 @@ impl Connection for MongoConnection { outcome } + /// The schemas this engine lists are the server's databases, which a + /// query tab can move between. + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.list_schemas(options).await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { let default = self.database.clone(); self.read(options, move |context, session| { diff --git a/linux/crates/drivers/mongodb/src/dialect.rs b/linux/crates/drivers/mongodb/src/dialect.rs index 8bcb36fda9..ece3facbed 100644 --- a/linux/crates/drivers/mongodb/src/dialect.rs +++ b/linux/crates/drivers/mongodb/src/dialect.rs @@ -17,6 +17,11 @@ use tablepro_core::value::Value; pub struct MongoDialect; impl SqlDialect for MongoDialect { + /// mongosh's own: `use` takes the name as it is, never quoted. + fn use_database(&self, name: &str) -> String { + format!("use {name}") + } + /// A write reports how many documents it matched, and `_id` is /// unique, so a count proves a save touched the document it meant to, /// and an insert with an `_id` already taken fails with E11000. @@ -106,6 +111,11 @@ mod tests { use super::*; + #[test] + fn a_tab_moves_with_the_shells_own_use() { + assert_eq!(MongoDialect.use_database("analytics"), "use analytics"); + } + #[test] fn an_import_writes_a_document_a_step() { assert_eq!(MongoDialect.insert_batch_limits().rows, 1); diff --git a/linux/crates/drivers/mssql/src/connection.rs b/linux/crates/drivers/mssql/src/connection.rs index 2aebc5c9b4..d88cd13e86 100644 --- a/linux/crates/drivers/mssql/src/connection.rs +++ b/linux/crates/drivers/mssql/src/connection.rs @@ -254,6 +254,14 @@ impl Connection for MssqlConnection { .await } + async fn list_databases(&self, options: CallOptions) -> Result, DriverError> { + self.read( + options, + read_op(|client, watch| Box::pin(metadata::list_databases(client, watch))), + ) + .await + } + async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError> { let scope = scope.clone(); self.read( diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 7b470b9519..c6c8a26284 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -480,6 +480,11 @@ mod tests { MssqlDialect::new(16) } + #[test] + fn a_tab_moves_with_use_and_a_bracketed_name() { + assert_eq!(dialect().use_database("sales ]2026"), "USE [sales ]]2026]"); + } + fn column(declared: &str) -> ColumnInfo { ColumnInfo { name: "c".to_owned(), diff --git a/linux/crates/drivers/mssql/src/metadata.rs b/linux/crates/drivers/mssql/src/metadata.rs index c93ec84c41..08d452420d 100644 --- a/linux/crates/drivers/mssql/src/metadata.rs +++ b/linux/crates/drivers/mssql/src/metadata.rs @@ -24,6 +24,14 @@ const SCHEMAS_SQL: &str = "SELECT s.name, \ THEN 1 ELSE 0 END AS int) \ FROM sys.schemas s ORDER BY s.name"; +/// The databases the login may open, the connection's own the default +/// and the four the server keeps for itself (`database_id` 1 to 4) as +/// system ones. +const DATABASES_SQL: &str = "SELECT d.name, \ + CAST(CASE WHEN d.name = DB_NAME() THEN 1 ELSE 0 END AS int), \ + CAST(CASE WHEN d.database_id <= 4 THEN 1 ELSE 0 END AS int) \ + FROM sys.databases d WHERE HAS_DBACCESS(d.name) = 1 ORDER BY d.name"; + const OBJECTS_SQL: &str = "SELECT SCHEMA_NAME(o.schema_id), o.name, RTRIM(o.type) \ FROM sys.objects o \ WHERE o.type IN ('U', 'V', 'SO', 'FN', 'IF', 'TF', 'FS', 'FT', 'P', 'PC') AND o.is_ms_shipped = 0"; @@ -80,6 +88,21 @@ pub(crate) async fn list_schemas(client: &mut MssqlClient, watch: &Watch) -> Res .map_err(Interrupt::Failed) } +pub(crate) async fn list_databases(client: &mut MssqlClient, watch: &Watch) -> Result, Interrupt> { + let rows = catalogue_rows(client, watch, DATABASES_SQL, &[]).await?; + rows.iter() + .map(|row| { + let cells = Cells::new(row, "sys.databases"); + Ok(SchemaInfo { + name: cells.required_text(0)?, + is_default: cells.flag(1), + is_system: cells.flag(2), + }) + }) + .collect::>() + .map_err(Interrupt::Failed) +} + pub(crate) async fn list_objects( client: &mut MssqlClient, watch: &Watch, diff --git a/linux/crates/drivers/mssql/tests/integration/metadata.rs b/linux/crates/drivers/mssql/tests/integration/metadata.rs index c1f3baf099..9ccc4402d0 100644 --- a/linux/crates/drivers/mssql/tests/integration/metadata.rs +++ b/linux/crates/drivers/mssql/tests/integration/metadata.rs @@ -114,6 +114,30 @@ async fn lists_objects_and_names_rows_by_key() { assert!(schemas.iter().any(|schema| schema.name == "sys" && schema.is_system)); } +/// The databases a query tab can move to: the login's own marked as the +/// default and the server's four as system ones. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn the_servers_databases_are_listed_for_a_query_tab() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + + let databases = connection.list_databases(within(CALL)).await.expect("the databases"); + + let find = |name: &str| databases.iter().find(|database| database.name == name); + assert!( + databases.iter().filter(|database| database.is_default).count() == 1, + "{databases:?}" + ); + let master = find("master").expect("master"); + assert!(master.is_system, "{master:?}"); + let own = databases + .iter() + .find(|database| database.is_default) + .expect("the login's database"); + assert!(!own.is_system || own.name == "master", "{own:?}"); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn fetch_table_definition_reports_pk_name_collation_comment() { From c47fc2884fa7a92fbfbc1865b1206ecbb6b80726 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:02:44 +0100 Subject: [PATCH 06/23] docs(linux-editor): add the changelog entry --- linux/changelog.d/3898.added.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 linux/changelog.d/3898.added.md diff --git a/linux/changelog.d/3898.added.md b/linux/changelog.d/3898.added.md new file mode 100644 index 0000000000..80506f53f1 --- /dev/null +++ b/linux/changelog.d/3898.added.md @@ -0,0 +1 @@ +The SQL editor's database menu also moves a tab on SQL Server, ClickHouse, Cassandra, ScyllaDB and MongoDB. From d4cc649280a9c9b8273d3617a4de8a22a31612c0 Mon Sep 17 00:00:00 2001 From: sedax90 <5001801+sedax90@users.noreply.github.com> Date: Fri, 30 Oct 2026 10:06:04 +0100 Subject: [PATCH 07/23] fix(linux-editor): refuse a MongoDB database name a use statement cannot carry --- .../app/src/ui/editor/editor_session_slot.rs | 6 ++++-- linux/crates/core/src/dialect/sql_dialect.rs | 7 ++++--- linux/crates/drivers/mongodb/src/dialect.rs | 21 +++++++++++++++---- linux/crates/drivers/mssql/src/dialect.rs | 5 ++++- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 47c9098207..ee119ff50b 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -159,13 +159,15 @@ impl EditorSessionSlot { let Some(database) = wanted else { return Ok(editor); }; - let Some(dialect) = session.current_dialect() else { + let Some(statement) = session + .current_dialect() + .and_then(|dialect| dialect.use_database(&database.name)) + else { return Err(AcquireError::Failed(crate::i18n::gettext_f( "Could not switch this tab to the database “{database}”", &[("database", &database.name)], ))); }; - let statement = dialect.use_database(&database.name); let ran = editor .run( UserSql::new(statement.as_str()), diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index 6ffacbd454..741cbbb8b7 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -32,9 +32,10 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { fn quote_identifier(&self, name: &str) -> String; /// The statement that moves an editor session to the server's database - /// `name`, which a query tab runs before its next statement. - fn use_database(&self, name: &str) -> String { - format!("USE {}", self.quote_identifier(name)) + /// `name`, which a query tab runs before its next statement. `None` + /// for a name the engine's statement cannot carry whole. + fn use_database(&self, name: &str) -> Option { + Some(format!("USE {}", self.quote_identifier(name))) } /// Bind one value, giving back the SQL that names it. diff --git a/linux/crates/drivers/mongodb/src/dialect.rs b/linux/crates/drivers/mongodb/src/dialect.rs index ece3facbed..9ecd13b520 100644 --- a/linux/crates/drivers/mongodb/src/dialect.rs +++ b/linux/crates/drivers/mongodb/src/dialect.rs @@ -17,9 +17,16 @@ use tablepro_core::value::Value; pub struct MongoDialect; impl SqlDialect for MongoDialect { - /// mongosh's own: `use` takes the name as it is, never quoted. - fn use_database(&self, name: &str) -> String { - format!("use {name}") + /// mongosh's own `use`, the name quoted. Its parser takes a quoted + /// name as it stands, escapes and all, and the splitter ends a + /// statement at `;`, so a name holding a quote, a backslash, `;` or a + /// space is refused rather than sent as more than one statement. + fn use_database(&self, name: &str) -> Option { + let carried = !name.is_empty() + && !name + .chars() + .any(|c| matches!(c, '"' | '\'' | '\\' | ';') || c.is_whitespace() || c.is_control()); + carried.then(|| format!("use \"{name}\"")) } /// A write reports how many documents it matched, and `_id` is @@ -113,7 +120,13 @@ mod tests { #[test] fn a_tab_moves_with_the_shells_own_use() { - assert_eq!(MongoDialect.use_database("analytics"), "use analytics"); + assert_eq!( + MongoDialect.use_database("analytics").as_deref(), + Some("use \"analytics\"") + ); + for name in ["x; db.dropDatabase()", "a\"b", "a\\b", "two words", "line\nbreak", ""] { + assert_eq!(MongoDialect.use_database(name), None, "{name:?}"); + } } #[test] diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index c6c8a26284..4af1d8aeac 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -482,7 +482,10 @@ mod tests { #[test] fn a_tab_moves_with_use_and_a_bracketed_name() { - assert_eq!(dialect().use_database("sales ]2026"), "USE [sales ]]2026]"); + assert_eq!( + dialect().use_database("sales ]2026").as_deref(), + Some("USE [sales ]]2026]") + ); } fn column(declared: &str) -> ColumnInfo { From 0c8f034f779c9a68838370c80435cc1c2857c517 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:15:54 +0700 Subject: [PATCH 08/23] fix(linux-editor): name the database a tab's session is in, and only where a connection reaches several --- linux/changelog.d/3896.added.md | 2 +- .../app/src/ui/editor/editor_session_slot.rs | 5 + linux/crates/app/src/ui/editor/mod.rs | 1 + linux/crates/app/src/ui/editor/tab_scope.rs | 101 ++++++++++-------- linux/crates/app/src/ui/editor/tests.rs | 100 +++++++++++++---- .../core/src/dialect/database_layout.rs | 13 +++ linux/crates/core/src/dialect/mod.rs | 2 + linux/crates/core/src/dialect/sql_dialect.rs | 8 +- linux/crates/core/src/editor_session.rs | 7 ++ .../core/src/sql_syntax/catalog_change.rs | 28 +++++ linux/crates/core/src/sql_syntax/mod.rs | 2 +- linux/crates/drivers/cassandra/src/dialect.rs | 8 +- linux/crates/drivers/cassandra/src/editor.rs | 6 ++ .../crates/drivers/clickhouse/src/dialect.rs | 8 +- .../drivers/clickhouse/src/editor_session.rs | 6 ++ linux/crates/drivers/mongodb/src/dialect.rs | 8 +- .../drivers/mongodb/src/editor_session.rs | 4 + linux/crates/drivers/mssql/src/dialect.rs | 8 +- linux/crates/drivers/mssql/src/editor_run.rs | 10 ++ .../drivers/mssql/src/editor_session.rs | 26 ++++- .../mssql/tests/integration/sessions.rs | 5 + linux/crates/drivers/mysql/src/dialect.rs | 8 +- .../drivers/mysql/src/editor_session.rs | 34 +++++- .../mysql/tests/integration/editor_session.rs | 29 +++++ linux/crates/drivers/postgres/src/dialect.rs | 7 +- .../test-support/src/fake_editor_session.rs | 11 ++ linux/data/resources/ui/sql-editor.ui | 2 - 27 files changed, 363 insertions(+), 86 deletions(-) create mode 100644 linux/crates/core/src/dialect/database_layout.rs diff --git a/linux/changelog.d/3896.added.md b/linux/changelog.d/3896.added.md index 207d0fefd8..325194bc2f 100644 --- a/linux/changelog.d/3896.added.md +++ b/linux/changelog.d/3896.added.md @@ -1 +1 @@ -The SQL editor's bar names the database and schema the tab works on. +The SQL editor's bar names the database the tab's statements run in, and its schema where a database holds schemas. diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 5e18a149e8..58d5019343 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -139,6 +139,11 @@ impl EditorSessionSlot { } } + /// Where the tab's session says it is, once it says. + pub fn database(&self) -> Option { + lock(&self.state).open.as_ref().and_then(|open| open.editor.database()) + } + /// Where the transaction of `editor` stood after its last run. pub fn transaction(&self, editor: &Arc) -> Option { let state = lock(&self.state); diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index 15c3a498fc..d994114567 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -691,6 +691,7 @@ impl SqlEditor { self.emit_by_name::<()>("transaction-changed", &[&dirty]); } self.record_history(entry, dialect); + self.show_scope(); self.emit_by_name::<()>("run-finished", &[&outcome]); self.emit_by_name::<()>("run-state-changed", &[&false]); } diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 752b9c22d3..e4789ef638 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -1,36 +1,32 @@ -//! The database the tab works on, named in the editor bar as the macOS -//! editor names it (`QueryContainerPicker`): the database, then the schema -//! an unqualified name resolves to where the engine nests schemas in a -//! database. A file's name is already in the window title and the sidebar. +//! The database the tab works on, named in the editor bar. use gtk4::glib; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use tablepro_core::dialect::DatabaseLayout; +use tablepro_session::Session; use super::SqlEditor; use crate::i18n::gettext_f; -/// The bar's text, dot-qualified as SQL and the sidebar spell a qualified -/// object, and what a screen reader says for it. `None` with no database. -pub(super) fn scope_text(database: Option<&str>, default_schema: Option<&str>) -> Option<(String, String)> { - let database = database.filter(|database| !database.is_empty())?; - // Where the engine's schemas are its databases, as on MySQL, the - // default one is the database itself and adds nothing. - match default_schema.filter(|schema| !schema.is_empty() && *schema != database) { - Some(schema) => Some(( +/// The bar's text, dot-qualified as SQL spells a qualified name, and what +/// a screen reader says for it. +pub(super) fn scope_text(database: &str, schema: Option<&str>) -> (String, String) { + match schema.filter(|schema| !schema.is_empty()) { + Some(schema) => ( format!("{database}.{schema}"), gettext_f( "{database}, schema {schema}", &[("database", database), ("schema", schema)], ), - )), - None => Some((database.to_owned(), database.to_owned())), + ), + None => (database.to_owned(), database.to_owned()), } } impl SqlEditor { - /// Name the tab's database now, and its schema once the catalog has - /// listed the schemas. + /// Name where the tab's next statement runs and, on an engine whose + /// databases hold schemas, the schema completion resolves names in. pub(super) fn show_scope(&self) { let Some((session, tasks)) = self .deps() @@ -39,29 +35,55 @@ impl SqlEditor { self.set_scope(None); return; }; - let database = session.database().map(str::to_owned); - self.set_scope(scope_text(database.as_deref(), None)); - if database.is_none() { + let Some((database, layout)) = self.scope_database(&session) else { + self.set_scope(None); + return; + }; + self.set_scope(Some(scope_text(&database, None))); + // The catalog describes the connection's own database only. + if layout != DatabaseLayout::WithSchemas || session.database() != Some(database.as_str()) { return; } - // The catalog reads on the runtime, as completion's reads do. - let reading = tasks.spawn_task(async move { - session.schemas().await.ok().and_then(|schemas| { - schemas - .iter() - .find(|schema| schema.is_default) - .map(|schema| schema.name.clone()) + let reading = { + let session = session.clone(); + tasks.spawn_task(async move { + session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }) }) - }); + }; let editor = self.downgrade(); glib::spawn_future_local(async move { - let default = reading.await.ok().flatten(); - if let Some(editor) = editor.upgrade() { - editor.set_scope(scope_text(database.as_deref(), default.as_deref())); + let Some(schema) = reading.await.ok().flatten() else { + return; + }; + if let Some(editor) = editor.upgrade() + && editor.scope_database(&session).is_some_and(|(now, _)| now == database) + { + editor.set_scope(Some(scope_text(&database, Some(&schema)))); } }); } + /// The database the tab's session last reported, else the + /// connection's. `None` where a connection reaches one database. + fn scope_database(&self, session: &Session) -> Option<(String, DatabaseLayout)> { + let layout = session.current_dialect()?.database_layout(); + if layout == DatabaseLayout::Single { + return None; + } + let database = self + .imp() + .slot + .database() + .or_else(|| session.database().map(str::to_owned)) + .filter(|name| !name.is_empty())?; + Some((database, layout)) + } + fn set_scope(&self, scope: Option<(String, String)>) { let imp = self.imp(); let shown = scope.is_some(); @@ -80,21 +102,12 @@ mod tests { use super::scope_text; #[test] - fn the_scope_names_the_database_then_a_schema_of_its_own() { - assert_eq!( - scope_text(Some("shop"), Some("public")), - Some(("shop.public".to_owned(), "shop, schema public".to_owned())) - ); - assert_eq!( - scope_text(Some("shop"), Some("shop")), - Some(("shop".to_owned(), "shop".to_owned())), - "a schema that is the database" - ); + fn the_scope_names_the_database_then_its_schema() { assert_eq!( - scope_text(Some("shop"), None), - Some(("shop".to_owned(), "shop".to_owned())) + scope_text("shop", Some("public")), + ("shop.public".to_owned(), "shop, schema public".to_owned()) ); - assert_eq!(scope_text(None, Some("main")), None, "a file"); - assert_eq!(scope_text(Some(""), None), None); + assert_eq!(scope_text("shop", None), ("shop".to_owned(), "shop".to_owned())); + assert_eq!(scope_text("shop", Some("")), ("shop".to_owned(), "shop".to_owned())); } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index ff881962d7..f0a2d0dec2 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2619,10 +2619,9 @@ fn a_command_editor_has_none_of_the_sql_tools() { ); } -/// The bar names the tab's database and its default schema, as the -/// macOS editor bar does. -#[gtk4::test] -fn the_bar_names_the_tabs_database_and_schema() { +/// An editor on a connection to `shop`, whose catalog lists `schemas` +/// with the first as the default. +fn scoped_editor(dialect: Arc, schemas: &[&str]) -> Harness { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); let fake = Arc::new(FakeEditorSession::new(answering())); @@ -2630,8 +2629,11 @@ fn the_bar_names_the_tabs_database_and_schema() { if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { *database = Some("shop".to_owned()); } - let mut script = ConnectionScript::default(); - let editor_session: Arc = fake; + let mut script = ConnectionScript { + dialect, + ..ConnectionScript::default() + }; + let editor_session: Arc = fake.clone(); script.open_editor_session.always(FakeReply::ok(editor_session)); let (pending, _) = FakeConnector::pending_session(request, script); let session = Session::start( @@ -2644,30 +2646,88 @@ fn the_bar_names_the_tabs_database_and_schema() { prompter: None, }, ); - let schema = |name: &str, is_default: bool| tablepro_core::meta::SchemaInfo { - name: name.to_owned(), - is_default, - is_system: false, - }; - session - .catalog() - .commit(vec![schema("public", true), schema("audit", false)], Vec::new()); + let listed = schemas + .iter() + .enumerate() + .map(|(index, name)| tablepro_core::meta::SchemaInfo { + name: (*name).to_owned(), + is_default: index == 0, + is_system: false, + }) + .collect(); + session.catalog().commit(listed, Vec::new()); let active = ActiveSession::new(); active.replace(session); let settings = MemorySettings::new(); let editor = SqlEditor::new(SqlEditorInit { - initial_query: None, + initial_query: Some("SELECT 1".to_owned()), settings: settings.get().clone(), history: None, tasks: runtime.tasks(), - session: active, + session: active.clone(), hold: Rc::new(tablepro_session::runtime::CountingHold::new()), close_timeout: Duration::from_secs(5), snippets: crate::test_support::builtin_snippets_only(), }); + Harness { + editor, + fake, + history: None, + active, + settings, + runtime, + } +} + +fn postgres_dialect() -> Arc { + Arc::new(tablepro_driver_postgres::PgDialect::new( + &tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0), + )) +} + +fn scope_shown(harness: &Harness) -> Option { + let imp = harness.editor.imp(); + imp.scope_box.is_visible().then(|| imp.scope_label.label().to_string()) +} + +#[gtk4::test] +fn the_bar_names_the_database_and_the_schema_names_resolve_in() { + let harness = scoped_editor(postgres_dialect(), &["public", "audit"]); + + wait_until(WAIT, || scope_shown(&harness).as_deref() == Some("shop.public")) + .unwrap_or_else(|_| panic!("the bar says {:?}", scope_shown(&harness))); + assert_eq!( + harness.editor.imp().scope_box.tooltip_text().as_deref(), + Some("shop, schema public") + ); +} + +/// Where the server's databases are the catalog's schemas, the default +/// schema is the database and adds nothing. +#[gtk4::test] +fn the_bar_names_a_database_that_is_its_own_schema_once() { + let harness = scoped_editor(Arc::new(tablepro_driver_mongodb::MongoDialect), &["shop", "analytics"]); + + crate::test_support::drain_main_context(); + assert_eq!(scope_shown(&harness).as_deref(), Some("shop")); +} + +#[gtk4::test] +fn an_engine_of_one_database_names_none() { + let harness = scoped_editor(Arc::new(tablepro_test_support::FakeDialect), &["main"]); + + crate::test_support::drain_main_context(); + assert_eq!(scope_shown(&harness), None); +} + +/// A `USE` in the script moves the session, and the bar goes with it. +#[gtk4::test] +fn the_bar_follows_a_use_the_tab_ran() { + let harness = scoped_editor(postgres_dialect(), &["public"]); + wait_until(WAIT, || scope_shown(&harness).as_deref() == Some("shop.public")).expect("the scope"); + harness.fake.set_database(Some("analytics")); + + run_and_wait(&harness); - let label = editor.imp().scope_label.get(); - crate::test_support::wait_until(Duration::from_secs(5), || label.label() == "shop.public") - .unwrap_or_else(|_| panic!("the bar says {:?}", label.label())); - assert!(editor.imp().scope_box.is_visible()); + assert_eq!(scope_shown(&harness).as_deref(), Some("analytics")); } diff --git a/linux/crates/core/src/dialect/database_layout.rs b/linux/crates/core/src/dialect/database_layout.rs new file mode 100644 index 0000000000..33067c5843 --- /dev/null +++ b/linux/crates/core/src/dialect/database_layout.rs @@ -0,0 +1,13 @@ +/// How the server's databases stand to the catalog a connection reads. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DatabaseLayout { + /// A connection reaches one database: a file, or numbered databases + /// of keys. + Single, + /// The server's databases are the schemas the catalog lists, so one + /// connection reads every one of them. + AsSchemas, + /// Each database holds schemas of its own, and a connection's catalog + /// describes only the database it opened. + WithSchemas, +} diff --git a/linux/crates/core/src/dialect/mod.rs b/linux/crates/core/src/dialect/mod.rs index 08c8769e7b..92eed0f063 100644 --- a/linux/crates/core/src/dialect/mod.rs +++ b/linux/crates/core/src/dialect/mod.rs @@ -3,6 +3,7 @@ mod bind_error; mod bind_target; +mod database_layout; mod dialect_capabilities; mod insert_batch_limits; mod keyset_direction; @@ -16,6 +17,7 @@ mod test_dialect; pub use bind_error::BindError; pub use bind_target::BindTarget; +pub use database_layout::DatabaseLayout; pub use dialect_capabilities::{DialectCapabilities, InsertConflict}; pub use insert_batch_limits::InsertBatchLimits; pub use keyset_direction::KeysetDirection; diff --git a/linux/crates/core/src/dialect/sql_dialect.rs b/linux/crates/core/src/dialect/sql_dialect.rs index 79b7e00948..b02f232434 100644 --- a/linux/crates/core/src/dialect/sql_dialect.rs +++ b/linux/crates/core/src/dialect/sql_dialect.rs @@ -6,8 +6,8 @@ use crate::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, Typ use crate::value::Value; use super::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, KeysetDirection, LikeCase, LikeForm, LiteralError, - Placeholder, TablePaging, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, KeysetDirection, LikeCase, LikeForm, + LiteralError, Placeholder, TablePaging, }; /// How one engine spells SQL. @@ -31,6 +31,10 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { /// whatever it contains. fn quote_identifier(&self, name: &str) -> String; + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::Single + } + /// Bind one value, giving back the SQL that names it. /// /// `ordinal` is the number of parameters the statement already diff --git a/linux/crates/core/src/editor_session.rs b/linux/crates/core/src/editor_session.rs index 20660f092d..5e29af3596 100644 --- a/linux/crates/core/src/editor_session.rs +++ b/linux/crates/core/src/editor_session.rs @@ -71,5 +71,12 @@ pub trait EditorSession: Send + Sync { async fn transaction_state(&self, options: CallOptions) -> Result; + /// The database an unqualified name resolves to now, as the server + /// last said. `None` where the driver does not follow it: the session + /// is still in the database it opened in. + fn database(&self) -> Option { + None + } + async fn close(&self); } diff --git a/linux/crates/core/src/sql_syntax/catalog_change.rs b/linux/crates/core/src/sql_syntax/catalog_change.rs index 3a7806743b..2fc62739eb 100644 --- a/linux/crates/core/src/sql_syntax/catalog_change.rs +++ b/linux/crates/core/src/sql_syntax/catalog_change.rs @@ -1,5 +1,6 @@ use super::SqlGrammar; use super::script::{ScriptPlan, ScriptTokenKind}; +use super::statement_tokens::lex_statements; /// Words that add, rename or remove what the sidebar lists. They count /// anywhere in a statement: a SQL Server batch needs no `;` between its @@ -27,6 +28,17 @@ pub fn changes_catalog(text: &str, grammar: SqlGrammar) -> bool { ScriptPlan::server_readings(text, grammar).is_none_or(|mut plans| plans.any(|plan| plan_changes(text, &plan))) } +/// Whether running `text` can leave the session in another database. A +/// `USE` anywhere counts, since a SQL Server batch needs no `;` between +/// its statements; an index hint's `USE` only costs a question. +pub fn may_change_database(text: &str, grammar: SqlGrammar) -> bool { + lex_statements(text, grammar) + .statements + .iter() + .flatten() + .any(|token| token.is_word("USE")) +} + fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { let is = |word: &str, set: &[&str]| set.iter().any(|verb| word.eq_ignore_ascii_case(verb)); plan.statements().iter().any(|statement| { @@ -48,6 +60,22 @@ fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { mod tests { use super::*; + #[test] + fn a_use_can_change_the_database() { + for grammar in SqlGrammar::SQL { + for sql in [ + "USE analytics", + "SELECT 1; use `a b`", + "SELECT 1 USE analytics SELECT 2", + ] { + assert!(may_change_database(sql, grammar), "{grammar:?}: {sql}"); + } + for sql in ["SELECT 'USE x'", "-- USE x\nSELECT 1", "SELECT used FROM t"] { + assert!(!may_change_database(sql, grammar), "{grammar:?}: {sql}"); + } + } + } + #[test] fn ddl_changes_the_catalog_in_every_grammar() { for grammar in SqlGrammar::SQL { diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index 36acc3d9a3..2952c3fd57 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -16,7 +16,7 @@ mod statement_access; mod statement_tokens; pub use builtin_snippet::{BuiltinSnippet, builtin_snippets}; -pub use catalog_change::changes_catalog; +pub use catalog_change::{changes_catalog, may_change_database}; pub use credential_rules::CredentialRules; pub use errors::{ExpressionSyntaxError, TypeSyntaxError}; pub use grammar::SqlGrammar; diff --git a/linux/crates/drivers/cassandra/src/dialect.rs b/linux/crates/drivers/cassandra/src/dialect.rs index 7a4850f713..336fd673db 100644 --- a/linux/crates/drivers/cassandra/src/dialect.rs +++ b/linux/crates/drivers/cassandra/src/dialect.rs @@ -1,8 +1,8 @@ use tablepro_core::column::{ColumnInfo, ColumnType, SqlExpression}; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LikeForm, - LiteralError, Placeholder, SqlDialect, TablePaging, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LikeForm, LiteralError, Placeholder, SqlDialect, TablePaging, }; use tablepro_core::filter::{Combinator, FilterOp}; use tablepro_core::meta::{EngineRowId, EngineRowIdPart, TableRef}; @@ -141,6 +141,10 @@ const DURATION: &[FilterOp] = &[FilterOp::Eq]; const CASSANDRA_IN_OUTSIDE_KEY: (u32, u32) = (5, 0); impl SqlDialect for CqlDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + fn capabilities(&self) -> DialectCapabilities { match self.conditional_writes { true => DialectCapabilities { diff --git a/linux/crates/drivers/cassandra/src/editor.rs b/linux/crates/drivers/cassandra/src/editor.rs index 5292545157..97da762cd6 100644 --- a/linux/crates/drivers/cassandra/src/editor.rs +++ b/linux/crates/drivers/cassandra/src/editor.rs @@ -74,6 +74,12 @@ impl EditorSession for CqlEditorSession { self.id } + /// The driver moves the session itself when a `USE` answers. + fn database(&self) -> Option { + let link = self.link.get().ok()?; + link.session().get_keyspace().map(|keyspace| keyspace.to_string()) + } + fn state(&self) -> EditorSessionState { match self.state.load(Ordering::SeqCst) { READY => EditorSessionState::Ready, diff --git a/linux/crates/drivers/clickhouse/src/dialect.rs b/linux/crates/drivers/clickhouse/src/dialect.rs index fc978cd8ab..01cacf8445 100644 --- a/linux/crates/drivers/clickhouse/src/dialect.rs +++ b/linux/crates/drivers/clickhouse/src/dialect.rs @@ -4,8 +4,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LikeCase, LikeForm, - LiteralError, Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LikeCase, LikeForm, LiteralError, Placeholder, SqlDialect, }; use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart, TableRef}; @@ -53,6 +53,10 @@ impl ClickHouseDialect { } impl SqlDialect for ClickHouseDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + fn capabilities(&self) -> DialectCapabilities { // A mutation reports nothing useful about how many rows it // touched, so a write proves itself with a probe instead. diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 0a3b2ccf21..4d00aacd94 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -76,6 +76,12 @@ impl EditorSession for ClickHouseEditorSession { self.id } + /// Every request names the database, which a `USE` in the session + /// does not override. + fn database(&self) -> Option { + self.session.database().map(str::to_owned) + } + fn state(&self) -> EditorSessionState { self.status.state() } diff --git a/linux/crates/drivers/mongodb/src/dialect.rs b/linux/crates/drivers/mongodb/src/dialect.rs index 8bcb36fda9..9fa2d50bd4 100644 --- a/linux/crates/drivers/mongodb/src/dialect.rs +++ b/linux/crates/drivers/mongodb/src/dialect.rs @@ -1,7 +1,7 @@ use tablepro_core::column::{ColumnType, SqlExpression}; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LiteralError, - Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LiteralError, Placeholder, SqlDialect, }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ExpressionSyntaxError, HistoryRecording, SqlGrammar, TypeSyntaxError}; @@ -17,6 +17,10 @@ use tablepro_core::value::Value; pub struct MongoDialect; impl SqlDialect for MongoDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + /// A write reports how many documents it matched, and `_id` is /// unique, so a count proves a save touched the document it meant to, /// and an insert with an `_id` already taken fails with E11000. diff --git a/linux/crates/drivers/mongodb/src/editor_session.rs b/linux/crates/drivers/mongodb/src/editor_session.rs index 96f6946fd0..be9f35e515 100644 --- a/linux/crates/drivers/mongodb/src/editor_session.rs +++ b/linux/crates/drivers/mongodb/src/editor_session.rs @@ -64,6 +64,10 @@ impl EditorSession for MongoEditorSession { self.id } + fn database(&self) -> Option { + Some(self.database.lock().unwrap_or_else(PoisonError::into_inner).clone()) + } + fn state(&self) -> EditorSessionState { self.status.state() } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 7b470b9519..7aaa8972a4 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -4,8 +4,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, LikeCase, LikeForm, - LiteralError, Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertBatchLimits, InsertConflict, KeysetDirection, + LikeCase, LikeForm, LiteralError, Placeholder, SqlDialect, }; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; use tablepro_core::sql_syntax::{ @@ -42,6 +42,10 @@ impl MssqlDialect { } impl SqlDialect for MssqlDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::WithSchemas + } + /// SQL Server adds the rows its triggers touch to a statement's /// count, so a write proves itself with a probe instead. fn capabilities(&self) -> DialectCapabilities { diff --git a/linux/crates/drivers/mssql/src/editor_run.rs b/linux/crates/drivers/mssql/src/editor_run.rs index 4223ab45a0..cbcaf2f7c1 100644 --- a/linux/crates/drivers/mssql/src/editor_run.rs +++ b/linux/crates/drivers/mssql/src/editor_run.rs @@ -247,6 +247,16 @@ pub(crate) async fn transaction_state(client: &mut MssqlClient) -> Result Result, DriverError> { + let stream = client.simple_query("SELECT DB_NAME()").await.map_err(map_error)?; + let row = stream.into_row().await.map_err(map_error)?; + Ok(row.and_then(|row| match decode_row(&row).into_iter().next() { + Some(Value::Text(name)) => Some(name), + _ => None, + })) +} + /// XACT_STATE is -1 when an error has doomed the transaction: it can /// only be rolled back. fn state_of(transaction_count: Option, xact_state: Option) -> TransactionState { diff --git a/linux/crates/drivers/mssql/src/editor_session.rs b/linux/crates/drivers/mssql/src/editor_session.rs index 4ec3c2850e..372ffaffd9 100644 --- a/linux/crates/drivers/mssql/src/editor_session.rs +++ b/linux/crates/drivers/mssql/src/editor_session.rs @@ -3,6 +3,7 @@ use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; +use tablepro_core::sql_syntax::{SqlGrammar, may_change_database}; use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LossPhase, ResultSink, RunEnd, RunOptions, RunSummary, StopReason, StopRequest, TimeoutPhase, TransactionState, UserSql, @@ -11,7 +12,7 @@ use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::editor_run::{EditorRun, ScriptOutcome, transaction_state}; +use crate::editor_run::{EditorRun, ScriptOutcome, current_database, transaction_state}; use crate::endpoint::MssqlEndpoint; use crate::errors::ends_connection; use crate::in_band_request::StopPolicy; @@ -38,6 +39,8 @@ struct Shared { lost: AtomicBool, /// The running statement's stop, so closing the tab stops it. current: Mutex>, + /// Where a `USE` left the session, once one ran. + database: Mutex>, statement_timeout: Option, policy: StopPolicy, cancel_grace: Duration, @@ -59,6 +62,7 @@ impl MssqlEditorSession { running: AtomicBool::new(false), lost: AtomicBool::new(false), current: Mutex::new(None), + database: Mutex::new(None), statement_timeout: options.statement_timeout, policy: StopPolicy::within(liveness.cancel_grace), cancel_grace: liveness.cancel_grace, @@ -103,7 +107,8 @@ impl Shared { let supervised = self.supervise(link, &request, &sql, &options, sink.as_mut()).await; let (end, transaction_state, keep) = match supervised { Some(Ok(outcome)) if outcome.reusable => { - let (state, keep) = self.state_after_run(link).await; + let follow = may_change_database(sql.as_str(), SqlGrammar::MsSql); + let (state, keep) = self.state_after_run(link, follow).await; (Ok(outcome.end), state, keep) } // The run ended, but somewhere the connection cannot go on @@ -175,7 +180,14 @@ impl Shared { /// Where the transaction stands after a run, within the cancel grace, /// and unknown rather than guessed when the answer does not come. - async fn state_after_run(&self, link: &mut MssqlLink) -> (TransactionState, bool) { + async fn state_after_run(&self, link: &mut MssqlLink, follow_database: bool) -> (TransactionState, bool) { + if follow_database { + match tokio::time::timeout(self.cancel_grace, current_database(&mut link.client)).await { + Ok(Ok(current)) => *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current, + Ok(Err(error)) => return (TransactionState::Unknown, !ends_connection(&error)), + Err(_) => return (TransactionState::Unknown, false), + } + } match tokio::time::timeout(self.cancel_grace, transaction_state(&mut link.client)).await { Ok(Ok(state)) => (state, true), Ok(Err(error)) => (TransactionState::Unknown, !ends_connection(&error)), @@ -198,6 +210,14 @@ impl EditorSession for MssqlEditorSession { self.id } + fn database(&self) -> Option { + self.shared + .database + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + fn state(&self) -> EditorSessionState { self.shared.state() } diff --git a/linux/crates/drivers/mssql/tests/integration/sessions.rs b/linux/crates/drivers/mssql/tests/integration/sessions.rs index 01874bd9ae..ae1e0008a0 100644 --- a/linux/crates/drivers/mssql/tests/integration/sessions.rs +++ b/linux/crates/drivers/mssql/tests/integration/sessions.rs @@ -17,6 +17,11 @@ async fn use_database_persists_across_runs() { let database = run_ok(session.as_ref(), "SELECT DB_NAME()").await.rows(); assert_eq!(database, vec![vec![Value::Text("tempdb".to_owned())]]); + assert_eq!( + session.database().as_deref(), + Some("tempdb"), + "where the session says it is" + ); } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index dedd423ca4..df5d173259 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -5,8 +5,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertConflict, KeysetDirection, LikeCase, LikeForm, LiteralError, - Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertConflict, KeysetDirection, LikeCase, LikeForm, + LiteralError, Placeholder, SqlDialect, }; use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; @@ -61,6 +61,10 @@ impl MySqlDialect { } impl SqlDialect for MySqlDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::AsSchemas + } + fn capabilities(&self) -> DialectCapabilities { // CLIENT_FOUND_ROWS is on, so an UPDATE reports the rows it // matched, including ones it left unchanged. diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 7907f61351..13d86f79cd 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -1,22 +1,26 @@ -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; use sqlx::mysql::MySqlConnection; use sqlx::{AssertSqlSafe, Connection as _, Executor as _}; +use tablepro_core::sql_syntax::{SqlGrammar, may_change_database}; use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LivenessPolicy, - LossPhase, ResultSink, RowLimit, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, + LossPhase, ResultSink, RowLimit, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, Value, }; use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; use crate::cancel::ControlLink; +use crate::decode::decode_row; use crate::editor_run::{RunOutcome, run_script}; use crate::errors::map_error; use crate::flavour::MySqlFlavour; +use crate::mysql_timestamp_mode::MySqlTimestampMode; +use crate::mysql_wire_format::MySqlWireFormat; use crate::physical::{MysqlEndpoint, SessionKind}; use crate::session_statements::{SessionLimits, select_limit}; use crate::transaction_probe::TransactionProbe; @@ -47,6 +51,8 @@ struct Shared { flavour: MySqlFlavour, control: ControlLink, probe: TransactionProbe, + /// Where a `USE` left the session, once one ran. + database: Mutex>, running: AtomicBool, lost: AtomicBool, /// Cancelled when the session closes, so a running statement stops. @@ -85,6 +91,7 @@ impl MysqlEditorSession { flavour: physical.flavour, control: ControlLink::new(endpoint), probe: TransactionProbe::new(physical.flavour), + database: Mutex::new(None), running: AtomicBool::new(false), lost: AtomicBool::new(false), closing: CancellationToken::new(), @@ -126,6 +133,9 @@ impl Shared { let supervised = self.supervise(held, &sql, &options, sink.as_mut()).await; let transaction_state = match &supervised { Ok((_, true)) => { + if may_change_database(sql.as_str(), SqlGrammar::MySql) { + self.follow_database(&mut held.connection).await; + } let asking = self.probe.state(&mut held.connection); tokio::time::timeout(self.liveness.cancel_grace, asking) .await @@ -154,6 +164,18 @@ impl Shared { }) } + async fn follow_database(&self, connection: &mut MySqlConnection) { + let asking = connection.fetch_one(sqlx::raw_sql("SELECT DATABASE()")); + let Ok(Ok(row)) = tokio::time::timeout(self.liveness.cancel_grace, asking).await else { + return; + }; + let current = match decode_row(&row, MySqlWireFormat::Text, MySqlTimestampMode::SessionLocal).first() { + Some(Value::Text(name)) => Some(name.clone()), + _ => None, + }; + *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current; + } + /// Run the script, stopping it on a cancel, at the deadline, when /// the session closes or when the sink stops reading. A stop is KILL /// QUERY on a connection of the session's own; the statement then @@ -317,6 +339,14 @@ impl EditorSession for MysqlEditorSession { outcome } + fn database(&self) -> Option { + self.shared + .database + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + async fn transaction_state(&self, options: CallOptions) -> Result { let shared = &self.shared; let asking = async { diff --git a/linux/crates/drivers/mysql/tests/integration/editor_session.rs b/linux/crates/drivers/mysql/tests/integration/editor_session.rs index 236d599ca2..6701d7132d 100644 --- a/linux/crates/drivers/mysql/tests/integration/editor_session.rs +++ b/linux/crates/drivers/mysql/tests/integration/editor_session.rs @@ -259,3 +259,32 @@ async fn a_deadline_reaches_a_run_still_setting_its_row_limit() { editor.close().await; connection.close().await.expect("a close"); } + +/// The session says where a `USE` left it, so the editor bar can follow. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_use_moves_the_session_and_it_says_where() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let root = connect_root(&fixture).await; + let grant = format!( + "GRANT ALL ON analytics.* TO '{}'@'%'", + fixture.password_credentials().username + ); + setup( + root.as_ref(), + &["CREATE DATABASE IF NOT EXISTS analytics", grant.as_str()], + ) + .await; + root.close().await.expect("a close"); + let connection = connect(&fixture, false).await; + let editor = session(connection.as_ref()).await; + + run_ok(editor.as_ref(), "SELECT 1").await; + assert_eq!(editor.database(), None, "{flavour:?}: no USE ran"); + run_ok(editor.as_ref(), "USE analytics").await; + assert_eq!(editor.database().as_deref(), Some("analytics"), "{flavour:?}"); + editor.close().await; + connection.close().await.expect("a close"); + } +} diff --git a/linux/crates/drivers/postgres/src/dialect.rs b/linux/crates/drivers/postgres/src/dialect.rs index f9dfaabd0d..c7693e6ea3 100644 --- a/linux/crates/drivers/postgres/src/dialect.rs +++ b/linux/crates/drivers/postgres/src/dialect.rs @@ -6,7 +6,8 @@ use tablepro_core::column::{ }; use tablepro_core::ddl::DdlDialect; use tablepro_core::dialect::{ - BindError, BindTarget, DialectCapabilities, InsertConflict, KeysetDirection, LiteralError, Placeholder, SqlDialect, + BindError, BindTarget, DatabaseLayout, DialectCapabilities, InsertConflict, KeysetDirection, LiteralError, + Placeholder, SqlDialect, }; use tablepro_core::export::value_to_text; use tablepro_core::meta::{EngineRowId, EngineRowIdPart}; @@ -41,6 +42,10 @@ impl PgDialect { } impl SqlDialect for PgDialect { + fn database_layout(&self) -> DatabaseLayout { + DatabaseLayout::WithSchemas + } + fn capabilities(&self) -> DialectCapabilities { DialectCapabilities { exact_row_counts: true, diff --git a/linux/crates/test-support/src/fake_editor_session.rs b/linux/crates/test-support/src/fake_editor_session.rs index b53d4666f8..1dc4ddfee8 100644 --- a/linux/crates/test-support/src/fake_editor_session.rs +++ b/linux/crates/test-support/src/fake_editor_session.rs @@ -25,6 +25,7 @@ pub struct FakeEditorSession { texts: Mutex>, state: Mutex, transaction_state: Mutex, + database: Mutex>, close_behaviour: CloseBehaviour, closes: AtomicUsize, closed: AtomicBool, @@ -44,6 +45,7 @@ impl FakeEditorSession { texts: Mutex::new(Vec::new()), state: Mutex::new(EditorSessionState::Ready), transaction_state: Mutex::new(TransactionState::Idle), + database: Mutex::new(None), close_behaviour, closes: AtomicUsize::new(0), closed: AtomicBool::new(false), @@ -75,6 +77,11 @@ impl FakeEditorSession { *lock(&self.transaction_state) = state; } + /// Where the session says it is, as after a `USE`. + pub fn set_database(&self, database: Option<&str>) { + *lock(&self.database) = database.map(str::to_owned); + } + /// The connection goes while no run is on it, as when the server /// ends a session left idle in a transaction. pub fn lose(&self) { @@ -188,6 +195,10 @@ impl EditorSession for FakeEditorSession { Ok(*lock(&self.transaction_state)) } + fn database(&self) -> Option { + lock(&self.database).clone() + } + async fn close(&self) { let first = self.closes.fetch_add(1, Ordering::SeqCst) == 0; self.closing.cancel(); diff --git a/linux/data/resources/ui/sql-editor.ui b/linux/data/resources/ui/sql-editor.ui index b343a5e633..f520d2d01d 100644 --- a/linux/data/resources/ui/sql-editor.ui +++ b/linux/data/resources/ui/sql-editor.ui @@ -93,8 +93,6 @@ 8 8 - false 6 From a771366247fd6119741c7ae2d8be3a6c9bbfc4bd Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:22:42 +0700 Subject: [PATCH 09/23] fix(linux-editor): move a tab's own session with the driver, and complete only where the catalog reaches --- .../app/src/ui/assistant/schema_reader.rs | 2 +- .../src/ui/editor/completion/catalog_read.rs | 110 +++++++++--- .../app/src/ui/editor/completion/mod.rs | 2 +- .../completion/sql_completion_provider.rs | 9 +- .../completion/sql_completion_provider_imp.rs | 3 +- .../app/src/ui/editor/editor_actions.rs | 16 +- .../app/src/ui/editor/editor_session_slot.rs | 132 ++++++-------- linux/crates/app/src/ui/editor/imp.rs | 4 +- linux/crates/app/src/ui/editor/mod.rs | 6 +- .../crates/app/src/ui/editor/script_runner.rs | 9 + linux/crates/app/src/ui/editor/tests.rs | 161 +++++++++++------- linux/crates/app/src/ui/workspace/mod.rs | 4 + .../app/src/ui/workspace/tab_factory.rs | 2 + .../app/src/ui/workspace/workspace_tabs.rs | 5 +- linux/crates/core/src/connection.rs | 5 +- linux/crates/core/src/editor_session.rs | 9 + linux/crates/drivers/mysql/src/dialect.rs | 6 +- .../drivers/mysql/src/editor_session.rs | 46 +++++ linux/crates/drivers/mysql/src/metadata.rs | 2 +- .../test-support/src/fake_editor_session.rs | 23 +++ 20 files changed, 368 insertions(+), 188 deletions(-) diff --git a/linux/crates/app/src/ui/assistant/schema_reader.rs b/linux/crates/app/src/ui/assistant/schema_reader.rs index 3096018fc5..aff96ad98c 100644 --- a/linux/crates/app/src/ui/assistant/schema_reader.rs +++ b/linux/crates/app/src/ui/assistant/schema_reader.rs @@ -39,7 +39,7 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> CompletionScope::General { tables_in_statement: named, }, - None, + crate::ui::editor::CatalogHome::Connection, ) .await; let schema = session.schemas().await.ok().and_then(|schemas| { diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs index 2153a4adea..24f9febb8c 100644 --- a/linux/crates/app/src/ui/editor/completion/catalog_read.rs +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -14,58 +14,93 @@ pub struct CatalogAnswer { pub columns: Vec<(TableRef, Arc<[ColumnInfo]>)>, } +/// Where an unqualified name resolves for the tab. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum CatalogHome { + /// The connection's default schema. + #[default] + Connection, + /// Another of the server's databases, which the catalog lists as a + /// schema. + Schema(String), + /// A database the catalog does not describe, which nothing offered + /// from it would be right for. + Elsewhere, +} + /// Read what `scope` offers. The catalog shares each read with every /// other view and keeps what it read, so typing does not reach the /// server again; a list that cannot be read is left out. -/// -/// `home` is the database the tab was moved to from the editor bar, where -/// an unqualified name now resolves; `None` keeps the connection's own. -pub async fn read(session: Arc, scope: CompletionScope, home: Option) -> CatalogAnswer { - let home = match home { - Some(database) => ObjectScope::Schema(database), - None => ObjectScope::DefaultSchema, +pub async fn read(session: Arc, scope: CompletionScope, home: CatalogHome) -> CatalogAnswer { + let defaults = match &home { + CatalogHome::Connection => listed(session.objects(ObjectScope::DefaultSchema).await), + CatalogHome::Schema(name) => listed(session.objects(ObjectScope::Schema(name.clone())).await), + CatalogHome::Elsewhere => return CatalogAnswer::default(), }; - let defaults = listed(session.objects(home).await); + let home = Home { home, defaults }; match scope { CompletionScope::Objects { schema: None } => CatalogAnswer { schemas: Some(listed(session.schemas().await)), - objects: Some(defaults), + objects: Some(home.defaults.clone()), columns: Vec::new(), }, CompletionScope::Objects { schema: Some(name) } => { // `users.` typed before the FROM that names users. - let table = relation_named(&defaults, &name); + let table = relation_named(&home.defaults, &name); CatalogAnswer { schemas: None, - objects: schema_objects(&session, &name).await, + objects: schema_objects(&session, &home, &name).await, columns: columns_of(&session, table.into_iter().collect()).await, } } CompletionScope::Qualified { qualifier, name } => { let table = TableRef::new(Some(qualifier), name.clone()); - match known_table(&session, &defaults, &table).await { + match known_table(&session, &home, &table).await { Some(table) => CatalogAnswer { columns: columns_of(&session, vec![table]).await, ..CatalogAnswer::default() }, None => CatalogAnswer { - objects: schema_objects(&session, &name).await, + objects: schema_objects(&session, &home, &name).await, ..CatalogAnswer::default() }, } } CompletionScope::Columns { tables } => CatalogAnswer { - columns: columns_of(&session, known_tables(&session, &defaults, &tables).await).await, + columns: columns_of(&session, known_tables(&session, &home, &tables).await).await, ..CatalogAnswer::default() }, CompletionScope::General { tables_in_statement } => CatalogAnswer { schemas: None, - columns: columns_of(&session, known_tables(&session, &defaults, &tables_in_statement).await).await, - objects: Some(defaults), + columns: columns_of(&session, known_tables(&session, &home, &tables_in_statement).await).await, + objects: Some(home.defaults), }, } } +/// The schema unqualified names resolve in and what it holds. +struct Home { + home: CatalogHome, + defaults: Arc<[ObjectInfo]>, +} + +impl Home { + /// Whether `schema` names the home schema, whose objects are read. + fn is(&self, schema: &SchemaInfo) -> bool { + match &self.home { + CatalogHome::Connection => schema.is_default, + CatalogHome::Schema(name) => schema.name == *name, + CatalogHome::Elsewhere => false, + } + } + + /// A name for the home database the catalog does not list as a + /// schema, as MySQL lists only the connection's. + fn named(&self, name: &str) -> bool { + matches!(&self.home, CatalogHome::Schema(home) if home.eq_ignore_ascii_case(name)) + } +} + async fn columns_of(session: &Session, tables: Vec) -> Vec<(TableRef, Arc<[ColumnInfo]>)> { let reads = tables.into_iter().map(|table| async move { let columns = listed(session.columns(table.clone()).await); @@ -83,7 +118,10 @@ fn listed(read: Result, Arc>) -> Arc<[T]> { /// The objects of the schema called `name`, or `None` when the catalog /// has no such schema. -async fn schema_objects(session: &Session, name: &str) -> Option> { +async fn schema_objects(session: &Session, home: &Home, name: &str) -> Option> { + if home.named(name) { + return Some(home.defaults.clone()); + } let schemas = listed(session.schemas().await); let schema = schema_named(&schemas, name)?; Some(listed(session.objects(ObjectScope::Schema(schema.name.clone())).await)) @@ -92,10 +130,10 @@ async fn schema_objects(session: &Session, name: &str) -> Option Vec { +async fn known_tables(session: &Session, home: &Home, tables: &[TableRef]) -> Vec { let mut known = Vec::new(); for table in tables { - if let Some(table) = known_table(session, defaults, table).await + if let Some(table) = known_table(session, home, table).await && !known.contains(&table) { known.push(table); @@ -106,14 +144,17 @@ async fn known_tables(session: &Session, defaults: &[ObjectInfo], tables: &[Tabl /// The catalog's own name for `table`, so the read is the one the /// table's other views share. -async fn known_table(session: &Session, defaults: &[ObjectInfo], table: &TableRef) -> Option { +async fn known_table(session: &Session, home: &Home, table: &TableRef) -> Option { let Some(schema) = &table.schema else { - return relation_named(defaults, &table.name); + return relation_named(&home.defaults, &table.name); }; + if home.named(schema) { + return relation_named(&home.defaults, &table.name); + } let schemas = listed(session.schemas().await); let schema = schema_named(&schemas, schema)?; - if schema.is_default { - return relation_named(defaults, &table.name); + if home.is(schema) { + return relation_named(&home.defaults, &table.name); } let objects = listed(session.objects(ObjectScope::Schema(schema.name.clone())).await); relation_named(&objects, &table.name) @@ -156,6 +197,29 @@ mod tests { } } + /// A tab moved to `analytics` reads `shop.orders` from `shop`, the + /// connection's default schema, not from its own. + #[test] + fn only_the_tabs_own_database_is_read_from_its_home() { + let shop = SchemaInfo { + name: "shop".to_owned(), + is_default: true, + is_system: false, + }; + let moved = Home { + home: CatalogHome::Schema("analytics".to_owned()), + defaults: Arc::from([]), + }; + assert!(!moved.is(&shop)); + assert!(moved.named("analytics")); + let staying = Home { + home: CatalogHome::Connection, + defaults: Arc::from([]), + }; + assert!(staying.is(&shop)); + assert!(!staying.named("shop"), "the catalog lists the connection's own"); + } + #[test] fn an_unqualified_table_takes_the_catalogs_schema() { let defaults = [object("users", ObjectKind::Table), object("Orders", ObjectKind::Table)]; diff --git a/linux/crates/app/src/ui/editor/completion/mod.rs b/linux/crates/app/src/ui/editor/completion/mod.rs index f5e2c4c98d..a131338771 100644 --- a/linux/crates/app/src/ui/editor/completion/mod.rs +++ b/linux/crates/app/src/ui/editor/completion/mod.rs @@ -17,6 +17,6 @@ mod tests; #[cfg(test)] pub(super) use tests::accept_builtin_snippet; -pub(crate) use catalog_read::read as read_catalog; +pub(crate) use catalog_read::{CatalogHome, read as read_catalog}; pub(crate) use completion_scope::{CompletionScope, tables_named}; pub use sql_completion_provider::SqlCompletionProvider; diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs index 0d17dd37bc..bebf5a2564 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider.rs @@ -14,7 +14,7 @@ use tablepro_core::sql_syntax::script::{LexicalSettings, text_offsets}; use tablepro_session::Session; use tablepro_session::runtime::Tasks; -use super::catalog_read::{self, CatalogAnswer}; +use super::catalog_read::{self, CatalogAnswer, CatalogHome}; use super::completion_scope::{self, CompletionScope}; use super::fuzzy; use super::proposal_kind::ProposalKind; @@ -44,10 +44,9 @@ pub struct ProviderDeps { } impl SqlCompletionProvider { - /// Resolve unqualified names in `database`, the tab's own when the - /// editor bar moved it off the connection's. - pub fn set_home(&self, database: Option) { - self.imp().home.replace(database); + /// Resolve unqualified names in `home`, the tab's database. + pub fn set_home(&self, home: CatalogHome) { + self.imp().home.replace(home); } pub fn new(session: ActiveSession, tasks: Tasks, snippets: Rc) -> Self { diff --git a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs index ea5265cdcd..710220ea4c 100644 --- a/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs +++ b/linux/crates/app/src/ui/editor/completion/sql_completion_provider_imp.rs @@ -19,8 +19,7 @@ const PRIORITY: i32 = 100; #[derive(Default)] pub struct SqlCompletionProvider { pub deps: OnceCell, - /// The database the tab was moved to, where unqualified names resolve. - pub home: std::cell::RefCell>, + pub home: std::cell::RefCell, } #[glib::object_subclass] diff --git a/linux/crates/app/src/ui/editor/editor_actions.rs b/linux/crates/app/src/ui/editor/editor_actions.rs index a32aaa051d..fecf7ccd06 100644 --- a/linux/crates/app/src/ui/editor/editor_actions.rs +++ b/linux/crates/app/src/ui/editor/editor_actions.rs @@ -1,6 +1,6 @@ -use gtk4::gio; use gtk4::prelude::*; use gtk4::subclass::prelude::*; +use gtk4::{gio, glib}; use libadwaita as adw; use libadwaita::prelude::*; @@ -68,6 +68,18 @@ impl SqlEditor { // Hidden until a workspace attaches an assistant that may send. set_enabled(&actions, name, false); } + let editor = self.downgrade(); + let database = gio::SimpleAction::new_stateful( + super::tab_scope::MOVE_ACTION, + Some(glib::VariantTy::STRING), + &"".to_variant(), + ); + database.connect_change_state(move |_, value| { + if let (Some(editor), Some(name)) = (editor.upgrade(), value.and_then(|value| value.str())) { + editor.move_to_database(name); + } + }); + actions.add_action(&database); sync_run_actions(&actions, false, None); sync_caret_actions(&actions, &self.imp().source_view); // Commands have no comments, and no layout to format them into. @@ -238,6 +250,8 @@ fn close_view_popups(view: &impl IsA) -> bool { /// its completion popup. The session takes one run at a time, so a /// commit or rollback waits for the run in flight. fn sync_run_actions(actions: &gio::SimpleActionGroup, running: bool, open: Option) { + // A tab moves between runs, and never away from its transaction. + set_enabled(actions, super::tab_scope::MOVE_ACTION, !running && open.is_none()); set_enabled(actions, "cancel", running); set_enabled(actions, "roll-back", !running && open.is_some()); set_enabled(actions, "commit", !running && open == Some(OpenTransaction::Open)); diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index 3791fdc468..e0be235a77 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -1,11 +1,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; use std::time::Duration; -use async_trait::async_trait; -use tablepro_core::{ - EditorSession, EditorSessionOptions, EditorSessionState, ResultEvent, ResultSink, RunOptions, SinkFlow, - TransactionState, UserSql, -}; +use tablepro_core::{CallOptions, EditorSession, EditorSessionOptions, EditorSessionState, TransactionState}; use tablepro_session::{OperationError, Session}; use tokio_util::sync::CancellationToken; @@ -32,15 +28,9 @@ struct SlotState { /// told yet. rolled_back: bool, closed: bool, - /// The database the tab was moved to from the editor bar. Every - /// session the tab opens or holds is moved there before anything else. - database: Option, -} - -/// A database a tab was moved to. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct TabDatabase { - pub name: String, + /// The database the tab works in once it left the connection's: picked + /// from the editor bar, restored, or where a `USE` left its session. + database: Option, } struct OpenEditor { @@ -50,8 +40,6 @@ struct OpenEditor { editor: Arc, /// Where the transaction stood after the last run that reported it. transaction: Option, - /// The tab's database this session was moved to, if any. - moved_to: Option, } enum Lookup { @@ -76,6 +64,12 @@ pub enum AcquireError { /// The session's connection was lost with a transaction open, which /// the server rolled back. Nothing was opened in its place. TransactionRolledBack, + /// The tab could not get to its database, so it stays where its + /// session is. + Moved { + database: String, + reason: String, + }, Failed(String), } @@ -94,8 +88,9 @@ impl EditorSessionSlot { close_timeout: Duration, cancel: &CancellationToken, ) -> Result, AcquireError> { + let deadline = statement_timeout.map(|timeout| tokio::time::Instant::now() + timeout); let stale = match self.lookup(session) { - Lookup::Ready(editor) => return self.move_to_database(session, editor, cancel).await, + Lookup::Ready(editor) => return self.reach_database(session, editor, deadline, cancel).await, Lookup::Closed => return Err(AcquireError::Cancelled), Lookup::RolledBack { stale } => { if let Some(stale) = stale { @@ -111,7 +106,7 @@ impl EditorSessionSlot { let options = EditorSessionOptions { statement_timeout }; match session.open_editor_session(options, cancel).await { Ok(editor) => match self.adopt(session, editor.clone()) { - Ok(()) => self.move_to_database(session, editor, cancel).await, + Ok(()) => self.reach_database(session, editor, deadline, cancel).await, Err(orphan) => { close_quietly(orphan, close_timeout).await; Err(AcquireError::Cancelled) @@ -125,69 +120,54 @@ impl EditorSessionSlot { } } - /// Move the tab's sessions to `database` from the next run on. - pub fn set_database(&self, database: Option) { - lock(&self.state).database = database; + /// Move the tab to `database` from its next run on. + pub fn set_database(&self, database: String) { + lock(&self.state).database = Some(database); } - /// The tab's database, when it was moved off the connection's own. - pub fn database(&self) -> Option { + /// The tab's database, `None` while it is the connection's. + pub fn database(&self) -> Option { lock(&self.state).database.clone() } - /// Run the engine's `USE` for the tab's database on `editor` unless it - /// ran there already. Built now, when the session's dialect is known: - /// a tab restored before the connection came up named only the name. - async fn move_to_database( + /// Take where a run left `editor`, which a `USE` in it may have moved. + pub fn follow(&self, editor: &Arc) { + let mut state = lock(&self.state); + let held = state.open.as_ref().is_some_and(|open| open.editor.id() == editor.id()); + if let Some(database) = editor.database().filter(|_| held) { + state.database = Some(database); + } + } + + /// Put `editor` in the tab's database unless it is there. A session + /// that cannot get there leaves the tab where the session is. + async fn reach_database( &self, session: &Arc, editor: Arc, + deadline: Option, cancel: &CancellationToken, ) -> Result, AcquireError> { - let wanted = { - let state = lock(&self.state); - let moved = state - .open - .as_ref() - .filter(|open| Arc::ptr_eq(&open.editor, &editor)) - .and_then(|open| open.moved_to.clone()); - state - .database - .clone() - .filter(|database| moved.as_ref() != Some(&database.name)) - }; - let Some(database) = wanted else { + let Some(wanted) = self.database() else { return Ok(editor); }; - let Some(dialect) = session.current_dialect() else { - return Err(AcquireError::Failed(crate::i18n::gettext_f( - "Could not switch this tab to the database “{database}”", - &[("database", &database.name)], - ))); - }; - let statement = format!("USE {}", dialect.quote_identifier(&database.name)); - let ran = editor - .run( - UserSql::new(statement.as_str()), - RunOptions::new(cancel.clone()), - Box::new(Discard), - ) - .await; - match ran { - Ok(summary) if summary.succeeded() => { - if let Some(open) = lock(&self.state) - .open - .as_mut() - .filter(|open| Arc::ptr_eq(&open.editor, &editor)) - { - open.moved_to = Some(database.name); - } - Ok(editor) + let here = editor.database().or_else(|| session.database().map(str::to_owned)); + if here.as_deref() == Some(wanted.as_str()) { + return Ok(editor); + } + match editor + .use_database(&wanted, CallOptions::new(deadline, cancel.clone())) + .await + { + Ok(()) => Ok(editor), + Err(tablepro_core::DriverError::Cancelled) => Err(AcquireError::Cancelled), + Err(error) => { + lock(&self.state).database = here; + Err(AcquireError::Moved { + database: wanted, + reason: crate::ui::error_text::driver_message(&error), + }) } - _ => Err(AcquireError::Failed(crate::i18n::gettext_f( - "Could not switch this tab to the database “{database}”", - &[("database", &database.name)], - ))), } } @@ -220,11 +200,6 @@ impl EditorSessionSlot { } } - /// Where the tab's session says it is, once it says. - pub fn database(&self) -> Option { - lock(&self.state).open.as_ref().and_then(|open| open.editor.database()) - } - /// Where the transaction of `editor` stood after its last run. pub fn transaction(&self, editor: &Arc) -> Option { let state = lock(&self.state); @@ -254,7 +229,6 @@ impl EditorSessionSlot { owner: Arc::downgrade(owner), editor, transaction: None, - moved_to: None, }); Ok(()) } @@ -316,16 +290,6 @@ fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex.lock().unwrap_or_else(PoisonError::into_inner) } -/// Takes what a `USE` sends back, which is nothing the tab shows. -struct Discard; - -#[async_trait] -impl ResultSink for Discard { - async fn accept(&mut self, _event: ResultEvent) -> SinkFlow { - SinkFlow::Continue - } -} - #[cfg(test)] pub(super) mod tests { use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 19a4382fc7..c43482b723 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -64,8 +64,6 @@ pub struct SqlEditor { /// This tab's own connection, which keeps its transaction and /// temporary tables between runs. pub slot: Arc, - /// The tab's own actions: the database menu's. - pub tab_actions: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. @@ -142,6 +140,8 @@ impl ObjectImpl for SqlEditor { .param_types([bool::static_type()]) .build(), Signal::builder("file-changed").build(), + // The tab moved to another database. + Signal::builder("database-changed").build(), // Vim's :q, which closes the tab through its close prompts. Signal::builder("close-requested").build(), Signal::builder("assistant-requested") diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index d994114567..e1c3b7a61a 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -49,7 +49,7 @@ use tablepro_session::runtime::{ProcessHold, Tasks}; use tokio_util::sync::CancellationToken; use self::buffer_edit::{CursorAnchor, replace_all_undoable}; -pub(crate) use self::completion::{CompletionScope, read_catalog, tables_named}; +pub(crate) use self::completion::{CatalogHome, CompletionScope, read_catalog, tables_named}; pub(crate) use self::editor_file::file_name; use self::editor_view::EditorView; use self::imp::RunningScript; @@ -154,6 +154,7 @@ impl SqlEditor { editor.connect_buttons(); editor.connect_grid(); editor.show_scope(); + editor.list_databases(); editor } @@ -692,6 +693,9 @@ impl SqlEditor { } self.record_history(entry, dialect); self.show_scope(); + if imp.changes_catalog.get() { + self.list_databases(); + } self.emit_by_name::<()>("run-finished", &[&outcome]); self.emit_by_name::<()>("run-state-changed", &[&false]); } diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs index 922d106e62..d3901a8c16 100644 --- a/linux/crates/app/src/ui/editor/script_runner.rs +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -243,6 +243,14 @@ pub async fn run_in_tab( refuse(&events, message, true).await; return; } + Err(AcquireError::Moved { database, reason }) => { + let message = crate::i18n::gettext_f( + "Could not move this tab to the database “{database}”: {reason}", + &[("database", &database), ("reason", &reason)], + ); + refuse(&events, message, false).await; + return; + } Err(AcquireError::Failed(message)) => { refuse(&events, message, false).await; return; @@ -250,6 +258,7 @@ pub async fn run_in_tab( }; let before = slot.transaction(&editor); let after = run_batches(&editor, &run, &cancel, &events, before).await; + slot.follow(&editor); if editor.state() != EditorSessionState::Lost { slot.note_transaction(&editor, after.transaction); return; diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 79b9198c4e..5ae932f317 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2622,9 +2622,18 @@ fn a_command_editor_has_none_of_the_sql_tools() { /// An editor on a connection to `shop`, whose catalog lists `schemas` /// with the first as the default. fn scoped_editor(dialect: Arc, schemas: &[&str]) -> Harness { + scoped_editor_with(dialect, schemas, answering(), |_, _| {}) +} + +fn scoped_editor_with( + dialect: Arc, + schemas: &[&str], + runs: Replies, + setup: impl FnOnce(&mut ConnectionScript, &mut tablepro_session::connect::ConnectRequest), +) -> Harness { crate::register_test_resources(); let runtime = crate::test_support::test_runtime(); - let fake = Arc::new(FakeEditorSession::new(answering())); + let fake = Arc::new(FakeEditorSession::new(runs)); let mut request = FakeConnector::network_request("db.example", 5432).expect("a request"); if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { *database = Some("shop".to_owned()); @@ -2633,6 +2642,7 @@ fn scoped_editor(dialect: Arc, schemas: dialect, ..ConnectionScript::default() }; + setup(&mut script, &mut request); let editor_session: Arc = fake.clone(); script.open_editor_session.always(FakeReply::ok(editor_session)); let (pending, _) = FakeConnector::pending_session(request, script); @@ -2732,74 +2742,103 @@ fn the_bar_follows_a_use_the_tab_ran() { assert_eq!(scope_shown(&harness).as_deref(), Some("analytics")); } -/// The bar is a menu of the server's databases; picking one moves the -/// tab there, so the next run sends the engine's `USE` before the query. -#[gtk4::test] -fn picking_a_database_moves_the_tab_and_its_next_run() { - crate::register_test_resources(); - let runtime = crate::test_support::test_runtime(); - let fake = Arc::new(FakeEditorSession::new(answering())); - let mut request = FakeConnector::network_request("db.example", 3306).expect("a request"); - if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { - *database = Some("shop".to_owned()); - } +fn mysql_dialect() -> Arc { + Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &tablepro_core::ServerInfo::new("MySQL", "8.4.0", 8, 4, 0), + )) +} + +/// A MySQL connection to `shop` on a server that also holds `analytics`. +fn databases_editor(runs: Replies, read_only: bool) -> Harness { let database = |name: &str, is_default: bool, is_system: bool| tablepro_core::meta::SchemaInfo { name: name.to_owned(), is_default, is_system, }; - let mut script = ConnectionScript::default(); - script.list_databases.always(FakeReply::ok(vec![ - database("analytics", false, false), - database("shop", true, false), - database("mysql", false, true), - ])); - let editor_session: Arc = fake.clone(); - script.open_editor_session.always(FakeReply::ok(editor_session)); - let (pending, _) = FakeConnector::pending_session(request, script); - let session = Session::start( - pending, - tablepro_session::SessionDeps { - connector: Arc::new(FakeConnector::new([])), - credentials: Arc::new(FakeCredentialSource::new()), - deadlines: Arc::new(DefaultDeadlines::new(tablepro_core::LivenessPolicy::DESKTOP)), - tasks: runtime.tasks(), - prompter: None, - }, - ); - let active = ActiveSession::new(); - active.replace(session); - let settings = MemorySettings::new(); - let editor = SqlEditor::new(SqlEditorInit { - initial_query: Some("SELECT 1".to_owned()), - settings: settings.get().clone(), - history: None, - tasks: runtime.tasks(), - session: active.clone(), - hold: Rc::new(tablepro_session::runtime::CountingHold::new()), - close_timeout: Duration::from_secs(5), - snippets: crate::test_support::builtin_snippets_only(), + let harness = scoped_editor_with(mysql_dialect(), &["shop"], runs, |script, _| { + script.list_databases.always(FakeReply::ok(vec![ + database("analytics", false, false), + database("shop", true, false), + database("mysql", false, true), + ])); + script.read_only = read_only; }); - let button = editor.imp().scope_button.get(); - crate::test_support::wait_until(Duration::from_secs(5), || button.is_visible()).expect("the database menu"); + let button = harness.editor.imp().scope_button.get(); + wait_until(WAIT, || button.is_visible()).expect("the database menu"); + harness +} - WidgetExt::activate_action(&editor, "tab.database", Some(&"analytics".to_variant())).expect("tab.database"); - assert_eq!(editor.imp().scope_button_label.label(), "analytics"); +fn pick_database(harness: &Harness, name: &str) { + WidgetExt::activate_action(&harness.editor, "editor.database", Some(&name.to_variant())).expect("editor.database"); +} + +/// Picking a database moves the tab: the bar names it at once, and the +/// next run moves the session there before its statements. +#[gtk4::test] +fn picking_a_database_moves_the_tab_and_its_next_run() { + let harness = databases_editor(answering(), false); + let changed = SignalLog::connect(&harness.editor, "database-changed", |_| ()); + + pick_database(&harness, "analytics"); + assert_eq!(harness.editor.imp().scope_button_label.label(), "analytics"); + assert_eq!(changed.count(), 1, "the workspace keeps where the tab is"); - let harness = Harness { - editor, - fake: fake.clone(), - history: None, - active, - settings, - runtime, - }; run_and_wait(&harness); - let sent = sent(&fake); - assert!( - sent.first() - .is_some_and(|first| first.starts_with("USE ") && first.contains("analytics")), - "{sent:?}" + assert_eq!(harness.fake.moves(), ["analytics"]); + assert_eq!( + sent(&harness.fake), + ["SELECT 1"], + "the move is the session's, not a statement" + ); + assert_eq!(harness.editor.tab_database().as_deref(), Some("analytics")); + + pick_database(&harness, "shop"); + run_and_wait(&harness); + assert_eq!( + harness.fake.moves(), + ["analytics", "shop"], + "back to the connection's own" ); - assert_eq!(sent.last().map(String::as_str), Some("SELECT 1")); + assert_eq!(harness.editor.tab_database(), None); +} + +/// A database the server will not move to leaves the tab where its +/// session is, and the run says why. +#[gtk4::test] +fn a_refused_move_leaves_the_tab_where_its_session_is() { + let harness = databases_editor(answering(), false); + harness + .fake + .refuse_moves(DriverError::server("Access denied to database 'analytics'")); + + pick_database(&harness, "analytics"); + run_and_wait(&harness); + + assert!(sent(&harness.fake).is_empty(), "the script ran in the wrong database"); + assert_eq!(harness.editor.imp().scope_button_label.label(), "shop"); + let log = harness.editor.imp().log.borrow(); + let said = log.as_ref().and_then(|log| log.first_failure()).unwrap_or_default(); + assert!(said.contains("analytics") && said.contains("Access denied"), "{said}"); +} + +/// A `USE` is no write, so a read-only connection moves too. +#[gtk4::test] +fn a_read_only_connection_offers_its_databases() { + let harness = databases_editor(answering(), true); + + pick_database(&harness, "analytics"); + run_and_wait(&harness); + + assert_eq!(harness.fake.moves(), ["analytics"]); +} + +/// A tab never leaves its transaction behind in another database. +#[gtk4::test] +fn a_tab_holding_a_transaction_stays_where_it_is() { + let harness = databases_editor(transaction_runs(), false); + run_and_wait(&harness); + let action = imp_action(&harness.editor, "database").expect("the action"); + + assert!(!action.is_enabled(), "the tab could leave its transaction"); } diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index 200a2f17ad..0bfa005a70 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -283,6 +283,7 @@ pub enum ClosedTabDescriptor { Editor { query: String, file: Option, + database: Option, }, Table { schema: Option, @@ -499,6 +500,8 @@ pub(crate) enum WorkspaceInput { /// An editor tab was saved, opened a file, or its text came to /// differ from its file's or match it again. EditorTabFileChanged(Uuid), + /// An editor tab moved to another database, which its record keeps. + EditorTabDatabaseChanged, /// The session's saved tabs and their editor text, read off the GTK /// thread. WorkspaceLoaded(workspace_tabs::LoadedWorkspace), @@ -1263,6 +1266,7 @@ impl WorkspaceModel { } WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), WorkspaceInput::EditorTabFileChanged(id) => self.on_editor_tab_file_changed(id), + WorkspaceInput::EditorTabDatabaseChanged => self.persist_workspace_state(), WorkspaceInput::WorkspaceLoaded(loaded) => self.on_workspace_loaded(loaded), WorkspaceInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), WorkspaceInput::SafeModeChanged => self.refresh_title(), diff --git a/linux/crates/app/src/ui/workspace/tab_factory.rs b/linux/crates/app/src/ui/workspace/tab_factory.rs index 533ae82559..49a33e49fb 100644 --- a/linux/crates/app/src/ui/workspace/tab_factory.rs +++ b/linux/crates/app/src/ui/workspace/tab_factory.rs @@ -496,6 +496,8 @@ pub(super) fn connect_editor( editor.connect_content_changed(move |_| content.deliver(WorkspaceInput::EditorTabContentChanged(tab_id))); let file = input.clone(); editor.connect_file_changed(move |_| file.deliver(WorkspaceInput::EditorTabFileChanged(tab_id))); + let database = input.clone(); + editor.connect_database_changed(move |_| database.deliver(WorkspaceInput::EditorTabDatabaseChanged)); let transaction = input.clone(); editor.connect_transaction_changed(move |_, _| transaction.deliver(WorkspaceInput::EditorTransactionChanged)); let toast = input.clone(); diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index 6fa5140a74..4b2f884211 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -664,6 +664,7 @@ fn describe_closed_tab(slot: &WorkspaceTab) -> Option { WorkspaceTab::Editor(s) => Some(ClosedTabDescriptor::Editor { query: whole_text(&s.editor.buffer()), file: editor_file_record(&s.editor), + database: s.editor.tab_database(), }), WorkspaceTab::Table(s) => Some(ClosedTabDescriptor::Table { schema: s.schema.clone(), @@ -695,13 +696,13 @@ impl WorkspaceModel { /// Open again a tab this session closed, which the shell kept. pub(super) fn on_reopen_closed_tab(&mut self, descriptor: ClosedTabDescriptor) { match descriptor { - ClosedTabDescriptor::Editor { query, file } => { + ClosedTabDescriptor::Editor { query, file, database } => { let initial = if query.is_empty() { None } else { Some(query) }; self.open_editor(TabSpec::Editor { initial_query: initial, draft: None, file, - database: None, + database, }); } ClosedTabDescriptor::Table { diff --git a/linux/crates/core/src/connection.rs b/linux/crates/core/src/connection.rs index c16484fab6..43ea64fd27 100644 --- a/linux/crates/core/src/connection.rs +++ b/linux/crates/core/src/connection.rs @@ -103,9 +103,8 @@ pub trait Connection: Send + Sync { }) } - /// The server's databases a query tab can switch to, the connection's - /// own marked as the default, and the engine's own marked as system. - /// An engine whose connection is to one database keeps this default. + /// The server's databases the login can open, the connection's own + /// marked as the default and the engine's own as system. async fn list_databases(&self, _options: CallOptions) -> Result, DriverError> { Err(DriverError::Unsupported { feature: UnsupportedFeature::DatabaseSwitching, diff --git a/linux/crates/core/src/editor_session.rs b/linux/crates/core/src/editor_session.rs index 5e29af3596..ea2d6d1662 100644 --- a/linux/crates/core/src/editor_session.rs +++ b/linux/crates/core/src/editor_session.rs @@ -78,5 +78,14 @@ pub trait EditorSession: Send + Sync { None } + /// Move the session to the server's database `database`, keeping + /// what it holds. Unsupported where the engine binds a session's + /// database when it connects. + async fn use_database(&self, _database: &str, _options: CallOptions) -> Result<(), DriverError> { + Err(DriverError::Unsupported { + feature: crate::UnsupportedFeature::DatabaseSwitching, + }) + } + async fn close(&self); } diff --git a/linux/crates/drivers/mysql/src/dialect.rs b/linux/crates/drivers/mysql/src/dialect.rs index df5d173259..57f67107ee 100644 --- a/linux/crates/drivers/mysql/src/dialect.rs +++ b/linux/crates/drivers/mysql/src/dialect.rs @@ -60,6 +60,10 @@ impl MySqlDialect { } } +pub(crate) fn quote(name: &str) -> String { + format!("`{}`", name.replace('`', "``")) +} + impl SqlDialect for MySqlDialect { fn database_layout(&self) -> DatabaseLayout { DatabaseLayout::AsSchemas @@ -79,7 +83,7 @@ impl SqlDialect for MySqlDialect { } fn quote_identifier(&self, name: &str) -> String { - format!("`{}`", name.replace('`', "``")) + quote(name) } fn placeholder(&self, _ordinal: usize, value: Value, target: BindTarget<'_>) -> Result { diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 13d86f79cd..293c7c2d38 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -16,6 +16,7 @@ use tokio_util::sync::CancellationToken; use crate::cancel::ControlLink; use crate::decode::decode_row; +use crate::dialect::quote; use crate::editor_run::{RunOutcome, run_script}; use crate::errors::map_error; use crate::flavour::MySqlFlavour; @@ -347,6 +348,51 @@ impl EditorSession for MysqlEditorSession { .clone() } + /// `USE` carries any name: a backtick in it is doubled. + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let shared = &self.shared; + let stopped = || match options.cancel().is_cancelled() { + true => DriverError::Cancelled, + false => DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }, + }; + let mut guard = tokio::select! { + biased; + () = options.cancel().cancelled() => return Err(stopped()), + () = elapsed(options.deadline()) => return Err(stopped()), + guard = shared.held.lock() => guard, + }; + let Some(held) = guard.as_mut().filter(|_| !shared.lost.load(Ordering::SeqCst)) else { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + }; + let statement = format!("USE {}", quote(database)); + let using = held.connection.execute(sqlx::raw_sql(AssertSqlSafe(statement))); + let used = tokio::select! { + biased; + () = options.cancel().cancelled() => None, + () = elapsed(options.deadline()) => None, + used = using => Some(used), + }; + match used { + Some(Ok(_)) => { + *shared.database.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); + Ok(()) + } + Some(Err(error)) => Err(map_error(&error, shared.flavour)), + // The answer is still on the wire, so the connection is in no + // state anything knows. + None => { + shared.lost.store(true, Ordering::SeqCst); + *guard = None; + Err(stopped()) + } + } + } + async fn transaction_state(&self, options: CallOptions) -> Result { let shared = &self.shared; let asking = async { diff --git a/linux/crates/drivers/mysql/src/metadata.rs b/linux/crates/drivers/mysql/src/metadata.rs index 0899312eb0..ca91863308 100644 --- a/linux/crates/drivers/mysql/src/metadata.rs +++ b/linux/crates/drivers/mysql/src/metadata.rs @@ -113,7 +113,7 @@ pub(crate) async fn list_databases( }; databases.push(SchemaInfo { is_default: current.as_deref() == Some(name.as_str()), - is_system: SYSTEM_DATABASES.iter().any(|system| name.eq_ignore_ascii_case(system)), + is_system: SYSTEM_DATABASES.contains(&name.as_str()), name, }); } diff --git a/linux/crates/test-support/src/fake_editor_session.rs b/linux/crates/test-support/src/fake_editor_session.rs index 1dc4ddfee8..9f0235f719 100644 --- a/linux/crates/test-support/src/fake_editor_session.rs +++ b/linux/crates/test-support/src/fake_editor_session.rs @@ -26,6 +26,8 @@ pub struct FakeEditorSession { state: Mutex, transaction_state: Mutex, database: Mutex>, + moves: Mutex>, + refused_moves: Mutex>, close_behaviour: CloseBehaviour, closes: AtomicUsize, closed: AtomicBool, @@ -46,6 +48,8 @@ impl FakeEditorSession { state: Mutex::new(EditorSessionState::Ready), transaction_state: Mutex::new(TransactionState::Idle), database: Mutex::new(None), + moves: Mutex::new(Vec::new()), + refused_moves: Mutex::new(None), close_behaviour, closes: AtomicUsize::new(0), closed: AtomicBool::new(false), @@ -82,6 +86,16 @@ impl FakeEditorSession { *lock(&self.database) = database.map(str::to_owned); } + /// The databases `use_database` moved the session to, in order. + pub fn moves(&self) -> Vec { + lock(&self.moves).clone() + } + + /// Every later `use_database` fails with `error`. + pub fn refuse_moves(&self, error: DriverError) { + *lock(&self.refused_moves) = Some(error); + } + /// The connection goes while no run is on it, as when the server /// ends a session left idle in a transaction. pub fn lose(&self) { @@ -199,6 +213,15 @@ impl EditorSession for FakeEditorSession { lock(&self.database).clone() } + async fn use_database(&self, database: &str, _options: CallOptions) -> Result<(), DriverError> { + if let Some(error) = lock(&self.refused_moves).clone() { + return Err(error); + } + lock(&self.moves).push(database.to_owned()); + *lock(&self.database) = Some(database.to_owned()); + Ok(()) + } + async fn close(&self) { let first = self.closes.fetch_add(1, Ordering::SeqCst) == 0; self.closing.cancel(); From c86f693e50cfaac2579ed52882eb4279c7814f14 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:30:24 +0700 Subject: [PATCH 10/23] fix(linux-editor): tell the workspace when a USE moves the tab --- linux/crates/app/src/ui/editor/imp.rs | 5 ++++- linux/crates/app/src/ui/editor/tab_scope.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index c43482b723..00a1988c56 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -64,6 +64,8 @@ pub struct SqlEditor { /// This tab's own connection, which keeps its transaction and /// temporary tables between runs. pub slot: Arc, + /// The database the bar names, to tell the workspace when it changes. + pub named_database: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. @@ -140,7 +142,8 @@ impl ObjectImpl for SqlEditor { .param_types([bool::static_type()]) .build(), Signal::builder("file-changed").build(), - // The tab moved to another database. + // The tab is in another database: picked from the bar, or + // where a `USE` took its session. Signal::builder("database-changed").build(), // Vim's :q, which closes the tab through its close prompts. Signal::builder("close-requested").build(), diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 1e6b70c3f5..83c9bbf88b 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -82,6 +82,10 @@ impl SqlEditor { }; self.set_scope(Some(scope_text(&database, None))); self.follow_in_completion(catalog_home(layout, session.database(), &database)); + let moved = self.imp().named_database.replace(Some(database.clone())) != Some(database.clone()); + if moved { + self.emit_by_name::<()>("database-changed", &[]); + } if let Some(action) = super::imp_action(self, MOVE_ACTION) { action.set_state(&database.to_variant()); } @@ -177,7 +181,6 @@ impl SqlEditor { } self.imp().slot.set_database(database.to_owned()); self.show_scope(); - self.emit_by_name::<()>("database-changed", &[]); } /// The database a restore puts the tab back in: `None` while it is From 0c0e7c268fa1171b4cbf0d61d84d452fcf6a3f31 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:34:35 +0700 Subject: [PATCH 11/23] fix(linux-editor): move SQL Server, ClickHouse, Cassandra and MongoDB tabs with each driver's own means --- linux/changelog.d/3898.fixed.md | 1 + .../core/src/sql_syntax/catalog_change.rs | 26 ++++ linux/crates/core/src/sql_syntax/mod.rs | 2 +- linux/crates/drivers/cassandra/src/editor.rs | 25 +++- linux/crates/drivers/cassandra/src/errors.rs | 2 +- .../drivers/clickhouse/src/editor_session.rs | 115 ++++++++++++++++-- .../drivers/mongodb/src/editor_session.rs | 11 ++ linux/crates/drivers/mssql/src/dialect.rs | 6 +- linux/crates/drivers/mssql/src/editor_run.rs | 9 ++ .../drivers/mssql/src/editor_session.rs | 51 +++++++- linux/crates/drivers/mssql/src/metadata.rs | 8 +- .../mssql/tests/integration/sessions.rs | 26 ++++ 12 files changed, 264 insertions(+), 18 deletions(-) create mode 100644 linux/changelog.d/3898.fixed.md diff --git a/linux/changelog.d/3898.fixed.md b/linux/changelog.d/3898.fixed.md new file mode 100644 index 0000000000..bdbc531689 --- /dev/null +++ b/linux/changelog.d/3898.fixed.md @@ -0,0 +1 @@ +On ClickHouse, a USE in the SQL editor now applies to the statements that follow it. diff --git a/linux/crates/core/src/sql_syntax/catalog_change.rs b/linux/crates/core/src/sql_syntax/catalog_change.rs index 2fc62739eb..8154324b4c 100644 --- a/linux/crates/core/src/sql_syntax/catalog_change.rs +++ b/linux/crates/core/src/sql_syntax/catalog_change.rs @@ -39,6 +39,22 @@ pub fn may_change_database(text: &str, grammar: SqlGrammar) -> bool { .any(|token| token.is_word("USE")) } +/// The database the last `USE ` in `text` names, for an engine +/// whose client, not its server, keeps the database a request runs in. A +/// name that would need unescaping is left out. +pub fn used_database(text: &str, grammar: SqlGrammar) -> Option { + lex_statements(text, grammar) + .statements + .iter() + .rev() + .find_map(|statement| match statement.as_slice() { + [first, name] if first.is_word("USE") => name.name(), + _ => None, + }) + .filter(|name| !name.is_empty() && !name.contains(['`', '"', '\\'])) + .map(str::to_owned) +} + fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { let is = |word: &str, set: &[&str]| set.iter().any(|verb| word.eq_ignore_ascii_case(verb)); plan.statements().iter().any(|statement| { @@ -60,6 +76,16 @@ fn plan_changes(text: &str, plan: &ScriptPlan) -> bool { mod tests { use super::*; + #[test] + fn a_use_names_its_database() { + let grammar = SqlGrammar::ClickHouse; + assert_eq!(used_database("USE analytics", grammar).as_deref(), Some("analytics")); + assert_eq!(used_database("use `my db`;", grammar).as_deref(), Some("my db")); + assert_eq!(used_database("USE a; USE b", grammar).as_deref(), Some("b")); + assert_eq!(used_database("SELECT 1", grammar), None); + assert_eq!(used_database("USE `a``b`", grammar), None, "a name to unescape"); + } + #[test] fn a_use_can_change_the_database() { for grammar in SqlGrammar::SQL { diff --git a/linux/crates/core/src/sql_syntax/mod.rs b/linux/crates/core/src/sql_syntax/mod.rs index 2952c3fd57..42693f6f0c 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -16,7 +16,7 @@ mod statement_access; mod statement_tokens; pub use builtin_snippet::{BuiltinSnippet, builtin_snippets}; -pub use catalog_change::{changes_catalog, may_change_database}; +pub use catalog_change::{changes_catalog, may_change_database, used_database}; pub use credential_rules::CredentialRules; pub use errors::{ExpressionSyntaxError, TypeSyntaxError}; pub use grammar::SqlGrammar; diff --git a/linux/crates/drivers/cassandra/src/editor.rs b/linux/crates/drivers/cassandra/src/editor.rs index 97da762cd6..a35c20c3d1 100644 --- a/linux/crates/drivers/cassandra/src/editor.rs +++ b/linux/crates/drivers/cassandra/src/editor.rs @@ -21,7 +21,7 @@ use tablepro_core::{ use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::errors::execution_error; +use crate::errors::{execution_error, use_keyspace_error}; use crate::rows::{ColumnsRead, MAX_PAGE_ROWS, next_state, page_size}; use crate::session::{CqlLink, LinkSlot}; @@ -80,6 +80,22 @@ impl EditorSession for CqlEditorSession { link.session().get_keyspace().map(|keyspace| keyspace.to_string()) } + /// The driver checks the name, which CQL takes as letters, digits and + /// `_` alone, and moves every connection of the session. + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let link = self.link.get()?; + let using = link.session().use_keyspace(database, true); + tokio::select! { + biased; + () = options.cancel().cancelled() => Err(DriverError::Cancelled), + () = until(options.deadline()) => Err(DriverError::Timeout { + phase: tablepro_core::TimeoutPhase::Statement, + server_cancelled: false, + }), + used = using => used.map_err(|error| use_keyspace_error(error, LossPhase::Statement)), + } + } + fn state(&self) -> EditorSessionState { match self.state.load(Ordering::SeqCst) { READY => EditorSessionState::Ready, @@ -375,3 +391,10 @@ fn lost(error: &DriverError) -> bool { ErrorCategory::Network | ErrorCategory::ConnectionLost | ErrorCategory::Tls ) } + +async fn until(deadline: Option) { + match deadline { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} diff --git a/linux/crates/drivers/cassandra/src/errors.rs b/linux/crates/drivers/cassandra/src/errors.rs index 0750d4f0b6..1098400562 100644 --- a/linux/crates/drivers/cassandra/src/errors.rs +++ b/linux/crates/drivers/cassandra/src/errors.rs @@ -88,7 +88,7 @@ fn pool_error(error: ConnectionPoolError, during: LossPhase) -> DriverError { } } -fn use_keyspace_error(error: UseKeyspaceError, during: LossPhase) -> DriverError { +pub(crate) fn use_keyspace_error(error: UseKeyspaceError, during: LossPhase) -> DriverError { match error { UseKeyspaceError::RequestError(attempt) => attempt_error(attempt, during), other => DriverError::server(other.to_string()), diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 4d00aacd94..8a566e9c29 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -1,10 +1,10 @@ -use std::sync::Arc; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; use tablepro_core::{ CallOptions, DriverError, EditorSession, EditorSessionId, EditorSessionOptions, EditorSessionState, LossPhase, - ResultSink, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, + ResultSink, RunEnd, RunOptions, RunSummary, TimeoutPhase, TransactionState, UserSql, }; use tokio_util::sync::CancellationToken; use uuid::Uuid; @@ -24,7 +24,9 @@ use crate::session_status::SessionStatus; /// takes no scripts. pub(crate) struct ClickHouseEditorSession { id: EditorSessionId, - session: clickhouse::Client, + /// Every request names its database, which overrides a `USE` the + /// session ran, so the tab's database lives in the client. + session: Arc>, control: clickhouse::Client, statement_timeout: Option, grace: Duration, @@ -34,6 +36,10 @@ pub(crate) struct ClickHouseEditorSession { } impl ClickHouseEditorSession { + fn client(&self) -> clickhouse::Client { + self.session.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + /// Open the session with one trivial question, so a session the /// server will not create fails here rather than on the first run. pub(crate) async fn open( @@ -59,7 +65,7 @@ impl ClickHouseEditorSession { id: EditorSessionId::new(), // Without the check, a run on a session the server let expire // quietly starts a new, empty one under the same id. - session: session.with_setting("session_check", "1"), + session: Arc::new(Mutex::new(session.with_setting("session_check", "1"))), control: endpoint.control().clone(), statement_timeout: options.statement_timeout, grace: liveness.cancel_grace, @@ -76,10 +82,26 @@ impl EditorSession for ClickHouseEditorSession { self.id } - /// Every request names the database, which a `USE` in the session - /// does not override. fn database(&self) -> Option { - self.session.database().map(str::to_owned) + self.client().database().map(str::to_owned) + } + + /// The next request names `database`, once a request there answers. + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let moved = self.client().with_database(database); + let asking = drain(moved.query_raw("SELECT 1")); + let answered = tokio::select! { + biased; + () = options.cancel().cancelled() => return Err(DriverError::Cancelled), + () = until(options.deadline()) => return Err(DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }), + answered = asking => answered, + }; + answered?; + *self.session.lock().unwrap_or_else(PoisonError::into_inner) = moved; + Ok(()) } fn state(&self) -> EditorSessionState { @@ -97,17 +119,27 @@ impl EditorSession for ClickHouseEditorSession { ) -> Result { self.status.begin()?; let guard = options.cancel.clone().drop_guard(); + let used = + tablepro_core::sql_syntax::used_database(sql.as_str(), tablepro_core::sql_syntax::SqlGrammar::ClickHouse); let run = EditorRun { - session: self.session.clone(), + session: self.client(), control: self.control.clone(), statement_timeout: self.statement_timeout, grace: self.grace, shutdown: self.shutdown.clone(), lingering: self.lingering.clone(), }; - let status = self.status.clone(); + let (status, client) = (self.status.clone(), self.session.clone()); let running = tokio::spawn(async move { let outcome = run.run(sql, options, sink).await; + // The server took the `USE`; the requests after it name that + // database, as the session would have. + if let (Ok(summary), Some(database)) = (&outcome, used) + && summary.end == RunEnd::Completed + { + let mut client = client.lock().unwrap_or_else(PoisonError::into_inner); + *client = client.clone().with_database(database); + } match &outcome { // The server still held the session for a stopped query: // the session itself is fine. @@ -143,6 +175,13 @@ impl EditorSession for ClickHouseEditorSession { } } +async fn until(deadline: Option) { + match deadline { + Some(at) => tokio::time::sleep_until(at).await, + None => std::future::pending().await, + } +} + #[cfg(test)] mod tests { use async_trait::async_trait; @@ -171,6 +210,64 @@ mod tests { .await } + struct Collect(Arc>>>); + + #[async_trait] + impl ResultSink for Collect { + async fn accept(&mut self, event: ResultEvent) -> SinkFlow { + if let ResultEvent::Rows(rows) = event { + self.0.lock().unwrap_or_else(PoisonError::into_inner).extend(rows); + } + SinkFlow::Continue + } + } + + async fn current_database(session: &ClickHouseEditorSession) -> Vec> { + let rows = Arc::new(Mutex::new(Vec::new())); + session + .run( + UserSql::new("SELECT currentDatabase()"), + RunOptions::new(CancellationToken::new()), + Box::new(Collect(rows.clone())), + ) + .await + .expect("a run"); + let rows = rows.lock().unwrap_or_else(PoisonError::into_inner).clone(); + rows + } + + /// Every request names its database, so a `USE` lasts only because + /// the session's next requests name the database it chose. + #[tokio::test] + #[ignore = "requires docker"] + async fn a_session_runs_in_the_database_it_moved_to() { + let fixture = ClickHouseFixture::start().await.expect("a ClickHouse container"); + let endpoint = ClickHouseEndpoint::for_fixture(&fixture, test_liveness()); + let session = + ClickHouseEditorSession::open(&endpoint, EditorSessionOptions::default(), CancellationToken::new()) + .await + .expect("an editor session"); + let text = |name: &str| vec![vec![tablepro_core::Value::Text(name.to_owned())]]; + + session + .use_database("system", CallOptions::new(None, CancellationToken::new())) + .await + .expect("a move to system"); + assert_eq!(current_database(&session).await, text("system")); + assert_eq!(session.database().as_deref(), Some("system")); + + let used = run(&session, "USE default").await; + assert_eq!(used.map(|summary| summary.end), Ok(RunEnd::Completed)); + assert_eq!(current_database(&session).await, text("default")); + assert_eq!(session.database().as_deref(), Some("default")); + + let refused = session + .use_database("no_such_database", CallOptions::new(None, CancellationToken::new())) + .await; + assert!(refused.is_err(), "{refused:?}"); + assert_eq!(session.database().as_deref(), Some("default"), "a refused move moved"); + } + #[tokio::test] #[ignore = "requires docker"] async fn a_session_the_server_let_expire_is_lost_not_replaced() { diff --git a/linux/crates/drivers/mongodb/src/editor_session.rs b/linux/crates/drivers/mongodb/src/editor_session.rs index be9f35e515..121b1f6026 100644 --- a/linux/crates/drivers/mongodb/src/editor_session.rs +++ b/linux/crates/drivers/mongodb/src/editor_session.rs @@ -68,6 +68,17 @@ impl EditorSession for MongoEditorSession { Some(self.database.lock().unwrap_or_else(PoisonError::into_inner).clone()) } + /// As the shell's `use`: the next statements name the database. + async fn use_database(&self, database: &str, _options: CallOptions) -> Result<(), DriverError> { + if self.status.state() == EditorSessionState::Lost { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + } + *self.database.lock().unwrap_or_else(PoisonError::into_inner) = database.to_owned(); + Ok(()) + } + fn state(&self) -> EditorSessionState { self.status.state() } diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index b36f450c87..2e2b2a4660 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -41,6 +41,10 @@ impl MssqlDialect { } } +pub(crate) fn quote(name: &str) -> String { + format!("[{}]", name.replace(']', "]]")) +} + impl SqlDialect for MssqlDialect { fn database_layout(&self) -> DatabaseLayout { DatabaseLayout::WithSchemas @@ -60,7 +64,7 @@ impl SqlDialect for MssqlDialect { } fn quote_identifier(&self, name: &str) -> String { - format!("[{}]", name.replace(']', "]]")) + quote(name) } fn placeholder(&self, ordinal: usize, value: Value, target: BindTarget<'_>) -> Result { diff --git a/linux/crates/drivers/mssql/src/editor_run.rs b/linux/crates/drivers/mssql/src/editor_run.rs index cbcaf2f7c1..6e7a9df7de 100644 --- a/linux/crates/drivers/mssql/src/editor_run.rs +++ b/linux/crates/drivers/mssql/src/editor_run.rs @@ -247,6 +247,15 @@ pub(crate) async fn transaction_state(client: &mut MssqlClient) -> Result Result<(), DriverError> { + let statement = format!("USE {}", crate::dialect::quote(database)); + let stream = client.simple_query(statement).await.map_err(map_error)?; + stream.into_results().await.map_err(map_error)?; + Ok(()) +} + /// The database the session is in, from the server itself. pub(crate) async fn current_database(client: &mut MssqlClient) -> Result, DriverError> { let stream = client.simple_query("SELECT DB_NAME()").await.map_err(map_error)?; diff --git a/linux/crates/drivers/mssql/src/editor_session.rs b/linux/crates/drivers/mssql/src/editor_session.rs index 372ffaffd9..0e6f63ffe7 100644 --- a/linux/crates/drivers/mssql/src/editor_session.rs +++ b/linux/crates/drivers/mssql/src/editor_session.rs @@ -12,7 +12,7 @@ use tokio::sync::OwnedMutexGuard; use tokio::time::Instant; use tokio_util::sync::CancellationToken; -use crate::editor_run::{EditorRun, ScriptOutcome, current_database, transaction_state}; +use crate::editor_run::{EditorRun, ScriptOutcome, current_database, transaction_state, use_database}; use crate::endpoint::MssqlEndpoint; use crate::errors::ends_connection; use crate::in_band_request::StopPolicy; @@ -247,6 +247,55 @@ impl EditorSession for MssqlEditorSession { outcome } + async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { + let shared = &self.shared; + let mut held = tokio::select! { + biased; + () = options.cancel().cancelled() => return Err(DriverError::Cancelled), + () = elapsed(options.deadline()) => { + return Err(DriverError::Timeout { phase: TimeoutPhase::Statement, server_cancelled: false }); + } + held = shared.link.lock() => held, + }; + let Some(link) = held.as_mut().filter(|_| !shared.lost.load(Ordering::SeqCst)) else { + return Err(DriverError::ConnectionLost { + during: LossPhase::Idle, + }); + }; + let using = use_database(&mut link.client, database); + let used = tokio::select! { + biased; + () = options.cancel().cancelled() => None, + () = elapsed(options.deadline()) => None, + used = using => Some(used), + }; + match used { + Some(Ok(())) => { + *shared.database.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); + Ok(()) + } + Some(Err(error)) => { + if ends_connection(&error) { + shared.lost.store(true, Ordering::SeqCst); + *held = None; + } + Err(error) + } + // Cut off mid-request, the connection is out of step. + None => { + shared.lost.store(true, Ordering::SeqCst); + *held = None; + Err(match options.cancel().is_cancelled() { + true => DriverError::Cancelled, + false => DriverError::Timeout { + phase: TimeoutPhase::Statement, + server_cancelled: false, + }, + }) + } + } + } + async fn transaction_state(&self, options: CallOptions) -> Result { let shared = &self.shared; let mut held = tokio::select! { diff --git a/linux/crates/drivers/mssql/src/metadata.rs b/linux/crates/drivers/mssql/src/metadata.rs index 08d452420d..f098234f89 100644 --- a/linux/crates/drivers/mssql/src/metadata.rs +++ b/linux/crates/drivers/mssql/src/metadata.rs @@ -24,13 +24,13 @@ const SCHEMAS_SQL: &str = "SELECT s.name, \ THEN 1 ELSE 0 END AS int) \ FROM sys.schemas s ORDER BY s.name"; -/// The databases the login may open, the connection's own the default -/// and the four the server keeps for itself (`database_id` 1 to 4) as -/// system ones. +/// The online databases the login may open, the connection's own the +/// default and the four the server keeps for itself (`database_id` 1 to +/// 4) as system ones. const DATABASES_SQL: &str = "SELECT d.name, \ CAST(CASE WHEN d.name = DB_NAME() THEN 1 ELSE 0 END AS int), \ CAST(CASE WHEN d.database_id <= 4 THEN 1 ELSE 0 END AS int) \ - FROM sys.databases d WHERE HAS_DBACCESS(d.name) = 1 ORDER BY d.name"; + FROM sys.databases d WHERE d.state = 0 AND HAS_DBACCESS(d.name) = 1 ORDER BY d.name"; const OBJECTS_SQL: &str = "SELECT SCHEMA_NAME(o.schema_id), o.name, RTRIM(o.type) \ FROM sys.objects o \ diff --git a/linux/crates/drivers/mssql/tests/integration/sessions.rs b/linux/crates/drivers/mssql/tests/integration/sessions.rs index ae1e0008a0..4364a74859 100644 --- a/linux/crates/drivers/mssql/tests/integration/sessions.rs +++ b/linux/crates/drivers/mssql/tests/integration/sessions.rs @@ -24,6 +24,32 @@ async fn use_database_persists_across_runs() { ); } +/// The editor bar's move: a bracketed name carries a `]`, and a database +/// the server refuses leaves the session where it was. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_session_moves_to_a_database_by_its_name() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &["IF DB_ID(N'odd]name') IS NULL CREATE DATABASE [odd]]name]"], + ) + .await; + let session = session(connection.as_ref()).await; + let options = || tablepro_core::CallOptions::new(None, CancellationToken::new()); + + session.use_database("odd]name", options()).await.expect("a move"); + let database = run_ok(session.as_ref(), "SELECT DB_NAME()").await.rows(); + assert_eq!(database, vec![vec![Value::Text("odd]name".to_owned())]]); + assert_eq!(session.database().as_deref(), Some("odd]name")); + + let refused = session.use_database("no_such_database", options()).await; + assert!(refused.is_err(), "{refused:?}"); + let database = run_ok(session.as_ref(), "SELECT DB_NAME()").await.rows(); + assert_eq!(database, vec![vec![Value::Text("odd]name".to_owned())]]); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn begin_tran_insert_big_select_commit_persists() { From cbe0a1105f705f82a932b4f56046fea11214f5a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:54:49 +0700 Subject: [PATCH 12/23] fix(linux-editor): end a session whose database after a USE the server did not say --- .../drivers/mssql/src/editor_session.rs | 5 ++-- .../drivers/mysql/src/editor_session.rs | 24 ++++++++++++------- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/linux/crates/drivers/mssql/src/editor_session.rs b/linux/crates/drivers/mssql/src/editor_session.rs index 372ffaffd9..d7145794de 100644 --- a/linux/crates/drivers/mssql/src/editor_session.rs +++ b/linux/crates/drivers/mssql/src/editor_session.rs @@ -184,8 +184,9 @@ impl Shared { if follow_database { match tokio::time::timeout(self.cancel_grace, current_database(&mut link.client)).await { Ok(Ok(current)) => *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current, - Ok(Err(error)) => return (TransactionState::Unknown, !ends_connection(&error)), - Err(_) => return (TransactionState::Unknown, false), + // A session in a database nothing knows ends rather than + // run the next script in the wrong one. + Ok(Err(_)) | Err(_) => return (TransactionState::Unknown, false), } } match tokio::time::timeout(self.cancel_grace, transaction_state(&mut link.client)).await { diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 13d86f79cd..d2a5587a7a 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -131,11 +131,16 @@ impl Shared { let started = Instant::now(); self.running.store(true, Ordering::SeqCst); let supervised = self.supervise(held, &sql, &options, sink.as_mut()).await; + // A session in a database nothing knows would run the next script + // in the wrong one, so it ends instead. + let placed = match &supervised { + Ok((_, true)) if may_change_database(sql.as_str(), SqlGrammar::MySql) => { + self.follow_database(&mut held.connection).await + } + _ => true, + }; let transaction_state = match &supervised { - Ok((_, true)) => { - if may_change_database(sql.as_str(), SqlGrammar::MySql) { - self.follow_database(&mut held.connection).await; - } + Ok((_, true)) if placed => { let asking = self.probe.state(&mut held.connection); tokio::time::timeout(self.liveness.cancel_grace, asking) .await @@ -145,8 +150,8 @@ impl Shared { }; self.running.store(false, Ordering::SeqCst); let end = match supervised { - Ok((end, true)) => end, - Ok((end, false)) => { + Ok((end, true)) if placed => end, + Ok((end, _)) => { self.lost.store(true, Ordering::SeqCst); *guard = None; end @@ -164,16 +169,19 @@ impl Shared { }) } - async fn follow_database(&self, connection: &mut MySqlConnection) { + /// Ask where a `USE` left the session. False when the answer did not + /// come. + async fn follow_database(&self, connection: &mut MySqlConnection) -> bool { let asking = connection.fetch_one(sqlx::raw_sql("SELECT DATABASE()")); let Ok(Ok(row)) = tokio::time::timeout(self.liveness.cancel_grace, asking).await else { - return; + return false; }; let current = match decode_row(&row, MySqlWireFormat::Text, MySqlTimestampMode::SessionLocal).first() { Some(Value::Text(name)) => Some(name.clone()), _ => None, }; *self.database.lock().unwrap_or_else(PoisonError::into_inner) = current; + true } /// Run the script, stopping it on a cancel, at the deadline, when From 3738fe25385f90250e67763af2ec16f11fe700a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:55:17 +0700 Subject: [PATCH 13/23] fix(linux-editor): end a run only once the tab knows the database it left the session in --- linux/crates/app/src/ui/editor/script_runner.rs | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs index d3901a8c16..4d0d1356cd 100644 --- a/linux/crates/app/src/ui/editor/script_runner.rs +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -126,7 +126,8 @@ pub struct AfterRun { } /// Send each batch in turn, reporting each one as it ends. `transaction` -/// is where the session's transaction stood before the run. +/// is where the session's transaction stood before the run. The caller +/// says `Finished` once the tab knows where the run left it. pub async fn run_batches( editor: &Arc, run: &ScriptRun, @@ -201,7 +202,6 @@ pub async fn run_batches( } } } - let _ = events.send(RunEvent::Finished).await; after } @@ -261,12 +261,15 @@ pub async fn run_in_tab( slot.follow(&editor); if editor.state() != EditorSessionState::Lost { slot.note_transaction(&editor, after.transaction); + let _ = events.send(RunEvent::Finished).await; return; } let generation = *session.link_changes().borrow(); session.signal(MonitorSignal::ProbeRequested { generation }); let refuse_next = transaction_open(after.transaction) && !after.rollback_told; - if slot.forget(&editor, refuse_next) { + let forgotten = slot.forget(&editor, refuse_next); + let _ = events.send(RunEvent::Finished).await; + if forgotten { close_quietly(editor, close_timeout).await; } } @@ -399,7 +402,11 @@ mod tests { } log }; - let (_, log) = tokio::join!(run_batches(&session, run, cancel, &events, None), collect); + let running = async { + run_batches(&session, run, cancel, &events, None).await; + let _ = events.send(RunEvent::Finished).await; + }; + let (_, log) = tokio::join!(running, collect); log } From 755a489653abc749aab162439b09906843c71b14 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 17:56:17 +0700 Subject: [PATCH 14/23] fix(linux-editor): report only the keyspace Cassandra confirmed, and end a session whose move did not finish --- linux/crates/drivers/cassandra/src/editor.rs | 37 ++++++++++++++++---- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/linux/crates/drivers/cassandra/src/editor.rs b/linux/crates/drivers/cassandra/src/editor.rs index a35c20c3d1..84c6351594 100644 --- a/linux/crates/drivers/cassandra/src/editor.rs +++ b/linux/crates/drivers/cassandra/src/editor.rs @@ -4,8 +4,8 @@ //! only that tab, as cqlsh moves only its own connection. CQL keeps no //! other state on a connection: no transaction, no temporary table. -use std::sync::Arc; use std::sync::atomic::{AtomicU8, Ordering}; +use std::sync::{Arc, Mutex, PoisonError}; use std::time::Duration; use async_trait::async_trait; @@ -40,12 +40,17 @@ pub(crate) struct CqlEditorSession { statement_timeout: Option, state: Arc, shutdown: CancellationToken, + /// The keyspace the server confirmed. The driver records one it is + /// still moving to, or failed to. + keyspace: Arc>>, } impl CqlEditorSession { pub(crate) fn new(link: CqlLink, read_only: bool, statement_timeout: Option) -> Self { + let keyspace = link.session().get_keyspace().map(|keyspace| keyspace.to_string()); Self { id: EditorSessionId::new(), + keyspace: Arc::new(Mutex::new(keyspace)), link: LinkSlot::new(link), read_only, statement_timeout, @@ -74,18 +79,19 @@ impl EditorSession for CqlEditorSession { self.id } - /// The driver moves the session itself when a `USE` answers. fn database(&self) -> Option { - let link = self.link.get().ok()?; - link.session().get_keyspace().map(|keyspace| keyspace.to_string()) + self.keyspace.lock().unwrap_or_else(PoisonError::into_inner).clone() } /// The driver checks the name, which CQL takes as letters, digits and - /// `_` alone, and moves every connection of the session. + /// `_` alone, and moves every connection of the session. A move that + /// does not finish leaves them in keyspaces nothing knows, so the + /// session ends. async fn use_database(&self, database: &str, options: CallOptions) -> Result<(), DriverError> { let link = self.link.get()?; + self.begin()?; let using = link.session().use_keyspace(database, true); - tokio::select! { + let used = tokio::select! { biased; () = options.cancel().cancelled() => Err(DriverError::Cancelled), () = until(options.deadline()) => Err(DriverError::Timeout { @@ -93,6 +99,18 @@ impl EditorSession for CqlEditorSession { server_cancelled: false, }), used = using => used.map_err(|error| use_keyspace_error(error, LossPhase::Statement)), + }; + match used { + Ok(()) => { + *self.keyspace.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); + self.state.store(READY, Ordering::SeqCst); + Ok(()) + } + Err(error) => { + self.state.store(LOST, Ordering::SeqCst); + self.link.release(); + Err(error) + } } } @@ -122,8 +140,15 @@ impl EditorSession for CqlEditorSession { shutdown: self.shutdown.clone(), }; let state = self.state.clone(); + let (keyspace, link) = (self.keyspace.clone(), run.link.clone()); + let follows = tablepro_core::sql_syntax::may_change_database(sql.as_str(), SqlGrammar::Cql); let running = tokio::spawn(async move { let outcome = run.run(sql, options, sink).await; + // The driver records a keyspace once the server confirms it. + if outcome.is_ok() && follows { + *keyspace.lock().unwrap_or_else(PoisonError::into_inner) = + link.session().get_keyspace().map(|keyspace| keyspace.to_string()); + } let next = match &outcome { Ok(_) => READY, Err(_) => LOST, From ad372fe357d29c8b270bc01811abcf0ffbc4cc3f Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 18:59:51 +0700 Subject: [PATCH 15/23] test(linux-mssql): pin the bracket quoting a move's USE uses --- linux/crates/drivers/mssql/src/dialect.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/linux/crates/drivers/mssql/src/dialect.rs b/linux/crates/drivers/mssql/src/dialect.rs index 2e2b2a4660..e48961435a 100644 --- a/linux/crates/drivers/mssql/src/dialect.rs +++ b/linux/crates/drivers/mssql/src/dialect.rs @@ -489,11 +489,8 @@ mod tests { } #[test] - fn a_tab_moves_with_use_and_a_bracketed_name() { - assert_eq!( - dialect().use_database("sales ]2026").as_deref(), - Some("USE [sales ]]2026]") - ); + fn a_bracket_in_a_name_is_doubled() { + assert_eq!(quote("sales ]2026"), "[sales ]]2026]"); } fn column(declared: &str) -> ColumnInfo { From 93ababb56047ae02882269e27300cf88cb09b624 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:05:51 +0700 Subject: [PATCH 16/23] fix(linux-editor): offer the databases of a connection opened without a name, and drop a MySQL session a USE lost --- linux/crates/app/src/ui/editor/imp.rs | 3 ++ linux/crates/app/src/ui/editor/tab_scope.rs | 35 ++++++++++++++----- .../drivers/mysql/src/editor_session.rs | 9 ++++- 3 files changed, 37 insertions(+), 10 deletions(-) diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 00a1988c56..3f0170cd70 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -66,6 +66,9 @@ pub struct SqlEditor { pub slot: Arc, /// The database the bar names, to tell the workspace when it changes. pub named_database: RefCell>, + /// The server's name for the connection's database, for one opened + /// without a name. + pub listed_own_database: RefCell>, pub running: RefCell>, pub log: RefCell>, /// Whether the last run left a transaction open, as last reported. diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 83c9bbf88b..81958e23ce 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -81,7 +81,8 @@ impl SqlEditor { return; }; self.set_scope(Some(scope_text(&database, None))); - self.follow_in_completion(catalog_home(layout, session.database(), &database)); + let own = self.own_database(&session); + self.follow_in_completion(catalog_home(layout, own.as_deref(), &database)); let moved = self.imp().named_database.replace(Some(database.clone())) != Some(database.clone()); if moved { self.emit_by_name::<()>("database-changed", &[]); @@ -90,7 +91,7 @@ impl SqlEditor { action.set_state(&database.to_variant()); } // The catalog describes the connection's own database only. - if layout != DatabaseLayout::WithSchemas || session.database() != Some(database.as_str()) { + if layout != DatabaseLayout::WithSchemas || own.as_deref() != Some(database.as_str()) { return; } let reading = { @@ -128,11 +129,21 @@ impl SqlEditor { .imp() .slot .database() - .or_else(|| session.database().map(str::to_owned)) + .or_else(|| self.own_database(session)) .filter(|name| !name.is_empty())?; Some((database, layout)) } + /// The database the connection opened in: the name it was given, else + /// the one the server listed as its own. + fn own_database(&self, session: &Session) -> Option { + session + .database() + .filter(|name| !name.is_empty()) + .map(str::to_owned) + .or_else(|| self.imp().listed_own_database.borrow().clone()) + } + /// Ask the server for its databases, for the bar's menu. A server /// that cannot list them leaves the bar a label. pub(super) fn list_databases(&self) { @@ -142,7 +153,10 @@ impl SqlEditor { else { return; }; - if self.scope_database(&session).is_none() { + let several = session + .current_dialect() + .is_some_and(|dialect| dialect.database_layout() != DatabaseLayout::Single); + if !several { return; } let listing = tasks.spawn_task(async move { @@ -167,6 +181,9 @@ impl SqlEditor { }; if let Some(editor) = editor.upgrade() { let imp = editor.imp(); + let own = databases.iter().find(|database| database.is_default); + imp.listed_own_database + .replace(own.map(|database| database.name.clone())); imp.scope_button.set_menu_model(database_menu(&databases).as_ref()); editor.show_scope(); } @@ -187,11 +204,11 @@ impl SqlEditor { /// the connection's own. pub(crate) fn tab_database(&self) -> Option { let database = self.imp().slot.database()?; - let own = self.deps().and_then(|deps| deps.session.get()); - match own { - Some(session) if session.database() == Some(database.as_str()) => None, - _ => Some(database), - } + let own = self + .deps() + .and_then(|deps| deps.session.get()) + .and_then(|session| self.own_database(&session)); + (own.as_deref() != Some(database.as_str())).then_some(database) } pub(crate) fn connect_database_changed(&self, f: F) -> glib::SignalHandlerId { diff --git a/linux/crates/drivers/mysql/src/editor_session.rs b/linux/crates/drivers/mysql/src/editor_session.rs index 9fa8db8b85..681243cc05 100644 --- a/linux/crates/drivers/mysql/src/editor_session.rs +++ b/linux/crates/drivers/mysql/src/editor_session.rs @@ -390,7 +390,14 @@ impl EditorSession for MysqlEditorSession { *shared.database.lock().unwrap_or_else(PoisonError::into_inner) = Some(database.to_owned()); Ok(()) } - Some(Err(error)) => Err(map_error(&error, shared.flavour)), + Some(Err(error)) => { + let error = map_error(&error, shared.flavour); + if error.category() == tablepro_core::ErrorCategory::ConnectionLost { + shared.lost.store(true, Ordering::SeqCst); + *guard = None; + } + Err(error) + } // The answer is still on the wire, so the connection is in no // state anything knows. None => { From 615302c5b64171c9c913953807b324d57c17a6d3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:06:02 +0700 Subject: [PATCH 17/23] test(linux-editor): a connection without a database name names the server's --- linux/crates/app/src/ui/editor/tests.rs | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 5ae932f317..1ed38eba05 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2803,6 +2803,31 @@ fn picking_a_database_moves_the_tab_and_its_next_run() { assert_eq!(harness.editor.tab_database(), None); } +/// A connection opened without a database name is in the one the server +/// calls its own, which the bar names and the menu checks. +#[gtk4::test] +fn a_connection_without_a_database_name_names_the_servers() { + let database = |name: &str, is_default: bool| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + }; + let harness = scoped_editor_with(mysql_dialect(), &[], answering(), |script, request| { + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = None; + } + script + .list_databases + .always(FakeReply::ok(vec![database("analytics", false), database("app", true)])); + }); + let button = harness.editor.imp().scope_button.get(); + wait_until(WAIT, || button.is_visible()).expect("the database menu"); + + assert_eq!(harness.editor.imp().scope_button_label.label(), "app"); + pick_database(&harness, "app"); + assert_eq!(harness.editor.tab_database(), None, "the connection's own"); +} + /// A database the server will not move to leaves the tab where its /// session is, and the run says why. #[gtk4::test] From 139e75fccf148a04ee229465b9e3cc8bd7b40686 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:14:33 +0700 Subject: [PATCH 18/23] test(linux-clickhouse): read the collected rows without a binding --- linux/crates/drivers/clickhouse/src/editor_session.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 8a566e9c29..894506f6f3 100644 --- a/linux/crates/drivers/clickhouse/src/editor_session.rs +++ b/linux/crates/drivers/clickhouse/src/editor_session.rs @@ -232,8 +232,7 @@ mod tests { ) .await .expect("a run"); - let rows = rows.lock().unwrap_or_else(PoisonError::into_inner).clone(); - rows + rows.lock().unwrap_or_else(PoisonError::into_inner).clone() } /// Every request names its database, so a `USE` lasts only because From 2b50233e164ff5c10eb1943e5b235ef519c7a1e8 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:15:04 +0700 Subject: [PATCH 19/23] fix(linux-editor): show a database name's underscores in the menu --- linux/crates/app/src/ui/editor/tab_scope.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index 81958e23ce..fd95971a58 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -42,7 +42,9 @@ pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { for system in [false, true] { let section = gio::Menu::new(); for database in databases.iter().filter(|database| database.is_system == system) { - let item = gio::MenuItem::new(Some(&database.name), None); + // A popover menu reads `_` as a mnemonic mark, and database names + // such as `information_schema` hold one. + let item = gio::MenuItem::new(Some(&database.name.replace('_', "__")), None); item.set_action_and_target_value( Some(&format!("editor.{MOVE_ACTION}")), Some(&database.name.to_variant()), @@ -297,7 +299,11 @@ mod tests { assert_eq!(menu.n_items(), 2, "two sections"); assert_eq!(labels(&menu, 0), ["shop", "analytics"]); - assert_eq!(labels(&menu, 1), ["mysql", "information_schema"]); + assert_eq!( + labels(&menu, 1), + ["mysql", "information__schema"], + "an underscore is no mnemonic" + ); assert!( database_menu(&[database("shop", false)]).is_none(), "nothing to move to" From cfd96f77790b3f7609fc143ed7f79dbb84411c4d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 19:15:15 +0700 Subject: [PATCH 20/23] refactor(linux-editor): reuse the menu label helper for database names --- linux/crates/app/src/ui/editor/tab_scope.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index fd95971a58..c06d90c9cb 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -42,9 +42,7 @@ pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { for system in [false, true] { let section = gio::Menu::new(); for database in databases.iter().filter(|database| database.is_system == system) { - // A popover menu reads `_` as a mnemonic mark, and database names - // such as `information_schema` hold one. - let item = gio::MenuItem::new(Some(&database.name.replace('_', "__")), None); + let item = gio::MenuItem::new(Some(&crate::ui::menu_label(&database.name)), None); item.set_action_and_target_value( Some(&format!("editor.{MOVE_ACTION}")), Some(&database.name.to_variant()), From e50f1756928b68d7836032112d6fc0060301dcce Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 21:10:30 +0700 Subject: [PATCH 21/23] fix(linux-editor): export, describe and complete a moved tab in its own database, and offer one when none is open --- .../app/src/ui/assistant/assistant_access.rs | 17 ++++- .../app/src/ui/assistant/schema_reader.rs | 41 ++++++++---- .../src/ui/editor/completion/catalog_read.rs | 11 ++-- linux/crates/app/src/ui/editor/imp.rs | 3 + linux/crates/app/src/ui/editor/mod.rs | 10 ++- linux/crates/app/src/ui/editor/tab_scope.rs | 24 +++++-- linux/crates/app/src/ui/editor/tests.rs | 62 +++++++++++++++++++ .../crates/app/src/ui/workspace/assistant.rs | 18 ++++++ 8 files changed, 161 insertions(+), 25 deletions(-) diff --git a/linux/crates/app/src/ui/assistant/assistant_access.rs b/linux/crates/app/src/ui/assistant/assistant_access.rs index 67f888c59c..9e79516cda 100644 --- a/linux/crates/app/src/ui/assistant/assistant_access.rs +++ b/linux/crates/app/src/ui/assistant/assistant_access.rs @@ -14,6 +14,7 @@ use tablepro_core::{AiPolicy, AiProvider}; use crate::services::active_session::ActiveSession; use crate::services::ai_service::AiService; +use crate::ui::editor::CatalogHome; const CANCEL: &str = "cancel"; const ALLOW: &str = "allow"; @@ -54,6 +55,9 @@ pub(crate) struct AssistantAccess { /// Each Time. granted: RefCell>, asking: Cell, + /// Where the editor tab in front resolves names: its database, when + /// it moved off the connection's. + front: RefCell CatalogHome>>>, } impl AssistantAccess { @@ -72,9 +76,15 @@ impl AssistantAccess { policy: Box::new(policy), granted: RefCell::new(None), asking: Cell::new(false), + front: RefCell::new(None), }) } + /// Describe the database of the editor tab `front` says is in front. + pub(crate) fn follow_front(&self, front: impl Fn() -> CatalogHome + 'static) { + self.front.replace(Some(Box::new(front))); + } + pub(crate) fn ai(&self) -> &AiService { &self.ai } @@ -196,10 +206,15 @@ impl AssistantAccess { }; }; let engine = self.engine.clone(); + let home = self + .front + .borrow() + .as_ref() + .map_or_else(CatalogHome::default, |front| front()); let read = self .ai .tasks() - .spawn_task(async move { super::schema_reader::read(session, engine, text).await }) + .spawn_task(async move { super::schema_reader::read(session, engine, text, home).await }) .await; read.unwrap_or_else(|failure| { tracing::warn!(%failure, "the schema for the assistant could not be read"); diff --git a/linux/crates/app/src/ui/assistant/schema_reader.rs b/linux/crates/app/src/ui/assistant/schema_reader.rs index aff96ad98c..186927125c 100644 --- a/linux/crates/app/src/ui/assistant/schema_reader.rs +++ b/linux/crates/app/src/ui/assistant/schema_reader.rs @@ -10,12 +10,12 @@ use tablepro_core::sql_syntax::script::LexicalSettings; use tablepro_core::{ColumnInfo, TableRef}; use tablepro_session::Session; -use crate::ui::editor::{CompletionScope, read_catalog, tables_named}; +use crate::ui::editor::{CatalogHome, CompletionScope, read_catalog, tables_named}; /// The schema the user is in, its tables, and the structure of the /// tables `text` names. Shares the catalog's reads with the sidebar and /// completion, so it rarely reaches the server. -pub(crate) async fn read(session: Arc, engine: String, text: String) -> SchemaContext { +pub(crate) async fn read(session: Arc, engine: String, text: String, home: CatalogHome) -> SchemaContext { let grammar = session .current_dialect() .map_or(SqlGrammar::PostgreSql, |dialect| dialect.grammar()); @@ -24,9 +24,22 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> } else { QueryLanguage::Commands }; + // Nothing the connection's catalog holds is in that database. + if let CatalogHome::Elsewhere(database) = home { + return SchemaContext { + engine, + language, + database: Some(database), + ..SchemaContext::default() + }; + } let mut named = tables_named(&text, grammar, LexicalSettings::default_for(grammar)); // A question in words names tables as words, which no SQL parse sees. - if let Ok(objects) = session.objects(ObjectScope::DefaultSchema).await { + let listed = match &home { + CatalogHome::Schema(database) => ObjectScope::Schema(database.clone()), + CatalogHome::Connection | CatalogHome::Elsewhere(_) => ObjectScope::DefaultSchema, + }; + if let Ok(objects) = session.objects(listed).await { for table in named_in_words(&text, &objects) { if !named.contains(&table) { named.push(table); @@ -39,15 +52,18 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> CompletionScope::General { tables_in_statement: named, }, - crate::ui::editor::CatalogHome::Connection, + home.clone(), ) .await; - let schema = session.schemas().await.ok().and_then(|schemas| { - schemas - .iter() - .find(|schema| schema.is_default) - .map(|schema| schema.name.clone()) - }); + let schema = match &home { + CatalogHome::Schema(database) => Some(database.clone()), + CatalogHome::Connection | CatalogHome::Elsewhere(_) => session.schemas().await.ok().and_then(|schemas| { + schemas + .iter() + .find(|schema| schema.is_default) + .map(|schema| schema.name.clone()) + }), + }; let tables = answer .objects .map(|objects| { @@ -67,7 +83,10 @@ pub(crate) async fn read(session: Arc, engine: String, text: String) -> SchemaContext { engine, language, - database: session.database().map(str::to_owned), + database: match home { + CatalogHome::Schema(database) => Some(database), + CatalogHome::Connection | CatalogHome::Elsewhere(_) => session.database().map(str::to_owned), + }, schema, tables, described, diff --git a/linux/crates/app/src/ui/editor/completion/catalog_read.rs b/linux/crates/app/src/ui/editor/completion/catalog_read.rs index 24f9febb8c..a0e91a4d00 100644 --- a/linux/crates/app/src/ui/editor/completion/catalog_read.rs +++ b/linux/crates/app/src/ui/editor/completion/catalog_read.rs @@ -25,7 +25,7 @@ pub enum CatalogHome { Schema(String), /// A database the catalog does not describe, which nothing offered /// from it would be right for. - Elsewhere, + Elsewhere(String), } /// Read what `scope` offers. The catalog shares each read with every @@ -35,7 +35,7 @@ pub async fn read(session: Arc, scope: CompletionScope, home: CatalogHo let defaults = match &home { CatalogHome::Connection => listed(session.objects(ObjectScope::DefaultSchema).await), CatalogHome::Schema(name) => listed(session.objects(ObjectScope::Schema(name.clone())).await), - CatalogHome::Elsewhere => return CatalogAnswer::default(), + CatalogHome::Elsewhere(_) => return CatalogAnswer::default(), }; let home = Home { home, defaults }; match scope { @@ -90,14 +90,15 @@ impl Home { match &self.home { CatalogHome::Connection => schema.is_default, CatalogHome::Schema(name) => schema.name == *name, - CatalogHome::Elsewhere => false, + CatalogHome::Elsewhere(_) => false, } } /// A name for the home database the catalog does not list as a - /// schema, as MySQL lists only the connection's. + /// schema, as MySQL lists only the connection's. Exact, since + /// `Analytics` and `analytics` can be two databases. fn named(&self, name: &str) -> bool { - matches!(&self.home, CatalogHome::Schema(home) if home.eq_ignore_ascii_case(name)) + matches!(&self.home, CatalogHome::Schema(home) if home == name) } } diff --git a/linux/crates/app/src/ui/editor/imp.rs b/linux/crates/app/src/ui/editor/imp.rs index 3f0170cd70..7cf2bfe202 100644 --- a/linux/crates/app/src/ui/editor/imp.rs +++ b/linux/crates/app/src/ui/editor/imp.rs @@ -66,6 +66,9 @@ pub struct SqlEditor { pub slot: Arc, /// The database the bar names, to tell the workspace when it changes. pub named_database: RefCell>, + /// Where completion resolves unqualified names, which the assistant + /// describes too. + pub catalog_home: RefCell, /// The server's name for the connection's database, for one opened /// without a name. pub listed_own_database: RefCell>, diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index e1c3b7a61a..457426700f 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -871,8 +871,11 @@ impl SqlEditor { let EntryKind::ResultSet(result) = &entry.kind else { return None; }; - Some(ExportOffer { - origin: ExportOrigin::Editor { + // All Rows runs the statement again on the connection, which is in + // another database than this tab. + let origin = match self.tab_database() { + Some(_) => ExportOrigin::Shown { rows: shown }, + None => ExportOrigin::Editor { shown, query: ExportQuery::Editor { sql: result.statement.clone(), @@ -880,6 +883,9 @@ impl SqlEditor { shown_rows: result.row_count() as u64, truncated: result.summary.is_truncated(), }, + }; + Some(ExportOffer { + origin, session: self.deps().and_then(|deps| deps.session.get()), name: export_name_for_query(&entry.preview), target: None, diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index c06d90c9cb..d0e7eb8835 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -11,7 +11,7 @@ use tablepro_session::operation::OperationKind; use super::SqlEditor; use super::completion::{CatalogHome, SqlCompletionProvider}; -use crate::i18n::gettext_f; +use crate::i18n::{gettext, gettext_f}; /// The `editor` action a database menu item names, with the database as /// its target. @@ -33,9 +33,9 @@ pub(super) fn scope_text(database: &str, schema: Option<&str>) -> (String, Strin } /// The user's databases, then the engine's own after a separator. `None` -/// when there is no other database to move to. +/// when there is no database to move to but the one the tab is in. pub(super) fn database_menu(databases: &[SchemaInfo]) -> Option { - if databases.len() < 2 { + if !databases.iter().any(|database| !database.is_default) { return None; } let menu = gio::Menu::new(); @@ -61,7 +61,7 @@ pub(super) fn catalog_home(layout: DatabaseLayout, own: Option<&str>, database: match layout { _ if own == Some(database) => CatalogHome::Connection, DatabaseLayout::AsSchemas => CatalogHome::Schema(database.to_owned()), - DatabaseLayout::WithSchemas | DatabaseLayout::Single => CatalogHome::Elsewhere, + DatabaseLayout::WithSchemas | DatabaseLayout::Single => CatalogHome::Elsewhere(database.to_owned()), } } @@ -77,7 +77,12 @@ impl SqlEditor { return; }; let Some((database, layout)) = self.scope_database(&session) else { - self.set_scope(None); + // A connection in no database yet can still pick one. + let pick = self.imp().scope_button.menu_model().is_some().then(|| { + let none = gettext("No Database"); + (none.clone(), none) + }); + self.set_scope(pick); return; }; self.set_scope(Some(scope_text(&database, None))); @@ -219,7 +224,14 @@ impl SqlEditor { ) } + /// Where unqualified names in this tab resolve, for what the assistant + /// says about it. + pub(crate) fn catalog_home(&self) -> CatalogHome { + self.imp().catalog_home.borrow().clone() + } + fn follow_in_completion(&self, home: CatalogHome) { + self.imp().catalog_home.replace(home.clone()); for provider in self.imp().completion_providers.borrow().iter() { if let Some(provider) = provider.downcast_ref::() { provider.set_home(home.clone()); @@ -322,7 +334,7 @@ mod tests { ); assert_eq!( catalog_home(DatabaseLayout::WithSchemas, Some("shop"), "analytics"), - CatalogHome::Elsewhere + CatalogHome::Elsewhere("analytics".to_owned()) ); } } diff --git a/linux/crates/app/src/ui/editor/tests.rs b/linux/crates/app/src/ui/editor/tests.rs index 1ed38eba05..a960c9fe1e 100644 --- a/linux/crates/app/src/ui/editor/tests.rs +++ b/linux/crates/app/src/ui/editor/tests.rs @@ -2828,6 +2828,68 @@ fn a_connection_without_a_database_name_names_the_servers() { assert_eq!(harness.editor.tab_database(), None, "the connection's own"); } +/// A connection in no database yet still offers the server's. +#[gtk4::test] +fn a_connection_in_no_database_offers_one() { + let database = |name: &str| tablepro_core::meta::SchemaInfo { + name: name.to_owned(), + is_default: false, + is_system: false, + }; + let harness = scoped_editor_with(mysql_dialect(), &[], answering(), |script, request| { + if let tablepro_session::connect::RequestTarget::Network { database, .. } = &mut request.target { + *database = None; + } + script.list_databases.always(FakeReply::ok(vec![database("analytics")])); + }); + let button = harness.editor.imp().scope_button.get(); + wait_until(WAIT, || button.is_visible()).expect("the database menu"); + + assert_eq!(harness.editor.imp().scope_button_label.label(), "No Database"); + pick_database(&harness, "analytics"); + assert_eq!(harness.editor.imp().scope_button_label.label(), "analytics"); +} + +/// All Rows runs the statement again on the connection, in another +/// database than a moved tab's, so only the rows shown are offered. +#[gtk4::test] +fn a_moved_tab_exports_the_rows_it_shows_only() { + let mut runs = Replies::default(); + runs.always(result_set(3, Some(40), Truncation::ReadPastLimit)); + let harness = databases_editor(runs, false); + let offers = SignalLog::connect(&harness.editor, "export-results", |values| { + values[1] + .get::() + .expect("an offer") + .borrow::() + .clone() + }); + pick_database(&harness, "analytics"); + run_and_wait(&harness); + + shown_banner(&harness).emit_by_name::<()>("button-clicked", &[]); + + let offers = offers.take(); + let [offer] = offers.as_slice() else { + panic!("one offer, not {}", offers.len()); + }; + assert!(matches!(offer.origin, ExportOrigin::Shown { .. }), "{:?}", offer.origin); +} + +/// The assistant describes the database the tab in front is in. +#[gtk4::test] +fn the_assistant_reads_the_tabs_database() { + let harness = databases_editor(answering(), false); + assert_eq!(harness.editor.catalog_home(), CatalogHome::Connection); + + pick_database(&harness, "analytics"); + + assert_eq!( + harness.editor.catalog_home(), + CatalogHome::Schema("analytics".to_owned()) + ); +} + /// A database the server will not move to leaves the tab where its /// session is, and the run says why. #[gtk4::test] diff --git a/linux/crates/app/src/ui/workspace/assistant.rs b/linux/crates/app/src/ui/workspace/assistant.rs index a4c788ba03..9bbcc91590 100644 --- a/linux/crates/app/src/ui/workspace/assistant.rs +++ b/linux/crates/app/src/ui/workspace/assistant.rs @@ -19,6 +19,7 @@ use crate::application::AppServices; use crate::runtime::DeliverInput; use crate::services::active_session::ActiveSession; use crate::ui::assistant::{AssistantAccess, AssistantPane, AssistantRequest, Availability, PaneHooks}; +use crate::ui::editor::CatalogHome; use crate::ui::handler_guard::HandlerGuard; pub(super) struct AssistantColumn { @@ -52,6 +53,10 @@ pub(super) fn install(parts: &WorkspaceTabParts, input: &WorkspaceSender) -> Ass parts.index.clone(), parts.tab_view.downgrade(), ); + { + let (tabs, index, view) = (tabs.clone(), index.clone(), view.clone()); + access.follow_front(move || active_editor_home(&tabs, &index, &view)); + } let pane: AssistantPane = glib::Object::new(); pane.bind(PaneHooks { access: access.clone(), @@ -81,6 +86,19 @@ pub(super) fn install(parts: &WorkspaceTabParts, input: &WorkspaceSender) -> Ass } } +/// Where this session's editor tab on screen resolves names: its +/// database, when it moved off the connection's. +fn active_editor_home(tabs: &WorkspaceTabs, index: &TabIndex, view: &glib::WeakRef) -> CatalogHome { + let id = view.upgrade().and_then(|view| index.id_of(&view.selected_page()?)); + match id.and_then(|id| match tabs.borrow().get(&id) { + Some(WorkspaceTab::Editor(slot)) => Some(slot.editor.catalog_home()), + _ => None, + }) { + Some(home) => home, + None => CatalogHome::Connection, + } +} + /// The text of this session's editor tab while it is the one on screen. fn active_editor_text(tabs: &WorkspaceTabs, index: &TabIndex, view: &glib::WeakRef) -> Option { let id = index.id_of(&view.upgrade()?.selected_page()?)?; From 94cea80bf746655f568d3c3099dd1c81638564f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 21:51:56 +0700 Subject: [PATCH 22/23] test(linux-mssql): create the test's database outside a transaction --- linux/crates/drivers/mssql/tests/integration/sessions.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/linux/crates/drivers/mssql/tests/integration/sessions.rs b/linux/crates/drivers/mssql/tests/integration/sessions.rs index 4364a74859..4df5fae92b 100644 --- a/linux/crates/drivers/mssql/tests/integration/sessions.rs +++ b/linux/crates/drivers/mssql/tests/integration/sessions.rs @@ -31,12 +31,13 @@ async fn use_database_persists_across_runs() { async fn a_session_moves_to_a_database_by_its_name() { let fixture = start().await; let connection = connect(&fixture, false).await; - setup( - connection.as_ref(), - &["IF DB_ID(N'odd]name') IS NULL CREATE DATABASE [odd]]name]"], + let session = session(connection.as_ref()).await; + // CREATE DATABASE runs outside a transaction, as the editor runs it. + run_ok( + session.as_ref(), + "IF DB_ID(N'odd]name') IS NULL CREATE DATABASE [odd]]name]", ) .await; - let session = session(connection.as_ref()).await; let options = || tablepro_core::CallOptions::new(None, CancellationToken::new()); session.use_database("odd]name", options()).await.expect("a move"); From f942f9032ec2a97888f94201e698dd41da00f7a5 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Fri, 30 Oct 2026 22:18:35 +0700 Subject: [PATCH 23/23] test(linux-editor): a menu for the one database a connection in none can pick --- linux/crates/app/src/ui/editor/tab_scope.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index d0e7eb8835..c3db3f867c 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -314,9 +314,14 @@ mod tests { ["mysql", "information__schema"], "an underscore is no mnemonic" ); + let own = SchemaInfo { + is_default: true, + ..database("shop", false) + }; + assert!(database_menu(&[own]).is_none(), "nothing to move to"); assert!( - database_menu(&[database("shop", false)]).is_none(), - "nothing to move to" + database_menu(&[database("shop", false)]).is_some(), + "a connection in no database can pick its one" ); }