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. 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 a3e14a8a91..fd979ac0e5 100644 --- a/linux/crates/core/src/sql_syntax/mod.rs +++ b/linux/crates/core/src/sql_syntax/mod.rs @@ -17,7 +17,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/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/cassandra/src/editor.rs b/linux/crates/drivers/cassandra/src/editor.rs index 97da762cd6..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; @@ -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}; @@ -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,10 +79,39 @@ 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. 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); + let used = 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)), + }; + 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) + } + } } fn state(&self) -> EditorSessionState { @@ -106,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, @@ -375,3 +416,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/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/clickhouse/src/editor_session.rs b/linux/crates/drivers/clickhouse/src/editor_session.rs index 4d00aacd94..894506f6f3 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,63 @@ 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"); + rows.lock().unwrap_or_else(PoisonError::into_inner).clone() + } + + /// 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/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/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/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 3d4e638b59..15ec2a7f9e 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 { @@ -526,6 +530,11 @@ mod tests { MssqlDialect::new(16) } + #[test] + fn a_bracket_in_a_name_is_doubled() { + assert_eq!(quote("sales ]2026"), "[sales ]]2026]"); + } + fn column(declared: &str) -> ColumnInfo { ColumnInfo { name: "c".to_owned(), 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 d7145794de..01ce460613 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; @@ -248,6 +248,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 c93ec84c41..f098234f89 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 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 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 \ 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() { diff --git a/linux/crates/drivers/mssql/tests/integration/sessions.rs b/linux/crates/drivers/mssql/tests/integration/sessions.rs index ae1e0008a0..4df5fae92b 100644 --- a/linux/crates/drivers/mssql/tests/integration/sessions.rs +++ b/linux/crates/drivers/mssql/tests/integration/sessions.rs @@ -24,6 +24,33 @@ 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; + 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 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() {