From 8d61de2e246ca407bb3210e69b6e1b88d54d44fa Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 11:18:58 +0700 Subject: [PATCH 1/8] feat(linux): truncate tables and create or drop databases and schemas from the sidebar --- core/crates/core/src/connection.rs | 10 +- core/crates/core/src/ddl/build_ddl_error.rs | 7 +- .../core/src/ddl/catalog_capabilities.rs | 81 ++ core/crates/core/src/ddl/catalog_plans.rs | 238 ++++ core/crates/core/src/ddl/catalog_tests.rs | 256 ++++ core/crates/core/src/ddl/database_choices.rs | 33 + core/crates/core/src/ddl/database_draft.rs | 93 ++ core/crates/core/src/ddl/ddl_dialect.rs | 92 +- core/crates/core/src/ddl/ddl_operation.rs | 28 + core/crates/core/src/ddl/ddl_plan.rs | 28 +- core/crates/core/src/ddl/mod.rs | 18 + core/crates/core/src/ddl/schema_draft.rs | 16 + .../core/src/ddl/truncate_table_options.rs | 13 + .../core/src/document/document_catalog.rs | 41 + core/crates/core/src/document/mod.rs | 2 + core/crates/core/src/error.rs | 9 +- core/crates/core/src/unsupported_feature.rs | 3 + core/crates/core/src/write_batch.rs | 3 + core/crates/drivers/cassandra/src/catalog.rs | 19 +- .../drivers/cassandra/src/connection.rs | 14 +- core/crates/drivers/cassandra/src/ddl.rs | 323 ++++- core/crates/drivers/cassandra/src/session.rs | 48 +- .../tests/integration/catalog_operations.rs | 158 +++ .../cassandra/tests/integration/main.rs | 1 + .../drivers/clickhouse/src/connection.rs | 9 +- core/crates/drivers/clickhouse/src/ddl.rs | 279 +++- .../crates/drivers/clickhouse/src/endpoint.rs | 8 +- core/crates/drivers/clickhouse/src/errors.rs | 37 + .../crates/drivers/clickhouse/src/metadata.rs | 27 +- .../drivers/clickhouse/src/write_batch.rs | 4 +- .../clickhouse/tests/integration/ddl.rs | 68 +- .../clickhouse/tests/integration/metadata.rs | 44 +- core/crates/drivers/duckdb/src/connection.rs | 2 +- core/crates/drivers/duckdb/src/ddl.rs | 29 +- .../drivers/duckdb/tests/integration/ddl.rs | 184 ++- .../crates/drivers/mongodb/src/write_batch.rs | 68 + .../mongodb/tests/integration/catalog.rs | 143 ++ .../drivers/mongodb/tests/integration/main.rs | 1 + core/crates/drivers/mssql/src/connection.rs | 18 +- core/crates/drivers/mssql/src/ddl.rs | 290 ++++- core/crates/drivers/mssql/src/metadata.rs | 79 +- core/crates/drivers/mssql/src/write_batch.rs | 48 +- .../tests/integration/catalog_operations.rs | 266 ++++ .../drivers/mssql/tests/integration/main.rs | 1 + core/crates/drivers/mysql/src/connection.rs | 29 +- core/crates/drivers/mysql/src/ddl/mod.rs | 286 +++- core/crates/drivers/mysql/src/errors.rs | 58 +- core/crates/drivers/mysql/src/metadata.rs | 58 +- core/crates/drivers/mysql/src/physical.rs | 13 +- core/crates/drivers/mysql/src/write_batch.rs | 2 +- .../drivers/mysql/tests/integration/ddl.rs | 110 +- .../mysql/tests/integration/metadata.rs | 81 +- .../crates/drivers/postgres/src/connection.rs | 35 +- core/crates/drivers/postgres/src/ddl.rs | 470 ++++++- core/crates/drivers/postgres/src/errors.rs | 44 +- .../postgres/src/metadata/database_choices.rs | 128 ++ .../drivers/postgres/src/metadata/mod.rs | 6 + core/crates/drivers/postgres/src/physical.rs | 11 +- .../drivers/postgres/src/write_batch.rs | 114 +- .../postgres/tests/integration/catalog_ddl.rs | 328 +++++ .../postgres/tests/integration/main.rs | 1 + core/crates/drivers/sqlite/src/connection.rs | 2 +- core/crates/drivers/sqlite/src/ddl/mod.rs | 32 +- core/crates/drivers/sqlite/src/ddl/tests.rs | 91 +- .../drivers/sqlite/tests/integration/ddl.rs | 90 +- core/crates/ffi/src/error.rs | 30 + .../session/src/health/failure_cause.rs | 9 + .../src/operation/default_deadlines.rs | 3 +- .../session/src/operation/replay_rule.rs | 1 + .../session/src/session/schema_catalog.rs | 5 + core/crates/session/src/session/session.rs | 5 + core/crates/test-support/src/call_counters.rs | 1 + .../test-support/src/conformance/cases/ddl.rs | 177 ++- .../src/conformance/ddl_suite_macro.rs | 22 +- .../test-support/src/connection_script.rs | 4 +- .../test-support/src/fake_connection.rs | 34 +- .../docs/decisions/0011-catalog-operations.md | 32 + core/docs/decisions/README.md | 1 + linux/ROADMAP.md | 4 +- linux/crates/app/src/ui/editor/tab_scope.rs | 7 +- linux/crates/app/src/ui/error_text/ddl.rs | 41 +- linux/crates/app/src/ui/error_text/driver.rs | 4 + linux/crates/app/src/ui/error_text/tests.rs | 39 + .../app/src/ui/error_text/unsupported.rs | 2 + .../src/ui/{structure_tab => }/form_dialog.rs | 27 +- linux/crates/app/src/ui/mod.rs | 1 + linux/crates/app/src/ui/session_window/mod.rs | 31 +- .../src/ui/session_window/other_databases.rs | 373 +++++- .../src/ui/session_window/session_teardown.rs | 52 +- .../ui/session_window/session_window_input.rs | 28 + .../session_window/tests/other_databases.rs | 238 +++- .../src/ui/session_window/tests/sessions.rs | 2 +- .../src/ui/session_window/window_actions.rs | 4 +- .../ui/structure_tab/foreign_key_dialog.rs | 4 +- .../app/src/ui/structure_tab/index_dialog.rs | 4 +- linux/crates/app/src/ui/structure_tab/mod.rs | 1 - .../crates/app/src/ui/structure_tab/tests.rs | 6 +- .../app/src/ui/workspace/catalog_dialogs.rs | 665 ++++++++++ .../app/src/ui/workspace/catalog_ops.rs | 1157 +++++++++++++++++ .../app/src/ui/workspace/health_banner.rs | 16 + linux/crates/app/src/ui/workspace/mod.rs | 71 +- linux/crates/app/src/ui/workspace/saves.rs | 2 + .../ui/workspace/sidebar/connection_tree.rs | 27 +- .../app/src/ui/workspace/sidebar/mod.rs | 88 +- .../workspace/sidebar/sidebar_affordances.rs | 108 +- .../src/ui/workspace/sidebar/sidebar_event.rs | 8 + .../src/ui/workspace/sidebar/sidebar_index.rs | 209 ++- .../src/ui/workspace/sidebar/sidebar_menu.rs | 361 ++++- .../src/ui/workspace/sidebar/sidebar_model.rs | 94 +- .../src/ui/workspace/sidebar/sidebar_view.rs | 64 +- .../app/src/ui/workspace/sidebar/tests.rs | 451 ++++++- .../app/src/ui/workspace/sidebar/tree_menu.rs | 216 ++- .../crates/app/src/ui/workspace/structure.rs | 168 ++- .../app/src/ui/workspace/tests/catalog_ops.rs | 602 +++++++++ .../crates/app/src/ui/workspace/tests/mod.rs | 1 + .../app/src/ui/workspace/workspace_tabs.rs | 13 +- linux/crates/app/src/ui/write_confirmation.rs | 33 + linux/crates/app/src/workspace/mod.rs | 3 +- .../crates/app/src/workspace/schema_write.rs | 148 ++- .../app/src/workspace/workspace_handle.rs | 9 +- linux/docs/adding-drivers.md | 4 +- .../0010-ddl-plans-and-table-definitions.md | 2 +- .../docs/decisions/0016-driver-contract-v2.md | 2 +- ...0030-a-database-is-a-session-of-its-own.md | 2 +- ...33-dropping-a-database-the-window-holds.md | 24 + linux/docs/decisions/README.md | 7 +- linux/docs/drivers/postgresql.md | 2 + linux/docs/error-handling.md | 3 +- linux/docs/testing.md | 5 +- linux/po/POTFILES.in | 5 +- linux/po/vi.po | 2 +- .../TablePro.App/Strings/en-US/Resources.resw | 3 + 132 files changed, 10375 insertions(+), 520 deletions(-) create mode 100644 core/crates/core/src/ddl/catalog_capabilities.rs create mode 100644 core/crates/core/src/ddl/catalog_plans.rs create mode 100644 core/crates/core/src/ddl/catalog_tests.rs create mode 100644 core/crates/core/src/ddl/database_choices.rs create mode 100644 core/crates/core/src/ddl/database_draft.rs create mode 100644 core/crates/core/src/ddl/schema_draft.rs create mode 100644 core/crates/core/src/ddl/truncate_table_options.rs create mode 100644 core/crates/core/src/document/document_catalog.rs create mode 100644 core/crates/drivers/cassandra/tests/integration/catalog_operations.rs create mode 100644 core/crates/drivers/mongodb/tests/integration/catalog.rs create mode 100644 core/crates/drivers/mssql/tests/integration/catalog_operations.rs create mode 100644 core/crates/drivers/postgres/src/metadata/database_choices.rs create mode 100644 core/crates/drivers/postgres/tests/integration/catalog_ddl.rs create mode 100644 core/docs/decisions/0011-catalog-operations.md rename linux/crates/app/src/ui/{structure_tab => }/form_dialog.rs (91%) create mode 100644 linux/crates/app/src/ui/workspace/catalog_dialogs.rs create mode 100644 linux/crates/app/src/ui/workspace/catalog_ops.rs create mode 100644 linux/crates/app/src/ui/workspace/tests/catalog_ops.rs create mode 100644 linux/docs/decisions/0033-dropping-a-database-the-window-holds.md diff --git a/core/crates/core/src/connection.rs b/core/crates/core/src/connection.rs index c030ffcd62..e56c1b10a2 100644 --- a/core/crates/core/src/connection.rs +++ b/core/crates/core/src/connection.rs @@ -6,7 +6,7 @@ use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; use crate::call_options::CallOptions; use crate::column::ColumnInfo; use crate::commit::{CommitResolution, CommitToken}; -use crate::ddl::{CharacterSet, InboundForeignKey, OutboundForeignKey, TableDefinition}; +use crate::ddl::{CharacterSet, DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use crate::dialect::SqlDialect; use crate::editor_session::{EditorSession, EditorSessionOptions}; use crate::error::DriverError; @@ -118,6 +118,14 @@ pub trait Connection: Send + Sync { }) } + /// What the server offers for a new database's options, for the New + /// Database dialog's pickers. + async fn fetch_database_choices(&self, _options: CallOptions) -> Result { + Err(DriverError::Unsupported { + feature: UnsupportedFeature::DatabaseOptions, + }) + } + /// 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> { diff --git a/core/crates/core/src/ddl/build_ddl_error.rs b/core/crates/core/src/ddl/build_ddl_error.rs index 39a2b1c3a8..f2f7a76c9a 100644 --- a/core/crates/core/src/ddl/build_ddl_error.rs +++ b/core/crates/core/src/ddl/build_ddl_error.rs @@ -1,6 +1,6 @@ use thiserror::Error; -use super::{DdlOperation, DraftProblem, ReferentialAction, RefusalReason}; +use super::{CatalogOption, DdlOperation, DraftProblem, ReferentialAction, RefusalReason}; use crate::dialect::LiteralError; use crate::sql_syntax::TypeSyntaxError; @@ -39,4 +39,9 @@ pub enum BuildDdlError { /// reviewed, because the draft or the connection changed meanwhile. #[error("the statements to run are not the ones reviewed")] NotAsReviewed, + /// A schema or database option the statement cannot carry: an empty + /// name, or a value the engine only takes bare that holds more than + /// letters, digits and underscores. + #[error("{value:?} is not a {option} this engine takes")] + InvalidOption { option: CatalogOption, value: String }, } diff --git a/core/crates/core/src/ddl/catalog_capabilities.rs b/core/crates/core/src/ddl/catalog_capabilities.rs new file mode 100644 index 0000000000..d3477073f1 --- /dev/null +++ b/core/crates/core/src/ddl/catalog_capabilities.rs @@ -0,0 +1,81 @@ +/// What an engine does to a whole table, schema or database, beside +/// changing a table's structure, so the sidebar offers only those. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct CatalogCapabilities { + pub truncate_table: bool, + /// Whether a truncate can empty the tables whose foreign keys point + /// at the table in the same statement. Where it can, the server + /// refuses a referenced table without it (PostgreSQL). + pub truncate_cascade: bool, + /// Whether a truncate can choose to start the table's numbering + /// again. MySQL and SQL Server always do; DuckDB and SQLite never. + pub truncate_restart_identity: bool, + /// Whether a truncate is refused for a table another table's foreign + /// key points at, whatever the rows: by the server (MySQL, SQL + /// Server), or by the plan on SQLite, whose DELETE would run the + /// other table's ON DELETE action. + pub truncate_refused_when_referenced: bool, + pub create_schema: bool, + pub drop_schema: bool, + /// Whether a schema can be dropped with everything in it. Where it + /// cannot, the server refuses a schema that holds anything. + pub drop_schema_cascade: bool, + /// Whether a new schema can name its owner. + pub schema_owner: bool, + pub create_database: bool, + pub drop_database: bool, + /// Whether a drop can first close the other sessions on the database. + pub drop_database_force: bool, + /// The options a new database takes beside its name. + pub database_fields: DatabaseFields, + pub database_noun: DatabaseNoun, + /// Whether a statement can run on every server of a cluster + /// (ClickHouse's ON CLUSTER). + pub on_cluster: bool, + /// The longest schema or database name the server keeps whole. + /// PostgreSQL cuts a longer one short with only a notice, so the + /// object would not be the one the user named. + pub name_limit: Option, +} + +/// How long a name can be, in the unit the server counts. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NameLimit { + Bytes(usize), + Chars(usize), +} + +impl NameLimit { + pub fn admits(self, name: &str) -> bool { + match self { + Self::Bytes(limit) => name.len() <= limit, + Self::Chars(limit) => name.chars().count() <= limit, + } + } +} + +/// The options the New Database dialog shows for an engine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct DatabaseFields { + pub owner: bool, + pub template: bool, + pub encoding: bool, + pub locale: bool, + pub character_set: bool, + pub collation: bool, + /// A keyspace's replication, durable writes and, on ScyllaDB, + /// tablets. + pub replication: bool, + /// ScyllaDB's tablets switch. + pub tablets: bool, + /// MongoDB keeps a database only once it holds a collection. + pub first_collection: bool, +} + +/// What the engine calls a database. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DatabaseNoun { + #[default] + Database, + Keyspace, +} diff --git a/core/crates/core/src/ddl/catalog_plans.rs b/core/crates/core/src/ddl/catalog_plans.rs new file mode 100644 index 0000000000..6457979c14 --- /dev/null +++ b/core/crates/core/src/ddl/catalog_plans.rs @@ -0,0 +1,238 @@ +//! Emptying a table, and creating and dropping a schema or database, for +//! any engine: SQL through its `DdlDialect`, MongoDB through its shell. + +use super::{ + BuildDdlError, CatalogCapabilities, CatalogOption, DatabaseDraft, DatabaseFields, DatabaseNoun, DdlOperation, + DdlPlan, DropDatabaseOptions, DropSchemaOptions, SchemaDraft, TruncateTableOptions, +}; +use crate::dialect::SqlDialect; +use crate::document; +use crate::meta::TableRef; +use crate::write_batch::WriteKind; + +/// What the engine behind `dialect` does to whole tables, schemas and +/// databases. +pub fn catalog_capabilities(dialect: &dyn SqlDialect) -> CatalogCapabilities { + if document::speaks_shell(dialect) { + return CatalogCapabilities { + truncate_table: true, + create_database: true, + drop_database: true, + database_fields: DatabaseFields { + first_collection: true, + ..DatabaseFields::default() + }, + database_noun: DatabaseNoun::Database, + ..CatalogCapabilities::default() + }; + } + dialect + .ddl() + .map(|ddl| ddl.catalog_capabilities()) + .unwrap_or_default() +} + +/// Whether the engine keeps the database `name` for itself. +pub fn protects_database(dialect: &dyn SqlDialect, name: &str) -> bool { + if document::speaks_shell(dialect) { + return MONGODB_DATABASES.contains(&name); + } + dialect.ddl().is_some_and(|ddl| ddl.protects_database(name)) +} + +/// Whether the engine keeps the schema `name` for itself. +pub fn protects_schema(dialect: &dyn SqlDialect, name: &str) -> bool { + dialect.ddl().is_some_and(|ddl| ddl.protects_schema(name)) +} + +/// The databases a MongoDB server keeps for itself. +const MONGODB_DATABASES: [&str; 3] = ["admin", "config", "local"]; + +pub fn plan_truncate_table( + dialect: &dyn SqlDialect, + table: &TableRef, + options: &TruncateTableOptions, +) -> Result { + if document::speaks_shell(dialect) { + refuse_options(&catalog_capabilities(dialect), options)?; + return Ok(DdlPlan::from_statements(vec![document::delete_all_documents(table)])); + } + ddl(dialect, DdlOperation::TruncateTable)?.plan_truncate_table(table, options) +} + +pub fn plan_create_schema(dialect: &dyn SqlDialect, draft: &SchemaDraft) -> Result { + ddl(dialect, DdlOperation::CreateSchema)?.plan_create_schema(draft) +} + +pub fn plan_drop_schema( + dialect: &dyn SqlDialect, + schema: &str, + options: DropSchemaOptions, +) -> Result { + ddl(dialect, DdlOperation::DropSchema)?.plan_drop_schema(schema, options) +} + +pub fn plan_create_database(dialect: &dyn SqlDialect, draft: &DatabaseDraft) -> Result { + if document::speaks_shell(dialect) { + check_database_draft(&catalog_capabilities(dialect), draft)?; + let name = catalog_name(CatalogOption::Name, &draft.name)?; + let collection = catalog_name( + CatalogOption::Collection, + draft.first_collection.as_deref().unwrap_or_default(), + )?; + return Ok(database_statement(document::create_database(name, collection))); + } + ddl(dialect, DdlOperation::CreateDatabase)?.plan_create_database(draft) +} + +pub fn plan_drop_database( + dialect: &dyn SqlDialect, + database: &str, + options: &DropDatabaseOptions, +) -> Result { + if document::speaks_shell(dialect) { + refuse_drop_options(&catalog_capabilities(dialect), options)?; + let name = catalog_name(CatalogOption::Name, database)?; + return Ok(database_statement(document::drop_database(name))); + } + ddl(dialect, DdlOperation::DropDatabase)?.plan_drop_database(database, options) +} + +fn database_statement(statement: crate::statement::Statement) -> DdlPlan { + DdlPlan { + kind: WriteKind::DatabaseChanges, + ..DdlPlan::from_statements(vec![statement]) + } +} + +fn ddl(dialect: &dyn SqlDialect, operation: DdlOperation) -> Result<&dyn super::DdlDialect, BuildDdlError> { + dialect.ddl().ok_or(BuildDdlError::Unsupported(operation)) +} + +/// A schema, database or collection name: anything the quoting can +/// carry, but not nothing. +pub fn catalog_name(option: CatalogOption, name: &str) -> Result<&str, BuildDdlError> { + match name.trim().is_empty() || name.contains('\0') { + true => Err(BuildDdlError::InvalidOption { + option, + value: name.to_owned(), + }), + false => Ok(name), + } +} + +/// A schema or database name, refused when empty or longer than the +/// server keeps. +pub fn named<'a>( + capabilities: &CatalogCapabilities, + option: CatalogOption, + name: &'a str, +) -> Result<&'a str, BuildDdlError> { + let name = catalog_name(option, name)?; + match capabilities.name_limit.is_none_or(|limit| limit.admits(name)) { + true => Ok(name), + false => Err(BuildDdlError::InvalidOption { + option, + value: name.to_owned(), + }), + } +} + +/// A value an engine takes only bare, such as a MySQL character set or +/// a SQL Server collation: letters, digits and underscores, so nothing +/// else can reach the statement. +pub fn bare_word(option: CatalogOption, value: &str) -> Result<&str, BuildDdlError> { + let bare = !value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_'); + match bare { + true => Ok(value), + false => Err(BuildDdlError::InvalidOption { + option, + value: value.to_owned(), + }), + } +} + +/// Refuse a truncate option the engine does not take, before any SQL. +pub fn refuse_options(capabilities: &CatalogCapabilities, options: &TruncateTableOptions) -> Result<(), BuildDdlError> { + let refused = [ + (!capabilities.truncate_table, DdlOperation::TruncateTable), + ( + options.cascade && !capabilities.truncate_cascade, + DdlOperation::TruncateTableCascade, + ), + ( + options.restart_identity && !capabilities.truncate_restart_identity, + DdlOperation::TruncateRestartIdentity, + ), + ( + options.cluster.is_some() && !capabilities.on_cluster, + DdlOperation::OnCluster, + ), + ]; + refuse_first(&refused) +} + +/// Refuse a drop option the engine does not take, before any SQL. +pub fn refuse_drop_options( + capabilities: &CatalogCapabilities, + options: &DropDatabaseOptions, +) -> Result<(), BuildDdlError> { + let refused = [ + (!capabilities.drop_database, DdlOperation::DropDatabase), + ( + options.force && !capabilities.drop_database_force, + DdlOperation::DropDatabaseForce, + ), + ( + options.cluster.is_some() && !capabilities.on_cluster, + DdlOperation::OnCluster, + ), + ]; + refuse_first(&refused) +} + +/// Refuse a new database whose options the engine has no place for, so +/// a caller that skips the dialog gets an error rather than a statement +/// that leaves an option out. +pub fn check_database_draft(capabilities: &CatalogCapabilities, draft: &DatabaseDraft) -> Result<(), BuildDdlError> { + let fields = capabilities.database_fields; + let refused = [ + (!capabilities.create_database, DdlOperation::CreateDatabase), + (draft.owner.is_some() && !fields.owner, DdlOperation::CatalogOwner), + (draft.cluster.is_some() && !capabilities.on_cluster, DdlOperation::OnCluster), + ]; + refuse_first(&refused)?; + let unplaced = [ + (draft.template.is_some() && !fields.template, CatalogOption::Template), + (draft.encoding.is_some() && !fields.encoding, CatalogOption::Encoding), + (draft.locale.is_some() && !fields.locale, CatalogOption::Locale), + ( + draft.character_set.is_some() && !fields.character_set, + CatalogOption::CharacterSet, + ), + (draft.collation.is_some() && !fields.collation, CatalogOption::Collation), + ( + draft.replication.is_some() && !fields.replication, + CatalogOption::ReplicationFactor, + ), + (draft.tablets.is_some() && !fields.tablets, CatalogOption::ReplicationFactor), + ( + draft.first_collection.is_some() && !fields.first_collection, + CatalogOption::Collection, + ), + ]; + match unplaced.iter().find(|(set, _)| *set) { + Some((_, option)) => Err(BuildDdlError::InvalidOption { + option: *option, + value: String::new(), + }), + None => Ok(()), + } +} + +fn refuse_first(refused: &[(bool, DdlOperation)]) -> Result<(), BuildDdlError> { + match refused.iter().find(|(refuses, _)| *refuses) { + Some((_, operation)) => Err(BuildDdlError::Unsupported(*operation)), + None => Ok(()), + } +} diff --git a/core/crates/core/src/ddl/catalog_tests.rs b/core/crates/core/src/ddl/catalog_tests.rs new file mode 100644 index 0000000000..5b4c35ae9d --- /dev/null +++ b/core/crates/core/src/ddl/catalog_tests.rs @@ -0,0 +1,256 @@ +use super::*; +use crate::dialect::{SqlDialect, TestDialect}; +use crate::meta::TableRef; +use crate::sql_syntax::SqlGrammar; +use crate::write_batch::WriteKind; + +#[derive(Debug)] +struct CatalogDdl { + capabilities: DdlCapabilities, + catalog: CatalogCapabilities, + sql: TestDialect, +} + +impl DdlDialect for CatalogDdl { + fn capabilities(&self) -> &DdlCapabilities { + &self.capabilities + } + + fn sql_dialect(&self) -> &dyn SqlDialect { + &self.sql + } + + fn type_suggestions(&self) -> &'static [&'static str] { + &["integer"] + } + + fn default_column_type(&self) -> &'static str { + "text" + } + + fn plan_create_table(&self, _draft: &TableDraft) -> Result { + Ok(DdlPlan::from_sql(Vec::new())) + } + + fn plan_alter_table(&self, _original: &TableDefinition, _changes: &TableChanges) -> Result { + Ok(DdlPlan::from_sql(Vec::new())) + } + + fn catalog_capabilities(&self) -> CatalogCapabilities { + self.catalog + } +} + +fn every_operation() -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + truncate_cascade: true, + truncate_restart_identity: true, + create_schema: true, + drop_schema: true, + drop_schema_cascade: true, + schema_owner: true, + create_database: true, + drop_database: true, + ..CatalogCapabilities::default() + } +} + +fn ddl(catalog: CatalogCapabilities) -> CatalogDdl { + CatalogDdl { + capabilities: DdlCapabilities::default(), + catalog, + sql: TestDialect::exact(), + } +} + +#[test] +fn a_default_truncate_writes_the_options_the_engine_takes() { + let table = TableRef::new(Some("app".to_owned()), "or\"ders"); + let both = TruncateTableOptions { + cascade: true, + restart_identity: true, + cluster: None, + }; + + let plan = ddl(every_operation()).plan_truncate_table(&table, &both).expect("a plan"); + + assert_eq!( + plan.sql(), + ["TRUNCATE TABLE \"app\".\"or\"\"ders\" RESTART IDENTITY CASCADE"] + ); + assert_eq!(plan.kind, WriteKind::SchemaChanges); + let plain = CatalogCapabilities { + truncate_table: true, + ..CatalogCapabilities::default() + }; + assert_eq!( + ddl(plain).plan_truncate_table(&table, &both), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateTableCascade)) + ); + assert_eq!( + ddl(CatalogCapabilities::default()).plan_truncate_table(&table, &TruncateTableOptions::default()), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateTable)) + ); + let on_cluster = TruncateTableOptions { + cluster: Some("main".to_owned()), + ..TruncateTableOptions::default() + }; + assert_eq!( + ddl(plain).plan_truncate_table(&table, &on_cluster), + Err(BuildDdlError::Unsupported(DdlOperation::OnCluster)) + ); +} + +#[test] +fn a_default_schema_plan_quotes_its_name_and_owner() { + let draft = SchemaDraft { + name: "sa\"les".to_owned(), + owner: Some("ann".to_owned()), + }; + + let created = ddl(every_operation()).plan_create_schema(&draft).expect("a plan"); + let dropped = ddl(every_operation()) + .plan_drop_schema("sales", DropSchemaOptions { cascade: true }) + .expect("a plan"); + + assert_eq!(created.sql(), ["CREATE SCHEMA \"sa\"\"les\" AUTHORIZATION \"ann\""]); + assert_eq!(dropped.sql(), ["DROP SCHEMA \"sales\" CASCADE"]); + let no_owner = CatalogCapabilities { + schema_owner: false, + ..every_operation() + }; + assert_eq!( + ddl(no_owner).plan_create_schema(&draft), + Err(BuildDdlError::Unsupported(DdlOperation::CatalogOwner)) + ); + let empty = SchemaDraft { + name: " ".to_owned(), + owner: None, + }; + assert!(matches!( + ddl(every_operation()).plan_create_schema(&empty), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Name, + .. + }) + )); +} + +#[test] +fn a_database_plan_runs_outside_a_transaction_and_refuses_options_it_has_no_place_for() { + let created = ddl(every_operation()) + .plan_create_database(&DatabaseDraft::named("shop")) + .expect("a plan"); + let dropped = ddl(every_operation()) + .plan_drop_database("shop", &DropDatabaseOptions::default()) + .expect("a plan"); + + assert_eq!(created.sql(), ["CREATE DATABASE \"shop\""]); + assert_eq!(created.kind, WriteKind::DatabaseChanges); + assert_eq!(dropped.sql(), ["DROP DATABASE \"shop\""]); + assert_eq!( + dropped.into_write_batch(TableRef::unqualified("shop")).kind, + WriteKind::DatabaseChanges + ); + let with_template = DatabaseDraft { + template: Some("template0".to_owned()), + ..DatabaseDraft::named("shop") + }; + assert!(matches!( + ddl(every_operation()).plan_create_database(&with_template), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Template, + .. + }) + )); + let forced = DropDatabaseOptions { + force: true, + cluster: None, + }; + assert_eq!( + ddl(every_operation()).plan_drop_database("shop", &forced), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabaseForce)) + ); +} + +#[test] +fn a_bare_word_is_letters_digits_and_underscores() { + assert_eq!(bare_word(CatalogOption::Collation, "utf8mb4_0900_ai_ci"), Ok("utf8mb4_0900_ai_ci")); + for refused in ["", "latin1'; DROP", "a b", "a-b"] { + assert!(bare_word(CatalogOption::Collation, refused).is_err(), "{refused:?}"); + } +} + +#[test] +fn mongodb_empties_a_collection_and_makes_a_database_through_its_shell() { + let shell = TestDialect::exact().parsing_as(SqlGrammar::Mongo); + let table = TableRef::new(Some("shop".to_owned()), "orders"); + + let emptied = plan_truncate_table(&shell, &table, &TruncateTableOptions::default()).expect("a plan"); + let mut draft = DatabaseDraft::named("shop"); + draft.first_collection = Some("orders".to_owned()); + let created = plan_create_database(&shell, &draft).expect("a plan"); + let dropped = plan_drop_database(&shell, "shop", &DropDatabaseOptions::default()).expect("a plan"); + + assert_eq!( + emptied.sql(), + [r#"db.getSiblingDB("shop").getCollection("orders").deleteMany({})"#] + ); + assert_eq!( + created.sql(), + [r#"db.getSiblingDB("shop").createCollection("orders")"#] + ); + assert_eq!(dropped.sql(), [r#"db.getSiblingDB("shop").dropDatabase()"#]); + assert_eq!((created.kind, dropped.kind), (WriteKind::DatabaseChanges, WriteKind::DatabaseChanges)); + assert_eq!(emptied.kind, WriteKind::SchemaChanges); + assert!(protects_database(&shell, "admin") && !protects_database(&shell, "shop")); + let capabilities = catalog_capabilities(&shell); + assert!(capabilities.truncate_table && capabilities.create_database && capabilities.drop_database); + assert!(!capabilities.create_schema && capabilities.database_fields.first_collection); + assert!(matches!( + plan_create_database(&shell, &DatabaseDraft::named("shop")), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Collection, + .. + }) + )); + let cascade = TruncateTableOptions { + cascade: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + plan_truncate_table(&shell, &table, &cascade), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateTableCascade)) + ); +} + +#[test] +fn an_engine_without_a_ddl_dialect_offers_nothing() { + let plain = TestDialect::exact(); + + assert_eq!(catalog_capabilities(&plain), CatalogCapabilities::default()); + assert_eq!( + plan_create_schema(&plain, &SchemaDraft::default()), + Err(BuildDdlError::Unsupported(DdlOperation::CreateSchema)) + ); +} + +#[test] +fn a_name_longer_than_the_server_keeps_is_refused() { + let limited = CatalogCapabilities { + name_limit: Some(NameLimit::Bytes(4)), + ..every_operation() + }; + + assert!(ddl(limited).plan_create_database(&DatabaseDraft::named("shop")).is_ok()); + assert!(matches!( + ddl(limited).plan_create_database(&DatabaseDraft::named("shops")), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Name, + .. + }) + )); + assert!(NameLimit::Chars(3).admits("ŝöp")); + assert!(!NameLimit::Bytes(4).admits("ŝöp")); +} diff --git a/core/crates/core/src/ddl/database_choices.rs b/core/crates/core/src/ddl/database_choices.rs new file mode 100644 index 0000000000..c428e665d8 --- /dev/null +++ b/core/crates/core/src/ddl/database_choices.rs @@ -0,0 +1,33 @@ +use super::CharacterSet; + +/// What the server offers for the options of a new database, read when +/// the New Database dialog opens. A list an engine has no use for stays +/// empty. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DatabaseChoices { + /// Roles that can own a database or schema. + pub owners: Vec, + /// Databases a new one can be copied from. + pub templates: Vec, + pub encodings: Vec, + pub locales: Vec, + pub character_sets: Vec, + pub collations: Vec, + /// Clusters with more than one server, which ON CLUSTER can name. A + /// single server lists none. + pub clusters: Vec, + /// Data centres, for a keyspace's replication. + pub data_centers: Vec, + /// What the server uses when the option is left out, where it says. + pub defaults: DatabaseDefaults, +} + +/// The server's own choice for an option left out. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct DatabaseDefaults { + pub owner: Option, + pub encoding: Option, + pub locale: Option, + pub character_set: Option, + pub collation: Option, +} diff --git a/core/crates/core/src/ddl/database_draft.rs b/core/crates/core/src/ddl/database_draft.rs new file mode 100644 index 0000000000..81808bc951 --- /dev/null +++ b/core/crates/core/src/ddl/database_draft.rs @@ -0,0 +1,93 @@ +/// A database, or a Cassandra keyspace, to create. Each option is left +/// out of the statement while it is `None`, so the server's default +/// holds. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct DatabaseDraft { + pub name: String, + pub owner: Option, + pub template: Option, + pub encoding: Option, + /// The collation and character classification together, as + /// PostgreSQL's LOCALE sets both. + pub locale: Option, + pub character_set: Option, + pub collation: Option, + pub cluster: Option, + pub replication: Option, + pub durable_writes: bool, + /// ScyllaDB's tablets: `None` leaves the server's default. + pub tablets: Option, + /// The collection MongoDB creates the database with. + pub first_collection: Option, +} + +impl DatabaseDraft { + pub fn named(name: impl Into) -> Self { + Self { + name: name.into(), + owner: None, + template: None, + encoding: None, + locale: None, + character_set: None, + collation: None, + cluster: None, + replication: None, + durable_writes: true, + tablets: None, + first_collection: None, + } + } +} + +/// How a keyspace places its replicas. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum KeyspaceReplication { + /// One factor for the whole cluster. + Simple { factor: u32 }, + /// A factor per data centre, by name. + NetworkTopology { factors: Vec<(String, u32)> }, +} + +/// How a database is dropped. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct DropDatabaseOptions { + /// Close the other sessions on it first. Only an engine with + /// `drop_database_force` takes it. + pub force: bool, + pub cluster: Option, +} + +/// An option of a schema or database the statement could not carry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum CatalogOption { + Name, + Owner, + Template, + Encoding, + Locale, + CharacterSet, + Collation, + Cluster, + DataCenter, + ReplicationFactor, + Collection, +} + +impl std::fmt::Display for CatalogOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Name => "name", + Self::Owner => "owner", + Self::Template => "template", + Self::Encoding => "encoding", + Self::Locale => "locale", + Self::CharacterSet => "character set", + Self::Collation => "collation", + Self::Cluster => "cluster", + Self::DataCenter => "data centre", + Self::ReplicationFactor => "replication factor", + Self::Collection => "collection", + }) + } +} diff --git a/core/crates/core/src/ddl/ddl_dialect.rs b/core/crates/core/src/ddl/ddl_dialect.rs index 4fe8d919e9..97bbd2542e 100644 --- a/core/crates/core/src/ddl/ddl_dialect.rs +++ b/core/crates/core/src/ddl/ddl_dialect.rs @@ -1,6 +1,8 @@ +use super::catalog_plans::{catalog_name, check_database_draft, named, refuse_drop_options, refuse_options}; use super::{ - BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlOperation, DdlPlan, DropTableOptions, - Permission, ReferentialAction, TableChanges, TableDefinition, TableDraft, + BuildDdlError, CatalogCapabilities, CatalogOption, ColumnCapabilities, ColumnDefinition, DatabaseDraft, + DdlCapabilities, DdlOperation, DdlPlan, DropDatabaseOptions, DropSchemaOptions, DropTableOptions, Permission, + ReferentialAction, SchemaDraft, TableChanges, TableDefinition, TableDraft, TruncateTableOptions, }; use crate::dialect::SqlDialect; use crate::meta::{IndexInfo, TableRef}; @@ -75,6 +77,92 @@ pub trait DdlDialect: Send + Sync + std::fmt::Debug { )])) } + /// What the engine does to whole tables, schemas and databases. The + /// plans below refuse what this leaves out. + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities::default() + } + + /// Whether the engine keeps this database for itself, so it is never + /// offered for a drop: the server's own, and any it refuses to drop. + fn protects_database(&self, _name: &str) -> bool { + false + } + + /// Whether the engine keeps this schema for itself, as SQL Server + /// keeps `dbo` and DuckDB `main`. + fn protects_schema(&self, _name: &str) -> bool { + false + } + + /// Empty the table and keep its definition. + fn plan_truncate_table(&self, table: &TableRef, options: &TruncateTableOptions) -> Result { + refuse_options(&self.catalog_capabilities(), options)?; + let restart = if options.restart_identity { " RESTART IDENTITY" } else { "" }; + let cascade = if options.cascade { " CASCADE" } else { "" }; + Ok(DdlPlan::from_sql([format!( + "TRUNCATE TABLE {}{restart}{cascade}", + self.sql_dialect().qualify(table) + )])) + } + + fn plan_create_schema(&self, draft: &SchemaDraft) -> Result { + let capabilities = self.catalog_capabilities(); + if !capabilities.create_schema { + return Err(BuildDdlError::Unsupported(DdlOperation::CreateSchema)); + } + if draft.owner.is_some() && !capabilities.schema_owner { + return Err(BuildDdlError::Unsupported(DdlOperation::CatalogOwner)); + } + let dialect = self.sql_dialect(); + let mut sql = format!( + "CREATE SCHEMA {}", + dialect.quote_identifier(named(&capabilities, CatalogOption::Name, &draft.name)?) + ); + if let Some(owner) = &draft.owner { + sql.push_str(" AUTHORIZATION "); + sql.push_str(&dialect.quote_identifier(catalog_name(CatalogOption::Owner, owner)?)); + } + Ok(DdlPlan::from_sql([sql])) + } + + fn plan_drop_schema(&self, schema: &str, options: DropSchemaOptions) -> Result { + let capabilities = self.catalog_capabilities(); + if !capabilities.drop_schema { + return Err(BuildDdlError::Unsupported(DdlOperation::DropSchema)); + } + if options.cascade && !capabilities.drop_schema_cascade { + return Err(BuildDdlError::Unsupported(DdlOperation::DropSchemaCascade)); + } + let cascade = if options.cascade { " CASCADE" } else { "" }; + Ok(DdlPlan::from_sql([format!( + "DROP SCHEMA {}{cascade}", + self.sql_dialect() + .quote_identifier(catalog_name(CatalogOption::Name, schema)?) + )])) + } + + /// A new database with only its name. An engine that offers options + /// writes them in its own override. + fn plan_create_database(&self, draft: &DatabaseDraft) -> Result { + let capabilities = self.catalog_capabilities(); + check_database_draft(&capabilities, draft)?; + Ok(DdlPlan::for_databases([format!( + "CREATE DATABASE {}", + self.sql_dialect() + .quote_identifier(named(&capabilities, CatalogOption::Name, &draft.name)?) + )])) + } + + fn plan_drop_database(&self, database: &str, options: &DropDatabaseOptions) -> Result { + refuse_drop_options(&self.catalog_capabilities(), options)?; + Ok(DdlPlan::for_databases([format!( + "DROP DATABASE {}", + self.sql_dialect() + .quote_identifier(catalog_name(CatalogOption::Name, database)?) + )])) + } + /// Statements that create the table as it is defined now: the /// server's own text where the engine keeps one, otherwise written /// from the definition. diff --git a/core/crates/core/src/ddl/ddl_operation.rs b/core/crates/core/src/ddl/ddl_operation.rs index e06dace165..0224e40288 100644 --- a/core/crates/core/src/ddl/ddl_operation.rs +++ b/core/crates/core/src/ddl/ddl_operation.rs @@ -37,6 +37,23 @@ pub enum DdlOperation { /// NOT NULL on a column outside the primary key, on an engine with /// no such constraint. RequiredColumn, + TruncateTable, + /// Emptying the tables whose foreign keys point at the table too. + TruncateTableCascade, + /// Starting a table's numbering again as it is emptied. + TruncateRestartIdentity, + CreateSchema, + DropSchema, + /// Dropping a schema with everything in it. + DropSchemaCascade, + /// An owner other than the user who creates a schema or database. + CatalogOwner, + CreateDatabase, + DropDatabase, + /// Closing the other sessions on a database so it can be dropped. + DropDatabaseForce, + /// Running the statement on every server of a cluster. + OnCluster, } impl fmt::Display for DdlOperation { @@ -68,6 +85,17 @@ impl fmt::Display for DdlOperation { Self::DropForeignKey => "dropping a foreign key", Self::ColumnDefault => "a column default", Self::RequiredColumn => "a column that refuses NULL outside the primary key", + Self::TruncateTable => "emptying a table", + Self::TruncateTableCascade => "emptying a table with the tables that reference it", + Self::TruncateRestartIdentity => "restarting a table's numbering", + Self::CreateSchema => "creating a schema", + Self::DropSchema => "dropping a schema", + Self::DropSchemaCascade => "dropping a schema with everything in it", + Self::CatalogOwner => "naming an owner", + Self::CreateDatabase => "creating a database", + Self::DropDatabase => "dropping a database", + Self::DropDatabaseForce => "closing other sessions on a database", + Self::OnCluster => "running on a cluster", }) } } diff --git a/core/crates/core/src/ddl/ddl_plan.rs b/core/crates/core/src/ddl/ddl_plan.rs index 8b3b39d142..e1a7909bad 100644 --- a/core/crates/core/src/ddl/ddl_plan.rs +++ b/core/crates/core/src/ddl/ddl_plan.rs @@ -9,6 +9,9 @@ use crate::write_batch::{WriteBatch, WriteKind}; #[derive(Debug, Clone, PartialEq)] pub struct DdlPlan { pub statements: Vec, + /// How the write is wrapped: a database is created or dropped + /// outside any transaction. + pub kind: WriteKind, pub foreign_keys: ForeignKeyMode, pub precondition: Option, /// Set where the plan copies the table into a new one, which the @@ -29,6 +32,27 @@ impl DdlPlan { builder.finish() }) .collect(), + kind: WriteKind::SchemaChanges, + foreign_keys: ForeignKeyMode::Enforced, + precondition: None, + rebuild: None, + } + } + + /// SQL that creates or drops a database, which PostgreSQL and SQL + /// Server refuse inside a transaction. + pub fn for_databases(statements: impl IntoIterator) -> Self { + Self { + kind: WriteKind::DatabaseChanges, + ..Self::from_sql(statements) + } + } + + /// Shell calls with their values bound, as MongoDB runs them. + pub fn from_statements(statements: Vec) -> Self { + Self { + statements, + kind: WriteKind::SchemaChanges, foreign_keys: ForeignKeyMode::Enforced, precondition: None, rebuild: None, @@ -79,8 +103,8 @@ impl DdlPlan { /// `table` is the table as it is named before the plan runs. pub fn into_write_batch(self, table: TableRef) -> WriteBatch { - let foreign_keys = self.foreign_keys.clone(); - WriteBatch::new(WriteKind::SchemaChanges, table, self.into_steps(), foreign_keys) + let (kind, foreign_keys) = (self.kind, self.foreign_keys.clone()); + WriteBatch::new(kind, table, self.into_steps(), foreign_keys) } } diff --git a/core/crates/core/src/ddl/mod.rs b/core/crates/core/src/ddl/mod.rs index 9e14707b41..c56d94baf6 100644 --- a/core/crates/core/src/ddl/mod.rs +++ b/core/crates/core/src/ddl/mod.rs @@ -3,6 +3,8 @@ //! turn them into DDL. mod build_ddl_error; +mod catalog_capabilities; +mod catalog_plans; mod change_review; mod changed_attributes; mod character_set; @@ -15,6 +17,8 @@ mod column_key_roles; mod column_rename; mod column_storage; mod column_visibility; +mod database_choices; +mod database_draft; mod ddl_capabilities; mod ddl_dialect; mod ddl_operation; @@ -39,6 +43,7 @@ mod primary_key_change; mod primary_key_constraint; mod referential_action; mod refusal_reason; +mod schema_draft; mod schema_object_source; mod table_changes; mod table_definition; @@ -47,12 +52,21 @@ mod table_options; mod table_options_change; mod table_rebuild; mod table_source; +mod truncate_table_options; mod unmodeled_attribute; +#[cfg(test)] +mod catalog_tests; #[cfg(test)] mod tests; pub use build_ddl_error::BuildDdlError; +pub use catalog_capabilities::{CatalogCapabilities, DatabaseFields, DatabaseNoun, NameLimit}; +pub use catalog_plans::{ + bare_word, catalog_capabilities, catalog_name, check_database_draft, named, plan_create_database, plan_create_schema, + plan_drop_database, plan_drop_schema, plan_truncate_table, protects_database, protects_schema, refuse_drop_options, + refuse_options, +}; pub use change_review::ChangeReview; pub use changed_attributes::ChangedAttributes; pub use character_set::CharacterSet; @@ -65,6 +79,8 @@ pub use column_key_roles::ColumnKeyRoles; pub use column_rename::ColumnRename; pub use column_storage::{ColumnCompression, ColumnStorage}; pub use column_visibility::ColumnVisibility; +pub use database_choices::{DatabaseChoices, DatabaseDefaults}; +pub use database_draft::{CatalogOption, DatabaseDraft, DropDatabaseOptions, KeyspaceReplication}; pub use ddl_capabilities::DdlCapabilities; pub use ddl_dialect::DdlDialect; pub use ddl_operation::DdlOperation; @@ -88,6 +104,7 @@ pub use primary_key_change::PrimaryKeyChange; pub use primary_key_constraint::PrimaryKeyConstraint; pub use referential_action::ReferentialAction; pub use refusal_reason::RefusalReason; +pub use schema_draft::{DropSchemaOptions, SchemaDraft}; pub use schema_object_source::{SchemaObjectKind, SchemaObjectSource}; pub use table_changes::TableChanges; pub use table_definition::TableDefinition; @@ -96,4 +113,5 @@ pub use table_options::TableOptions; pub use table_options_change::{CharacterSetChoice, TableOptionsChange}; pub use table_rebuild::{RecreatedObject, TableRebuild}; pub use table_source::TableSource; +pub use truncate_table_options::TruncateTableOptions; pub use unmodeled_attribute::UnmodeledAttribute; diff --git a/core/crates/core/src/ddl/schema_draft.rs b/core/crates/core/src/ddl/schema_draft.rs new file mode 100644 index 0000000000..f2581834f9 --- /dev/null +++ b/core/crates/core/src/ddl/schema_draft.rs @@ -0,0 +1,16 @@ +/// A schema to create. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct SchemaDraft { + pub name: String, + /// The role that owns it, or the user who creates it. Only an engine + /// with `schema_owner` takes one. + pub owner: Option, +} + +/// How a schema is dropped. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct DropSchemaOptions { + /// Drop everything in it, and what depends on that elsewhere. Only an + /// engine with `drop_schema_cascade` takes it. + pub cascade: bool, +} diff --git a/core/crates/core/src/ddl/truncate_table_options.rs b/core/crates/core/src/ddl/truncate_table_options.rs new file mode 100644 index 0000000000..f4341d88b3 --- /dev/null +++ b/core/crates/core/src/ddl/truncate_table_options.rs @@ -0,0 +1,13 @@ +/// How a table is emptied. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)] +pub struct TruncateTableOptions { + /// Empty the tables whose foreign keys point at it too. Only an + /// engine with `truncate_cascade` takes it. + pub cascade: bool, + /// Start the table's numbering again. Only an engine with + /// `truncate_restart_identity` takes it. + pub restart_identity: bool, + /// Run it on every server of this cluster. Only an engine with + /// `on_cluster` takes it. + pub cluster: Option, +} diff --git a/core/crates/core/src/document/document_catalog.rs b/core/crates/core/src/document/document_catalog.rs new file mode 100644 index 0000000000..6c7fae4cb4 --- /dev/null +++ b/core/crates/core/src/document/document_catalog.rs @@ -0,0 +1,41 @@ +use crate::meta::TableRef; +use crate::sql_syntax::mongo::{CallRoot, ShellCall, ShellMethod, ShellValue}; +use crate::statement::Statement; + +use super::ShellParams; +use super::document_read::collection; + +/// Delete every document and keep the collection with its indexes and +/// options, which `drop()` would take too. +pub(crate) fn delete_all_documents(table: &TableRef) -> Statement { + let mut call = collection(table); + call.methods + .push(ShellMethod::new("deleteMany", vec![ShellValue::Object(Vec::new())])); + ShellParams::new().statement(&call) +} + +/// MongoDB keeps a database only once something is stored in it, so a +/// new one is made by creating its first collection. +pub(crate) fn create_database(database: &str, first_collection: &str) -> Statement { + let mut call = database_call(database); + call.methods.push(ShellMethod::new( + "createCollection", + vec![ShellValue::string(first_collection)], + )); + ShellParams::new().statement(&call) +} + +pub(crate) fn drop_database(database: &str) -> Statement { + let mut call = database_call(database); + call.methods.push(ShellMethod::new("dropDatabase", Vec::new())); + ShellParams::new().statement(&call) +} + +fn database_call(database: &str) -> ShellCall { + ShellCall { + root: CallRoot::Db, + database: Some(database.to_owned()), + collection: None, + methods: Vec::new(), + } +} diff --git a/core/crates/core/src/document/mod.rs b/core/crates/core/src/document/mod.rs index ba5d558e99..b85ba3cc80 100644 --- a/core/crates/core/src/document/mod.rs +++ b/core/crates/core/src/document/mod.rs @@ -5,6 +5,7 @@ //! `SqlGrammar::Mongo`. Each builder writes one mongosh call, and every //! value the user gave is bound as `$n`, never written into the text. +mod document_catalog; mod document_filter; mod document_read; mod document_write; @@ -15,6 +16,7 @@ mod shell_params; mod tests; mod value_order; +pub(crate) use document_catalog::{create_database, delete_all_documents, drop_database}; pub(crate) use document_filter::{filter_document, typed_document}; pub(crate) use document_read::{count, export, page}; pub(crate) use document_write::{delete, insert, insert_many, key_probe, update}; diff --git a/core/crates/core/src/error.rs b/core/crates/core/src/error.rs index c20a9e5905..3568fbc27e 100644 --- a/core/crates/core/src/error.rs +++ b/core/crates/core/src/error.rs @@ -108,6 +108,12 @@ pub enum DriverError { #[error("no database file at {}", .path.display())] FileNotFound { path: PathBuf }, + /// The server has no database by the name the connection opens, as + /// when it was dropped. Retrying cannot help until the connection + /// names another one or the database is made again. + #[error("the server has no database {database:?}")] + DatabaseNotFound { database: String }, + #[error("no permission to open {}", .path.display())] FileAccessDenied { path: PathBuf }, @@ -160,6 +166,7 @@ impl DriverError { Self::CommitOutcomeUnknown { .. } => "driver.commit_outcome_unknown", Self::PartiallyApplied { .. } => "driver.partially_applied", Self::FileNotFound { .. } => "driver.file_not_found", + Self::DatabaseNotFound { .. } => "driver.database_not_found", Self::FileAccessDenied { .. } => "driver.file_access_denied", Self::NotADatabase { .. } => "driver.not_a_database", Self::FileIo { .. } => "driver.file_io", @@ -208,7 +215,7 @@ impl DriverError { | Self::NotADatabase { .. } | Self::FileIo { .. } => ErrorCategory::File, Self::Busy(_) | Self::EditorSessionBusy => ErrorCategory::Busy, - Self::Config(_) | Self::Unsupported { .. } => ErrorCategory::Configuration, + Self::Config(_) | Self::Unsupported { .. } | Self::DatabaseNotFound { .. } => ErrorCategory::Configuration, Self::GuardUnsupported { .. } | Self::Decode { .. } | Self::Protocol(_) => ErrorCategory::Internal, } } diff --git a/core/crates/core/src/unsupported_feature.rs b/core/crates/core/src/unsupported_feature.rs index 7c58083c7e..0430ee450f 100644 --- a/core/crates/core/src/unsupported_feature.rs +++ b/core/crates/core/src/unsupported_feature.rs @@ -50,6 +50,8 @@ pub enum UnsupportedFeature { CharacterSets, /// Another of the server's databases for a query tab. DatabaseSwitching, + /// The options a new database can take. + DatabaseOptions, /// The SQL that defines a routine, a sequence or a trigger. ObjectDefinitions, /// The SQL of a SQL Server routine whose code is a .NET assembly. @@ -88,6 +90,7 @@ impl fmt::Display for UnsupportedFeature { Self::Keys => f.write_str("keys"), Self::CharacterSets => f.write_str("character sets"), Self::DatabaseSwitching => f.write_str("database switching"), + Self::DatabaseOptions => f.write_str("options for a new database"), Self::ObjectDefinitions => f.write_str("object definitions"), Self::ClrRoutine => f.write_str("the SQL of a CLR routine"), Self::EncryptedDefinition => f.write_str("the SQL of an encrypted module"), diff --git a/core/crates/core/src/write_batch.rs b/core/crates/core/src/write_batch.rs index 9a7653707d..30dff881e4 100644 --- a/core/crates/core/src/write_batch.rs +++ b/core/crates/core/src/write_batch.rs @@ -11,6 +11,9 @@ use crate::statement::GuardedStatement; pub enum WriteKind { RowChanges, SchemaChanges, + /// Creates or drops a database. PostgreSQL and SQL Server refuse + /// that inside a transaction, so each statement runs on its own. + DatabaseChanges, } /// One save, as the steps that make it up. diff --git a/core/crates/drivers/cassandra/src/catalog.rs b/core/crates/drivers/cassandra/src/catalog.rs index 21d8d2c9a7..2f1e534306 100644 --- a/core/crates/drivers/cassandra/src/catalog.rs +++ b/core/crates/drivers/cassandra/src/catalog.rs @@ -1,6 +1,6 @@ //! What the server's schema tables say about keyspaces and tables. -use std::collections::{HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet}; use tablepro_core::DriverError; use tablepro_core::column::{ColumnDefault, ColumnInfo}; @@ -107,6 +107,23 @@ pub(crate) async fn list_schemas(link: &CqlLink, default: Option<&str>) -> Resul .collect()) } +/// The cluster's data centres as the node the session stays on knows +/// them: its own and its peers'. +pub(crate) async fn data_centers(link: &CqlLink) -> Result, DriverError> { + let mut centres = BTreeSet::new(); + for sql in [ + "SELECT data_center FROM system.local", + "SELECT data_center FROM system.peers", + ] { + let rows = link.catalogue(sql, ()).await?.into_rows_result().map_err(rows_error)?; + for row in rows.rows::<(Option,)>().map_err(rows_error)? { + let (centre,) = row.map_err(rows_error)?; + centres.extend(centre); + } + } + Ok(centres.into_iter().collect()) +} + /// Tables, then materialized views. ScyllaDB keeps each secondary index /// as a view named after it, which is the index's and not listed, and a /// table's lightweight transaction state in a `$paxos` table, a diff --git a/core/crates/drivers/cassandra/src/connection.rs b/core/crates/drivers/cassandra/src/connection.rs index 599e39f40d..607ee1f4f3 100644 --- a/core/crates/drivers/cassandra/src/connection.rs +++ b/core/crates/drivers/cassandra/src/connection.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_trait::async_trait; use tablepro_core::column::ColumnInfo; -use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey, TableDefinition}; +use tablepro_core::ddl::{DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ IndexInfo, ObjectInfo, ObjectScope, RowCountEstimate, RowIdentity, SchemaInfo, TableEstimate, TableRef, @@ -249,6 +249,18 @@ impl Connection for CqlConnection { self.list_schemas(options).await } + /// A keyspace's replication names data centres; nothing else is + /// chosen when one is made. + async fn fetch_database_choices(&self, options: CallOptions) -> Result { + self.read(options, move |link, _| async move { + catalog::data_centers(&link).await.map(|data_centers| DatabaseChoices { + data_centers, + ..DatabaseChoices::default() + }) + }) + .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/core/crates/drivers/cassandra/src/ddl.rs b/core/crates/drivers/cassandra/src/ddl.rs index 627ce19679..5e0bfe1d6d 100644 --- a/core/crates/drivers/cassandra/src/ddl.rs +++ b/core/crates/drivers/cassandra/src/ddl.rs @@ -1,11 +1,20 @@ +use std::collections::HashSet; + use tablepro_core::ddl::{ - BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, - DraftProblem, Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, TableDraft, + BuildDdlError, CatalogCapabilities, CatalogOption, ColumnCapabilities, ColumnDefinition, DatabaseDraft, + DatabaseFields, DatabaseNoun, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, DraftProblem, DropDatabaseOptions, + KeyspaceReplication, NameLimit, Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, + TableDraft, bare_word, catalog_name, check_database_draft, named, refuse_drop_options, }; use tablepro_core::dialect::SqlDialect; +use crate::catalog::is_system_keyspace; +use crate::decode::quoted; use crate::dialect::CqlDialect; +/// The replication map's own keys, which no data centre can share. +const REPLICATION_KEYS: [&str; 2] = ["class", "replication_factor"]; + static CAPABILITIES: DdlCapabilities = DdlCapabilities { create_table: true, rename_table: false, @@ -159,6 +168,109 @@ impl DdlDialect for CqlDialect { std::iter::once(source.create_statement.clone()).chain(dependents), )) } + + /// A keyspace is the database, and TRUNCATE needs every node of the + /// cluster up. + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + create_database: true, + drop_database: true, + database_fields: DatabaseFields { + replication: true, + tablets: self.is_scylla(), + ..DatabaseFields::default() + }, + database_noun: DatabaseNoun::Keyspace, + name_limit: Some(NameLimit::Chars(48)), + ..CatalogCapabilities::default() + } + } + + fn protects_database(&self, name: &str) -> bool { + is_system_keyspace(name) + } + + /// The server takes a keyspace name of letters, digits and + /// underscores, quoted or not, so nothing else is written. + fn plan_create_database(&self, draft: &DatabaseDraft) -> Result { + let capabilities = self.catalog_capabilities(); + check_database_draft(&capabilities, draft)?; + let name = named( + &capabilities, + CatalogOption::Name, + bare_word(CatalogOption::Name, &draft.name)?, + )?; + let replication = draft.replication.as_ref().ok_or_else(|| BuildDdlError::InvalidOption { + option: CatalogOption::ReplicationFactor, + value: String::new(), + })?; + let mut sql = format!( + "CREATE KEYSPACE {} WITH replication = {}", + self.quote_identifier(name), + replication_map(replication)? + ); + if !draft.durable_writes { + sql.push_str(" AND durable_writes = false"); + } + // Only turning tablets off is written; otherwise the server's own default holds. + if draft.tablets == Some(false) { + sql.push_str(" AND tablets = {'enabled': false}"); + } + Ok(DdlPlan::for_databases([sql])) + } + + fn plan_drop_database(&self, database: &str, options: &DropDatabaseOptions) -> Result { + refuse_drop_options(&self.catalog_capabilities(), options)?; + Ok(DdlPlan::for_databases([format!( + "DROP KEYSPACE {}", + self.quote_identifier(catalog_name(CatalogOption::Name, database)?) + )])) + } +} + +/// The replication map of a new keyspace, each data centre named as a +/// CQL string. +fn replication_map(replication: &KeyspaceReplication) -> Result { + let factors = match replication { + KeyspaceReplication::Simple { factor } => { + return Ok(format!( + "{{'class': 'SimpleStrategy', 'replication_factor': {}}}", + replicas(*factor)? + )); + } + KeyspaceReplication::NetworkTopology { factors } => factors, + }; + if factors.is_empty() { + return Err(BuildDdlError::InvalidOption { + option: CatalogOption::DataCenter, + value: String::new(), + }); + } + let mut seen = HashSet::new(); + let mut entries = vec!["'class': 'NetworkTopologyStrategy'".to_owned()]; + for (centre, factor) in factors { + let centre = catalog_name(CatalogOption::DataCenter, centre)?; + if REPLICATION_KEYS.contains(¢re) || !seen.insert(centre) { + return Err(BuildDdlError::InvalidOption { + option: CatalogOption::DataCenter, + value: centre.to_owned(), + }); + } + entries.push(format!("{}: {}", quoted(centre), replicas(*factor)?)); + } + Ok(format!("{{{}}}", entries.join(", "))) +} + +/// A keyspace with no replicas could hold nothing. +fn replicas(factor: u32) -> Result { + match factor { + 0 => Err(BuildDdlError::InvalidOption { + option: CatalogOption::ReplicationFactor, + value: factor.to_string(), + }), + factor => Ok(factor), + } } impl CqlDialect { @@ -170,11 +282,14 @@ impl CqlDialect { #[cfg(test)] mod tests { - use tablepro_core::ServerInfo; use tablepro_core::column::{ColumnDefault, ColumnInfo}; - use tablepro_core::ddl::{ColumnAttributes, ColumnKeyRoles, DdlOperation, DraftType, TableSource, diff_table}; + use tablepro_core::ddl::{ + self as catalog, ColumnAttributes, ColumnKeyRoles, DdlOperation, DraftType, DropSchemaOptions, SchemaDraft, + TableSource, TruncateTableOptions, diff_table, + }; use tablepro_core::meta::TableRef; use tablepro_core::value::Value; + use tablepro_core::{ServerInfo, WriteKind}; use super::*; use crate::cql_type::CqlType; @@ -338,4 +453,204 @@ mod tests { ] ); } + + fn scylla() -> CqlDialect { + CqlDialect::new(&ServerInfo::new("ScyllaDB", "2026.1.0", 2026, 1, 0)) + } + + fn keyspace(replication: KeyspaceReplication) -> DatabaseDraft { + DatabaseDraft { + replication: Some(replication), + ..DatabaseDraft::named("shop") + } + } + + fn invalid(plan: Result) -> Option { + match plan { + Err(BuildDdlError::InvalidOption { option, .. }) => Some(option), + _ => None, + } + } + + #[test] + fn a_truncate_names_the_keyspace_and_takes_no_option() { + let table = TableRef::new(Some("shop".to_owned()), "us\"ers"); + + let plan = dialect() + .plan_truncate_table(&table, &TruncateTableOptions::default()) + .unwrap(); + + assert_eq!(plan.sql(), ["TRUNCATE TABLE \"shop\".\"us\"\"ers\""]); + let restart = TruncateTableOptions { + restart_identity: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + dialect().plan_truncate_table(&table, &restart), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateRestartIdentity)) + ); + } + + #[test] + fn a_keyspace_places_its_replicas_per_data_centre_or_across_the_cluster() { + let topology = keyspace(KeyspaceReplication::NetworkTopology { + factors: vec![("dc1".to_owned(), 3), ("it's".to_owned(), 2)], + }); + let mut simple = keyspace(KeyspaceReplication::Simple { factor: 1 }); + simple.durable_writes = false; + + let planned = dialect().plan_create_database(&topology).unwrap(); + + assert_eq!( + planned.sql(), + ["CREATE KEYSPACE \"shop\" WITH replication = \ + {'class': 'NetworkTopologyStrategy', 'dc1': 3, 'it''s': 2}"] + ); + assert_eq!(planned.kind, WriteKind::DatabaseChanges); + assert_eq!( + dialect().plan_create_database(&simple).unwrap().sql(), + ["CREATE KEYSPACE \"shop\" WITH replication = \ + {'class': 'SimpleStrategy', 'replication_factor': 1} AND durable_writes = false"] + ); + let dropped = dialect() + .plan_drop_database("Shop", &DropDatabaseOptions::default()) + .unwrap(); + assert_eq!(dropped.sql(), ["DROP KEYSPACE \"Shop\""]); + assert_eq!(dropped.kind, WriteKind::DatabaseChanges); + let forced = DropDatabaseOptions { + force: true, + cluster: None, + }; + assert_eq!( + dialect().plan_drop_database("shop", &forced), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabaseForce)) + ); + } + + #[test] + fn only_scylladb_turns_tablets_off() { + let mut draft = keyspace(KeyspaceReplication::NetworkTopology { + factors: vec![("dc1".to_owned(), 1)], + }); + draft.tablets = Some(false); + + assert_eq!( + scylla().plan_create_database(&draft).unwrap().sql(), + ["CREATE KEYSPACE \"shop\" WITH replication = \ + {'class': 'NetworkTopologyStrategy', 'dc1': 1} AND tablets = {'enabled': false}"] + ); + assert_eq!( + invalid(dialect().plan_create_database(&draft)), + Some(CatalogOption::ReplicationFactor) + ); + draft.tablets = Some(true); + assert_eq!( + scylla().plan_create_database(&draft).unwrap().sql(), + ["CREATE KEYSPACE \"shop\" WITH replication = {'class': 'NetworkTopologyStrategy', 'dc1': 1}"] + ); + assert!(scylla().catalog_capabilities().database_fields.tablets); + assert!(!dialect().catalog_capabilities().database_fields.tablets); + } + + #[test] + fn a_keyspace_the_server_would_refuse_is_not_planned() { + let dialect = dialect(); + let topology = |factors: Vec<(&str, u32)>| { + keyspace(KeyspaceReplication::NetworkTopology { + factors: factors + .into_iter() + .map(|(centre, factor)| (centre.to_owned(), factor)) + .collect(), + }) + }; + let renamed = |name: String| DatabaseDraft { + name, + ..topology(vec![("dc1", 1)]) + }; + + assert_eq!( + invalid(dialect.plan_create_database(&DatabaseDraft::named("shop"))), + Some(CatalogOption::ReplicationFactor) + ); + assert_eq!( + invalid(dialect.plan_create_database(&keyspace(KeyspaceReplication::Simple { factor: 0 }))), + Some(CatalogOption::ReplicationFactor) + ); + assert_eq!( + invalid(dialect.plan_create_database(&topology(vec![("dc1", 0)]))), + Some(CatalogOption::ReplicationFactor) + ); + for centres in [vec![], vec![("", 1)], vec![("class", 1)], vec![("dc1", 1), ("dc1", 2)]] { + assert_eq!( + invalid(dialect.plan_create_database(&topology(centres.clone()))), + Some(CatalogOption::DataCenter), + "{centres:?}" + ); + } + for name in ["sh-op".to_owned(), "sh\"op".to_owned(), "k".repeat(49), String::new()] { + assert_eq!( + invalid(dialect.plan_create_database(&renamed(name.clone()))), + Some(CatalogOption::Name), + "{name:?}" + ); + } + assert!(dialect.plan_create_database(&renamed("k".repeat(48))).is_ok()); + assert!(dialect.plan_create_database(&renamed("1st_Shop".to_owned())).is_ok()); + let mut collated = topology(vec![("dc1", 1)]); + collated.collation = Some("utf8".to_owned()); + assert_eq!( + invalid(dialect.plan_create_database(&collated)), + Some(CatalogOption::Collation) + ); + } + + #[test] + fn the_system_keyspaces_are_protected() { + for name in ["system", "system_auth", "system_schema", "system_views"] { + assert!(dialect().protects_database(name), "{name}"); + } + assert!(!dialect().protects_database("shop")); + assert!(!dialect().protects_database("systems")); + } + + /// The sidebar offers what the capabilities say, so each one has to + /// plan, with every option it offers, and nothing else may. + #[test] + fn every_offered_catalog_operation_plans_and_no_other() { + for dialect in [dialect(), scylla()] { + let offered = catalog::catalog_capabilities(&dialect); + let table = TableRef::new(Some("shop".to_owned()), "users"); + let truncate = TruncateTableOptions { + cascade: offered.truncate_cascade, + restart_identity: offered.truncate_restart_identity, + cluster: None, + }; + let mut draft = keyspace(KeyspaceReplication::NetworkTopology { + factors: vec![("dc1".to_owned(), 1)], + }); + draft.durable_writes = false; + draft.tablets = offered.database_fields.tablets.then_some(false); + let drop_options = DropDatabaseOptions { + force: offered.drop_database_force, + cluster: None, + }; + + assert!(offered.truncate_table && offered.create_database && offered.drop_database); + assert!(offered.database_fields.replication); + assert_eq!(offered.database_noun, DatabaseNoun::Keyspace); + assert_eq!(offered.name_limit, Some(NameLimit::Chars(48))); + assert!(catalog::plan_truncate_table(&dialect, &table, &truncate).is_ok()); + assert!(catalog::plan_create_database(&dialect, &draft).is_ok()); + assert!(catalog::plan_drop_database(&dialect, "shop", &drop_options).is_ok()); + assert!(!offered.create_schema && !offered.drop_schema); + assert_eq!( + catalog::plan_create_schema(&dialect, &SchemaDraft::default()), + Err(BuildDdlError::Unsupported(DdlOperation::CreateSchema)) + ); + assert_eq!( + catalog::plan_drop_schema(&dialect, "shop", DropSchemaOptions::default()), + Err(BuildDdlError::Unsupported(DdlOperation::DropSchema)) + ); + } + } } diff --git a/core/crates/drivers/cassandra/src/session.rs b/core/crates/drivers/cassandra/src/session.rs index a89c0e68be..46bb6c9f47 100644 --- a/core/crates/drivers/cassandra/src/session.rs +++ b/core/crates/drivers/cassandra/src/session.rs @@ -10,6 +10,7 @@ use scylla::client::execution_profile::ExecutionProfile; use scylla::client::session::Session; use scylla::client::session_builder::SessionBuilder; use scylla::client::{PoolSize, SelfIdentity}; +use scylla::errors::{DbError, NewSessionError, RequestAttemptError, UseKeyspaceError}; use scylla::policies::retry::FallthroughRetryPolicy; use scylla::response::query_result::QueryResult; use scylla::response::{PagingState, PagingStateResponse}; @@ -144,7 +145,14 @@ impl CqlEndpoint { ) .await); } - Ok(Err(error)) => return Err(self.explain(&relay, new_session_error(error)).await), + Ok(Err(error)) => { + if let Some(keyspace) = keyspace.filter(|_| keyspace_missing(&error)) { + return Err(DriverError::DatabaseNotFound { + database: keyspace.to_owned(), + }); + } + return Err(self.explain(&relay, new_session_error(error)).await); + } Ok(Ok(session)) => session, }; let link = CqlLink { @@ -408,6 +416,20 @@ impl CqlLink { } } +/// Whether the server refused USE of the session's keyspace as one it +/// does not have, which no retry mends. Cassandra and ScyllaDB both +/// answer Invalid with "Keyspace '' does not exist"; another +/// Invalid answer keeps its own words. +fn keyspace_missing(error: &NewSessionError) -> bool { + match error { + NewSessionError::UseKeyspaceError(UseKeyspaceError::RequestError(RequestAttemptError::DbError( + DbError::Invalid, + message, + ))) => message.starts_with("Keyspace ") && message.ends_with(" does not exist"), + _ => false, + } +} + /// The values of `statement` as the markers of `prepared` take them. fn bound_values(prepared: &PreparedStatement, statement: &Statement) -> Result>, DriverError> { let specs = prepared.get_variable_col_specs(); @@ -444,6 +466,30 @@ fn version_numbers(version: &str) -> [u32; 3] { mod tests { use super::*; + #[test] + fn a_keyspace_the_server_lacks_is_told_from_other_failures() { + let refused = |error: DbError, message: &str| { + NewSessionError::UseKeyspaceError(UseKeyspaceError::RequestError(RequestAttemptError::DbError( + error, + message.to_owned(), + ))) + }; + + assert!(keyspace_missing(&refused( + DbError::Invalid, + "Keyspace 'gone' does not exist" + ))); + assert!(!keyspace_missing(&refused( + DbError::Unauthorized, + "Keyspace 'gone' does not exist" + ))); + assert!(!keyspace_missing(&refused( + DbError::Invalid, + "Keyspace 'kept' is being dropped" + ))); + assert!(!keyspace_missing(&NewSessionError::EmptyKnownNodesList)); + } + #[test] fn versions_read_their_leading_numbers() { assert_eq!(version_numbers("5.0.9"), [5, 0, 9]); diff --git a/core/crates/drivers/cassandra/tests/integration/catalog_operations.rs b/core/crates/drivers/cassandra/tests/integration/catalog_operations.rs new file mode 100644 index 0000000000..96dc9df4ac --- /dev/null +++ b/core/crates/drivers/cassandra/tests/integration/catalog_operations.rs @@ -0,0 +1,158 @@ +//! Emptying a table and making and dropping a keyspace, as the sidebar +//! plans them, against both servers. + +use tablepro_core::ddl::{ + self as catalog, DatabaseDraft, DdlPlan, DropDatabaseOptions, KeyspaceReplication, TruncateTableOptions, +}; +use tablepro_core::meta::ObjectScope; +use tablepro_core::{ConnectTarget, Connection, DatabaseDriver, DriverError, TableRef, Value, WorkspaceSettings}; +use tablepro_driver_cassandra::CassandraDriver; +use tokio_util::sync::CancellationToken; + +use crate::support::{ + CALL, CassandraServer, KEYSPACE, ScyllaServer, Server, connect, count, fixture_options, scalar, setup, start, + table, within, +}; + +const MADE: &str = "tablepro_made"; + +async fn run(connection: &dyn Connection, plan: DdlPlan, table: TableRef) -> Result<(), DriverError> { + connection + .write_batch(&plan.into_write_batch(table), within(CALL)) + .await + .map(|_| ()) +} + +async fn keyspace_names(connection: &dyn Connection) -> Vec { + connection + .list_databases(within(CALL)) + .await + .expect("the keyspaces") + .into_iter() + .map(|keyspace| keyspace.name) + .collect() +} + +/// TRUNCATE keeps the table and drops every row, on every node. +async fn a_truncate_empties_the_table() { + let fixture = start::().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE tablepro.notes (id int PRIMARY KEY, body text)", + "INSERT INTO tablepro.notes (id, body) VALUES (1, 'a')", + "INSERT INTO tablepro.notes (id, body) VALUES (2, 'b')", + ], + ) + .await; + let dialect = connection.dialect(); + let plan = catalog::plan_truncate_table(dialect.as_ref(), &table("notes"), &TruncateTableOptions::default()) + .expect("a plan"); + + run(connection.as_ref(), plan, table("notes")) + .await + .expect("the truncate"); + + assert_eq!( + count(connection.as_ref(), "SELECT count(*) FROM tablepro.notes").await, + 0 + ); + let objects = connection + .list_objects(&ObjectScope::Schema(KEYSPACE.to_owned()), within(CALL)) + .await + .expect("the tables"); + assert!(objects.iter().any(|object| object.name == "notes"), "{objects:?}"); +} + +/// A keyspace placed in every data centre the node knows, made and +/// dropped through the save path. +async fn a_keyspace_is_made_in_each_data_centre_and_dropped() { + let fixture = start::().await; + let connection = connect(&fixture, false).await; + let dialect = connection.dialect(); + let offered = catalog::catalog_capabilities(dialect.as_ref()); + let choices = connection + .fetch_database_choices(within(CALL)) + .await + .expect("the choices"); + let mut draft = DatabaseDraft::named(MADE); + draft.replication = Some(KeyspaceReplication::NetworkTopology { + factors: choices.data_centers.iter().map(|centre| (centre.clone(), 1)).collect(), + }); + draft.durable_writes = false; + draft.tablets = offered.database_fields.tablets.then_some(false); + let created = catalog::plan_create_database(dialect.as_ref(), &draft).expect("a plan"); + + run(connection.as_ref(), created, TableRef::new(Some(MADE.to_owned()), "")) + .await + .expect("the keyspace is made"); + let listed = keyspace_names(connection.as_ref()).await; + let durable = scalar( + connection.as_ref(), + "SELECT durable_writes FROM system_schema.keyspaces WHERE keyspace_name = 'tablepro_made'", + ) + .await; + let dropped = catalog::plan_drop_database(dialect.as_ref(), MADE, &DropDatabaseOptions::default()).expect("a plan"); + run(connection.as_ref(), dropped, TableRef::new(Some(MADE.to_owned()), "")) + .await + .expect("the keyspace is dropped"); + + assert!(!choices.data_centers.is_empty()); + assert!(listed.iter().any(|name| name == MADE), "{listed:?}"); + assert_eq!(durable, Value::Bool(false)); + let after = keyspace_names(connection.as_ref()).await; + assert!(!after.iter().any(|name| name == MADE), "{after:?}"); +} + +async fn a_keyspace_the_server_lacks_is_not_found() { + let fixture = start::().await; + let mut options = fixture_options(&fixture, WorkspaceSettings::workspace(false)); + if let ConnectTarget::Network(target) = &mut options.target { + target.database = Some("tablepro_gone".to_owned()); + } + + let refused = CassandraDriver.connect(options, CancellationToken::new()).await; + + assert!( + matches!(&refused, Err(DriverError::DatabaseNotFound { database }) if database == "tablepro_gone"), + "{:?}", + refused.as_ref().err() + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn cassandra_truncate_empties_the_table() { + a_truncate_empties_the_table::().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn scylla_truncate_empties_the_table() { + a_truncate_empties_the_table::().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn cassandra_keyspace_is_made_and_dropped() { + a_keyspace_is_made_in_each_data_centre_and_dropped::().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn scylla_keyspace_is_made_and_dropped() { + a_keyspace_is_made_in_each_data_centre_and_dropped::().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn cassandra_missing_keyspace_is_not_found() { + a_keyspace_the_server_lacks_is_not_found::().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn scylla_missing_keyspace_is_not_found() { + a_keyspace_the_server_lacks_is_not_found::().await; +} diff --git a/core/crates/drivers/cassandra/tests/integration/main.rs b/core/crates/drivers/cassandra/tests/integration/main.rs index e8eaa9dc42..2335a93aa1 100644 --- a/core/crates/drivers/cassandra/tests/integration/main.rs +++ b/core/crates/drivers/cassandra/tests/integration/main.rs @@ -6,6 +6,7 @@ reason = "the setup helpers fail a test the way the test functions themselves do" )] +mod catalog_operations; mod conformance_engine; mod editor; mod metadata; diff --git a/core/crates/drivers/clickhouse/src/connection.rs b/core/crates/drivers/clickhouse/src/connection.rs index 59e06d3df8..dc84335f24 100644 --- a/core/crates/drivers/clickhouse/src/connection.rs +++ b/core/crates/drivers/clickhouse/src/connection.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_trait::async_trait; use tablepro_core::column::ColumnInfo; -use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey, TableDefinition}; +use tablepro_core::ddl::{DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ IndexInfo, ObjectDefinition, ObjectInfo, ObjectScope, RowIdentity, SchemaInfo, TableEstimate, TableRef, @@ -285,6 +285,13 @@ impl Connection for ClickHouseConnection { .await } + async fn fetch_database_choices(&self, options: CallOptions) -> Result { + self.workspace_read(options, |calls| async move { + metadata::fetch_database_choices(&calls).await + }) + .await + } + async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError> { let scope = scope.clone(); let database = self.default_database.clone(); diff --git a/core/crates/drivers/clickhouse/src/ddl.rs b/core/crates/drivers/clickhouse/src/ddl.rs index dd3a6a409f..618d081125 100644 --- a/core/crates/drivers/clickhouse/src/ddl.rs +++ b/core/crates/drivers/clickhouse/src/ddl.rs @@ -1,13 +1,17 @@ use tablepro_core::column::ColumnType; use tablepro_core::ddl::ddl_sql::default_sql; use tablepro_core::ddl::{ - BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, Permission, - RefusalReason, TableChanges, TableDefinition, TableDraft, + BuildDdlError, CatalogCapabilities, CatalogOption, ColumnCapabilities, ColumnDefinition, DatabaseDraft, + DdlCapabilities, DdlDialect, DdlPlan, DraftColumn, DropDatabaseOptions, Permission, RefusalReason, TableChanges, + TableDefinition, TableDraft, TruncateTableOptions, catalog_name, check_database_draft, named, refuse_drop_options, + refuse_options, }; use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::TableRef; use tablepro_core::value::Value; use crate::dialect::ClickHouseDialect; +use crate::metadata::SYSTEM_DATABASES; static CAPABILITIES: DdlCapabilities = DdlCapabilities { // A table needs an engine and a sorting key the draft has no place @@ -194,9 +198,65 @@ impl DdlDialect for ClickHouseDialect { .map(|source| DdlPlan::from_sql([source.create_statement.clone()])) .ok_or(BuildDdlError::NoTableSource) } + + /// A database is the only namespace, so there are no schemas. The + /// engine stays the server's default. + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + create_database: true, + drop_database: true, + on_cluster: true, + ..CatalogCapabilities::default() + } + } + + /// The server lets a login drop these, and a drop of `system` stops + /// half way. `default` is where a login lands when it names none. + fn protects_database(&self, name: &str) -> bool { + SYSTEM_DATABASES.contains(&name) || name == "default" + } + + fn plan_truncate_table(&self, table: &TableRef, options: &TruncateTableOptions) -> Result { + refuse_options(&self.catalog_capabilities(), options)?; + Ok(DdlPlan::from_sql([format!( + "TRUNCATE TABLE {}{}", + self.qualify(table), + self.on_cluster(options.cluster.as_deref())? + )])) + } + + fn plan_create_database(&self, draft: &DatabaseDraft) -> Result { + let capabilities = self.catalog_capabilities(); + check_database_draft(&capabilities, draft)?; + Ok(DdlPlan::for_databases([format!( + "CREATE DATABASE {}{}", + self.quote_identifier(named(&capabilities, CatalogOption::Name, &draft.name)?), + self.on_cluster(draft.cluster.as_deref())? + )])) + } + + fn plan_drop_database(&self, database: &str, options: &DropDatabaseOptions) -> Result { + refuse_drop_options(&self.catalog_capabilities(), options)?; + Ok(DdlPlan::for_databases([format!( + "DROP DATABASE {}{}", + self.quote_identifier(catalog_name(CatalogOption::Name, database)?), + self.on_cluster(options.cluster.as_deref())? + )])) + } } impl ClickHouseDialect { + fn on_cluster(&self, cluster: Option<&str>) -> Result { + match cluster { + Some(cluster) => Ok(format!( + " ON CLUSTER {}", + self.quote_identifier(catalog_name(CatalogOption::Cluster, cluster)?) + )), + None => Ok(String::new()), + } + } + fn column_definition(&self, column: &DraftColumn) -> Result { let resolved = column.column_type.resolve(self, &column.name)?; let mut parts = vec![ @@ -246,12 +306,13 @@ fn wrapped<'a>(declaration: &'a str, wrapper: &str) -> Option<&'a str> { #[cfg(test)] mod tests { - use tablepro_core::ServerInfo; use tablepro_core::column::{ColumnDefault, ColumnInfo}; use tablepro_core::ddl::{ - ColumnAttributes, ColumnKeyRoles, DdlOperation, DraftType, DropTableOptions, TableSource, diff_table, + ColumnAttributes, ColumnKeyRoles, DdlOperation, DraftType, DropSchemaOptions, DropTableOptions, SchemaDraft, + TableSource, catalog_capabilities, diff_table, plan_create_database, plan_create_schema, plan_drop_database, + plan_drop_schema, plan_truncate_table, protects_database, }; - use tablepro_core::meta::TableRef; + use tablepro_core::{ServerInfo, WriteKind}; use super::*; @@ -535,4 +596,212 @@ mod tests { } assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); } + + fn cluster(name: Option<&str>) -> Option { + name.map(str::to_owned) + } + + #[test] + fn a_truncate_runs_here_or_on_the_cluster_named() { + let table = TableRef::new(Some("analytics".to_owned()), "ev`ents"); + let truncate = |on: Option<&str>| { + let options = TruncateTableOptions { + cluster: cluster(on), + ..TruncateTableOptions::default() + }; + dialect().plan_truncate_table(&table, &options) + }; + + let here = truncate(None).expect("a plan"); + let everywhere = truncate(Some("main")).expect("a plan"); + + assert_eq!(here.sql(), ["TRUNCATE TABLE `analytics`.`ev\\`ents`"]); + assert_eq!(here.kind, WriteKind::SchemaChanges); + assert_eq!( + everywhere.sql(), + ["TRUNCATE TABLE `analytics`.`ev\\`ents` ON CLUSTER `main`"] + ); + let cascade = TruncateTableOptions { + cascade: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + dialect().plan_truncate_table(&table, &cascade), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateTableCascade)) + ); + assert!(matches!( + truncate(Some(" ")), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Cluster, + .. + }) + )); + } + + #[test] + fn a_database_is_created_and_dropped_here_or_on_the_cluster_named() { + let created = |on: Option<&str>| { + let draft = DatabaseDraft { + cluster: cluster(on), + ..DatabaseDraft::named("sh`op") + }; + dialect().plan_create_database(&draft).expect("a plan") + }; + let dropped = |on: Option<&str>| { + let options = DropDatabaseOptions { + force: false, + cluster: cluster(on), + }; + dialect().plan_drop_database("sh`op", &options).expect("a plan") + }; + + assert_eq!(created(None).sql(), ["CREATE DATABASE `sh\\`op`"]); + assert_eq!( + created(Some("main")).sql(), + ["CREATE DATABASE `sh\\`op` ON CLUSTER `main`"] + ); + assert_eq!(dropped(None).sql(), ["DROP DATABASE `sh\\`op`"]); + assert_eq!( + dropped(Some("main")).sql(), + ["DROP DATABASE `sh\\`op` ON CLUSTER `main`"] + ); + for plan in [created(None), created(Some("main")), dropped(None), dropped(Some("main"))] { + assert_eq!(plan.kind, WriteKind::DatabaseChanges); + } + } + + #[test] + fn a_database_option_clickhouse_has_no_place_for_is_refused() { + let forced = DropDatabaseOptions { + force: true, + cluster: None, + }; + let collated = DatabaseDraft { + collation: Some("utf8mb4_bin".to_owned()), + ..DatabaseDraft::named("shop") + }; + + assert_eq!( + dialect().plan_drop_database("shop", &forced), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabaseForce)) + ); + assert!(matches!( + dialect().plan_create_database(&collated), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Collation, + .. + }) + )); + assert!(matches!( + dialect().plan_create_database(&DatabaseDraft::named("")), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Name, + .. + }) + )); + } + + /// Without the capability a cluster is refused before any SQL, so a + /// caller that skipped the dialog never sends an ON CLUSTER the + /// server cannot run. + #[test] + fn a_cluster_is_refused_where_the_engine_has_none() { + let without = CatalogCapabilities { + on_cluster: false, + ..dialect().catalog_capabilities() + }; + let options = TruncateTableOptions { + cluster: cluster(Some("main")), + ..TruncateTableOptions::default() + }; + let draft = DatabaseDraft { + cluster: cluster(Some("main")), + ..DatabaseDraft::named("shop") + }; + let dropped = DropDatabaseOptions { + force: false, + cluster: cluster(Some("main")), + }; + + assert_eq!( + refuse_options(&without, &options), + Err(BuildDdlError::Unsupported(DdlOperation::OnCluster)) + ); + assert_eq!( + check_database_draft(&without, &draft), + Err(BuildDdlError::Unsupported(DdlOperation::OnCluster)) + ); + assert_eq!( + refuse_drop_options(&without, &dropped), + Err(BuildDdlError::Unsupported(DdlOperation::OnCluster)) + ); + assert!(dialect().catalog_capabilities().on_cluster); + } + + #[test] + fn the_servers_own_databases_and_default_are_protected() { + for name in ["system", "INFORMATION_SCHEMA", "information_schema", "default"] { + assert!(protects_database(&dialect(), name), "{name}"); + } + assert!(!protects_database(&dialect(), "analytics")); + assert!(!protects_database(&dialect(), "Default")); + } + + /// The sidebar offers what the capabilities say, so each one must + /// plan, and each one left out must not. + #[test] + fn every_catalog_operation_offered_plans_and_no_other_does() { + let dialect = dialect(); + let offered = catalog_capabilities(&dialect); + let table = TableRef::new(Some("analytics".to_owned()), "events"); + let schema = SchemaDraft { + name: "analytics".to_owned(), + owner: None, + }; + let on = offered.on_cluster.then(|| "main".to_owned()); + let options = TruncateTableOptions { + cluster: on.clone(), + ..TruncateTableOptions::default() + }; + let draft = DatabaseDraft { + cluster: on.clone(), + ..DatabaseDraft::named("shop") + }; + let dropped = DropDatabaseOptions { + force: offered.drop_database_force, + cluster: on, + }; + let plans = [ + ( + "truncate", + offered.truncate_table, + plan_truncate_table(&dialect, &table, &options), + ), + ( + "create schema", + offered.create_schema, + plan_create_schema(&dialect, &schema), + ), + ( + "drop schema", + offered.drop_schema, + plan_drop_schema(&dialect, "analytics", DropSchemaOptions::default()), + ), + ( + "create database", + offered.create_database, + plan_create_database(&dialect, &draft), + ), + ( + "drop database", + offered.drop_database, + plan_drop_database(&dialect, "shop", &dropped), + ), + ]; + + for (operation, offered, plan) in plans { + assert_eq!(offered, plan.is_ok(), "{operation}: {plan:?}"); + } + assert!(offered.truncate_table && offered.create_database && offered.drop_database); + } } diff --git a/core/crates/drivers/clickhouse/src/endpoint.rs b/core/crates/drivers/clickhouse/src/endpoint.rs index 2c6a60e263..5f43763268 100644 --- a/core/crates/drivers/clickhouse/src/endpoint.rs +++ b/core/crates/drivers/clickhouse/src/endpoint.rs @@ -7,7 +7,7 @@ use tablepro_core::{ use uuid::Uuid; use crate::client_factory::{base_client, read_level, read_only_settings}; -use crate::errors::{ErrorContext, map_error_in}; +use crate::errors::{ErrorContext, map_error_in, missing_at_login}; use crate::result_reader::drain; /// How long the server keeps an editor session's state between runs. @@ -34,6 +34,8 @@ pub(crate) struct ClickHouseEndpoint { read_only: bool, // The login's own `readonly` level, learned on connect. login_readonly: u8, + /// The database every request names, as the client was built with it. + database: Option, } impl ClickHouseEndpoint { @@ -53,6 +55,7 @@ impl ClickHouseEndpoint { session_timeout: SESSION_TIMEOUT, read_only, login_readonly: 0, + database: target.database.clone().filter(|name| !name.is_empty()), }; endpoint.hold(0); Ok(endpoint) @@ -145,7 +148,8 @@ impl ClickHouseEndpoint { let context = ErrorContext::Login { tls_requested: self.tls_requested, }; - return Err(self.explain(map_error_in(&error, context)).await); + let mapped = missing_at_login(map_error_in(&error, context), self.database.as_deref()); + return Err(self.explain(mapped).await); } Ok(Ok(body)) => body, }; diff --git a/core/crates/drivers/clickhouse/src/errors.rs b/core/crates/drivers/clickhouse/src/errors.rs index ad6168f302..92d24796b8 100644 --- a/core/crates/drivers/clickhouse/src/errors.rs +++ b/core/crates/drivers/clickhouse/src/errors.rs @@ -10,6 +10,7 @@ use tablepro_core::{ /// Codes the app acts on. Everything else is the server's own answer, /// passed through with its message. pub(crate) mod code { + pub(crate) const UNKNOWN_DATABASE: i32 = 81; pub(crate) const TIMEOUT_EXCEEDED: i32 = 159; pub(crate) const READONLY: i32 = 164; pub(crate) const UNKNOWN_USER: i32 = 192; @@ -83,6 +84,18 @@ pub(crate) fn map_error_in(error: &ClickHouseError, context: ErrorContext) -> Dr } } +/// The login's answer when `database` is the one it named. Only the +/// login reads code 81 so: a statement's 81 is about a database the +/// statement names. +pub(crate) fn missing_at_login(error: DriverError, database: Option<&str>) -> DriverError { + match database { + Some(database) if server_code(&error) == Some(code::UNKNOWN_DATABASE) => DriverError::DatabaseNotFound { + database: database.to_owned(), + }, + _ => error, + } +} + /// An I/O error from reading a response body. The cursor carries the /// client's own error inside it, so that is recovered first. pub(crate) fn map_io_error(error: io::Error) -> DriverError { @@ -211,6 +224,30 @@ mod tests { assert_eq!(map_error(&denied).category(), ErrorCategory::Server); } + #[test] + fn an_unknown_database_is_missing_only_for_the_login() { + let login = |message: &str| { + map_error_in( + &ClickHouseError::BadResponse(message.to_owned()), + ErrorContext::Login { tls_requested: false }, + ) + }; + let unknown = "Code: 81. DB::Exception: Database shop does not exist. (UNKNOWN_DATABASE)"; + + assert_eq!( + missing_at_login(login(unknown), Some("shop")), + DriverError::DatabaseNotFound { + database: "shop".to_owned() + } + ); + assert!(matches!(missing_at_login(login(unknown), None), DriverError::Server(_))); + assert!(matches!(bad_response(unknown), DriverError::Server(_))); + assert!(matches!( + missing_at_login(login("Code: 516. DB::Exception: Authentication failed"), Some("shop")), + DriverError::Auth { .. } + )); + } + #[test] fn a_read_only_refusal_carries_the_servers_code() { let error = bad_response("Code: 164. DB::Exception: Cannot execute query in readonly mode"); diff --git a/core/crates/drivers/clickhouse/src/metadata.rs b/core/crates/drivers/clickhouse/src/metadata.rs index e6d7f85543..340ad095f3 100644 --- a/core/crates/drivers/clickhouse/src/metadata.rs +++ b/core/crates/drivers/clickhouse/src/metadata.rs @@ -1,12 +1,37 @@ +use tablepro_core::ddl::DatabaseChoices; use tablepro_core::meta::{ObjectDefinition, ObjectInfo, ObjectKind, ObjectScope, SchemaInfo}; use tablepro_core::sql_syntax::terminated; use tablepro_core::{DriverError, UnsupportedFeature}; use crate::call_client::CallClient; +use crate::errors::{code, server_code}; use crate::result_reader::{CatalogRow, read_table}; /// The databases the server keeps for itself. -const SYSTEM_DATABASES: [&str; 3] = ["system", "INFORMATION_SCHEMA", "information_schema"]; +pub(crate) const SYSTEM_DATABASES: [&str; 3] = ["system", "INFORMATION_SCHEMA", "information_schema"]; + +/// Clusters of more than one server. A stock single server lists +/// `default` with its one host and has no Keeper, so ON CLUSTER would +/// fail there. +const CLUSTERS_SQL: &str = "SELECT cluster FROM system.clusters GROUP BY cluster HAVING count() > 1 ORDER BY cluster"; + +/// The clusters a new database or a truncate can run on. A login that +/// may not read `system.clusters` sees none, and runs on this server. +pub(crate) async fn fetch_database_choices(calls: &CallClient) -> Result { + let clusters = match read_table(calls.raw(CLUSTERS_SQL)?, None).await { + Ok(result) => result + .rows + .iter() + .map(|row| CatalogRow(row).text(0)) + .collect::, _>>()?, + Err(error) if server_code(&error) == Some(code::ACCESS_DENIED) => Vec::new(), + Err(error) => return Err(error), + }; + Ok(DatabaseChoices { + clusters, + ..DatabaseChoices::default() + }) +} /// Every database, with the session's own as the default. pub(crate) async fn list_schemas(calls: &CallClient) -> Result, DriverError> { diff --git a/core/crates/drivers/clickhouse/src/write_batch.rs b/core/crates/drivers/clickhouse/src/write_batch.rs index 16d6c062e3..960569a066 100644 --- a/core/crates/drivers/clickhouse/src/write_batch.rs +++ b/core/crates/drivers/clickhouse/src/write_batch.rs @@ -36,7 +36,9 @@ pub(crate) fn precheck(batch: &WriteBatch, read_only: bool) -> Result<(), Driver /// Everything one write needs once its task runs. pub(crate) struct WritePlan { pub(crate) batch: WriteBatch, - /// The batch's table, qualified, for the commit token. + /// The batch's table, qualified, for the commit token. A database + /// batch names none, which is harmless: only a lost ALTER's + /// resolution reads it. pub(crate) table: TableRef, pub(crate) ledger: Arc, /// Reads that may be stopped, for the probes. diff --git a/core/crates/drivers/clickhouse/tests/integration/ddl.rs b/core/crates/drivers/clickhouse/tests/integration/ddl.rs index 5ee4716e34..882d4058ec 100644 --- a/core/crates/drivers/clickhouse/tests/integration/ddl.rs +++ b/core/crates/drivers/clickhouse/tests/integration/ddl.rs @@ -1,10 +1,13 @@ //! Structure changes the ClickHouse dialect plans, run against a real //! server. -use tablepro_core::Connection; -use tablepro_core::ddl::{TableDefinition, TableDraft, diff_table}; +use tablepro_core::ddl::{ + DatabaseDraft, DropDatabaseOptions, TableDefinition, TableDraft, TruncateTableOptions, diff_table, + plan_create_database, plan_drop_database, plan_truncate_table, +}; +use tablepro_core::{Connection, TableRef}; -use crate::support::{CALL, connect, scalar, setup, start, table, text, within}; +use crate::support::{CALL, DATABASE, connect, count, quoted, scalar, setup, start, table, text, unique, within}; async fn read(connection: &dyn Connection) -> TableDefinition { connection @@ -68,3 +71,62 @@ async fn swap_column_names_applies() { ); connection.close().await.expect("a close"); } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn truncate_empties_the_table_and_keeps_it() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let name = unique("emptied"); + let qualified = format!("{DATABASE}.{}", quoted(&name)); + setup( + connection.as_ref(), + &[ + &format!("CREATE TABLE {qualified} (id UInt64) ENGINE = MergeTree ORDER BY id"), + &format!("INSERT INTO {qualified} VALUES (1), (2)"), + ], + ) + .await; + let dialect = connection.dialect(); + let plan = plan_truncate_table(dialect.as_ref(), &table(&name), &TruncateTableOptions::default()).expect("a plan"); + + connection + .write_batch(&plan.into_write_batch(table(&name)), within(CALL)) + .await + .expect("the truncate runs"); + + let kept = format!("SELECT count() FROM system.tables WHERE database = '{DATABASE}' AND name = '{name}'"); + assert_eq!(count(connection.as_ref(), &format!("SELECT count() FROM {qualified}")).await, 0); + assert_eq!(count(connection.as_ref(), &kept).await, 1); + connection.close().await.expect("a close"); +} + +/// A database plan runs through the save path with a batch that names +/// the database and no table, as the app sends it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_database_is_created_and_dropped_through_a_save() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let name = unique("made"); + let dialect = connection.dialect(); + let target = TableRef::new(Some(name.clone()), ""); + let created = plan_create_database(dialect.as_ref(), &DatabaseDraft::named(name.clone())).expect("a plan"); + let dropped = plan_drop_database(dialect.as_ref(), &name, &DropDatabaseOptions::default()).expect("a plan"); + let listed = format!("SELECT count() FROM system.databases WHERE name = '{name}'"); + + connection + .write_batch(&created.into_write_batch(target.clone()), within(CALL)) + .await + .expect("the database is created"); + let schemas = connection.list_schemas(within(CALL)).await.expect("the schemas"); + connection + .write_batch(&dropped.into_write_batch(target), within(CALL)) + .await + .expect("the database is dropped"); + + let made = schemas.iter().find(|schema| schema.name == name).expect("the new database"); + assert!(!made.is_default && !made.is_system, "{made:?}"); + assert_eq!(count(connection.as_ref(), &listed).await, 0); + connection.close().await.expect("a close"); +} diff --git a/core/crates/drivers/clickhouse/tests/integration/metadata.rs b/core/crates/drivers/clickhouse/tests/integration/metadata.rs index da73ad3869..6d22afd085 100644 --- a/core/crates/drivers/clickhouse/tests/integration/metadata.rs +++ b/core/crates/drivers/clickhouse/tests/integration/metadata.rs @@ -3,9 +3,14 @@ use tablepro_core::column::{ColumnKind, TemporalZone, TextLength}; use tablepro_core::meta::{IndexKey, ObjectInfo, ObjectKind, ObjectScope, RowCountEstimate, RowIdentity}; use tablepro_core::statement::StatementBuilder; -use tablepro_core::{Connection, FilterOp, FilterRule, FilterSet, FilterValue, RowLimit, Value, build_filter}; +use tablepro_core::{ + ConnectTarget, Connection, DatabaseDriver, DriverError, FilterOp, FilterRule, FilterSet, FilterValue, RowLimit, + Value, WorkspaceSettings, build_filter, +}; +use tablepro_driver_clickhouse::ClickhouseDriver; +use tokio_util::sync::CancellationToken; -use crate::support::{CALL, DATABASE, connect, quoted, setup, start, table, unique, within}; +use crate::support::{CALL, DATABASE, connect, fixture_options, quoted, setup, start, table, unique, within}; async fn matching(connection: &dyn Connection, name: &str, rule: FilterRule) -> Vec { let table = table(name); @@ -248,6 +253,41 @@ async fn objects_and_schemas_are_listed_by_kind() { connection.close().await.expect("a close"); } +/// A stock single server lists a `default` cluster of one host and has +/// no Keeper, so ON CLUSTER would fail there and none is offered. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_single_server_offers_no_cluster() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + + let choices = connection + .fetch_database_choices(within(CALL)) + .await + .expect("the choices"); + + assert!(choices.clusters.is_empty(), "{choices:?}"); + connection.close().await.expect("a close"); +} + +/// A login that names a database the server does not have stops at +/// the connect with the name, rather than as a server error the health +/// monitor would retry. +#[tokio::test] +#[ignore = "requires docker"] +async fn a_login_to_a_missing_database_names_it() { + let fixture = start().await; + let gone = unique("gone"); + let mut options = fixture_options(&fixture, WorkspaceSettings::workspace(false)); + if let ConnectTarget::Network(target) = &mut options.target { + target.database = Some(gone.clone()); + } + + let outcome = ClickhouseDriver.connect(options, CancellationToken::new()).await; + + assert_eq!(outcome.err(), Some(DriverError::DatabaseNotFound { database: gone })); +} + #[tokio::test] #[ignore = "requires docker"] async fn like_escape_matches_literal_underscore_and_percent() { diff --git a/core/crates/drivers/duckdb/src/connection.rs b/core/crates/drivers/duckdb/src/connection.rs index 37bf79f855..2eb71e1543 100644 --- a/core/crates/drivers/duckdb/src/connection.rs +++ b/core/crates/drivers/duckdb/src/connection.rs @@ -431,7 +431,7 @@ impl Connection for DuckDbConnection { // by the statement timeout would only roll back. let options = match batch.kind { WriteKind::RowChanges => self.statement_bounded(options), - WriteKind::SchemaChanges => options, + WriteKind::SchemaChanges | WriteKind::DatabaseChanges => options, }; let ledger = Arc::new(WriteLedger::new(WriteAtomicity::Transactional)); let recorded = ledger.clone(); diff --git a/core/crates/drivers/duckdb/src/ddl.rs b/core/crates/drivers/duckdb/src/ddl.rs index cf31959b89..20609070df 100644 --- a/core/crates/drivers/duckdb/src/ddl.rs +++ b/core/crates/drivers/duckdb/src/ddl.rs @@ -11,8 +11,9 @@ use tablepro_core::column::{ColumnKind, ColumnType}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; use tablepro_core::ddl::{ - BuildDdlError, ColumnAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, - DdlPlan, DraftColumn, Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, TableDraft, + BuildDdlError, CatalogCapabilities, ColumnAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, + DdlDialect, DdlOperation, DdlPlan, DraftColumn, Permission, ReferentialAction, RefusalReason, TableChanges, + TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, TableRef}; @@ -52,6 +53,10 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { table_options: false, }; +/// `main` is DuckDB's internal entry, which it refuses to drop; the +/// other two hold its catalogue views. +const PROTECTED_SCHEMAS: [&str; 3] = ["main", "information_schema", "pg_catalog"]; + const TYPE_SUGGESTIONS: &[&str] = &[ "VARCHAR", "INTEGER", @@ -369,6 +374,26 @@ impl DdlDialect for DuckDbDialect { } Ok(DdlPlan::from_sql(statements)) } + + /// TRUNCATE is DuckDB's DELETE without a WHERE: it refuses a row + /// another table's key still points at, and the sequences behind + /// defaults go on counting. A database is a file attached to the + /// connection, so there is nothing to create or drop. + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + create_schema: true, + drop_schema: true, + drop_schema_cascade: true, + ..CatalogCapabilities::default() + } + } + + fn protects_schema(&self, name: &str) -> bool { + PROTECTED_SCHEMAS + .iter() + .any(|protected| protected.eq_ignore_ascii_case(name)) + } } /// ` COLLATE ` for a text column the catalogue read with one, as diff --git a/core/crates/drivers/duckdb/tests/integration/ddl.rs b/core/crates/drivers/duckdb/tests/integration/ddl.rs index 9731f5d1e2..3db5934011 100644 --- a/core/crates/drivers/duckdb/tests/integration/ddl.rs +++ b/core/crates/drivers/duckdb/tests/integration/ddl.rs @@ -1,9 +1,13 @@ //! Structure changes DuckDB takes and refuses, beyond the conformance //! suite: what something depending on a table holds in place. -use tablepro_core::ddl::{BuildDdlError, DdlOperation, DraftColumn, DraftType, RefusalReason, TableDraft, diff_table}; +use tablepro_core::ddl::{ + self as catalog, BuildDdlError, DatabaseDraft, DdlOperation, DraftColumn, DraftType, DropDatabaseOptions, + DropSchemaOptions, RefusalReason, SchemaDraft, TableDraft, TruncateTableOptions, diff_table, +}; use tablepro_core::meta::TableRef; use tablepro_core::{Connection, DriverError, Value}; +use tablepro_driver_duckdb::DuckDbDialect; use crate::support::{TestFile, apply, call, query}; @@ -322,3 +326,181 @@ async fn an_index_statement_makes_the_index_again_on_its_own_table() { assert_eq!(read(table).await, indexes, "{statement}"); } } + +/// A truncate names the table with its file's catalogue; a schema is +/// named alone, so it lands in the file the connection opened. +#[test] +fn the_catalog_plans_take_only_what_duckdb_has() { + let dialect = DuckDbDialect::for_catalog("shop"); + let table = TableRef::new(Some("sa\"les".to_owned()), "orders"); + let schema = SchemaDraft { + name: "sa\"les".to_owned(), + owner: None, + }; + + let truncated = catalog::plan_truncate_table(&dialect, &table, &TruncateTableOptions::default()).unwrap(); + let created = catalog::plan_create_schema(&dialect, &schema).unwrap(); + let dropped = catalog::plan_drop_schema(&dialect, "sales", DropSchemaOptions { cascade: true }).unwrap(); + let restricted = catalog::plan_drop_schema(&dialect, "sales", DropSchemaOptions::default()).unwrap(); + + assert_eq!(truncated.sql(), ["TRUNCATE TABLE \"shop\".\"sa\"\"les\".\"orders\""]); + assert_eq!(created.sql(), ["CREATE SCHEMA \"sa\"\"les\""]); + assert_eq!(dropped.sql(), ["DROP SCHEMA \"sales\" CASCADE"]); + assert_eq!(restricted.sql(), ["DROP SCHEMA \"sales\""]); + let owned = SchemaDraft { + owner: Some("ann".to_owned()), + ..schema + }; + assert_eq!( + catalog::plan_create_schema(&dialect, &owned), + Err(BuildDdlError::Unsupported(DdlOperation::CatalogOwner)) + ); + let restart = TruncateTableOptions { + restart_identity: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + catalog::plan_truncate_table(&dialect, &table, &restart), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateRestartIdentity)) + ); + let cascade = TruncateTableOptions { + cascade: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + catalog::plan_truncate_table(&dialect, &table, &cascade), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateTableCascade)) + ); + assert_eq!( + catalog::plan_create_database(&dialect, &DatabaseDraft::named("other")), + Err(BuildDdlError::Unsupported(DdlOperation::CreateDatabase)) + ); + assert_eq!( + catalog::plan_drop_database(&dialect, "other", &DropDatabaseOptions::default()), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabase)) + ); +} + +#[test] +fn main_and_the_catalogue_view_schemas_are_protected() { + let dialect = DuckDbDialect::for_catalog("shop"); + + for name in ["main", "MAIN", "information_schema", "pg_catalog"] { + assert!(catalog::protects_schema(&dialect, name), "{name}"); + } + assert!(!catalog::protects_schema(&dialect, "sales")); + assert!(!catalog::protects_database(&dialect, "shop")); +} + +/// The sidebar offers what the capabilities say, so each one has to +/// plan, with every option it offers, and nothing else may. +#[test] +fn every_offered_catalog_operation_plans() { + let dialect = DuckDbDialect::for_catalog("shop"); + let offered = catalog::catalog_capabilities(&dialect); + let table = TableRef::new(Some("main".to_owned()), "orders"); + let truncate = TruncateTableOptions { + cascade: offered.truncate_cascade, + restart_identity: offered.truncate_restart_identity, + cluster: None, + }; + let schema = SchemaDraft { + name: "sales".to_owned(), + owner: offered.schema_owner.then(|| "ann".to_owned()), + }; + let drop_schema = DropSchemaOptions { + cascade: offered.drop_schema_cascade, + }; + + assert!(offered.truncate_table && offered.create_schema && offered.drop_schema && offered.drop_schema_cascade); + assert!(!offered.create_database && !offered.drop_database && !offered.schema_owner); + assert!(!offered.truncate_refused_when_referenced && offered.name_limit.is_none()); + assert!(catalog::plan_truncate_table(&dialect, &table, &truncate).is_ok()); + assert!(catalog::plan_create_schema(&dialect, &schema).is_ok()); + assert!(catalog::plan_drop_schema(&dialect, "sales", drop_schema).is_ok()); +} + +/// The linked DuckDB parses TRUNCATE as DELETE without a WHERE: the +/// table stays and its rows go, and a row another table's key still +/// points at holds the whole statement back. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_truncate_empties_the_table_unless_a_key_points_at_a_row() { + let file = TestFile::new(); + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE parent (id INTEGER PRIMARY KEY)", + "CREATE TABLE child (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES parent (id))", + "INSERT INTO parent VALUES (1), (2)", + "INSERT INTO child VALUES (10, 1)", + ], + ) + .await + .unwrap(); + let dialect = connection.dialect(); + let truncate = |name: &str| { + let table = TableRef::new(Some("main".to_owned()), name); + catalog::plan_truncate_table(dialect.as_ref(), &table, &TruncateTableOptions::default()) + .unwrap() + .into_write_batch(table) + }; + + let held = connection.write_batch(&truncate("parent"), call()).await; + let parents_held = query(connection.as_ref(), "SELECT count(*) FROM parent").await; + connection.write_batch(&truncate("child"), call()).await.unwrap(); + connection.write_batch(&truncate("parent"), call()).await.unwrap(); + + assert!(matches!(held, Err(DriverError::RolledBack { .. })), "{held:?}"); + assert_eq!(parents_held.rows, vec![vec![Value::Int(2)]]); + let left = query( + connection.as_ref(), + "SELECT (SELECT count(*) FROM parent), (SELECT count(*) FROM child), \ + (SELECT count(*) FROM duckdb_tables() WHERE table_name IN ('parent', 'child'))", + ) + .await; + assert_eq!(left.rows, vec![vec![Value::Int(0), Value::Int(0), Value::Int(2)]]); + connection.close().await.unwrap(); +} + +/// A schema that holds a table drops only with CASCADE, and DuckDB +/// refuses to drop `main` at all, which is why it is never offered. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_schema_is_made_and_dropped_with_what_it_holds() { + let file = TestFile::new(); + let connection = file.open().await; + let dialect = connection.dialect(); + let sales = TableRef::new(Some("sales".to_owned()), ""); + let draft = SchemaDraft { + name: "sales".to_owned(), + owner: None, + }; + let drop_plan = |cascade| { + catalog::plan_drop_schema(dialect.as_ref(), "sales", DropSchemaOptions { cascade }) + .unwrap() + .into_write_batch(sales.clone()) + }; + let created = catalog::plan_create_schema(dialect.as_ref(), &draft).unwrap(); + + connection + .write_batch(&created.into_write_batch(sales.clone()), call()) + .await + .unwrap(); + apply(connection.as_ref(), &["CREATE TABLE sales.orders (id INTEGER)"]) + .await + .unwrap(); + let listed = connection.list_schemas(call()).await.unwrap(); + let restricted = connection.write_batch(&drop_plan(false), call()).await; + connection.write_batch(&drop_plan(true), call()).await.unwrap(); + let main = apply(connection.as_ref(), &["DROP SCHEMA main CASCADE"]).await; + + assert!(listed.iter().any(|schema| schema.name == "sales"), "{listed:?}"); + assert!( + matches!(restricted, Err(DriverError::RolledBack { .. })), + "{restricted:?}" + ); + let after = connection.list_schemas(call()).await.unwrap(); + assert!(!after.iter().any(|schema| schema.name == "sales"), "{after:?}"); + assert!(main.is_err(), "DuckDB dropped main"); + connection.close().await.unwrap(); +} diff --git a/core/crates/drivers/mongodb/src/write_batch.rs b/core/crates/drivers/mongodb/src/write_batch.rs index bc8b837950..3c8befa092 100644 --- a/core/crates/drivers/mongodb/src/write_batch.rs +++ b/core/crates/drivers/mongodb/src/write_batch.rs @@ -113,3 +113,71 @@ fn failure(ledger: &WriteLedger, index: usize, error: DriverError) -> DriverErro other => ledger.failed(index, other, SessionFate::RolledBack), } } + +#[cfg(test)] +mod tests { + use bson::doc; + use tablepro_core::ddl::{ + DatabaseDraft, DdlPlan, DropDatabaseOptions, TruncateTableOptions, plan_create_database, plan_drop_database, + plan_truncate_table, + }; + use tablepro_core::meta::TableRef; + + use super::*; + use crate::dialect::MongoDialect; + use crate::shell_command::{Plan, Reply, WriteKind}; + + /// The one command a catalog plan sends from a session whose own + /// database is `shop`. + fn sent(plan: &DdlPlan) -> Plan { + let [statement] = plan.statements.as_slice() else { + panic!("not one statement: {:?}", plan.statements); + }; + generated(statement, "shop").expect("a command").1 + } + + #[test] + fn a_truncate_deletes_every_document_in_the_collection_s_own_database() { + let table = TableRef::new(Some("archive".to_owned()), "orders"); + let plan = plan_truncate_table(&MongoDialect, &table, &TruncateTableOptions::default()).expect("a plan"); + + assert_eq!( + sent(&plan), + Plan::Command { + database: "archive".to_owned(), + command: doc! { "delete": "orders", "deletes": [{ "q": {}, "limit": 0_i32 }] }, + reply: Reply::Write(WriteKind::Delete), + } + ); + } + + #[test] + fn a_new_database_is_made_by_its_first_collection_there() { + let mut draft = DatabaseDraft::named("fresh"); + draft.first_collection = Some("first".to_owned()); + let plan = plan_create_database(&MongoDialect, &draft).expect("a plan"); + + assert_eq!( + sent(&plan), + Plan::Command { + database: "fresh".to_owned(), + command: doc! { "create": "first" }, + reply: Reply::Whole, + } + ); + } + + #[test] + fn a_drop_goes_to_the_database_it_names() { + let plan = plan_drop_database(&MongoDialect, "old", &DropDatabaseOptions::default()).expect("a plan"); + + assert_eq!( + sent(&plan), + Plan::Command { + database: "old".to_owned(), + command: doc! { "dropDatabase": 1_i32 }, + reply: Reply::Whole, + } + ); + } +} diff --git a/core/crates/drivers/mongodb/tests/integration/catalog.rs b/core/crates/drivers/mongodb/tests/integration/catalog.rs new file mode 100644 index 0000000000..6258a40a0e --- /dev/null +++ b/core/crates/drivers/mongodb/tests/integration/catalog.rs @@ -0,0 +1,143 @@ +//! Emptying a collection and creating and dropping a database through +//! the plans the sidebar runs with `write_batch`. Each names a database +//! other than the connection's own, which has to be the one it changes. + +use tablepro_core::ddl::{ + DatabaseDraft, DropDatabaseOptions, TruncateTableOptions, plan_create_database, plan_drop_database, + plan_truncate_table, +}; +use tablepro_core::meta::{ObjectScope, SchemaInfo, TableRef}; +use tablepro_core::{Connection, RunEnd, Value}; + +use crate::support::{CALL, connect, editor, rows, run, start, unique, within}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn deleting_every_document_keeps_the_collection_and_its_indexes() { + let fixture = start().await; + let connection = connect(&fixture).await; + let session = editor(connection.as_ref()).await; + let (database, collection) = (unique("emptied"), unique("orders")); + let (filled, _) = run( + session.as_ref(), + &format!( + "db.getSiblingDB('{database}').getCollection('{collection}').insertMany([{{_id: 1, label: 'a'}}, \ + {{_id: 2, label: 'b'}}, {{_id: 3, label: 'c'}}])\n\ + db.getSiblingDB('{database}').getCollection('{collection}').createIndex({{label: 1}})" + ), + ) + .await; + assert_eq!(filled.end, RunEnd::Completed); + let table = TableRef::new(Some(database.clone()), collection.clone()); + let plan = + plan_truncate_table(connection.dialect().as_ref(), &table, &TruncateTableOptions::default()).expect("a plan"); + + let report = connection + .write_batch(&plan.into_write_batch(table.clone()), within(CALL)) + .await + .expect("the delete"); + + assert_eq!(report.rows_affected, [Some(3)]); + let (counted, events) = run( + session.as_ref(), + &format!("db.getSiblingDB('{database}').getCollection('{collection}').countDocuments({{}})"), + ) + .await; + assert_eq!(counted.end, RunEnd::Completed); + assert_eq!(rows(&events), [vec![Value::Int(0)]]); + let indexes = connection + .fetch_indexes(&table, within(CALL)) + .await + .expect("the indexes"); + assert!(indexes.iter().any(|index| index.name == "label_1"), "{indexes:?}"); + let objects = connection + .list_objects(&ObjectScope::Schema(database.clone()), within(CALL)) + .await + .expect("the database's collections"); + assert!(objects.iter().any(|object| object.name == collection), "{objects:?}"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_new_database_is_listed_with_its_first_collection() { + let fixture = start().await; + let connection = connect(&fixture).await; + let (database, collection) = (unique("created"), unique("first")); + let mut draft = DatabaseDraft::named(database.clone()); + draft.first_collection = Some(collection.clone()); + let plan = plan_create_database(connection.dialect().as_ref(), &draft).expect("a plan"); + + connection + .write_batch( + &plan.into_write_batch(TableRef::new(Some(database.clone()), "")), + within(CALL), + ) + .await + .expect("the create"); + + assert!(lists(connection.as_ref(), &database).await, "{database} is not listed"); + let objects = connection + .list_objects(&ObjectScope::Schema(database.clone()), within(CALL)) + .await + .expect("the new database's collections"); + assert!(objects.iter().any(|object| object.name == collection), "{objects:?}"); + let own = connection + .list_objects(&ObjectScope::DefaultSchema, within(CALL)) + .await + .expect("the connection's own collections"); + assert!( + own.iter().all(|object| object.name != collection), + "the collection was made in the connection's database: {own:?}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_dropped_database_is_no_longer_listed() { + let fixture = start().await; + let connection = connect(&fixture).await; + let session = editor(connection.as_ref()).await; + let (database, kept) = (unique("dropped"), unique("kept")); + let (filled, _) = run( + session.as_ref(), + &format!( + "db.getSiblingDB('{database}').getCollection('items').insertOne({{_id: 1}})\n\ + db.getCollection('{kept}').insertOne({{_id: 1}})" + ), + ) + .await; + assert_eq!(filled.end, RunEnd::Completed); + assert!(lists(connection.as_ref(), &database).await, "{database} is not listed"); + let plan = plan_drop_database( + connection.dialect().as_ref(), + &database, + &DropDatabaseOptions::default(), + ) + .expect("a plan"); + + connection + .write_batch( + &plan.into_write_batch(TableRef::new(Some(database.clone()), "")), + within(CALL), + ) + .await + .expect("the drop"); + + assert!( + !lists(connection.as_ref(), &database).await, + "{database} is still listed" + ); + let own = connection + .list_objects(&ObjectScope::DefaultSchema, within(CALL)) + .await + .expect("the connection's own collections"); + assert!( + own.iter().any(|object| object.name == kept), + "the connection's own database was dropped: {own:?}" + ); +} + +async fn lists(connection: &dyn Connection, database: &str) -> bool { + let databases: Vec = connection.list_databases(within(CALL)).await.expect("the databases"); + databases.iter().any(|listed| listed.name == database) +} diff --git a/core/crates/drivers/mongodb/tests/integration/main.rs b/core/crates/drivers/mongodb/tests/integration/main.rs index e97cffe06f..154c4a3c02 100644 --- a/core/crates/drivers/mongodb/tests/integration/main.rs +++ b/core/crates/drivers/mongodb/tests/integration/main.rs @@ -6,6 +6,7 @@ reason = "the setup helpers fail a test the way the test functions themselves do" )] +mod catalog; mod conformance_engine; mod statements; mod support; diff --git a/core/crates/drivers/mssql/src/connection.rs b/core/crates/drivers/mssql/src/connection.rs index aeb32f8d6f..3534652507 100644 --- a/core/crates/drivers/mssql/src/connection.rs +++ b/core/crates/drivers/mssql/src/connection.rs @@ -5,7 +5,7 @@ use std::time::Duration; use async_trait::async_trait; use deadpool::managed::{PoolError, TimeoutType}; use tablepro_core::column::ColumnInfo; -use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey, TableDefinition}; +use tablepro_core::ddl::{DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ IndexInfo, ObjectDefinition, ObjectInfo, ObjectScope, RowIdentity, SchemaInfo, TableEstimate, TableRef, @@ -15,7 +15,7 @@ use tablepro_core::{ CallOptions, CommitResolution, CommitToken, Connection, DriverError, EditorSession, EditorSessionOptions, ErrorCategory, ForeignKeyInfo, LinkState, LinkWatch, LivenessPolicy, LossPhase, OwnedCalls, QueryResult, ReadStatement, ResultSink, RowLimit, ServerInfo, SessionFate, StreamSummary, TimeoutPhase, WorkspaceSettings, - WriteAtomicity, WriteBatch, WriteKind, WriteLedger, WriteReport, + WriteBatch, WriteKind, WriteLedger, WriteReport, }; use tokio::time::Instant; use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; @@ -32,7 +32,7 @@ use crate::mssql_pooled::MssqlPooled; use crate::standalone::Standalone; use crate::stream_read::stream; use crate::workspace_call::{Guard, ReadOp, count_op, query_bound_op, read_op}; -use crate::write_batch::{WritePlan, perform, precheck}; +use crate::write_batch::{WritePlan, atomicity, perform, precheck}; /// A workspace's link to one SQL Server: a pool of four connections /// behind the sidebar, the grid and every save. @@ -264,6 +264,14 @@ impl Connection for MssqlConnection { .await } + async fn fetch_database_choices(&self, options: CallOptions) -> Result { + self.read( + options, + read_op(|client, watch| Box::pin(metadata::fetch_database_choices(client, watch))), + ) + .await + } + async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError> { let scope = scope.clone(); self.read( @@ -460,10 +468,10 @@ impl Connection for MssqlConnection { // deadline and cancel still bound it. let options = match batch.kind { WriteKind::RowChanges => bounded(&options, Some(self.settings.statement_timeout)), - WriteKind::SchemaChanges => options, + WriteKind::SchemaChanges | WriteKind::DatabaseChanges => options, }; let stop = Arc::new(MssqlStop::new()); - let ledger = Arc::new(WriteLedger::new(WriteAtomicity::Transactional)); + let ledger = Arc::new(WriteLedger::new(atomicity(batch.kind))); let pool = self.pool.clone(); let timeouts = workspace_timeouts(self.liveness()); let plan = WritePlan { diff --git a/core/crates/drivers/mssql/src/ddl.rs b/core/crates/drivers/mssql/src/ddl.rs index 9d7f485e22..06ea990a83 100644 --- a/core/crates/drivers/mssql/src/ddl.rs +++ b/core/crates/drivers/mssql/src/ddl.rs @@ -1,9 +1,10 @@ use tablepro_core::column::{ColumnDefault, ColumnKind}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql, parenthesized}; use tablepro_core::ddl::{ - BuildDdlError, ColumnAlteration, ColumnAttributes, ColumnCapabilities, ColumnDefinition, DdlCapabilities, - DdlDialect, DdlOperation, DdlPlan, DraftColumn, DraftType, GeneratedStorage, IdentityStyle, Permission, - ReferentialAction, RefusalReason, ResolvedType, TableChanges, TableDefinition, TableDraft, + BuildDdlError, CatalogCapabilities, CatalogOption, ColumnAlteration, ColumnAttributes, ColumnCapabilities, + ColumnDefinition, DatabaseDraft, DatabaseFields, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, + DraftType, GeneratedStorage, IdentityStyle, NameLimit, Permission, ReferentialAction, RefusalReason, ResolvedType, + TableChanges, TableDefinition, TableDraft, bare_word, check_database_draft, named, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, TableRef}; @@ -42,6 +43,27 @@ static CAPABILITIES: DdlCapabilities = DdlCapabilities { table_options: false, }; +/// The databases the server keeps for itself and refuses to drop. +const SYSTEM_DATABASES: [&str; 4] = ["master", "model", "msdb", "tempdb"]; + +/// The schemas every database has: four the server refuses to drop, and +/// those of the fixed database roles. +const FIXED_SCHEMAS: [&str; 13] = [ + "dbo", + "guest", + "sys", + "INFORMATION_SCHEMA", + "db_owner", + "db_accessadmin", + "db_securityadmin", + "db_ddladmin", + "db_backupoperator", + "db_datareader", + "db_datawriter", + "db_denydatareader", + "db_denydatawriter", +]; + fn alter_table(dialect: &MssqlDialect, table: &TableRef, action: &str) -> String { format!("ALTER TABLE {} {action}", dialect.qualify(table)) } @@ -229,6 +251,54 @@ impl DdlDialect for MssqlDialect { } DdlPlan::for_changes(changes, statements) } + + /// TRUNCATE always starts the identity again at its seed, and runs + /// inside the write's transaction. A database is made or dropped + /// outside one, as SQL Server requires. No drop closes the other + /// sessions first: SINGLE_USER set before a drop that is stopped or + /// loses its link stays set, as no CATCH runs then, so the server's + /// refusal of a database in use stands. + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + // Even a key set to NOCHECK, and whatever rows the tables hold. + truncate_refused_when_referenced: true, + create_schema: true, + drop_schema: true, + schema_owner: true, + create_database: true, + drop_database: true, + database_fields: DatabaseFields { + collation: true, + ..DatabaseFields::default() + }, + name_limit: Some(NameLimit::Chars(128)), + ..CatalogCapabilities::default() + } + } + + fn protects_database(&self, name: &str) -> bool { + SYSTEM_DATABASES.iter().any(|system| system.eq_ignore_ascii_case(name)) + } + + fn protects_schema(&self, name: &str) -> bool { + FIXED_SCHEMAS.iter().any(|fixed| fixed.eq_ignore_ascii_case(name)) + } + + fn plan_create_database(&self, draft: &DatabaseDraft) -> Result { + let capabilities = self.catalog_capabilities(); + check_database_draft(&capabilities, draft)?; + let mut sql = format!( + "CREATE DATABASE {}", + self.quote_identifier(named(&capabilities, CatalogOption::Name, &draft.name)?) + ); + // T-SQL takes a collation only as a bare name. + if let Some(collation) = &draft.collation { + sql.push_str(" COLLATE "); + sql.push_str(bare_word(CatalogOption::Collation, collation)?); + } + Ok(DdlPlan::for_databases([sql])) + } } impl MssqlDialect { @@ -552,8 +622,12 @@ fn column_comment(table: &TableRef, column: &str, had_comment: bool, comment: Op #[cfg(test)] mod tests { + use tablepro_core::WriteKind; use tablepro_core::column::{ColumnInfo, SqlExpression}; - use tablepro_core::ddl::{CollationName, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, diff_table}; + use tablepro_core::ddl::{ + self as catalog, CollationName, DropDatabaseOptions, DropSchemaOptions, DropTableOptions, InboundForeignKey, + PrimaryKeyConstraint, SchemaDraft, TruncateTableOptions, diff_table, + }; use tablepro_core::value::Value; use super::*; @@ -1173,6 +1247,214 @@ mod tests { ); } + #[test] + fn a_truncate_names_the_table_and_takes_no_option() { + let table = TableRef::new(Some("dbo".to_owned()), "or]ders"); + + let plan = dialect() + .plan_truncate_table(&table, &TruncateTableOptions::default()) + .expect("a plan"); + + assert_eq!(plan.sql(), ["TRUNCATE TABLE [dbo].[or]]ders]"]); + assert_eq!(plan.kind, WriteKind::SchemaChanges); + let cascade = TruncateTableOptions { + cascade: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + dialect().plan_truncate_table(&table, &cascade), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateTableCascade)) + ); + let restart = TruncateTableOptions { + restart_identity: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + dialect().plan_truncate_table(&table, &restart), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateRestartIdentity)) + ); + } + + #[test] + fn a_schema_is_created_with_its_owner_and_dropped_without_cascade() { + let draft = SchemaDraft { + name: "sa]les".to_owned(), + owner: Some("ann".to_owned()), + }; + + let created = dialect().plan_create_schema(&draft); + let dropped = dialect().plan_drop_schema("sales", DropSchemaOptions::default()); + + assert_eq!(sql(created), ["CREATE SCHEMA [sa]]les] AUTHORIZATION [ann]"]); + assert_eq!(sql(dropped), ["DROP SCHEMA [sales]"]); + assert_eq!( + dialect().plan_drop_schema("sales", DropSchemaOptions { cascade: true }), + Err(BuildDdlError::Unsupported(DdlOperation::DropSchemaCascade)) + ); + } + + #[test] + fn a_database_is_created_outside_a_transaction_with_a_bare_collation() { + let mut draft = DatabaseDraft::named("sh]op"); + draft.collation = Some("Latin1_General_100_CI_AS_SC_UTF8".to_owned()); + + let plan = dialect().plan_create_database(&draft).expect("a plan"); + + assert_eq!( + plan.sql(), + ["CREATE DATABASE [sh]]op] COLLATE Latin1_General_100_CI_AS_SC_UTF8"] + ); + assert_eq!(plan.kind, WriteKind::DatabaseChanges); + assert_eq!( + sql(dialect().plan_create_database(&DatabaseDraft::named("shop"))), + ["CREATE DATABASE [shop]"] + ); + draft.collation = Some("Latin1_General_CI_AS; DROP DATABASE x".to_owned()); + assert!(matches!( + dialect().plan_create_database(&draft), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Collation, + .. + }) + )); + let mut owned = DatabaseDraft::named("shop"); + owned.owner = Some("ann".to_owned()); + assert_eq!( + dialect().plan_create_database(&owned), + Err(BuildDdlError::Unsupported(DdlOperation::CatalogOwner)) + ); + let mut encoded = DatabaseDraft::named("shop"); + encoded.encoding = Some("UTF8".to_owned()); + assert!(matches!( + dialect().plan_create_database(&encoded), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Encoding, + .. + }) + )); + } + + #[test] + fn a_database_is_dropped_outside_a_transaction_and_never_forced() { + let dropped = dialect() + .plan_drop_database("sh]op", &DropDatabaseOptions::default()) + .expect("a plan"); + + assert_eq!(dropped.sql(), ["DROP DATABASE [sh]]op]"]); + assert_eq!(dropped.kind, WriteKind::DatabaseChanges); + let force = DropDatabaseOptions { + force: true, + cluster: None, + }; + assert_eq!( + dialect().plan_drop_database("shop", &force), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabaseForce)) + ); + let clustered = DropDatabaseOptions { + force: false, + cluster: Some("main".to_owned()), + }; + assert_eq!( + dialect().plan_drop_database("shop", &clustered), + Err(BuildDdlError::Unsupported(DdlOperation::OnCluster)) + ); + } + + #[test] + fn a_name_longer_than_a_sysname_is_refused() { + let longest = SchemaDraft { + name: "é".repeat(128), + owner: None, + }; + let longer = "d".repeat(129); + + assert!(dialect().plan_create_schema(&longest).is_ok()); + assert!( + dialect() + .plan_create_database(&DatabaseDraft::named("d".repeat(128))) + .is_ok() + ); + assert!(matches!( + dialect().plan_create_database(&DatabaseDraft::named(longer.clone())), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Name, + .. + }) + )); + assert!(matches!( + dialect().plan_create_schema(&SchemaDraft { + name: longer, + owner: None + }), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Name, + .. + }) + )); + } + + #[test] + fn the_servers_own_databases_and_schemas_are_protected() { + let dialect = dialect(); + + for name in ["master", "MODEL", "msdb", "tempdb"] { + assert!(dialect.protects_database(name), "{name}"); + } + assert!(!dialect.protects_database("shop")); + for name in [ + "dbo", + "guest", + "sys", + "INFORMATION_SCHEMA", + "db_owner", + "db_denydatawriter", + ] { + assert!(dialect.protects_schema(name), "{name}"); + } + assert!(!dialect.protects_schema("sales")); + assert!(!dialect.protects_schema("db_sales")); + } + + /// The sidebar offers what the capabilities say, so each one has to + /// plan, with every option it offers. + #[test] + fn every_offered_catalog_operation_plans() { + let dialect = dialect(); + let offered = catalog::catalog_capabilities(&dialect); + let table = TableRef::new(Some("dbo".to_owned()), "orders"); + let truncate = TruncateTableOptions { + cascade: offered.truncate_cascade, + restart_identity: offered.truncate_restart_identity, + cluster: None, + }; + let schema = SchemaDraft { + name: "sales".to_owned(), + owner: offered.schema_owner.then(|| "ann".to_owned()), + }; + let mut database = DatabaseDraft::named("shop"); + database.collation = offered + .database_fields + .collation + .then(|| "Latin1_General_CI_AS".to_owned()); + let drop_schema = DropSchemaOptions { + cascade: offered.drop_schema_cascade, + }; + let drop_database = DropDatabaseOptions { + force: offered.drop_database_force, + cluster: None, + }; + + assert!(offered.truncate_table && offered.truncate_refused_when_referenced); + assert!(offered.create_schema && offered.drop_schema && offered.schema_owner); + assert!(offered.create_database && offered.drop_database && !offered.drop_database_force); + assert!(!offered.on_cluster && !offered.drop_schema_cascade); + assert!(catalog::plan_truncate_table(&dialect, &table, &truncate).is_ok()); + assert!(catalog::plan_create_schema(&dialect, &schema).is_ok()); + assert!(catalog::plan_drop_schema(&dialect, "sales", drop_schema).is_ok()); + assert!(catalog::plan_create_database(&dialect, &database).is_ok()); + assert!(catalog::plan_drop_database(&dialect, "shop", &drop_database).is_ok()); + } + #[test] fn every_type_suggestion_parses() { let dialect = dialect(); diff --git a/core/crates/drivers/mssql/src/metadata.rs b/core/crates/drivers/mssql/src/metadata.rs index 065ce0d16a..e371c051ce 100644 --- a/core/crates/drivers/mssql/src/metadata.rs +++ b/core/crates/drivers/mssql/src/metadata.rs @@ -3,7 +3,7 @@ //! which resolves an unqualified name in the login's default schema the //! way the server does everywhere else. -use tablepro_core::ddl::{InboundForeignKey, ReferentialAction, TableDefinition}; +use tablepro_core::ddl::{DatabaseChoices, DatabaseDefaults, InboundForeignKey, ReferentialAction, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ EditorRefusal, IndexInfo, IndexKey, ObjectDefinition, ObjectInfo, ObjectKind, ObjectScope, RowCountEstimate, @@ -37,6 +37,20 @@ const DATABASES_SQL: &str = "SELECT d.name, \ 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 COLLATIONS_SQL: &str = "SELECT name FROM sys.fn_helpcollations() ORDER BY name"; + +/// The users and roles of the connected database that can own a +/// schema: not the fixed roles, nor the principals the server keeps. +const OWNERS_SQL: &str = "SELECT name FROM sys.database_principals \ + WHERE type IN ('S', 'U', 'G', 'E', 'X', 'R') AND is_fixed_role = 0 \ + AND name NOT IN ('public', 'sys', 'INFORMATION_SCHEMA', 'guest') \ + ORDER BY name"; + +/// The server's collation, cast from its sql_variant so the row holds +/// text, and USER_NAME(): the user the session runs as in this +/// database, whom CREATE SCHEMA without AUTHORIZATION makes the owner. +const DEFAULTS_SQL: &str = "SELECT CAST(SERVERPROPERTY('Collation') AS nvarchar(128)), USER_NAME()"; + /// One read for the objects and their triggers. `{objects}` and /// `{triggers}` narrow each half to a schema. `parent_class` 1 keeps the /// triggers of tables and views, not the database's DDL triggers. @@ -153,6 +167,41 @@ pub(crate) async fn list_databases(client: &mut MssqlClient, watch: &Watch) -> R .map_err(Interrupt::Failed) } +/// The collations a new database can take and the owners a new schema +/// can name, with what each gets when left out. +pub(crate) async fn fetch_database_choices( + client: &mut MssqlClient, + watch: &Watch, +) -> Result { + let collations = catalogue_rows(client, watch, COLLATIONS_SQL, &[]).await?; + let owners = catalogue_rows(client, watch, OWNERS_SQL, &[]).await?; + let defaults = catalogue_rows(client, watch, DEFAULTS_SQL, &[]).await?; + Ok(database_choices(&collations, &owners, &defaults)?) +} + +fn database_choices( + collations: &[Vec], + owners: &[Vec], + defaults: &[Vec], +) -> Result { + let names = |rows: &[Vec], query: &'static str| { + rows.iter() + .map(|row| Cells::new(row, query).required_text(0)) + .collect::, _>>() + }; + let defaults = defaults.first().map(|row| Cells::new(row, "SERVERPROPERTY")); + Ok(DatabaseChoices { + collations: names(collations, "sys.fn_helpcollations")?, + owners: names(owners, "sys.database_principals")?, + defaults: DatabaseDefaults { + collation: defaults.and_then(|cells| cells.text(0)), + owner: defaults.and_then(|cells| cells.text(1)), + ..DatabaseDefaults::default() + }, + ..DatabaseChoices::default() + }) +} + pub(crate) async fn list_objects( client: &mut MssqlClient, watch: &Watch, @@ -568,6 +617,34 @@ mod tests { assert!(sequence_statement("[s]", &row).ends_with(" CYCLE NO CACHE;")); } + #[test] + fn the_choices_list_collations_and_owners_with_the_servers_defaults() { + let collations = vec![ + vec![text("Latin1_General_100_CI_AS")], + vec![text("SQL_Latin1_General_CP1_CI_AS")], + ]; + let owners = vec![vec![text("ann")], vec![text("dbo")], vec![text("reporting")]]; + let defaults = vec![vec![text("SQL_Latin1_General_CP1_CI_AS"), text("dbo")]]; + + let choices = database_choices(&collations, &owners, &defaults).unwrap(); + + assert_eq!( + choices.collations, + ["Latin1_General_100_CI_AS", "SQL_Latin1_General_CP1_CI_AS"] + ); + assert_eq!(choices.owners, ["ann", "dbo", "reporting"]); + assert_eq!( + choices.defaults.collation.as_deref(), + Some("SQL_Latin1_General_CP1_CI_AS") + ); + assert_eq!(choices.defaults.owner.as_deref(), Some("dbo")); + assert!(choices.encodings.is_empty() && choices.templates.is_empty()); + let unknown = database_choices(&[], &[], &[vec![Value::Null, Value::Null]]).unwrap(); + assert_eq!(unknown.defaults, DatabaseDefaults::default()); + assert!(database_choices(&[vec![Value::Null]], &[], &[]).is_err()); + assert!(database_choices(&[], &[vec![Value::Null]], &[]).is_err()); + } + #[test] fn referential_actions_map_to_keywords() { assert_eq!(referential_action("CASCADE"), Some(ReferentialAction::Cascade)); diff --git a/core/crates/drivers/mssql/src/write_batch.rs b/core/crates/drivers/mssql/src/write_batch.rs index 79b14a96e8..c0c120efda 100644 --- a/core/crates/drivers/mssql/src/write_batch.rs +++ b/core/crates/drivers/mssql/src/write_batch.rs @@ -2,7 +2,10 @@ use std::sync::Arc; use futures_util::TryStreamExt; use tablepro_core::statement::{RowGuard, Statement}; -use tablepro_core::{DriverError, ReadOnlyRefusal, SessionFate, StopRequest, WriteBatch, WriteLedger, WriteReport}; +use tablepro_core::{ + DriverError, ReadOnlyRefusal, SessionFate, StopRequest, WriteAtomicity, WriteBatch, WriteKind, WriteLedger, + WriteReport, +}; use tiberius::{Command, CommandItem}; use tokio::time::Instant; @@ -23,6 +26,23 @@ pub(crate) fn precheck(batch: &WriteBatch, read_only: bool) -> Result<(), Driver batch.foreign_keys.require_enforced() } +/// Whether the batch runs inside one transaction. SQL Server refuses +/// CREATE and DROP DATABASE inside one, so each of those statements runs +/// on its own, and nothing takes back one that landed. +pub(crate) fn in_transaction(kind: WriteKind) -> bool { + match kind { + WriteKind::RowChanges | WriteKind::SchemaChanges => true, + WriteKind::DatabaseChanges => false, + } +} + +pub(crate) fn atomicity(kind: WriteKind) -> WriteAtomicity { + match in_transaction(kind) { + true => WriteAtomicity::Transactional, + false => WriteAtomicity::StatementByStatement, + } +} + /// Everything one write needs once its task holds a connection. pub(crate) struct WritePlan { pub(crate) batch: WriteBatch, @@ -30,8 +50,9 @@ pub(crate) struct WritePlan { pub(crate) policy: StopPolicy, } -/// Run the batch in one transaction, recording each step in the ledger -/// so a lost connection reports what is really in the database. +/// Run the batch in one transaction, or a database change one statement +/// at a time, recording each step in the ledger so a lost connection +/// reports what is really in the database. pub(crate) async fn perform( pooled: &mut MssqlPooled, plan: &WritePlan, @@ -64,7 +85,8 @@ struct Write<'a> { impl Write<'_> { async fn run(&mut self, batch: &WriteBatch) -> Result { - if let Err(interrupt) = self.batch("BEGIN TRANSACTION").await { + let transaction = in_transaction(batch.kind); + if transaction && let Err(interrupt) = self.batch("BEGIN TRANSACTION").await { return Err(self.failed(0, interrupt).await); } let mut report = Vec::with_capacity(batch.steps.len()); @@ -93,7 +115,9 @@ impl Write<'_> { } report.push(Some(affected)); } - self.commit(batch.steps.len()).await?; + if transaction { + self.commit(batch.steps.len()).await?; + } Ok(WriteReport::new(report)) } @@ -243,7 +267,7 @@ impl Write<'_> { #[cfg(test)] mod tests { use tablepro_core::meta::TableRef; - use tablepro_core::{ForeignKeyCheck, ForeignKeyMode, UnsupportedFeature, WriteKind}; + use tablepro_core::{ForeignKeyCheck, ForeignKeyMode, UnsupportedFeature}; use super::*; @@ -271,6 +295,18 @@ mod tests { assert!(precheck(&batch(ForeignKeyMode::Enforced), false).is_ok()); } + #[test] + fn only_a_database_change_runs_outside_a_transaction() { + assert!(in_transaction(WriteKind::RowChanges)); + assert!(in_transaction(WriteKind::SchemaChanges)); + assert!(!in_transaction(WriteKind::DatabaseChanges)); + assert_eq!(atomicity(WriteKind::SchemaChanges), WriteAtomicity::Transactional); + assert_eq!( + atomicity(WriteKind::DatabaseChanges), + WriteAtomicity::StatementByStatement + ); + } + #[test] fn write_batch_client_guard() { assert!(matches!( diff --git a/core/crates/drivers/mssql/tests/integration/catalog_operations.rs b/core/crates/drivers/mssql/tests/integration/catalog_operations.rs new file mode 100644 index 0000000000..5eff0d01b2 --- /dev/null +++ b/core/crates/drivers/mssql/tests/integration/catalog_operations.rs @@ -0,0 +1,266 @@ +//! Emptying a table and making and dropping schemas and databases, as +//! the sidebar plans them, run against a real server. + +use std::sync::Arc; +use std::time::Duration; + +use tablepro_core::ddl::{ + self as catalog, DatabaseDraft, DdlPlan, DropDatabaseOptions, DropSchemaOptions, SchemaDraft, TruncateTableOptions, +}; +use tablepro_core::{ConnectTarget, Connection, DriverError, TableRef, Value, WorkspaceSettings}; +use tablepro_test_fixtures::MssqlFixture; +use tokio::time::Instant; + +use crate::support::{CALL, connect, dbo, dial, fixture_options, require, rows, scalar, setup, start, within}; + +async fn run(connection: &dyn Connection, plan: DdlPlan, table: TableRef) -> Result<(), DriverError> { + connection + .write_batch(&plan.into_write_batch(table), within(CALL)) + .await + .map(|_| ()) +} + +/// The table a schema or database batch carries: its name, and no table. +fn database(name: &str) -> TableRef { + TableRef::new(Some(name.to_owned()), "") +} + +async fn database_names(connection: &dyn Connection) -> Vec { + connection + .list_databases(within(CALL)) + .await + .expect("the databases") + .into_iter() + .map(|database| database.name) + .collect() +} + +async fn schema_names(connection: &dyn Connection) -> Vec { + connection + .list_schemas(within(CALL)) + .await + .expect("the schemas") + .into_iter() + .map(|schema| schema.name) + .collect() +} + +/// A connection whose login names `database`. +async fn dial_into(fixture: &MssqlFixture, database: &str) -> Result, DriverError> { + let mut options = fixture_options(fixture, require(), WorkspaceSettings::workspace(false)); + if let ConnectTarget::Network(target) = &mut options.target { + target.database = Some(database.to_owned()); + } + dial(options).await +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_truncate_empties_the_table_and_starts_its_identity_again() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE dbo.kept (id int IDENTITY(1, 1) PRIMARY KEY, name nvarchar(20))", + "INSERT INTO dbo.kept (name) VALUES (N'a'), (N'b')", + ], + ) + .await; + let dialect = connection.dialect(); + let plan = + catalog::plan_truncate_table(dialect.as_ref(), &dbo("kept"), &TruncateTableOptions::default()).expect("a plan"); + + run(connection.as_ref(), plan, dbo("kept")).await.expect("the truncate"); + setup(connection.as_ref(), &["INSERT INTO dbo.kept (name) VALUES (N'c')"]).await; + + assert_eq!( + rows(connection.as_ref(), "SELECT id, name FROM dbo.kept").await, + [[Value::Int(1), Value::Text("c".to_owned())]] + ); +} + +/// SQL Server refuses by the key's definition, so an empty child table +/// still holds its parent. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_table_another_tables_key_points_at_is_not_truncated() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE dbo.parent (id int PRIMARY KEY)", + "CREATE TABLE dbo.child (id int PRIMARY KEY, parent_id int REFERENCES dbo.parent (id))", + "INSERT INTO dbo.parent (id) VALUES (1)", + ], + ) + .await; + let dialect = connection.dialect(); + let plan = catalog::plan_truncate_table(dialect.as_ref(), &dbo("parent"), &TruncateTableOptions::default()) + .expect("a plan"); + + let refused = run(connection.as_ref(), plan, dbo("parent")).await; + + assert!(matches!(refused, Err(DriverError::RolledBack { .. })), "{refused:?}"); + assert_eq!( + scalar(connection.as_ref(), "SELECT COUNT(*) FROM dbo.parent").await, + Value::Int(1) + ); +} + +/// CREATE SCHEMA has to start its batch, which sp_executesql gives it +/// inside the write's transaction. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_schema_is_created_with_its_owner_and_dropped_once_empty() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let dialect = connection.dialect(); + let draft = SchemaDraft { + name: "sales".to_owned(), + owner: Some("dbo".to_owned()), + }; + let created = catalog::plan_create_schema(dialect.as_ref(), &draft).expect("a plan"); + let drop_plan = + || catalog::plan_drop_schema(dialect.as_ref(), "sales", DropSchemaOptions::default()).expect("a plan"); + + run(connection.as_ref(), created, database("sales")) + .await + .expect("the schema is created"); + setup(connection.as_ref(), &["CREATE TABLE sales.orders (id int)"]).await; + let refused = run(connection.as_ref(), drop_plan(), database("sales")).await; + setup(connection.as_ref(), &["DROP TABLE sales.orders"]).await; + let owner = scalar( + connection.as_ref(), + "SELECT USER_NAME(principal_id) FROM sys.schemas WHERE name = N'sales'", + ) + .await; + let listed = schema_names(connection.as_ref()).await; + run(connection.as_ref(), drop_plan(), database("sales")) + .await + .expect("the schema is dropped"); + + assert!(matches!(refused, Err(DriverError::RolledBack { .. })), "{refused:?}"); + assert_eq!(owner, Value::Text("dbo".to_owned())); + assert!(listed.iter().any(|name| name == "sales"), "{listed:?}"); + let after = schema_names(connection.as_ref()).await; + assert!(!after.iter().any(|name| name == "sales"), "{after:?}"); +} + +/// The owners New Schema offers are the connected database's users and +/// roles a schema can belong to, and the one left out is the session's +/// own user, which the fixture's login maps to dbo. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn the_choices_list_collations_and_the_owners_a_schema_can_have() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + setup(connection.as_ref(), &["CREATE ROLE reporting"]).await; + + let choices = connection + .fetch_database_choices(within(CALL)) + .await + .expect("the choices"); + + let collation = choices.defaults.collation.clone().expect("the server's collation"); + assert!(choices.collations.contains(&collation), "{collation}"); + assert!( + choices.owners.iter().any(|owner| owner == "dbo"), + "{:?}", + choices.owners + ); + assert!( + choices.owners.iter().any(|owner| owner == "reporting"), + "{:?}", + choices.owners + ); + for kept in [ + "public", + "guest", + "sys", + "INFORMATION_SCHEMA", + "db_owner", + "db_datareader", + ] { + assert!(!choices.owners.iter().any(|owner| owner == kept), "{kept}"); + } + assert_eq!(choices.defaults.owner.as_deref(), Some("dbo")); +} + +/// Sessions still in `database`. +async fn sessions_in(connection: &dyn Connection, database: &str) -> u64 { + scalar( + connection, + &format!("SELECT COUNT(*) FROM sys.dm_exec_sessions WHERE database_id = DB_ID(N'{database}')"), + ) + .await + .as_exact_u64() + .expect("a count") +} + +/// SQL Server refuses CREATE and DROP DATABASE inside a transaction, and +/// a drop while another session is in the database. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_database_is_created_and_dropped_once_no_session_is_in_it() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let dialect = connection.dialect(); + let mut draft = DatabaseDraft::named("tablepro_made"); + draft.collation = Some("Latin1_General_100_CI_AS".to_owned()); + let created = catalog::plan_create_database(dialect.as_ref(), &draft).expect("a plan"); + let drop_plan = || { + catalog::plan_drop_database(dialect.as_ref(), "tablepro_made", &DropDatabaseOptions::default()).expect("a plan") + }; + + run(connection.as_ref(), created, database("tablepro_made")) + .await + .expect("the database is created"); + let inside = dial_into(&fixture, "tablepro_made") + .await + .expect("a connection in the new database"); + assert_eq!( + scalar(inside.as_ref(), "SELECT DB_NAME()").await, + Value::Text("tablepro_made".to_owned()) + ); + let refused = run(connection.as_ref(), drop_plan(), database("tablepro_made")).await; + let collation = scalar( + connection.as_ref(), + "SELECT collation_name FROM sys.databases WHERE name = N'tablepro_made'", + ) + .await; + inside.close().await.expect("the connection closes"); + let until = Instant::now() + Duration::from_secs(10); + while sessions_in(connection.as_ref(), "tablepro_made").await > 0 && Instant::now() < until { + tokio::time::sleep(Duration::from_millis(100)).await; + } + run(connection.as_ref(), drop_plan(), database("tablepro_made")) + .await + .expect("the drop"); + + assert!(matches!(refused, Err(DriverError::RolledBack { .. })), "{refused:?}"); + assert_eq!(collation, Value::Text("Latin1_General_100_CI_AS".to_owned())); + let after = database_names(connection.as_ref()).await; + assert!(!after.iter().any(|name| name == "tablepro_made"), "{after:?}"); +} + +/// Error 4060 also answers a database that is offline or that the login +/// may not open, so the refusal keeps the server's own words rather +/// than claiming the database is gone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_login_to_a_database_the_server_lacks_is_refused_in_its_words() { + let fixture = start().await; + + let refused = dial_into(&fixture, "tablepro_gone").await; + + assert!( + matches!( + &refused, + Err(DriverError::Server(_) | DriverError::Auth { diagnostics: Some(_) }) + ), + "{:?}", + refused.as_ref().err() + ); +} diff --git a/core/crates/drivers/mssql/tests/integration/main.rs b/core/crates/drivers/mssql/tests/integration/main.rs index 6e26f190e3..67925e5fb8 100644 --- a/core/crates/drivers/mssql/tests/integration/main.rs +++ b/core/crates/drivers/mssql/tests/integration/main.rs @@ -9,6 +9,7 @@ )] mod cancellation; +mod catalog_operations; mod conformance_engine; mod csv_import; mod ddl; diff --git a/core/crates/drivers/mysql/src/connection.rs b/core/crates/drivers/mysql/src/connection.rs index 6c823e9ccc..0ec283b4df 100644 --- a/core/crates/drivers/mysql/src/connection.rs +++ b/core/crates/drivers/mysql/src/connection.rs @@ -5,7 +5,7 @@ use async_trait::async_trait; use deadpool::managed::{PoolError, TimeoutType}; use sqlx::Executor as _; use tablepro_core::column::ColumnInfo; -use tablepro_core::ddl::{CharacterSet, InboundForeignKey, OutboundForeignKey, TableDefinition}; +use tablepro_core::ddl::{CharacterSet, DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ IndexInfo, ObjectDefinition, ObjectInfo, ObjectScope, RowIdentity, SchemaInfo, TableEstimate, TableRef, @@ -156,10 +156,13 @@ impl MysqlConnection { } /// Whether the table's engine can roll back, read before the write - /// so the ledger knows what an interruption leaves behind. + /// so the ledger knows what an interruption leaves behind. DDL + /// commits each statement whatever the engine, and a database batch + /// names no table to look up. async fn atomicity(&self, batch: &WriteBatch, options: &CallOptions) -> Result { - if batch.kind == WriteKind::SchemaChanges { - return Ok(WriteAtomicity::StatementByStatement); + match batch.kind { + WriteKind::SchemaChanges | WriteKind::DatabaseChanges => return Ok(WriteAtomicity::StatementByStatement), + WriteKind::RowChanges => {} } let table = batch.table.clone(); // A call cancels its token when it returns, so the lookup gets a @@ -276,12 +279,10 @@ impl Connection for MysqlConnection { .await } + /// 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.read( - options, - read_op(|connection, flavour| Box::pin(metadata::list_databases(connection, flavour))), - ) - .await + self.list_schemas(options).await } async fn list_objects(&self, scope: &ObjectScope, options: CallOptions) -> Result, DriverError> { @@ -405,6 +406,16 @@ impl Connection for MysqlConnection { .await } + async fn fetch_database_choices(&self, options: CallOptions) -> Result { + self.read( + options, + read_op(move |connection, flavour| { + Box::pin(async move { metadata::fetch_database_choices(connection, flavour).await }) + }), + ) + .await + } + async fn row_identity(&self, table: &TableRef, options: CallOptions) -> Result { let table = table.clone(); let dialect = *self.dialect; diff --git a/core/crates/drivers/mysql/src/ddl/mod.rs b/core/crates/drivers/mysql/src/ddl/mod.rs index f3da090510..de85696d77 100644 --- a/core/crates/drivers/mysql/src/ddl/mod.rs +++ b/core/crates/drivers/mysql/src/ddl/mod.rs @@ -3,15 +3,17 @@ mod mysql_column_definition; use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, foreign_key_sql, index_keys_sql}; use tablepro_core::ddl::{ - BuildDdlError, ChangedAttributes, ColumnAlteration, ColumnCapabilities, ColumnDefinition, DdlCapabilities, - DdlDialect, DdlOperation, DdlPlan, DraftTableOptions, Permission, ReferentialAction, RefusalReason, TableChanges, - TableDefinition, TableDraft, TableOptionsChange, + BuildDdlError, CatalogCapabilities, CatalogOption, ChangedAttributes, ColumnAlteration, ColumnCapabilities, + ColumnDefinition, DatabaseDraft, DatabaseFields, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, + DraftTableOptions, NameLimit, Permission, ReferentialAction, RefusalReason, TableChanges, TableDefinition, + TableDraft, TableOptionsChange, bare_word, check_database_draft, named, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; use crate::catalog::printed_names; use crate::dialect::MySqlDialect; +use crate::metadata::SYSTEM_DATABASES; use mysql_column_definition::{nullability_permission, render, restatement}; const fn capabilities(expression_index_keys: bool) -> DdlCapabilities { @@ -271,6 +273,50 @@ impl DdlDialect for MySqlDialect { .map(|source| DdlPlan::from_sql([source.create_statement.clone()])) .ok_or(BuildDdlError::NoTableSource) } + + /// A schema is a database here. TRUNCATE always starts the + /// AUTO_INCREMENT counter again, so that is no choice to offer. + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + // InnoDB refuses any table another table's foreign key names, + // even with no row pointing at it. + truncate_refused_when_referenced: true, + create_database: true, + drop_database: true, + database_fields: DatabaseFields { + character_set: true, + collation: true, + ..DatabaseFields::default() + }, + name_limit: Some(NameLimit::Chars(64)), + ..CatalogCapabilities::default() + } + } + + fn protects_database(&self, name: &str) -> bool { + SYSTEM_DATABASES.contains(&name) + } + + /// Either option alone is enough: a collation names its character + /// set, and a character set brings its default collation. + fn plan_create_database(&self, draft: &DatabaseDraft) -> Result { + let capabilities = self.catalog_capabilities(); + check_database_draft(&capabilities, draft)?; + let mut sql = format!( + "CREATE DATABASE {}", + self.quote_identifier(named(&capabilities, CatalogOption::Name, &draft.name)?) + ); + if let Some(character_set) = &draft.character_set { + sql.push_str(" CHARACTER SET "); + sql.push_str(bare_word(CatalogOption::CharacterSet, character_set)?); + } + if let Some(collation) = &draft.collation { + sql.push_str(" COLLATE "); + sql.push_str(bare_word(CatalogOption::Collation, collation)?); + } + Ok(DdlPlan::for_databases([sql])) + } } /// Whether an alteration needs the column's definition restated. A @@ -418,14 +464,15 @@ fn refuse_renamed_names(alteration: &ColumnAlteration, changes: &TableChanges) - #[cfg(test)] mod tests { - use tablepro_core::ForeignKeyInfo; - use tablepro_core::ServerInfo; use tablepro_core::column::{ColumnDefault, ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ - CollationName, ColumnAttributes, ColumnVisibility, DdlOperation, DraftColumn, DraftType, DropTableOptions, - GeneratedColumn, GeneratedStorage, InboundForeignKey, PrimaryKeyConstraint, TableOptions, UnmodeledAttribute, - diff_table, + CollationName, ColumnAttributes, ColumnVisibility, DdlOperation, DraftColumn, DraftType, DropDatabaseOptions, + DropSchemaOptions, DropTableOptions, GeneratedColumn, GeneratedStorage, InboundForeignKey, PrimaryKeyConstraint, + SchemaDraft, TableOptions, TruncateTableOptions, UnmodeledAttribute, catalog_capabilities, diff_table, + plan_create_database, plan_create_schema, plan_drop_database, plan_drop_schema, plan_truncate_table, + protects_database, }; + use tablepro_core::{ForeignKeyInfo, ServerInfo, WriteKind}; use tablepro_core::meta::{IndexInfo, IndexKey}; use tablepro_core::value::Value; @@ -1368,4 +1415,227 @@ mod tests { } assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); } + + fn database(name: &str, character_set: Option<&str>, collation: Option<&str>) -> DatabaseDraft { + DatabaseDraft { + character_set: character_set.map(str::to_owned), + collation: collation.map(str::to_owned), + ..DatabaseDraft::named(name) + } + } + + #[test] + fn a_table_is_truncated_by_its_qualified_name_with_no_option() { + let table = TableRef::new(Some("app".to_owned()), "or`ders"); + + let plan = dialect() + .plan_truncate_table(&table, &TruncateTableOptions::default()) + .expect("a plan"); + + assert_eq!(plan.sql(), ["TRUNCATE TABLE `app`.`or``ders`"]); + assert_eq!(plan.kind, WriteKind::SchemaChanges); + let refused = [ + ( + TruncateTableOptions { + cascade: true, + ..TruncateTableOptions::default() + }, + DdlOperation::TruncateTableCascade, + ), + ( + TruncateTableOptions { + restart_identity: true, + ..TruncateTableOptions::default() + }, + DdlOperation::TruncateRestartIdentity, + ), + ( + TruncateTableOptions { + cluster: Some("main".to_owned()), + ..TruncateTableOptions::default() + }, + DdlOperation::OnCluster, + ), + ]; + for (options, operation) in refused { + assert_eq!( + dialect().plan_truncate_table(&table, &options), + Err(BuildDdlError::Unsupported(operation)) + ); + } + } + + #[test] + fn a_database_is_created_with_the_character_set_and_collation_it_names() { + let cases = [ + ( + database("sh`op", Some("utf8mb4"), Some("utf8mb4_0900_ai_ci")), + "CREATE DATABASE `sh``op` CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci", + ), + ( + database("shop", Some("latin1"), None), + "CREATE DATABASE `shop` CHARACTER SET latin1", + ), + ( + database("shop", None, Some("utf8mb4_bin")), + "CREATE DATABASE `shop` COLLATE utf8mb4_bin", + ), + (database("shop", None, None), "CREATE DATABASE `shop`"), + ]; + + for (draft, expected) in cases { + let plan = dialect().plan_create_database(&draft).expect("a plan"); + assert_eq!(plan.sql(), [expected]); + assert_eq!(plan.kind, WriteKind::DatabaseChanges); + } + } + + #[test] + fn a_database_is_dropped_by_its_quoted_name() { + let plan = dialect() + .plan_drop_database("sh`op", &DropDatabaseOptions::default()) + .expect("a plan"); + + assert_eq!(plan.sql(), ["DROP DATABASE `sh``op`"]); + assert_eq!(plan.kind, WriteKind::DatabaseChanges); + let forced = DropDatabaseOptions { + force: true, + cluster: None, + }; + assert_eq!( + dialect().plan_drop_database("shop", &forced), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabaseForce)) + ); + } + + #[test] + fn a_character_set_or_collation_beyond_a_bare_word_is_refused() { + let cases = [ + ( + database("shop", Some("utf8mb4 COLLATE x; DROP DATABASE y"), None), + CatalogOption::CharacterSet, + ), + (database("shop", None, Some("utf8mb4_bin`")), CatalogOption::Collation), + (database("shop", Some(""), None), CatalogOption::CharacterSet), + ]; + + for (draft, refused) in cases { + assert!( + matches!( + dialect().plan_create_database(&draft), + Err(BuildDdlError::InvalidOption { option, .. }) if option == refused + ), + "{draft:?}" + ); + } + } + + #[test] + fn a_database_option_mysql_has_no_place_for_is_refused() { + let owned = DatabaseDraft { + owner: Some("ann".to_owned()), + ..DatabaseDraft::named("shop") + }; + let templated = DatabaseDraft { + template: Some("template0".to_owned()), + ..DatabaseDraft::named("shop") + }; + let clustered = DatabaseDraft { + cluster: Some("main".to_owned()), + ..DatabaseDraft::named("shop") + }; + + assert_eq!( + dialect().plan_create_database(&owned), + Err(BuildDdlError::Unsupported(DdlOperation::CatalogOwner)) + ); + assert!(matches!( + dialect().plan_create_database(&templated), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Template, + .. + }) + )); + assert_eq!( + dialect().plan_create_database(&clustered), + Err(BuildDdlError::Unsupported(DdlOperation::OnCluster)) + ); + } + + #[test] + fn a_database_name_is_kept_to_64_characters() { + let longest = "é".repeat(64); + let longer = "é".repeat(65); + + assert!(dialect().plan_create_database(&DatabaseDraft::named(longest)).is_ok()); + assert!(matches!( + dialect().plan_create_database(&DatabaseDraft::named(longer)), + Err(BuildDdlError::InvalidOption { + option: CatalogOption::Name, + .. + }) + )); + } + + #[test] + fn the_servers_own_databases_are_protected() { + for name in ["information_schema", "mysql", "performance_schema", "sys"] { + assert!(protects_database(&dialect(), name), "{name}"); + } + assert!(!protects_database(&dialect(), "shop")); + assert!(!protects_database(&mariadb(), "Mysql")); + } + + /// The sidebar offers what the capabilities say, so each one must + /// plan, and each one left out must not. + #[test] + fn every_catalog_operation_offered_plans_and_no_other_does() { + for dialect in [dialect(), mariadb()] { + let offered = catalog_capabilities(&dialect); + let fields = offered.database_fields; + let table = TableRef::new(Some("app".to_owned()), "users"); + let schema = SchemaDraft { + name: "app".to_owned(), + owner: None, + }; + let draft = database( + "shop", + fields.character_set.then_some("utf8mb4"), + fields.collation.then_some("utf8mb4_bin"), + ); + let plans = [ + ( + "truncate", + offered.truncate_table, + plan_truncate_table(&dialect, &table, &TruncateTableOptions::default()), + ), + ( + "create schema", + offered.create_schema, + plan_create_schema(&dialect, &schema), + ), + ( + "drop schema", + offered.drop_schema, + plan_drop_schema(&dialect, "app", DropSchemaOptions::default()), + ), + ( + "create database", + offered.create_database, + plan_create_database(&dialect, &draft), + ), + ( + "drop database", + offered.drop_database, + plan_drop_database(&dialect, "shop", &DropDatabaseOptions::default()), + ), + ]; + + for (operation, offered, plan) in plans { + assert_eq!(offered, plan.is_ok(), "{operation}: {plan:?}"); + } + assert!(offered.truncate_table && offered.create_database && offered.drop_database); + assert!(offered.truncate_refused_when_referenced); + } + } } diff --git a/core/crates/drivers/mysql/src/errors.rs b/core/crates/drivers/mysql/src/errors.rs index bc86482971..d96a10e944 100644 --- a/core/crates/drivers/mysql/src/errors.rs +++ b/core/crates/drivers/mysql/src/errors.rs @@ -33,6 +33,9 @@ pub(crate) const MARIADB_STATEMENT_TIMEOUT: u16 = 1969; /// ER_SECURE_TRANSPORT_REQUIRED under `require_secure_transport`, which /// MariaDB took over with MySQL's number. pub(crate) const SECURE_TRANSPORT_REQUIRED: u16 = 3159; +/// ER_BAD_DB_ERROR: the database a login or a statement names is not +/// there. +const UNKNOWN_DATABASE: u16 = 1049; /// ER_SERVER_SHUTDOWN. const SERVER_SHUTDOWN: u16 = 1053; /// MariaDB's ER_CONNECTION_KILLED. @@ -61,18 +64,35 @@ pub(crate) fn map_error(error: &sqlx::Error, flavour: MySqlFlavour) -> DriverErr } /// What a failed connect means. The flavour is not known before the -/// login, so the numbers either server uses are both read. -pub(crate) fn map_connect_error(error: &sqlx::Error, endpoint: &NetworkEndpoint) -> DriverError { +/// login, so the numbers either server uses are both read. `database` +/// is the one the login names. +pub(crate) fn map_connect_error( + error: &sqlx::Error, + endpoint: &NetworkEndpoint, + database: Option<&str>, +) -> DriverError { match error { sqlx::Error::Io(io) => match tablepro_net::tls::classify(io) { Some((failure, detail)) => DriverError::Tls { failure, detail }, None => connect_io_error(io, endpoint), }, - other => match map_with(other, None) { - DriverError::ConnectionLost { .. } => DriverError::ConnectionLost { - during: LossPhase::Connect, + other => during_login(map_with(other, None), database), + } +} + +/// An error read during the login. Only here does 1049 mean the +/// connection itself cannot open: a statement's 1049 is about a database +/// the statement names. +fn during_login(mapped: DriverError, database: Option<&str>) -> DriverError { + match mapped { + DriverError::ConnectionLost { .. } => DriverError::ConnectionLost { + during: LossPhase::Connect, + }, + mapped => match database { + Some(database) if server_number(&mapped) == Some(UNKNOWN_DATABASE) => DriverError::DatabaseNotFound { + database: database.to_owned(), }, - mapped => mapped, + _ => mapped, }, } } @@ -318,11 +338,35 @@ mod tests { let error = sqlx::Error::Io(io::Error::from(io::ErrorKind::ConnectionRefused)); assert_eq!( - map_connect_error(&error, &endpoint), + map_connect_error(&error, &endpoint, Some("shop")), DriverError::Transport(TransportError::Refused { endpoint }) ); } + #[test] + fn an_unknown_database_is_missing_only_for_the_login() { + let unknown = || classify_number(numbered(UNKNOWN_DATABASE), None); + + assert!(matches!(unknown(), DriverError::Server(_))); + assert_eq!( + during_login(unknown(), Some("shop")), + DriverError::DatabaseNotFound { + database: "shop".to_owned() + } + ); + assert!(matches!(during_login(unknown(), None), DriverError::Server(_))); + assert!(matches!( + during_login(classify_number(numbered(ACCESS_DENIED), None), Some("shop")), + DriverError::Auth { .. } + )); + assert_eq!( + during_login(classify_number(numbered(SERVER_SHUTDOWN), None), Some("shop")), + DriverError::ConnectionLost { + during: LossPhase::Connect + } + ); + } + #[test] fn a_tls_refusal_without_a_rustls_error_is_the_server_refusing_tls() { let error = sqlx::Error::Tls("server does not support TLS".into()); diff --git a/core/crates/drivers/mysql/src/metadata.rs b/core/crates/drivers/mysql/src/metadata.rs index c718bc126e..3a45624ddd 100644 --- a/core/crates/drivers/mysql/src/metadata.rs +++ b/core/crates/drivers/mysql/src/metadata.rs @@ -10,8 +10,8 @@ use sqlx::mysql::{MySqlConnection, MySqlDatabaseError}; use sqlx::{AssertSqlSafe, Executor as _, Row as _}; use tablepro_core::column::{ColumnDefault, ColumnInfo, ColumnKind, SqlExpression, TemporalZone}; use tablepro_core::ddl::{ - CharacterSet, ColumnDefinition, InboundForeignKey, OutboundForeignKey, PrimaryKeyConstraint, ReferentialAction, - TableDefinition, TableOptions, TableSource, UnmodeledAttribute, + CharacterSet, ColumnDefinition, DatabaseChoices, DatabaseDefaults, InboundForeignKey, OutboundForeignKey, + PrimaryKeyConstraint, ReferentialAction, TableDefinition, TableOptions, TableSource, UnmodeledAttribute, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ @@ -69,6 +69,11 @@ fn table_binds(table: &TableRef) -> [Option<&str>; 2] { [table.schema.as_deref(), Some(table.name.as_str())] } +/// The databases MySQL and MariaDB keep for themselves. +pub(crate) const SYSTEM_DATABASES: [&str; 4] = ["information_schema", "mysql", "performance_schema", "sys"]; + +/// Every database the login can see, the connection's own the default, +/// so one that holds nothing yet is listed too. pub(crate) async fn list_schemas( connection: &mut MySqlConnection, flavour: MySqlFlavour, @@ -78,29 +83,6 @@ pub(crate) async fn list_schemas( Some(row) => row.text(0)?, None => None, }; - Ok(current - .into_iter() - .map(|name| SchemaInfo { - name, - is_default: true, - is_system: false, - }) - .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, @@ -721,6 +703,32 @@ pub(crate) async fn fetch_character_sets( character_sets(&sets, &collations) } +/// What CREATE DATABASE falls back to when it names neither option. +const SERVER_DEFAULTS_SQL: &str = "SELECT @@character_set_server, @@collation_server"; + +/// The character sets, each with its collations, and the pair a new +/// database takes when it names neither. +pub(crate) async fn fetch_database_choices( + connection: &mut MySqlConnection, + flavour: MySqlFlavour, +) -> Result { + let character_sets = fetch_character_sets(connection, flavour).await?; + let rows = catalog_rows(connection, flavour, SERVER_DEFAULTS_SQL, "SESSION", &[]).await?; + let (character_set, collation) = match rows.first() { + Some(row) => (row.text(0)?, row.text(1)?), + None => (None, None), + }; + Ok(DatabaseChoices { + character_sets, + defaults: DatabaseDefaults { + character_set, + collation, + ..DatabaseDefaults::default() + }, + ..DatabaseChoices::default() + }) +} + /// A TIMESTAMP's constant default as wall time in the server's zone, /// which is where a schema change runs (`server_zone`), so restating it /// keeps the instant. This session reads in UTC, so SHOW CREATE TABLE diff --git a/core/crates/drivers/mysql/src/physical.rs b/core/crates/drivers/mysql/src/physical.rs index 0fecce887e..090affafc4 100644 --- a/core/crates/drivers/mysql/src/physical.rs +++ b/core/crates/drivers/mysql/src/physical.rs @@ -200,11 +200,12 @@ impl MysqlEndpoint { if let Some(relay) = self.relay.get() { relay.take_failure(); } + let database = options.get_database(); let mut connection = match options.connect().await { Ok(connection) => connection, - Err(error) => return Err(self.explain(map_connect_error(&error, self.service())).await), + Err(error) => return Err(self.explain(map_connect_error(&error, self.service(), database)).await), }; - let (connection_id, version) = identify(&mut connection, self.service()).await?; + let (connection_id, version) = identify(&mut connection, self.service(), database).await?; let flavour = MySqlFlavour::detect(&version); let mut statements = match kind { SessionKind::Control => Vec::new(), @@ -297,11 +298,15 @@ fn ssl_mode(mode: TlsMode) -> Result { /// The connection id and version, read over the text protocol so no /// statement is prepared before the session is set up. -async fn identify(connection: &mut MySqlConnection, endpoint: &NetworkEndpoint) -> Result<(u64, String), DriverError> { +async fn identify( + connection: &mut MySqlConnection, + endpoint: &NetworkEndpoint, + database: Option<&str>, +) -> Result<(u64, String), DriverError> { let row = sqlx::raw_sql("SELECT CONNECTION_ID(), VERSION()") .fetch_one(&mut *connection) .await - .map_err(|error| map_connect_error(&error, endpoint))?; + .map_err(|error| map_connect_error(&error, endpoint, database))?; let text = |index: usize| -> Result { let bytes: &[u8] = row.try_get_unchecked(index).map_err(|error| DriverError::Decode { column: index.to_string(), diff --git a/core/crates/drivers/mysql/src/write_batch.rs b/core/crates/drivers/mysql/src/write_batch.rs index b14e9bed1c..d8286a3781 100644 --- a/core/crates/drivers/mysql/src/write_batch.rs +++ b/core/crates/drivers/mysql/src/write_batch.rs @@ -78,7 +78,7 @@ async fn in_server_zone(context: &mut WriteContext<'_>, plan: &WritePlan) -> Res match (plan.batch.kind, plan.atomicity) { (WriteKind::RowChanges, WriteAtomicity::Transactional) => transactional(context, plan).await, (WriteKind::RowChanges, WriteAtomicity::StatementByStatement) => stepwise(context, plan).await, - (WriteKind::SchemaChanges, _) => schema_changes(context, plan).await, + (WriteKind::SchemaChanges | WriteKind::DatabaseChanges, _) => schema_changes(context, plan).await, } } diff --git a/core/crates/drivers/mysql/tests/integration/ddl.rs b/core/crates/drivers/mysql/tests/integration/ddl.rs index 709d2b3f2b..f2c59dc98a 100644 --- a/core/crates/drivers/mysql/tests/integration/ddl.rs +++ b/core/crates/drivers/mysql/tests/integration/ddl.rs @@ -2,13 +2,16 @@ use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{ - BuildDdlError, CollationName, ColumnVisibility, DraftColumn, DraftTableOptions, DraftType, GeneratedStorage, - Permission, RefusalReason, TableDefinition, TableDraft, TableOptions, diff_table, + BuildDdlError, CollationName, ColumnVisibility, DatabaseDraft, DraftColumn, DraftTableOptions, DraftType, + DropDatabaseOptions, GeneratedStorage, Permission, RefusalReason, TableDefinition, TableDraft, TableOptions, + TruncateTableOptions, diff_table, plan_create_database, plan_drop_database, plan_truncate_table, }; -use tablepro_core::{Connection, WriteKind}; +use tablepro_core::{Connection, TableRef, WriteKind}; use tablepro_test_fixtures::{MysqlFlavour, MysqlOptions}; -use crate::support::{CALL, FLAVOURS, apply, connect, scalar, setup, start, start_with, table, text, within}; +use crate::support::{ + CALL, FLAVOURS, apply, apply_on, connect, connect_root, rows, scalar, setup, start, start_with, table, text, within, +}; async fn read(connection: &dyn Connection) -> TableDefinition { connection @@ -748,3 +751,102 @@ async fn a_retyped_column_keeps_its_character_set_and_on_update() { connection.close().await.expect("a close"); } } + +/// TRUNCATE empties the table, keeps it and starts its AUTO_INCREMENT +/// counter again. A table another table's foreign key names is refused +/// even while no row points at it, which is why the app asks first. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn truncate_empties_the_table_and_restarts_its_counter() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let connection = connect(&fixture, false).await; + setup( + connection.as_ref(), + &[ + "CREATE TABLE emptied (id int AUTO_INCREMENT PRIMARY KEY, label varchar(10))", + "CREATE TABLE parent (id int PRIMARY KEY)", + "CREATE TABLE child (id int PRIMARY KEY, parent_id int, \ + FOREIGN KEY (parent_id) REFERENCES parent (id))", + ], + ) + .await; + apply_on( + connection.as_ref(), + WriteKind::RowChanges, + &table("emptied"), + &["INSERT INTO emptied (label) VALUES ('a'), ('b'), ('c')", "INSERT INTO parent (id) VALUES (1)"], + ) + .await + .expect("rows to empty"); + let dialect = connection.dialect(); + let truncate = |name: &str| { + plan_truncate_table(dialect.as_ref(), &table(name), &TruncateTableOptions::default()) + .expect("a plan") + .into_write_batch(table(name)) + }; + + connection + .write_batch(&truncate("emptied"), within(CALL)) + .await + .expect("the truncate runs"); + let referenced = connection.write_batch(&truncate("parent"), within(CALL)).await; + apply_on( + connection.as_ref(), + WriteKind::RowChanges, + &table("emptied"), + &["INSERT INTO emptied (label) VALUES ('d')"], + ) + .await + .expect("a row after the truncate"); + + let kept: Vec = rows(connection.as_ref(), "SELECT COUNT(*), MAX(id) FROM emptied") + .await + .first() + .map(|row| row.iter().map(text).collect()) + .unwrap_or_default(); + let parents = text(&scalar(connection.as_ref(), "SELECT COUNT(*) FROM parent").await); + assert_eq!(kept, ["1", "1"], "{flavour:?}"); + assert!(referenced.is_err(), "{flavour:?}: {referenced:?}"); + assert_eq!(parents, "1", "{flavour:?}"); + connection.close().await.expect("a close"); + } +} + +/// A database plan runs through the save path with a batch that names +/// the database and no table, as the app sends it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_database_is_created_with_its_options_then_dropped() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let root = connect_root(&fixture).await; + let dialect = root.dialect(); + let draft = DatabaseDraft { + character_set: Some("latin1".to_owned()), + collation: Some("latin1_general_ci".to_owned()), + ..DatabaseDraft::named("made") + }; + let made = TableRef::new(Some("made".to_owned()), ""); + let created = plan_create_database(dialect.as_ref(), &draft).expect("a plan"); + let dropped = plan_drop_database(dialect.as_ref(), "made", &DropDatabaseOptions::default()).expect("a plan"); + let listed = "SELECT DEFAULT_CHARACTER_SET_NAME, DEFAULT_COLLATION_NAME FROM information_schema.SCHEMATA \ + WHERE SCHEMA_NAME = 'made'"; + + root.write_batch(&created.into_write_batch(made.clone()), within(CALL)) + .await + .expect("the database is created"); + let options: Vec = rows(root.as_ref(), listed) + .await + .first() + .map(|row| row.iter().map(text).collect()) + .unwrap_or_default(); + root.write_batch(&dropped.into_write_batch(made), within(CALL)) + .await + .expect("the database is dropped"); + + assert_eq!(options, ["latin1", "latin1_general_ci"], "{flavour:?}"); + assert!(rows(root.as_ref(), listed).await.is_empty(), "{flavour:?}"); + root.close().await.expect("a close"); + } +} diff --git a/core/crates/drivers/mysql/tests/integration/metadata.rs b/core/crates/drivers/mysql/tests/integration/metadata.rs index 83946b958a..bf972568d7 100644 --- a/core/crates/drivers/mysql/tests/integration/metadata.rs +++ b/core/crates/drivers/mysql/tests/integration/metadata.rs @@ -5,11 +5,15 @@ use tablepro_core::ddl::{ColumnVisibility, GeneratedStorage, IdentityStyle, Tabl use tablepro_core::dialect::{BindTarget, LikeForm, SqlDialect}; use tablepro_core::meta::{EditorRefusal, ObjectInfo, ObjectKind, ObjectScope}; use tablepro_core::statement::StatementBuilder; -use tablepro_core::{Connection, DriverError, RowLimit, Value}; +use tablepro_core::{Connection, DatabaseDriver, DriverError, RowLimit, TlsConfig, Value, WorkspaceSettings}; +use tablepro_driver_mysql::MysqlDriver; use tablepro_test_fixtures::MysqlFlavour; +use tablepro_test_support::conformance::test_liveness; +use tokio_util::sync::CancellationToken; use crate::support::{ - CALL, FLAVOURS, connect, connect_root, rows, run_ok, session, session_scalar, setup, start, table, within, + CALL, FLAVOURS, connect, connect_root, direct, options_over, password, rows, run_ok, session, session_scalar, + setup, start, table, within, }; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] @@ -386,9 +390,13 @@ async fn objects_and_estimates_are_listed() { assert_eq!(kind_of("listed_view"), Some(ObjectKind::View)); assert_eq!(kind_of("listed_procedure"), Some(ObjectKind::Procedure)); assert_eq!(kind_of("listed_function"), Some(ObjectKind::Function)); - assert_eq!(schemas.len(), 1); - assert_eq!(schemas[0].name, "tablepro"); - assert!(schemas[0].is_default); + let defaults: Vec<&str> = schemas + .iter() + .filter(|schema| schema.is_default) + .map(|schema| schema.name.as_str()) + .collect(); + assert_eq!(defaults, ["tablepro"], "{flavour:?}: {schemas:?}"); + assert!(schemas.iter().any(|schema| schema.name == "information_schema" && schema.is_system)); assert!(estimate.rows.value().is_some(), "{flavour:?}: {estimate:?}"); connection.close().await.expect("a close"); } @@ -533,6 +541,69 @@ async fn the_servers_databases_are_listed_for_a_query_tab() { } } +/// The sidebar shows a database that holds nothing yet, and only the +/// login's own is the default. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn an_empty_database_is_listed_beside_the_logins_own() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let root = connect_root(&fixture).await; + let grant = format!( + "GRANT ALL ON untouched.* TO '{}'@'%'", + fixture.password_credentials().username + ); + setup(root.as_ref(), &["CREATE DATABASE untouched", grant.as_str()]).await; + root.close().await.expect("a close"); + let connection = connect(&fixture, false).await; + + let schemas = connection.list_schemas(within(CALL)).await.expect("the schemas"); + + let find = |name: &str| schemas.iter().find(|schema| schema.name == name); + let empty = find("untouched").expect("the empty database"); + assert!(!empty.is_default && !empty.is_system, "{flavour:?}: {empty:?}"); + let defaults: Vec<&str> = schemas + .iter() + .filter(|schema| schema.is_default) + .map(|schema| schema.name.as_str()) + .collect(); + assert_eq!(defaults, ["tablepro"], "{flavour:?}: {schemas:?}"); + connection.close().await.expect("a close"); + } +} + +/// A login that names a database the server does not have stops at +/// the connect with the name, rather than as a server error the health +/// monitor would retry. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_login_to_a_missing_database_names_it() { + for flavour in FLAVOURS { + let fixture = start(flavour).await; + let liveness = test_liveness(); + // root, since a login without a grant on the name is refused + // access (1044) before the server looks for the database. + let options = options_over( + direct(fixture.host(), fixture.port(), &liveness), + password(&fixture.root_credentials()), + "gone", + TlsConfig::disabled(), + WorkspaceSettings::workspace(false), + liveness, + ); + + let outcome = MysqlDriver.connect(options, CancellationToken::new()).await; + + assert_eq!( + outcome.err(), + Some(DriverError::DatabaseNotFound { + database: "gone".to_owned() + }), + "{flavour:?}" + ); + } +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore = "requires docker"] async fn routines_sequences_and_triggers_are_listed_with_their_definitions() { diff --git a/core/crates/drivers/postgres/src/connection.rs b/core/crates/drivers/postgres/src/connection.rs index 374e3e59d5..e41fe06ea2 100644 --- a/core/crates/drivers/postgres/src/connection.rs +++ b/core/crates/drivers/postgres/src/connection.rs @@ -4,7 +4,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use async_trait::async_trait; use deadpool_postgres::{Pool, PoolError, TimeoutType, Timeouts}; use tablepro_core::column::ColumnInfo; -use tablepro_core::ddl::{InboundForeignKey, OutboundForeignKey, TableDefinition}; +use tablepro_core::ddl::{DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ IndexInfo, ObjectDefinition, ObjectInfo, ObjectScope, RowIdentity, SchemaInfo, TableEstimate, TableRef, @@ -14,7 +14,7 @@ use tablepro_core::{ CallOptions, CommitResolution, CommitToken, Connection, DriverError, EditorSession, EditorSessionOptions, ErrorCategory, ForeignKeyInfo, LinkState, LinkWatch, LivenessPolicy, LossPhase, OwnedCalls, QueryResult, ReadStatement, ResultSink, RowLimit, ServerInfo, StreamSummary, TimeoutPhase, WorkspaceSettings, WriteAtomicity, - WriteBatch, WriteLedger, WriteReport, + WriteBatch, WriteKind, WriteLedger, WriteReport, }; use tokio::time::Instant; use tokio_util::sync::{CancellationToken, WaitForCancellationFutureOwned}; @@ -244,6 +244,15 @@ fn suspects_link(error: &DriverError) -> bool { } } +/// A database is created or dropped outside any transaction, so each +/// statement that lands stays. +fn atomicity(kind: WriteKind) -> WriteAtomicity { + match kind { + WriteKind::RowChanges | WriteKind::SchemaChanges => WriteAtomicity::Transactional, + WriteKind::DatabaseChanges => WriteAtomicity::StatementByStatement, + } +} + fn means_link_lost(error: &DriverError) -> bool { matches!( error.category(), @@ -311,6 +320,11 @@ impl Connection for PgConnection { .await } + async fn fetch_database_choices(&self, options: CallOptions) -> Result { + self.catalog_read(options, read_op(|client| Box::pin(metadata::database_choices(client)))) + .await + } + async fn list_schemas(&self, options: CallOptions) -> Result, DriverError> { self.catalog_read(options, read_op(|client| Box::pin(metadata::list_schemas(client)))) .await @@ -473,7 +487,7 @@ impl Connection for PgConnection { self.ensure_open()?; precheck(batch, self.settings.read_only)?; let stop = Arc::new(PgStop::new(self.endpoint.clone())); - let ledger = Arc::new(WriteLedger::new(WriteAtomicity::Transactional)); + let ledger = Arc::new(WriteLedger::new(atomicity(batch.kind))); let pool = self.pool.clone(); let timeouts = workspace_timeouts(self.liveness()); let plan = WritePlan { @@ -537,3 +551,18 @@ impl Connection for PgConnection { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_database_change_lands_statement_by_statement() { + assert_eq!( + atomicity(WriteKind::DatabaseChanges), + WriteAtomicity::StatementByStatement + ); + assert_eq!(atomicity(WriteKind::SchemaChanges), WriteAtomicity::Transactional); + assert_eq!(atomicity(WriteKind::RowChanges), WriteAtomicity::Transactional); + } +} diff --git a/core/crates/drivers/postgres/src/ddl.rs b/core/crates/drivers/postgres/src/ddl.rs index c02eb0cd3a..ea7189a8b7 100644 --- a/core/crates/drivers/postgres/src/ddl.rs +++ b/core/crates/drivers/postgres/src/ddl.rs @@ -1,9 +1,11 @@ use tablepro_core::column::{ColumnKind, ColumnType, IntegerKind, IntegerWidth, NetworkFamily, TextLength}; use tablepro_core::ddl::ddl_sql::{column_list, create_index_sql, default_sql, foreign_key_sql}; use tablepro_core::ddl::{ - BuildDdlError, CollationName, ColumnAlteration, ColumnAttributes, ColumnCapabilities, ColumnCompression, - ColumnDefinition, ColumnStorage, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, GeneratedStorage, - IdentityStyle, Permission, RefusalReason, ResolvedType, TableChanges, TableDefinition, TableDraft, + BuildDdlError, CatalogCapabilities, CatalogOption, CollationName, ColumnAlteration, ColumnAttributes, + ColumnCapabilities, ColumnCompression, ColumnDefinition, ColumnStorage, DatabaseDraft, DatabaseFields, + DatabaseNoun, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, DraftColumn, DropDatabaseOptions, + GeneratedStorage, IdentityStyle, NameLimit, Permission, RefusalReason, ResolvedType, TableChanges, TableDefinition, + TableDraft, catalog_name, check_database_draft, named, refuse_drop_options, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; @@ -59,6 +61,14 @@ const TYPE_SUGGESTIONS: &[&str] = &[ "bytea", ]; +/// The `server_version_num` of the release that added DROP DATABASE's +/// FORCE and CREATE DATABASE's LOCALE. +const RELEASE_13: u32 = 130_000; + +/// NAMEDATALEN less its terminator. The server cuts a longer name short +/// with only a notice. +const NAME_BYTES: usize = 63; + /// PostgreSQL changes a table in one ALTER TABLE: it orders the /// subcommands itself, drops before adds, so one statement carries /// every column change. Renames cannot share it, and indexes, comments @@ -209,6 +219,102 @@ impl DdlDialect for PgDialect { ); DdlPlan::for_changes(changes, statements) } + + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + truncate_cascade: true, + truncate_restart_identity: true, + truncate_refused_when_referenced: false, + create_schema: true, + drop_schema: true, + drop_schema_cascade: true, + schema_owner: true, + create_database: true, + drop_database: true, + drop_database_force: self.server_version_num() >= RELEASE_13, + database_fields: DatabaseFields { + owner: true, + template: true, + encoding: true, + locale: true, + ..DatabaseFields::default() + }, + database_noun: DatabaseNoun::Database, + on_cluster: false, + name_limit: Some(NameLimit::Bytes(NAME_BYTES)), + } + } + + /// The server refuses to drop a template. `postgres` is only where + /// clients connect by default, and the server drops it like any other. + fn protects_database(&self, name: &str) -> bool { + matches!(name, "template0" | "template1") + } + + fn protects_schema(&self, name: &str) -> bool { + name == "information_schema" || name.starts_with("pg_") + } + + /// Only the options set are written, so the server's defaults hold + /// for the rest. The server takes an encoding or locale other than + /// the template's only from template0, so one set without a template + /// copies template0. + fn plan_create_database(&self, draft: &DatabaseDraft) -> Result { + let capabilities = self.catalog_capabilities(); + check_database_draft(&capabilities, draft)?; + let mut sql = format!( + "CREATE DATABASE {}", + self.quote_identifier(named(&capabilities, CatalogOption::Name, &draft.name)?) + ); + if let Some(owner) = &draft.owner { + sql.push_str(" OWNER "); + sql.push_str(&self.quote_identifier(catalog_name(CatalogOption::Owner, owner)?)); + } + match &draft.template { + Some(template) => { + sql.push_str(" TEMPLATE "); + sql.push_str(&self.quote_identifier(catalog_name(CatalogOption::Template, template)?)); + } + None if draft.encoding.is_some() || draft.locale.is_some() => sql.push_str(" TEMPLATE template0"), + None => {} + } + if let Some(encoding) = &draft.encoding { + sql.push_str(&format!(" ENCODING '{}'", encoding_name(encoding)?)); + } + if let Some(locale) = &draft.locale { + let locale = self.locale_literal(locale)?; + // Before LOCALE, the two settings it stands for were set apart. + match self.server_version_num() >= RELEASE_13 { + true => sql.push_str(&format!(" LOCALE {locale}")), + false => sql.push_str(&format!(" LC_COLLATE {locale} LC_CTYPE {locale}")), + } + } + Ok(DdlPlan::for_databases([sql])) + } + + fn plan_drop_database(&self, database: &str, options: &DropDatabaseOptions) -> Result { + refuse_drop_options(&self.catalog_capabilities(), options)?; + let force = if options.force { " WITH (FORCE)" } else { "" }; + Ok(DdlPlan::for_databases([format!( + "DROP DATABASE {}{force}", + self.quote_identifier(catalog_name(CatalogOption::Name, database)?) + )])) + } +} + +/// An encoding is a string the server looks up by name, so only the +/// letters, digits, underscores and hyphens its names are spelled with +/// reach the statement. +fn encoding_name(value: &str) -> Result<&str, BuildDdlError> { + let spelled = !value.is_empty() && value.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-'); + match spelled { + true => Ok(value), + false => Err(BuildDdlError::InvalidOption { + option: CatalogOption::Encoding, + value: value.to_owned(), + }), + } } /// The type the USING clause casts to when the new type has a length. @@ -433,6 +539,16 @@ impl PgDialect { None => format!("COMMENT ON COLUMN {target} IS NULL"), } } + + fn locale_literal(&self, locale: &str) -> Result { + let locale = catalog_name(CatalogOption::Locale, locale)?; + SqlDialect::literal(self, &Value::Text(locale.to_owned()), &ColumnType::untyped_text("text")).map_err(|_| { + BuildDdlError::InvalidOption { + option: CatalogOption::Locale, + value: locale.to_owned(), + } + }) + } } /// Turning numbering off drops the identity or the serial default. @@ -619,14 +735,15 @@ fn serial_type(column_type: &ColumnType) -> Option<&'static str> { #[cfg(test)] mod tests { - use tablepro_core::ForeignKeyInfo; - use tablepro_core::ServerInfo; use tablepro_core::column::{ColumnDefault, ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ - ColumnAttributes, DraftType, DropTableOptions, InboundForeignKey, PrimaryKeyConstraint, ReferentialAction, - diff_table, + ColumnAttributes, DraftType, DropSchemaOptions, DropTableOptions, InboundForeignKey, KeyspaceReplication, + PrimaryKeyConstraint, ReferentialAction, SchemaDraft, TruncateTableOptions, catalog_capabilities, diff_table, + plan_create_database, plan_create_schema, plan_drop_database, plan_drop_schema, plan_truncate_table, + protects_database, protects_schema, }; use tablepro_core::meta::{IndexInfo, IndexKey}; + use tablepro_core::{ForeignKeyInfo, ServerInfo, WriteKind}; use super::*; @@ -1308,4 +1425,343 @@ mod tests { } assert!(dialect.parse_type(dialect.default_column_type()).is_ok()); } + + fn released(major: u32) -> PgDialect { + PgDialect::new(&ServerInfo::new("PostgreSQL", format!("{major}.0"), major, 0, 0)) + } + + fn orders() -> TableRef { + TableRef::new(Some("app".to_owned()), "orders") + } + + fn create(draft: DatabaseDraft) -> Vec { + sql(dialect().plan_create_database(&draft)) + } + + fn refused_option(plan: Result) -> Option { + match plan { + Err(BuildDdlError::InvalidOption { option, .. }) => Some(option), + _ => None, + } + } + + #[test] + fn a_truncate_restarts_identity_before_it_cascades() { + let truncate = |cascade, restart_identity| { + let options = TruncateTableOptions { + cascade, + restart_identity, + cluster: None, + }; + dialect().plan_truncate_table(&orders(), &options) + }; + + assert_eq!(sql(truncate(false, false)), ["TRUNCATE TABLE \"app\".\"orders\""]); + assert_eq!( + sql(truncate(true, false)), + ["TRUNCATE TABLE \"app\".\"orders\" CASCADE"] + ); + assert_eq!( + sql(truncate(false, true)), + ["TRUNCATE TABLE \"app\".\"orders\" RESTART IDENTITY"] + ); + assert_eq!( + sql(truncate(true, true)), + ["TRUNCATE TABLE \"app\".\"orders\" RESTART IDENTITY CASCADE"] + ); + assert_eq!( + truncate(false, false).map(|plan| plan.kind), + Ok(WriteKind::SchemaChanges) + ); + let on_cluster = TruncateTableOptions { + cluster: Some("main".to_owned()), + ..TruncateTableOptions::default() + }; + assert_eq!( + dialect().plan_truncate_table(&orders(), &on_cluster), + Err(BuildDdlError::Unsupported(DdlOperation::OnCluster)) + ); + } + + #[test] + fn a_schema_is_created_with_its_owner_and_dropped_with_what_it_holds() { + let owned = SchemaDraft { + name: "sa\"les".to_owned(), + owner: Some("ann".to_owned()), + }; + let plain = SchemaDraft { + name: "sales".to_owned(), + owner: None, + }; + + assert_eq!( + sql(dialect().plan_create_schema(&owned)), + ["CREATE SCHEMA \"sa\"\"les\" AUTHORIZATION \"ann\""] + ); + assert_eq!(sql(dialect().plan_create_schema(&plain)), ["CREATE SCHEMA \"sales\""]); + assert_eq!( + sql(dialect().plan_drop_schema("sales", DropSchemaOptions { cascade: true })), + ["DROP SCHEMA \"sales\" CASCADE"] + ); + assert_eq!( + sql(dialect().plan_drop_schema("sales", DropSchemaOptions::default())), + ["DROP SCHEMA \"sales\""] + ); + } + + #[test] + fn a_new_database_writes_only_the_options_set_in_the_servers_order() { + let every = DatabaseDraft { + owner: Some("ann".to_owned()), + template: Some("tpl\"x".to_owned()), + encoding: Some("LATIN1".to_owned()), + locale: Some("de_DE.ISO8859-1".to_owned()), + ..DatabaseDraft::named("shop") + }; + let owned = DatabaseDraft { + owner: Some("ann".to_owned()), + ..DatabaseDraft::named("shop") + }; + let copied = DatabaseDraft { + template: Some("tpl".to_owned()), + ..DatabaseDraft::named("shop") + }; + + assert_eq!(create(DatabaseDraft::named("sh\"op")), ["CREATE DATABASE \"sh\"\"op\""]); + assert_eq!(create(owned), ["CREATE DATABASE \"shop\" OWNER \"ann\""]); + assert_eq!(create(copied), ["CREATE DATABASE \"shop\" TEMPLATE \"tpl\""]); + assert_eq!( + create(every), + ["CREATE DATABASE \"shop\" OWNER \"ann\" TEMPLATE \"tpl\"\"x\" ENCODING 'LATIN1' LOCALE 'de_DE.ISO8859-1'"] + ); + let plan = dialect().plan_create_database(&DatabaseDraft::named("shop")); + assert_eq!(plan.map(|plan| plan.kind), Ok(WriteKind::DatabaseChanges)); + } + + /// template1 takes only its own encoding and locale, so either one + /// set without a template copies template0. A template the user chose + /// stays, and the server answers for it. + #[test] + fn an_encoding_or_locale_without_a_template_copies_template0() { + let encoded = DatabaseDraft { + encoding: Some("UTF8".to_owned()), + ..DatabaseDraft::named("shop") + }; + let localised = DatabaseDraft { + locale: Some("C".to_owned()), + ..DatabaseDraft::named("shop") + }; + let chosen = DatabaseDraft { + template: Some("template1".to_owned()), + ..encoded.clone() + }; + + assert_eq!( + create(encoded), + ["CREATE DATABASE \"shop\" TEMPLATE template0 ENCODING 'UTF8'"] + ); + assert_eq!( + create(localised.clone()), + ["CREATE DATABASE \"shop\" TEMPLATE template0 LOCALE 'C'"] + ); + assert_eq!( + create(chosen), + ["CREATE DATABASE \"shop\" TEMPLATE \"template1\" ENCODING 'UTF8'"] + ); + assert_eq!( + sql(released(12).plan_create_database(&localised)), + ["CREATE DATABASE \"shop\" TEMPLATE template0 LC_COLLATE 'C' LC_CTYPE 'C'"] + ); + } + + #[test] + fn an_encoding_or_locale_that_could_leave_its_string_is_refused() { + let encoded = |encoding: &str| DatabaseDraft { + encoding: Some(encoding.to_owned()), + ..DatabaseDraft::named("shop") + }; + let localised = |locale: &str| DatabaseDraft { + locale: Some(locale.to_owned()), + ..DatabaseDraft::named("shop") + }; + + assert_eq!( + create(encoded("UTF-8")), + ["CREATE DATABASE \"shop\" TEMPLATE template0 ENCODING 'UTF-8'"] + ); + for encoding in ["", "UTF8'; DROP DATABASE prod; --", "UTF 8"] { + assert_eq!( + refused_option(dialect().plan_create_database(&encoded(encoding))), + Some(CatalogOption::Encoding), + "{encoding:?}" + ); + } + assert_eq!( + create(localised("it's")), + ["CREATE DATABASE \"shop\" TEMPLATE template0 LOCALE 'it''s'"] + ); + for locale in ["", " ", "C\0"] { + assert_eq!( + refused_option(dialect().plan_create_database(&localised(locale))), + Some(CatalogOption::Locale), + "{locale:?}" + ); + } + let empty_owner = DatabaseDraft { + owner: Some(String::new()), + ..DatabaseDraft::named("shop") + }; + assert_eq!( + refused_option(dialect().plan_create_database(&empty_owner)), + Some(CatalogOption::Owner) + ); + } + + #[test] + fn a_name_past_63_bytes_is_refused_before_the_server_cuts_it_short() { + let longest = "a".repeat(63); + let schema = |name: &str| SchemaDraft { + name: name.to_owned(), + owner: None, + }; + + assert!(dialect().plan_create_database(&DatabaseDraft::named(&longest)).is_ok()); + assert!(dialect().plan_create_schema(&schema(longest.as_str())).is_ok()); + for name in ["a".repeat(64), "é".repeat(32)] { + assert_eq!( + refused_option(dialect().plan_create_database(&DatabaseDraft::named(&name))), + Some(CatalogOption::Name), + "{name}" + ); + assert_eq!( + refused_option(dialect().plan_create_schema(&schema(name.as_str()))), + Some(CatalogOption::Name), + "{name}" + ); + } + assert_eq!(dialect().catalog_capabilities().name_limit, Some(NameLimit::Bytes(63))); + } + + #[test] + fn a_drop_closes_the_other_sessions_only_from_13() { + let forced = DropDatabaseOptions { + force: true, + cluster: None, + }; + let plain = DropDatabaseOptions::default(); + + assert_eq!( + sql(released(13).plan_drop_database("sh\"op", &forced)), + ["DROP DATABASE \"sh\"\"op\" WITH (FORCE)"] + ); + assert_eq!( + sql(released(13).plan_drop_database("shop", &plain)), + ["DROP DATABASE \"shop\""] + ); + assert_eq!( + sql(released(12).plan_drop_database("shop", &plain)), + ["DROP DATABASE \"shop\""] + ); + assert_eq!( + released(12).plan_drop_database("shop", &forced), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabaseForce)) + ); + assert!(released(13).catalog_capabilities().drop_database_force); + assert!(!released(12).catalog_capabilities().drop_database_force); + let plan = released(18).plan_drop_database("shop", &forced); + assert_eq!(plan.map(|plan| plan.kind), Ok(WriteKind::DatabaseChanges)); + } + + #[test] + fn templates_and_system_schemas_are_withheld_from_a_drop() { + let dialect = dialect(); + + for database in ["template0", "template1"] { + assert!(protects_database(&dialect, database), "{database}"); + } + for database in ["postgres", "shop", "Template1"] { + assert!(!protects_database(&dialect, database), "{database}"); + } + for schema in ["pg_catalog", "pg_toast", "pg_temp_3", "information_schema"] { + assert!(protects_schema(&dialect, schema), "{schema}"); + } + for schema in ["public", "sales", "PG_sales"] { + assert!(!protects_schema(&dialect, schema), "{schema}"); + } + } + + /// The menu offers what the capabilities say, so each one has to plan. + #[test] + fn every_catalog_operation_offered_plans() { + for major in [12, 13, 18] { + let dialect = released(major); + let offered = catalog_capabilities(&dialect); + let fields = offered.database_fields; + let set = |on: bool, value: &str| on.then(|| value.to_owned()); + let truncate = TruncateTableOptions { + cascade: offered.truncate_cascade, + restart_identity: offered.truncate_restart_identity, + cluster: set(offered.on_cluster, "main"), + }; + let schema = SchemaDraft { + name: "sales".to_owned(), + owner: set(offered.schema_owner, "ann"), + }; + let database = DatabaseDraft { + owner: set(fields.owner, "ann"), + template: set(fields.template, "template0"), + encoding: set(fields.encoding, "UTF8"), + locale: set(fields.locale, "C"), + character_set: set(fields.character_set, "utf8mb4"), + collation: set(fields.collation, "C"), + cluster: set(offered.on_cluster, "main"), + replication: fields.replication.then_some(KeyspaceReplication::Simple { factor: 1 }), + tablets: fields.tablets.then_some(true), + first_collection: set(fields.first_collection, "orders"), + ..DatabaseDraft::named("shop") + }; + let cascade = DropSchemaOptions { + cascade: offered.drop_schema_cascade, + }; + let drop = DropDatabaseOptions { + force: offered.drop_database_force, + cluster: set(offered.on_cluster, "main"), + }; + + let truncated = plan_truncate_table(&dialect, &orders(), &truncate); + let created_schema = plan_create_schema(&dialect, &schema); + let dropped_schema = plan_drop_schema(&dialect, "sales", cascade); + let created = plan_create_database(&dialect, &database); + let dropped = plan_drop_database(&dialect, "shop", &drop); + + assert_eq!(truncated.is_ok(), offered.truncate_table, "{major}: {truncated:?}"); + assert_eq!( + created_schema.is_ok(), + offered.create_schema, + "{major}: {created_schema:?}" + ); + assert_eq!( + dropped_schema.is_ok(), + offered.drop_schema, + "{major}: {dropped_schema:?}" + ); + assert_eq!(created.is_ok(), offered.create_database, "{major}: {created:?}"); + assert_eq!(dropped.is_ok(), offered.drop_database, "{major}: {dropped:?}"); + } + let offered = released(18).catalog_capabilities(); + assert!(offered.truncate_table && offered.truncate_cascade && offered.truncate_restart_identity); + assert!(offered.create_schema && offered.drop_schema && offered.drop_schema_cascade && offered.schema_owner); + assert!(offered.create_database && offered.drop_database && offered.drop_database_force); + assert!(!offered.truncate_refused_when_referenced && !offered.on_cluster); + assert_eq!( + offered.database_fields, + DatabaseFields { + owner: true, + template: true, + encoding: true, + locale: true, + ..DatabaseFields::default() + } + ); + } } diff --git a/core/crates/drivers/postgres/src/errors.rs b/core/crates/drivers/postgres/src/errors.rs index 3684c3fbc8..45dac95d45 100644 --- a/core/crates/drivers/postgres/src/errors.rs +++ b/core/crates/drivers/postgres/src/errors.rs @@ -45,14 +45,25 @@ pub(crate) fn map_error(error: &tokio_postgres::Error) -> DriverError { DriverError::Protocol(describe(error)) } -/// An error from opening a connection, where a TLS failure before the -/// handshake began means the server never agreed to TLS at all. +/// invalid_catalog_name: at login, the database the connection names +/// does not exist. +const INVALID_CATALOG_NAME: &str = "3D000"; + +/// An error from opening a connection to `database`, where a TLS failure +/// before the handshake began means the server never agreed to TLS at +/// all. A missing database is told apart only here: retrying the login +/// cannot help, while the same SQLSTATE from a statement is that +/// statement's own answer. pub(crate) fn map_connect_error( error: &tokio_postgres::Error, + database: &str, tls_requested: bool, handshake_started: bool, ) -> DriverError { - let mapped = map_error(error); + connect_error(map_error(error), database, tls_requested, handshake_started) +} + +fn connect_error(mapped: DriverError, database: &str, tls_requested: bool, handshake_started: bool) -> DriverError { match mapped { DriverError::Tls { detail, .. } if tls_requested && !handshake_started => DriverError::Tls { failure: TlsFailure::ServerRefusedTls, @@ -61,6 +72,11 @@ pub(crate) fn map_connect_error( DriverError::ConnectionLost { .. } => DriverError::ConnectionLost { during: LossPhase::Connect, }, + DriverError::Server(diagnostics) if diagnostics.sqlstate() == Some(INVALID_CATALOG_NAME) => { + DriverError::DatabaseNotFound { + database: database.to_owned(), + } + } other => other, } } @@ -278,6 +294,28 @@ mod tests { assert_eq!(read_only_refusal(other.clone()), other); } + #[test] + fn a_missing_database_at_login_names_the_database_and_a_statements_3d000_stays_the_servers() { + let missing = || map_diagnostics(diagnostics("3D000", "database \"shopp\" does not exist")); + + assert_eq!( + connect_error(missing(), "shopp", false, false), + DriverError::DatabaseNotFound { + database: "shopp".to_owned() + } + ); + assert!( + matches!(missing(), DriverError::Server(d) if d.sqlstate() == Some("3D000")), + "{:?}", + missing() + ); + let refused = map_diagnostics(diagnostics("28P01", "password authentication failed")); + assert!(matches!( + connect_error(refused, "shopp", false, false), + DriverError::Auth { .. } + )); + } + #[test] fn a_pool_wait_that_runs_out_is_busy() { assert_eq!( diff --git a/core/crates/drivers/postgres/src/metadata/database_choices.rs b/core/crates/drivers/postgres/src/metadata/database_choices.rs new file mode 100644 index 0000000000..513375c093 --- /dev/null +++ b/core/crates/drivers/postgres/src/metadata/database_choices.rs @@ -0,0 +1,128 @@ +use tablepro_core::DriverError; +use tablepro_core::ddl::{DatabaseChoices, DatabaseDefaults}; +use tokio_postgres::{Client, Row}; + +use super::{decode_error, text_rows}; + +/// The `pg_` roles are the server's own and are given nothing a user +/// makes. +pub(super) const OWNERS_SQL: &str = "\ +SELECT r.rolname::text +FROM pg_catalog.pg_roles r +WHERE NOT pg_catalog.starts_with(r.rolname, 'pg_') +ORDER BY 1"; + +pub(super) const TEMPLATES_SQL: &str = "\ +SELECT d.datname::text +FROM pg_catalog.pg_database d +WHERE d.datistemplate +ORDER BY 1"; + +/// The operating system's locales as initdb found them. An ICU +/// collation's name is no locale the libc provider takes. +pub(super) const LOCALES_SQL: &str = "\ +SELECT DISTINCT c.collcollate::text +FROM pg_catalog.pg_collation c +WHERE c.collprovider = 'c' AND c.collcollate IS NOT NULL AND c.collcollate <> '' +ORDER BY 1"; + +/// A database left without an owner belongs to its creator, and one +/// left without an encoding or locale takes template1's. +pub(super) const DEFAULTS_SQL: &str = "\ +SELECT CURRENT_USER::text, + (SELECT pg_catalog.pg_encoding_to_char(d.encoding)::text + FROM pg_catalog.pg_database d WHERE d.datname = 'template1'), + (SELECT d.datcollate::text FROM pg_catalog.pg_database d WHERE d.datname = 'template1')"; + +/// Every encoding the character set table marks as a server encoding +/// (https://www.postgresql.org/docs/current/multibyte.html, the +/// "Server?" column). The client-only ones, such as SJIS and BIG5, are +/// refused by CREATE DATABASE. +const SERVER_ENCODINGS: [&str; 35] = [ + "EUC_CN", + "EUC_JP", + "EUC_JIS_2004", + "EUC_KR", + "EUC_TW", + "ISO_8859_5", + "ISO_8859_6", + "ISO_8859_7", + "ISO_8859_8", + "KOI8R", + "KOI8U", + "LATIN1", + "LATIN2", + "LATIN3", + "LATIN4", + "LATIN5", + "LATIN6", + "LATIN7", + "LATIN8", + "LATIN9", + "LATIN10", + "MULE_INTERNAL", + "SQL_ASCII", + "UTF8", + "WIN866", + "WIN874", + "WIN1250", + "WIN1251", + "WIN1252", + "WIN1253", + "WIN1254", + "WIN1255", + "WIN1256", + "WIN1257", + "WIN1258", +]; + +pub(crate) async fn database_choices(client: &Client) -> Result { + // Every query is sent before the first answer is awaited, so all of + // them run inside the flight's transaction, ahead of its COMMIT. + let (owners, templates, locales, defaults) = futures_util::try_join!( + text_rows(client, OWNERS_SQL, &[]), + text_rows(client, TEMPLATES_SQL, &[]), + text_rows(client, LOCALES_SQL, &[]), + text_rows(client, DEFAULTS_SQL, &[]), + )?; + let defaults = defaults + .first() + .ok_or_else(|| DriverError::Protocol("the database defaults read returned no row".to_owned()))?; + let field = decode_error("pg_database"); + Ok(DatabaseChoices { + owners: names(&owners, "pg_roles")?, + templates: names(&templates, "pg_database")?, + encodings: SERVER_ENCODINGS.iter().map(|&encoding| encoding.to_owned()).collect(), + locales: names(&locales, "pg_collation")?, + defaults: DatabaseDefaults { + owner: defaults.try_get(0).map_err(&field)?, + encoding: defaults.try_get(1).map_err(&field)?, + locale: defaults.try_get(2).map_err(&field)?, + ..DatabaseDefaults::default() + }, + ..DatabaseChoices::default() + }) +} + +fn names(rows: &[Row], table: &'static str) -> Result, DriverError> { + let field = decode_error(table); + rows.iter().map(|row| row.try_get(0).map_err(&field)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_encodings_offered_are_the_server_encodings_once_each() { + let mut sorted = SERVER_ENCODINGS.to_vec(); + sorted.sort_unstable(); + sorted.dedup(); + + assert_eq!(sorted.len(), SERVER_ENCODINGS.len()); + assert!(SERVER_ENCODINGS.contains(&"UTF8") && SERVER_ENCODINGS.contains(&"SQL_ASCII")); + for client_only in ["BIG5", "GB18030", "GBK", "JOHAB", "SJIS", "SHIFT_JIS_2004", "UHC"] { + assert!(!SERVER_ENCODINGS.contains(&client_only), "{client_only}"); + } + } +} diff --git a/core/crates/drivers/postgres/src/metadata/mod.rs b/core/crates/drivers/postgres/src/metadata/mod.rs index bb58057803..17e2f5c26a 100644 --- a/core/crates/drivers/postgres/src/metadata/mod.rs +++ b/core/crates/drivers/postgres/src/metadata/mod.rs @@ -2,6 +2,7 @@ //! tab. Every statement projects built-in types only, so none of them //! makes tokio-postgres look a type up with a named statement. +mod database_choices; mod definitions; mod estimate; mod foreign_keys; @@ -9,6 +10,7 @@ mod identity; mod indexes; mod objects; +pub(crate) use database_choices::database_choices; pub(crate) use definitions::fetch_object_definition; pub(crate) use estimate::table_estimate; pub(crate) use foreign_keys::{fetch_foreign_keys, fetch_outbound_foreign_keys}; @@ -180,6 +182,10 @@ mod tests { ("schemas", objects::SCHEMAS_SQL.to_owned()), ("objects", objects::OBJECTS_SQL.to_owned()), ("databases", objects::DATABASES_SQL.to_owned()), + ("owners", database_choices::OWNERS_SQL.to_owned()), + ("templates", database_choices::TEMPLATES_SQL.to_owned()), + ("locales", database_choices::LOCALES_SQL.to_owned()), + ("database defaults", database_choices::DEFAULTS_SQL.to_owned()), ("function definitions", definitions::FUNCTIONS_SQL.to_owned()), ("sequence definition", definitions::SEQUENCE_SQL.to_owned()), ("trigger definition", definitions::TRIGGER_SQL.to_owned()), diff --git a/core/crates/drivers/postgres/src/physical.rs b/core/crates/drivers/postgres/src/physical.rs index 5d7b07314d..cb02ea6473 100644 --- a/core/crates/drivers/postgres/src/physical.rs +++ b/core/crates/drivers/postgres/src/physical.rs @@ -72,6 +72,15 @@ impl PgEndpoint { self.config.get_dbname() } + /// The database a login opens: the one named, or else the server's + /// default, a database named after the user. + fn login_database(&self) -> &str { + self.config + .get_dbname() + .or_else(|| self.config.get_user()) + .unwrap_or_default() + } + pub(crate) fn liveness(&self) -> &LivenessPolicy { &self.liveness } @@ -101,7 +110,7 @@ impl PgEndpoint { Ok(Ok(connected)) => Ok(connected), Ok(Err(error)) => { let started = handshake_started.load(Ordering::SeqCst); - let mapped = map_connect_error(&error, self.tls.requested(), started); + let mapped = map_connect_error(&error, self.login_database(), self.tls.requested(), started); Err(self.explain(mapped).await) } } diff --git a/core/crates/drivers/postgres/src/write_batch.rs b/core/crates/drivers/postgres/src/write_batch.rs index b8251416fb..3129e55a08 100644 --- a/core/crates/drivers/postgres/src/write_batch.rs +++ b/core/crates/drivers/postgres/src/write_batch.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use futures_util::TryStreamExt; use postgres_types::{ToSql, Type}; -use tablepro_core::statement::{RowGuard, Statement}; +use tablepro_core::statement::{GuardedStatement, RowGuard, Statement}; use tablepro_core::{ CommitToken, DriverError, ReadOnlyRefusal, SessionFate, StopRequest, WorkspaceSettings, WriteBatch, WriteKind, WriteLedger, WriteReport, @@ -22,6 +22,14 @@ pub(crate) fn precheck(batch: &WriteBatch, read_only: bool) -> Result<(), Driver if read_only { return Err(DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard)); } + // A database change goes out as a simple query, which carries no + // parameters and reports no row count a guard could check. + let bare = |step: &GuardedStatement| step.statement.params().is_empty() && step.guard == RowGuard::None; + if batch.kind == WriteKind::DatabaseChanges && !batch.steps.iter().all(bare) { + return Err(DriverError::Protocol( + "a database change carries no parameters or row guards".to_owned(), + )); + } batch.foreign_keys.require_enforced() } @@ -34,21 +42,25 @@ pub(crate) struct WritePlan { pub(crate) legacy_txid: bool, } -/// Run the batch in one transaction, recording each step in the ledger -/// so a lost connection reports what is really in the database. +/// Run the batch in one transaction, or a database change statement by +/// statement, recording each step in the ledger so a lost connection +/// reports what is really in the database. pub(crate) async fn perform( call: &PgCall, plan: &WritePlan, request: &StopRequest, stop: &PgStop, ) -> Result { + if plan.batch.kind == WriteKind::DatabaseChanges { + return statement_by_statement(call, plan, request, stop).await; + } let client = call.client()?; let ledger = &plan.ledger; // A schema change may legitimately run long; the call's deadline and // cancel still bound it. let statement_timeout = match plan.batch.kind { WriteKind::RowChanges => Some(plan.settings.statement_timeout), - WriteKind::SchemaChanges => None, + WriteKind::SchemaChanges | WriteKind::DatabaseChanges => None, }; let begin = Preamble::new(Access::ReadWrite, statement_timeout, plan.settings.lock_timeout); let started = Instant::now(); @@ -124,6 +136,57 @@ pub(crate) async fn perform( } } +/// CREATE and DROP DATABASE refuse a transaction block, and statements +/// sent together in one simple query form one, so each goes out alone +/// and lands on its own. No bound is set: SET LOCAL needs a transaction, +/// and a session setting would stay on a server connection a pooler +/// hands to other clients. The call's deadline and cancel still stop it. +async fn statement_by_statement( + call: &PgCall, + plan: &WritePlan, + request: &StopRequest, + stop: &PgStop, +) -> Result { + let client = call.client()?; + let ledger = &plan.ledger; + let mut report = Vec::with_capacity(plan.batch.steps.len()); + for (index, step) in plan.batch.steps.iter().enumerate() { + if request.is_requested() { + return Err(ledger.interrupted(stop.interrupted(request), SessionFate::Discarded)); + } + ledger.statement_sending(index); + if let Err(error) = client.batch_execute(step.statement.sql()).await { + return Err(statement_failed(call, plan, index, &error, request, stop)); + } + ledger.statement_confirmed(index); + report.push(None); + } + Ok(WriteReport::new(report)) +} + +/// A statement the server answered with an error did not apply. One +/// whose answer was lost may have, so its outcome is left unknown. +fn statement_failed( + call: &PgCall, + plan: &WritePlan, + index: usize, + error: &tokio_postgres::Error, + request: &StopRequest, + stop: &PgStop, +) -> DriverError { + let answered = error.as_db_error().is_some(); + let error = map_error(error); + if !answered { + call.taint(); + return plan.ledger.interrupted(error, SessionFate::Discarded); + } + let error = match request.is_requested() && acknowledges_stop(&error) { + true => acknowledged_stop(request, stop), + false => error, + }; + plan.ledger.failed(index, error, SessionFate::RolledBack) +} + /// A statement the server refused, or one a stop interrupted. The /// transaction is rolled back either way, and the stop reads as what /// asked for it. @@ -209,7 +272,8 @@ async fn transaction_id(client: &Client, legacy: bool) -> Result Statement { + let mut builder = StatementBuilder::new(); + builder.push_sql(text); + builder.finish() + } + + fn database_changes(steps: Vec) -> WriteBatch { + WriteBatch::new( + WriteKind::DatabaseChanges, + TableRef::new(Some("shop".to_owned()), ""), + steps, + ForeignKeyMode::Enforced, + ) + } + + #[test] + fn a_database_change_goes_out_only_as_plain_statements() { + let mut bound = StatementBuilder::new(); + bound.push_sql("DROP DATABASE $1"); + bound.push_bound(BoundParam::new(Value::Text("shop".to_owned()), ParamType::Text).expect("a text parameter")); + let plain = GuardedStatement::unguarded(sql("DROP DATABASE \"shop\"")); + + assert!(precheck(&database_changes(vec![plain.clone()]), false).is_ok()); + for refused in [ + GuardedStatement::unguarded(bound.finish()), + GuardedStatement::affecting_exactly(sql("DROP DATABASE \"shop\""), 1), + ] { + assert!( + matches!( + precheck(&database_changes(vec![plain.clone(), refused.clone()]), false), + Err(DriverError::Protocol(_)) + ), + "{refused:?}" + ); + } + let mut guarded = batch(ForeignKeyMode::Enforced); + guarded.steps = vec![GuardedStatement::affecting_exactly(sql("DELETE FROM t"), 1)]; + assert!(precheck(&guarded, false).is_ok()); + } } diff --git a/core/crates/drivers/postgres/tests/integration/catalog_ddl.rs b/core/crates/drivers/postgres/tests/integration/catalog_ddl.rs new file mode 100644 index 0000000000..9aa7d77d5b --- /dev/null +++ b/core/crates/drivers/postgres/tests/integration/catalog_ddl.rs @@ -0,0 +1,328 @@ +//! Emptying tables and creating and dropping schemas and databases +//! through the plans the PostgreSQL dialect writes, against a real +//! server. + +use tablepro_core::ddl::{ + DatabaseDraft, DdlPlan, DropDatabaseOptions, DropSchemaOptions, SchemaDraft, TruncateTableOptions, + plan_create_database, plan_create_schema, plan_drop_database, plan_drop_schema, plan_truncate_table, +}; +use tablepro_core::meta::TableRef; +use tablepro_core::{ + ConnectOptions, Connection, DatabaseDriver, DriverError, StatementOutcome, TlsConfig, WorkspaceSettings, WriteKind, + WriteReport, +}; +use tablepro_driver_postgres::PgDriver; +use tablepro_test_fixtures::PostgresFixture; +use tablepro_test_support::conformance::test_liveness; +use tokio_util::sync::CancellationToken; + +use crate::support::{ + CALL, apply, connect, connect_with, direct, options_over, password, scalar, setup, start, text, within, +}; + +/// A schema or database plan runs as the sidebar runs it: the batch +/// names the schema or database and no table. +async fn run(connection: &dyn Connection, plan: DdlPlan, name: &str) -> Result { + let batch = plan.into_write_batch(TableRef::new(Some(name.to_owned()), "")); + connection.write_batch(&batch, within(CALL)).await +} + +async fn truncate( + connection: &dyn Connection, + table: &TableRef, + options: TruncateTableOptions, +) -> Result<(), DriverError> { + let plan = plan_truncate_table(connection.dialect().as_ref(), table, &options).expect("a truncate plan"); + connection + .write_batch(&plan.into_write_batch(table.clone()), within(CALL)) + .await + .map(|_| ()) +} + +/// The fixture's login, opening `database`. +fn options_in(fixture: &PostgresFixture, database: &str) -> ConnectOptions { + let liveness = test_liveness(); + options_over( + direct(fixture.host(), fixture.port(), &liveness), + password(&fixture.password_credentials()), + database, + TlsConfig::disabled(), + WorkspaceSettings::workspace(false), + liveness, + ) +} + +async fn value(connection: &dyn Connection, sql: &str) -> String { + text(&scalar(connection, sql).await) +} + +async fn databases(connection: &dyn Connection) -> Vec { + connection + .list_databases(within(CALL)) + .await + .expect("the databases") + .into_iter() + .map(|database| database.name) + .collect() +} + +async fn schemas(connection: &dyn Connection) -> Vec { + connection + .list_schemas(within(CALL)) + .await + .expect("the schemas") + .into_iter() + .map(|schema| schema.name) + .collect() +} + +/// The server refuses a referenced table on its own. CASCADE empties +/// the tables that reference it too, and only RESTART IDENTITY starts +/// the numbering again. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_truncate_cascades_to_referencing_tables_and_restarts_numbering_when_asked() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let connection: &dyn Connection = connection.as_ref(); + setup( + connection, + &[ + "CREATE TABLE parent (id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, label text)", + "CREATE TABLE child (id int GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, parent_id int REFERENCES parent)", + "INSERT INTO parent (label) VALUES ('a'), ('b')", + "INSERT INTO child (parent_id) VALUES (1), (2)", + ], + ) + .await; + let parent = TableRef::new(Some("public".to_owned()), "parent"); + let counts = "SELECT (SELECT count(*) FROM parent) || '|' || (SELECT count(*) FROM child)"; + let cascade = TruncateTableOptions { + cascade: true, + ..TruncateTableOptions::default() + }; + + let refused = truncate(connection, &parent, TruncateTableOptions::default()).await; + assert!(refused.is_err(), "{refused:?}"); + assert_eq!(value(connection, counts).await, "2|2"); + + truncate(connection, &parent, cascade.clone()) + .await + .expect("a cascading truncate"); + assert_eq!(value(connection, counts).await, "0|0"); + setup(connection, &["INSERT INTO parent (label) VALUES ('c')"]).await; + assert_eq!(value(connection, "SELECT id FROM parent").await, "3"); + + let restart = TruncateTableOptions { + restart_identity: true, + ..cascade + }; + truncate(connection, &parent, restart) + .await + .expect("a truncate that restarts the numbering"); + assert_eq!(value(connection, counts).await, "0|0"); + setup(connection, &["INSERT INTO parent (label) VALUES ('d')"]).await; + assert_eq!(value(connection, "SELECT id FROM parent").await, "1"); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_schema_is_created_for_its_owner_and_dropped_only_with_what_it_holds() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let connection: &dyn Connection = connection.as_ref(); + let dialect = connection.dialect(); + let owner = fixture.password_credentials().username; + let draft = SchemaDraft { + name: "sales".to_owned(), + owner: Some(owner.clone()), + }; + + let created = plan_create_schema(dialect.as_ref(), &draft).expect("a schema plan"); + run(connection, created, "sales").await.expect("the schema is created"); + setup(connection, &["CREATE TABLE sales.orders (id int)"]).await; + assert!(schemas(connection).await.contains(&"sales".to_owned())); + assert_eq!( + value( + connection, + "SELECT nspowner::regrole::text FROM pg_namespace WHERE nspname = 'sales'" + ) + .await, + owner + ); + + let plain = plan_drop_schema(dialect.as_ref(), "sales", DropSchemaOptions::default()).expect("a drop plan"); + let refused = run(connection, plain, "sales").await; + assert!(refused.is_err(), "{refused:?}"); + assert!(schemas(connection).await.contains(&"sales".to_owned())); + + let cascade = + plan_drop_schema(dialect.as_ref(), "sales", DropSchemaOptions { cascade: true }).expect("a drop plan"); + run(connection, cascade, "sales").await.expect("the schema is dropped"); + assert!(!schemas(connection).await.contains(&"sales".to_owned())); + connection.close().await.expect("a close"); +} + +/// The server refuses CREATE and DROP DATABASE inside a transaction, so +/// these pass only if the write sends them on their own. An encoding +/// and locale other than template1's are taken from template0. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_database_is_created_and_dropped_outside_a_transaction() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let connection: &dyn Connection = connection.as_ref(); + let dialect = connection.dialect(); + let draft = DatabaseDraft { + encoding: Some("LATIN1".to_owned()), + locale: Some("C".to_owned()), + ..DatabaseDraft::named("tp_latin") + }; + + let created = plan_create_database(dialect.as_ref(), &draft).expect("a create plan"); + assert_eq!(created.kind, WriteKind::DatabaseChanges); + let report = run(connection, created, "tp_latin") + .await + .expect("CREATE DATABASE runs on its own"); + assert_eq!(report, WriteReport::new(vec![None])); + assert_eq!( + value( + connection, + "SELECT pg_encoding_to_char(encoding) || '|' || datcollate FROM pg_database WHERE datname = 'tp_latin'" + ) + .await, + "LATIN1|C" + ); + assert!(databases(connection).await.contains(&"tp_latin".to_owned())); + + let dropped = + plan_drop_database(dialect.as_ref(), "tp_latin", &DropDatabaseOptions::default()).expect("a drop plan"); + run(connection, dropped, "tp_latin") + .await + .expect("DROP DATABASE runs on its own"); + assert!(!databases(connection).await.contains(&"tp_latin".to_owned())); + connection.close().await.expect("a close"); +} + +/// Each statement lands on its own, so a failure after the first reports +/// the first as applied rather than rolled back. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_database_change_that_fails_partway_reports_what_landed() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let connection: &dyn Connection = connection.as_ref(); + + let outcome = apply( + connection, + WriteKind::DatabaseChanges, + &["CREATE DATABASE tp_twice", "CREATE DATABASE tp_twice"], + ) + .await; + + assert!( + matches!( + outcome, + Err(DriverError::PartiallyApplied { + applied: 1, + statement_index: 1, + failed_outcome: StatementOutcome::Failed, + .. + }) + ), + "{outcome:?}" + ); + assert!(databases(connection).await.contains(&"tp_twice".to_owned())); + connection.close().await.expect("a close"); +} + +/// Another workspace on the database keeps a plain drop out. The forced +/// one ends its sessions, and that workspace's next login finds the +/// database gone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_forced_drop_ends_the_sessions_a_plain_drop_is_refused_for() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let connection: &dyn Connection = connection.as_ref(); + let dialect = connection.dialect(); + let created = plan_create_database(dialect.as_ref(), &DatabaseDraft::named("tp_busy")).expect("a create plan"); + run(connection, created, "tp_busy") + .await + .expect("the database is created"); + let sibling = connect_with(options_in(&fixture, "tp_busy")).await; + + let plain = plan_drop_database(dialect.as_ref(), "tp_busy", &DropDatabaseOptions::default()).expect("a drop plan"); + let refused = run(connection, plain, "tp_busy").await; + assert!( + matches!(refused, Err(DriverError::RolledBack { statement_index: 0, .. })), + "{refused:?}" + ); + + let force = DropDatabaseOptions { + force: true, + cluster: None, + }; + let forced = plan_drop_database(dialect.as_ref(), "tp_busy", &force).expect("a forced drop on 13 and later"); + run(connection, forced, "tp_busy").await.expect("the forced drop runs"); + assert!(!databases(connection).await.contains(&"tp_busy".to_owned())); + + // The pooled connection the server ended may be found dead first, + // before a login replaces it. + let mut outcome = sibling.list_schemas(within(CALL)).await; + if matches!(outcome, Err(DriverError::ConnectionLost { .. })) { + outcome = sibling.list_schemas(within(CALL)).await; + } + assert!( + matches!(&outcome, Err(DriverError::DatabaseNotFound { database }) if database == "tp_busy"), + "{outcome:?}" + ); + sibling.close().await.expect("a close"); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn the_new_database_options_list_the_servers_roles_templates_and_template1s_defaults() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let user = fixture.password_credentials().username; + + let choices = connection + .fetch_database_choices(within(CALL)) + .await + .expect("the database choices"); + + assert!(choices.owners.contains(&user), "{:?}", choices.owners); + assert!(choices.owners.iter().all(|owner| !owner.starts_with("pg_"))); + for template in ["template0", "template1"] { + assert!( + choices.templates.contains(&template.to_owned()), + "{:?}", + choices.templates + ); + } + assert!(choices.encodings.contains(&"UTF8".to_owned())); + assert!(choices.locales.contains(&"C".to_owned()), "{:?}", choices.locales); + assert_eq!(choices.defaults.owner, Some(user)); + assert_eq!(choices.defaults.encoding.as_deref(), Some("UTF8")); + assert!(choices.defaults.locale.is_some()); + connection.close().await.expect("a close"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires docker"] +async fn a_login_to_a_missing_database_names_the_database() { + let fixture = start().await; + + let outcome = PgDriver + .connect(options_in(&fixture, "tp_never_made"), CancellationToken::new()) + .await; + + assert!( + matches!(&outcome, Err(DriverError::DatabaseNotFound { database }) if database == "tp_never_made"), + "got {:?}", + outcome.err() + ); +} diff --git a/core/crates/drivers/postgres/tests/integration/main.rs b/core/crates/drivers/postgres/tests/integration/main.rs index 75e1453b3c..a33ef514b5 100644 --- a/core/crates/drivers/postgres/tests/integration/main.rs +++ b/core/crates/drivers/postgres/tests/integration/main.rs @@ -7,6 +7,7 @@ )] mod cancellation; +mod catalog_ddl; mod conformance_engine; mod csv_import; mod ddl; diff --git a/core/crates/drivers/sqlite/src/connection.rs b/core/crates/drivers/sqlite/src/connection.rs index a0b6814523..9641bb1c16 100644 --- a/core/crates/drivers/sqlite/src/connection.rs +++ b/core/crates/drivers/sqlite/src/connection.rs @@ -498,7 +498,7 @@ impl Connection for SqliteConnection { // halfway by the statement timeout would only roll back. let options = match batch.kind { WriteKind::RowChanges => self.statement_bounded(options), - WriteKind::SchemaChanges => options, + WriteKind::SchemaChanges | WriteKind::DatabaseChanges => options, }; let pooled = self.acquire(&options).await?; let ledger = Arc::new(WriteLedger::new(WriteAtomicity::Transactional)); diff --git a/core/crates/drivers/sqlite/src/ddl/mod.rs b/core/crates/drivers/sqlite/src/ddl/mod.rs index b4709885e5..b96f827216 100644 --- a/core/crates/drivers/sqlite/src/ddl/mod.rs +++ b/core/crates/drivers/sqlite/src/ddl/mod.rs @@ -14,9 +14,9 @@ mod source_read_error; use tablepro_core::column::{ColumnDefault, ColumnType}; use tablepro_core::ddl::ddl_sql::{column_list, default_sql, index_keys_sql, parenthesized}; use tablepro_core::ddl::{ - BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, - DdlPrecondition, DraftColumn, DropTableOptions, GeneratedStorage, Permission, RefusalReason, SchemaObjectKind, - TableChanges, TableDefinition, TableDraft, + BuildDdlError, CatalogCapabilities, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, + DdlOperation, DdlPlan, DdlPrecondition, DraftColumn, DropTableOptions, GeneratedStorage, Permission, RefusalReason, + SchemaObjectKind, TableChanges, TableDefinition, TableDraft, TruncateTableOptions, refuse_options, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, TableRef}; @@ -203,6 +203,32 @@ impl DdlDialect for SqliteDialect { Ok(plan) } + /// ATTACH adds a file to this connection only, and nothing of it + /// stays in the database, so there is no database or schema to + /// create or drop. + fn catalog_capabilities(&self) -> CatalogCapabilities { + CatalogCapabilities { + truncate_table: true, + truncate_refused_when_referenced: true, + ..CatalogCapabilities::default() + } + } + + /// SQLite has no TRUNCATE. A DELETE with no WHERE empties the table + /// and keeps it, and an AUTOINCREMENT counter goes on from where it + /// was. It would also run the ON DELETE action of every key pointing + /// at the table, emptying or clearing rows of other tables, so the + /// same precondition as Drop Table refuses a referenced table. + fn plan_truncate_table(&self, table: &TableRef, options: &TruncateTableOptions) -> Result { + refuse_options(&self.catalog_capabilities(), options)?; + let mut plan = DdlPlan::from_sql([format!("DELETE FROM {}", self.qualify(table))]); + plan.precondition = Some(DdlPrecondition { + probe: self.inbound_keys_probe(table)?, + rows: 0, + }); + Ok(plan) + } + /// The CREATE statement SQLite stored, then the stored statements of /// the table's own indexes and triggers. Indexes SQLite made for a /// constraint have no statement and come back with the table. diff --git a/core/crates/drivers/sqlite/src/ddl/tests.rs b/core/crates/drivers/sqlite/src/ddl/tests.rs index ff04ab6226..adc102c3ae 100644 --- a/core/crates/drivers/sqlite/src/ddl/tests.rs +++ b/core/crates/drivers/sqlite/src/ddl/tests.rs @@ -1,8 +1,9 @@ use tablepro_core::column::{ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ - ColumnAttributes, ColumnDefinition, DdlOperation, DraftType, GeneratedColumn, IdentityStyle, InboundForeignKey, - Permission, PrimaryKeyConstraint, RecreatedObject, ReferentialAction, RefusalReason, SchemaObjectSource, - TableRebuild, TableSource, diff_table, + self as catalog, ColumnAttributes, ColumnDefinition, DatabaseDraft, DdlOperation, DraftType, DropDatabaseOptions, + DropSchemaOptions, GeneratedColumn, IdentityStyle, InboundForeignKey, Permission, PrimaryKeyConstraint, + RecreatedObject, ReferentialAction, RefusalReason, SchemaDraft, SchemaObjectSource, TableRebuild, TableSource, + diff_table, }; use tablepro_core::meta::IndexKey; use tablepro_core::value::Value; @@ -1446,3 +1447,87 @@ fn a_rebuild_names_what_it_drops_and_makes_again() { assert!(keyed.rebuild.is_some_and(|rebuild| rebuild.renumbers_rowids)); assert_eq!(native.rebuild, None); } + +#[test] +fn a_truncate_is_a_delete_of_every_row() { + let qualified = TableRef::new(Some("main".to_owned()), "or\"ders"); + + let plan = dialect() + .plan_truncate_table(&qualified, &TruncateTableOptions::default()) + .expect("a plan"); + let bare = dialect() + .plan_truncate_table(&TableRef::unqualified("orders"), &TruncateTableOptions::default()) + .expect("a plan"); + + assert_eq!(plan.sql(), ["DELETE FROM \"main\".\"or\"\"ders\""]); + assert_eq!(bare.sql(), ["DELETE FROM \"orders\""]); + assert_eq!(plan.foreign_keys, ForeignKeyMode::Enforced); + let Some(precondition) = &plan.precondition else { + panic!("a truncate without the inbound keys probe"); + }; + assert_eq!(precondition.rows, 0); + let probe = precondition.probe.sql(); + for read in [ + "FROM \"main\".\"sqlite_schema\" m, pragma_foreign_key_list(m.name, 'main') f", + "f.\"table\" = 'or\"ders' COLLATE NOCASE", + "m.name <> 'or\"ders' COLLATE NOCASE", + ] { + assert!(probe.contains(read), "{read} is not in {probe}"); + } + assert!(plan.clone().into_steps()[0].guard.probe().is_some()); + let restart = TruncateTableOptions { + restart_identity: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + dialect().plan_truncate_table(&qualified, &restart), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateRestartIdentity)) + ); + let cascade = TruncateTableOptions { + cascade: true, + ..TruncateTableOptions::default() + }; + assert_eq!( + dialect().plan_truncate_table(&qualified, &cascade), + Err(BuildDdlError::Unsupported(DdlOperation::TruncateTableCascade)) + ); +} + +/// The sidebar offers what the capabilities say, so each one has to +/// plan, with every option it offers, and nothing else may. +#[test] +fn every_offered_catalog_operation_plans_and_no_other() { + let dialect = dialect(); + let offered = catalog::catalog_capabilities(&dialect); + let table = TableRef::new(Some("main".to_owned()), "orders"); + let truncate = TruncateTableOptions { + cascade: offered.truncate_cascade, + restart_identity: offered.truncate_restart_identity, + cluster: None, + }; + let schema = SchemaDraft { + name: "sales".to_owned(), + owner: None, + }; + + assert!(offered.truncate_table && offered.truncate_refused_when_referenced); + assert!(catalog::plan_truncate_table(&dialect, &table, &truncate).is_ok()); + assert!(!offered.create_schema && !offered.drop_schema); + assert!(!offered.create_database && !offered.drop_database); + assert_eq!( + catalog::plan_create_schema(&dialect, &schema), + Err(BuildDdlError::Unsupported(DdlOperation::CreateSchema)) + ); + assert_eq!( + catalog::plan_drop_schema(&dialect, "sales", DropSchemaOptions::default()), + Err(BuildDdlError::Unsupported(DdlOperation::DropSchema)) + ); + assert_eq!( + catalog::plan_create_database(&dialect, &DatabaseDraft::named("sales")), + Err(BuildDdlError::Unsupported(DdlOperation::CreateDatabase)) + ); + assert_eq!( + catalog::plan_drop_database(&dialect, "sales", &DropDatabaseOptions::default()), + Err(BuildDdlError::Unsupported(DdlOperation::DropDatabase)) + ); +} diff --git a/core/crates/drivers/sqlite/tests/integration/ddl.rs b/core/crates/drivers/sqlite/tests/integration/ddl.rs index 1ea12c39ca..6b5d4ec288 100644 --- a/core/crates/drivers/sqlite/tests/integration/ddl.rs +++ b/core/crates/drivers/sqlite/tests/integration/ddl.rs @@ -4,8 +4,8 @@ use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{ - BuildDdlError, DdlOperation, DraftColumn, DraftType, DropTableOptions, ReferentialAction, RefusalReason, - SchemaObjectKind, TableDefinition, TableDraft, diff_table, + self as catalog, BuildDdlError, DdlOperation, DraftColumn, DraftType, DropTableOptions, ReferentialAction, + RefusalReason, SchemaObjectKind, TableDefinition, TableDraft, TruncateTableOptions, diff_table, }; use tablepro_core::meta::{IndexKey, TableRef}; use tablepro_core::statement::GuardedStatement; @@ -1528,3 +1528,89 @@ async fn an_integer_column_holding_nulls_is_not_made_the_rowid() { ); connection.close().await.unwrap(); } + +/// Truncate Table as the workspace plans and runs it. +async fn truncate(connection: &dyn Connection, table: &TableRef) -> Result { + let dialect = connection.dialect(); + let plan = catalog::plan_truncate_table(dialect.as_ref(), table, &TruncateTableOptions::default()).unwrap(); + connection + .write_batch(&plan.into_write_batch(table.clone()), call()) + .await +} + +/// SQLite has no TRUNCATE, so the plan deletes every row: the table +/// and its AUTOINCREMENT counter stay, and a key of the table on itself +/// does not hold it. +#[tokio::test] +async fn a_truncate_empties_the_table_and_keeps_its_counter() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, referrer INTEGER REFERENCES customers)", + "INSERT INTO customers (referrer) VALUES (NULL), (1)", + ], + ) + .await + .unwrap(); + + truncate( + connection.as_ref(), + &TableRef::new(Some("main".to_owned()), "customers"), + ) + .await + .unwrap(); + apply(connection.as_ref(), &["INSERT INTO customers (referrer) VALUES (NULL)"]) + .await + .unwrap(); + + let left = query( + connection.as_ref(), + "SELECT (SELECT group_concat(id) FROM customers), \ + (SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = 'customers')", + ) + .await; + assert_eq!(left.rows, [vec![text("3"), Value::Int(1)]]); + connection.close().await.unwrap(); +} + +/// The DELETE would run these keys' ON DELETE actions, deleting the +/// order and clearing the note's key, so the plan's probe refuses the +/// table before anything runs. +#[tokio::test] +async fn a_table_another_tables_key_points_at_is_not_truncated() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE customers (id INTEGER PRIMARY KEY)", + "CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES Customers ON DELETE CASCADE)", + "CREATE TABLE notes (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers ON DELETE SET NULL)", + "INSERT INTO customers VALUES (1)", + "INSERT INTO orders VALUES (10, 1)", + "INSERT INTO notes VALUES (20, 1)", + ], + ) + .await + .unwrap(); + + let outcome = truncate(connection.as_ref(), &named("customers")).await; + + assert_eq!( + outcome, + Err(DriverError::RowGuardFailed { + statement_index: 0, + expected: 0, + actual: 2 + }) + ); + let left = query( + connection.as_ref(), + "SELECT (SELECT count(*) FROM customers), (SELECT customer_id FROM orders), (SELECT customer_id FROM notes)", + ) + .await; + assert_eq!(left.rows, [vec![Value::Int(1), Value::Int(1), Value::Int(1)]]); + connection.close().await.unwrap(); +} diff --git a/core/crates/ffi/src/error.rs b/core/crates/ffi/src/error.rs index 9769d1cd96..419d226e69 100644 --- a/core/crates/ffi/src/error.rs +++ b/core/crates/ffi/src/error.rs @@ -207,6 +207,9 @@ fn server_message(error: &DriverError) -> Option { DriverError::Auth { diagnostics: Some(diagnostics), } => diagnostics, + // The variant keeps only the name, which an app shows after its + // own sentence for the code, as it does the server's words. + DriverError::DatabaseNotFound { database } => return Some(named_only(database)), _ => return None, }; Some(ServerMessage { @@ -219,6 +222,17 @@ fn server_message(error: &DriverError) -> Option { }) } +fn named_only(name: &str) -> ServerMessage { + ServerMessage { + message: name.to_owned(), + code: None, + sqlstate: None, + detail: None, + hint: None, + position: None, + } +} + fn file_path(error: &DriverError) -> Option<&Path> { match error { DriverError::FileNotFound { path } @@ -410,6 +424,22 @@ mod tests { assert_eq!(server.position, Some(15)); } + #[test] + fn a_missing_database_names_it_where_the_server_s_words_go() { + let error = ConnectError::Driver(DriverError::DatabaseNotFound { + database: "shopp".to_owned(), + }); + + let info = info(error.into()); + + assert_eq!(info.codes, ["connect.driver", "driver.database_not_found"]); + assert_eq!(info.code, "driver.database_not_found"); + let server = info.server.expect("the database's name"); + assert_eq!(server.message, "shopp"); + assert_eq!((server.code, server.sqlstate, server.detail), (None, None, None)); + assert!(info.path.is_none()); + } + #[test] fn every_kind_of_cancel_is_a_cancel() { assert_eq!(CoreError::from(DriverError::Cancelled), CoreError::Cancelled); diff --git a/core/crates/session/src/health/failure_cause.rs b/core/crates/session/src/health/failure_cause.rs index 201f7d6334..12acbb6ee4 100644 --- a/core/crates/session/src/health/failure_cause.rs +++ b/core/crates/session/src/health/failure_cause.rs @@ -98,6 +98,15 @@ mod tests { assert_eq!(FailureCause::of(&DriverError::Busy(BusyReason::NoFreeConnection)), None); } + #[test] + fn a_database_the_server_no_longer_has_parks() { + let missing = DriverError::DatabaseNotFound { + database: "shop".to_owned(), + }; + + assert_eq!(FailureCause::of(&missing), Some(FailureCause::Configuration)); + } + #[test] fn ssh_failures_a_retry_would_repeat_park() { let too_many = ssh(SshFailure::TooManyAuthenticationFailures { diff --git a/core/crates/session/src/operation/default_deadlines.rs b/core/crates/session/src/operation/default_deadlines.rs index e185b4c062..067bc9015b 100644 --- a/core/crates/session/src/operation/default_deadlines.rs +++ b/core/crates/session/src/operation/default_deadlines.rs @@ -26,7 +26,7 @@ impl DeadlinePolicy for DefaultDeadlines { OperationKind::Metadata => Some(Self::METADATA), OperationKind::Page | OperationKind::Count => Some(Self::PAGE), OperationKind::Save(WriteKind::RowChanges) => Some(Self::ROW_CHANGES), - OperationKind::Save(WriteKind::SchemaChanges) | OperationKind::Export => None, + OperationKind::Save(WriteKind::SchemaChanges | WriteKind::DatabaseChanges) | OperationKind::Export => None, OperationKind::Probe => Some(self.liveness.probe_timeout), OperationKind::Close => Some(self.liveness.close_timeout), } @@ -47,6 +47,7 @@ mod tests { assert_eq!(seconds(OperationKind::Count), Some(60)); assert_eq!(seconds(OperationKind::Save(WriteKind::RowChanges)), Some(120)); assert_eq!(seconds(OperationKind::Save(WriteKind::SchemaChanges)), None); + assert_eq!(seconds(OperationKind::Save(WriteKind::DatabaseChanges)), None); assert_eq!(seconds(OperationKind::Probe), Some(5)); assert_eq!(seconds(OperationKind::Close), Some(10)); } diff --git a/core/crates/session/src/operation/replay_rule.rs b/core/crates/session/src/operation/replay_rule.rs index f339a7bef4..f24ea0fa7d 100644 --- a/core/crates/session/src/operation/replay_rule.rs +++ b/core/crates/session/src/operation/replay_rule.rs @@ -65,6 +65,7 @@ mod tests { (OperationKind::Count, true), (OperationKind::Save(WriteKind::RowChanges), false), (OperationKind::Save(WriteKind::SchemaChanges), false), + (OperationKind::Save(WriteKind::DatabaseChanges), false), (OperationKind::Probe, false), (OperationKind::Close, false), (OperationKind::Export, false), diff --git a/core/crates/session/src/session/schema_catalog.rs b/core/crates/session/src/session/schema_catalog.rs index 50c23d4ab6..dcfc066519 100644 --- a/core/crates/session/src/session/schema_catalog.rs +++ b/core/crates/session/src/session/schema_catalog.rs @@ -177,6 +177,11 @@ impl SchemaCatalog { self.read::(()).await } + /// Read the schemas again, even when a list is cached. + pub async fn refresh_schemas(&self) -> Fetched { + self.refresh::(()).await + } + pub async fn objects(&self, scope: ObjectScope) -> Fetched { self.read::(scope).await } diff --git a/core/crates/session/src/session/session.rs b/core/crates/session/src/session/session.rs index a2edaf40f1..dff1692fb4 100644 --- a/core/crates/session/src/session/session.rs +++ b/core/crates/session/src/session/session.rs @@ -225,6 +225,11 @@ impl Session { self.catalog.schemas().await } + /// Read the schemas again, after one was created or dropped. + pub async fn refresh_schemas(&self) -> Result, Arc> { + self.catalog.refresh_schemas().await + } + pub async fn objects(&self, scope: ObjectScope) -> Result, Arc> { self.catalog.objects(scope).await } diff --git a/core/crates/test-support/src/call_counters.rs b/core/crates/test-support/src/call_counters.rs index 18aabc964d..39ef8536ad 100644 --- a/core/crates/test-support/src/call_counters.rs +++ b/core/crates/test-support/src/call_counters.rs @@ -16,6 +16,7 @@ pub struct CallCounters { pub fetch_inbound_foreign_keys: Arc, pub fetch_table_definition: Arc, pub fetch_character_sets: Arc, + pub fetch_database_choices: Arc, pub fetch_object_definition: Arc, pub row_identity: Arc, pub table_estimate: Arc, diff --git a/core/crates/test-support/src/conformance/cases/ddl.rs b/core/crates/test-support/src/conformance/cases/ddl.rs index b5e94c2fd7..fb17f38efb 100644 --- a/core/crates/test-support/src/conformance/cases/ddl.rs +++ b/core/crates/test-support/src/conformance/cases/ddl.rs @@ -1,14 +1,17 @@ //! Structure changes planned through the connection's own DDL dialect //! and run as the structure tab runs them, then read back through -//! `fetch_table_definition`. +//! `fetch_table_definition`. Emptying a table and creating and dropping +//! a schema or database go through the plans the sidebar uses. use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{ - BuildDdlError, ColumnDefinition, DdlDialect, DdlOperation, DdlPlan, DraftColumn, DraftType, DropTableOptions, - Permission, RefusalReason, TableDefinition, TableDraft, diff_table, + BuildDdlError, ColumnDefinition, DatabaseDraft, DdlDialect, DdlOperation, DdlPlan, DraftColumn, DraftType, + DropDatabaseOptions, DropSchemaOptions, DropTableOptions, KeyspaceReplication, Permission, RefusalReason, + SchemaDraft, TableDefinition, TableDraft, TruncateTableOptions, catalog_capabilities, diff_table, + plan_create_database, plan_create_schema, plan_drop_database, plan_drop_schema, plan_truncate_table, }; use tablepro_core::dialect::SqlDialect; -use tablepro_core::meta::{IndexInfo, IndexKey, ObjectKind, ObjectScope}; +use tablepro_core::meta::{IndexInfo, IndexKey, ObjectKind, ObjectScope, SchemaInfo}; use tablepro_core::value::Value; use tablepro_core::{ Connection, DriverError, ForeignKeyInfo, ForeignKeyMode, RowLimit, TableRef, WriteAtomicity, WriteBatch, WriteKind, @@ -16,7 +19,8 @@ use tablepro_core::{ }; use crate::conformance::support::{ - CALL_DEADLINE, apply, connect, count, expect, insert_sql, statement, unexpected, unique, within, writable, + CALL_DEADLINE, apply, connect, count, create_scratch_table, expect, insert_sql, statement, unexpected, unique, + within, writable, }; use crate::conformance::{ConformanceDdlEngine, ConformanceFailure}; @@ -25,6 +29,9 @@ const FAILED_CASE: &str = "failed_ddl_batch_outcome_matches_ddl_atomicity"; const SCRIPT_CASE: &str = "create_table_script_recreates_the_table"; const ROWS_CASE: &str = "structure_changes_keep_rows_and_indexes"; const INDEX_CASE: &str = "index_statement_recreates_the_index"; +const TRUNCATE_CASE: &str = "truncate_empties_the_table_and_keeps_it"; +const SCHEMA_CASE: &str = "created_schema_is_listed_until_dropped"; +const DATABASE_CASE: &str = "created_database_is_listed_until_dropped"; /// Case 1: each structure change the engine offers applies and reads /// back as drafted, and each one it refuses is refused by the plan @@ -369,6 +376,166 @@ pub async fn index_statement_recreates_the_index(engine Ok(()) } +/// Case 6: a truncate with no options empties a table and leaves it with +/// its columns. An engine that offers none has to refuse the plan. +pub async fn truncate_empties_the_table_and_keeps_it( + engine: E, +) -> Result<(), ConformanceFailure> { + let case = TRUNCATE_CASE; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let ddl = ddl_of(case, dialect.as_ref())?; + let options = TruncateTableOptions::default(); + if !catalog_capabilities(dialect.as_ref()).truncate_table { + let table = engine.scratch_table(&unique("ddl_truncate")); + refused( + case, + "truncate", + plan_truncate_table(dialect.as_ref(), &table, &options), + )?; + connection.close().await?; + return Ok(()); + } + let table = create_scratch_table(&engine, connection.as_ref(), "ddl_truncate").await?; + let inserts = (1..) + .zip(["first", "second", "third"]) + .map(|(id, label)| insert_sql(dialect.as_ref(), &table, id, label)) + .collect(); + apply(connection.as_ref(), &table, WriteKind::RowChanges, inserts).await?; + let before = definition(connection.as_ref(), &table).await?; + let rows = count(connection.as_ref(), &table).await?; + expect(case, rows == 3, || format!("{rows} rows were written of 3"))?; + + let plan = + plan_truncate_table(dialect.as_ref(), &table, &options).map_err(|error| planned(case, "truncate", error))?; + run(connection.as_ref(), &table, plan).await?; + + let rows = count(connection.as_ref(), &table).await?; + expect(case, rows == 0, || format!("{rows} rows are left after the truncate"))?; + let after = definition(connection.as_ref(), &table).await?; + let names = |table: &TableDefinition| -> Vec { + table.columns.iter().map(|column| column.name().to_owned()).collect() + }; + expect(case, names(&before) == names(&after), || { + format!("the columns were {:?} and are {:?}", names(&before), names(&after)) + })?; + drop_table(case, connection.as_ref(), ddl, &table).await?; + connection.close().await?; + Ok(()) +} + +/// Case 7: a new schema is listed, and once dropped it is not. Where the +/// engine offers either step alone, the other has to be refused. +pub async fn created_schema_is_listed_until_dropped( + engine: E, +) -> Result<(), ConformanceFailure> { + let case = SCHEMA_CASE; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let capabilities = catalog_capabilities(dialect.as_ref()); + let name = unique("ddl_schema"); + let draft = SchemaDraft { + name: name.clone(), + ..SchemaDraft::default() + }; + let create = plan_create_schema(dialect.as_ref(), &draft); + let drop = plan_drop_schema(dialect.as_ref(), &name, DropSchemaOptions::default()); + if !capabilities.create_schema || !capabilities.drop_schema { + if !capabilities.create_schema { + refused(case, "create schema", create)?; + } + if !capabilities.drop_schema { + refused(case, "drop schema", drop)?; + } + connection.close().await?; + return Ok(()); + } + let catalog = TableRef::new(Some(name.clone()), ""); + + let create = create.map_err(|error| planned(case, "create schema", error))?; + run(connection.as_ref(), &catalog, create).await?; + let schemas = connection.list_schemas(within(CALL_DEADLINE)).await?; + expect(case, lists(&schemas, &name), || { + format!("{name} is not listed after the create: {schemas:?}") + })?; + + let drop = drop.map_err(|error| planned(case, "drop schema", error))?; + run(connection.as_ref(), &catalog, drop).await?; + let schemas = connection.list_schemas(within(CALL_DEADLINE)).await?; + expect(case, !lists(&schemas, &name), || { + format!("{name} is still listed after the drop") + })?; + connection.close().await?; + Ok(()) +} + +/// Case 8: a new database is listed, and once dropped it is not. A +/// keyspace gets a replication factor of one, with ScyllaDB's tablets +/// off, which SimpleStrategy needs. An engine whose new database needs a +/// first collection (MongoDB) is left to its own tests. +pub async fn created_database_is_listed_until_dropped( + engine: E, +) -> Result<(), ConformanceFailure> { + let case = DATABASE_CASE; + let connection = connect(case, &engine, writable()).await?; + let dialect = connection.dialect(); + let capabilities = catalog_capabilities(dialect.as_ref()); + let fields = capabilities.database_fields; + let name = unique("ddl_db"); + let mut draft = DatabaseDraft::named(name.clone()); + if fields.replication { + draft.replication = Some(KeyspaceReplication::Simple { factor: 1 }); + } + if fields.tablets { + draft.tablets = Some(false); + } + let create = plan_create_database(dialect.as_ref(), &draft); + let drop = plan_drop_database(dialect.as_ref(), &name, &DropDatabaseOptions::default()); + if !capabilities.create_database || !capabilities.drop_database || fields.first_collection { + if !capabilities.create_database { + refused(case, "create database", create)?; + } + if !capabilities.drop_database { + refused(case, "drop database", drop)?; + } + connection.close().await?; + return Ok(()); + } + let catalog = TableRef::new(Some(name.clone()), ""); + + let create = create.map_err(|error| planned(case, "create database", error))?; + run(connection.as_ref(), &catalog, create).await?; + let databases = connection.list_databases(within(CALL_DEADLINE)).await?; + expect(case, lists(&databases, &name), || { + format!("{name} is not listed after the create: {databases:?}") + })?; + + let drop = drop.map_err(|error| planned(case, "drop database", error))?; + run(connection.as_ref(), &catalog, drop).await?; + let databases = connection.list_databases(within(CALL_DEADLINE)).await?; + expect(case, !lists(&databases, &name), || { + format!("{name} is still listed after the drop") + })?; + connection.close().await?; + Ok(()) +} + +/// What the capabilities leave out has to be refused by its plan, never +/// sent. +fn refused(case: &'static str, step: &str, outcome: Result) -> Result<(), ConformanceFailure> { + match outcome { + Err(BuildDdlError::Unsupported(_)) => Ok(()), + outcome => Err(unexpected( + case, + format!("{step} is not offered and the plan answered {outcome:?}"), + )), + } +} + +fn lists(schemas: &[SchemaInfo], name: &str) -> bool { + schemas.iter().any(|schema| schema.name == name) +} + fn named_index(case: &'static str, table: &TableDefinition, name: &str) -> Result { table .indexes diff --git a/core/crates/test-support/src/conformance/ddl_suite_macro.rs b/core/crates/test-support/src/conformance/ddl_suite_macro.rs index 409355041f..0b56395ae4 100644 --- a/core/crates/test-support/src/conformance/ddl_suite_macro.rs +++ b/core/crates/test-support/src/conformance/ddl_suite_macro.rs @@ -4,8 +4,8 @@ /// /// Each case but the last plans through the connection's own /// `DdlDialect` and runs the plan with `write_batch`, so what it checks -/// is the SQL the structure tab would send. The last runs the SQL -/// editor's built-in snippets through an editor session. +/// is the SQL the structure tab or the sidebar would send. The last runs +/// the SQL editor's built-in snippets through an editor session. #[macro_export] macro_rules! ddl_conformance_suite { (engine: $engine:ty, docker: $docker:tt $(,)?) => { @@ -39,6 +39,24 @@ macro_rules! ddl_conformance_suite { index_statement_recreates_the_index, $crate::conformance::cases::ddl::index_statement_recreates_the_index::<$engine> } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + truncate_empties_the_table_and_keeps_it, + $crate::conformance::cases::ddl::truncate_empties_the_table_and_keeps_it::<$engine> + } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + created_schema_is_listed_until_dropped, + $crate::conformance::cases::ddl::created_schema_is_listed_until_dropped::<$engine> + } + $crate::__conformance_case! { + $docker, + $engine as ConformanceEngine, + created_database_is_listed_until_dropped, + $crate::conformance::cases::ddl::created_database_is_listed_until_dropped::<$engine> + } $crate::__conformance_case! { $docker, $engine as ConformanceEngine, diff --git a/core/crates/test-support/src/connection_script.rs b/core/crates/test-support/src/connection_script.rs index 7f3b236ecd..7b10625c02 100644 --- a/core/crates/test-support/src/connection_script.rs +++ b/core/crates/test-support/src/connection_script.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use tablepro_core::ddl::{CharacterSet, InboundForeignKey, OutboundForeignKey, TableDefinition}; +use tablepro_core::ddl::{CharacterSet, DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::keyspace::{KeyListing, KeyPage}; use tablepro_core::meta::{ObjectDefinition, ObjectInfo, RowIdentity, SchemaInfo, TableEstimate}; @@ -34,6 +34,7 @@ pub struct ConnectionScript { pub fetch_inbound_foreign_keys: Replies>>, pub fetch_table_definition: Replies>, pub fetch_character_sets: Replies>>, + pub fetch_database_choices: Replies>, /// Keyed by the object's name. pub fetch_object_definition: Replies>>, pub row_identity: Replies>, @@ -71,6 +72,7 @@ impl Default for ConnectionScript { fetch_inbound_foreign_keys: Replies::default(), fetch_table_definition: Replies::default(), fetch_character_sets: Replies::default(), + fetch_database_choices: Replies::default(), fetch_object_definition: Replies::default(), row_identity: Replies::default(), table_estimate: Replies::default(), diff --git a/core/crates/test-support/src/fake_connection.rs b/core/crates/test-support/src/fake_connection.rs index bfe335307f..62b43f4621 100644 --- a/core/crates/test-support/src/fake_connection.rs +++ b/core/crates/test-support/src/fake_connection.rs @@ -2,7 +2,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex}; use async_trait::async_trait; -use tablepro_core::ddl::{CharacterSet, InboundForeignKey, OutboundForeignKey, TableDefinition}; +use tablepro_core::ddl::{CharacterSet, DatabaseChoices, InboundForeignKey, OutboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::keyspace::{KeyListing, KeyPage, KeyScan, KeyWindow}; use tablepro_core::meta::{ObjectDefinition, ObjectInfo, ObjectScope, RowIdentity, SchemaInfo, TableEstimate}; @@ -341,6 +341,19 @@ impl Connection for FakeConnection { .await } + async fn fetch_database_choices(&self, options: CallOptions) -> Result { + let counter = &self.counters.fetch_database_choices; + self.answer( + "fetch_database_choices", + counter, + |s| &mut s.fetch_database_choices, + None, + &options, + TimeoutPhase::Statement, + ) + .await + } + async fn fetch_object_definition( &self, object: &ObjectInfo, @@ -634,6 +647,25 @@ mod tests { assert_eq!(CallCounters::get(&counters.query_bound), 0); } + #[tokio::test] + async fn database_choices_come_from_the_script() { + let choices = DatabaseChoices { + encodings: vec!["UTF8".to_owned()], + ..DatabaseChoices::default() + }; + let mut script = ConnectionScript::default(); + script.fetch_database_choices.once(FakeReply::ok(choices.clone())); + let connection = FakeConnection::new(script); + let counters = connection.counters(); + + assert_eq!(connection.fetch_database_choices(unbounded()).await.unwrap(), choices); + assert!(matches!( + connection.fetch_database_choices(unbounded()).await, + Err(DriverError::Protocol(_)) + )); + assert_eq!(CallCounters::get(&counters.fetch_database_choices), 2); + } + #[tokio::test(start_paused = true)] async fn close_hang_stays_pending_under_paused_clock() { let hanging = FakeConnection::new(ConnectionScript { diff --git a/core/docs/decisions/0011-catalog-operations.md b/core/docs/decisions/0011-catalog-operations.md new file mode 100644 index 0000000000..3ae6a08b5f --- /dev/null +++ b/core/docs/decisions/0011-catalog-operations.md @@ -0,0 +1,32 @@ +# 0011: Truncating a table and creating or dropping a schema or database + +- **Status**: Accepted +- **Date**: 2026-11-05 + +## Context + +The apps could change a table's structure and drop a table through each engine's `DdlDialect` (Linux [0010](../../../linux/docs/decisions/0010-ddl-plans-and-table-definitions.md)), but nothing in the core wrote TRUNCATE or CREATE and DROP for a schema or a database. Each app would have spelled them itself, once per engine. Three engine rules shape the statements: PostgreSQL and SQL Server refuse CREATE and DROP DATABASE inside a transaction, which every write batch opened; MySQL and SQL Server refuse to truncate a table another table's foreign key points at, whatever its rows; and a session whose database was dropped reconnected forever, because "database does not exist" was a plain server error. + +## Decision + +The catalog operations are plans like structure changes. `DdlDialect` gains `catalog_capabilities` and `plan_truncate_table`, `plan_create_schema`, `plan_drop_schema`, `plan_create_database` and `plan_drop_database`, each refusing by default what the capabilities leave out. Free functions in `tablepro_core::ddl` reach them for any dialect and write MongoDB's through its shell (`deleteMany({})`, `createCollection` in the new database, `dropDatabase`). The New Database dialog shows only the fields `DatabaseFields` names, filled from `Connection::fetch_database_choices`. + +A database plan carries `WriteKind::DatabaseChanges`. A driver runs such a batch outside any transaction, one statement at a time, and reports what landed statement by statement. PostgreSQL sends no `SET LOCAL` bounds for it, since there is no transaction to scope them to; the call's deadline and stop still bound it. SQL Server drops a database with its other sessions closed in one batch, and sets it back to multi-user when the drop fails. + +A login to a database the server does not have fails as `DriverError::DatabaseNotFound { database }`, category Configuration, so the health monitor parks the session with that reason instead of retrying. Each driver maps it where it logs in only, never where a statement's error is read, since `USE` of a missing database is the user's own mistake. + +## Rationale + +- **One plan, one review, one history entry.** A catalog operation is a `DdlPlan`, so the Safe Mode review shows the statements that run and the history records them like a structure save. +- **Refused before the server sees it.** Each plan refuses an option the engine has no syntax for, and `CatalogCapabilities` tells the UI not to offer it, as `DdlCapabilities` does for structure changes. +- **The write kind decides the wrapping.** `WriteKind` already says how a write is wrapped; a database change is the case that must not be wrapped. + +## Consequences + +- PostgreSQL truncates with RESTART IDENTITY and CASCADE as options, drops a database WITH (FORCE) from 13, and writes TEMPLATE template0 when a new database names an encoding or locale and no template, since template1's would conflict. +- MySQL and SQL Server truncate only a table no other table's foreign key points at; the app refuses before asking rather than turning the checks off, which would leave rows pointing at nothing. MySQL lists every database as its schemas, as ClickHouse, Cassandra and MongoDB do, so an empty one can be shown. +- ClickHouse writes ON CLUSTER only for a cluster of more than one server that `system.clusters` lists, since a single server lists `default` with no Keeper behind it. +- SQLite empties a table with DELETE and has neither schemas nor databases to create: ATTACH adds a file to one connection. DuckDB creates and drops schemas; its databases are attached files. +- Cassandra and ScyllaDB create keyspaces with a replication strategy, durable writes and, on ScyllaDB, tablets. +- Redis offers none of them: it has no tables, and its numbered databases cannot be created or dropped. FLUSHDB stays in the editor. +- Each engine names the databases and schemas it keeps for itself (`protects_database`, `protects_schema`), and a name longer than the server keeps whole is refused (`NameLimit`). diff --git a/core/docs/decisions/README.md b/core/docs/decisions/README.md index 2c3f0fd4c2..1ad8f8e9a7 100644 --- a/core/docs/decisions/README.md +++ b/core/docs/decisions/README.md @@ -18,3 +18,4 @@ The format, the statuses and how a later ADR changes an earlier one follow [the | [0008](0008-importers-from-other-apps.md) | Importers from other apps live in the core | Accepted | One crate reads each app's files on every platform into one record shape, takes keychains and directories from the app, says what it cannot carry, and reviews the result against the library with the macOS duplicate handling. | | [0009](0009-in-process-ssh-on-windows.md) | SSH on Windows runs in process on russh | Accepted | russh behind `tablepro-ssh`'s `in-process` feature, on every target; Windows uses it, Linux keeps OpenSSH. Forwards are channels with no address, so MySQL and MongoDB relay them through a private listener that serves only this process. | | [0010](0010-macos-on-the-core.md) | The macOS app moves to the core in slices, starting with the connection library | Accepted | UniFFI Swift bindings in an XCFramework of the static library, without the engines, at the app's deployment target; UI, Keychain and the CloudKit transport stay Swift; the core maps the library's CloudKit records both ways with the existing fields and merges a pull field by field. | +| [0011](0011-catalog-operations.md) | Truncating a table and creating or dropping a schema or database | Accepted | `DdlDialect` plans TRUNCATE and CREATE and DROP for schemas and databases per engine, MongoDB through its shell; a database change runs outside any transaction as `WriteKind::DatabaseChanges`; a login to a missing database fails as `DatabaseNotFound` and parks. | diff --git a/linux/ROADMAP.md b/linux/ROADMAP.md index f92a184d6b..95641f30e6 100644 --- a/linux/ROADMAP.md +++ b/linux/ROADMAP.md @@ -17,7 +17,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Import and export | A JSON file with no secrets in it, tags and icons included. On import, an entry with the same id and the same settings updates its name, colour, group, tags and icon; any other is added as a new connection. Tags are matched by id, then by name, so a second import adds none | | Test before saving | Test connects once and reports, then disconnects | | Passwords | Kept in the Secret Service; a prompt asks when one is missing or refused, with Remember Password | -| Safe Mode | A level per connection, set in the connect dialog or the main menu while connected and saved back to the connection. Off asks before DROP, TRUNCATE, and DELETE or UPDATE without WHERE. Confirm Writes also asks before every other write from the editor, a grid save, a structure save, a CSV import or Drop Table, showing the SQL and the connection, with Cancel the default. Read-Only refuses every editor statement that does not read, and opens the connection read-only: SQLite, DuckDB and ClickHouse refuse writes at the engine, PostgreSQL and MySQL run in read-only transactions, SQL Server relies on the login, with the app's own reads in a rolled-back transaction, and Cassandra and ScyllaDB rely on the login. A statement is a read only when its engine's grammar shows it reads, each statement of a script on its own. Moving to or from Read-Only reconnects the window | +| Safe Mode | A level per connection, set in the connect dialog or the main menu while connected and saved back to the connection. Off asks before DROP, TRUNCATE, and DELETE or UPDATE without WHERE. Confirm Writes also asks before every other write from the editor, a grid save, a structure save, a CSV import, or a drop, truncate or create from the sidebar, showing the SQL and the connection, with Cancel the default. Read-Only refuses every editor statement that does not read, and opens the connection read-only: SQLite, DuckDB and ClickHouse refuse writes at the engine, PostgreSQL and MySQL run in read-only transactions, SQL Server relies on the login, with the app's own reads in a rolled-back transaction, and Cassandra and ScyllaDB rely on the login. A statement is a read only when its engine's grammar shows it reads, each statement of a script on its own. Moving to or from Read-Only reconnects the window | | Query timeout per connection | Custom Query Timeout in the connect dialog sets the editor's timeout for that connection in place of the one in Preferences, from the next connect; 0 turns it off. The app's own calls keep their fixed limits: 15 seconds for metadata, 60 for a page or a count, 120 for a row save or a CSV import, none for a schema change or an export, and on the server 60 for a table-tab statement and 10 for a lock wait | | Database files | SQLite and DuckDB: choose a file, create a new one, open one from Files or a terminal, where the file's header picks the engine, find a moved one with Locate, and choose read-only or read-write for one on a network or FUSE filesystem | | Kerberos for SQL Server | From the ticket cache, in the Flatpak through the KCM socket | @@ -56,7 +56,7 @@ The app runs on GNOME 50 ([ADR 0015](docs/decisions/0015-gnome-50-platform-floor | Foreign keys | Open Referenced Row (Ctrl+J) on a key cell opens the table it points at in a new tab, filtered on every column of the key; Show Referencing Rows on a saved row opens a table whose key points at it. A tab already showing those rows comes to the front | | Column layout | Order, hidden columns and widths kept per table; a column added since goes last and a dropped one keeps its place. A header drag, or Move Column Left and Move Column Right in the cell menu (Alt+Left and Alt+Right), reorders them; Widen Column and Narrow Column (Shift+Alt+Right and Shift+Alt+Left) resize them; Reset Columns in the Columns dialog goes back to the table's own order | | CSV import | Into an existing table, with a column mapping, in one write batch (one transaction where the engine has them), up to 64 MiB | -| Sidebar | Tables (partitioned ones included), views, materialized views and foreign tables by schema, with search and Refresh, read again after the editor runs DDL, Open in New Tab and Show CREATE TABLE; Edit Structure, Import CSV and Drop Table for tables | +| Sidebar | Tables (partitioned ones included), views, materialized views and foreign tables by schema, with search and Refresh, read again after the editor runs DDL, Open in New Tab and Show CREATE TABLE; Edit Structure, Import CSV, Truncate Table and Drop Table for tables, Delete All Documents for collections; New and Drop Schema, and New and Drop Database (Keyspace on Cassandra), on every engine that has them, never offered for a system object or the session's own database; empty schemas listed | | Routines and triggers | Functions, procedures and sequences in the sidebar after each schema's tables, and triggers with the table each fires on, on every engine that has them. Opening one shows its definition read-only, with Copy and Open in Editor; every overload of a PostgreSQL function name is shown | | ER diagram | Show ER Diagram in a table's menu or on a schema's heading opens the schema's tables as cards and their foreign keys as lines in crow's foot notation, read two tables at a time with Stop. Pan, zoom, Best Fit, drag tables, Reset Layout, and PNG and SVG export. Each table is a list item the keyboard and a screen reader reach: Tab and the arrow keys move between tables, Enter opens one, Alt+arrows move it, and its description names its columns and the tables it joins | diff --git a/linux/crates/app/src/ui/editor/tab_scope.rs b/linux/crates/app/src/ui/editor/tab_scope.rs index c31317fd51..7280cd3e80 100644 --- a/linux/crates/app/src/ui/editor/tab_scope.rs +++ b/linux/crates/app/src/ui/editor/tab_scope.rs @@ -154,9 +154,10 @@ impl SqlEditor { .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) { + /// Ask the server for its databases, for the bar's menu: when the tab + /// opens, and after a database is created or dropped anywhere in the + /// window. A server that cannot list them leaves the bar a label. + pub(crate) fn list_databases(&self) { let Some((session, tasks)) = self .deps() .and_then(|deps| deps.session.get().map(|session| (session, deps.tasks.clone()))) diff --git a/linux/crates/app/src/ui/error_text/ddl.rs b/linux/crates/app/src/ui/error_text/ddl.rs index 112a00b6f8..f10fa13a34 100644 --- a/linux/crates/app/src/ui/error_text/ddl.rs +++ b/linux/crates/app/src/ui/error_text/ddl.rs @@ -1,7 +1,7 @@ //! DDL refusals and problems in the user's words, for the Structure tab //! and for a save refused before it starts. -use tablepro_core::ddl::{BuildDdlError, DdlOperation, DraftProblem, RefusalReason}; +use tablepro_core::ddl::{BuildDdlError, CatalogOption, DdlOperation, DraftProblem, RefusalReason}; use super::syntax::{literal_message, type_syntax_message}; use crate::services::pending_structure::SaveBlocker; @@ -143,5 +143,44 @@ pub fn build_error_text(error: &BuildDdlError) -> String { BuildDdlError::NotAsReviewed => crate::i18n::gettext( "The changes were edited or the connection changed during the review, so nothing was saved. Save again to review them.", ), + BuildDdlError::InvalidOption { option, value } => invalid_option_text(*option, value), + } +} + +/// A schema or database option the statement cannot carry. An empty +/// value is one left out, or one the engine has no place for. +fn invalid_option_text(option: CatalogOption, value: &str) -> String { + let blank = value.trim().is_empty(); + match option { + CatalogOption::Name if blank => crate::i18n::gettext("Enter a name."), + CatalogOption::Name => crate::i18n::gettext_f( + "“{name}” is longer than this server keeps a name, or holds a character it refuses.", + &[("name", value)], + ), + CatalogOption::Collection if blank => crate::i18n::gettext("Enter the name of the first collection."), + _ if blank => crate::i18n::gettext_f( + "This database takes no {option} for a new database.", + &[("option", &option_name(option))], + ), + _ => crate::i18n::gettext_f( + "“{value}” is not a {option} this database takes.", + &[("value", value), ("option", &option_name(option))], + ), + } +} + +fn option_name(option: CatalogOption) -> String { + match option { + CatalogOption::Name => crate::i18n::pgettext("database option", "name"), + CatalogOption::Owner => crate::i18n::pgettext("database option", "owner"), + CatalogOption::Template => crate::i18n::pgettext("database option", "template"), + CatalogOption::Encoding => crate::i18n::pgettext("database option", "encoding"), + CatalogOption::Locale => crate::i18n::pgettext("database option", "locale"), + CatalogOption::CharacterSet => crate::i18n::pgettext("database option", "character set"), + CatalogOption::Collation => crate::i18n::pgettext("database option", "collation"), + CatalogOption::Cluster => crate::i18n::pgettext("database option", "cluster"), + CatalogOption::DataCenter => crate::i18n::pgettext("database option", "data centre"), + CatalogOption::ReplicationFactor => crate::i18n::pgettext("database option", "replication factor"), + CatalogOption::Collection => crate::i18n::pgettext("database option", "collection"), } } diff --git a/linux/crates/app/src/ui/error_text/driver.rs b/linux/crates/app/src/ui/error_text/driver.rs index e4d39d1757..fbb5089309 100644 --- a/linux/crates/app/src/ui/error_text/driver.rs +++ b/linux/crates/app/src/ui/error_text/driver.rs @@ -50,6 +50,10 @@ fn configuration_message(error: &DriverError) -> String { match error { DriverError::Config(config) => config_message(config), DriverError::Unsupported { feature } => unsupported_message(*feature), + DriverError::DatabaseNotFound { database } => crate::i18n::gettext_f( + "The database “{database}” does not exist on the server.", + &[("database", database)], + ), _ => crate::i18n::gettext("The connection settings cannot be used."), } } diff --git a/linux/crates/app/src/ui/error_text/tests.rs b/linux/crates/app/src/ui/error_text/tests.rs index 036710b034..b10a381970 100644 --- a/linux/crates/app/src/ui/error_text/tests.rs +++ b/linux/crates/app/src/ui/error_text/tests.rs @@ -812,3 +812,42 @@ fn a_column_change_beside_a_convert_says_to_save_it_first() { columns." ); } + +#[test] +fn a_database_that_is_gone_is_named_on_the_connect_page_and_the_banner() { + let gone = DriverError::DatabaseNotFound { + database: "shop".to_owned(), + }; + + assert_eq!( + connect_error_message(&ConnectError::Driver(gone.clone())), + "The database “shop” does not exist on the server." + ); + assert_eq!( + operation_error_message(&OperationError::Driver(gone)), + "The database “shop” does not exist on the server." + ); +} + +#[test] +fn a_catalog_option_the_statement_cannot_carry_says_which() { + use tablepro_core::ddl::{BuildDdlError, CatalogOption}; + + let invalid = |option, value: &str| { + build_error_text(&BuildDdlError::InvalidOption { + option, + value: value.to_owned(), + }) + }; + + assert_eq!(invalid(CatalogOption::Name, " "), "Enter a name."); + assert!(invalid(CatalogOption::Name, "a_very_long_name").contains("a_very_long_name")); + assert_eq!( + invalid(CatalogOption::Collation, "utf8 bin"), + "“utf8 bin” is not a collation this database takes." + ); + assert_eq!( + invalid(CatalogOption::Template, ""), + "This database takes no template for a new database." + ); +} diff --git a/linux/crates/app/src/ui/error_text/unsupported.rs b/linux/crates/app/src/ui/error_text/unsupported.rs index 5ef9cfafc3..d196d60c24 100644 --- a/linux/crates/app/src/ui/error_text/unsupported.rs +++ b/linux/crates/app/src/ui/error_text/unsupported.rs @@ -48,6 +48,7 @@ pub(super) fn unsupported_message(feature: UnsupportedFeature) -> String { 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::DatabaseOptions => gettext("This database lists no options for a new database."), UnsupportedFeature::ObjectDefinitions => gettext("This database cannot show the object's definition."), UnsupportedFeature::ClrRoutine => { gettext("This routine runs code from a .NET assembly, so the server has no SQL for it.") @@ -91,6 +92,7 @@ mod tests { UnsupportedFeature::Keys, UnsupportedFeature::CharacterSets, UnsupportedFeature::DatabaseSwitching, + UnsupportedFeature::DatabaseOptions, UnsupportedFeature::ObjectDefinitions, UnsupportedFeature::ClrRoutine, UnsupportedFeature::EncryptedDefinition, diff --git a/linux/crates/app/src/ui/structure_tab/form_dialog.rs b/linux/crates/app/src/ui/form_dialog.rs similarity index 91% rename from linux/crates/app/src/ui/structure_tab/form_dialog.rs rename to linux/crates/app/src/ui/form_dialog.rs index 5ea8e43f43..cd23c3fc95 100644 --- a/linux/crates/app/src/ui/structure_tab/form_dialog.rs +++ b/linux/crates/app/src/ui/form_dialog.rs @@ -1,7 +1,8 @@ -//! The frame the Add Index and Add Foreign Key forms share: an -//! `adw::Dialog` with Cancel and the suggested submit in its header bar -//! and the fields on a preferences page, as in GNOME Settings' Add -//! dialogs. The HIG keeps AlertDialog for confirmations. +//! The frame the Add Index, Add Foreign Key, New Schema and New +//! Database forms share: an `adw::Dialog` with Cancel and the suggested +//! submit in its header bar and the fields on a preferences page, as in +//! GNOME Settings' Add dialogs. The HIG keeps AlertDialog for +//! confirmations. //! //! The form is checked on every change. Submit is on only while it is //! valid, so Enter, which reaches it as the default widget, does @@ -16,7 +17,7 @@ use gtk4::prelude::*; use libadwaita as adw; use libadwaita::prelude::*; -pub(super) struct FormDialog { +pub(crate) struct FormDialog { /// Weak, since the dialog owns the state the form lives in /// (`give_state`), and a strong reference back would keep both. dialog: glib::WeakRef, @@ -28,7 +29,7 @@ pub(super) struct FormDialog { impl FormDialog { /// The form, and the dialog it is drawn in, which nothing else holds /// until it is presented. - pub(super) fn new(title: &str, submit_label: &str) -> (Self, adw::Dialog) { + pub(crate) fn new(title: &str, submit_label: &str) -> (Self, adw::Dialog) { let dialog = adw::Dialog::builder() .title(title) .content_width(420) @@ -84,7 +85,7 @@ impl FormDialog { /// An entry row that submits the form on Enter. A name typed into it /// is never markup. - pub(super) fn entry_row(title: &str) -> adw::EntryRow { + pub(crate) fn entry_row(title: &str) -> adw::EntryRow { adw::EntryRow::builder() .title(title) .use_markup(false) @@ -95,7 +96,7 @@ impl FormDialog { /// Submit on only for a valid form, and `problem` under the fields. /// A new problem is said: Enter does nothing until it is fixed, and /// the caption sits below the field that has the focus. - pub(super) fn show_state(&self, valid: bool, problem: Option) { + pub(crate) fn show_state(&self, valid: bool, problem: Option) { self.submit.set_sensitive(valid); match problem { Some(problem) => { @@ -111,11 +112,11 @@ impl FormDialog { } #[cfg(test)] - pub(super) fn caption(&self) -> Option { + pub(crate) fn caption(&self) -> Option { self.caption.is_visible().then(|| self.caption.label().to_string()) } - pub(super) fn close(&self) { + pub(crate) fn close(&self) { if let Some(dialog) = self.dialog.upgrade() { dialog.close(); } @@ -126,14 +127,14 @@ impl FormDialog { /// or with its window. The form's handlers hold the state weakly, and /// the row that opens a form keeps nothing, so without this every /// control would stop answering as soon as the form opened. -pub(super) fn give_state(dialog: &adw::Dialog, state: Rc) { +pub(crate) fn give_state(dialog: &adw::Dialog, state: Rc) { // Never disconnected: it runs once, as the dialog is disposed. let _owner = dialog.add_weak_ref_notify_local(move || drop(state)); } /// The `error` style on a field the problem shown is about, and the /// problem on the field a screen reader is on. -pub(super) fn mark_invalid(widget: &impl IsA, problem: Option<&str>) { +pub(crate) fn mark_invalid(widget: &impl IsA, problem: Option<&str>) { match problem { Some(_) => widget.add_css_class("error"), None => widget.remove_css_class("error"), @@ -142,7 +143,7 @@ pub(super) fn mark_invalid(widget: &impl IsA, problem: Option<&str> } #[cfg(test)] -pub(super) mod tests { +pub(crate) mod tests { use std::time::Duration; use super::*; diff --git a/linux/crates/app/src/ui/mod.rs b/linux/crates/app/src/ui/mod.rs index 8140ea2bb8..cc9002932e 100644 --- a/linux/crates/app/src/ui/mod.rs +++ b/linux/crates/app/src/ui/mod.rs @@ -22,6 +22,7 @@ mod file_read; pub(crate) mod file_reveal; mod file_write; mod filter_strip; +mod form_dialog; mod format; mod handler_guard; mod history_dialog; diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index f56ba8b03c..506fbbd908 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -470,6 +470,12 @@ impl SessionWindow { SessionWindowInput::Disconnect => self.on_disconnect_front(), SessionWindowInput::DisconnectSession(key) => self.on_disconnect(key), SessionWindowInput::ChooseDatabaseIn(key) => self.choose_database_in(key), + SessionWindowInput::NewDatabaseIn(key) => self.deliver_to(&key, WorkspaceInput::NewDatabasePrompt), + SessionWindowInput::DropDatabaseIn(key) => self.drop_database_in(key), + SessionWindowInput::ReleaseDatabase { target, options } => self.release_database(target, options), + SessionWindowInput::DatabaseDropFinished(target) => self.lift_reservation(&target), + SessionWindowInput::DatabasesChanged(key) => self.databases_changed(&key), + SessionWindowInput::OpenDatabaseIn { key, database } => self.open_database_in(&key, database), SessionWindowInput::MovedTab(asked) => self.on_moved_tab(asked), SessionWindowInput::SessionTeardownApproved(key) => self.on_session_teardown_approved(&key), SessionWindowInput::ShowSession(key) => self.show_session(&key), @@ -484,16 +490,8 @@ impl SessionWindow { SessionWindowInput::SetSafeMode { key, level } => self.on_set_safe_mode(&key, level), SessionWindowInput::ReopenApproved(id) => self.on_reopen_approved(id), SessionWindowInput::CloseCurrent => self.on_close_current(), - SessionWindowInput::Reconnect => { - if let Some((_, session)) = self.front_session() { - session.signal(MonitorSignal::RetryNow); - } - } - SessionWindowInput::ResetWorkspaceLayout => { - if let Some(input) = self.workspace().and_then(|workspace| workspace.sender()) { - input.deliver(WorkspaceInput::ResetWorkspaceLayout); - } - } + SessionWindowInput::Reconnect => self.on_reconnect(), + SessionWindowInput::ResetWorkspaceLayout => self.on_reset_workspace_layout(), SessionWindowInput::CloseRequested(CloseReason::Quit(reply)) => { if let Some(guards) = self.close_guards() { guards.request_window_close(self, move |decision: CloseDecision| { @@ -535,6 +533,19 @@ impl SessionWindow { } } + /// The health banner's Retry, for the session in front. + fn on_reconnect(&self) { + if let Some((_, session)) = self.front_session() { + session.signal(MonitorSignal::RetryNow); + } + } + + fn on_reset_workspace_layout(&self) { + if let Some(input) = self.workspace().and_then(|workspace| workspace.sender()) { + input.deliver(WorkspaceInput::ResetWorkspaceLayout); + } + } + fn show_toast(&self, message: &str) { self.add_toast(crate::ui::plain_toast(message)); } diff --git a/linux/crates/app/src/ui/session_window/other_databases.rs b/linux/crates/app/src/ui/session_window/other_databases.rs index a55fb12d59..1bb5e79a82 100644 --- a/linux/crates/app/src/ui/session_window/other_databases.rs +++ b/linux/crates/app/src/ui/session_window/other_databases.rs @@ -7,11 +7,14 @@ use std::sync::Arc; +use futures::FutureExt; +use futures::channel::oneshot; use gtk4::prelude::*; use gtk4::subclass::prelude::*; use gtk4::{self as gtk, glib}; use libadwaita as adw; use libadwaita::prelude::*; +use tablepro_core::ddl::DropDatabaseOptions; use tablepro_core::dialect::DatabaseLayout; use tablepro_core::meta::SchemaInfo; use tablepro_session::operation::OperationKind; @@ -20,19 +23,60 @@ use tokio_util::sync::{CancellationToken, DropGuard}; use uuid::Uuid; use super::session_page::OpenSession; +use super::session_teardown::SessionClosing; use super::{MovedTab, SessionWindow}; use crate::i18n::{gettext, gettext_f}; use crate::runtime::DeliverInput; use crate::ui::error_text::{connect_error_message, operation_error_message}; -use crate::ui::workspace::{Workspace, WorkspaceInput}; +use crate::ui::workspace::{DatabaseDrop, Workspace, WorkspaceInput}; +use crate::workspace::CloseDecision; /// The response of the chooser that opens the database picked. const OPEN: &str = "open"; +/// The response of the chooser that goes on to ask about dropping the +/// database picked. +const CONTINUE: &str = "continue"; + +/// What a chooser of the server's databases asks. +struct Chooser { + heading: String, + body: String, + response: &'static str, + label: String, +} + +/// A drop a session asked for, held until the window has let go of the +/// database. +struct Release { + target: DatabaseDrop, + options: DropDatabaseOptions, +} + +impl Release { + /// Only the session that confirmed the drop gets it back. One that + /// went meanwhile took the drop's hold on the database with it. + fn hand_back(self, window: &SessionWindow) { + let workspace = window.find_open(|open| { + (open.claim.key() == &self.target.key && self.target.confirmed_by(&open.session)) + .then(|| open.workspace.clone()) + }); + match workspace.and_then(|workspace| workspace.sender()) { + Some(input) => input.deliver(WorkspaceInput::DropDatabaseReleased { + target: self.target, + options: self.options, + }), + None => tracing::debug!("the session that asked to drop a database is gone, so nothing is dropped"), + } + } +} #[derive(Default)] pub(crate) struct OtherDatabases { pub(super) open: Vec, opening: Vec, + /// Databases on their way to a drop, which nothing opens until it + /// ends. They go with the connection's session. + dropping: Vec, } /// A database still opening. Dropping it stops the open and lets its @@ -41,6 +85,9 @@ struct Opening { database: String, claim: SessionClaim, _stop: DropGuard, + /// Answers once the open has ended and closed what a stopped open + /// still connected. + settled: oneshot::Receiver<()>, } impl OtherDatabases { @@ -70,6 +117,39 @@ impl OtherDatabases { let position = self.opening.iter().position(|opening| opening.database == database)?; Some(self.opening.remove(position).claim) } + + /// Take the open of `database` out, which stops it once dropped. + fn stop_opening(&mut self, database: &str) -> Option { + let position = self.opening.iter().position(|opening| opening.database == database)?; + Some(self.opening.remove(position)) + } + + fn reserve(&mut self, database: &str) { + if !self.is_dropping(database) { + self.dropping.push(database.to_owned()); + } + } + + fn lift(&mut self, database: &str) { + self.dropping.retain(|name| name != database); + } + + fn is_dropping(&self, database: &str) -> bool { + self.dropping.iter().any(|name| name == database) + } +} + +fn shut_down(session: Arc) -> SessionClosing { + session.begin_shutdown().map(drop).boxed_local() +} + +/// Whether `key` is a session of the connection `of` is, or `of` itself +/// where it names no saved connection. +fn same_connection(key: &SessionKey, of: &SessionKey) -> bool { + match (connection_of(key), connection_of(of)) { + (Some(key), Some(of)) => key == of, + _ => key == of, + } } /// The saved connection a session belongs to, its own or another of @@ -93,6 +173,21 @@ pub(super) fn offered(databases: &[SchemaInfo], open: &[String]) -> Vec offered.into_iter().map(|database| database.name.clone()).collect() } +/// The databases a drop can offer: neither the connection's own, the +/// server's, nor one the engine keeps for itself. +pub(super) fn droppable(databases: &[SchemaInfo], own: Option<&str>, protects: impl Fn(&str) -> bool) -> Vec { + databases + .iter() + .filter(|database| { + !database.is_default + && !database.is_system + && own != Some(database.name.as_str()) + && !protects(&database.name) + }) + .map(|database| database.name.clone()) + .collect() +} + impl SessionWindow { /// Open the connection `id`'s database `database` beside it, with /// the stored secret and the connection's Safe Mode level. One open @@ -105,7 +200,8 @@ impl SessionWindow { let origin = self.find_open(|open| { let wanted = open.claim.key() == &connection && open.session.database() != Some(database.as_str()) - && !open.databases.names().contains(&database); + && !open.databases.names().contains(&database) + && !open.databases.is_dropping(&database); wanted.then(|| open.session.clone()) }); let Some(origin) = origin else { @@ -118,6 +214,7 @@ impl SessionWindow { return; }; let stop = CancellationToken::new(); + let (settle, settled) = oneshot::channel(); { let mut sessions = self.imp().sessions.borrow_mut(); let Some(open) = sessions.iter_mut().find(|open| open.claim.key() == &connection) else { @@ -127,6 +224,7 @@ impl SessionWindow { database: database.clone(), claim, _stop: stop.clone().drop_guard(), + settled, }); } let opening = { @@ -139,27 +237,28 @@ impl SessionWindow { let window = self.downgrade(); glib::spawn_future_local(async move { let opened = opening.await; - match window.upgrade() { + let closing = match window.upgrade() { Some(window) => window.on_database_opened(id, &origin, database, opened), - None => { - if let Ok(Ok(session)) = opened { - drop(session.begin_shutdown()); - } - } + None => opened.ok().and_then(Result::ok).map(shut_down), + }; + if let Some(closing) = closing { + closing.await; } + let _ = settle.send(()); }); } /// The claim is still the connection's only while the session that /// asked holds it: a teardown, or a reconnect that replaced it, - /// dropped the open with it. + /// dropped the open with it. A session an open stopped too late + /// still made is closed, which the drop that stopped it waits for. fn on_database_opened( &self, id: Uuid, origin: &Arc, database: String, opened: Result, ConnectError>, tablepro_session::runtime::TaskFailure>, - ) { + ) -> Option { let claim = self .imp() .sessions @@ -174,7 +273,7 @@ impl SessionWindow { session.set_safe_mode(origin.safe_mode()); self.join_database(id, claim, session); } - (None, Ok(Ok(session))) => drop(session.begin_shutdown()), + (None, Ok(Ok(session))) => return Some(shut_down(session)), (Some(_), Ok(Err(error))) if !matches!(error, ConnectError::Cancelled) => { // A database restored at launch that no longer opens is // not tried again at the next one. @@ -190,6 +289,7 @@ impl SessionWindow { (_, Ok(Err(_))) => {} (_, Err(failure)) => tracing::warn!(%failure, "opening another database failed"), } + None } /// Build the database's workspace in the shell and give it to the @@ -337,40 +437,267 @@ impl SessionWindow { fn ask_database(&self, id: Uuid, databases: &[SchemaInfo]) { let open = self - .find_open(|open| (open.claim.key() == &SessionKey::Saved(id)).then(|| open.databases.names())) + .find_open(|open| { + (open.claim.key() == &SessionKey::Saved(id)).then(|| { + let mut taken = open.databases.names(); + taken.extend(open.databases.dropping.iter().cloned()); + taken + }) + }) .unwrap_or_default(); let names = offered(databases, &open); if names.is_empty() { self.show_toast(&gettext("No other database of this server to open")); return; } + let chooser = Chooser { + heading: gettext("Open Database"), + body: gettext("It opens beside the connection, with tables and tabs of its own."), + response: OPEN, + label: gettext("Open"), + }; + self.ask_which(chooser, names, move |window, database| { + // A drop can have started while the chooser was open. + match window.is_dropping(id, &database) { + true => window.show_toast(&gettext_f( + "“{database}” is being dropped, so it cannot open.", + &[("database", &database)], + )), + false => window.open_database(id, database), + } + }); + } + + /// Whether the connection `id`'s database `database` is on its way to + /// a drop. + fn is_dropping(&self, id: Uuid, database: &str) -> bool { + self.find_open(|open| { + (open.claim.key() == &SessionKey::Saved(id)).then(|| open.databases.is_dropping(database)) + }) + .unwrap_or(false) + } + + /// Ask which of `names` to act on, in an alert with a drop-down. + fn ask_which(&self, chooser: Chooser, names: Vec, picked: impl Fn(&SessionWindow, String) + 'static) { let strings: Vec<&str> = names.iter().map(String::as_str).collect(); let choice = gtk::DropDown::from_strings(&strings); choice.update_property(&[gtk::accessible::Property::Label(&gettext("Database"))]); let dialog = adw::AlertDialog::builder() - .heading(gettext("Open Database")) - .body(gettext( - "It opens beside the connection, with tables and tabs of its own.", - )) + .heading(chooser.heading) + .body(chooser.body) .extra_child(&choice) - .default_response(OPEN) + .default_response(chooser.response) .close_response("cancel") .build(); - dialog.add_responses(&[("cancel", &gettext("Cancel")), (OPEN, &gettext("Open"))]); - dialog.set_response_appearance(OPEN, adw::ResponseAppearance::Suggested); + dialog.add_responses(&[("cancel", &gettext("Cancel")), (chooser.response, &chooser.label)]); + dialog.set_response_appearance(chooser.response, adw::ResponseAppearance::Suggested); let window = self.downgrade(); - dialog.connect_response(None, move |_, response| { - let picked = usize::try_from(choice.selected()) + let response = chooser.response; + dialog.connect_response(None, move |_, answered| { + let database = usize::try_from(choice.selected()) .ok() .and_then(|at| names.get(at)) .cloned(); - if let (OPEN, Some(window), Some(database)) = (response, window.upgrade(), picked) { - window.open_database(id, database); + if let (true, Some(window), Some(database)) = (answered == response, window.upgrade(), database) { + picked(&window, database); } }); dialog.present(Some(self)); } + /// Drop Database… on a connection's row asks which of the server's + /// databases first; on the row of a database open beside it, that + /// one is meant. The connection's own session asks and drops. + pub(super) fn drop_database_in(&self, key: SessionKey) { + match key { + SessionKey::SavedIn { id, database } => { + self.deliver_to(&SessionKey::Saved(id), WorkspaceInput::DropDatabasePrompt(database)); + } + SessionKey::Saved(id) => self.choose_database_to_drop(id), + SessionKey::File(_) => {} + } + } + + fn choose_database_to_drop(&self, id: Uuid) { + let key = SessionKey::Saved(id); + let (Some(session), Some(state)) = (self.held_session(&key), self.state()) else { + return; + }; + let listed = session.clone(); + let listing = state.services.tasks.spawn_task(async move { + listed + .run(OperationKind::Metadata, |lease, options| async move { + Ok(lease.list_databases(options).await?) + }) + .await + }); + let window = self.downgrade(); + glib::spawn_future_local(async move { + let listed = listing.await; + let Some(window) = window.upgrade() else { + return; + }; + match listed { + Ok(Ok(databases)) => window.ask_database_to_drop(id, &session, &databases), + Ok(Err(error)) => window.show_toast(&operation_error_message(&error)), + Err(failure) => tracing::warn!(%failure, "listing the server's databases failed"), + } + }); + } + + fn ask_database_to_drop(&self, id: Uuid, session: &Session, databases: &[SchemaInfo]) { + let Some(dialect) = session.current_dialect() else { + return; + }; + let names = droppable(databases, session.database(), |name| { + tablepro_core::ddl::protects_database(&*dialect, name) + }); + if names.is_empty() { + self.show_toast(&gettext("No database of this server can be dropped from here")); + return; + } + let chooser = Chooser { + heading: gettext("Drop Database"), + body: gettext("Nothing is dropped until you confirm on the next step."), + response: CONTINUE, + label: gettext("Continue"), + }; + self.ask_which(chooser, names, move |window, database| { + window.deliver_to(&SessionKey::Saved(id), WorkspaceInput::DropDatabasePrompt(database)); + }); + } + + /// Close the session the window holds on the database a session is + /// dropping, through that session's own prompts and down to its last + /// connection, then hand the drop back to the session that asked. From + /// now until the drop ends nothing opens the database. A prompt + /// answered Cancel drops nothing (0033). + pub(super) fn release_database(&self, target: DatabaseDrop, options: DropDatabaseOptions) { + let release = Release { target, options }; + let Some(id) = connection_of(&release.target.key) else { + release.hand_back(self); + return; + }; + let Some(stopped) = self.reserve_for_drop(&release.target) else { + tracing::debug!("the session that asked to drop a database is gone, so nothing is dropped"); + return; + }; + let sibling = SessionKey::SavedIn { + id, + database: release.target.database.clone(), + }; + if !self.holds_session(&sibling) { + self.hand_back_after(stopped, release); + return; + } + let (Some(handles), Some(guards)) = (self.teardown_handles(&sibling), self.close_guards()) else { + self.lift_reservation(&release.target); + return; + }; + guards.begin_teardown(); + if guards.may_tear_down(&handles) { + self.take_down_then_drop(&sibling, release); + return; + } + let window = self.downgrade(); + guards.ask_tear_down(self.upcast_ref(), &handles, move |decision| { + let Some(window) = window.upgrade() else { + return; + }; + match decision { + CloseDecision::Approved => window.take_down_then_drop(&sibling, release), + CloseDecision::Cancelled => window.lift_reservation(&release.target), + } + }); + } + + fn take_down_then_drop(&self, sibling: &SessionKey, release: Release) { + match self.take_down_session(sibling) { + Some(closing) => self.hand_back_after(closing, release), + None => release.hand_back(self), + } + } + + /// Hand the drop back once `closed` answers: what the window held on + /// the database has closed its connections. + fn hand_back_after(&self, closed: SessionClosing, release: Release) { + let window = self.downgrade(); + glib::spawn_future_local(async move { + closed.await; + if let Some(window) = window.upgrade() { + release.hand_back(&window); + } + }); + } + + /// Hold the database for the drop on the session that asked, and stop + /// an open of it still running. What answers once that open has ended, + /// or `None` when the session that asked is gone. + fn reserve_for_drop(&self, target: &DatabaseDrop) -> Option { + let stopped = { + let mut sessions = self.imp().sessions.try_borrow_mut().ok()?; + let owner = sessions + .iter_mut() + .find(|open| open.claim.key() == &target.key && target.confirmed_by(&open.session))?; + owner.databases.reserve(&target.database); + owner.databases.stop_opening(&target.database) + }; + // Its claim and its stop go here, outside the borrow of the + // sessions, which letting a claim go can read. + let settled: SessionClosing = match stopped { + Some(Opening { settled, .. }) => settled.map(drop).boxed_local(), + None => Box::pin(std::future::ready(())), + }; + Some(settled) + } + + /// The drop ended or will not run, so the database may open again. + /// Only the session that asked holds it. + pub(super) fn lift_reservation(&self, target: &DatabaseDrop) { + let Ok(mut sessions) = self.imp().sessions.try_borrow_mut() else { + return; + }; + let owner = sessions + .iter_mut() + .find(|open| open.claim.key() == &target.key && target.confirmed_by(&open.session)); + if let Some(owner) = owner { + owner.databases.lift(&target.database); + } + } + + /// Every session of the connection under `key` lists the server's + /// databases again. + pub(super) fn databases_changed(&self, key: &SessionKey) { + let workspaces: Vec = self + .imp() + .sessions + .try_borrow() + .map(|sessions| { + sessions + .iter() + .flat_map(OpenSession::with_databases) + .filter(|open| same_connection(open.claim.key(), key)) + .map(|open| open.workspace.clone()) + .collect() + }) + .unwrap_or_default(); + for workspace in workspaces { + if let Some(input) = workspace.sender() { + input.deliver(WorkspaceInput::DatabasesChanged); + } + } + } + + /// A database just created opens beside the connection where its + /// catalog lists only its own database, so it shows (0033). + pub(super) fn open_database_in(&self, key: &SessionKey, database: String) { + if let Some(id) = connection_of(key) + && self.opens_databases(id) + { + self.open_database(id, database); + } + } + /// The session the window holds under `key`. pub(super) fn held_session(&self, key: &SessionKey) -> Option> { self.find_open(|open| (open.claim.key() == key).then(|| open.session.clone())) diff --git a/linux/crates/app/src/ui/session_window/session_teardown.rs b/linux/crates/app/src/ui/session_window/session_teardown.rs index af4cf66278..be33b69057 100644 --- a/linux/crates/app/src/ui/session_window/session_teardown.rs +++ b/linux/crates/app/src/ui/session_window/session_teardown.rs @@ -9,6 +9,8 @@ use std::sync::Arc; use std::time::Duration; +use futures::FutureExt; +use futures::future::LocalBoxFuture; use gtk4 as gtk; use gtk4::glib; use gtk4::prelude::*; @@ -22,6 +24,10 @@ use super::SessionWindow; use super::session_page::OpenSession; use crate::ui::workspace::WorkspaceShell; +/// A session's close and its editor tabs', which a drop of its database +/// waits for. Dropping it lets the close run on unwatched. +pub(super) type SessionClosing = LocalBoxFuture<'static, ()>; + impl SessionWindow { /// Take every session down once the close prompts approved, each /// closed under one hold. Their pages stay in the shell, which goes @@ -47,7 +53,7 @@ impl SessionWindow { self.set_title(Some(&crate::i18n::gettext("TablePro"))); let shell = imp.shell.take(); for open in sessions { - self.close_open_session(None, open, false); + drop(self.close_open_session(None, open, false)); } if let Some(shell) = shell { shell.shutdown(); @@ -61,23 +67,27 @@ impl SessionWindow { /// it. With the last one the shell goes too. False when the window /// holds no such session. pub(super) fn tear_down_session(&self, key: &SessionKey) -> bool { + self.take_down_session(key).is_some() + } + + /// As `tear_down_session`, with the close of the session's + /// connections to wait for. + pub(super) fn take_down_session(&self, key: &SessionKey) -> Option { let imp = self.imp(); let open = { let Ok(mut sessions) = imp.sessions.try_borrow_mut() else { tracing::warn!("a session was torn down while the sessions were read"); - return false; + return None; }; match sessions.iter().position(|open| open.claim.key() == key) { Some(position) => Some(sessions.remove(position)), None => sessions.iter_mut().find_map(|open| open.databases.take(key)), } }; - let Some(open) = open else { - return false; - }; + let open = open?; open.flush(); let shell = self.shell(); - self.close_open_session(shell.as_ref(), open, true); + let closing = self.close_open_session(shell.as_ref(), open, true); if imp.sessions.borrow().is_empty() { if let Some(shell) = imp.shell.take() { shell.shutdown(); @@ -88,16 +98,21 @@ impl SessionWindow { } self.refresh_dirty(); self.render(); - true + Some(closing) } /// Close the other databases open beside `open` and stop those still /// opening, then take `open`'s workspace off the shell when one is /// given, shut it down, drop its claim and record, and close its /// session. Its pages leave the tab view when `close_pages`. - fn close_open_session(&self, shell: Option<&WorkspaceShell>, open: OpenSession, close_pages: bool) { + fn close_open_session( + &self, + shell: Option<&WorkspaceShell>, + open: OpenSession, + close_pages: bool, + ) -> SessionClosing { let Some(state) = self.state() else { - return; + return Box::pin(std::future::ready(())); }; let OpenSession { claim, @@ -105,9 +120,11 @@ impl SessionWindow { workspace, databases, } = open; - for database in databases.close() { - self.close_open_session(shell, database, close_pages); - } + let mut closings: Vec = databases + .close() + .into_iter() + .map(|database| self.close_open_session(shell, database, close_pages)) + .collect(); let editor_sessions = workspace.take_editor_sessions(); let key = claim.key().clone(); if let Some(shell) = shell { @@ -119,27 +136,30 @@ impl SessionWindow { // A quit freezes the record first, so only a disconnect or a // window closed by hand takes the session off it. self.forget_session(&key); - close_session( + let closed = close_session( session, editor_sessions, &*state.services.hold(), &state.services.tasks, state.context.close_timeout(), ); + closings.push(closed.map(drop).boxed_local()); + futures::future::join_all(closings).map(drop).boxed_local() } } /// Close the session and every editor tab's connection together, under /// the close deadline. One hold keeps the process alive until all of /// them end, so quitting right after a disconnect closes them rather -/// than cutting them off. +/// than cutting them off. The handle answers once they have; dropping +/// it leaves the close running. fn close_session( session: Arc, editor_sessions: Vec>, hold: &dyn ProcessHold, tasks: &Tasks, close_timeout: Duration, -) { +) -> glib::JoinHandle<()> { let hold = hold.acquire(); let editors_closing = tablepro_session::close_editor_sessions(editor_sessions, close_timeout, tasks); let closing = session.begin_shutdown(); @@ -155,7 +175,7 @@ fn close_session( Err(failure) => tracing::warn!(%failure, "closing the editor tabs' sessions failed"), } drop(hold); - }); + }) } #[cfg(test)] diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs index 0e23a1d9e9..c0325bbbf1 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -2,6 +2,7 @@ use gtk4::glib; use libadwaita as adw; use tablepro_core::SafeModeLevel; use tablepro_core::credentials::SecretKind; +use tablepro_core::ddl::DropDatabaseOptions; use tablepro_session::SessionKey; use tablepro_storage::{ConnectionColor, ConnectionGroupName, ConnectionTag, ConnectionTagName, SavedConnection}; use uuid::Uuid; @@ -15,6 +16,7 @@ use super::resolve_file_identity::ResolvedFile; use super::session_window_command::SessionWindowCommand; use crate::services::connection_deletion::DeletionOptions; use crate::ui::connect_dialog::ConnectSubmission; +use crate::ui::workspace::DatabaseDrop; use crate::workspace::{CloseReason, CompletedWork}; #[derive(Debug)] @@ -75,6 +77,32 @@ pub(crate) enum SessionWindowInput { /// Open Database… on a connection's row: pick another of its server's /// databases to open beside it. ChooseDatabaseIn(SessionKey), + /// New Database… on a connection's row. + NewDatabaseIn(SessionKey), + /// Drop Database… on a connection's row, which asks which one first, + /// or on the row of a database open beside its connection. + DropDatabaseIn(SessionKey), + /// A session is about to drop a database. The window first closes + /// the session it holds on that database, through that session's own + /// prompts, then hands the drop back to the session that asked, and + /// to no other (0033). + ReleaseDatabase { + target: DatabaseDrop, + options: DropDatabaseOptions, + }, + /// The drop the window let go of a database for ran or failed, so the + /// database may open again. + DatabaseDropFinished(DatabaseDrop), + /// A database of the connection under the key was created or + /// dropped, so each of the connection's sessions lists them again. + DatabasesChanged(SessionKey), + /// Open `database` beside the connection under `key`, as a new one + /// shows nowhere else where the connection's catalog holds its own + /// database only. + OpenDatabaseIn { + key: SessionKey, + database: String, + }, MovedTab(MovedTab), /// The prompts approved closing the session under the key. SessionTeardownApproved(SessionKey), diff --git a/linux/crates/app/src/ui/session_window/tests/other_databases.rs b/linux/crates/app/src/ui/session_window/tests/other_databases.rs index bb89c6a535..cb16f4734e 100644 --- a/linux/crates/app/src/ui/session_window/tests/other_databases.rs +++ b/linux/crates/app/src/ui/session_window/tests/other_databases.rs @@ -1,14 +1,19 @@ //! A connection's other databases open beside it and live and go with //! its session (0030). +use std::time::Duration; + use gtk4::prelude::*; +use libadwaita::prelude::*; +use tablepro_core::WriteReport; use tablepro_session::SessionKey; use tablepro_session::fakes::FakeEstablish; -use tablepro_test_support::{ConnectionScript, FakeReply}; +use tablepro_test_support::{CallCounters, CloseBehaviour, ConnectionScript, FakeReply}; use super::{Script, TestWindow, WAIT, editors, pick_level, refused}; use crate::test_support::{drain_main_context, wait_until}; use crate::ui::SessionWindowInput; +use crate::workspace::{answer, visible_alert}; const ANALYTICS: &str = "analytics"; @@ -64,11 +69,15 @@ fn analytics(test: &TestWindow) -> SessionKey { /// A connection that answers what a workspace asks as it opens. fn answering() -> FakeEstablish { + FakeEstablish::Connect(answering_script()) +} + +fn answering_script() -> ConnectionScript { let mut connection = ConnectionScript::default(); connection.probe.always(FakeReply::ok(())); connection.list_schemas.always(FakeReply::ok(Vec::new())); connection.list_objects.always(FakeReply::ok(Vec::new())); - FakeEstablish::Connect(connection) + connection } /// The connection connects; another of its databases never answers @@ -291,3 +300,228 @@ fn a_tab_moved_where_the_catalog_lists_every_database_opens_nothing() { assert_eq!(test.connector.calls().len(), 1, "a session opened for the database"); assert_eq!(tabs[0].followed_catalog(), None); } + +fn postgres() -> std::sync::Arc { + std::sync::Arc::new(tablepro_driver_postgres::PgDialect::new( + &tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0), + )) +} + +/// A PostgreSQL connection that writes what it is sent and answers what +/// a workspace asks as it opens. +fn writing_postgres() -> ConnectionScript { + let mut connection = answering_script(); + connection.dialect = postgres(); + connection + .write_batch + .always(FakeReply::ok(WriteReport::new(vec![None]))); + connection +} + +/// A PostgreSQL connection whose own session writes what it is sent, +/// and whose `ANALYTICS` takes `close` to close. `later` answers the +/// connects after those two. +fn dropping_window(name: &str, close: CloseBehaviour, later: Vec) -> TestWindow { + let mut database = answering_script(); + database.dialect = postgres(); + database.close = close; + let mut establish = vec![ + FakeEstablish::Connect(writing_postgres()), + FakeEstablish::Connect(database), + ]; + establish.extend(later); + network_window_with( + name, + Script { + establish, + connection: ConnectionScript { + dialect: postgres(), + ..ConnectionScript::default() + }, + ..Script::default() + }, + ) +} + +/// Open `ANALYTICS` beside the connection, with an unsaved edit in it +/// when `edited`, then confirm Drop Database on its row. +fn confirm_dropping_analytics(test: &TestWindow, edited: bool) -> SessionKey { + connect(test); + let sibling = analytics(test); + test.window.open_database(test.saved.id, ANALYTICS.to_owned()); + wait_until(WAIT, || test.window.held_session(&sibling).is_some()).expect("the database to open"); + if edited { + let held = test + .window + .workspaces() + .into_iter() + .find(|workspace| workspace.key().as_ref() == Some(&sibling)) + .expect("the database's workspace"); + super::sessions::edit_a_table(&held, "events"); + } + + test.window.send(SessionWindowInput::DropDatabaseIn(sibling.clone())); + wait_for_alert(test, "Drop Database “analytics”?"); + assert!(answer(&test.window, "drop")); + sibling +} + +fn wait_for_alert(test: &TestWindow, heading: &str) { + wait_until(WAIT, || { + visible_alert(&test.window).is_some_and(|alert| alert.heading().as_deref() == Some(heading)) + }) + .unwrap_or_else(|_| panic!("no alert {heading:?}")); +} + +fn sent_drop(connection: &tablepro_test_support::FakeConnection) -> bool { + connection + .observed_sql("write_batch") + .iter() + .flatten() + .any(|sql| sql.contains("DROP DATABASE \"analytics\"")) +} + +/// The server refuses to drop a database a session is on, so the +/// window's own session there closes, down to its last connection, +/// before the DROP leaves (0033). +#[gtk4::test] +fn dropping_another_database_closes_it_through_its_prompts_first() { + let test = dropping_window( + "DropOtherDatabase", + CloseBehaviour::Delay(Duration::from_millis(300)), + Vec::new(), + ); + let sibling = confirm_dropping_analytics(&test, true); + wait_for_alert(&test, "Save Changes?"); + let connections = test.connector.connections(); + let (connection, database) = (connections[0].clone(), connections[1].clone()); + + assert!(answer(&test.window, "discard")); + + wait_until(WAIT, || sent_drop(&connection)).expect("the drop"); + assert!( + database.close_finished(), + "the drop left before the database's session closed" + ); + assert!(test.window.held_session(&sibling).is_none()); + assert_eq!(test.services.sessions.window_for(&sibling), None); +} + +#[gtk4::test] +fn a_cancelled_prompt_drops_nothing() { + let test = dropping_window("DropOtherDatabaseCancelled", CloseBehaviour::Immediate, Vec::new()); + let sibling = confirm_dropping_analytics(&test, true); + wait_for_alert(&test, "Save Changes?"); + + assert!(answer(&test.window, "cancel")); + wait_until(WAIT, || visible_alert(&test.window).is_none()).expect("the prompt to close"); + drain_main_context(); + + let connection = test.connector.connections()[0].clone(); + assert_eq!(CallCounters::get(&connection.counters().write_batch), 0); + assert!( + test.window.held_session(&sibling).is_some(), + "a cancelled prompt closed the database" + ); +} + +/// The connection disconnected and connected again while the database +/// was closing: the new session, perhaps to another server by now, was +/// never asked to drop anything. +#[gtk4::test] +fn a_drop_never_runs_on_a_session_opened_again_under_the_same_key() { + let test = dropping_window( + "DropAfterReconnect", + CloseBehaviour::Delay(Duration::from_secs(3)), + vec![FakeEstablish::Connect(writing_postgres())], + ); + confirm_dropping_analytics(&test, false); + let database = test.connector.connections()[1].clone(); + wait_until(WAIT, || database.is_closed()).expect("the database to start closing"); + + test.window.send(SessionWindowInput::DisconnectSession(test.key())); + test.wait_for_page("picker"); + connect(&test); + assert!( + !database.close_finished(), + "the database closed before the connection came back, so nothing was tested" + ); + + wait_until(WAIT, || database.close_finished()).expect("the database to close"); + let dropped = wait_until(Duration::from_millis(500), || { + test.connector + .connections() + .iter() + .any(|connection| sent_drop(connection)) + }); + assert!(dropped.is_err(), "a drop confirmed on the old session ran"); +} + +/// Neither Open Database… nor a tab moving there opens a database on its +/// way to a drop; once the drop has run, it opens again. +#[gtk4::test] +fn a_database_on_its_way_to_a_drop_does_not_open_again() { + let mut reopened = answering_script(); + reopened.dialect = postgres(); + let test = dropping_window( + "DropReservesTheDatabase", + CloseBehaviour::Delay(Duration::from_secs(2)), + vec![FakeEstablish::Connect(reopened)], + ); + let sibling = confirm_dropping_analytics(&test, false); + wait_until(WAIT, || test.window.held_session(&sibling).is_none()).expect("the database to close"); + let connection = test.connector.connections()[0].clone(); + let connects = test.connector.calls().len(); + + test.window.open_database(test.saved.id, ANALYTICS.to_owned()); + drain_main_context(); + + assert_eq!( + test.connector.calls().len(), + connects, + "the database opened while it was being dropped" + ); + assert_eq!(test.services.sessions.window_for(&sibling), None); + wait_until(WAIT, || sent_drop(&connection)).expect("the drop"); + wait_until(WAIT, || { + test.window.open_database(test.saved.id, ANALYTICS.to_owned()); + test.window.held_session(&sibling).is_some() + }) + .expect("the database to open once the drop ran"); +} + +#[test] +fn a_drop_offers_neither_the_connections_database_nor_the_servers_own() { + let databases = [ + tablepro_core::meta::SchemaInfo { + name: "postgres".to_owned(), + is_default: true, + is_system: false, + }, + tablepro_core::meta::SchemaInfo { + name: "template1".to_owned(), + is_default: false, + is_system: false, + }, + tablepro_core::meta::SchemaInfo { + name: "rdsadmin".to_owned(), + is_default: false, + is_system: true, + }, + tablepro_core::meta::SchemaInfo { + name: "shop".to_owned(), + is_default: false, + is_system: false, + }, + tablepro_core::meta::SchemaInfo { + name: "analytics".to_owned(), + is_default: false, + is_system: false, + }, + ]; + + let offered = + super::super::other_databases::droppable(&databases, Some("analytics"), |name| name.starts_with("template")); + + assert_eq!(offered, ["shop"]); +} diff --git a/linux/crates/app/src/ui/session_window/tests/sessions.rs b/linux/crates/app/src/ui/session_window/tests/sessions.rs index 1d970de0c6..09f2919d97 100644 --- a/linux/crates/app/src/ui/session_window/tests/sessions.rs +++ b/linux/crates/app/src/ui/session_window/tests/sessions.rs @@ -91,7 +91,7 @@ fn holds_page(view: &adw::TabView, page: &adw::TabPage) -> bool { } /// Give `workspace` a table tab with a cell edited and not saved. -fn edit_a_table(workspace: &Workspace, name: &str) { +pub(super) fn edit_a_table(workspace: &Workspace, name: &str) { let before = workspace.open_tabs().unwrap_or_default(); workspace .sender() diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index 780dd72c8a..f4168308b0 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -46,13 +46,15 @@ const ACTIONS: [(&str, ActionInput); 12] = [ /// The actions a connection tree row or a toast fires for its own /// session, which take the session as their target (`session_target`). -const SESSION_ACTIONS: [(&str, SessionInput); 8] = [ +const SESSION_ACTIONS: [(&str, SessionInput); 10] = [ ("show-session", SessionWindowInput::ShowSession), ("disconnect-session", SessionWindowInput::DisconnectSession), ("stop-connecting", SessionWindowInput::StopConnecting), ("new-query-in", SessionWindowInput::NewQueryIn), ("refresh-in", SessionWindowInput::RefreshIn), ("open-database-in", SessionWindowInput::ChooseDatabaseIn), + ("new-database-in", SessionWindowInput::NewDatabaseIn), + ("drop-database-in", SessionWindowInput::DropDatabaseIn), ("show-connect-failure", SessionWindowInput::ShowConnectFailure), ("forget-connect-failure", SessionWindowInput::ForgetConnectFailure), ]; diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs index 3459289729..7b886ea717 100644 --- a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs @@ -18,7 +18,7 @@ use tablepro_session::runtime::{TaskFailure, Tasks}; use tablepro_session::{OperationError, Session}; use super::foreign_key_draft::{ForeignKeyDraft, ForeignKeyDraftError}; -use super::form_dialog::{FormDialog, give_state, mark_invalid}; +use crate::ui::form_dialog::{FormDialog, give_state, mark_invalid}; use super::referential_action_label::{action_at, action_labels}; use super::table_choice_object::{TableChoiceObject, table_choices}; @@ -512,7 +512,7 @@ mod tests { use tablepro_core::{ColumnInfo, DriverError}; use tablepro_test_support::{ConnectionScript, FakeReply}; - use super::super::form_dialog::tests::{WAIT, window}; + use crate::ui::form_dialog::tests::{WAIT, window}; use super::*; use crate::test_support::{FakeSession, OwnedWindow, drain_main_context, wait_until}; diff --git a/linux/crates/app/src/ui/structure_tab/index_dialog.rs b/linux/crates/app/src/ui/structure_tab/index_dialog.rs index 206eec37f7..58d2a84623 100644 --- a/linux/crates/app/src/ui/structure_tab/index_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/index_dialog.rs @@ -11,7 +11,7 @@ use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::IndexInfo; -use super::form_dialog::{FormDialog, give_state, mark_invalid}; +use crate::ui::form_dialog::{FormDialog, give_state, mark_invalid}; use super::index_draft::{DraftKey, IndexDraft, IndexDraftError}; use crate::ui::menu_label; @@ -304,7 +304,7 @@ fn edit_later(inner: &Weak, edit: impl FnOnce(&mut IndexDraft)) { mod tests { use std::time::Duration; - use super::super::form_dialog::tests::{WAIT, press_enter, window}; + use crate::ui::form_dialog::tests::{WAIT, press_enter, window}; use super::*; use crate::test_support::{drain_main_context, wait_until}; diff --git a/linux/crates/app/src/ui/structure_tab/mod.rs b/linux/crates/app/src/ui/structure_tab/mod.rs index 84b9eb6eab..64ce0d586f 100644 --- a/linux/crates/app/src/ui/structure_tab/mod.rs +++ b/linux/crates/app/src/ui/structure_tab/mod.rs @@ -60,7 +60,6 @@ mod foreign_key_draft; mod foreign_key_handlers; mod foreign_key_object; mod foreign_keys_input; -mod form_dialog; mod imp; mod index_dialog; mod index_draft; diff --git a/linux/crates/app/src/ui/structure_tab/tests.rs b/linux/crates/app/src/ui/structure_tab/tests.rs index b3cee1bf1d..f4779531f3 100644 --- a/linux/crates/app/src/ui/structure_tab/tests.rs +++ b/linux/crates/app/src/ui/structure_tab/tests.rs @@ -1384,7 +1384,7 @@ fn a_default_the_parser_refuses_is_marked_until_it_parses() { /// The tab in a window of its own, which the add rows open their forms /// on. fn in_window(harness: &Harness) -> OwnedWindow { - let window = super::form_dialog::tests::window(); + let window = crate::ui::form_dialog::tests::window(); window.set_content(Some(harness.tab())); window } @@ -1422,7 +1422,7 @@ fn add_index_from_the_tab_adds_the_index() { form.activate_action("index.add-key", Some(&"score".to_variant())) .is_ok() ); - super::form_dialog::tests::press_enter(&name); + crate::ui::form_dialog::tests::press_enter(&name); wait_until(WAIT, || harness.draft().indexes.len() == 1).expect("the index to be added"); let index = harness.draft().indexes[0].clone(); @@ -1474,7 +1474,7 @@ fn add_foreign_key_from_the_tab_adds_the_key() { rows_titled::(&form, "id references").is_some_and(|pair| pair.is_sensitive()) }) .expect("the columns of accounts to be read"); - super::form_dialog::tests::press_enter(&name); + crate::ui::form_dialog::tests::press_enter(&name); wait_until(WAIT, || harness.draft().foreign_keys.len() == 1).expect("the key to be added"); let key = harness.draft().foreign_keys[0].clone(); diff --git a/linux/crates/app/src/ui/workspace/catalog_dialogs.rs b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs new file mode 100644 index 0000000000..18627aa709 --- /dev/null +++ b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs @@ -0,0 +1,665 @@ +//! The New Schema and New Database forms: only the options the engine +//! takes, the server's own choices in the pickers, and Create on only +//! once the statement builds. + +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; + +use tablepro_core::WriteKind; +use tablepro_core::ddl::{ + self, BuildDdlError, CatalogCapabilities, CatalogOption, CharacterSet, DatabaseChoices, DatabaseDraft, + DatabaseNoun, DdlOperation, DdlPlan, KeyspaceReplication, SchemaDraft, +}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::meta::TableRef; + +use super::catalog_ops::{CatalogInput, CatalogLanded, asks_first, read_choices}; +use super::{WorkspaceInput, WorkspaceModel, WorkspaceSender}; +use crate::i18n::{gettext, gettext_f}; +use crate::runtime::DeliverInput; +use crate::ui::error_text; +use crate::ui::form_dialog::{FormDialog, give_state}; +use crate::ui::write_confirmation::{self, WriteConfirmation}; +use crate::workspace::database_target; + +/// A picker this long gets a search entry. +const SEARCHABLE: usize = 12; +const NETWORK_TOPOLOGY: u32 = 0; +const SIMPLE: u32 = 1; + +/// Which form the server's choices were read for. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CatalogForm { + Schema, + Database, +} + +impl WorkspaceModel { + /// Sidebar "New Schema…". + pub(super) fn on_new_schema_prompt(&mut self) { + let Some((session, _, capabilities)) = self.writable_catalog() else { + return; + }; + if !capabilities.create_schema { + self.refuse_catalog_change(&gettext("Cannot Create Schema"), DdlOperation::CreateSchema); + return; + } + self.read_choices_for(CatalogForm::Schema, session, capabilities.schema_owner); + } + + /// "New Database…" or "New Keyspace…" on a connection's row. + pub(super) fn on_new_database_prompt(&mut self) { + let Some((session, _, capabilities)) = self.writable_catalog() else { + return; + }; + if !capabilities.create_database { + self.refuse_catalog_change(&gettext("Cannot Create Database"), DdlOperation::CreateDatabase); + return; + } + let fields = capabilities.database_fields; + let picks = fields.owner + || fields.template + || fields.encoding + || fields.locale + || fields.character_set + || fields.collation + || fields.replication + || capabilities.on_cluster; + self.read_choices_for(CatalogForm::Database, session, picks); + } + + /// The form opens once the server's choices are read, or at once + /// where it has nothing to pick. + fn read_choices_for(&self, form: CatalogForm, session: Arc, picks: bool) { + if !picks { + self.present_catalog_form(form, DatabaseChoices::default()); + return; + } + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, read_choices(session), move |read| { + let choices = read.ok().flatten().map(Box::new); + input.deliver(WorkspaceInput::Catalog(CatalogInput::ChoicesRead { form, choices })); + }); + } + + pub(super) fn present_catalog_form(&self, form: CatalogForm, choices: DatabaseChoices) { + match form { + CatalogForm::Schema => self.present_new_schema(choices), + CatalogForm::Database => self.present_new_database(choices), + } + } + + fn present_new_schema(&self, choices: DatabaseChoices) { + let Some((session, dialect, capabilities)) = self.writable_catalog() else { + return; + }; + let (form, dialog) = FormDialog::new(&gettext("New Schema"), &gettext("_Create")); + let group = adw::PreferencesGroup::new(); + let name = FormDialog::entry_row(&gettext("Name")); + group.add(&name); + let owner = capabilities.schema_owner.then(|| { + Picker::new( + &group, + &gettext("Owner"), + default_entry(choices.defaults.owner.as_deref()), + choices.owners, + ) + }); + form.page.add(&group); + let state = Rc::new(SchemaForm { + form, + name, + owner, + dialect, + capabilities, + }); + let weak = Rc::downgrade(&state); + let check = Rc::new(move || { + if let Some(state) = weak.upgrade() { + state.check(); + } + }); + let changed = check.clone(); + state.name.connect_changed(move |_| changed()); + if let Some(owner) = &state.owner { + owner.row.connect_selected_notify(move |_| check()); + } + let (input, parent, asks) = (self.input.clone(), self.parent_ref(), asks_first(&session)); + let submitting = Rc::downgrade(&state); + state.form.submit.connect_clicked(move |_| { + if let Some(state) = submitting.upgrade() { + state.submit(&input, parent.as_ref(), asks.clone()); + } + }); + state.check(); + give_state(&dialog, state); + dialog.present(self.dialog_parent().as_ref()); + } + + fn present_new_database(&self, choices: DatabaseChoices) { + let Some((session, dialect, capabilities)) = self.writable_catalog() else { + return; + }; + let title = match capabilities.database_noun { + DatabaseNoun::Keyspace => gettext("New Keyspace"), + DatabaseNoun::Database => gettext("New Database"), + }; + let (form, dialog) = FormDialog::new(&title, &gettext("_Create")); + let state = Rc::new(database_form(form, choices, dialect, capabilities)); + state.follow_character_set(); + connect_database_form(&state); + let (input, parent, asks) = (self.input.clone(), self.parent_ref(), asks_first(&session)); + let submitting = Rc::downgrade(&state); + state.form.submit.connect_clicked(move |_| { + if let Some(state) = submitting.upgrade() { + state.submit(&input, parent.as_ref(), asks.clone()); + } + }); + state.check(); + give_state(&dialog, state); + dialog.present(self.dialog_parent().as_ref()); + } + + /// The window, held weakly by a form that outlives nothing of it. + fn parent_ref(&self) -> Option> { + self.dialog_parent().map(|parent| parent.downgrade()) + } + + pub(super) fn on_create_schema(&mut self, draft: SchemaDraft) { + let (target, created) = (TableRef::new(Some(draft.name.clone()), ""), draft.name.clone()); + self.start_catalog_change( + WriteKind::SchemaChanges, + target, + move |dialect, _| ddl::plan_create_schema(dialect, &draft), + move |result| match result { + Ok(()) => WorkspaceInput::Catalog(CatalogInput::Landed(CatalogLanded::SchemaCreated(created))), + Err(error) => WorkspaceInput::ShowAlert { + title: gettext("Could Not Create Schema"), + body: error_text::operation_error_message(&error), + }, + }, + ); + } + + pub(super) fn on_create_database(&mut self, draft: DatabaseDraft) { + let (target, created) = (database_target(&draft.name), draft.name.clone()); + self.start_catalog_change( + WriteKind::DatabaseChanges, + target, + move |dialect, _| ddl::plan_create_database(dialect, &draft), + move |result| match result { + Ok(()) => WorkspaceInput::Catalog(CatalogInput::Landed(CatalogLanded::DatabaseCreated(created))), + Err(error) => WorkspaceInput::ShowAlert { + title: gettext("Could Not Create Database"), + body: error_text::operation_error_message(&error), + }, + }, + ); + } +} + +/// Create is on only for a plan that builds. Its problem shows once +/// there is a name, so a form does not open on an error. +fn show_plan(form: &FormDialog, name: &str, planned: &Result) { + let problem = match planned { + Err(error) if !name.is_empty() => Some(error_text::build_error_text(error)), + _ => None, + }; + form.show_state(planned.is_ok(), problem); +} + +/// Under Confirm Writes the statements are asked about first. +fn confirm_then( + parent: Option, + asks_first: Option, + heading: String, + sql: String, + create: impl FnOnce() + 'static, +) { + let Some(connection) = asks_first else { + create(); + return; + }; + let Some(parent) = parent else { + tracing::debug!("no window to ask in before a create"); + return; + }; + glib::spawn_future_local(async move { + let confirmation = WriteConfirmation::creation(&connection, heading, sql); + if write_confirmation::confirm(&parent, &confirmation).await { + create(); + } + }); +} + +struct SchemaForm { + form: FormDialog, + name: adw::EntryRow, + owner: Option, + dialect: Arc, + capabilities: CatalogCapabilities, +} + +impl SchemaForm { + fn draft(&self) -> SchemaDraft { + SchemaDraft { + name: self.name.text().trim().to_owned(), + owner: self.owner.as_ref().and_then(Picker::picked), + } + } + + fn check(&self) -> Option { + let draft = self.draft(); + let planned = ddl::named(&self.capabilities, CatalogOption::Name, &draft.name) + .and_then(|_| ddl::plan_create_schema(&*self.dialect, &draft)); + show_plan(&self.form, &draft.name, &planned); + planned.ok() + } + + fn submit(&self, input: &WorkspaceSender, parent: Option<&glib::WeakRef>, asks: Option) { + let Some(plan) = self.check() else { + return; + }; + let draft = self.draft(); + self.form.close(); + let heading = gettext_f("Create Schema “{name}”?", &[("name", &draft.name)]); + let input = input.clone(); + confirm_then( + parent.and_then(glib::WeakRef::upgrade), + asks, + heading, + plan.preview(), + move || input.deliver(WorkspaceInput::Catalog(CatalogInput::CreateSchema(draft))), + ); + } +} + +struct DatabaseForm { + form: FormDialog, + name: adw::EntryRow, + owner: Option, + template: Option, + encoding: Option, + locale: Option, + character_set: Option, + collation: Option, + cluster: Option, + replication: Option, + tablets: Option, + first_collection: Option, + /// Each character set's collations, which the collation picker + /// follows. + character_sets: Vec, + default_character_set: Option, + /// The server's own collation, which holds while both pickers are left + /// to the server. + default_collation: Option, + dialect: Arc, + capabilities: CatalogCapabilities, +} + +/// The rows `capabilities` names and nothing else, with the server's +/// choices in them. +fn database_form( + form: FormDialog, + choices: DatabaseChoices, + dialect: Arc, + capabilities: CatalogCapabilities, +) -> DatabaseForm { + let fields = capabilities.database_fields; + let DatabaseChoices { + owners, + templates, + encodings, + locales, + character_sets, + collations, + clusters, + data_centers, + defaults, + } = choices; + let group = adw::PreferencesGroup::new(); + let name = FormDialog::entry_row(&gettext("Name")); + group.add(&name); + let picker = |title: String, default: Option<&str>, values: Vec| { + Picker::new(&group, &title, default_entry(default), values) + }; + let owner = fields + .owner + .then(|| picker(gettext("Owner"), defaults.owner.as_deref(), owners)); + let template = fields.template.then(|| picker(gettext("Template"), None, templates)); + let encoding = fields + .encoding + .then(|| picker(gettext("Encoding"), defaults.encoding.as_deref(), encodings)); + let locale = fields + .locale + .then(|| picker(gettext("Locale"), defaults.locale.as_deref(), locales)); + let set_names: Vec = character_sets.iter().map(|set| set.name.clone()).collect(); + let character_set = fields + .character_set + .then(|| picker(gettext("Character set"), defaults.character_set.as_deref(), set_names)); + // Where a character set is picked, its own collations fill the list. + let collation = fields.collation.then(|| match fields.character_set { + true => picker(gettext("Collation"), defaults.collation.as_deref(), Vec::new()), + false => picker(gettext("Collation"), defaults.collation.as_deref(), collations), + }); + let cluster = (capabilities.on_cluster && !clusters.is_empty()) + .then(|| Picker::new(&group, &gettext("Cluster"), gettext("This server only"), clusters)); + let replication = fields.replication.then(|| Replication::new(&group, data_centers)); + let tablets = fields.tablets.then(|| { + let row = adw::SwitchRow::builder() + .title(gettext("Tablets")) + .subtitle(gettext("Split each table's data into tablets the cluster balances")) + .active(true) + .build(); + group.add(&row); + row + }); + form.page.add(&group); + let first_collection = fields.first_collection.then(|| { + let collection = adw::PreferencesGroup::builder() + .description(gettext("MongoDB stores a database only once it holds a collection.")) + .build(); + let row = FormDialog::entry_row(&gettext("First collection")); + collection.add(&row); + form.page.add(&collection); + row + }); + DatabaseForm { + form, + name, + owner, + template, + encoding, + locale, + character_set, + collation, + cluster, + replication, + tablets, + first_collection, + character_sets, + default_character_set: defaults.character_set, + default_collation: defaults.collation, + dialect, + capabilities, + } +} + +/// Every row checks the form again as it changes; a character set picked +/// also changes the collations offered. +fn connect_database_form(state: &Rc) { + let weak = Rc::downgrade(state); + let check: Rc = Rc::new(move || { + if let Some(state) = weak.upgrade() { + if let Some(replication) = &state.replication { + replication.show_strategy(); + } + state.check(); + } + }); + for entry in std::iter::once(&state.name).chain(state.first_collection.as_ref()) { + let check = check.clone(); + entry.connect_changed(move |_| check()); + } + let pickers = [ + &state.owner, + &state.template, + &state.encoding, + &state.locale, + &state.collation, + &state.cluster, + ]; + for picker in pickers.into_iter().flatten() { + let check = check.clone(); + picker.row.connect_selected_notify(move |_| check()); + } + if let Some(character_set) = &state.character_set { + let weak = Rc::downgrade(state); + character_set.row.connect_selected_notify(move |_| { + if let Some(state) = weak.upgrade() { + state.follow_character_set(); + state.check(); + } + }); + } + if let Some(replication) = &state.replication { + replication.connect(&check); + } + if let Some(tablets) = &state.tablets { + let check = check.clone(); + tablets.connect_active_notify(move |_| check()); + } +} + +impl DatabaseForm { + fn draft(&self) -> DatabaseDraft { + let picked = |picker: &Option| picker.as_ref().and_then(Picker::picked); + let mut draft = DatabaseDraft::named(self.name.text().trim()); + draft.owner = picked(&self.owner); + draft.template = picked(&self.template); + draft.encoding = picked(&self.encoding); + draft.locale = picked(&self.locale); + draft.character_set = picked(&self.character_set); + draft.collation = picked(&self.collation); + draft.cluster = picked(&self.cluster); + if let Some(replication) = &self.replication { + draft.replication = Some(replication.chosen()); + draft.durable_writes = replication.durable_writes.is_active(); + } + draft.tablets = self.tablets.as_ref().map(adw::SwitchRow::is_active); + draft.first_collection = self.first_collection.as_ref().map(|row| row.text().trim().to_owned()); + draft + } + + fn check(&self) -> Option { + let draft = self.draft(); + let planned = ddl::named(&self.capabilities, CatalogOption::Name, &draft.name) + .and_then(|_| ddl::plan_create_database(&*self.dialect, &draft)); + show_plan(&self.form, &draft.name, &planned); + planned.ok() + } + + /// The collations of the character set picked, or of the server's own + /// while it is left to the server. Left to the server, neither option + /// is sent and the server's own collation holds, which need not be its + /// character set's default. + fn follow_character_set(&self) { + let (Some(character_set), Some(collation)) = (&self.character_set, &self.collation) else { + return; + }; + let picked = character_set.picked(); + let name = picked.clone().or_else(|| self.default_character_set.clone()); + let set = self.character_sets.iter().find(|set| Some(&set.name) == name.as_ref()); + let default = match picked { + Some(_) => set.map(|set| set.default_collation.clone()), + None => self.default_collation.clone(), + }; + let collations = set.map(|set| set.collations.clone()).unwrap_or_default(); + collation.replace(default_entry(default.as_deref()), collations); + } + + fn submit(&self, input: &WorkspaceSender, parent: Option<&glib::WeakRef>, asks: Option) { + let Some(plan) = self.check() else { + return; + }; + let draft = self.draft(); + self.form.close(); + let args = [("name", draft.name.as_str())]; + let heading = match self.capabilities.database_noun { + DatabaseNoun::Keyspace => gettext_f("Create Keyspace “{name}”?", &args), + DatabaseNoun::Database => gettext_f("Create Database “{name}”?", &args), + }; + let input = input.clone(); + confirm_then( + parent.and_then(glib::WeakRef::upgrade), + asks, + heading, + plan.preview(), + move || input.deliver(WorkspaceInput::Catalog(CatalogInput::CreateDatabase(Box::new(draft)))), + ); + } +} + +/// A combo row whose first entry leaves the option out of the +/// statement, so the server's own choice holds. +struct Picker { + row: adw::ComboRow, + values: RefCell>, +} + +impl Picker { + fn new(group: &adw::PreferencesGroup, title: &str, first: String, values: Vec) -> Self { + let row = adw::ComboRow::builder().title(title).build(); + row.set_search_match_mode(gtk::StringFilterMatchMode::Substring); + row.set_expression(Some(>k::PropertyExpression::new( + gtk::StringObject::static_type(), + None::<>k::Expression>, + "string", + ))); + group.add(&row); + let picker = Self { + row, + values: RefCell::default(), + }; + picker.replace(first, values); + picker + } + + fn replace(&self, first: String, values: Vec) { + let entries: Vec<&str> = std::iter::once(first.as_str()) + .chain(values.iter().map(String::as_str)) + .collect(); + // Taken in first: setting the model announces the new selection, + // and a handler reads the values. + let model = gtk::StringList::new(&entries); + self.row.set_enable_search(values.len() > SEARCHABLE); + self.values.replace(values); + self.row.set_model(Some(&model)); + self.row.set_selected(0); + } + + /// `None` while the first entry is picked. + fn picked(&self) -> Option { + let at = usize::try_from(self.row.selected()).ok()?.checked_sub(1)?; + self.values.borrow().get(at).cloned() + } +} + +/// The first entry of a picker, naming the server's own choice where it +/// said which. +fn default_entry(default: Option<&str>) -> String { + match default { + Some(value) => gettext_f("Default ({value})", &[("value", value)]), + None => gettext("Default"), + } +} + +/// A keyspace's replicas: one factor for the cluster, or one per data +/// centre. +struct Replication { + strategy: adw::ComboRow, + factor: adw::SpinRow, + centres: Vec<(String, adw::SpinRow)>, + durable_writes: adw::SwitchRow, +} + +impl Replication { + fn new(group: &adw::PreferencesGroup, data_centers: Vec) -> Self { + let names = [gettext("Network topology"), gettext("Simple")]; + let strategies: Vec<&str> = names.iter().map(String::as_str).collect(); + let strategy = adw::ComboRow::builder() + .title(gettext("Replication")) + .model(>k::StringList::new(&strategies)) + .build(); + let factor = replicas(&gettext("Replication factor")); + let centres: Vec<(String, adw::SpinRow)> = data_centers + .into_iter() + .map(|centre| { + let row = replicas(&gettext_f("Replicas in {dc}", &[("dc", ¢re)])); + (centre, row) + }) + .collect(); + // With no data centre read there is nothing to name a replica + // count after. + strategy.set_selected(if centres.is_empty() { SIMPLE } else { NETWORK_TOPOLOGY }); + let durable_writes = adw::SwitchRow::builder() + .title(gettext("Durable Writes")) + .subtitle(gettext("Write each change to the commit log before it is acknowledged")) + .active(true) + .build(); + group.add(&strategy); + group.add(&factor); + for (_, row) in ¢res { + group.add(row); + } + group.add(&durable_writes); + let replication = Self { + strategy, + factor, + centres, + durable_writes, + }; + replication.show_strategy(); + replication + } + + fn simple(&self) -> bool { + self.strategy.selected() == SIMPLE + } + + fn show_strategy(&self) { + let simple = self.simple(); + self.factor.set_visible(simple); + for (_, row) in &self.centres { + row.set_visible(!simple); + } + } + + fn chosen(&self) -> KeyspaceReplication { + match self.simple() { + true => KeyspaceReplication::Simple { + factor: replica_count(&self.factor), + }, + false => KeyspaceReplication::NetworkTopology { + factors: self + .centres + .iter() + .map(|(centre, row)| (centre.clone(), replica_count(row))) + .collect(), + }, + } + } + + fn connect(&self, check: &Rc) { + let changed = check.clone(); + self.strategy.connect_selected_notify(move |_| changed()); + for row in std::iter::once(&self.factor).chain(self.centres.iter().map(|(_, row)| row)) { + let changed = check.clone(); + row.connect_value_notify(move |_| changed()); + } + let changed = check.clone(); + self.durable_writes.connect_active_notify(move |_| changed()); + } +} + +/// One replica is all a single-node cluster can hold; a real cluster +/// raises it. +fn replicas(title: &str) -> adw::SpinRow { + let row = adw::SpinRow::with_range(1.0, 99.0, 1.0); + row.set_title(title); + row.set_value(1.0); + row +} + +/// The row's range keeps the count from 1 to 99. +fn replica_count(row: &adw::SpinRow) -> u32 { + row.value() as u32 +} diff --git a/linux/crates/app/src/ui/workspace/catalog_ops.rs b/linux/crates/app/src/ui/workspace/catalog_ops.rs new file mode 100644 index 0000000000..38c7deae8b --- /dev/null +++ b/linux/crates/app/src/ui/workspace/catalog_ops.rs @@ -0,0 +1,1157 @@ +//! Emptying a table and dropping a schema or database from the sidebar, +//! what follows once one lands, and the runner every catalog change +//! shares with Drop Table: one recorded write the window's close waits +//! for. + +use std::collections::VecDeque; +use std::rc::Rc; +use std::sync::{Arc, Weak}; + +use gtk4 as gtk; +use gtk4::glib; +use gtk4::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use uuid::Uuid; + +use tablepro_core::ddl::{ + self, BuildDdlError, CatalogCapabilities, DatabaseChoices, DatabaseDraft, DatabaseNoun, DdlOperation, DdlPlan, + DropDatabaseOptions, DropSchemaOptions, InboundForeignKey, SchemaDraft, TruncateTableOptions, +}; +use tablepro_core::dialect::{DatabaseLayout, SqlDialect}; +use tablepro_core::meta::{ObjectScope, SchemaInfo, TableRef}; +use tablepro_core::sql_syntax::SqlGrammar; +use tablepro_core::{SafeModeLevel, WriteKind}; +use tablepro_session::{OperationError, OperationKind, Session}; + +use super::catalog_dialogs::CatalogForm; +use super::structure::{display_name, key_list, read_definition, referencing_keys, settled}; +use super::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; +use crate::i18n::{gettext, gettext_f, ngettext_f}; +use crate::runtime::DeliverInput; +use crate::ui::SessionWindowInput; +use crate::ui::error_text; +use crate::ui::write_confirmation::asks_before_writes; +use crate::workspace::{CatalogChange, PendingState, database_target, run_catalog_change}; + +const CANCEL: &str = "cancel"; +pub(super) const TRUNCATE: &str = "truncate"; +pub(super) const DROP: &str = "drop"; + +/// The steps of a catalog change between the sidebar's or the window's +/// ask and the write. +#[derive(Debug)] +pub(crate) enum CatalogInput { + TruncateTableChecked(Box), + TruncateTableConfirmed { + table: TableRef, + options: TruncateTableOptions, + /// The other tables the truncate empties through Cascade. + cascaded: Vec, + }, + /// Every tab on these tables reads them again. + ReloadTables(Vec), + /// How many objects the schema still holds, read before the drop asks. + DropSchemaChecked { + schema: String, + held: usize, + }, + DropSchemaConfirmed { + schema: String, + options: DropSchemaOptions, + }, + /// The clusters a drop can run on, read before it asks. + DropDatabaseChecked { + database: String, + clusters: Vec, + }, + DropDatabaseConfirmed { + database: String, + options: DropDatabaseOptions, + }, + /// What the server offers for the form's pickers, `None` when it + /// lists nothing. + ChoicesRead { + form: CatalogForm, + choices: Option>, + }, + CreateSchema(SchemaDraft), + CreateDatabase(Box), + /// The DROP DATABASE ran, or failed, which lets the window open the + /// database again. + DatabaseDropSettled { + target: DatabaseDrop, + result: Result<(), OperationError>, + }, + Landed(CatalogLanded), +} + +/// A database the session under `key` is dropping, and which session +/// that is: by the time the window has let go of the database, the same +/// key can be open again as another session, perhaps to another server. +#[derive(Debug, Clone)] +pub(crate) struct DatabaseDrop { + pub(crate) key: tablepro_session::SessionKey, + pub(crate) origin: Weak, + pub(crate) database: String, +} + +impl DatabaseDrop { + /// Whether `session` is the one the user confirmed the drop on. + pub(crate) fn confirmed_by(&self, session: &Arc) -> bool { + self.origin + .upgrade() + .is_some_and(|origin| Arc::ptr_eq(&origin, session)) + } +} + +/// What was read before the prompt to empty a table asks. +#[derive(Debug)] +pub(crate) struct TruncateCheck { + table: TableRef, + /// The keys of other tables that point at it. + referencing: Vec, + /// Every other table a cascading truncate empties with it. + cascades: Vec, + clusters: Vec, +} + +/// A catalog change the server applied. +#[derive(Debug)] +pub(crate) enum CatalogLanded { + Truncated { table: TableRef, cascaded: Vec }, + SchemaCreated(String), + SchemaDropped(String), + DatabaseCreated(String), + DatabaseDropped(String), +} + +impl WorkspaceModel { + pub(super) fn on_catalog_input(&mut self, input: CatalogInput) { + match input { + CatalogInput::TruncateTableChecked(check) => self.on_truncate_table_checked(*check), + CatalogInput::TruncateTableConfirmed { + table, + options, + cascaded, + } => self.on_truncate_table_confirmed(table, options, cascaded), + CatalogInput::ReloadTables(tables) => self.reload_tables(&tables), + CatalogInput::DropSchemaChecked { schema, held } => self.on_drop_schema_checked(schema, held), + CatalogInput::DropSchemaConfirmed { schema, options } => self.on_drop_schema_confirmed(schema, options), + CatalogInput::DropDatabaseChecked { database, clusters } => { + self.present_drop_database_prompt(database, clusters); + } + CatalogInput::DropDatabaseConfirmed { database, options } => { + self.on_drop_database_confirmed(database, options); + } + CatalogInput::ChoicesRead { form, choices } => { + self.present_catalog_form(form, choices.map(|choices| *choices).unwrap_or_default()); + } + CatalogInput::CreateSchema(draft) => self.on_create_schema(draft), + CatalogInput::CreateDatabase(draft) => self.on_create_database(*draft), + CatalogInput::DatabaseDropSettled { target, result } => self.on_database_drop_settled(target, result), + CatalogInput::Landed(landed) => self.on_catalog_landed(landed), + } + } + + /// The session and what its engine does to whole tables, schemas and + /// databases. A connection that may not write says so, and nothing + /// runs. + pub(super) fn writable_catalog(&self) -> Option<(Arc, Arc, CatalogCapabilities)> { + let Some(session) = self.context.active.get() else { + self.show_toast(&gettext("Connect to a database first.")); + return None; + }; + if session.read_only() { + self.show_toast(&gettext("This connection is read-only.")); + return None; + } + let Some(dialect) = session.current_dialect() else { + self.show_toast(&gettext("The connection is not back yet. Try again once it is.")); + return None; + }; + let capabilities = ddl::catalog_capabilities(&*dialect); + Some((session, dialect, capabilities)) + } + + /// Say the engine cannot do `operation`, for a menu offered before a + /// reconnect to another server version. + pub(super) fn refuse_catalog_change(&self, title: &str, operation: DdlOperation) { + self.show_error_alert( + title, + &error_text::build_error_text(&BuildDdlError::Unsupported(operation)), + ); + } + + /// Run a catalog change as a save of its own: a window close waits + /// for it or stops it, a quit keeps the process until it answers, and + /// the history records it. `answer` goes through the workspace's + /// input, so one that lands after a disconnect is dropped with it. + /// False when it did not start, and `answer` is never called. + pub(super) fn start_catalog_change(&self, kind: WriteKind, target: TableRef, plan: P, answer: A) -> bool + where + P: Fn(&dyn SqlDialect, &TableRef) -> Result + Send + Sync + 'static, + A: FnOnce(Result<(), OperationError>) -> WorkspaceInput + 'static, + { + let Some(session) = self.context.active.get() else { + self.show_toast(&gettext("Connect to a database first.")); + return false; + }; + if session.read_only() { + self.show_toast(&gettext("This connection is read-only.")); + return false; + } + let handle = self.workspace_handle.clone(); + let Some(ticket) = handle.ledger().begin(&*self.context.hold, Uuid::new_v4(), kind) else { + return false; + }; + let change = CatalogChange { + kind, + target, + history: handle.history_entry(&session), + }; + let cancel = ticket.cancel.clone(); + let work = async move { run_catalog_change(session, change, &cancel, plan).await }; + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { + input.deliver(answer(settled(result))); + handle.ledger().settle(ticket.id); + }); + true + } + + /// Sidebar "Truncate Table…" or "Delete All Documents…". Where another + /// table's foreign key decides whether the table can be emptied, the + /// keys are read first, so a refusal comes before the question. Where + /// Cascade can empty them too, every table it would reach is read. + pub(super) fn on_truncate_table_prompt(&mut self, table: TableRef) { + let Some((session, _, capabilities)) = self.writable_catalog() else { + return; + }; + if !capabilities.truncate_table { + self.refuse_catalog_change(&gettext("Cannot Truncate Table"), DdlOperation::TruncateTable); + return; + } + let (walks_cascade, refuses_referenced) = ( + capabilities.truncate_cascade, + capabilities.truncate_refused_when_referenced, + ); + let reads_clusters = capabilities.on_cluster; + let work = async move { + let (referencing, cascades) = match (walks_cascade, refuses_referenced) { + (true, _) => cascade_from(&session, &table).await, + (false, true) => (keys_pointing_at(session.clone(), table.clone()).await, Vec::new()), + (false, false) => (Vec::new(), Vec::new()), + }; + let clusters = match reads_clusters { + true => read_choices(session) + .await + .map(|choices| choices.clusters) + .unwrap_or_default(), + false => Vec::new(), + }; + TruncateCheck { + table, + referencing, + cascades, + clusters, + } + }; + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| match result { + Ok(check) => input.deliver(WorkspaceInput::Catalog(CatalogInput::TruncateTableChecked(Box::new( + check, + )))), + Err(failure) => tracing::warn!(%failure, "the table could not be read before emptying it"), + }); + } + + fn on_truncate_table_checked(&mut self, check: TruncateCheck) { + let Some((session, dialect, capabilities)) = self.writable_catalog() else { + return; + }; + if !check.referencing.is_empty() && capabilities.truncate_refused_when_referenced { + self.show_error_alert( + &gettext("Cannot Truncate Table"), + &gettext_f( + "Foreign keys on other tables point at {table}: {keys}. Drop those foreign keys first, or delete the rows in a query.", + &[ + ("table", &display_name(&check.table)), + ("keys", &key_list(&check.table, &check.referencing)), + ], + ), + ); + return; + } + let unsaved = Unsaved { + here: !self.unsaved_tabs_on(&check.table).is_empty(), + cascaded: check + .cascades + .iter() + .any(|table| !self.unsaved_tabs_on(table).is_empty()), + }; + let prompt = truncate_prompt(check, unsaved, &session, dialect, &capabilities); + let input = self.input.clone(); + let (table, cascades, controls) = (prompt.table, prompt.cascades, prompt.controls); + prompt.prompt.on_response(TRUNCATE, move || { + let options = controls.options(); + let cascaded = match options.cascade { + true => cascades.clone(), + false => Vec::new(), + }; + input.deliver(WorkspaceInput::Catalog(CatalogInput::TruncateTableConfirmed { + table: table.clone(), + options, + cascaded, + })); + }); + prompt.prompt.present(self.dialog_parent().as_ref()); + } + + fn on_truncate_table_confirmed(&mut self, table: TableRef, options: TruncateTableOptions, cascaded: Vec) { + let emptied = table.clone(); + self.start_catalog_change( + WriteKind::SchemaChanges, + table, + move |dialect, table| ddl::plan_truncate_table(dialect, table, &options), + move |result| match result { + Ok(()) => WorkspaceInput::Catalog(CatalogInput::Landed(CatalogLanded::Truncated { + table: emptied, + cascaded, + })), + Err(error) => failed(gettext("Truncate Failed"), &error), + }, + ); + } + + /// Every tab on the tables reads them again. + fn reload_tables(&self, tables: &[TableRef]) { + let Some(session) = self.context.active.get() else { + return; + }; + for table in tables { + self.reload_table_tabs(&session, table); + } + } + + /// The table tabs on `table` holding row edits not yet saved, which + /// would point at rows that are gone once it is emptied. + pub(super) fn unsaved_tabs_on(&self, table: &TableRef) -> Vec { + let tabs: Vec = self + .workspace_tabs + .borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Table(slot) if slot.schema == table.schema && slot.table == table.name => Some(slot.id), + _ => None, + }) + .collect(); + tabs.into_iter() + .filter(|tab| { + self.change_trackers.get(*tab).is_some_and(|tracker| { + matches!( + tracker.pending_state(), + PendingState::Unsaved | PendingState::OutcomeUnknown(_) + ) + }) + }) + .collect() + } + + /// Sidebar "Drop Schema…". What the schema still holds is counted + /// first: an engine that cannot drop it with everything in it is + /// refused before the question. + pub(super) fn on_drop_schema_prompt(&mut self, schema: String) { + let Some((session, dialect, capabilities)) = self.writable_catalog() else { + return; + }; + if !capabilities.drop_schema { + self.refuse_catalog_change(&gettext("Cannot Drop Schema"), DdlOperation::DropSchema); + return; + } + if !droppable_schema(&self.catalog_schemas, session.database(), &*dialect, &schema) { + tracing::debug!("a schema the sidebar no longer offers to drop was asked to drop"); + return; + } + let work = async move { + let held = match session.objects(ObjectScope::AllSchemas).await { + Ok(objects) => objects + .iter() + .filter(|object| object.schema.as_deref() == Some(schema.as_str())) + .count(), + Err(error) => { + tracing::debug!(%error, "the schema's objects could not be counted before a drop"); + 0 + } + }; + (schema, held) + }; + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| match result { + Ok((schema, held)) => { + input.deliver(WorkspaceInput::Catalog(CatalogInput::DropSchemaChecked { + schema, + held, + })); + } + Err(failure) => tracing::warn!(%failure, "the schema could not be read before a drop"), + }); + } + + fn on_drop_schema_checked(&mut self, schema: String, held: usize) { + let Some((session, dialect, capabilities)) = self.writable_catalog() else { + return; + }; + let count = u32::try_from(held).unwrap_or(u32::MAX); + let args = [("schema", schema.as_str()), ("n", &held.to_string())]; + if held > 0 && !capabilities.drop_schema_cascade { + self.show_error_alert( + &gettext("Cannot Drop Schema"), + &ngettext_f( + "“{schema}” still holds {n} object. Drop it first.", + "“{schema}” still holds {n} objects. Drop them first.", + count, + &args, + ), + ); + return; + } + let mut body = gettext("Everything in it will be permanently deleted."); + if held > 0 { + body.push(' '); + body.push_str(&ngettext_f( + "It holds {n} object. Turn on Cascade to drop it too, with anything elsewhere that depends on it.", + "It holds {n} objects. Turn on Cascade to drop them too, with anything elsewhere that depends on them.", + count, + &args, + )); + } + let mut prompt = CatalogPrompt::new( + &gettext_f("Drop Schema “{schema}”?", &args), + &body, + (DROP, &gettext("_Drop")), + asks_first(&session).as_deref(), + ); + let cascade = capabilities.drop_schema_cascade.then(|| { + prompt.switch( + &gettext("Cascade"), + &gettext("Also drop what is in it, and anything elsewhere that depends on it"), + ) + }); + let (planned, read) = (schema.clone(), cascade.clone()); + prompt.follow(DROP, move || { + let options = DropSchemaOptions { + cascade: is_on(read.as_ref()), + }; + let allowed = held == 0 || options.cascade; + (ddl::plan_drop_schema(&*dialect, &planned, options), allowed) + }); + let input = self.input.clone(); + prompt.on_response(DROP, move || { + input.deliver(WorkspaceInput::Catalog(CatalogInput::DropSchemaConfirmed { + schema: schema.clone(), + options: DropSchemaOptions { + cascade: is_on(cascade.as_ref()), + }, + })); + }); + prompt.present(self.dialog_parent().as_ref()); + } + + fn on_drop_schema_confirmed(&mut self, schema: String, options: DropSchemaOptions) { + let (target, dropped) = (TableRef::new(Some(schema.clone()), ""), schema.clone()); + self.start_catalog_change( + WriteKind::SchemaChanges, + target, + move |dialect, _| ddl::plan_drop_schema(dialect, &schema, options), + move |result| match result { + Ok(()) => WorkspaceInput::Catalog(CatalogInput::Landed(CatalogLanded::SchemaDropped(dropped))), + Err(error) => failed(gettext("Drop Failed"), &error), + }, + ); + } + + /// Sidebar or window "Drop Database…". Whether it may still be + /// dropped is checked again, since the menu that asked can be older + /// than the list. + pub(super) fn on_drop_database_prompt(&mut self, database: String) { + let Some((session, dialect, capabilities)) = self.writable_catalog() else { + return; + }; + if !capabilities.drop_database { + self.refuse_catalog_change(&gettext("Cannot Drop Database"), DdlOperation::DropDatabase); + return; + } + let free = match dialect.database_layout() { + DatabaseLayout::AsSchemas => { + droppable_schema(&self.catalog_schemas, session.database(), &*dialect, &database) + } + DatabaseLayout::WithSchemas | DatabaseLayout::Single => { + session.database() != Some(database.as_str()) && !ddl::protects_database(&*dialect, &database) + } + }; + if !free { + tracing::debug!("a database the sidebar no longer offers to drop was asked to drop"); + return; + } + if !capabilities.on_cluster { + self.present_drop_database_prompt(database, Vec::new()); + return; + } + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, read_choices(session), move |read| { + let clusters = read.ok().flatten().map(|choices| choices.clusters).unwrap_or_default(); + input.deliver(WorkspaceInput::Catalog(CatalogInput::DropDatabaseChecked { + database, + clusters, + })); + }); + } + + fn present_drop_database_prompt(&self, database: String, clusters: Vec) { + let Some((session, dialect, capabilities)) = self.writable_catalog() else { + return; + }; + let args = [("name", database.as_str())]; + let heading = match capabilities.database_noun { + DatabaseNoun::Keyspace => gettext_f("Drop Keyspace “{name}”?", &args), + DatabaseNoun::Database => gettext_f("Drop Database “{name}”?", &args), + }; + let mut body = gettext("All tables and data in it will be permanently deleted."); + if dialect.database_layout() == DatabaseLayout::WithSchemas { + body.push(' '); + body.push_str(&gettext("Where it is open beside this connection, it closes first.")); + } + let mut prompt = CatalogPrompt::new( + &heading, + &body, + (DROP, &gettext("_Drop")), + asks_first(&session).as_deref(), + ); + let controls = DropDatabaseControls { + force: capabilities.drop_database_force.then(|| { + prompt.switch( + &gettext("Close Other Connections"), + &gettext("End every other session on it, so the server lets it go"), + ) + }), + cluster: cluster_choice(&mut prompt, &capabilities, clusters), + }; + let (planned, read) = (database.clone(), controls.clone()); + prompt.follow(DROP, move || { + (ddl::plan_drop_database(&*dialect, &planned, &read.options()), true) + }); + let input = self.input.clone(); + prompt.on_response(DROP, move || { + input.deliver(WorkspaceInput::Catalog(CatalogInput::DropDatabaseConfirmed { + database: database.clone(), + options: controls.options(), + })); + }); + prompt.present(self.dialog_parent().as_ref()); + } + + /// Where another database is a session of its own beside the + /// connection, the window closes the one it holds on this database + /// first and hands the drop back (0033). + fn on_drop_database_confirmed(&mut self, database: String, options: DropDatabaseOptions) { + let Some(session) = self.context.active.get() else { + return; + }; + let target = DatabaseDrop { + key: self.key.clone(), + origin: Arc::downgrade(&session), + database, + }; + match (self.database_layout(), self.shell.upgrade()) { + (Some(DatabaseLayout::WithSchemas), Some(shell)) => { + shell.tell_window(SessionWindowInput::ReleaseDatabase { target, options }); + } + _ => self.on_database_released(target, options), + } + } + + /// Nothing of the window holds the database any longer. The drop runs + /// only on the session it was confirmed on. + pub(super) fn on_database_released(&mut self, target: DatabaseDrop, options: DropDatabaseOptions) { + let confirmed_here = self + .context + .active + .get() + .is_some_and(|session| target.confirmed_by(&session)); + if !confirmed_here { + tracing::debug!("a drop confirmed on a session this workspace no longer holds does not run"); + self.drop_finished(target); + return; + } + let (database, settled) = (target.database.clone(), target.clone()); + let started = self.start_catalog_change( + WriteKind::DatabaseChanges, + database_target(&target.database), + move |dialect, _| ddl::plan_drop_database(dialect, &database, &options), + move |result| { + WorkspaceInput::Catalog(CatalogInput::DatabaseDropSettled { + target: settled, + result, + }) + }, + ); + if !started { + self.drop_finished(target); + } + } + + fn on_database_drop_settled(&mut self, target: DatabaseDrop, result: Result<(), OperationError>) { + let database = target.database.clone(); + self.drop_finished(target); + match result { + Ok(()) => self.on_catalog_landed(CatalogLanded::DatabaseDropped(database)), + Err(error) => self.show_error_alert(&gettext("Drop Failed"), &error_text::operation_error_message(&error)), + } + } + + /// The window may open the database again, which it refused while + /// the drop was on its way. + fn drop_finished(&self, target: DatabaseDrop) { + if let Some(shell) = self.shell.upgrade() { + shell.tell_window(SessionWindowInput::DatabaseDropFinished(target)); + } + } + + fn on_catalog_landed(&mut self, landed: CatalogLanded) { + match landed { + CatalogLanded::Truncated { table, cascaded } => self.on_truncated(table, cascaded), + CatalogLanded::SchemaCreated(_) => self.catalog_shape_changed(), + CatalogLanded::SchemaDropped(schema) => { + self.close_tabs_in(&schema); + self.catalog_shape_changed(); + } + CatalogLanded::DatabaseCreated(database) => { + self.databases_changed_everywhere(); + self.open_beside(database); + } + CatalogLanded::DatabaseDropped(database) => { + // Where the databases are the schemas, the tabs on its + // tables name it as their schema. + if self.database_layout() == Some(DatabaseLayout::AsSchemas) { + self.close_tabs_in(&database); + } + self.databases_changed_everywhere(); + } + } + } + + /// The edits the prompt said would go are dropped, and every tab on + /// a table it emptied, through Cascade too, reads it again. The reads + /// go through the input, after the drop of the edits. + fn on_truncated(&mut self, table: TableRef, cascaded: Vec) { + self.show_toast(&gettext_f("“{table}” is empty", &[("table", &display_name(&table))])); + let emptied: Vec = std::iter::once(table).chain(cascaded).collect(); + let discarded: Vec = emptied.iter().flat_map(|table| self.unsaved_tabs_on(table)).collect(); + if !discarded.is_empty() { + self.workspace_handle.discard(&discarded, false); + } + self.input + .deliver(WorkspaceInput::Catalog(CatalogInput::ReloadTables(emptied))); + } + + /// Close the tabs on a schema, or on a database that is one, that is + /// gone. Reopen Closed Tab never offers them. + fn close_tabs_in(&mut self, schema: &str) { + let targets: Vec = self + .workspace_tabs + .borrow() + .iter() + .filter(|(_, tab)| match tab { + WorkspaceTab::Table(slot) => slot.schema.as_deref() == Some(schema), + WorkspaceTab::Structure(slot) => slot.schema.as_deref() == Some(schema), + WorkspaceTab::Diagram(slot) => slot.schema.as_deref() == Some(schema), + WorkspaceTab::Editor(_) | WorkspaceTab::Key(_) => false, + }) + .map(|(id, _)| *id) + .collect(); + self.forget_tabs(targets); + } + + /// Every session of the connection lists the server's databases + /// again, this one too. + fn databases_changed_everywhere(&mut self) { + match self.shell.upgrade() { + Some(shell) => shell.tell_window(SessionWindowInput::DatabasesChanged(self.key.clone())), + None => self.on_databases_changed(), + } + } + + /// A database was created or dropped somewhere on the connection. + pub(super) fn on_databases_changed(&mut self) { + self.relist_editor_databases(); + self.catalog_shape_changed(); + } + + /// Each editor tab asks the server for its databases again, for its + /// bar's menu. + pub(super) fn relist_editor_databases(&self) { + for tab in self.workspace_tabs.borrow().values() { + if let WorkspaceTab::Editor(slot) = tab { + slot.editor.list_databases(); + } + } + } + + /// A database created where the connection's catalog lists only its + /// own opens beside it, so it shows. + fn open_beside(&self, database: String) { + if self.database_layout() == Some(DatabaseLayout::WithSchemas) + && let Some(shell) = self.shell.upgrade() + { + shell.tell_window(SessionWindowInput::OpenDatabaseIn { + key: self.key.clone(), + database, + }); + } + } + + fn database_layout(&self) -> Option { + let dialect = self.context.active.get()?.current_dialect()?; + Some(dialect.database_layout()) + } +} + +/// Whether the sidebar still lists the schema `name` and may drop it. +/// Where the server's databases are the schemas it is a database: not +/// one the engine keeps, nor the one the session opened. Elsewhere it +/// is a schema of the session's database, which can share that +/// database's name: not one the engine keeps, nor the default. +pub(super) fn droppable_schema( + schemas: &[SchemaInfo], + own_database: Option<&str>, + dialect: &dyn SqlDialect, + name: &str, +) -> bool { + let listed = schemas.iter().find(|schema| schema.name == name); + if !listed.is_some_and(|schema| !schema.is_system && !schema.is_default) { + return false; + } + match dialect.database_layout() { + DatabaseLayout::AsSchemas => !ddl::protects_database(dialect, name) && own_database != Some(name), + DatabaseLayout::WithSchemas | DatabaseLayout::Single => !ddl::protects_schema(dialect, name), + } +} + +/// The connection's name when its Safe Mode asks before every write, so +/// the question shows the statements too. +pub(super) fn asks_first(session: &Session) -> Option { + (session.safe_mode() == SafeModeLevel::ConfirmWrites).then(|| session.metadata().name.clone()) +} + +fn failed(title: String, error: &OperationError) -> WorkspaceInput { + WorkspaceInput::ShowAlert { + title, + body: error_text::operation_error_message(error), + } +} + +/// What the server offers for a new database's options. `None` when it +/// lists none, so every picker keeps only the server's default. +pub(super) async fn read_choices(session: Arc) -> Option { + let read = session + .run(OperationKind::Metadata, |lease, options| async move { + Ok(lease.fetch_database_choices(options).await?) + }) + .await; + match read { + Ok(choices) => Some(choices), + Err(error) => { + tracing::debug!(%error, "the server's options for a new database could not be read"); + None + } + } +} + +/// The keys of other tables that point at `table`, and every table a +/// cascading truncate of it empties beside it: those, the tables pointing +/// at them, and so on, each once. A table's keys on itself go with it. +/// Keys that cannot be read end the walk there, and the server decides. +async fn cascade_from(session: &Session, table: &TableRef) -> (Vec, Vec) { + let mut referencing = Vec::new(); + let mut reached: Vec = Vec::new(); + let mut pending = VecDeque::from([table.clone()]); + while let Some(next) = pending.pop_front() { + for key in inbound_keys(session, next.clone()).await { + if key.table == next { + continue; + } + if next == *table { + referencing.push(key.clone()); + } + if key.table != *table && !reached.contains(&key.table) { + reached.push(key.table.clone()); + pending.push_back(key.table); + } + } + } + (referencing, reached) +} + +async fn inbound_keys(session: &Session, table: TableRef) -> Vec { + let read = session + .run(OperationKind::Metadata, move |lease, options| { + let table = table.clone(); + async move { Ok(lease.fetch_inbound_foreign_keys(&table, options).await?) } + }) + .await; + match read { + Ok(keys) => keys, + Err(error) => { + tracing::debug!(%error, "the foreign keys pointing at a table could not be read before emptying it"); + Vec::new() + } + } +} + +/// The keys of other tables that point at `table`. Keys that cannot be +/// read leave the refusal to the server. +async fn keys_pointing_at(session: Arc, table: TableRef) -> Vec { + match read_definition(session, table).await { + Ok((definition, _)) => referencing_keys(&definition), + Err(error) => { + tracing::debug!(%error, "the table's foreign keys could not be read before emptying it"); + Vec::new() + } + } +} + +/// Whether open tabs hold row edits a truncate would discard: on the +/// table itself, and on the tables Cascade empties with it. +#[derive(Debug, Clone, Copy)] +struct Unsaved { + here: bool, + cascaded: bool, +} + +/// The question that empties a table, with the options it takes. +struct TruncatePrompt { + prompt: CatalogPrompt, + table: TableRef, + cascades: Vec, + controls: TruncateControls, +} + +fn truncate_prompt( + check: TruncateCheck, + unsaved: Unsaved, + session: &Session, + dialect: Arc, + capabilities: &CatalogCapabilities, +) -> TruncatePrompt { + let TruncateCheck { + table, + referencing, + cascades, + clusters, + } = check; + let documents = dialect.grammar() == SqlGrammar::Mongo; + // The server refuses a referenced table unless the tables pointing + // at it are emptied with it. + let needs_cascade = capabilities.truncate_cascade && !referencing.is_empty(); + let name = display_name(&table); + let (heading, verb) = match documents { + true => ( + gettext_f("Delete All Documents in “{collection}”?", &[("collection", &name)]), + gettext("_Delete All"), + ), + false => ( + gettext_f("Truncate “{table}”?", &[("table", &name)]), + gettext("_Truncate"), + ), + }; + let body = truncate_body(&table, &referencing, documents, needs_cascade, unsaved.here); + let mut prompt = CatalogPrompt::new(&heading, &body, (TRUNCATE, &verb), asks_first(session).as_deref()); + let controls = TruncateControls { + restart_identity: capabilities.truncate_restart_identity.then(|| { + prompt.switch( + &gettext("Restart Identity"), + &gettext("Sequences owned by its columns start again"), + ) + }), + cascade: capabilities + .truncate_cascade + .then(|| prompt.switch(&gettext("Cascade"), &cascade_subtitle(&table, &cascades, None))), + cluster: cluster_choice(&mut prompt, capabilities, clusters), + }; + let (planned, read) = (table.clone(), controls.clone()); + let emptied_too = cascade_subtitle(&table, &cascades, Some(unsaved.cascaded)); + let left_alone = cascade_subtitle(&table, &cascades, None); + prompt.follow(TRUNCATE, move || { + let options = read.options(); + // The tables Cascade reaches are named only while it would empty + // them. + if let Some(row) = read.cascade.as_ref().and_then(glib::WeakRef::upgrade) { + row.set_subtitle(if options.cascade { &emptied_too } else { &left_alone }); + } + let allowed = !needs_cascade || options.cascade; + (ddl::plan_truncate_table(&*dialect, &planned, &options), allowed) + }); + TruncatePrompt { + prompt, + table, + cascades, + controls, + } +} + +/// The Cascade row's subtitle: what it does, and with `on` the tables it +/// empties, and whether that loses unsaved edits in their tabs. +fn cascade_subtitle(table: &TableRef, cascades: &[TableRef], on: Option) -> String { + let (Some(unsaved), false) = (on, cascades.is_empty()) else { + return gettext("Also empty the tables whose foreign keys point at it"); + }; + let names: Vec = cascades.iter().map(|cascaded| relative_name(table, cascaded)).collect(); + let tables = names.join(", "); + let args = [("tables", tables.as_str())]; + let count = u32::try_from(names.len()).unwrap_or(u32::MAX); + match unsaved { + true => ngettext_f( + "Also empties {tables}, and discards the unsaved changes in its open tabs", + "Also empties {tables}, and discards the unsaved changes in their open tabs", + count, + &args, + ), + false => ngettext_f("Also empties {tables}", "Also empties {tables}", count, &args), + } +} + +/// `other` by name alone where it sits in `table`'s schema. +fn relative_name(table: &TableRef, other: &TableRef) -> String { + match other.schema == table.schema { + true => other.name.clone(), + false => display_name(other), + } +} + +fn truncate_body( + table: &TableRef, + referencing: &[InboundForeignKey], + documents: bool, + needs_cascade: bool, + unsaved: bool, +) -> String { + let mut body = match documents { + true => gettext("All documents in it will be deleted. The collection, its indexes and its options stay."), + false => gettext( + "All rows in it will be deleted. The table and its structure stay. This can't be undone from inside TablePro.", + ), + }; + if needs_cascade { + let tables = referencing_tables(table, referencing); + body.push(' '); + body.push_str(&ngettext_f( + "{tables} points at it. Turn on Cascade to empty it too.", + "{tables} point at it. Turn on Cascade to empty them too.", + u32::try_from(tables.len()).unwrap_or(u32::MAX), + &[("tables", &tables.join(", "))], + )); + } + if unsaved { + body.push(' '); + body.push_str(&gettext("Unsaved changes to its rows in open tabs will be discarded.")); + } + body +} + +/// The tables `keys` belong to, each once, qualified only where it sits +/// in another schema than `table`. +pub(super) fn referencing_tables(table: &TableRef, keys: &[InboundForeignKey]) -> Vec { + let mut tables: Vec = Vec::new(); + for key in keys { + let name = relative_name(table, &key.table); + if !tables.contains(&name) { + tables.push(name); + } + } + tables +} + +/// The truncate the prompt's rows describe now. +#[derive(Clone, Default)] +struct TruncateControls { + restart_identity: Option>, + cascade: Option>, + cluster: Option, +} + +impl TruncateControls { + fn options(&self) -> TruncateTableOptions { + TruncateTableOptions { + cascade: is_on(self.cascade.as_ref()), + restart_identity: is_on(self.restart_identity.as_ref()), + cluster: self.cluster.as_ref().and_then(ClusterChoice::picked), + } + } +} + +/// The drop the prompt's rows describe now. +#[derive(Clone, Default)] +struct DropDatabaseControls { + force: Option>, + cluster: Option, +} + +impl DropDatabaseControls { + fn options(&self) -> DropDatabaseOptions { + DropDatabaseOptions { + force: is_on(self.force.as_ref()), + cluster: self.cluster.as_ref().and_then(ClusterChoice::picked), + } + } +} + +fn is_on(row: Option<&glib::WeakRef>) -> bool { + row.and_then(glib::WeakRef::upgrade).is_some_and(|row| row.is_active()) +} + +#[derive(Clone)] +struct ClusterChoice { + row: glib::WeakRef, + clusters: Vec, +} + +impl ClusterChoice { + /// `None` while the first entry, this server only, is picked. + fn picked(&self) -> Option { + let at = usize::try_from(self.row.upgrade()?.selected()).ok()?.checked_sub(1)?; + self.clusters.get(at).cloned() + } +} + +/// A Cluster row only where the server is one of a cluster it lists: a +/// single server lists none. +fn cluster_choice( + prompt: &mut CatalogPrompt, + capabilities: &CatalogCapabilities, + clusters: Vec, +) -> Option { + if !capabilities.on_cluster || clusters.is_empty() { + return None; + } + let entries: Vec = std::iter::once(gettext("This server only")) + .chain(clusters.iter().cloned()) + .collect(); + let row = prompt.combo(&gettext("Cluster"), &entries); + Some(ClusterChoice { row, clusters }) +} + +/// A destructive catalog question as the HIG asks for one: Cancel first, +/// the default and what Escape answers; the verb in red; the options as +/// rows of a boxed list; and under Confirm Writes the statements, written +/// again whenever an option changes. +struct CatalogPrompt { + dialog: adw::AlertDialog, + content: gtk::Box, + options: gtk::ListBox, + switches: Vec, + combos: Vec, + sql: Option, +} + +impl CatalogPrompt { + fn new(heading: &str, body: &str, verb: (&'static str, &str), asks_first: Option<&str>) -> Self { + let body = match asks_first { + Some(connection) => format!("{}\n{body}", asks_before_writes(connection)), + None => body.to_owned(), + }; + let dialog = adw::AlertDialog::new(Some(heading), Some(&body)); + // A table, schema or connection name is never markup. + dialog.set_heading_use_markup(false); + dialog.set_body_use_markup(false); + dialog.add_response(CANCEL, &gettext("_Cancel")); + dialog.add_response(verb.0, verb.1); + dialog.set_response_appearance(verb.0, adw::ResponseAppearance::Destructive); + dialog.set_default_response(Some(CANCEL)); + dialog.set_close_response(CANCEL); + let options = gtk::ListBox::builder() + .selection_mode(gtk::SelectionMode::None) + .visible(false) + .build(); + options.add_css_class("boxed-list"); + let content = gtk::Box::new(gtk::Orientation::Vertical, 12); + content.append(&options); + let sql = asks_first.and_then(|_| { + dialog.set_prefer_wide_layout(true); + let view = crate::ui::review_dialog::sql_view("", &dialog); + content.append(&view); + view.child().and_downcast::().map(|text| text.buffer()) + }); + Self { + dialog, + content, + options, + switches: Vec::new(), + combos: Vec::new(), + sql, + } + } + + fn switch(&mut self, title: &str, subtitle: &str) -> glib::WeakRef { + let row = adw::SwitchRow::builder().title(title).subtitle(subtitle).build(); + self.options.append(&row); + self.options.set_visible(true); + let weak = row.downgrade(); + self.switches.push(row); + weak + } + + fn combo(&mut self, title: &str, entries: &[String]) -> glib::WeakRef { + let entries: Vec<&str> = entries.iter().map(String::as_str).collect(); + let row = adw::ComboRow::builder() + .title(title) + .model(>k::StringList::new(&entries)) + .build(); + self.options.append(&row); + self.options.set_visible(true); + let weak = row.downgrade(); + self.combos.push(row); + weak + } + + /// Write the statements and turn the verb on or off now, and again + /// whenever an option changes. `state` gives the plan the options + /// make and whether the verb may answer with them. + fn follow(&self, verb: &'static str, state: impl Fn() -> (Result, bool) + 'static) { + let dialog = self.dialog.downgrade(); + let sql = self.sql.clone(); + let refresh = Rc::new(move || { + let (planned, allowed) = state(); + if let Some(dialog) = dialog.upgrade() { + dialog.set_response_enabled(verb, allowed && planned.is_ok()); + } + if let Some(buffer) = &sql { + let shown = match &planned { + Ok(plan) => plan.preview(), + Err(error) => error_text::build_error_text(error), + }; + buffer.set_text(&shown); + } + }); + refresh(); + for row in &self.switches { + let refresh = refresh.clone(); + row.connect_active_notify(move |_| refresh()); + } + for row in &self.combos { + let refresh = refresh.clone(); + row.connect_selected_notify(move |_| refresh()); + } + } + + fn on_response(&self, verb: &'static str, answered: impl Fn() + 'static) { + self.dialog.connect_response(Some(verb), move |_, _| answered()); + } + + fn present(self, parent: Option<>k::Widget>) { + if !self.switches.is_empty() || !self.combos.is_empty() || self.sql.is_some() { + self.dialog.set_extra_child(Some(&self.content)); + } + self.dialog.present(parent); + } +} diff --git a/linux/crates/app/src/ui/workspace/health_banner.rs b/linux/crates/app/src/ui/workspace/health_banner.rs index ac7e9a0366..4982731292 100644 --- a/linux/crates/app/src/ui/workspace/health_banner.rs +++ b/linux/crates/app/src/ui/workspace/health_banner.rs @@ -571,6 +571,22 @@ mod tests { assert_eq!(banner.button_label().as_deref(), Some("Retry")); } + /// A database dropped under the session parks it rather than + /// retrying forever, and the banner names the database. + #[gtk4::test] + fn a_session_whose_database_is_gone_parks_and_says_which() { + let gone = FakeEstablish::Fail(ConnectError::Driver(DriverError::DatabaseNotFound { + database: "shop".to_owned(), + })); + let test = parked("DatabaseGone", vec![gone], None); + + let banner = banner(&test); + wait_until(WAIT, || { + banner.title() == "The database “shop” does not exist on the server." + }) + .unwrap_or_else(|_| panic!("the banner said {}", banner.title())); + } + #[gtk4::test] fn review_action_disabled_for_other_failures() { let refused = FakeEstablish::Fail(ConnectError::Driver(DriverError::auth(None))); diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index d2d5419edb..9adaecbb4c 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -6,6 +6,8 @@ //! window shows the connection picker once it holds no session. mod assistant; +mod catalog_dialogs; +mod catalog_ops; mod close_flow; mod definitions; mod diagram; @@ -60,6 +62,7 @@ use tablepro_core::{DataModel, EditorSession}; use tablepro_session::{ObjectListSnapshot, Session, SessionHealth, SessionKey}; use uuid::Uuid; +pub(crate) use self::catalog_ops::DatabaseDrop; use self::session_watches::SessionWatches; use self::tab_title::ConnectionLabel; pub(crate) use self::workspace_shell::WorkspaceShell; @@ -632,6 +635,28 @@ pub(crate) enum WorkspaceInput { }, /// The table is gone, so its tabs close without asking. DropTableSucceeded(tablepro_core::TableRef), + /// Sidebar "Truncate Table…" or "Delete All Documents…". + TruncateTablePrompt(tablepro_core::TableRef), + /// Sidebar "New Schema…". + NewSchemaPrompt, + /// Sidebar "Drop Schema…". + DropSchemaPrompt(String), + /// "New Database…" or "New Keyspace…" on the connection's row. + NewDatabasePrompt, + /// "Drop Database…" or "Drop Keyspace…", from the sidebar or the + /// window. + DropDatabasePrompt(String), + /// The window let go of the database the user confirmed dropping, + /// so the DROP can run (0033). + DropDatabaseReleased { + target: DatabaseDrop, + options: tablepro_core::ddl::DropDatabaseOptions, + }, + /// A database of the connection was created or dropped, here or in + /// another of its sessions. + DatabasesChanged, + /// A step of a catalog change after its prompt. + Catalog(catalog_ops::CatalogInput), /// A table tab's grid asked to open the rows a foreign key leads to. FollowReference { tab: uuid::Uuid, @@ -1229,6 +1254,26 @@ impl WorkspaceModel { self.context.active.take(); } + fn on_editor_transaction_changed(&self) { + self.refresh_unsaved(); + self.refresh_transaction_banner(); + } + + fn on_editor_tab_run_finished(&mut self, id: Uuid, outcome: crate::workspace::WorkOutcome) { + self.report_work(id, crate::workspace::WorkKind::Query, outcome); + self.on_editor_run_finished(id); + } + + fn on_export_ended(&self, tab: Uuid, outcome: crate::workspace::WorkOutcome) { + self.report_work(tab, crate::workspace::WorkKind::Export, outcome); + } + + fn on_drafts_not_saved(&self) { + self.show_toast(&crate::i18n::gettext( + "Editor text could not be saved to disk, so it may not come back after TablePro restarts.", + )); + } + fn on_export_results(&self, tab: Uuid, offer: ExportOffer) { if let Some(window) = self.window() { let dialect = self.context.active.get().and_then(|session| session.current_dialect()); @@ -1270,10 +1315,7 @@ impl WorkspaceModel { WorkspaceInput::StopSaving(id) => self.stop_saving(id), WorkspaceInput::Workspace(event) => self.on_workspace_event(event), WorkspaceInput::SavesInFlightChanged(count) => self.on_saves_in_flight_changed(count), - WorkspaceInput::EditorTransactionChanged => { - self.refresh_unsaved(); - self.refresh_transaction_banner(); - } + WorkspaceInput::EditorTransactionChanged => self.on_editor_transaction_changed(), WorkspaceInput::ShowTransactionTab => self.show_transaction_tab(), WorkspaceInput::WorkspaceTabClosed(id) => self.close_workspace_tab_by_id(id), WorkspaceInput::CloseWorkspaceTab(id) => self.close_workspace_tab(id), @@ -1292,6 +1334,14 @@ impl WorkspaceModel { WorkspaceInput::DropTableChecked { table, referencing } => self.on_drop_table_checked(table, &referencing), WorkspaceInput::DropTableConfirmed { schema, table } => self.on_drop_table_confirmed(schema, table), WorkspaceInput::DropTableSucceeded(table) => self.on_drop_table_succeeded(table), + WorkspaceInput::TruncateTablePrompt(table) => self.on_truncate_table_prompt(table), + WorkspaceInput::NewSchemaPrompt => self.on_new_schema_prompt(), + WorkspaceInput::DropSchemaPrompt(schema) => self.on_drop_schema_prompt(schema), + WorkspaceInput::NewDatabasePrompt => self.on_new_database_prompt(), + WorkspaceInput::DropDatabasePrompt(database) => self.on_drop_database_prompt(database), + WorkspaceInput::DropDatabaseReleased { target, options } => self.on_database_released(target, options), + WorkspaceInput::DatabasesChanged => self.on_databases_changed(), + WorkspaceInput::Catalog(input) => self.on_catalog_input(input), WorkspaceInput::FollowReference { tab, reference } => self.on_follow_reference(tab, *reference), WorkspaceInput::ReferenceChecked { tab, reference, found } => { self.on_reference_checked(tab, *reference, found); @@ -1299,10 +1349,7 @@ impl WorkspaceModel { WorkspaceInput::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), WorkspaceInput::NewEditorTab => self.open_editor_tab(None), WorkspaceInput::EditorTabRunStateChanged(id, running) => self.on_editor_tab_run_state_changed(id, running), - WorkspaceInput::EditorTabRunFinished(id, outcome) => { - self.report_work(id, crate::workspace::WorkKind::Query, outcome); - self.on_editor_run_finished(id); - } + WorkspaceInput::EditorTabRunFinished(id, outcome) => self.on_editor_tab_run_finished(id, outcome), WorkspaceInput::EditorTabContentChanged(id) => self.on_editor_tab_content_changed(id), WorkspaceInput::EditorTabFileChanged(id) => self.on_editor_tab_file_changed(id), WorkspaceInput::EditorTabDatabaseChanged(id) => self.on_editor_tab_database_changed(id), @@ -1314,9 +1361,7 @@ impl WorkspaceModel { WorkspaceInput::SharesBar => self.on_shares_bar(), WorkspaceInput::SavedConnectionsChanged => self.on_saved_connections_changed(), WorkspaceInput::WorkspaceLayoutReset(outcome) => self.on_workspace_layout_reset(outcome), - WorkspaceInput::DraftsNotSaved => self.show_toast(&crate::i18n::gettext( - "Editor text could not be saved to disk, so it may not come back after TablePro restarts.", - )), + WorkspaceInput::DraftsNotSaved => self.on_drafts_not_saved(), WorkspaceInput::ShowHistory => self.on_show_history(), WorkspaceInput::ShowSavedQueries => self.on_show_saved_queries(), WorkspaceInput::ImportCsvRequested { schema, table } => self.on_import_csv_requested(schema, table), @@ -1333,9 +1378,7 @@ impl WorkspaceModel { WorkspaceInput::ReplaceActiveTabQuery(text) => self.on_replace_active_tab_query(text), WorkspaceInput::RefreshPage => self.on_refresh_active_tab(), WorkspaceInput::ExportResults { tab, offer } => self.on_export_results(tab, *offer), - WorkspaceInput::ExportEnded { tab, outcome } => { - self.report_work(tab, crate::workspace::WorkKind::Export, outcome); - } + WorkspaceInput::ExportEnded { tab, outcome } => self.on_export_ended(tab, outcome), WorkspaceInput::ReopenClosedTab(tab) => self.on_reopen_closed_tab(tab), WorkspaceInput::RefreshTab(id) => self.on_refresh_tab(id), WorkspaceInput::FocusTab(id) => self.focus_tab(id), diff --git a/linux/crates/app/src/ui/workspace/saves.rs b/linux/crates/app/src/ui/workspace/saves.rs index e8000e11d1..4e07a724e3 100644 --- a/linux/crates/app/src/ui/workspace/saves.rs +++ b/linux/crates/app/src/ui/workspace/saves.rs @@ -126,6 +126,8 @@ impl WorkspaceModel { let message = cause.sentence(&self.tab_name(tab)); self.send_to_structure_tab(tab, structure_tab::SaveInput::SaveOutcomeUnknown(message)); } + // A database is created or dropped from the sidebar, never by a tab's save. + (WriteKind::DatabaseChanges, _) => {} } // The close prompt waited for the save in front of the user, and // goes on to close the tab or asks about it again. diff --git a/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs b/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs index 72b6ae1c42..a970d01a4b 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/connection_tree.rs @@ -813,7 +813,8 @@ impl TreeInner { } /// A lone schema, as a file or a key-value engine has, opens as it - /// comes, while its connection's row is open. + /// comes, while its connection's row is open. An empty one opens + /// once it holds something, a batch of rows at a time like any other. fn open_lone_schema(&self, key: &SessionKey) { let Some(view) = self.view_of(key) else { return; @@ -821,7 +822,8 @@ impl TreeInner { let schemas = view.schemas(); let lone = (schemas.n_items() == 1) .then(|| schemas.item(0).and_downcast::()) - .flatten(); + .flatten() + .filter(|schema| schema.rows().is_some_and(|rows| rows.n_items() > 0)); { let mut branches = self.branches.borrow_mut(); let Some(branch) = branches.get_mut(key) else { @@ -1202,6 +1204,7 @@ impl TreeInner { &ObjectKey(object.target()), object.kind(), view.affordances(), + view.holds(), )); } let node = item.downcast_ref::()?; @@ -1209,7 +1212,9 @@ impl TreeInner { NodeKind::Schema => { let view = view?; self.popover.insert_action_group(GROUP, Some(view.actions())); - schema_menu(node.schema_name().as_deref(), view.affordances(), view.holds()) + let schema = node.schema_name(); + let droppable = schema.as_deref().is_some_and(|schema| view.droppable(schema)); + schema_menu(schema.as_deref(), droppable, view.affordances(), view.holds()) } NodeKind::Connection => self.connection_menu(node), NodeKind::Group => self.group_menu(node), @@ -1287,15 +1292,13 @@ impl TreeInner { stored: self.stored(node).is_some(), } }; - // A saved connection's own row, on an engine whose catalog holds - // one database (0030). - let opens_databases = matches!(key, SessionKey::Saved(_)) - && self - .branches - .borrow() - .get(&key) - .is_some_and(|branch| branch.view.affordances().opens_databases); - let lead = session_section(&key, state, opens_databases); + let affordances = self + .branches + .borrow() + .get(&key) + .map(|branch| branch.view.affordances()) + .unwrap_or_default(); + let lead = session_section(&key, state, affordances); let saved = match &key { SessionKey::Saved(id) => self.saved.get(*id), SessionKey::SavedIn { .. } | SessionKey::File(_) => None, diff --git a/linux/crates/app/src/ui/workspace/sidebar/mod.rs b/linux/crates/app/src/ui/workspace/sidebar/mod.rs index 77478048a8..97b48083d4 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/mod.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/mod.rs @@ -23,11 +23,12 @@ mod tree_menu; mod tree_node; mod tree_row; -use std::collections::BTreeSet; use std::sync::Arc; use gtk4::prelude::*; use tablepro_core::DataModel; +use tablepro_core::ddl; +use tablepro_core::dialect::{DatabaseLayout, SqlDialect}; use tablepro_core::meta::{ObjectInfo, ObjectScope, SchemaInfo, TableRef}; use tablepro_session::runtime::{TaskFailure, Tasks}; use tablepro_session::{ObjectListSnapshot, Session}; @@ -87,11 +88,15 @@ fn workspace_input(event: SidebarEvent) -> WorkspaceInput { schema: table.schema, table: table.name, }, + SidebarEvent::TruncateTable(table) => WorkspaceInput::TruncateTablePrompt(table), SidebarEvent::DropTable(table) => WorkspaceInput::DropTablePrompt { schema: table.schema, table: table.name, }, SidebarEvent::NewTable(schema) => WorkspaceInput::NewTableTab { schema }, + SidebarEvent::NewSchema => WorkspaceInput::NewSchemaPrompt, + SidebarEvent::DropSchema(schema) => WorkspaceInput::DropSchemaPrompt(schema), + SidebarEvent::DropDatabase(database) => WorkspaceInput::DropDatabasePrompt(database), SidebarEvent::ShowDiagram(schema) => WorkspaceInput::ShowDiagram { schema }, SidebarEvent::ShowDefinition(object) => WorkspaceInput::ShowDefinition(object), } @@ -106,9 +111,9 @@ pub(crate) struct SidebarListing { } /// Read the session's schemas and index `objects` under them, or under -/// `known` when they cannot be read. The index is built on a blocking -/// thread: sorting a large catalog by collation keys would freeze the -/// window. +/// `known` when they cannot be read, offering to drop none the session +/// keeps. The index is built on a blocking thread: sorting a large +/// catalog by collation keys would freeze the window. pub(super) async fn list_catalog( session: Arc, tasks: Tasks, @@ -122,37 +127,31 @@ pub(super) async fn list_catalog( None } }; + let dialect = session.current_dialect(); + let database = session.database().map(str::to_owned); tasks .spawn_blocking_task(move || { - let schemas: Option> = schemas.map(|schemas| shown_schemas(&schemas, &objects).into()); - SidebarListing { - index: SidebarIndex::build(&objects, schemas.as_deref().unwrap_or(&known)), - schemas, - } + let mut index = SidebarIndex::build(&objects, schemas.as_deref().unwrap_or(&known)); + index.withhold_drops(|name| kept(dialect.as_deref(), database.as_deref(), name)); + SidebarListing { index, schemas } }) .await } -/// The schemas the sidebar shows sections for: the session's own, and -/// any an object names that the session does not list, as a server whose -/// databases are its schemas lists only the one it is in. -fn shown_schemas(schemas: &[SchemaInfo], objects: &[ObjectInfo]) -> Vec { - let known: BTreeSet<&str> = schemas.iter().map(|schema| schema.name.as_str()).collect(); - let extra: BTreeSet<&str> = objects - .iter() - .filter(|object| object.kind.is_browsable() || object.kind.has_definition()) - .filter_map(|object| object.schema.as_deref()) - .filter(|schema| !known.contains(schema)) - .collect(); - schemas - .iter() - .cloned() - .chain(extra.into_iter().map(|name| SchemaInfo { - name: name.to_owned(), - is_default: false, - is_system: false, - })) - .collect() +/// Whether the sidebar never offers to drop the schema `name`. Where the +/// schemas are the server's databases, that is the database the session +/// opened or one the engine keeps for itself; elsewhere a schema the +/// engine keeps, whatever the database is called. The index already +/// withholds the default schema. A session with no connection offers +/// none. +fn kept(dialect: Option<&dyn SqlDialect>, database: Option<&str>, name: &str) -> bool { + let Some(dialect) = dialect else { + return true; + }; + match dialect.database_layout() { + DatabaseLayout::AsSchemas => database == Some(name) || ddl::protects_database(dialect, name), + DatabaseLayout::WithSchemas | DatabaseLayout::Single => ddl::protects_schema(dialect, name), + } } impl WorkspaceModel { @@ -256,10 +255,13 @@ impl WorkspaceModel { self.catalog_banner.set_revealed(true); } - /// Read the session's tables again, and every table's columns on - /// their next ask: what changed the schema did not say which tables - /// it touched. + /// Read the session's schemas and tables again, and every table's + /// columns on their next ask: what changed the schema did not say + /// which tables it touched. Another tab or client may have made or + /// dropped a database, so the editors' database menus are read again + /// too. pub(super) fn refresh_catalog(&mut self) { + self.relist_editor_databases(); if self.keys.is_some() { self.list_keys(); return; @@ -268,7 +270,27 @@ impl WorkspaceModel { return; }; session.invalidate_columns(); - self.on_schema_changed(None, None); + self.diagrams_out_of_date(None, false); + self.catalog_shape_changed(); + } + + /// A schema or database was made or dropped. The schemas are read + /// before the objects, so the list the catalog watch takes to the + /// sidebar is indexed under the new ones; then the default schema's + /// list, which the editor's completion reads, one read at a time. + pub(super) fn catalog_shape_changed(&self) { + let Some(session) = self.context.active.get() else { + return; + }; + drop(self.context.tasks.spawn_task(async move { + // A failure keeps the schemas there were, under which the + // new objects still show. + if let Err(error) = session.refresh_schemas().await { + tracing::debug!(%error, "the schemas could not be read again"); + } + let _ = session.refresh_objects(ObjectScope::AllSchemas).await; + let _ = session.refresh_objects(ObjectScope::DefaultSchema).await; + })); } /// A run that sent DDL, or a commit that may publish it, changes what diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs index c3b26f3d27..e44b5ec158 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_affordances.rs @@ -1,3 +1,4 @@ +use tablepro_core::ddl::{CatalogCapabilities, catalog_capabilities, protects_database}; use tablepro_core::dialect::{DatabaseLayout, SqlDialect}; use tablepro_session::Session; @@ -15,35 +16,70 @@ pub(crate) struct SidebarAffordances { /// Another of the server's databases opens beside the connection, /// where a connection's catalog describes only its own (0030). pub opens_databases: bool, + /// The schemas the catalog lists are the server's databases, so a + /// schema's row drops a database. + pub lists_databases: bool, + /// What the engine does to whole tables, schemas and databases + /// (core 0011). + pub catalog: CatalogCapabilities, + /// Whether the database the session opened may be dropped from its + /// row beside the connection: never one the engine keeps for itself. + pub own_database_droppable: bool, } impl SidebarAffordances { - pub(crate) fn of(read_only: bool, dialect: Option<&dyn SqlDialect>) -> Self { + pub(crate) fn of(read_only: bool, dialect: Option<&dyn SqlDialect>, database: Option<&str>) -> Self { let ddl = dialect.and_then(|dialect| dialect.ddl()); + let layout = dialect.map(|dialect| dialect.database_layout()); Self { read_only, can_create_table: ddl.is_some_and(|ddl| ddl.capabilities().create_table), can_edit_structure: ddl.is_some(), speaks_sql: dialect.is_some_and(|dialect| dialect.grammar().is_sql()), - opens_databases: dialect.is_some_and(|dialect| dialect.database_layout() == DatabaseLayout::WithSchemas), + opens_databases: layout == Some(DatabaseLayout::WithSchemas), + lists_databases: layout == Some(DatabaseLayout::AsSchemas), + catalog: dialect.map(catalog_capabilities).unwrap_or_default(), + own_database_droppable: dialect + .zip(database) + .is_some_and(|(dialect, database)| !protects_database(dialect, database)), } } /// Read again after each reconnect, which can reach another server /// version. pub(crate) fn for_session(session: &Session) -> Self { - Self::of(session.read_only(), session.current_dialect().as_deref()) + Self::of( + session.read_only(), + session.current_dialect().as_deref(), + session.database(), + ) } /// Whether a section header has its New Table button. pub(crate) fn offers_new_table(self) -> bool { self.can_create_table && !self.read_only } + + /// Whether a schema's row offers to make another schema. Where the + /// schemas are the server's databases, a new one is New Database… + /// on the connection's row. + pub(crate) fn offers_new_schema(self) -> bool { + self.catalog.create_schema && !self.read_only && !self.lists_databases + } + + pub(crate) fn offers_new_database(self) -> bool { + self.catalog.create_database && !self.read_only + } + + pub(crate) fn offers_drop_database(self) -> bool { + self.catalog.drop_database && !self.read_only + } } #[cfg(test)] mod tests { use tablepro_core::ServerInfo; + use tablepro_core::ddl::DatabaseNoun; use tablepro_test_support::FakeDialect; use super::*; @@ -54,16 +90,18 @@ mod tests { let clickhouse = tablepro_driver_clickhouse::ClickHouseDialect::new(&ServerInfo::new("ClickHouse", "25.8", 25, 8, 0)); - let none = SidebarAffordances::of(false, Some(&FakeDialect)); - let creates = SidebarAffordances::of(false, Some(&postgres)); - let edits_only = SidebarAffordances::of(false, Some(&clickhouse)); - let gone = SidebarAffordances::of(false, None); + let none = SidebarAffordances::of(false, Some(&FakeDialect), None); + let creates = SidebarAffordances::of(false, Some(&postgres), Some("app")); + let edits_only = SidebarAffordances::of(false, Some(&clickhouse), Some("default")); + let gone = SidebarAffordances::of(false, None, Some("app")); assert!(!none.can_edit_structure && !none.offers_new_table()); assert!(creates.speaks_sql); assert!(creates.opens_databases, "PostgreSQL lists one database's schemas"); + assert!(!creates.lists_databases); assert!(!edits_only.opens_databases, "ClickHouse lists every database already"); - let mongo = SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect)); + assert!(edits_only.lists_databases); + let mongo = SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect), Some("shop")); assert!( !mongo.can_edit_structure && !mongo.speaks_sql, "a collection has no SQL to show" @@ -80,11 +118,63 @@ mod tests { #[test] fn affordances_hide_drop_when_read_only() { let postgres = tablepro_driver_postgres::PgDialect::new(&ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + let writable = SidebarAffordances { + catalog: CatalogCapabilities { + create_schema: true, + create_database: true, + drop_database: true, + ..CatalogCapabilities::default() + }, + ..SidebarAffordances::default() + }; - let read_only = SidebarAffordances::of(true, Some(&postgres)); + let read_only = SidebarAffordances::of(true, Some(&postgres), Some("app")); assert!(read_only.read_only); assert!(read_only.can_create_table, "the dialect still creates tables"); assert!(!read_only.offers_new_table()); + assert!(writable.offers_new_schema() && writable.offers_new_database() && writable.offers_drop_database()); + let locked = SidebarAffordances { + read_only: true, + ..writable + }; + assert!(!locked.offers_new_schema() && !locked.offers_new_database() && !locked.offers_drop_database()); + } + + #[test] + fn a_server_whose_databases_are_its_schemas_makes_no_schema_of_its_own() { + let databases = SidebarAffordances { + lists_databases: true, + catalog: CatalogCapabilities { + create_schema: true, + create_database: true, + ..CatalogCapabilities::default() + }, + ..SidebarAffordances::default() + }; + + assert!(!databases.offers_new_schema()); + assert!(databases.offers_new_database()); + } + + /// MongoDB's catalog comes from the core, which writes its shell. + #[test] + fn mongodb_empties_collections_and_makes_and_drops_databases() { + let mongo = SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect), Some("shop")); + + assert!(mongo.lists_databases); + assert!(mongo.catalog.truncate_table); + assert!(mongo.offers_new_database() && mongo.offers_drop_database()); + assert_eq!(mongo.catalog.database_noun, DatabaseNoun::Database); + assert!(mongo.own_database_droppable); + assert!( + !SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect), Some("admin")) + .own_database_droppable, + "the server keeps admin for itself" + ); + assert!( + !SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect), None).own_database_droppable, + "a session that opened no database has none to drop" + ); } } diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs index 4be76f9390..ca54f28fd1 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_event.rs @@ -9,9 +9,17 @@ pub(crate) enum SidebarEvent { EditStructure(TableRef), ShowCreateTable(TableRef), ImportCsv(TableRef), + /// Empty the table, or a document engine's collection, and keep it. + TruncateTable(TableRef), DropTable(TableRef), /// A new table in this schema, or the default one. NewTable(Option), + NewSchema, + /// A schema the list still offered to drop. + DropSchema(String), + /// A database the engine lists as a schema, which the list still + /// offered to drop. + DropDatabase(String), /// This schema's ER diagram, or the default one's. ShowDiagram(Option), ShowDefinition(ObjectInfo), diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs index bed654e8ad..2361a4e839 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs @@ -18,6 +18,8 @@ const SYSTEM_RANK: u32 = 2; pub(crate) struct SidebarIndex { entries: Vec, sections: Vec
, + /// Each section's id, in list order. + order: Vec, /// Every entry's `search_key` of `schema.name`, then a trigger's of /// `schema.table` after a U+0001, in list order, each ended by a NUL. /// No name or typed search holds either, so no match spans two. A @@ -52,6 +54,15 @@ pub(crate) struct IndexEntry { pub(crate) struct Section { pub(crate) title: String, pub(crate) rank: u32, + /// The schema its actions name: the one the session lists, or the + /// one its objects name when the session lists none of that name. + /// None where the engine lists the default schema's objects with no + /// schema, as SQLite does its main, so its actions name none too. + pub(crate) schema: Option, + /// Whether the list offers to drop the schema: the session lists it + /// as one of the user's own, it is not the default, and nothing + /// withheld it. + pub(crate) droppable: bool, } impl SidebarIndex { @@ -70,11 +81,27 @@ impl SidebarIndex { } /// `untitled` heads the objects with no schema when no schema is the - /// default one. + /// default one. Every schema of the user's own the session lists has + /// a section, empty or not, so its menu reaches it; the engine's own + /// have one only for what they hold. fn build_titled(objects: &[ObjectInfo], schemas: &[SchemaInfo], untitled: String) -> Self { let placement = Placement::new(schemas, untitled); let mut sections: Vec
= Vec::new(); let mut section_ids: HashMap<(u32, &str), u32> = HashMap::new(); + for schema in schemas.iter().filter(|schema| !schema.is_system) { + let (title, rank) = placement.section_of(Some(schema.name.as_str())); + section_ids.entry((rank, title)).or_insert_with(|| { + add_section( + &mut sections, + Section { + title: title.to_owned(), + rank, + schema: Some(schema.name.clone()), + droppable: !schema.is_default, + }, + ) + }); + } let mut keyed: Vec<(u32, glib::CollationKey, String, IndexEntry)> = Vec::new(); for object in objects .iter() @@ -82,12 +109,21 @@ impl SidebarIndex { { let (title, rank) = placement.section_of(object.schema.as_deref()); let section = *section_ids.entry((rank, title)).or_insert_with(|| { - sections.push(Section { - title: title.to_owned(), - rank, - }); - u32::try_from(sections.len() - 1).unwrap_or(u32::MAX) + add_section( + &mut sections, + Section { + title: title.to_owned(), + rank, + schema: object.schema.clone(), + droppable: false, + }, + ) }); + if object.schema.is_none() + && let Some(placed) = sections.get_mut(section as usize) + { + placed.schema = None; + } let mut search = search_key(&qualified(object.schema.as_deref(), &object.name).to_lowercase()); if let Some(table) = &object.parent_table { search.push('\u{1}'); @@ -104,16 +140,21 @@ impl SidebarIndex { )); } let order = section_order(§ions); + let mut place = vec![0; sections.len()]; + for (at, &id) in order.iter().enumerate() { + place[id as usize] = at; + } keyed.sort_by( |(left, left_name, _, left_entry), (right, right_name, _, right_entry)| { - order[*left as usize] - .cmp(&order[*right as usize]) + place[*left as usize] + .cmp(&place[*right as usize]) .then_with(|| kind_rank(left_entry.object.kind).cmp(&kind_rank(right_entry.object.kind))) .then_with(|| left_name.cmp(right_name)) }, ); let mut index = Self { sections, + order, ..Self::default() }; for (_, _, search, entry) in keyed { @@ -139,6 +180,7 @@ impl SidebarIndex { self.entries.get(index as usize) } + #[cfg(test)] pub(crate) fn section(&self, section: u32) -> Option<&Section> { self.sections.get(section as usize) } @@ -149,10 +191,39 @@ impl SidebarIndex { } /// Every section's entries, in list order. + #[cfg(test)] pub(crate) fn runs(&self) -> &[SectionRun] { &self.runs } + /// Every section in list order, with its run and the run's place + /// among the runs when it holds anything. A search's picks are split + /// by run, so an empty section never takes another's. + pub(crate) fn listed_sections(&self) -> impl Iterator)> { + let mut runs = self.runs.iter().copied().enumerate().peekable(); + self.order.iter().filter_map(move |&id| { + let section = self.sections.get(id as usize)?; + Some((section, runs.next_if(|(_, run)| run.section == id))) + }) + } + + /// Stop offering to drop each schema `kept` names: the database the + /// session opened, or one the engine keeps for itself. + pub(crate) fn withhold_drops(&mut self, kept: impl Fn(&str) -> bool) { + for section in &mut self.sections { + if section.schema.as_deref().is_none_or(&kept) { + section.droppable = false; + } + } + } + + /// Whether the list offers to drop the schema `name`. + pub(crate) fn droppable(&self, name: &str) -> bool { + self.sections + .iter() + .any(|section| section.droppable && section.schema.as_deref() == Some(name)) + } + /// `entries`, in list order, split by the run each one is in: one /// list per run, in run order. pub(crate) fn split_by_run(&self, entries: &[u32]) -> Vec> { @@ -242,8 +313,13 @@ fn kind_rank(kind: ObjectKind) -> u8 { } } -/// Each section's place in the list, by its id. -fn section_order(sections: &[Section]) -> Vec { +fn add_section(sections: &mut Vec
, section: Section) -> u32 { + sections.push(section); + u32::try_from(sections.len() - 1).unwrap_or(u32::MAX) +} + +/// Each section's id, in list order. +fn section_order(sections: &[Section]) -> Vec { let keys: Vec = sections.iter().map(|section| sort_key(§ion.title)).collect(); let mut ids: Vec = (0..sections.len()).collect(); ids.sort_by(|&left, &right| { @@ -252,11 +328,9 @@ fn section_order(sections: &[Section]) -> Vec { .cmp(§ions[right].rank) .then_with(|| keys[left].cmp(&keys[right])) }); - let mut order = vec![0; sections.len()]; - for (place, id) in ids.into_iter().enumerate() { - order[id] = place; - } - order + ids.into_iter() + .map(|id| u32::try_from(id).unwrap_or(u32::MAX)) + .collect() } /// The key GtkStringSorter sorts by when it ignores case. @@ -627,4 +701,109 @@ mod tests { ] ); } + + /// Each section's title, the schema it names, whether it offers a + /// drop, and its run's place and span, in list order. + fn sections(index: &SidebarIndex) -> Vec<(String, Option, bool, Option<(usize, u32, u32)>)> { + index + .listed_sections() + .map(|(section, run)| { + ( + section.title.clone(), + section.schema.clone(), + section.droppable, + run.map(|(at, run)| (at, run.start, run.end)), + ) + }) + .collect() + } + + #[test] + fn every_listed_schema_of_the_users_own_has_a_section_even_empty() { + let schemas = [ + schema("public", true, false), + schema("sales", false, false), + schema("archive", false, false), + schema("pg_catalog", false, true), + schema("information_schema", false, true), + ]; + + let index = SidebarIndex::build( + &[ + table("sales", "orders"), + table("public", "users"), + table("information_schema", "tables"), + ], + &schemas, + ); + + let named = |name: &str| Some(name.to_owned()); + assert_eq!( + sections(&index), + [ + ("public".to_owned(), named("public"), false, Some((0, 0, 1))), + ("archive".to_owned(), named("archive"), true, None), + ("sales".to_owned(), named("sales"), true, Some((1, 1, 2))), + ( + "information_schema".to_owned(), + named("information_schema"), + false, + Some((2, 2, 3)) + ), + ], + "an engine's own schema shows only for what it holds" + ); + assert_eq!( + sections(&SidebarIndex::build(&[], &[schema("public", true, false)])), + [("public".to_owned(), named("public"), false, None)] + ); + } + + #[test] + fn a_section_names_its_schema_as_the_engine_names_its_objects() { + let attached = [schema("main", true, false), schema("archive", false, false)]; + + let listed = SidebarIndex::build( + &[object(None, "notes", ObjectKind::Table), table("other", "old")], + &attached, + ); + let empty = SidebarIndex::build(&[], &attached); + + assert_eq!( + sections(&listed), + [ + ("main".to_owned(), None, false, Some((0, 0, 1))), + ("archive".to_owned(), Some("archive".to_owned()), true, None), + ("other".to_owned(), Some("other".to_owned()), false, Some((1, 1, 2))), + ], + "a schema only an object names is not the session's to drop" + ); + assert_eq!( + sections(&empty) + .into_iter() + .map(|(_, schema, ..)| schema) + .collect::>(), + [Some("main".to_owned()), Some("archive".to_owned())] + ); + } + + #[test] + fn a_drop_is_offered_only_for_a_listed_schema_nothing_withheld() { + let mut index = SidebarIndex::build( + &[table("public", "users"), table("audit", "log")], + &[ + schema("public", true, false), + schema("sales", false, false), + schema("archive", false, false), + ], + ); + + index.withhold_drops(|name| name == "sales"); + + assert!(index.droppable("archive")); + assert!(!index.droppable("sales"), "withheld"); + assert!(!index.droppable("public"), "the default schema"); + assert!(!index.droppable("audit"), "not listed by the session"); + assert!(!index.droppable("gone")); + } } diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs index 9a3bbc8f45..68208f3b5f 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_menu.rs @@ -1,5 +1,6 @@ use gtk4::gio; use gtk4::prelude::*; +use tablepro_core::ddl::DatabaseNoun; use tablepro_core::meta::ObjectKind; use super::object_key::ObjectKey; @@ -11,8 +12,15 @@ pub(super) const OPEN_IN_NEW_TAB: &str = "open-in-new-tab"; pub(super) const EDIT_STRUCTURE: &str = "edit-structure"; pub(super) const SHOW_CREATE_TABLE: &str = "show-create-table"; pub(super) const IMPORT_CSV: &str = "import-csv"; +pub(super) const TRUNCATE_TABLE: &str = "truncate-table"; pub(super) const DROP_TABLE: &str = "drop-table"; pub(super) const NEW_TABLE: &str = "new-table"; +pub(super) const NEW_SCHEMA: &str = "new-schema"; +/// A schema's drop, whose target is the schema's name and never none. +pub(super) const DROP_SCHEMA: &str = "drop-schema"; +/// The drop of a database the engine lists as a schema, whose target is +/// its name. +pub(super) const DROP_DATABASE: &str = "drop-database"; pub(super) const SHOW_DIAGRAM: &str = "show-diagram"; /// A schema's diagram, whose target is the schema, as New Table's is. pub(super) const SHOW_SCHEMA_DIAGRAM: &str = "show-schema-diagram"; @@ -23,9 +31,14 @@ pub(super) const GROUP: &str = "sidebar"; /// one popover serves every row and acts on the object it opened for, /// even after the list changes under it. What writes sits in sections of /// its own, and a read-only connection is not offered it. A view's shape -/// comes from its query, so only a table offers to change or drop it, -/// and a routine, a sequence or a trigger is changed in the editor. -pub(crate) fn object_menu(key: &ObjectKey, kind: ObjectKind, affordances: SidebarAffordances) -> gio::Menu { +/// comes from its query, so only a table offers to change, empty or drop +/// it, and a routine, a sequence or a trigger is changed in the editor. +pub(crate) fn object_menu( + key: &ObjectKey, + kind: ObjectKind, + affordances: SidebarAffordances, + holds: Holds, +) -> gio::Menu { let menu = gio::Menu::new(); let open = gio::Menu::new(); if let ObjectTarget::Definition(_) = key.0 { @@ -62,38 +75,103 @@ pub(crate) fn object_menu(key: &ObjectKey, kind: ObjectKind, affordances: Sideba let data = gio::Menu::new(); data.append_item(&item(&crate::i18n::gettext("_Import CSV…"), IMPORT_CSV, key)); menu.append_section(None, &data); + let danger = gio::Menu::new(); + if affordances.catalog.truncate_table { + // A collection is emptied by deleting its documents, which is + // what the dialog asks before. + let label = match holds { + Holds::Collections => crate::i18n::gettext("Delete All _Documents…"), + Holds::Tables | Holds::Keys => crate::i18n::gettext("Tr_uncate Table…"), + }; + danger.append_item(&item(&label, TRUNCATE_TABLE, key)); + } if affordances.can_edit_structure { - let danger = gio::Menu::new(); danger.append_item(&item(&crate::i18n::gettext("Drop _Table…"), DROP_TABLE, key)); + } + if danger.n_items() > 0 { menu.append_section(None, &danger); } menu } -/// A schema's menu: New Table where the connection creates tables, and -/// the schema's diagram where it holds tables in SQL. ClickHouse creates -/// none: a table there needs an engine and a sorting key the structure -/// tab has no place for. Keys have neither. Each item carries the schema, -/// or nothing for the objects an engine lists with none. -pub(crate) fn schema_menu(schema: Option<&str>, affordances: SidebarAffordances, holds: Holds) -> Option { +/// A schema's menu: New Table where the connection creates tables, New +/// Schema where it creates schemas, the schema's diagram where it holds +/// tables in SQL, and the schema's drop, or its database's, when the list +/// offers to drop it. ClickHouse creates no table: one there needs an +/// engine and a sorting key the structure tab has no place for. Keys +/// have none of them. New Table and the diagram carry the schema, or +/// nothing for the objects an engine lists with none; a drop carries the +/// schema's name and is never offered without one. +pub(crate) fn schema_menu( + schema: Option<&str>, + droppable: bool, + affordances: SidebarAffordances, + holds: Holds, +) -> Option { if holds == Holds::Keys { return None; } let menu = gio::Menu::new(); let target = schema.map(str::to_owned).to_variant(); + let create = gio::Menu::new(); if affordances.offers_new_table() { let item = gio::MenuItem::new(Some(&crate::i18n::gettext("_New Table…")), None); item.set_action_and_target_value(Some(&format!("{GROUP}.{NEW_TABLE}")), Some(&target)); - menu.append_item(&item); + create.append_item(&item); } + if affordances.offers_new_schema() { + create.append( + Some(&crate::i18n::gettext("New _Schema…")), + Some(&format!("{GROUP}.{NEW_SCHEMA}")), + ); + } + append_filled(&menu, &create); + let show = gio::Menu::new(); if affordances.speaks_sql { let item = gio::MenuItem::new(Some(&crate::i18n::gettext("Show ER _Diagram")), None); item.set_action_and_target_value(Some(&format!("{GROUP}.{SHOW_SCHEMA_DIAGRAM}")), Some(&target)); - menu.append_item(&item); + show.append_item(&item); + } + append_filled(&menu, &show); + if let (Some(schema), true, Some(action)) = (schema, droppable, schema_drop(affordances)) { + let danger = gio::Menu::new(); + let item = gio::MenuItem::new(Some(&drop_label(action, affordances.catalog.database_noun)), None); + item.set_action_and_target_value(Some(&format!("{GROUP}.{action}")), Some(&schema.to_variant())); + danger.append_item(&item); + menu.append_section(None, &danger); } (menu.n_items() > 0).then_some(menu) } +/// What a schema's row drops when the list offers to drop the schema: +/// the schema, or the database it is where the engine's databases are +/// its schemas. A read-only connection drops nothing. +pub(super) fn schema_drop(affordances: SidebarAffordances) -> Option<&'static str> { + if affordances.read_only { + return None; + } + if affordances.lists_databases { + affordances.catalog.drop_database.then_some(DROP_DATABASE) + } else { + affordances.catalog.drop_schema.then_some(DROP_SCHEMA) + } +} + +// The schema menu's other items hold S and D, which "Drop Schema" and "Drop Database" would take. +fn drop_label(action: &str, noun: DatabaseNoun) -> String { + match (action, noun) { + (DROP_SCHEMA, _) => crate::i18n::gettext("Drop Sc_hema…"), + (_, DatabaseNoun::Keyspace) => crate::i18n::gettext("Drop _Keyspace…"), + (_, DatabaseNoun::Database) => crate::i18n::gettext("Drop Data_base…"), + } +} + +fn append_filled(menu: &gio::Menu, section: &gio::Menu) { + if section.n_items() > 0 { + menu.append_section(None, section); + } +} + /// Whether `action` is offered for a `kind` on a connection with /// `affordances`, as `object_menu` offers it. pub(super) fn offers(action: &str, kind: ObjectKind, affordances: SidebarAffordances) -> bool { @@ -106,6 +184,7 @@ pub(super) fn offers(action: &str, kind: ObjectKind, affordances: SidebarAfforda SHOW_CREATE_TABLE | SHOW_DIAGRAM => kind.is_browsable() && affordances.speaks_sql, EDIT_STRUCTURE => table && affordances.can_edit_structure, IMPORT_CSV => table && !affordances.read_only, + TRUNCATE_TABLE => table && !affordances.read_only && affordances.catalog.truncate_table, // Dropping is planned by the DDL dialect, as structure changes are. DROP_TABLE => table && !affordances.read_only && affordances.can_edit_structure, _ => false, @@ -121,6 +200,7 @@ fn item(label: &str, action: &str, key: &ObjectKey) -> gio::MenuItem { #[cfg(test)] mod tests { use gtk4::glib; + use tablepro_core::ddl::CatalogCapabilities; use tablepro_core::meta::TableRef; use super::*; @@ -143,6 +223,23 @@ mod tests { found } + /// Each item's label, in menu order, sections flattened. + fn labels(menu: &gio::MenuModel) -> Vec { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(label) = menu + .item_attribute_value(index, "label", None) + .and_then(|value| value.get::()) + { + found.push(label); + } + if let Some(section) = menu.item_link(index, "section") { + found.extend(labels(§ion)); + } + } + found + } + fn actions(menu: &gio::Menu) -> Vec { items(menu.upcast_ref()).into_iter().map(|(action, _)| action).collect() } @@ -154,17 +251,48 @@ mod tests { can_edit_structure: true, speaks_sql: true, opens_databases: true, + ..SidebarAffordances::default() + } + } + + /// `all`, on an engine that also empties tables and makes and drops + /// schemas and databases. + fn everything() -> SidebarAffordances { + SidebarAffordances { + catalog: CatalogCapabilities { + truncate_table: true, + create_schema: true, + drop_schema: true, + create_database: true, + drop_database: true, + ..CatalogCapabilities::default() + }, + own_database_droppable: true, + ..all() } } + /// `everything`, where the schemas are the server's databases. + fn databases(noun: DatabaseNoun) -> SidebarAffordances { + let mut affordances = everything(); + affordances.opens_databases = false; + affordances.lists_databases = true; + affordances.catalog.database_noun = noun; + affordances + } + + fn orders() -> ObjectKey { + ObjectKey::rows(TableRef::new(Some("sales".to_owned()), "orders")) + } + #[test] fn every_item_carries_the_object_key() { - let key = ObjectKey::rows(TableRef::new(Some("sales".to_owned()), "orders")); + let key = orders(); - let menu = object_menu(&key, ObjectKind::Table, all()); + let menu = object_menu(&key, ObjectKind::Table, everything(), Holds::Tables); let found = items(menu.upcast_ref()); - assert_eq!(found.len(), 6); + assert_eq!(found.len(), 7); for (action, target) in found { let target = target.unwrap_or_else(|| panic!("{action} has no target")); assert_eq!(ObjectKey::from_variant(&target).as_ref(), Some(&key), "{action}"); @@ -173,10 +301,21 @@ mod tests { #[test] fn every_item_has_an_access_key_of_its_own() { - let key = ObjectKey::rows(TableRef::new(Some("sales".to_owned()), "orders")); - - assert_access_keys(&object_menu(&key, ObjectKind::Table, all())); - assert_access_keys(&schema_menu(Some("sales"), all(), Holds::Tables).expect("a menu")); + let key = orders(); + + assert_access_keys(&object_menu(&key, ObjectKind::Table, all(), Holds::Tables)); + assert_access_keys(&object_menu(&key, ObjectKind::Table, everything(), Holds::Tables)); + let mongo = SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect), Some("shop")); + assert_access_keys(&object_menu(&key, ObjectKind::Table, mongo, Holds::Collections)); + assert_access_keys(&schema_menu(Some("sales"), false, all(), Holds::Tables).expect("a menu")); + for affordances in [ + everything(), + databases(DatabaseNoun::Database), + databases(DatabaseNoun::Keyspace), + ] { + let menu = schema_menu(Some("sales"), true, affordances, Holds::Tables).expect("a menu"); + assert_access_keys(&menu); + } } #[test] @@ -187,7 +326,7 @@ mod tests { "orders", ))); - let menu = object_menu(&key, ObjectKind::Trigger, all()); + let menu = object_menu(&key, ObjectKind::Trigger, everything(), Holds::Tables); let found = items(menu.upcast_ref()); assert_eq!(found.len(), 1); @@ -201,9 +340,10 @@ mod tests { SHOW_DIAGRAM, EDIT_STRUCTURE, IMPORT_CSV, + TRUNCATE_TABLE, DROP_TABLE, ] { - assert!(!offers(action, ObjectKind::Trigger, all()), "{action}"); + assert!(!offers(action, ObjectKind::Trigger, everything()), "{action}"); } assert!(offers(SHOW_DEFINITION, ObjectKind::Function, all())); assert!(!offers(SHOW_DEFINITION, ObjectKind::Table, all())); @@ -214,11 +354,11 @@ mod tests { let key = ObjectKey::rows(TableRef::unqualified("orders")); let read_only = SidebarAffordances { read_only: true, - ..all() + ..everything() }; assert_eq!( - actions(&object_menu(&key, ObjectKind::Table, read_only)), + actions(&object_menu(&key, ObjectKind::Table, read_only, Holds::Tables)), [ "sidebar.open-in-new-tab", "sidebar.show-diagram", @@ -226,6 +366,7 @@ mod tests { "sidebar.show-create-table" ] ); + assert!(!offers(TRUNCATE_TABLE, ObjectKind::Table, read_only)); } #[test] @@ -237,9 +378,13 @@ mod tests { }; assert!( - !actions(&object_menu(&key, ObjectKind::Table, without)).contains(&"sidebar.edit-structure".to_owned()) + !actions(&object_menu(&key, ObjectKind::Table, without, Holds::Tables)) + .contains(&"sidebar.edit-structure".to_owned()) + ); + assert!( + actions(&object_menu(&key, ObjectKind::Table, all(), Holds::Tables)) + .contains(&"sidebar.drop-table".to_owned()) ); - assert!(actions(&object_menu(&key, ObjectKind::Table, all())).contains(&"sidebar.drop-table".to_owned())); } #[test] @@ -247,34 +392,82 @@ mod tests { let key = ObjectKey::rows(TableRef::unqualified("user:1")); let kind = ObjectKind::Key(tablepro_core::meta::KeyKind::Hash); - assert_eq!(actions(&object_menu(&key, kind, all())), ["sidebar.open-in-new-tab"]); - for action in [SHOW_CREATE_TABLE, SHOW_DIAGRAM, EDIT_STRUCTURE, IMPORT_CSV, DROP_TABLE] { - assert!(!offers(action, kind, all()), "{action}"); + assert_eq!( + actions(&object_menu(&key, kind, everything(), Holds::Keys)), + ["sidebar.open-in-new-tab"] + ); + for action in [ + SHOW_CREATE_TABLE, + SHOW_DIAGRAM, + EDIT_STRUCTURE, + IMPORT_CSV, + TRUNCATE_TABLE, + DROP_TABLE, + ] { + assert!(!offers(action, kind, everything()), "{action}"); } } #[test] fn a_collection_offers_neither_sql_nor_a_drop_the_dialect_cannot_plan() { let key = ObjectKey::rows(TableRef::new(Some("shop".to_owned()), "orders")); - let documents = SidebarAffordances { - read_only: false, - can_create_table: false, - can_edit_structure: false, - speaks_sql: false, - opens_databases: false, - }; + let documents = SidebarAffordances::default(); assert_eq!( - actions(&object_menu(&key, ObjectKind::Table, documents)), + actions(&object_menu(&key, ObjectKind::Table, documents, Holds::Collections)), ["sidebar.open-in-new-tab", "sidebar.import-csv"] ); assert!(!offers(DROP_TABLE, ObjectKind::Table, documents)); assert!(!offers(SHOW_CREATE_TABLE, ObjectKind::Table, documents)); } + #[test] + fn a_table_or_a_collection_is_emptied_beside_its_drop() { + let table = object_menu(&orders(), ObjectKind::Table, everything(), Holds::Tables); + let mongo = SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect), Some("shop")); + let collection = object_menu(&orders(), ObjectKind::Table, mongo, Holds::Collections); + + let offered = actions(&table); + assert_eq!( + offered[offered.len() - 2..], + ["sidebar.truncate-table", "sidebar.drop-table"] + ); + assert!(labels(table.upcast_ref()).contains(&"Tr_uncate Table…".to_owned())); + assert_eq!( + actions(&collection), + [ + "sidebar.open-in-new-tab", + "sidebar.import-csv", + "sidebar.truncate-table" + ] + ); + assert!(labels(collection.upcast_ref()).contains(&"Delete All _Documents…".to_owned())); + assert!(offers(TRUNCATE_TABLE, ObjectKind::PartitionedTable, everything())); + } + + #[test] + fn a_view_or_an_engine_that_cannot_empty_a_table_offers_no_truncate() { + let view = object_menu(&orders(), ObjectKind::View, everything(), Holds::Tables); + let without = SidebarAffordances { + catalog: CatalogCapabilities { + truncate_table: false, + ..everything().catalog + }, + ..everything() + }; + + assert!(!actions(&view).contains(&"sidebar.truncate-table".to_owned())); + assert!(!offers(TRUNCATE_TABLE, ObjectKind::View, everything())); + assert!( + !actions(&object_menu(&orders(), ObjectKind::Table, without, Holds::Tables)) + .contains(&"sidebar.truncate-table".to_owned()) + ); + assert!(!offers(TRUNCATE_TABLE, ObjectKind::Table, without)); + } + #[test] fn a_schema_menu_offers_new_table_and_its_diagram_with_the_schema() { - let menu = schema_menu(Some("sales"), all(), Holds::Tables).expect("a menu"); + let menu = schema_menu(Some("sales"), false, all(), Holds::Tables).expect("a menu"); let found = items(menu.upcast_ref()); let actions: Vec<&str> = found.iter().map(|(action, _)| action.as_str()).collect(); @@ -288,6 +481,89 @@ mod tests { } } + #[test] + fn a_droppable_schema_offers_a_new_schema_and_its_drop_by_name() { + let menu = schema_menu(Some("sales"), true, everything(), Holds::Tables).expect("a menu"); + + let found = items(menu.upcast_ref()); + let actions: Vec<&str> = found.iter().map(|(action, _)| action.as_str()).collect(); + assert_eq!( + actions, + [ + "sidebar.new-table", + "sidebar.new-schema", + "sidebar.show-schema-diagram", + "sidebar.drop-schema" + ] + ); + let target = |wanted: &str| { + found + .iter() + .find(|(action, _)| action == wanted) + .and_then(|(_, target)| target.clone()) + }; + assert_eq!(target("sidebar.new-schema"), None, "a new schema is the connection's"); + assert_eq!( + target("sidebar.drop-schema").and_then(|target| target.get::()), + Some("sales".to_owned()) + ); + assert!(labels(menu.upcast_ref()).contains(&"Drop Sc_hema…".to_owned())); + } + + #[test] + fn a_schema_that_is_a_database_drops_the_database_in_the_engines_words() { + for (noun, label) in [ + (DatabaseNoun::Database, "Drop Data_base…"), + (DatabaseNoun::Keyspace, "Drop _Keyspace…"), + ] { + let menu = schema_menu(Some("shop"), true, databases(noun), Holds::Tables).expect("a menu"); + + let found = items(menu.upcast_ref()); + let actions: Vec<&str> = found.iter().map(|(action, _)| action.as_str()).collect(); + assert_eq!( + actions, + [ + "sidebar.new-table", + "sidebar.show-schema-diagram", + "sidebar.drop-database" + ], + "{label}" + ); + assert_eq!( + found + .last() + .and_then(|(_, target)| target.as_ref()) + .and_then(|target| target.get::()), + Some("shop".to_owned()) + ); + assert!(labels(menu.upcast_ref()).contains(&label.to_owned()), "{label}"); + } + } + + #[test] + fn a_schema_the_list_withholds_or_cannot_name_offers_no_drop() { + let actions = |menu: Option| menu.map(|menu| actions(&menu)).unwrap_or_default(); + let read_only = SidebarAffordances { + read_only: true, + ..everything() + }; + + for offered in [ + actions(schema_menu(Some("public"), false, everything(), Holds::Tables)), + actions(schema_menu(None, true, everything(), Holds::Tables)), + actions(schema_menu(Some("sales"), true, read_only, Holds::Tables)), + ] { + assert!(!offered.contains(&"sidebar.drop-schema".to_owned()), "{offered:?}"); + } + assert_eq!( + actions(schema_menu(Some("sales"), true, read_only, Holds::Tables)), + ["sidebar.show-schema-diagram"] + ); + assert_eq!(schema_drop(read_only), None); + assert_eq!(schema_drop(everything()), Some(DROP_SCHEMA)); + assert_eq!(schema_drop(databases(DatabaseNoun::Database)), Some(DROP_DATABASE)); + } + #[test] fn a_schema_menu_follows_the_dialect_and_what_the_session_holds() { let read_only = SidebarAffordances { @@ -303,18 +579,21 @@ mod tests { let actions = |menu: Option| menu.map(|menu| actions(&menu)).unwrap_or_default(); assert_eq!( - actions(schema_menu(None, read_only, Holds::Tables)), + actions(schema_menu(None, false, read_only, Holds::Tables)), ["sidebar.show-schema-diagram"] ); - assert!(schema_menu(Some("shop"), documents, Holds::Collections).is_none()); - assert!(schema_menu(None, all(), Holds::Keys).is_none(), "keys have no diagram"); + assert!(schema_menu(Some("shop"), true, documents, Holds::Collections).is_none()); + assert!( + schema_menu(None, false, everything(), Holds::Keys).is_none(), + "keys have no diagram" + ); } #[test] fn a_view_offers_no_structure_change() { let key = ObjectKey::rows(TableRef::unqualified("active_users")); - let offered = actions(&object_menu(&key, ObjectKind::View, all())); + let offered = actions(&object_menu(&key, ObjectKind::View, all(), Holds::Tables)); assert_eq!( offered, diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs index f26bbbdb2a..aa12ae8249 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_model.rs @@ -26,8 +26,8 @@ pub(crate) enum Search { } /// The session's tables as the connection tree lists them: a schema -/// node per section of the index, each over its tables and over the -/// ones the search finds. A new index or search is worked out into rows +/// node per section of the index, an empty schema's too, each over its +/// tables and over the ones the search finds. A new index or search is worked out into rows /// apart from the lists. A schema's tables take their rows in place; a /// schema whose results change gets a new list of them, which the tree /// swaps in with every other schema's in one change. A list of what the @@ -35,7 +35,7 @@ pub(crate) enum Search { pub(crate) struct SidebarModel { /// Every section's schema, in list order. schemas: gio::ListStore, - /// Holds `nothing` once a list landed with no table in it. + /// Holds `nothing` once a list landed with no schema to show. empty: gio::ListStore, rows: gtk::FlattenListModel, nothing: TreeNode, @@ -169,23 +169,25 @@ impl SidebarModel { // A schema named as an engine names the objects of none is a // section of its own under the same title. let mut titled: HashSet<&str> = HashSet::new(); - for (at, run) in index.runs().iter().enumerate() { - let Some(section) = index.section(run.section) else { - continue; - }; + for (section, run) in index.listed_sections() { let node = titled .insert(section.title.as_str()) .then(|| held.get(§ion.title).cloned()) .flatten() .unwrap_or_else(|| TreeNode::schema(§ion.title, None)); - node.set_schema_name(index.entry(run.start).and_then(|entry| entry.object.schema.clone())); + node.set_schema_name(section.schema.clone()); + let entries = run.map_or_else(Entries::default, |(_, run)| Entries::Run(run.start, run.end)); if let Some(rows) = node.rows() { - rows.show(&index, Entries::Run(run.start, run.end)); + rows.show(&index, entries.clone()); } let results = match &mut found { Found::Nothing => Entries::default(), - Found::Everything => Entries::Run(run.start, run.end), - Found::Picked(picked) => Entries::Picked(picked.get_mut(at).map(std::mem::take).unwrap_or_default()), + Found::Everything => entries, + Found::Picked(picked) => Entries::Picked( + run.and_then(|(at, _)| picked.get_mut(at)) + .map(std::mem::take) + .unwrap_or_default(), + ), }; if node.found_rows().is_none_or(|list| !list.shows(&index, &results)) { let list = SidebarList::new(); @@ -219,6 +221,11 @@ impl SidebarModel { self.answered.get() == self.asked.get() } + /// Whether the list on screen offers to drop the schema `name`. + pub(crate) fn droppable(&self, name: &str) -> bool { + self.shown.borrow().droppable(name) + } + /// The kind of `target` when it is listed at all, searched for or not. pub(crate) fn kind_of(&self, target: &ObjectTarget) -> Option { let index = self.shown.borrow(); @@ -522,4 +529,69 @@ mod tests { [("public".to_owned(), vec!["user:1".to_owned(), "user:2".to_owned()])] ); } + + #[gtk4::test] + fn an_empty_schema_has_its_node_and_the_placeholder_waits_for_none_at_all() { + let model = SidebarModel::new(); + + replace( + &model, + &[], + &[schema("public", true, false), schema("pg_catalog", false, true)], + ); + + let schemas = nodes(&model.schemas()); + assert_eq!(listed(&model.schemas()), [("public".to_owned(), Vec::new())]); + assert_eq!(model.rows().n_items(), 1, "a placeholder beside an empty schema"); + assert_eq!(schemas[0].schema_name().as_deref(), Some("public")); + replace(&model, &[object("public", "orders")], &[schema("public", true, false)]); + assert_eq!( + nodes(&model.schemas()), + schemas, + "the schema's node went when it filled" + ); + assert_eq!( + listed(&model.schemas()), + [("public".to_owned(), vec!["orders".to_owned()])] + ); + } + + #[gtk4::test] + fn what_a_search_finds_stays_under_its_schema_past_an_empty_one() { + let model = SidebarModel::new(); + replace( + &model, + &[object("public", "users"), object("sales", "leads")], + &[ + schema("public", true, false), + schema("archive", false, false), + schema("sales", false, false), + ], + ); + assert_eq!( + nodes(&model.schemas()).iter().map(TreeNode::title).collect::>(), + ["public", "archive", "sales"] + ); + + search(&model, "lead"); + assert_eq!(found(&model), [("sales".to_owned(), vec!["leads".to_owned()])]); + search(&model, "user"); + assert_eq!(found(&model), [("public".to_owned(), vec!["users".to_owned()])]); + assert!(model.droppable("archive") && model.droppable("sales")); + assert!(!model.droppable("public"), "the default schema"); + } + + #[gtk4::test] + fn a_server_search_lists_nothing_under_an_empty_schema() { + let model = SidebarModel::new(); + model.hold_search(Search::Everything); + + replace( + &model, + &[object("sales", "user:1")], + &[schema("archive", false, false), schema("sales", false, false)], + ); + + assert_eq!(found(&model), [("sales".to_owned(), vec!["user:1".to_owned()])]); + } } diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs index 0c1027d461..4e2135d835 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_view.rs @@ -13,8 +13,8 @@ use super::sidebar_affordances::SidebarAffordances; use super::sidebar_event::SidebarEvent; use super::sidebar_index::SidebarIndex; use super::sidebar_menu::{ - DROP_TABLE, EDIT_STRUCTURE, IMPORT_CSV, NEW_TABLE, OPEN_IN_NEW_TAB, SHOW_CREATE_TABLE, SHOW_DEFINITION, - SHOW_DIAGRAM, SHOW_SCHEMA_DIAGRAM, offers, + DROP_DATABASE, DROP_SCHEMA, DROP_TABLE, EDIT_STRUCTURE, IMPORT_CSV, NEW_SCHEMA, NEW_TABLE, OPEN_IN_NEW_TAB, + SHOW_CREATE_TABLE, SHOW_DEFINITION, SHOW_DIAGRAM, SHOW_SCHEMA_DIAGRAM, TRUNCATE_TABLE, offers, schema_drop, }; use super::sidebar_model::{RowsAsked, RowsWorkedOut, Search, SidebarModel}; use super::tree_node::TreeNode; @@ -187,6 +187,13 @@ impl SidebarView { (self.inner.events)(SidebarEvent::ShowDefinition(object)); } + /// Whether the list on screen offers to drop the schema `name`: one + /// the session lists as the user's own, neither its default nor one + /// withheld. + pub(crate) fn droppable(&self, name: &str) -> bool { + self.inner.model.droppable(name) + } + /// Whether the session lists `table`, searched for or not. pub(crate) fn lists(&self, table: &TableRef) -> bool { self.inner.model.kind_of(&ObjectTarget::Rows(table.clone())).is_some() @@ -260,6 +267,7 @@ impl SidebarInner { EDIT_STRUCTURE => SidebarEvent::EditStructure(table), SHOW_CREATE_TABLE => SidebarEvent::ShowCreateTable(table), IMPORT_CSV => SidebarEvent::ImportCsv(table), + TRUNCATE_TABLE => SidebarEvent::TruncateTable(table), DROP_TABLE => SidebarEvent::DropTable(table), SHOW_DIAGRAM => SidebarEvent::ShowDiagram(table.schema), _ => return, @@ -278,6 +286,39 @@ impl SidebarInner { (self.events)(SidebarEvent::NewTable(schema.filter(|schema| !schema.is_empty()))); } + fn new_schema(&self) { + if self.holds.get() == Holds::Keys || !self.affordances.get().offers_new_schema() { + return; + } + (self.events)(SidebarEvent::NewSchema); + } + + /// Drop the schema, or the database it is, that the target names: a + /// name the list no longer has, or no longer offers to drop, does + /// nothing, as a stale object menu does. + fn drop_schema(&self, action: &str, target: Option<&glib::Variant>) { + let Some(schema) = target + .and_then(|target| target.get::()) + .filter(|schema| !schema.is_empty()) + else { + tracing::warn!(action, "a drop was asked for without a schema"); + return; + }; + if self.holds.get() == Holds::Keys || schema_drop(self.affordances.get()) != Some(action) { + tracing::warn!(action, "a drop the connection does not offer"); + return; + } + if !self.model.droppable(&schema) { + tracing::warn!(action, "a drop named a schema the list no longer offers"); + return; + } + let event = match action { + DROP_DATABASE => SidebarEvent::DropDatabase(schema), + _ => SidebarEvent::DropSchema(schema), + }; + (self.events)(event); + } + fn show_schema_diagram(&self, target: Option<&glib::Variant>) { let Some(schema) = target.and_then(|target| target.get::>()) else { tracing::warn!("a diagram was asked for without a schema"); @@ -351,6 +392,7 @@ fn install_actions(inner: &Rc) { EDIT_STRUCTURE, SHOW_CREATE_TABLE, IMPORT_CSV, + TRUNCATE_TABLE, DROP_TABLE, SHOW_DEFINITION, ] { @@ -379,4 +421,22 @@ fn install_actions(inner: &Rc) { } }); inner.actions.add_action(&diagram); + let new_schema = gio::SimpleAction::new(NEW_SCHEMA, None); + let view = Rc::downgrade(inner); + new_schema.connect_activate(move |_, _| { + if let Some(view) = view.upgrade() { + view.new_schema(); + } + }); + inner.actions.add_action(&new_schema); + for name in [DROP_SCHEMA, DROP_DATABASE] { + let action = gio::SimpleAction::new(name, Some(glib::VariantTy::STRING)); + let view = Rc::downgrade(inner); + action.connect_activate(move |_, target| { + if let Some(view) = view.upgrade() { + view.drop_schema(name, target); + } + }); + inner.actions.add_action(&action); + } } diff --git a/linux/crates/app/src/ui/workspace/sidebar/tests.rs b/linux/crates/app/src/ui/workspace/sidebar/tests.rs index 40b957a23a..6e147de899 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tests.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tests.rs @@ -22,7 +22,9 @@ use super::connection_tree::{Pane, TreeInner, Which}; use super::object_key::ObjectKey; use super::object_target::ObjectTarget; use super::sidebar_event::SidebarEvent; +use super::sidebar_menu::{object_menu, schema_menu}; use super::sidebar_object::SidebarObject; +use super::sidebar_view::Holds; use super::tree_node::TreeNode; use super::tree_row::TreeRow; use super::*; @@ -114,6 +116,24 @@ fn every_affordance() -> SidebarAffordances { can_edit_structure: true, speaks_sql: true, opens_databases: true, + ..SidebarAffordances::default() + } +} + +/// `every_affordance`, on an engine that also empties tables and makes +/// and drops schemas and databases, as PostgreSQL does. +fn catalog_affordance() -> SidebarAffordances { + SidebarAffordances { + catalog: tablepro_core::ddl::CatalogCapabilities { + truncate_table: true, + create_schema: true, + drop_schema: true, + create_database: true, + drop_database: true, + ..tablepro_core::ddl::CatalogCapabilities::default() + }, + own_database_droppable: true, + ..every_affordance() } } @@ -149,11 +169,7 @@ fn connection(name: &str, group: Option<&str>) -> SavedConnection { /// A session's sidebar listing `objects`, with what it sent. fn session(objects: &[ObjectInfo], affordances: SidebarAffordances) -> (SidebarView, Events) { - let events = Events::default(); - let sink = events.clone(); - let view = SidebarView::new(move |event| sink.borrow_mut().push(event), affordances, None); - view.show(SidebarIndex::build(objects, &public())); - (view, events) + session_of(objects, &["public"], affordances) } fn tables(view: &SidebarView, names: &[&str]) { @@ -161,6 +177,26 @@ fn tables(view: &SidebarView, names: &[&str]) { view.show(SidebarIndex::build(&objects, &public())); } +/// Show a list of no object under no schema at all. +fn nothing(view: &SidebarView) { + view.show(SidebarIndex::build(&[], &[])); +} + +/// A session's sidebar listing `objects` under `schemas`, the first the +/// default, with what it sent. +fn session_of(objects: &[ObjectInfo], schemas: &[&str], affordances: SidebarAffordances) -> (SidebarView, Events) { + let events = Events::default(); + let sink = events.clone(); + let view = SidebarView::new(move |event| sink.borrow_mut().push(event), affordances, None); + let listed: Vec = schemas + .iter() + .enumerate() + .map(|(at, name)| schema(name, at == 0)) + .collect(); + view.show(SidebarIndex::build(objects, &listed)); + (view, events) +} + /// The tree in a window of its own, over a store of saved connections, /// with the window's and the picker's actions recording what reached /// them. @@ -213,6 +249,8 @@ impl Shown { "stop-connecting", "new-query-in", "refresh-in", + "new-database-in", + "drop-database-in", "show-connect-failure", "forget-connect-failure", ] { @@ -494,6 +532,34 @@ fn menu_items(menu: &gio::MenuModel) -> Vec<(String, Option)> { found } +/// Each menu item's label, sections and submenus flattened. +fn menu_labels(menu: &gio::MenuModel) -> Vec { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(label) = menu + .item_attribute_value(index, "label", None) + .and_then(|value| value.get::()) + { + found.push(label); + } + for link in ["section", "submenu"] { + if let Some(linked) = menu.item_link(index, link) { + found.extend(menu_labels(&linked)); + } + } + } + found +} + +/// Each schema's title, in the order the session's sidebar lists them. +fn schema_titles(view: &SidebarView) -> Vec { + let schemas = view.schemas(); + (0..schemas.n_items()) + .filter_map(|at| schemas.item(at).and_downcast::()) + .map(|node| node.title()) + .collect() +} + fn menu_actions(popover: >k::PopoverMenu) -> Vec { popover .menu_model() @@ -1084,6 +1150,7 @@ fn an_open_connection_with_nothing_to_list_says_so() { let alpha = connection("Alpha", None); let shown = Shown::new(std::slice::from_ref(&alpha)); let (view, _) = session(&[], every_affordance()); + nothing(&view); shown.open(&alpha, &view); @@ -1125,7 +1192,7 @@ fn clearing_a_search_over_an_emptied_list_says_there_are_no_tables() { shown.search("alp"); assert_eq!(shown.rows(), ["Alpha"]); - tables(&view, &[]); + nothing(&view); shown.search(""); assert_eq!(shown.rows(), ["Alpha", "No Tables"]); @@ -1139,7 +1206,7 @@ fn a_list_of_collections_or_keys_says_so_when_it_is_empty() { collections.hold_collections(); let (listing, _) = session(&[], every_affordance()); listing.hold_keys(|| {}); - tables(&collections, &[]); + nothing(&collections); listing.show_keys(SidebarIndex::keys(&[], "Database 0".to_owned()), false); shown.open(&documents, &collections); @@ -1517,7 +1584,7 @@ fn a_closed_session_lists_nothing_and_leaves_the_others_open() { fn a_file_no_saved_connection_names_has_a_row_of_its_own() { let shown = Shown::new(&[connection("Alpha", None)]); let key = SessionKey::File("file:///home/me/sales.db".to_owned()); - let (view, _) = session(&[object("main", "orders")], every_affordance()); + let (view, _) = session_of(&[object("main", "orders")], &["main"], every_affordance()); shown.tree.add_session(&key, "sales.db", &view); @@ -2257,3 +2324,371 @@ fn a_reconnect_names_tabs_by_its_schemas_when_the_list_fails() { .expect("the reconnect's schemas never named the tabs"); assert_eq!(shown_tables(&workspace), Some(1), "the failure emptied the list"); } + +#[gtk4::test] +fn an_empty_schema_has_its_row_and_a_menu_that_works() { + let alpha = connection("Alpha", None); + let shown = Shown::new(std::slice::from_ref(&alpha)); + let (view, events) = session_of( + &[object("public", "orders")], + &["public", "sales"], + catalog_affordance(), + ); + shown.open(&alpha, &view); + let popover = shown.popover(); + + assert_eq!(shown.rows(), ["Alpha", "public", "sales"]); + shown.menu("sales"); + assert_eq!( + menu_actions(&popover), + [ + "sidebar.new-table", + "sidebar.new-schema", + "sidebar.show-schema-diagram", + "sidebar.drop-schema" + ] + ); + WidgetExt::activate_action(&popover, "sidebar.new-table", Some(&Some("sales").to_variant())) + .expect("sidebar.new-table"); + WidgetExt::activate_action(&popover, "sidebar.new-schema", None).expect("sidebar.new-schema"); + WidgetExt::activate_action(&popover, "sidebar.drop-schema", Some(&"sales".to_variant())) + .expect("sidebar.drop-schema"); + popover.popdown(); + assert_eq!( + events.take(), + [ + SidebarEvent::NewTable(Some("sales".to_owned())), + SidebarEvent::NewSchema, + SidebarEvent::DropSchema("sales".to_owned()), + ] + ); + + shown.menu("public"); + let offered = menu_actions(&popover); + popover.popdown(); + assert!( + !offered.contains(&"sidebar.drop-schema".to_owned()), + "the default schema: {offered:?}" + ); +} + +#[gtk4::test] +fn a_database_listed_as_a_schema_is_dropped_as_a_database_but_never_the_sessions_own() { + let alpha = connection("Alpha", None); + let shown = Shown::new(std::slice::from_ref(&alpha)); + let databases = SidebarAffordances { + opens_databases: false, + lists_databases: true, + ..catalog_affordance() + }; + let (view, events) = session_of(&[object("shop", "orders")], &["shop", "archive"], databases); + shown.open(&alpha, &view); + let popover = shown.popover(); + + shown.menu("archive"); + let offered = menu_actions(&popover); + let labels = popover.menu_model().map(|menu| menu_labels(&menu)).unwrap_or_default(); + WidgetExt::activate_action(&popover, "sidebar.drop-database", Some(&"archive".to_variant())) + .expect("sidebar.drop-database"); + popover.popdown(); + shown.menu("shop"); + let own = menu_actions(&popover); + popover.popdown(); + view.actions() + .activate_action("drop-schema", Some(&"archive".to_variant())); + + assert_eq!( + offered, + [ + "sidebar.new-table", + "sidebar.show-schema-diagram", + "sidebar.drop-database" + ] + ); + assert!(labels.contains(&"Drop Data_base…".to_owned()), "{labels:?}"); + assert!(!own.contains(&"sidebar.drop-database".to_owned()), "{own:?}"); + assert_eq!(events.take(), [SidebarEvent::DropDatabase("archive".to_owned())]); +} + +/// The menu was built for a list the session has since read again: a +/// drop or a new schema it no longer offers does nothing, as a stale +/// table menu does. +#[gtk4::test] +fn a_stale_schema_menu_does_nothing() { + let (view, events) = session_of( + &[object("public", "orders")], + &["public", "sales"], + catalog_affordance(), + ); + assert!(view.droppable("sales")); + + view.show(SidebarIndex::build(&[object("public", "orders")], &public())); + let actions = view.actions(); + actions.activate_action("drop-schema", Some(&"sales".to_variant())); + actions.activate_action("drop-schema", Some(&String::new().to_variant())); + actions.activate_action("drop-schema", Some(&"public".to_variant())); + actions.activate_action("drop-database", Some(&"public".to_variant())); + view.show(SidebarIndex::build( + &[], + &[schema("public", true), schema("sales", false)], + )); + view.set_affordances(SidebarAffordances { + read_only: true, + ..catalog_affordance() + }); + actions.activate_action("drop-schema", Some(&"sales".to_variant())); + actions.activate_action("new-schema", None); + + assert!(events.take().is_empty()); +} + +#[gtk4::test] +fn truncate_reaches_the_workspace_for_a_table_never_a_view_or_a_read_only_connection() { + let active = ObjectInfo { + kind: ObjectKind::View, + ..object("public", "active") + }; + let (view, events) = session(&[object("public", "orders"), active], catalog_affordance()); + let truncate = |name: &str| { + view.actions() + .activate_action("truncate-table", Some(&ObjectKey::rows(table(name)).to_variant())); + }; + + truncate("orders"); + truncate("active"); + truncate("gone"); + view.set_affordances(SidebarAffordances { + read_only: true, + ..catalog_affordance() + }); + truncate("orders"); + + assert_eq!(events.take(), [SidebarEvent::TruncateTable(table("orders"))]); +} + +#[gtk4::test] +fn a_collection_is_emptied_by_deleting_its_documents() { + let docs = connection("Docs", None); + let shown = Shown::new(std::slice::from_ref(&docs)); + let mongo = SidebarAffordances::of(false, Some(&tablepro_driver_mongodb::MongoDialect), Some("shop")); + let (collections, events) = session_of(&[object("shop", "orders")], &["shop"], mongo); + collections.hold_collections(); + shown.open(&docs, &collections); + let popover = shown.popover(); + + shown.menu("orders"); + let labels = popover.menu_model().map(|menu| menu_labels(&menu)).unwrap_or_default(); + WidgetExt::activate_action(&popover, "sidebar.truncate-table", Some(&rows_target("shop", "orders"))) + .expect("sidebar.truncate-table"); + popover.popdown(); + + assert!(labels.contains(&"Delete All _Documents…".to_owned()), "{labels:?}"); + assert!(!labels.contains(&"Tr_uncate Table…".to_owned()), "{labels:?}"); + assert_eq!( + events.take(), + [SidebarEvent::TruncateTable(TableRef::new( + Some("shop".to_owned()), + "orders" + ))] + ); +} + +/// The menus and the actions they name are kept apart by hand, so every +/// item a menu can offer must name an action the view installed, with +/// the target that action takes. +#[gtk4::test] +fn every_item_a_sidebar_menu_offers_names_an_action_of_the_view() { + let (view, _) = session_of( + &[object("public", "orders")], + &["public", "sales"], + catalog_affordance(), + ); + let databases = SidebarAffordances { + lists_databases: true, + ..catalog_affordance() + }; + let key = ObjectKey::rows(table("orders")); + let mut menus = vec![object_menu( + &key, + ObjectKind::Table, + catalog_affordance(), + Holds::Tables, + )]; + menus.extend(schema_menu(Some("sales"), true, catalog_affordance(), Holds::Tables)); + menus.extend(schema_menu(Some("sales"), true, databases, Holds::Tables)); + + for menu in menus { + for (action, target) in menu_items(menu.upcast_ref()) { + let name = action.strip_prefix("sidebar.").unwrap_or_else(|| panic!("{action}")); + let installed = view + .actions() + .lookup_action(name) + .unwrap_or_else(|| panic!("{action} is not installed")); + assert_eq!( + installed.parameter_type().as_deref(), + target.as_ref().map(glib::Variant::type_), + "{action}" + ); + } + } +} + +#[gtk4::test] +fn a_search_finds_tables_under_their_schema_past_an_empty_one() { + let alpha = connection("Alpha", None); + let shown = Shown::new(std::slice::from_ref(&alpha)); + let (view, _) = session_of( + &[object("public", "users"), object("sales", "leads")], + &["public", "archive", "sales"], + every_affordance(), + ); + shown.open(&alpha, &view); + assert_eq!(shown.rows(), ["Alpha", "public", "archive", "sales"]); + + shown.search("lead"); + + wait_until(WAIT, || shown.rows() == ["leads"]).expect("only the table it finds"); + wait_until(WAIT, || shown.headings() == ["sales (Alpha)"]).expect("the table's own schema"); +} + +#[gtk4::test] +fn a_connections_row_makes_and_drops_databases_and_a_databases_row_drops_its_own() { + let alpha = connection("Alpha", None); + let shown = Shown::new(std::slice::from_ref(&alpha)); + let (view, _) = session(&[object("public", "orders")], catalog_affordance()); + let key = shown.open(&alpha, &view); + let (beside, _) = session(&[object("public", "carts")], catalog_affordance()); + let sibling = SessionKey::SavedIn { + id: alpha.id, + database: "shop".to_owned(), + }; + shown.tree.add_session(&sibling, "Alpha · shop", &beside); + let popover = shown.popover(); + let offered = |name: &str| { + shown.menu(name); + let items = popover.menu_model().map(|menu| menu_items(&menu)).unwrap_or_default(); + popover.popdown(); + items + .into_iter() + .filter(|(action, _)| ["win.new-database-in", "win.drop-database-in"].contains(&action.as_str())) + .map(|(action, target)| (action, target.and_then(|target| target.get::()))) + .collect::>() + }; + + assert_eq!( + offered("Alpha"), + [ + ("win.new-database-in".to_owned(), target(&key)), + ("win.drop-database-in".to_owned(), target(&key)), + ] + ); + assert_eq!( + offered("Alpha · shop"), + [ + ("win.new-database-in".to_owned(), target(&sibling)), + ("win.drop-database-in".to_owned(), target(&sibling)), + ] + ); + view.set_affordances(SidebarAffordances { + read_only: true, + ..catalog_affordance() + }); + assert!(offered("Alpha").is_empty(), "a read-only connection"); +} + +/// Whether the sidebar withholds a drop, as `list_catalog` asks of each +/// schema it lists. +#[test] +fn a_drop_is_withheld_for_the_sessions_own_database_and_what_the_engine_keeps() { + let mongo = tablepro_driver_mongodb::MongoDialect; + + assert!(kept(Some(&mongo), Some("shop"), "shop"), "the session's own database"); + assert!(kept(Some(&mongo), Some("shop"), "admin"), "the server keeps it"); + assert!(!kept(Some(&mongo), Some("shop"), "archive")); + assert!(kept(None, Some("shop"), "archive"), "no connection, no drop"); + let clickhouse = tablepro_driver_clickhouse::ClickHouseDialect::new(&tablepro_core::ServerInfo::new( + "ClickHouse", + "25.8", + 25, + 8, + 0, + )); + assert!( + kept(Some(&clickhouse), Some("default"), "default"), + "a server whose databases are its schemas keeps the session's own" + ); + assert!(!kept(Some(&clickhouse), Some("default"), "sales")); + let mut index = SidebarIndex::build( + &[], + &[ + SchemaInfo { + name: "shop".to_owned(), + is_default: false, + is_system: false, + }, + schema("admin", false), + schema("archive", false), + SchemaInfo { + name: "local".to_owned(), + is_default: false, + is_system: true, + }, + ], + ); + index.withhold_drops(|name| kept(Some(&mongo), Some("shop"), name)); + let offered: Vec<&str> = ["shop", "admin", "archive", "local"] + .into_iter() + .filter(|name| index.droppable(name)) + .collect(); + assert_eq!(offered, ["archive"]); +} + +/// A schema is not a database where each database holds schemas of its +/// own, so neither the database's name nor a database the server keeps +/// withholds a schema of that name. +#[test] +fn a_schema_named_like_a_database_is_dropped_where_schemas_are_not_databases() { + let postgres = + tablepro_driver_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)); + + assert!(!kept(Some(&postgres), Some("shop"), "shop"), "named like its database"); + assert!( + !kept(Some(&postgres), Some("shop"), "template1"), + "named like a database the server keeps" + ); + let mut index = SidebarIndex::build( + &[], + &[ + schema("public", true), + schema("shop", false), + schema("template1", false), + ], + ); + index.withhold_drops(|name| kept(Some(&postgres), Some("shop"), name)); + assert!(index.droppable("shop") && index.droppable("template1")); + assert!(!index.droppable("public"), "the default schema"); +} + +#[gtk4::test] +fn a_new_schema_shows_once_the_schemas_are_read_again() { + let test = TestWindow::connected_with("SidebarSchemaShape", |script| { + script.list_schemas.once(FakeReply::ok(vec![schema("public", true)])); + script + .list_schemas + .once(FakeReply::ok(vec![schema("public", true), schema("sales", false)])); + script.list_schemas.once(FakeReply::ok(vec![schema("public", true)])); + script + .list_objects + .always(FakeReply::ok(vec![object("public", "users")])); + }); + let workspace = test.workspace(); + let titles = || workspace.read(|model| schema_titles(&model.sidebar)); + wait_until(WAIT, || titles() == Some(vec!["public".to_owned()])).expect("the connect's schema"); + + workspace.with_model(|model| model.catalog_shape_changed()); + + wait_until(WAIT, || titles() == Some(vec!["public".to_owned(), "sales".to_owned()])) + .expect("the new schema, which holds nothing yet"); + WidgetExt::activate_action(&test.window, "win.refresh-tables", None).expect("win.refresh-tables"); + wait_until(WAIT, || titles() == Some(vec!["public".to_owned()])).expect("the dropped schema gone on Refresh"); +} diff --git a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs index 84bf62e214..0183a4b3b2 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs @@ -1,12 +1,17 @@ -use gtk4::gio; +use gtk4::{gio, glib}; +use tablepro_core::ddl::DatabaseNoun; use tablepro_session::SessionKey; +use super::sidebar_affordances::SidebarAffordances; + /// The window actions the tree's connection rows reach, each with the /// session's key as its target. pub(super) const SHOW_SESSION: &str = "win.show-session"; const NEW_QUERY_IN: &str = "win.new-query-in"; const REFRESH_IN: &str = "win.refresh-in"; const OPEN_DATABASE_IN: &str = "win.open-database-in"; +const NEW_DATABASE_IN: &str = "win.new-database-in"; +const DROP_DATABASE_IN: &str = "win.drop-database-in"; pub(super) const DISCONNECT_SESSION: &str = "win.disconnect-session"; const STOP_CONNECTING: &str = "win.stop-connecting"; pub(super) const SHOW_CONNECT_FAILURE: &str = "win.show-connect-failure"; @@ -27,24 +32,41 @@ pub(super) enum InWindow { /// The connection's menu section for the window: the session's own /// actions while it is open, Stop Connecting while it connects, else -/// Connect for a saved connection, and the reason a connect failed. -/// `opens_databases` offers Open Database… on an open one. -pub(super) fn session_section(key: &SessionKey, state: InWindow, opens_databases: bool) -> gio::Menu { +/// Connect for a saved connection, and the reason a connect failed. An +/// open session's `affordances` offer Open Database… on a saved +/// connection's own row whose catalog holds one database, New Database… +/// where the engine makes one, and Drop Database… on that row, which +/// picks among the server's databases, or on another database's row +/// beside it (0033). +pub(super) fn session_section(key: &SessionKey, state: InWindow, affordances: SidebarAffordances) -> gio::Menu { let target = crate::ui::session_target::to_variant(key); let menu = gio::Menu::new(); - let add = |label: String, action: &str| { - let item = gio::MenuItem::new(Some(&label), None); - item.set_action_and_target_value(Some(action), Some(&target)); - menu.append_item(&item); - }; + let add = |label: String, action: &str| menu.append_item(&item(&label, action, &target)); match state { InWindow::Open => { - add(crate::i18n::gettext("_New Query"), NEW_QUERY_IN); + // Q, so New Database… can take N: each of its other letters is Disconnect's or the picker's. + add(crate::i18n::gettext("New _Query"), NEW_QUERY_IN); add(crate::i18n::gettext("Re_fresh"), REFRESH_IN); - if opens_databases { + if matches!(key, SessionKey::Saved(_)) && affordances.opens_databases { add(crate::i18n::gettext("_Open Database…"), OPEN_DATABASE_IN); } + if affordances.offers_new_database() { + let label = match affordances.catalog.database_noun { + DatabaseNoun::Database => crate::i18n::gettext("_New Database…"), + DatabaseNoun::Keyspace => crate::i18n::gettext("_New Keyspace…"), + }; + add(label, NEW_DATABASE_IN); + } add(crate::i18n::gettext("_Disconnect"), DISCONNECT_SESSION); + if drops_database(key, affordances) { + let danger = gio::Menu::new(); + danger.append_item(&item( + &crate::i18n::gettext("Drop Data_base…"), + DROP_DATABASE_IN, + &target, + )); + menu.append_section(None, &danger); + } } // A connect can wait for a host that never answers. InWindow::Connecting => add(crate::i18n::gettext("Stop _Connecting"), STOP_CONNECTING), @@ -64,8 +86,28 @@ pub(super) fn session_section(key: &SessionKey, state: InWindow, opens_databases menu } +/// Whether the row of an open session offers Drop Database…. Only +/// engines whose connection holds one database open another beside it, +/// and a file holds no other. +fn drops_database(key: &SessionKey, affordances: SidebarAffordances) -> bool { + let row = match key { + SessionKey::Saved(_) => affordances.opens_databases, + SessionKey::SavedIn { .. } => affordances.own_database_droppable, + SessionKey::File(_) => false, + }; + row && affordances.offers_drop_database() +} + +fn item(label: &str, action: &str, target: &glib::Variant) -> gio::MenuItem { + let item = gio::MenuItem::new(Some(label), None); + item.set_action_and_target_value(Some(action), Some(target)); + item +} + #[cfg(test)] mod tests { + use gtk4::prelude::*; + use tablepro_core::ddl::CatalogCapabilities; use tablepro_storage::{ConnectionList, ConnectionTag, SavedConnection}; use uuid::Uuid; @@ -73,6 +115,53 @@ mod tests { use crate::services::library_outline::{LibraryOutline, LibrarySection}; use crate::test_support::assert_access_keys; + /// An engine that holds one database per connection and makes and + /// drops them, as PostgreSQL does. + fn databases() -> SidebarAffordances { + SidebarAffordances { + opens_databases: true, + own_database_droppable: true, + catalog: CatalogCapabilities { + create_database: true, + drop_database: true, + ..CatalogCapabilities::default() + }, + ..SidebarAffordances::default() + } + } + + fn keyspaces() -> SidebarAffordances { + let mut affordances = databases(); + affordances.opens_databases = false; + affordances.lists_databases = true; + affordances.catalog.database_noun = DatabaseNoun::Keyspace; + affordances + } + + /// Each item's action and target, sections flattened. + fn items(menu: &gio::MenuModel) -> Vec<(String, Option)> { + let mut found = Vec::new(); + for index in 0..menu.n_items() { + if let Some(action) = menu + .item_attribute_value(index, "action", None) + .and_then(|value| value.get::()) + { + let target = menu + .item_attribute_value(index, "target", None) + .and_then(|value| value.get::()); + found.push((action, target)); + } + if let Some(section) = menu.item_link(index, "section") { + found.extend(items(§ion)); + } + } + found + } + + fn actions(menu: &gio::Menu) -> Vec { + items(menu.upcast_ref()).into_iter().map(|(action, _)| action).collect() + } + #[test] fn every_state_keeps_the_access_keys_of_the_connections_menu_apart() { let id = Uuid::new_v4(); @@ -90,14 +179,16 @@ mod tests { }, ]; - for state in stored { - assert_access_keys(&crate::ui::picker::connection_menu_with( - &session_section(&key, state, true), - id, - LibrarySection::Tree, - &outline, - &[], - )); + for affordances in [databases(), keyspaces()] { + for state in stored { + assert_access_keys(&crate::ui::picker::connection_menu_with( + &session_section(&key, state, affordances), + id, + LibrarySection::Tree, + &outline, + &[], + )); + } } // No saved connection names this one, so the section is the menu. assert_access_keys(&session_section( @@ -106,7 +197,92 @@ mod tests { failed: true, stored: false, }, - true, + databases(), )); + let beside = SessionKey::SavedIn { + id, + database: "shop".to_owned(), + }; + assert_access_keys(&session_section(&beside, InWindow::Open, databases())); + } + + #[test] + fn a_new_database_is_offered_where_the_engine_makes_one_and_writes_are_allowed() { + let key = SessionKey::Saved(Uuid::new_v4()); + let read_only = SidebarAffordances { + read_only: true, + ..databases() + }; + let cannot = SidebarAffordances { + catalog: CatalogCapabilities::default(), + ..databases() + }; + + let offered = actions(&session_section(&key, InWindow::Open, databases())); + let target = items(session_section(&key, InWindow::Open, databases()).upcast_ref()) + .into_iter() + .find(|(action, _)| action == NEW_DATABASE_IN) + .and_then(|(_, target)| target); + + assert!(offered.contains(&NEW_DATABASE_IN.to_owned()), "{offered:?}"); + assert_eq!(target, crate::ui::session_target::to_variant(&key).get::()); + for affordances in [read_only, cannot] { + let offered = actions(&session_section(&key, InWindow::Open, affordances)); + assert!(!offered.contains(&NEW_DATABASE_IN.to_owned()), "{offered:?}"); + assert!(!offered.contains(&DROP_DATABASE_IN.to_owned()), "{offered:?}"); + } + assert!( + !actions(&session_section(&key, InWindow::Connecting, databases())).contains(&NEW_DATABASE_IN.to_owned()), + "a session still connecting" + ); + } + + #[test] + fn a_database_is_dropped_from_its_connections_row_or_its_own_beside_it() { + let id = Uuid::new_v4(); + let own = SessionKey::Saved(id); + let beside = SessionKey::SavedIn { + id, + database: "shop".to_owned(), + }; + let kept = SidebarAffordances { + own_database_droppable: false, + ..databases() + }; + let drops = |key: &SessionKey, affordances| { + items(session_section(key, InWindow::Open, affordances).upcast_ref()) + .into_iter() + .find(|(action, _)| action == DROP_DATABASE_IN) + .map(|(_, target)| target) + }; + + assert_eq!( + drops(&own, databases()), + Some(crate::ui::session_target::to_variant(&own).get::()) + ); + assert_eq!( + drops(&beside, databases()), + Some(crate::ui::session_target::to_variant(&beside).get::()) + ); + assert_eq!(drops(&beside, kept), None, "a database the server keeps for itself"); + assert_eq!( + drops(&own, keyspaces()), + None, + "a keyspace is dropped from its own row in the tree" + ); + assert_eq!( + drops(&SessionKey::File("file:///srv/shop.duckdb".to_owned()), databases()), + None + ); + assert_eq!( + actions(&session_section(&beside, InWindow::Open, databases())), + [ + NEW_QUERY_IN, + REFRESH_IN, + NEW_DATABASE_IN, + DISCONNECT_SESSION, + DROP_DATABASE_IN + ] + ); } } diff --git a/linux/crates/app/src/ui/workspace/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs index fa97b6a25d..3e856f78dd 100644 --- a/linux/crates/app/src/ui/workspace/structure.rs +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -22,7 +22,6 @@ use crate::ui::workspace::{WorkspaceInput, WorkspaceModel, WorkspaceTab}; use super::structure_target::StructureTarget; use super::tab_spec::{TabSpec, TablePage}; -use crate::workspace::run_schema_plan; /// The drop the prompt shows is the drop that runs. const DROP_OPTIONS: DropTableOptions = DropTableOptions { @@ -156,64 +155,39 @@ impl WorkspaceModel { Some((session.metadata().name.clone(), plan.preview())) } - /// User confirmed the drop. Run DROP TABLE async; only close any - /// open Browse / Structure tabs for the table after the DROP - /// returns Ok. Tabs stay open if DROP fails (FK violation, - /// privileges) so the user doesn't lose their state on a failed - /// destructive action. - /// - /// The drop runs as a save of its own: a window close waits for it - /// or stops it, and a quit keeps the process until it answers. + /// The table's tabs close only once the DROP lands, so a failed drop + /// (a foreign key, privileges) leaves the user's state where it was. pub(super) fn on_drop_table_confirmed(&mut self, schema: Option, table: String) { - let Some(session) = self.context.active.get() else { - self.show_error_alert( - &crate::i18n::gettext("Cannot Drop Table"), - &crate::i18n::gettext("No active connection."), - ); - return; - }; - let handle = self.workspace_handle.clone(); - let Some(ticket) = handle - .ledger() - .begin(&*self.context.hold, Uuid::new_v4(), WriteKind::SchemaChanges) - else { - return; - }; let dropped = TableRef::new(schema, table); let options = DROP_OPTIONS; - let table = dropped.clone(); - let cancel = ticket.cancel.clone(); - let work = async move { - run_schema_plan(session, table, DdlOperation::DropTable, &cancel, move |ddl, table| { - ddl.plan_drop_table(table, options) - }) - .await - }; - // The answer goes through the workspace's input, so one that - // lands after a disconnect is dropped with the workspace. - let input = self.input.clone(); - crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { - match settled(result).and_then(|(_, written)| written) { - Ok(_) => input.deliver(WorkspaceInput::DropTableSucceeded(dropped)), + let answered = dropped.clone(); + self.start_catalog_change( + WriteKind::SchemaChanges, + dropped, + move |dialect, table| { + dialect + .ddl() + .ok_or(BuildDdlError::Unsupported(DdlOperation::DropTable))? + .plan_drop_table(table, options) + }, + move |result| match result { + Ok(()) => WorkspaceInput::DropTableSucceeded(answered), // The only guard a drop plan carries is SQLite's count of // the keys pointing at the table, which one added since // the prompt read them fails. - Err(OperationError::Driver(DriverError::RowGuardFailed { .. })) => { - input.deliver(WorkspaceInput::ShowAlert { - title: crate::i18n::gettext("Drop Failed"), - body: crate::i18n::gettext_f( - "A foreign key on another table points at {table}, so it was not dropped. Drop that foreign key first.", - &[("table", &display_name(&dropped))], - ), - }) - } - Err(error) => input.deliver(WorkspaceInput::ShowAlert { + Err(OperationError::Driver(DriverError::RowGuardFailed { .. })) => WorkspaceInput::ShowAlert { + title: crate::i18n::gettext("Drop Failed"), + body: crate::i18n::gettext_f( + "A foreign key on another table points at {table}, so it was not dropped. Drop that foreign key first.", + &[("table", &display_name(&answered))], + ), + }, + Err(error) => WorkspaceInput::ShowAlert { title: crate::i18n::gettext("Drop Failed"), body: error_text::operation_error_message(&error), - }), - } - handle.ledger().settle(ticket.id); - }); + }, + }, + ); } pub(super) fn on_drop_table_succeeded(&mut self, table: TableRef) { @@ -224,21 +198,28 @@ impl WorkspaceModel { }); } - /// Walk the tab map and force-close any Browse / Structure tabs - /// pointing at `(schema, table)`. Skips the per-tab pending-changes - /// dialog because the underlying table is gone. + /// Close the Browse and Structure tabs on `(schema, table)` without + /// asking about their pending changes: the table is gone, and so is + /// any way to reopen them. pub(super) fn close_tabs_for_table(&mut self, schema: Option<&str>, table: &str) { + let targets: Vec = self + .workspace_tabs + .borrow() + .iter() + .filter(|(_, tab)| match tab { + WorkspaceTab::Structure(s) => s.schema.as_deref() == schema && s.table == table, + WorkspaceTab::Table(s) => s.schema.as_deref() == schema && s.table == table, + _ => false, + }) + .map(|(id, _)| *id) + .collect(); + self.forget_tabs(targets); + } + + pub(super) fn forget_tabs(&mut self, targets: Vec) { let tab_view = self.tab_view.clone(); - let mut targets: Vec = Vec::new(); - for (id, tab) in self.workspace_tabs.borrow().iter() { - match tab { - WorkspaceTab::Structure(s) if s.schema.as_deref() == schema && s.table == table => targets.push(*id), - WorkspaceTab::Table(s) if s.schema.as_deref() == schema && s.table == table => targets.push(*id), - _ => {} - } - } for id in targets { - self.finish_close_workspace_tab(id, &tab_view); + self.forget_workspace_tab(id, &tab_view); } } @@ -363,27 +344,7 @@ impl WorkspaceModel { }; self.diagrams_out_of_date(schema.as_deref(), table.is_some()); if let Some(table) = table { - session.invalidate_table(&TableRef::new(schema.clone(), table.clone())); - let (browse_tabs, structure_tabs): (Vec<_>, Vec<_>) = self - .workspace_tabs - .borrow() - .values() - .filter_map(|tab| match tab { - WorkspaceTab::Table(slot) if slot.schema == schema && slot.table == table => { - Some((Some(slot.browse.clone()), None)) - } - WorkspaceTab::Structure(slot) if slot.schema == schema && slot.table == table => { - Some((None, Some(slot.structure.clone()))) - } - _ => None, - }) - .unzip(); - for browse in browse_tabs.into_iter().flatten() { - browse.send(crate::ui::browse_tab::DataInput::SchemaChanged); - } - for structure in structure_tabs.into_iter().flatten() { - structure.send(LoadInput::SchemaChanged); - } + self.reload_table_tabs(&session, &TableRef::new(schema, table)); } // The catalog watch takes the new list to the sidebar, and a // failure to it as a toast. The default schema's list, which the @@ -395,9 +356,38 @@ impl WorkspaceModel { } } +impl WorkspaceModel { + /// The catalog forgets the table's columns, and every tab open on the + /// table reads it again through its own requests, which share the + /// catalog's one column fetch. + pub(super) fn reload_table_tabs(&self, session: &Session, table: &TableRef) { + session.invalidate_table(table); + let (browse_tabs, structure_tabs): (Vec<_>, Vec<_>) = self + .workspace_tabs + .borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Table(slot) if slot.schema == table.schema && slot.table == table.name => { + Some((Some(slot.browse.clone()), None)) + } + WorkspaceTab::Structure(slot) if slot.schema == table.schema && slot.table == table.name => { + Some((None, Some(slot.structure.clone()))) + } + _ => None, + }) + .unzip(); + for browse in browse_tabs.into_iter().flatten() { + browse.send(crate::ui::browse_tab::DataInput::SchemaChanged); + } + for structure in structure_tabs.into_iter().flatten() { + structure.send(LoadInput::SchemaChanged); + } + } +} + /// The table's definition and the dialect of the connection that read /// it, so the DDL built from it is spelled for that same server. -async fn read_definition( +pub(super) async fn read_definition( session: Arc, table: TableRef, ) -> Result<(TableDefinition, Arc), OperationError> { @@ -413,7 +403,7 @@ async fn read_definition( } /// The sidebar lists every schema, so two tables can share a name. -fn display_name(table: &TableRef) -> String { +pub(super) fn display_name(table: &TableRef) -> String { match &table.schema { Some(schema) => format!("{schema}.{}", table.name), None => table.name.clone(), @@ -422,7 +412,7 @@ fn display_name(table: &TableRef) -> String { /// The keys of other tables that point at the table. A table's key on /// itself goes with it, so it does not stop a drop. -fn referencing_keys(definition: &TableDefinition) -> Vec { +pub(super) fn referencing_keys(definition: &TableDefinition) -> Vec { definition .inbound_foreign_keys .iter() @@ -433,7 +423,7 @@ fn referencing_keys(definition: &TableDefinition) -> Vec { /// Each key as its table and columns, the table qualified only where it /// sits in another schema than the dropped one. -fn key_list(dropped: &TableRef, keys: &[InboundForeignKey]) -> String { +pub(super) fn key_list(dropped: &TableRef, keys: &[InboundForeignKey]) -> String { keys.iter() .map(|key| { let table = match key.table.schema == dropped.schema { @@ -462,7 +452,7 @@ fn create_table_sql(definition: &TableDefinition, dialect: &dyn SqlDialect) -> R /// A task that never answered is reported like the operation it ran: a /// panic as the bug it is, an abort as the app stopping the work. -fn settled(result: Result, TaskFailure>) -> Result { +pub(super) fn settled(result: Result, TaskFailure>) -> Result { result.unwrap_or_else(|failure| { if failure.is_panic() { tracing::error!(%failure, "a structure task panicked"); diff --git a/linux/crates/app/src/ui/workspace/tests/catalog_ops.rs b/linux/crates/app/src/ui/workspace/tests/catalog_ops.rs new file mode 100644 index 0000000000..09bdd2e64a --- /dev/null +++ b/linux/crates/app/src/ui/workspace/tests/catalog_ops.rs @@ -0,0 +1,602 @@ +//! Emptying a table and creating or dropping a database from the +//! sidebar: what each asks, what it refuses, what it runs and records, +//! and what it closes. + +use std::sync::Arc; + +use gtk4 as gtk; +use gtk4::prelude::*; +use gtk4::subclass::prelude::*; +use libadwaita as adw; +use libadwaita::prelude::*; +use tablepro_core::ddl::{CharacterSet, DatabaseChoices, DatabaseDefaults, InboundForeignKey, TableDefinition}; +use tablepro_core::dialect::SqlDialect; +use tablepro_core::edit::CellInput; +use tablepro_core::meta::{SchemaInfo, TableRef}; +use tablepro_core::{ForeignKeyInfo, SafeModeLevel, ServerInfo, Value, WriteReport}; +use tablepro_storage::query_history::{HistoryPageRequest, HistoryQuery}; +use tablepro_test_support::{CallCounters, FakeConnection, FakeReply}; +use uuid::Uuid; + +use super::super::catalog_ops::{TRUNCATE, droppable_schema}; +use super::super::{OpenMode, Workspace, WorkspaceInput}; +use super::shell_of; +use crate::runtime::DeliverInput; +use crate::services::change_tracker::RowKey; +use crate::services::save_batch::SaveTarget; +use crate::services::tracker_edit::TrackerEdit; +use crate::test_support::{descendants, drain_main_context, finish_on_main, wait_until}; +use crate::ui::session_window::tests::{TestWindow, WAIT}; +use crate::workspace::test_workspace::integer; +use crate::workspace::{PendingState, answer, visible_alert}; + +fn postgres() -> Arc { + Arc::new(tablepro_driver_postgres::PgDialect::new(&ServerInfo::new( + "PostgreSQL", + "18.0", + 18, + 0, + 0, + ))) +} + +fn mysql() -> Arc { + Arc::new(tablepro_driver_mysql::MySqlDialect::new( + tablepro_driver_mysql::MySqlFlavour::MySql, + &ServerInfo::new("MySQL", "8.4.3", 8, 4, 3), + )) +} + +fn users() -> TableRef { + TableRef::new(Some("public".to_owned()), "users") +} + +fn pointing_at_users(name: &str, table: TableRef, column: &str) -> InboundForeignKey { + InboundForeignKey { + child_compares_alike: true, + name: name.to_owned(), + table, + columns: vec![column.to_owned()], + referenced_columns: vec!["id".to_owned()], + } +} + +fn users_definition(inbound_foreign_keys: Vec) -> TableDefinition { + TableDefinition { + table: users(), + columns: Vec::new(), + primary_key: None, + indexes: Vec::new(), + foreign_keys: Vec::::new(), + inbound_foreign_keys, + source: None, + options: None, + } +} + +/// A PostgreSQL connection that writes whatever it is sent, and whose +/// `users` table other tables point at through `keys`. +fn postgres_writing(name: &str, keys: Vec) -> TestWindow { + postgres_pointing(name, vec![("users", keys)]) +} + +/// A PostgreSQL connection that writes whatever it is sent, where the +/// keys each `(table, keys)` lists point at that table, and nothing +/// points at any other. +fn postgres_pointing(name: &str, pointing: Vec<(&str, Vec)>) -> TestWindow { + TestWindow::connected_with(name, |script| { + script.dialect = postgres(); + script.write_batch.always(FakeReply::ok(WriteReport::new(vec![None]))); + for (table, keys) in pointing { + script + .fetch_inbound_foreign_keys + .when_sql_contains(table, FakeReply::ok(keys)); + } + script.fetch_inbound_foreign_keys.always(FakeReply::ok(Vec::new())); + }) +} + +fn public(name: &str) -> TableRef { + TableRef::new(Some("public".to_owned()), name) +} + +/// Open `table` in a tab of its own and leave an unsaved edit in it. +fn edit_in_a_tab(workspace: &Workspace, table: &str) -> Uuid { + let before = workspace.open_tabs().unwrap_or_default(); + workspace + .sender() + .expect("the workspace's input") + .deliver(WorkspaceInput::SelectTable { + schema: Some("public".to_owned()), + name: table.to_owned(), + open_mode: OpenMode::NewTab, + }); + let mut opened = None; + wait_until(WAIT, || { + opened = workspace + .open_tabs() + .and_then(|tabs| tabs.into_iter().find(|tab| !before.contains(tab))); + opened.is_some() + }) + .expect("the table's tab"); + let tab = opened.expect("the tab"); + let tracker = super::row_tracker(workspace, tab).expect("the table's tracker"); + let _ = tracker.set_target(SaveTarget { + table: public(table), + columns: vec![integer("id", true), integer("n", false)], + }); + let _ = tracker.apply(TrackerEdit::Cell { + row_key: RowKey::from_pk_values(&[Value::Int(1)]).expect("a key"), + col: 1, + original: Value::Int(1), + value: CellInput::Value(Value::Int(2)), + }); + assert_eq!(tracker.pending_state(), PendingState::Unsaved); + tab +} + +fn pending(workspace: &Workspace, tab: Uuid) -> Option { + super::row_tracker(workspace, tab).map(|tracker| tracker.pending_state()) +} + +fn confirm_writes(test: &TestWindow) { + let session = test + .workspace() + .with_model(|model| model.context.active.get()) + .flatten() + .expect("the session"); + assert!(session.set_safe_mode(SafeModeLevel::ConfirmWrites)); +} + +fn alert_titled(test: &TestWindow, heading: &str) -> adw::AlertDialog { + let mut found = None; + wait_until(WAIT, || { + found = visible_alert(&test.window).filter(|alert| alert.heading().as_deref() == Some(heading)); + found.is_some() + }) + .unwrap_or_else(|_| { + panic!( + "no alert {heading:?}, but {:?}", + visible_alert(&test.window).and_then(|alert| alert.heading()) + ) + }); + found.expect("the alert") +} + +fn shown_sql(root: &impl IsA) -> String { + descendants(root) + .into_iter() + .find_map(|widget| widget.downcast::().ok()) + .map(|view| { + let buffer = view.buffer(); + buffer.text(&buffer.start_iter(), &buffer.end_iter(), false).to_string() + }) + .unwrap_or_default() +} + +fn switch_titled(root: &impl IsA, title: &str) -> adw::SwitchRow { + descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == title) + .unwrap_or_else(|| panic!("no {title} switch")) +} + +fn row_titles(root: &impl IsA) -> Vec { + descendants(root) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .map(|row| row.title().to_string()) + .collect() +} + +fn written(connection: &FakeConnection) -> Vec { + connection.observed_sql("write_batch").into_iter().flatten().collect() +} + +fn toast_shown(test: &TestWindow, text: &str) -> bool { + test.window.shell().is_some_and(|shell| { + descendants(&shell.toast_overlay()) + .iter() + .filter_map(|widget| widget.downcast_ref::()) + .any(|label| label.label() == text) + }) +} + +/// The statements the window's history holds, newest first. +fn recorded(test: &TestWindow) -> Vec { + let Some(store) = test.services.history().store() else { + return Vec::new(); + }; + let search = test.services.tasks.spawn_task(async move { + let everything = HistoryQuery::default(); + store.search_page(&everything, HistoryPageRequest::first(100)).await + }); + match finish_on_main(WAIT, search) { + Ok(Ok(Ok(page))) => page.entries.into_iter().map(|entry| entry.query).collect(), + _ => Vec::new(), + } +} + +#[gtk4::test] +fn a_dropped_table_is_in_the_history() { + let test = postgres_writing("DropRecorded", Vec::new()); + let availability = test.services.history().availability(); + wait_until(WAIT, || availability.borrow().is_ready()).expect("the query database to open"); + + test.workspace() + .with_model(|model| model.on_drop_table_confirmed(Some("public".to_owned()), "users".to_owned())); + + wait_until(WAIT, || { + recorded(&test) + .iter() + .any(|query| query.contains("DROP TABLE") && query.contains("\"users\"")) + }) + .expect("the drop in the history"); +} + +#[gtk4::test] +fn a_read_only_connection_empties_nothing() { + let test = TestWindow::connected_with("TruncateReadOnly", |script| { + script.dialect = postgres(); + script.read_only = true; + }); + + test.workspace() + .with_model(|model| model.on_truncate_table_prompt(users())); + + wait_until(WAIT, || toast_shown(&test, "This connection is read-only.")).expect("the refusal"); + drain_main_context(); + assert!( + visible_alert(&test.window).is_none(), + "a read-only connection asked to empty a table" + ); + let counters = test.connection().counters(); + assert_eq!(CallCounters::get(&counters.fetch_table_definition), 0); + assert_eq!(CallCounters::get(&counters.write_batch), 0); +} + +#[gtk4::test] +fn under_confirm_writes_the_truncate_shows_its_sql_and_cancel_is_the_default() { + let test = postgres_writing("TruncateConfirmWrites", Vec::new()); + confirm_writes(&test); + + test.workspace() + .with_model(|model| model.on_truncate_table_prompt(users())); + + let alert = alert_titled(&test, "Truncate “public.users”?"); + let sql = shown_sql(&alert); + assert!(sql.contains("TRUNCATE TABLE \"public\".\"users\""), "{sql}"); + assert!(alert.body().contains("asks before every change"), "{}", alert.body()); + assert_eq!(alert.default_response().as_deref(), Some("cancel")); + assert_eq!(alert.close_response(), "cancel"); + assert_eq!( + alert.response_appearance(TRUNCATE), + adw::ResponseAppearance::Destructive + ); + assert!(answer(&test.window, "cancel")); + drain_main_context(); + assert!(written(&test.connection()).is_empty(), "a cancelled truncate ran"); +} + +#[gtk4::test] +fn a_table_other_tables_point_at_is_refused_where_the_server_refuses_it() { + let shop_users = TableRef::new(Some("shop".to_owned()), "users"); + let mut definition = users_definition(vec![ + pointing_at_users( + "orders_user", + TableRef::new(Some("shop".to_owned()), "orders"), + "user_id", + ), + pointing_at_users("users_parent", shop_users.clone(), "parent_id"), + ]); + definition.table = shop_users.clone(); + let test = TestWindow::connected_with("TruncateRefusedMySql", |script| { + script.dialect = mysql(); + script.fetch_table_definition.always(FakeReply::ok(definition.clone())); + }); + + test.workspace() + .with_model(|model| model.on_truncate_table_prompt(shop_users)); + + let alert = alert_titled(&test, "Cannot Truncate Table"); + let body = alert.body(); + assert!(body.contains("orders (user_id)"), "{body}"); + assert!(body.contains("Drop those foreign keys first"), "{body}"); + assert!( + !body.contains("parent_id"), + "a key of the table on itself was named: {body}" + ); + assert!(!alert.has_response(TRUNCATE), "the refusal offered the truncate"); + assert!(answer(&test.window, "close")); + assert!(written(&test.connection()).is_empty()); +} + +#[gtk4::test] +fn on_postgres_cascade_lets_a_referenced_table_be_emptied_and_says_so_in_the_sql() { + let test = postgres_writing( + "TruncateCascade", + vec![pointing_at_users( + "orders_user", + TableRef::new(Some("public".to_owned()), "orders"), + "user_id", + )], + ); + confirm_writes(&test); + + test.workspace() + .with_model(|model| model.on_truncate_table_prompt(users())); + + let alert = alert_titled(&test, "Truncate “public.users”?"); + assert!(alert.body().contains("orders points at it"), "{}", alert.body()); + assert!( + !alert.is_response_enabled(TRUNCATE), + "a referenced table could be emptied without Cascade" + ); + assert!(!shown_sql(&alert).contains("CASCADE")); + switch_titled(&alert, "Cascade").set_active(true); + assert!(alert.is_response_enabled(TRUNCATE)); + let sql = shown_sql(&alert); + assert!(sql.contains("CASCADE"), "{sql}"); + + let connection = test.connection(); + assert!(answer(&test.window, TRUNCATE)); + wait_until(WAIT, || written(&connection).iter().any(|sql| sql.contains("CASCADE"))) + .expect("the truncate with Cascade"); +} + +#[gtk4::test] +fn a_new_database_waits_for_its_name_and_shows_only_its_engines_options() { + let test = TestWindow::connected_with("NewDatabase", |script| { + script.dialect = postgres(); + script.write_batch.always(FakeReply::ok(WriteReport::new(vec![None]))); + script.fetch_database_choices.always(FakeReply::ok(DatabaseChoices { + owners: vec!["app".to_owned(), "reporting".to_owned()], + defaults: DatabaseDefaults { + owner: Some("app".to_owned()), + ..DatabaseDefaults::default() + }, + ..DatabaseChoices::default() + })); + }); + + // A new PostgreSQL database opens beside the connection, which + // connects again, so the connection asked is the one taken now. + let connection = test.connection(); + test.workspace().with_model(|model| model.on_new_database_prompt()); + + let mut form = None; + wait_until(WAIT, || { + form = test + .window + .visible_dialog() + .filter(|dialog| dialog.title() == "New Database"); + form.is_some() + }) + .expect("the New Database form"); + let form = form.expect("the form"); + let titles = row_titles(&form); + for shown in ["Name", "Owner", "Template", "Encoding", "Locale"] { + assert!(titles.iter().any(|title| title == shown), "no {shown} row: {titles:?}"); + } + for hidden in ["Character set", "Collation", "Replication", "First collection"] { + assert!( + !titles.iter().any(|title| title == hidden), + "a {hidden} row: {titles:?}" + ); + } + let create = descendants(&form) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|button| button.has_css_class("suggested-action")) + .expect("the Create button"); + assert!(!create.is_sensitive(), "Create was on with no name"); + + descendants(&form) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == "Name") + .expect("the name row") + .set_text("shop"); + assert!(create.is_sensitive(), "Create stayed off with a name"); + create.emit_clicked(); + + wait_until(WAIT, || { + written(&connection) + .iter() + .any(|sql| sql.contains("CREATE DATABASE \"shop\"")) + }) + .expect("the CREATE DATABASE"); +} + +#[gtk4::test] +fn tabs_closed_by_a_drop_cannot_be_reopened() { + let test = postgres_writing("DropForgetsTabs", Vec::new()); + let workspace = test.workspace(); + workspace + .sender() + .expect("the workspace's input") + .deliver(WorkspaceInput::SelectTable { + schema: Some("public".to_owned()), + name: "users".to_owned(), + open_mode: OpenMode::NewTab, + }); + wait_until(WAIT, || workspace.open_tabs().is_some_and(|tabs| tabs.len() == 1)).expect("the table's tab"); + + workspace.with_model(|model| model.on_drop_table_confirmed(Some("public".to_owned()), "users".to_owned())); + + wait_until(WAIT, || workspace.open_tabs().is_some_and(|tabs| tabs.is_empty())).expect("the tab to close"); + assert!( + shell_of(&workspace).imp().closed_tabs.borrow().is_empty(), + "Reopen Closed Tab would open a table that is gone" + ); +} + +#[gtk4::test] +fn a_database_change_or_a_refresh_lists_each_editor_tabs_databases_again() { + let test = TestWindow::connected_with("RelistDatabases", |script| { + script.dialect = postgres(); + script.list_databases.always(FakeReply::ok(vec![SchemaInfo { + name: "shop".to_owned(), + is_default: true, + is_system: false, + }])); + }); + let workspace = test.workspace(); + let input = workspace.sender().expect("the workspace's input"); + let counters = test.connection().counters(); + input.deliver(WorkspaceInput::NewEditorTab); + wait_until(WAIT, || CallCounters::get(&counters.list_databases) >= 1).expect("the new tab to list them"); + let listed = CallCounters::get(&counters.list_databases); + + input.deliver(WorkspaceInput::DatabasesChanged); + wait_until(WAIT, || CallCounters::get(&counters.list_databases) > listed) + .expect("a database change to list them again"); + let listed = CallCounters::get(&counters.list_databases); + + input.deliver(WorkspaceInput::RefreshCatalog); + wait_until(WAIT, || CallCounters::get(&counters.list_databases) > listed).expect("a refresh to list them again"); +} + +/// Cascade empties the tables pointing at the table, and those pointing +/// at them: the Cascade row names them while it is on, and their tabs +/// lose their edits and read the tables again once it ran. +#[gtk4::test] +fn a_cascading_truncate_reloads_every_table_it_empties() { + let test = postgres_pointing( + "TruncateCascadeReach", + vec![ + ( + "users", + vec![pointing_at_users("orders_user", public("orders"), "user_id")], + ), + ( + "orders", + vec![pointing_at_users("lines_order", public("lines"), "order_id")], + ), + ], + ); + let workspace = test.workspace(); + let (orders, lines) = (edit_in_a_tab(&workspace, "orders"), edit_in_a_tab(&workspace, "lines")); + let connection = test.connection(); + let counters = connection.counters(); + + workspace.with_model(|model| model.on_truncate_table_prompt(users())); + + let alert = alert_titled(&test, "Truncate “public.users”?"); + let cascade = switch_titled(&alert, "Cascade"); + let subtitle = |row: &adw::SwitchRow| row.subtitle().map(|text| text.to_string()).unwrap_or_default(); + assert!(!subtitle(&cascade).contains("lines"), "{}", subtitle(&cascade)); + cascade.set_active(true); + let named = subtitle(&cascade); + assert!(named.contains("orders, lines"), "{named}"); + assert!(named.contains("unsaved changes"), "{named}"); + let reads = CallCounters::get(&counters.fetch_columns); + assert!(answer(&test.window, TRUNCATE)); + + wait_until(WAIT, || { + pending(&workspace, orders) == Some(PendingState::Clean) + && pending(&workspace, lines) == Some(PendingState::Clean) + }) + .expect("the edits of every table the cascade emptied to go"); + // A tab behind the one in front can wait to read until it is shown. + wait_until(WAIT, || CallCounters::get(&counters.fetch_columns) > reads) + .expect("the tables' tabs to read them again"); + assert!(written(&connection).iter().any(|sql| sql.contains("CASCADE"))); +} + +/// Left to the server, a MySQL database takes the server's own +/// collation, which need not be its character set's default. +#[gtk4::test] +fn the_collation_left_to_the_server_names_the_servers_own() { + let set = |name: &str, collations: &[&str]| CharacterSet { + name: name.to_owned(), + default_collation: collations[0].to_owned(), + collations: collations.iter().map(|collation| (*collation).to_owned()).collect(), + }; + let test = TestWindow::connected_with("NewDatabaseCollation", |script| { + script.dialect = mysql(); + script.fetch_database_choices.always(FakeReply::ok(DatabaseChoices { + character_sets: vec![ + set("utf8mb4", &["utf8mb4_0900_ai_ci", "utf8mb4_unicode_ci"]), + set("latin1", &["latin1_swedish_ci", "latin1_bin"]), + ], + defaults: DatabaseDefaults { + character_set: Some("utf8mb4".to_owned()), + collation: Some("utf8mb4_unicode_ci".to_owned()), + ..DatabaseDefaults::default() + }, + ..DatabaseChoices::default() + })); + }); + + test.workspace().with_model(|model| model.on_new_database_prompt()); + + let mut form = None; + wait_until(WAIT, || { + form = test + .window + .visible_dialog() + .filter(|dialog| dialog.title() == "New Database"); + form.is_some() + }) + .expect("the New Database form"); + let form = form.expect("the form"); + let combo = |title: &str| { + descendants(&form) + .into_iter() + .filter_map(|widget| widget.downcast::().ok()) + .find(|row| row.title() == title) + .unwrap_or_else(|| panic!("no {title} row")) + }; + let first_entry = |row: &adw::ComboRow| { + row.model() + .and_downcast::() + .and_then(|entries| entries.string(0)) + .map(|entry| entry.to_string()) + }; + let (character_set, collation) = (combo("Character set"), combo("Collation")); + + assert_eq!(first_entry(&collation).as_deref(), Some("Default (utf8mb4_unicode_ci)")); + character_set.set_selected(2); + assert_eq!(first_entry(&collation).as_deref(), Some("Default (latin1_swedish_ci)")); + character_set.set_selected(0); + assert_eq!(first_entry(&collation).as_deref(), Some("Default (utf8mb4_unicode_ci)")); +} + +#[test] +fn a_schema_named_like_its_database_can_be_dropped_where_schemas_are_not_databases() { + let schema = |name: &str, is_default: bool| SchemaInfo { + name: name.to_owned(), + is_default, + is_system: false, + }; + let schemas = [ + schema("public", true), + schema("shop", false), + schema("pg_temp_3", false), + ]; + + assert!(droppable_schema(&schemas, Some("shop"), &*postgres(), "shop")); + assert!(!droppable_schema(&schemas, Some("shop"), &*postgres(), "public")); + assert!(!droppable_schema(&schemas, Some("shop"), &*postgres(), "pg_temp_3")); + assert!(!droppable_schema(&schemas, Some("shop"), &*postgres(), "missing")); + assert!( + !droppable_schema(&schemas, Some("shop"), &*mysql(), "shop"), + "MySQL dropped the database the session is in" + ); +} + +#[test] +fn the_tables_that_point_at_one_are_named_once_each() { + let keys = [ + pointing_at_users("a", TableRef::new(Some("public".to_owned()), "orders"), "user_id"), + pointing_at_users("b", TableRef::new(Some("public".to_owned()), "orders"), "buyer_id"), + pointing_at_users("c", TableRef::new(Some("audit".to_owned()), "log"), "actor_id"), + ]; + + assert_eq!( + super::super::catalog_ops::referencing_tables(&users(), &keys), + ["orders", "audit.log"] + ); +} diff --git a/linux/crates/app/src/ui/workspace/tests/mod.rs b/linux/crates/app/src/ui/workspace/tests/mod.rs index b11246bb43..c9bac1b33b 100644 --- a/linux/crates/app/src/ui/workspace/tests/mod.rs +++ b/linux/crates/app/src/ui/workspace/tests/mod.rs @@ -1,4 +1,5 @@ mod assistant; +mod catalog_ops; mod close_tab; mod definitions; mod diagram; diff --git a/linux/crates/app/src/ui/workspace/workspace_tabs.rs b/linux/crates/app/src/ui/workspace/workspace_tabs.rs index ef22586280..cbd10461cd 100644 --- a/linux/crates/app/src/ui/workspace/workspace_tabs.rs +++ b/linux/crates/app/src/ui/workspace/workspace_tabs.rs @@ -230,13 +230,24 @@ impl WorkspaceModel { /// Tear down a tab without prompting. Internal helper called once /// the close-with-pending dialog (if any) has resolved. pub(super) fn finish_close_workspace_tab(&mut self, id: Uuid, tab_view: &adw::TabView) { + self.tear_down_tab(id, tab_view, true); + } + + /// Tear down a tab whose table, schema or database is gone, so Reopen + /// Closed Tab never offers it. + pub(super) fn forget_workspace_tab(&mut self, id: Uuid, tab_view: &adw::TabView) { + self.tear_down_tab(id, tab_view, false); + } + + fn tear_down_tab(&mut self, id: Uuid, tab_view: &adw::TabView, remember: bool) { let removed = self.workspace_tabs.borrow_mut().remove(&id); let Some(removed) = removed else { return; }; // Before the teardown, while the tab still has its page, sort // and query. - if let Some(descriptor) = describe_closed_tab(&removed) + if remember + && let Some(descriptor) = describe_closed_tab(&removed) && let (Some(shell), Some(this)) = (self.shell.upgrade(), self.this.upgrade()) { shell.remember_closed_tab(&this, descriptor); diff --git a/linux/crates/app/src/ui/write_confirmation.rs b/linux/crates/app/src/ui/write_confirmation.rs index 50258e4937..3736846020 100644 --- a/linux/crates/app/src/ui/write_confirmation.rs +++ b/linux/crates/app/src/ui/write_confirmation.rs @@ -131,6 +131,18 @@ impl WriteConfirmation { destructive: false, } } + + /// A schema or database the sidebar creates, on a connection that + /// asks before every write. `heading` names what is created. + pub(crate) fn creation(connection: &str, heading: String, sql: String) -> Self { + Self { + heading, + body: asks_before_writes(connection), + sql, + proceed: crate::i18n::gettext("_Create"), + destructive: false, + } + } } /// A client's statement as the user should read it. A character that @@ -383,6 +395,27 @@ mod tests { ); } + #[gtk4::test] + fn a_creation_asks_with_its_sql_and_create() { + adw::init().expect("libadwaita initialises under the test backend"); + let confirmation = WriteConfirmation::creation( + "Prod", + "Create Database “shop”?".to_owned(), + "CREATE DATABASE \"shop\";".to_owned(), + ); + + let dialog = build(&confirmation); + + assert_eq!(dialog.heading().as_deref(), Some("Create Database “shop”?")); + assert_eq!( + dialog.body(), + "Safe Mode on “Prod” asks before every change to the database." + ); + assert_eq!(shown_sql(&dialog), "CREATE DATABASE \"shop\";"); + assert_eq!(dialog.response_label(PROCEED).as_str(), "_Create"); + assert_eq!(dialog.default_response().as_deref(), Some(CANCEL)); + } + fn bound(sql: &str, value: Option) -> Statement { let mut builder = StatementBuilder::new(); builder.push_sql(sql); diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index fed49aefd4..93e691b386 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -103,7 +103,7 @@ pub use save_settled::SaveSettled; pub use save_snapshot::SaveSnapshot; pub use save_start::SaveStart; pub use save_ticket::SaveTicket; -pub(crate) use schema_write::run_schema_plan; +pub(crate) use schema_write::{CatalogChange, database_target, run_catalog_change, run_schema_plan}; pub use session_key_record::SessionKeyRecord; pub use sort_record::SortRecord; pub use tab_directory::TabDirectory; @@ -118,6 +118,7 @@ pub use work_kind::WorkKind; pub use work_outcome::WorkOutcome; pub use workspace_event::WorkspaceEvent; pub use workspace_file_name::WorkspaceFileName; +pub(crate) use workspace_handle::HistoryEntry; pub use workspace_handle::{WorkspaceDeps, WorkspaceHandle}; pub use workspace_load::WorkspaceLoad; pub use workspace_load_error::WorkspaceLoadError; diff --git a/linux/crates/app/src/workspace/schema_write.rs b/linux/crates/app/src/workspace/schema_write.rs index ac0d70549e..77af8d2561 100644 --- a/linux/crates/app/src/workspace/schema_write.rs +++ b/linux/crates/app/src/workspace/schema_write.rs @@ -1,12 +1,15 @@ use std::sync::Arc; use tablepro_core::ddl::{BuildDdlError, DdlDialect, DdlOperation, DdlPlan}; +use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::TableRef; use tablepro_core::statement::SqlBuildError; -use tablepro_core::{WriteKind, WriteReport}; +use tablepro_core::{CommitResolution, WriteKind, WriteReport}; use tablepro_session::{OperationError, OperationKind, Session}; use tokio_util::sync::CancellationToken; +use super::workspace_handle::{HistoryEntry, record, resolve_commit}; + /// Plan a schema change with the dialect of the connection that runs /// it, and run the plan as one write. The statements come back whether /// or not the write went through, for the history. `operation` is what @@ -20,25 +23,132 @@ pub(crate) async fn run_schema_plan

( ) -> Result<(Vec, Result), OperationError> where P: Fn(&dyn DdlDialect, &TableRef) -> Result + Send + Sync, +{ + run_plan( + &session, + WriteKind::SchemaChanges, + table, + cancel, + move |dialect, table| match dialect.ddl() { + Some(ddl) => plan(ddl, table), + None => Err(BuildDdlError::Unsupported(operation)), + }, + ) + .await +} + +/// A table emptied or dropped, or a schema or database created or +/// dropped. +pub(crate) struct CatalogChange { + /// What the session runs the write as. The batch is wrapped as the + /// plan's own kind says. + pub kind: WriteKind, + /// What the batch names: the table, or a schema or database as a + /// schema with no table. + pub target: TableRef, + pub history: HistoryEntry, +} + +/// Run a catalog change planned with the dialect of the connection that +/// runs it, ask the server about a commit whose answer was lost, and +/// record it in the history however it went, as a save is. +pub(crate) async fn run_catalog_change

( + session: Arc, + change: CatalogChange, + cancel: &CancellationToken, + plan: P, +) -> Result<(), OperationError> +where + P: Fn(&dyn SqlDialect, &TableRef) -> Result + Send + Sync, +{ + let CatalogChange { kind, target, history } = change; + let (statements, written) = run_plan(&session, kind, target, cancel, plan) + .await + .unwrap_or_else(|error| (Vec::new(), Err(error))); + let resolution = resolve_commit(&session, &written).await; + record(history, statements, &written, resolution.as_ref()).await; + landed(written, resolution) +} + +/// A database names no table, so its batch names it as a schema. +pub(crate) fn database_target(database: &str) -> TableRef { + TableRef::new(Some(database.to_owned()), "") +} + +fn landed( + written: Result, + resolution: Option>, +) -> Result<(), OperationError> { + match (written, resolution) { + (Ok(_), _) | (Err(_), Some(Ok(CommitResolution::Applied))) => Ok(()), + (Err(error), _) => Err(error), + } +} + +async fn run_plan

( + session: &Session, + kind: WriteKind, + target: TableRef, + cancel: &CancellationToken, + plan: P, +) -> Result<(Vec, Result), OperationError> +where + P: Fn(&dyn SqlDialect, &TableRef) -> Result + Send + Sync, { session - .run_cancellable( - OperationKind::Save(WriteKind::SchemaChanges), - cancel, - move |lease, options| { - let table = table.clone(); - let dialect = lease.dialect(); - let planned = match dialect.ddl() { - Some(ddl) => plan(ddl, &table).map_err(SqlBuildError::from), - None => Err(SqlBuildError::from(BuildDdlError::Unsupported(operation))), - }; - async move { - let plan = planned?; - let statements: Vec = plan.sql().into_iter().map(str::to_owned).collect(); - let written = lease.write_batch(&plan.into_write_batch(table), options).await; - Ok((statements, written.map_err(OperationError::from))) - } - }, - ) + .run_cancellable(OperationKind::Save(kind), cancel, move |lease, options| { + let target = target.clone(); + let dialect = lease.dialect(); + let planned = plan(&*dialect, &target).map_err(SqlBuildError::from); + async move { + let plan = planned?; + let statements: Vec = plan.sql().into_iter().map(str::to_owned).collect(); + let written = lease.write_batch(&plan.into_write_batch(target), options).await; + Ok((statements, written.map_err(OperationError::from))) + } + }) .await } + +#[cfg(test)] +mod tests { + use tablepro_core::ddl::DropDatabaseOptions; + use tablepro_core::{CommitToken, DriverError, LossPhase}; + + use super::*; + + fn postgres() -> tablepro_driver_postgres::PgDialect { + tablepro_driver_postgres::PgDialect::new(&tablepro_core::ServerInfo::new("PostgreSQL", "18.0", 18, 0, 0)) + } + + #[test] + fn a_database_batch_runs_outside_a_transaction_and_names_the_database() { + let plan = tablepro_core::ddl::plan_drop_database(&postgres(), "shop", &DropDatabaseOptions::default()) + .expect("PostgreSQL drops a database"); + + let batch = plan.into_write_batch(database_target("shop")); + + assert_eq!(batch.kind, WriteKind::DatabaseChanges); + assert_eq!(batch.table, TableRef::new(Some("shop".to_owned()), "")); + } + + #[test] + fn a_lost_commit_the_server_applied_landed() { + let lost = || { + Err(OperationError::from(DriverError::CommitOutcomeUnknown { + token: Some(CommitToken::PostgresXid { + xid: 4, + legacy_txid: false, + }), + source: Box::new(DriverError::ConnectionLost { + during: LossPhase::Commit, + }), + })) + }; + + assert_eq!(landed(lost(), Some(Ok(CommitResolution::Applied))), Ok(())); + assert!(landed(lost(), Some(Ok(CommitResolution::NotApplied))).is_err()); + assert!(landed(lost(), None).is_err()); + assert_eq!(landed(Ok(WriteReport::new(vec![None])), None), Ok(())); + } +} diff --git a/linux/crates/app/src/workspace/workspace_handle.rs b/linux/crates/app/src/workspace/workspace_handle.rs index 4b75b8d680..8dd7014f71 100644 --- a/linux/crates/app/src/workspace/workspace_handle.rs +++ b/linux/crates/app/src/workspace/workspace_handle.rs @@ -557,7 +557,7 @@ impl WorkspaceHandle { /// Captured before the work starts: the connection may be gone by /// the time the answer comes back. - fn history_entry(&self, session: &Session) -> HistoryEntry { + pub(crate) fn history_entry(&self, session: &Session) -> HistoryEntry { HistoryEntry { history: self.0.deps.history.clone(), connection: Some(session.metadata().clone()), @@ -613,7 +613,8 @@ fn same_statements(left: &[Statement], right: &[Statement]) -> bool { }) } -struct HistoryEntry { +/// Where a write the app ran on the user's behalf is recorded. +pub(crate) struct HistoryEntry { history: Option, connection: Option, dialect: Option>, @@ -622,7 +623,7 @@ struct HistoryEntry { /// A save changes the database as surely as a statement typed into the /// editor, so it goes in the same history. A history that cannot be /// written is worth a log line, never a lost save. -async fn record( +pub(super) async fn record( entry: HistoryEntry, statements: Vec, written: &Result, @@ -645,7 +646,7 @@ async fn record( /// Ask the server what became of a commit whose acknowledgement was /// lost, when the engine left a way to ask. -async fn resolve_commit( +pub(super) async fn resolve_commit( session: &Session, written: &Result, ) -> Option> { diff --git a/linux/docs/adding-drivers.md b/linux/docs/adding-drivers.md index cc49706c6c..ccfc7282e0 100644 --- a/linux/docs/adding-drivers.md +++ b/linux/docs/adding-drivers.md @@ -238,7 +238,9 @@ A call that times out or loses its connection may have met a dead route or only ## 5. Writes and the `WriteLedger` -A `WriteBatch` is a `WriteKind` (`RowChanges` or `SchemaChanges`), the `TableRef` it changes, the `GuardedStatement` steps and a `ForeignKeyMode`. A row change runs under the workspace statement timeout where the engine can stop one safely; a schema change never does. Both stay bounded by the call's deadline and cancel. +A `WriteBatch` is a `WriteKind` (`RowChanges`, `SchemaChanges` or `DatabaseChanges`), the `TableRef` it changes, the `GuardedStatement` steps and a `ForeignKeyMode`. A row change runs under the workspace statement timeout where the engine can stop one safely; a schema or database change never does. All stay bounded by the call's deadline and cancel. + +A `DatabaseChanges` batch creates or drops a database, and its `TableRef` names that database as the schema with an empty table name. The driver opens no transaction for it and runs the statements one at a time, because PostgreSQL and SQL Server refuse CREATE and DROP DATABASE inside one. Each step is a statement and a `RowGuard`: `None`, `AffectedExactly(n)`, or `ProbeExactly { probe, rows }`, which counts the rows with a `SELECT` before the statement runs. A mismatch means the row is not the one that was loaded, and the batch stops with `RowGuardFailed`. An engine that cannot report an affected count refuses `AffectedExactly` with `GuardUnsupported` before anything is sent. diff --git a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md index a5dd5ed84c..ad0606a940 100644 --- a/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md +++ b/linux/docs/decisions/0010-ddl-plans-and-table-definitions.md @@ -1,6 +1,6 @@ # 0010: DDL plans against table definitions -- **Status**: Accepted +- **Status**: Accepted; truncating and schema and database changes amended by core 0011 (`core/docs/decisions/0011-catalog-operations.md`) - **Date**: 2026-10-13 ## Context diff --git a/linux/docs/decisions/0016-driver-contract-v2.md b/linux/docs/decisions/0016-driver-contract-v2.md index a7c21e8aff..15db50113e 100644 --- a/linux/docs/decisions/0016-driver-contract-v2.md +++ b/linux/docs/decisions/0016-driver-contract-v2.md @@ -1,6 +1,6 @@ # 0016: Driver contract v2 -- **Status**: Accepted; the no-fork rule for tiberius superseded by core 0005 (`core/docs/decisions/0005-sql-server-transport.md`) +- **Status**: Accepted; the no-fork rule for tiberius superseded by core 0005 (`core/docs/decisions/0005-sql-server-transport.md`), a database change outside a transaction amended by core 0011 (`core/docs/decisions/0011-catalog-operations.md`) - **Date**: 2026-10-13 ## Context diff --git a/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md index c337953f2e..0459a0af67 100644 --- a/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md +++ b/linux/docs/decisions/0030-a-database-is-a-session-of-its-own.md @@ -1,6 +1,6 @@ # 0030: A query tab's database, and another database as a session of its own -- **Status**: Accepted +- **Status**: Accepted; dropping a sibling's database amended by 0033 - **Date**: 2026-10-30 ## Context diff --git a/linux/docs/decisions/0033-dropping-a-database-the-window-holds.md b/linux/docs/decisions/0033-dropping-a-database-the-window-holds.md new file mode 100644 index 0000000000..03b87c99b4 --- /dev/null +++ b/linux/docs/decisions/0033-dropping-a-database-the-window-holds.md @@ -0,0 +1,24 @@ +# 0033: Dropping a database the window holds open + +- **Status**: Accepted +- **Date**: 2026-11-05 + +## Context + +The sidebar now creates and drops databases and schemas (core [0011](../../../core/docs/decisions/0011-catalog-operations.md)). On PostgreSQL and SQL Server another database is a sibling session of its own ([0030](0030-a-database-is-a-session-of-its-own.md)), and both servers refuse to drop a database any session is connected to. A sibling's close was fire and forget, so a drop sent after it raced the sibling's own connections. On MySQL and ClickHouse the server drops the database a session is in and leaves the session pointing at nothing. + +## Decision + +The sidebar never offers to drop the database a session opened, its default schema, or one the engine keeps for itself. Drop Database on PostgreSQL and SQL Server is on the connection's row, picked from the server's databases, and on a sibling's row. Once the user confirms, the window runs the sibling's own disconnect prompts, closes it and waits for its connections to close, then the connection's own session sends the DROP. A prompt answered Cancel drops nothing. Other sessions on the database, the user's own query tabs there included, make the server refuse unless Close Other Connections is on. + +A session that reconnects into a database that is gone parks with "does not exist on the server" rather than retrying. A database created on PostgreSQL or SQL Server opens beside the connection, so it shows. + +## Rationale + +- **The prompts a disconnect asks still hold.** Taking a sibling down for a drop loses its tabs as a disconnect does, so it asks what a disconnect asks. +- **The server decides about other sessions.** The app closes only what it opened for that database; closing anyone else's is the user's choice, made with the switch. + +## Consequences + +- Dropping a database closes its sibling's row and tabs and forgets it, so it is not restored. +- Schemas with nothing in them get a sidebar row, and the schema list is read again after a create or drop, so a new schema shows at once. diff --git a/linux/docs/decisions/README.md b/linux/docs/decisions/README.md index 78e1f1cd69..e5a3809854 100644 --- a/linux/docs/decisions/README.md +++ b/linux/docs/decisions/README.md @@ -58,12 +58,12 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0007](0007-gsettings-preferences.md) | Preferences and window geometry in GSettings | Accepted; the schema id and path per profile amended 2026-10-15 | A schema with ranges and enums replaces preferences.json and window.json; keyed data stays in files. | | [0008](0008-flatpak-sandbox-permissions.md) | Flatpak sandbox permissions | Accepted; the Flathub submission deferred by 0018 | Network, display, home and removable drives for SQLite, the SSH agent and the Kerberos KCM socket; portals for the rest; two Flathub lint exceptions. | | [0009](0009-crypto-provider-and-sources.md) | One crypto provider, upstream sources and dependency bans | Accepted; sources and the PostgreSQL and ClickHouse TLS setup superseded by 0016, the ring ban amended by 0023, the rsa ban amended by core 0008 | aws-lc-rs only; deny.toml enforces sources, licenses, bans and advisory ignores. | -| [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | +| [0010](0010-ddl-plans-and-table-definitions.md) | DDL plans against table definitions | Accepted; truncating and schema and database changes amended by core 0011 | Each engine plans structure changes through its `DdlDialect` against the table's definition; the UI offers only what the plan accepts. | | [0012](0012-window-per-session.md) | One window per connection session, built from gtk-rs subclasses | Accepted; what a window holds superseded by 0028 | One `TableProApplication` per profile through D-Bus uniqueness; a window holds at most one session and returns to the picker on disconnect; the application, the window and its workspace are subclasses, not Relm4 components. | | [0013](0013-openssh-client-transport.md) | SSH through the system OpenSSH client | Accepted; the forced options amended by 0019, the TCP port amended by 0023, the russh consequence amended by core 0008 | ControlMaster per connection, Unix-socket forwards, prompts through an askpass helper. | | [0014](0014-lossless-values-and-dialects.md) | Lossless values and engine dialects | Accepted | A value reaches the user and goes back without loss; every statement is spelled by the dialect of the connection that runs it. | | [0015](0015-gnome-50-platform-floor.md) | GNOME 50 platform floor | Accepted; the runtime row amended by 0018 | GTK 4.22, libadwaita 1.9, GtkSourceView 5.18, GLib 2.88; AdwShortcutsDialog replaces the shortcuts window. | -| [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted; the no-fork rule for tiberius superseded by core 0005 | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | +| [0016](0016-driver-contract-v2.md) | Driver contract v2 | Accepted; the no-fork rule for tiberius superseded by core 0005, a database change outside a transaction amended by core 0011 | Owned calls with engine stops, phase-aware write outcomes, server-side row limits; PostgreSQL on tokio-postgres; no forks. | | [0017](0017-distribution-channels.md) | Distribution channels | Superseded by 0018 | Flathub, a .deb for Ubuntu 26.04 on GitHub Releases built with rustup, and an .rpm for Fedora 44 on COPR, all from `meson install` and one vendor tarball; no AppImage. | | [0018](0018-ppa-copr-and-flathub-later.md) | A PPA and COPR first, Flathub later | Accepted; the source tarball amended by core 0001 | A Launchpad PPA built with `rustc-1.97` and a COPR project built on each release are the primary channels; Flathub follows as a submission a maintainer writes; Rust 1.97 is the MSRV. | | [0019](0019-ssh-key-file-identities-only.md) | A chosen SSH key file limits the keys ssh offers | Superseded by 0020 | A key file comes with `IdentitiesOnly=yes`, so the agent's other keys cannot use up `MaxAuthTries` first. | @@ -77,9 +77,10 @@ A fact the repository keeps elsewhere, such as a workflow trigger, a branch, a v | [0027](0027-one-data-grid.md) | One data grid on GtkColumnView, with rows and a focused cell | Accepted | Every surface shows rows in one `DataGrid`: row selection plus a focused cell, stable row slots, a row number column, values as text and a row detail pane. | | [0028](0028-sessions-share-a-window.md) | A window holds several sessions in one tab view | Accepted | A `WorkspaceShell` per window shares one tab view among its sessions, each with its own workspace; the sidebar is a tree of the saved connections with each open session's schemas and tables. | | [0029](0029-images-decode-in-glycin.md) | Images from the database decode in glycin's sandbox | Accepted | Row Details decodes a value's picture with the glycin crate 3.x, in a sandboxed loader process, never with GTK's in-process loaders. | -| [0030](0030-a-database-is-a-session-of-its-own.md) | A query tab's database, and another database as a session of its own | Accepted | A query tab's own editor session moves to its database through the driver, or opens there where the engine binds it at connect; another database the window shows is a sibling session with its own workspace and sidebar row. | +| [0030](0030-a-database-is-a-session-of-its-own.md) | A query tab's database, and another database as a session of its own | Accepted; dropping a sibling's database amended by 0033 | A query tab's own editor session moves to its database through the driver, or opens there where the engine binds it at connect; another database the window shows is a sibling session with its own workspace and sidebar row. | | [0031](0031-duckdb-2.md) | DuckDB 2.0, writing files DuckDB 1.x reads | Accepted | duckdb 1.20000.1 compiles DuckDB 2.0.0, and every database opens with `storage_compatibility_version = 'v0.10.2'` so new and attached files stay readable by DuckDB 1.x. | | [0032](0032-connection-list-tree.md) | The connection list is the library's tree in a list view | Accepted | The picker and the window's tree show groups nested and in the library's order from one outline keyed by id, with Favourites and Recent sections in the picker, drag and drop, and a menu item for every move. | +| [0033](0033-dropping-a-database-the-window-holds.md) | Dropping a database the window holds open | Accepted | Drop is never offered for a session's own database; a sibling is closed after its prompts before the connection drops its database; a session whose database is gone parks. | 0011 is not used. It was reserved for the DDL decision, which was recorded as 0010, and docs cite ADRs by number, so the later ones keep theirs. diff --git a/linux/docs/drivers/postgresql.md b/linux/docs/drivers/postgresql.md index 38c9ca5a06..f94c64925b 100644 --- a/linux/docs/drivers/postgresql.md +++ b/linux/docs/drivers/postgresql.md @@ -82,6 +82,8 @@ A call that timed out or lost its connection is checked before the link is decla 3. The transaction id, `pg_current_xact_id()` (or `txid_current()` before PostgreSQL 13), read before COMMIT. 4. `COMMIT`. +A database change (`WriteKind::DatabaseChanges`) runs outside any transaction, because CREATE and DROP DATABASE refuse one. It is `StatementByStatement`: each statement goes out alone as a simple query, with no parameters, row guards or `SET LOCAL` bounds, and the call's deadline and cancel still stop it. + A COMMIT the server answers with an error (a deferred constraint, a serialization failure) rolled back. A COMMIT that gets no answer is `CommitOutcomeUnknown` with `CommitToken::PostgresXid`, and `resolve_commit` asks `pg_xact_status` (or `txid_status`): | Status | `CommitResolution` | diff --git a/linux/docs/error-handling.md b/linux/docs/error-handling.md index 1cb5674827..52c6e2a0c5 100644 --- a/linux/docs/error-handling.md +++ b/linux/docs/error-handling.md @@ -37,6 +37,7 @@ Errors are typed enums, built with `thiserror` wherever they cross a crate bound | `Busy(BusyReason)` | Something was busy: `NoFreeConnection` (the server has no room for another connection, or the pool had no free slot in time) or `FileLocked` (another connection held the database file's lock past the lock timeout). | | `EditorSessionBusy` | The tab's session is already running a statement. | | `Config(ConfigError)` | The settings cannot be used: a bad host, port, path, TLS option, auth mode or missing database name. | +| `DatabaseNotFound { database }` | The server has no database by the name the connection opens, as after a drop. A driver maps it at login only; a statement that names a missing database stays `Server`. | | `Decode { column, detail }` | The driver could not read a value. A bug on our side, not the server's. The detail is for the log. | | `Protocol(String)` | The server or the client library said something the driver did not expect. The text is for the log. | | `Unsupported { feature }` | The engine has no such feature. `UnsupportedFeature` names it, and the app writes the sentence. | @@ -54,7 +55,7 @@ Rules: | Category | Variants | What the app does | |---|---|---| -| `Configuration` | `Config`, `Unsupported`; SSH setup failures | Names the setting to change. The session stops retrying until the user changes something. | +| `Configuration` | `Config`, `Unsupported`, `DatabaseNotFound`; SSH setup failures | Names the setting to change. The session stops retrying until the user changes something. | | `Network` | `Transport` (resolution, refused, unreachable); an SSH forward that was rejected or could not open, or an SSH master that exited | Names the host and port. A session retries on a backoff. | | `Tls` | `Tls` | One sentence per `TlsFailure`, with a remedy button where one exists. The session stops retrying. | | `Authentication` | `Auth`, `LoginRefused`, `IntegratedAuth`; SSH auth and host-key failures, and a cancelled SSH prompt | Says the login or the host key was refused, with the server's reason. Never echoes what was typed. The session stops retrying. | diff --git a/linux/docs/testing.md b/linux/docs/testing.md index c605a06a2d..bd12a53fd5 100644 --- a/linux/docs/testing.md +++ b/linux/docs/testing.md @@ -173,9 +173,12 @@ Every case but the last plans through `connection.dialect().ddl()` and runs the - `create_table_script_recreates_the_table` drops a table and runs its `create_table_script`, which has to bring back the same column definitions and index keys. - `structure_changes_keep_rows_and_indexes` fills an indexed table, then changes a default and a nullability. The rows and the index have to come through, which on SQLite means through the table rebuild. - `index_statement_recreates_the_index` drops an index and runs the CREATE INDEX statement it was read with, which has to make the same index again. +- `truncate_empties_the_table_and_keeps_it` fills a table and truncates it with no options: no rows are left and the columns stay. +- `created_schema_is_listed_until_dropped` and `created_database_is_listed_until_dropped` create a schema or database, which `list_schemas` or `list_databases` has to list, then drop it, after which it is gone. A keyspace gets SimpleStrategy with a factor of 1 and, on ScyllaDB, tablets off. An engine that needs a first collection (MongoDB) is left to its own tests. +- Each of these three runs only what `ddl::catalog_capabilities` offers. What it leaves out has to be refused by its plan. - `builtin_snippets_run_as_written` takes the SQL editor's built-in snippets for the connection's grammar, fills their table, index, column and value stops, with the row's `id` too on CQL, which finds a row only by its key, and keeps the rest as written (`tablepro_test_support::expand_snippet`), and runs each one through an editor session as the editor splits it: one statement each, all completing. -The PostgreSQL, MySQL, SQL Server, ClickHouse, SQLite and DuckDB targets run it, and the MySQL target runs it on MariaDB as well. SQLite and DuckDB pass `docker: false`, so their cases run in the unit suite. +The PostgreSQL, MySQL, SQL Server, ClickHouse, Cassandra, SQLite and DuckDB targets run it, the MySQL target on MariaDB as well and the Cassandra target on ScyllaDB. SQLite and DuckDB pass `docker: false`, so their cases run in the unit suite. ### Large-table conformance diff --git a/linux/po/POTFILES.in b/linux/po/POTFILES.in index 3628fbd60a..3185d8ac12 100644 --- a/linux/po/POTFILES.in +++ b/linux/po/POTFILES.in @@ -418,6 +418,7 @@ crates/app/src/ui/filter_strip/rule_operators.rs crates/app/src/ui/filter_strip/rule_row.rs crates/app/src/ui/filter_strip/rule_value_editor.rs crates/app/src/ui/filter_strip/tests.rs +crates/app/src/ui/form_dialog.rs crates/app/src/ui/format/mod.rs crates/app/src/ui/format/thousands.rs crates/app/src/ui/handler_guard.rs @@ -528,7 +529,6 @@ crates/app/src/ui/structure_tab/foreign_key_draft.rs crates/app/src/ui/structure_tab/foreign_key_handlers.rs crates/app/src/ui/structure_tab/foreign_key_object.rs crates/app/src/ui/structure_tab/foreign_keys_input.rs -crates/app/src/ui/structure_tab/form_dialog.rs crates/app/src/ui/structure_tab/imp.rs crates/app/src/ui/structure_tab/index_dialog.rs crates/app/src/ui/structure_tab/index_draft.rs @@ -570,6 +570,8 @@ crates/app/src/ui/tags/tag_dialog.rs crates/app/src/ui/welcome_view.rs crates/app/src/ui/window_geometry.rs crates/app/src/ui/workspace/assistant.rs +crates/app/src/ui/workspace/catalog_dialogs.rs +crates/app/src/ui/workspace/catalog_ops.rs crates/app/src/ui/workspace/close_flow.rs crates/app/src/ui/workspace/definitions.rs crates/app/src/ui/workspace/diagram.rs @@ -614,6 +616,7 @@ crates/app/src/ui/workspace/tab_spec.rs crates/app/src/ui/workspace/tab_title.rs crates/app/src/ui/workspace/table_naming.rs crates/app/src/ui/workspace/tests/assistant.rs +crates/app/src/ui/workspace/tests/catalog_ops.rs crates/app/src/ui/workspace/tests/close_tab.rs crates/app/src/ui/workspace/tests/definitions.rs crates/app/src/ui/workspace/tests/diagram.rs diff --git a/linux/po/vi.po b/linux/po/vi.po index d2f3410e83..0ac329b168 100644 --- a/linux/po/vi.po +++ b/linux/po/vi.po @@ -4786,7 +4786,7 @@ msgid "New Query" msgstr "Truy vấn mới" #: crates/app/src/ui/workspace/sidebar/tree_menu.rs -msgid "_New Query" +msgid "New _Query" msgstr "_Truy vấn mới" #: crates/app/src/ui/workspace/shell.rs diff --git a/windows/src/TablePro.App/Strings/en-US/Resources.resw b/windows/src/TablePro.App/Strings/en-US/Resources.resw index 0371562a75..5709bd0664 100644 --- a/windows/src/TablePro.App/Strings/en-US/Resources.resw +++ b/windows/src/TablePro.App/Strings/en-US/Resources.resw @@ -960,6 +960,9 @@ This file isn't a database TablePro can open. + + This database doesn't exist on the server: + The server refused the user name or password. From 274125699f5669ff82d723c0c1b13ff79fe8b9a3 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 12:21:58 +0700 Subject: [PATCH 2/8] fix(linux): reload tabs a truncate empties and offer New Schema on the connection --- core/crates/core/src/ddl/catalog_plans.rs | 15 +++++--- core/crates/core/src/ddl/catalog_tests.rs | 17 ++++++--- core/crates/core/src/ddl/ddl_dialect.rs | 6 ++- core/crates/core/src/ddl/mod.rs | 6 +-- core/crates/drivers/clickhouse/src/ddl.rs | 7 +++- .../clickhouse/tests/integration/ddl.rs | 10 ++++- core/crates/drivers/mysql/src/ddl/mod.rs | 10 ++--- .../drivers/mysql/tests/integration/ddl.rs | 5 ++- .../mysql/tests/integration/metadata.rs | 6 ++- linux/crates/app/src/ui/session_window/mod.rs | 1 + .../ui/session_window/session_window_input.rs | 2 + .../src/ui/session_window/window_actions.rs | 3 +- .../ui/structure_tab/foreign_key_dialog.rs | 4 +- .../app/src/ui/structure_tab/index_dialog.rs | 4 +- .../app/src/ui/workspace/catalog_dialogs.rs | 4 +- .../app/src/ui/workspace/catalog_ops.rs | 38 +++++++++++++++++-- .../src/ui/workspace/sidebar/sidebar_index.rs | 9 +++-- .../app/src/ui/workspace/sidebar/tree_menu.rs | 25 ++++++++++++ linux/crates/app/src/workspace/mod.rs | 3 +- 19 files changed, 134 insertions(+), 41 deletions(-) diff --git a/core/crates/core/src/ddl/catalog_plans.rs b/core/crates/core/src/ddl/catalog_plans.rs index 6457979c14..e0e6627b5f 100644 --- a/core/crates/core/src/ddl/catalog_plans.rs +++ b/core/crates/core/src/ddl/catalog_plans.rs @@ -26,10 +26,7 @@ pub fn catalog_capabilities(dialect: &dyn SqlDialect) -> CatalogCapabilities { ..CatalogCapabilities::default() }; } - dialect - .ddl() - .map(|ddl| ddl.catalog_capabilities()) - .unwrap_or_default() + dialect.ddl().map(|ddl| ddl.catalog_capabilities()).unwrap_or_default() } /// Whether the engine keeps the database `name` for itself. @@ -199,7 +196,10 @@ pub fn check_database_draft(capabilities: &CatalogCapabilities, draft: &Database let refused = [ (!capabilities.create_database, DdlOperation::CreateDatabase), (draft.owner.is_some() && !fields.owner, DdlOperation::CatalogOwner), - (draft.cluster.is_some() && !capabilities.on_cluster, DdlOperation::OnCluster), + ( + draft.cluster.is_some() && !capabilities.on_cluster, + DdlOperation::OnCluster, + ), ]; refuse_first(&refused)?; let unplaced = [ @@ -215,7 +215,10 @@ pub fn check_database_draft(capabilities: &CatalogCapabilities, draft: &Database draft.replication.is_some() && !fields.replication, CatalogOption::ReplicationFactor, ), - (draft.tablets.is_some() && !fields.tablets, CatalogOption::ReplicationFactor), + ( + draft.tablets.is_some() && !fields.tablets, + CatalogOption::ReplicationFactor, + ), ( draft.first_collection.is_some() && !fields.first_collection, CatalogOption::Collection, diff --git a/core/crates/core/src/ddl/catalog_tests.rs b/core/crates/core/src/ddl/catalog_tests.rs index 5b4c35ae9d..0b6390ef41 100644 --- a/core/crates/core/src/ddl/catalog_tests.rs +++ b/core/crates/core/src/ddl/catalog_tests.rs @@ -73,7 +73,9 @@ fn a_default_truncate_writes_the_options_the_engine_takes() { cluster: None, }; - let plan = ddl(every_operation()).plan_truncate_table(&table, &both).expect("a plan"); + let plan = ddl(every_operation()) + .plan_truncate_table(&table, &both) + .expect("a plan"); assert_eq!( plan.sql(), @@ -176,7 +178,10 @@ fn a_database_plan_runs_outside_a_transaction_and_refuses_options_it_has_no_plac #[test] fn a_bare_word_is_letters_digits_and_underscores() { - assert_eq!(bare_word(CatalogOption::Collation, "utf8mb4_0900_ai_ci"), Ok("utf8mb4_0900_ai_ci")); + assert_eq!( + bare_word(CatalogOption::Collation, "utf8mb4_0900_ai_ci"), + Ok("utf8mb4_0900_ai_ci") + ); for refused in ["", "latin1'; DROP", "a b", "a-b"] { assert!(bare_word(CatalogOption::Collation, refused).is_err(), "{refused:?}"); } @@ -197,12 +202,12 @@ fn mongodb_empties_a_collection_and_makes_a_database_through_its_shell() { emptied.sql(), [r#"db.getSiblingDB("shop").getCollection("orders").deleteMany({})"#] ); + assert_eq!(created.sql(), [r#"db.getSiblingDB("shop").createCollection("orders")"#]); + assert_eq!(dropped.sql(), [r#"db.getSiblingDB("shop").dropDatabase()"#]); assert_eq!( - created.sql(), - [r#"db.getSiblingDB("shop").createCollection("orders")"#] + (created.kind, dropped.kind), + (WriteKind::DatabaseChanges, WriteKind::DatabaseChanges) ); - assert_eq!(dropped.sql(), [r#"db.getSiblingDB("shop").dropDatabase()"#]); - assert_eq!((created.kind, dropped.kind), (WriteKind::DatabaseChanges, WriteKind::DatabaseChanges)); assert_eq!(emptied.kind, WriteKind::SchemaChanges); assert!(protects_database(&shell, "admin") && !protects_database(&shell, "shop")); let capabilities = catalog_capabilities(&shell); diff --git a/core/crates/core/src/ddl/ddl_dialect.rs b/core/crates/core/src/ddl/ddl_dialect.rs index 97bbd2542e..e0b91019cc 100644 --- a/core/crates/core/src/ddl/ddl_dialect.rs +++ b/core/crates/core/src/ddl/ddl_dialect.rs @@ -98,7 +98,11 @@ pub trait DdlDialect: Send + Sync + std::fmt::Debug { /// Empty the table and keep its definition. fn plan_truncate_table(&self, table: &TableRef, options: &TruncateTableOptions) -> Result { refuse_options(&self.catalog_capabilities(), options)?; - let restart = if options.restart_identity { " RESTART IDENTITY" } else { "" }; + let restart = if options.restart_identity { + " RESTART IDENTITY" + } else { + "" + }; let cascade = if options.cascade { " CASCADE" } else { "" }; Ok(DdlPlan::from_sql([format!( "TRUNCATE TABLE {}{restart}{cascade}", diff --git a/core/crates/core/src/ddl/mod.rs b/core/crates/core/src/ddl/mod.rs index c56d94baf6..0b7a863533 100644 --- a/core/crates/core/src/ddl/mod.rs +++ b/core/crates/core/src/ddl/mod.rs @@ -63,9 +63,9 @@ mod tests; pub use build_ddl_error::BuildDdlError; pub use catalog_capabilities::{CatalogCapabilities, DatabaseFields, DatabaseNoun, NameLimit}; pub use catalog_plans::{ - bare_word, catalog_capabilities, catalog_name, check_database_draft, named, plan_create_database, plan_create_schema, - plan_drop_database, plan_drop_schema, plan_truncate_table, protects_database, protects_schema, refuse_drop_options, - refuse_options, + bare_word, catalog_capabilities, catalog_name, check_database_draft, named, plan_create_database, + plan_create_schema, plan_drop_database, plan_drop_schema, plan_truncate_table, protects_database, protects_schema, + refuse_drop_options, refuse_options, }; pub use change_review::ChangeReview; pub use changed_attributes::ChangedAttributes; diff --git a/core/crates/drivers/clickhouse/src/ddl.rs b/core/crates/drivers/clickhouse/src/ddl.rs index 618d081125..ef4567f8b6 100644 --- a/core/crates/drivers/clickhouse/src/ddl.rs +++ b/core/crates/drivers/clickhouse/src/ddl.rs @@ -665,7 +665,12 @@ mod tests { dropped(Some("main")).sql(), ["DROP DATABASE `sh\\`op` ON CLUSTER `main`"] ); - for plan in [created(None), created(Some("main")), dropped(None), dropped(Some("main"))] { + for plan in [ + created(None), + created(Some("main")), + dropped(None), + dropped(Some("main")), + ] { assert_eq!(plan.kind, WriteKind::DatabaseChanges); } } diff --git a/core/crates/drivers/clickhouse/tests/integration/ddl.rs b/core/crates/drivers/clickhouse/tests/integration/ddl.rs index 882d4058ec..a88bd3d606 100644 --- a/core/crates/drivers/clickhouse/tests/integration/ddl.rs +++ b/core/crates/drivers/clickhouse/tests/integration/ddl.rs @@ -96,7 +96,10 @@ async fn truncate_empties_the_table_and_keeps_it() { .expect("the truncate runs"); let kept = format!("SELECT count() FROM system.tables WHERE database = '{DATABASE}' AND name = '{name}'"); - assert_eq!(count(connection.as_ref(), &format!("SELECT count() FROM {qualified}")).await, 0); + assert_eq!( + count(connection.as_ref(), &format!("SELECT count() FROM {qualified}")).await, + 0 + ); assert_eq!(count(connection.as_ref(), &kept).await, 1); connection.close().await.expect("a close"); } @@ -125,7 +128,10 @@ async fn a_database_is_created_and_dropped_through_a_save() { .await .expect("the database is dropped"); - let made = schemas.iter().find(|schema| schema.name == name).expect("the new database"); + let made = schemas + .iter() + .find(|schema| schema.name == name) + .expect("the new database"); assert!(!made.is_default && !made.is_system, "{made:?}"); assert_eq!(count(connection.as_ref(), &listed).await, 0); connection.close().await.expect("a close"); diff --git a/core/crates/drivers/mysql/src/ddl/mod.rs b/core/crates/drivers/mysql/src/ddl/mod.rs index de85696d77..90bec57438 100644 --- a/core/crates/drivers/mysql/src/ddl/mod.rs +++ b/core/crates/drivers/mysql/src/ddl/mod.rs @@ -467,14 +467,14 @@ mod tests { use tablepro_core::column::{ColumnDefault, ColumnInfo, SqlExpression}; use tablepro_core::ddl::{ CollationName, ColumnAttributes, ColumnVisibility, DdlOperation, DraftColumn, DraftType, DropDatabaseOptions, - DropSchemaOptions, DropTableOptions, GeneratedColumn, GeneratedStorage, InboundForeignKey, PrimaryKeyConstraint, - SchemaDraft, TableOptions, TruncateTableOptions, UnmodeledAttribute, catalog_capabilities, diff_table, - plan_create_database, plan_create_schema, plan_drop_database, plan_drop_schema, plan_truncate_table, - protects_database, + DropSchemaOptions, DropTableOptions, GeneratedColumn, GeneratedStorage, InboundForeignKey, + PrimaryKeyConstraint, SchemaDraft, TableOptions, TruncateTableOptions, UnmodeledAttribute, + catalog_capabilities, diff_table, plan_create_database, plan_create_schema, plan_drop_database, + plan_drop_schema, plan_truncate_table, protects_database, }; - use tablepro_core::{ForeignKeyInfo, ServerInfo, WriteKind}; use tablepro_core::meta::{IndexInfo, IndexKey}; use tablepro_core::value::Value; + use tablepro_core::{ForeignKeyInfo, ServerInfo, WriteKind}; use super::*; use crate::flavour::MySqlFlavour; diff --git a/core/crates/drivers/mysql/tests/integration/ddl.rs b/core/crates/drivers/mysql/tests/integration/ddl.rs index f2c59dc98a..dd44a7da09 100644 --- a/core/crates/drivers/mysql/tests/integration/ddl.rs +++ b/core/crates/drivers/mysql/tests/integration/ddl.rs @@ -775,7 +775,10 @@ async fn truncate_empties_the_table_and_restarts_its_counter() { connection.as_ref(), WriteKind::RowChanges, &table("emptied"), - &["INSERT INTO emptied (label) VALUES ('a'), ('b'), ('c')", "INSERT INTO parent (id) VALUES (1)"], + &[ + "INSERT INTO emptied (label) VALUES ('a'), ('b'), ('c')", + "INSERT INTO parent (id) VALUES (1)", + ], ) .await .expect("rows to empty"); diff --git a/core/crates/drivers/mysql/tests/integration/metadata.rs b/core/crates/drivers/mysql/tests/integration/metadata.rs index bf972568d7..6684bef64b 100644 --- a/core/crates/drivers/mysql/tests/integration/metadata.rs +++ b/core/crates/drivers/mysql/tests/integration/metadata.rs @@ -396,7 +396,11 @@ async fn objects_and_estimates_are_listed() { .map(|schema| schema.name.as_str()) .collect(); assert_eq!(defaults, ["tablepro"], "{flavour:?}: {schemas:?}"); - assert!(schemas.iter().any(|schema| schema.name == "information_schema" && schema.is_system)); + assert!( + schemas + .iter() + .any(|schema| schema.name == "information_schema" && schema.is_system) + ); assert!(estimate.rows.value().is_some(), "{flavour:?}: {estimate:?}"); connection.close().await.expect("a close"); } diff --git a/linux/crates/app/src/ui/session_window/mod.rs b/linux/crates/app/src/ui/session_window/mod.rs index 506fbbd908..0ab092acc2 100644 --- a/linux/crates/app/src/ui/session_window/mod.rs +++ b/linux/crates/app/src/ui/session_window/mod.rs @@ -471,6 +471,7 @@ impl SessionWindow { SessionWindowInput::DisconnectSession(key) => self.on_disconnect(key), SessionWindowInput::ChooseDatabaseIn(key) => self.choose_database_in(key), SessionWindowInput::NewDatabaseIn(key) => self.deliver_to(&key, WorkspaceInput::NewDatabasePrompt), + SessionWindowInput::NewSchemaIn(key) => self.deliver_to(&key, WorkspaceInput::NewSchemaPrompt), SessionWindowInput::DropDatabaseIn(key) => self.drop_database_in(key), SessionWindowInput::ReleaseDatabase { target, options } => self.release_database(target, options), SessionWindowInput::DatabaseDropFinished(target) => self.lift_reservation(&target), diff --git a/linux/crates/app/src/ui/session_window/session_window_input.rs b/linux/crates/app/src/ui/session_window/session_window_input.rs index c0325bbbf1..4e7e762819 100644 --- a/linux/crates/app/src/ui/session_window/session_window_input.rs +++ b/linux/crates/app/src/ui/session_window/session_window_input.rs @@ -79,6 +79,8 @@ pub(crate) enum SessionWindowInput { ChooseDatabaseIn(SessionKey), /// New Database… on a connection's row. NewDatabaseIn(SessionKey), + /// New Schema… on a connection's row. + NewSchemaIn(SessionKey), /// Drop Database… on a connection's row, which asks which one first, /// or on the row of a database open beside its connection. DropDatabaseIn(SessionKey), diff --git a/linux/crates/app/src/ui/session_window/window_actions.rs b/linux/crates/app/src/ui/session_window/window_actions.rs index f4168308b0..f5a3c22ef3 100644 --- a/linux/crates/app/src/ui/session_window/window_actions.rs +++ b/linux/crates/app/src/ui/session_window/window_actions.rs @@ -46,7 +46,7 @@ const ACTIONS: [(&str, ActionInput); 12] = [ /// The actions a connection tree row or a toast fires for its own /// session, which take the session as their target (`session_target`). -const SESSION_ACTIONS: [(&str, SessionInput); 10] = [ +const SESSION_ACTIONS: [(&str, SessionInput); 11] = [ ("show-session", SessionWindowInput::ShowSession), ("disconnect-session", SessionWindowInput::DisconnectSession), ("stop-connecting", SessionWindowInput::StopConnecting), @@ -54,6 +54,7 @@ const SESSION_ACTIONS: [(&str, SessionInput); 10] = [ ("refresh-in", SessionWindowInput::RefreshIn), ("open-database-in", SessionWindowInput::ChooseDatabaseIn), ("new-database-in", SessionWindowInput::NewDatabaseIn), + ("new-schema-in", SessionWindowInput::NewSchemaIn), ("drop-database-in", SessionWindowInput::DropDatabaseIn), ("show-connect-failure", SessionWindowInput::ShowConnectFailure), ("forget-connect-failure", SessionWindowInput::ForgetConnectFailure), diff --git a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs index 7b886ea717..e0a640360c 100644 --- a/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/foreign_key_dialog.rs @@ -18,9 +18,9 @@ use tablepro_session::runtime::{TaskFailure, Tasks}; use tablepro_session::{OperationError, Session}; use super::foreign_key_draft::{ForeignKeyDraft, ForeignKeyDraftError}; -use crate::ui::form_dialog::{FormDialog, give_state, mark_invalid}; use super::referential_action_label::{action_at, action_labels}; use super::table_choice_object::{TableChoiceObject, table_choices}; +use crate::ui::form_dialog::{FormDialog, give_state, mark_invalid}; pub(super) struct ForeignKeyDialogInit { /// The draft's columns, in table order. @@ -512,9 +512,9 @@ mod tests { use tablepro_core::{ColumnInfo, DriverError}; use tablepro_test_support::{ConnectionScript, FakeReply}; - use crate::ui::form_dialog::tests::{WAIT, window}; use super::*; use crate::test_support::{FakeSession, OwnedWindow, drain_main_context, wait_until}; + use crate::ui::form_dialog::tests::{WAIT, window}; struct Harness { dialog: Option, diff --git a/linux/crates/app/src/ui/structure_tab/index_dialog.rs b/linux/crates/app/src/ui/structure_tab/index_dialog.rs index 58d2a84623..5edcfe239c 100644 --- a/linux/crates/app/src/ui/structure_tab/index_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/index_dialog.rs @@ -11,8 +11,8 @@ use libadwaita as adw; use libadwaita::prelude::*; use tablepro_core::IndexInfo; -use crate::ui::form_dialog::{FormDialog, give_state, mark_invalid}; use super::index_draft::{DraftKey, IndexDraft, IndexDraftError}; +use crate::ui::form_dialog::{FormDialog, give_state, mark_invalid}; use crate::ui::menu_label; /// The dialog, which owns the form's state (`give_state`). @@ -304,9 +304,9 @@ fn edit_later(inner: &Weak, edit: impl FnOnce(&mut IndexDraft)) { mod tests { use std::time::Duration; - use crate::ui::form_dialog::tests::{WAIT, press_enter, window}; use super::*; use crate::test_support::{drain_main_context, wait_until}; + use crate::ui::form_dialog::tests::{WAIT, press_enter, window}; fn dialog(added: &Rc>>) -> IndexDialog { dialog_sorting(added, true) diff --git a/linux/crates/app/src/ui/workspace/catalog_dialogs.rs b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs index 18627aa709..f56dd42b23 100644 --- a/linux/crates/app/src/ui/workspace/catalog_dialogs.rs +++ b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs @@ -173,13 +173,13 @@ impl WorkspaceModel { } pub(super) fn on_create_schema(&mut self, draft: SchemaDraft) { - let (target, created) = (TableRef::new(Some(draft.name.clone()), ""), draft.name.clone()); + let target = TableRef::new(Some(draft.name.clone()), ""); self.start_catalog_change( WriteKind::SchemaChanges, target, move |dialect, _| ddl::plan_create_schema(dialect, &draft), move |result| match result { - Ok(()) => WorkspaceInput::Catalog(CatalogInput::Landed(CatalogLanded::SchemaCreated(created))), + Ok(()) => WorkspaceInput::Catalog(CatalogInput::Landed(CatalogLanded::SchemaCreated)), Err(error) => WorkspaceInput::ShowAlert { title: gettext("Could Not Create Schema"), body: error_text::operation_error_message(&error), diff --git a/linux/crates/app/src/ui/workspace/catalog_ops.rs b/linux/crates/app/src/ui/workspace/catalog_ops.rs index 38c7deae8b..672001a283 100644 --- a/linux/crates/app/src/ui/workspace/catalog_ops.rs +++ b/linux/crates/app/src/ui/workspace/catalog_ops.rs @@ -120,7 +120,7 @@ pub(crate) struct TruncateCheck { #[derive(Debug)] pub(crate) enum CatalogLanded { Truncated { table: TableRef, cascaded: Vec }, - SchemaCreated(String), + SchemaCreated, SchemaDropped(String), DatabaseCreated(String), DatabaseDropped(String), @@ -358,6 +358,33 @@ impl WorkspaceModel { .collect() } + /// The tables of the table tabs that hold no unsaved edits, other + /// than those in `known`. + fn clean_table_tabs_besides(&self, known: &[TableRef]) -> Vec { + let tabs: Vec<(Uuid, TableRef)> = self + .workspace_tabs + .borrow() + .values() + .filter_map(|tab| match tab { + WorkspaceTab::Table(slot) => Some((slot.id, TableRef::new(slot.schema.clone(), slot.table.clone()))), + _ => None, + }) + .collect(); + let mut clean: Vec = Vec::new(); + for (tab, table) in tabs { + let unsaved = self.change_trackers.get(tab).is_some_and(|tracker| { + matches!( + tracker.pending_state(), + PendingState::Unsaved | PendingState::OutcomeUnknown(_) + ) + }); + if !unsaved && !known.contains(&table) && !clean.contains(&table) { + clean.push(table); + } + } + clean + } + /// Sidebar "Drop Schema…". What the schema still holds is counted /// first: an engine that cannot drop it with everything in it is /// refused before the question. @@ -621,7 +648,7 @@ impl WorkspaceModel { fn on_catalog_landed(&mut self, landed: CatalogLanded) { match landed { CatalogLanded::Truncated { table, cascaded } => self.on_truncated(table, cascaded), - CatalogLanded::SchemaCreated(_) => self.catalog_shape_changed(), + CatalogLanded::SchemaCreated => self.catalog_shape_changed(), CatalogLanded::SchemaDropped(schema) => { self.close_tabs_in(&schema); self.catalog_shape_changed(); @@ -646,11 +673,16 @@ impl WorkspaceModel { /// go through the input, after the drop of the edits. fn on_truncated(&mut self, table: TableRef, cascaded: Vec) { self.show_toast(&gettext_f("“{table}” is empty", &[("table", &display_name(&table))])); - let emptied: Vec = std::iter::once(table).chain(cascaded).collect(); + let mut emptied: Vec = std::iter::once(table).chain(cascaded).collect(); let discarded: Vec = emptied.iter().flat_map(|table| self.unsaved_tabs_on(table)).collect(); if !discarded.is_empty() { self.workspace_handle.discard(&discarded, false); } + // A PostgreSQL truncate also empties the table's partitions and + // inheritance children, which no foreign key names, so every other + // tab with nothing unsaved reads its rows again. One with unsaved + // edits keeps them, and its save's row guards refuse rows now gone. + emptied.extend(self.clean_table_tabs_besides(&emptied)); self.input .deliver(WorkspaceInput::Catalog(CatalogInput::ReloadTables(emptied))); } diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs index 2361a4e839..5ea65bd66f 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs @@ -702,9 +702,12 @@ mod tests { ); } - /// Each section's title, the schema it names, whether it offers a - /// drop, and its run's place and span, in list order. - fn sections(index: &SidebarIndex) -> Vec<(String, Option, bool, Option<(usize, u32, u32)>)> { + /// A section's title, the schema it names, whether it offers a drop, + /// and its run's place and span. + type Listed = (String, Option, bool, Option<(usize, u32, u32)>); + + /// Each section as `Listed`, in list order. + fn sections(index: &SidebarIndex) -> Vec { index .listed_sections() .map(|(section, run)| { diff --git a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs index 0183a4b3b2..b43e08692f 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs @@ -11,6 +11,7 @@ const NEW_QUERY_IN: &str = "win.new-query-in"; const REFRESH_IN: &str = "win.refresh-in"; const OPEN_DATABASE_IN: &str = "win.open-database-in"; const NEW_DATABASE_IN: &str = "win.new-database-in"; +const NEW_SCHEMA_IN: &str = "win.new-schema-in"; const DROP_DATABASE_IN: &str = "win.drop-database-in"; pub(super) const DISCONNECT_SESSION: &str = "win.disconnect-session"; const STOP_CONNECTING: &str = "win.stop-connecting"; @@ -57,6 +58,10 @@ pub(super) fn session_section(key: &SessionKey, state: InWindow, affordances: Si }; add(label, NEW_DATABASE_IN); } + // Here too, for a database whose every schema was dropped and so has no schema row. + if affordances.offers_new_schema() { + add(crate::i18n::gettext("New Sc_hema…"), NEW_SCHEMA_IN); + } add(crate::i18n::gettext("_Disconnect"), DISCONNECT_SESSION); if drops_database(key, affordances) { let danger = gio::Menu::new(); @@ -124,6 +129,7 @@ mod tests { catalog: CatalogCapabilities { create_database: true, drop_database: true, + create_schema: true, ..CatalogCapabilities::default() }, ..SidebarAffordances::default() @@ -162,6 +168,25 @@ mod tests { items(menu.upcast_ref()).into_iter().map(|(action, _)| action).collect() } + #[test] + fn a_connection_that_makes_schemas_offers_one_without_a_schema_row() { + let key = SessionKey::Saved(Uuid::new_v4()); + let schemas = SidebarAffordances { + catalog: CatalogCapabilities { + create_schema: true, + ..CatalogCapabilities::default() + }, + ..SidebarAffordances::default() + }; + let read_only = SidebarAffordances { + read_only: true, + ..schemas + }; + + assert!(actions(&session_section(&key, InWindow::Open, schemas)).contains(&NEW_SCHEMA_IN.to_owned())); + assert!(!actions(&session_section(&key, InWindow::Open, read_only)).contains(&NEW_SCHEMA_IN.to_owned())); + } + #[test] fn every_state_keeps_the_access_keys_of_the_connections_menu_apart() { let id = Uuid::new_v4(); diff --git a/linux/crates/app/src/workspace/mod.rs b/linux/crates/app/src/workspace/mod.rs index 93e691b386..a2b78a1f07 100644 --- a/linux/crates/app/src/workspace/mod.rs +++ b/linux/crates/app/src/workspace/mod.rs @@ -103,7 +103,7 @@ pub use save_settled::SaveSettled; pub use save_snapshot::SaveSnapshot; pub use save_start::SaveStart; pub use save_ticket::SaveTicket; -pub(crate) use schema_write::{CatalogChange, database_target, run_catalog_change, run_schema_plan}; +pub(crate) use schema_write::{CatalogChange, database_target, run_catalog_change}; pub use session_key_record::SessionKeyRecord; pub use sort_record::SortRecord; pub use tab_directory::TabDirectory; @@ -118,7 +118,6 @@ pub use work_kind::WorkKind; pub use work_outcome::WorkOutcome; pub use workspace_event::WorkspaceEvent; pub use workspace_file_name::WorkspaceFileName; -pub(crate) use workspace_handle::HistoryEntry; pub use workspace_handle::{WorkspaceDeps, WorkspaceHandle}; pub use workspace_load::WorkspaceLoad; pub use workspace_load_error::WorkspaceLoadError; From 2c43718deb47b8143df842243aebea33c286a428 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 12:32:02 +0700 Subject: [PATCH 3/8] fix(mongodb): report an unconfirmed catalog command as an open outcome --- core/crates/drivers/mongodb/src/execute.rs | 73 ++++++++++++++++--- .../crates/drivers/mongodb/src/write_batch.rs | 4 +- .../app/src/ui/workspace/catalog_dialogs.rs | 2 + .../app/src/ui/workspace/catalog_ops.rs | 8 +- .../app/src/ui/workspace/sidebar/tree_menu.rs | 1 + 5 files changed, 76 insertions(+), 12 deletions(-) diff --git a/core/crates/drivers/mongodb/src/execute.rs b/core/crates/drivers/mongodb/src/execute.rs index f079360d7b..aacf920643 100644 --- a/core/crates/drivers/mongodb/src/execute.rs +++ b/core/crates/drivers/mongodb/src/execute.rs @@ -48,6 +48,29 @@ pub(crate) async fn start( client: &Client, session: &mut ClientSession, plan: Plan, +) -> Result { + send(endpoint, client, session, plan, false).await +} + +/// As `start`, for a save. A command such as `dropDatabase` whose write +/// concern the server could not meet has still applied on the primary, +/// so it comes back as a write whose fate is open rather than an error, +/// which a save would report as rolled back. +pub(crate) async fn start_write( + endpoint: &MongoEndpoint, + client: &Client, + session: &mut ClientSession, + plan: Plan, +) -> Result { + send(endpoint, client, session, plan, true).await +} + +async fn send( + endpoint: &MongoEndpoint, + client: &Client, + session: &mut ClientSession, + plan: Plan, + saving: bool, ) -> Result { match plan { Plan::Use(database) => Ok(Source::Used(database)), @@ -99,19 +122,29 @@ pub(crate) async fn start( return Err(error); } let unconfirmed = reply_write_concern_error(&answer); - match (shaped(answer, reply), unconfirmed) { - (Source::Write { affected, summary, .. }, unconfirmed) => Ok(Source::Write { - affected, - summary, - unconfirmed, - }), - (_, Some(error)) => Err(error), - (source, None) => Ok(source), - } + outcome(shaped(answer, reply), unconfirmed, saving) } } } +/// A reply with the write concern it could not meet, if any. +fn outcome(source: Source, unconfirmed: Option, saving: bool) -> Result { + match (source, unconfirmed) { + (Source::Write { affected, summary, .. }, unconfirmed) => Ok(Source::Write { + affected, + summary, + unconfirmed, + }), + (_, Some(error)) if saving => Ok(Source::Write { + affected: 0, + summary: Document::new(), + unconfirmed: Some(error), + }), + (_, Some(error)) => Err(error), + (source, None) => Ok(source), + } +} + fn count_of(value: Option<&Bson>) -> u64 { match value { Some(Bson::Int32(number)) => u64::try_from(*number).unwrap_or(0), @@ -244,4 +277,26 @@ mod tests { }; assert_eq!(count, 42); } + + #[test] + fn a_saved_command_whose_write_concern_failed_is_applied_with_its_fate_open() { + let whole = || shaped(doc! { "ok": 1.0_f64 }, Reply::Whole); + let concern = || DriverError::server("waiting for replication timed out"); + + let saved = outcome(whole(), Some(concern()), true); + let run = outcome(whole(), Some(concern()), false); + + assert!( + matches!( + saved, + Ok(Source::Write { + unconfirmed: Some(_), + .. + }) + ), + "a save reads it as rolled back" + ); + assert!(run.is_err(), "the editor shows the server's error"); + assert!(matches!(outcome(whole(), None, true), Ok(Source::Documents { .. }))); + } } diff --git a/core/crates/drivers/mongodb/src/write_batch.rs b/core/crates/drivers/mongodb/src/write_batch.rs index 3c8befa092..27dac9d210 100644 --- a/core/crates/drivers/mongodb/src/write_batch.rs +++ b/core/crates/drivers/mongodb/src/write_batch.rs @@ -6,7 +6,7 @@ use tablepro_core::{DriverError, ReadOnlyRefusal, SessionFate, StopRequest, Writ use crate::call::generated; use crate::endpoint::MongoEndpoint; -use crate::execute::{Source, start}; +use crate::execute::{Source, start, start_write}; /// Refuse a batch before anything is sent: a read-only connection never /// writes, and MongoDB has no foreign keys to suspend. @@ -46,7 +46,7 @@ pub(crate) async fn perform( let (_, planned) = generated(&step.statement, &plan.database) .map_err(|error| ledger.failed(index, error, SessionFate::RolledBack))?; ledger.statement_sending(index); - let affected = match start(&plan.endpoint, &plan.client, session, planned).await { + let affected = match start_write(&plan.endpoint, &plan.client, session, planned).await { // Applied where it ran, with its fate open: as a write whose // answer never came. Ok(Source::Write { diff --git a/linux/crates/app/src/ui/workspace/catalog_dialogs.rs b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs index f56dd42b23..6cf995e234 100644 --- a/linux/crates/app/src/ui/workspace/catalog_dialogs.rs +++ b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs @@ -654,6 +654,8 @@ impl Replication { /// raises it. fn replicas(title: &str) -> adw::SpinRow { let row = adw::SpinRow::with_range(1.0, 99.0, 1.0); + // A data centre's name is never markup. + row.set_use_markup(false); row.set_title(title); row.set_value(1.0); row diff --git a/linux/crates/app/src/ui/workspace/catalog_ops.rs b/linux/crates/app/src/ui/workspace/catalog_ops.rs index 672001a283..3ec16c0d4b 100644 --- a/linux/crates/app/src/ui/workspace/catalog_ops.rs +++ b/linux/crates/app/src/ui/workspace/catalog_ops.rs @@ -1125,7 +1125,12 @@ impl CatalogPrompt { } fn switch(&mut self, title: &str, subtitle: &str) -> glib::WeakRef { - let row = adw::SwitchRow::builder().title(title).subtitle(subtitle).build(); + // The subtitle names tables, which are never markup. + let row = adw::SwitchRow::builder() + .title(title) + .subtitle(subtitle) + .use_markup(false) + .build(); self.options.append(&row); self.options.set_visible(true); let weak = row.downgrade(); @@ -1137,6 +1142,7 @@ impl CatalogPrompt { let entries: Vec<&str> = entries.iter().map(String::as_str).collect(); let row = adw::ComboRow::builder() .title(title) + .use_markup(false) .model(>k::StringList::new(&entries)) .build(); self.options.append(&row); diff --git a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs index b43e08692f..c8c4422f61 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tree_menu.rs @@ -305,6 +305,7 @@ mod tests { NEW_QUERY_IN, REFRESH_IN, NEW_DATABASE_IN, + NEW_SCHEMA_IN, DISCONNECT_SESSION, DROP_DATABASE_IN ] From a967ae5c1961424853a4559979d844d3258db1f4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:13:13 +0700 Subject: [PATCH 4/8] fix(mongodb): leave a failed many-document write's outcome open --- .../crates/drivers/mongodb/src/write_batch.rs | 44 +++++++++++++++++-- .../crates/test-fixtures/src/mysql_fixture.rs | 5 +++ .../app/src/ui/workspace/sidebar/tests.rs | 3 +- .../app/src/ui/workspace/tests/catalog_ops.rs | 3 +- 4 files changed, 50 insertions(+), 5 deletions(-) diff --git a/core/crates/drivers/mongodb/src/write_batch.rs b/core/crates/drivers/mongodb/src/write_batch.rs index 27dac9d210..e10a59be3e 100644 --- a/core/crates/drivers/mongodb/src/write_batch.rs +++ b/core/crates/drivers/mongodb/src/write_batch.rs @@ -1,6 +1,7 @@ use std::sync::Arc; use mongodb::{Client, ClientSession}; +use tablepro_core::sql_syntax::mongo::ShellStatement; use tablepro_core::statement::{GuardedStatement, RowGuard}; use tablepro_core::{DriverError, ReadOnlyRefusal, SessionFate, StopRequest, WriteBatch, WriteLedger, WriteReport}; @@ -43,8 +44,9 @@ pub(crate) async fn perform( return Err(ledger.interrupted(DriverError::Cancelled, SessionFate::RolledBack)); } prove(plan, session, index, step).await?; - let (_, planned) = generated(&step.statement, &plan.database) + let (parsed, planned) = generated(&step.statement, &plan.database) .map_err(|error| ledger.failed(index, error, SessionFate::RolledBack))?; + let several = writes_several(&parsed); ledger.statement_sending(index); let affected = match start_write(&plan.endpoint, &plan.client, session, planned).await { // Applied where it ran, with its fate open: as a write whose @@ -56,7 +58,7 @@ pub(crate) async fn perform( Ok(Source::Write { affected, .. }) => Some(affected), Ok(Source::Count(count)) => Some(count), Ok(_) => None, - Err(error) => return Err(failure(ledger, index, error)), + Err(error) => return Err(failure(ledger, index, error, several)), }; // A single-document write that matched no document changed // nothing, so the step is not counted as applied. @@ -105,15 +107,32 @@ fn expected_rows(guard: &RowGuard) -> Option { /// A refusal from the server applied nothing of its statement; a lost /// connection may have applied it. -fn failure(ledger: &WriteLedger, index: usize, error: DriverError) -> DriverError { +fn failure(ledger: &WriteLedger, index: usize, error: DriverError, several: bool) -> DriverError { match error { DriverError::ConnectionLost { .. } | DriverError::Transport(_) | DriverError::Timeout { .. } => { ledger.interrupted(error, SessionFate::Discarded) } + // A write over many documents applies one document at a time, so + // an error after it started, such as a killOp, can leave some + // written: its fate is open, not rolled back. + other if several => ledger.interrupted(other, SessionFate::Discarded), other => ledger.failed(index, other, SessionFate::RolledBack), } } +/// Whether the call writes any number of documents rather than one. +fn writes_several(statement: &ShellStatement) -> bool { + let ShellStatement::Call(call) = statement else { + return false; + }; + call.operation().is_some_and(|method| { + matches!( + method.name.as_str(), + "deleteMany" | "updateMany" | "insertMany" | "insert" | "remove" | "update" + ) + }) +} + #[cfg(test)] mod tests { use bson::doc; @@ -180,4 +199,23 @@ mod tests { } ); } + + #[test] + fn a_write_over_many_documents_that_fails_leaves_its_fate_open() { + let parsed = |text: &str| { + let mut builder = tablepro_core::statement::StatementBuilder::new(); + builder.push_sql(text); + generated(&builder.finish(), "shop").expect("a call").0 + }; + + assert!(writes_several(&parsed(r#"db.getCollection("orders").deleteMany({})"#))); + assert!(!writes_several(&parsed( + r#"db.getCollection("orders").deleteOne({"_id": 1})"# + ))); + assert!(!writes_several(&parsed(r#"db.getSiblingDB("shop").dropDatabase()"#))); + let ledger = WriteLedger::new(tablepro_core::WriteAtomicity::StatementByStatement); + ledger.statement_sending(0); + let killed = failure(&ledger, 0, DriverError::server("operation was interrupted"), true); + assert!(!matches!(killed, DriverError::RolledBack { .. }), "{killed:?}"); + } } diff --git a/core/crates/test-fixtures/src/mysql_fixture.rs b/core/crates/test-fixtures/src/mysql_fixture.rs index 81def851d8..9bfb78e376 100644 --- a/core/crates/test-fixtures/src/mysql_fixture.rs +++ b/core/crates/test-fixtures/src/mysql_fixture.rs @@ -124,6 +124,11 @@ impl MysqlFixture { "CREATE USER '{client}'@'%' IDENTIFIED BY '{certificate_password}' REQUIRE X509;\n\ GRANT ALL PRIVILEGES ON {DATABASE}.* TO '{client}'@'%';\n" )); + // The DDL conformance case creates and drops databases named + // tp_ddl_db_*, which the login otherwise has no right to. + principals.push_str(&format!( + "GRANT ALL PRIVILEGES ON `tp\\_ddl\\_db\\_%`.* TO '{PASSWORD_USER}'@'%';\n" + )); Ok(vec![ ContainerFile::readable(format!("{tls}/ca.pem"), pki.read(PkiFile::Ca)?), ContainerFile::readable(format!("{tls}/server.pem"), pki.read(PkiFile::ServerCertificate)?), diff --git a/linux/crates/app/src/ui/workspace/sidebar/tests.rs b/linux/crates/app/src/ui/workspace/sidebar/tests.rs index 6e147de899..c8793246fd 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/tests.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/tests.rs @@ -2672,7 +2672,8 @@ fn a_schema_named_like_a_database_is_dropped_where_schemas_are_not_databases() { #[gtk4::test] fn a_new_schema_shows_once_the_schemas_are_read_again() { let test = TestWindow::connected_with("SidebarSchemaShape", |script| { - script.list_schemas.once(FakeReply::ok(vec![schema("public", true)])); + // The connect lists none of these: the first is what + // catalog_shape_changed reads, the second what Refresh reads. script .list_schemas .once(FakeReply::ok(vec![schema("public", true), schema("sales", false)])); diff --git a/linux/crates/app/src/ui/workspace/tests/catalog_ops.rs b/linux/crates/app/src/ui/workspace/tests/catalog_ops.rs index 09bdd2e64a..9247382644 100644 --- a/linux/crates/app/src/ui/workspace/tests/catalog_ops.rs +++ b/linux/crates/app/src/ui/workspace/tests/catalog_ops.rs @@ -388,7 +388,8 @@ fn a_new_database_waits_for_its_name_and_shows_only_its_engines_options() { let create = descendants(&form) .into_iter() .filter_map(|widget| widget.downcast::().ok()) - .find(|button| button.has_css_class("suggested-action")) + // An entry row's own apply button is suggested-action too. + .find(|button| button.has_css_class("suggested-action") && button.label().as_deref() == Some("_Create")) .expect("the Create button"); assert!(!create.is_sensitive(), "Create was on with no name"); From 6d50c6184e3d126840379a64465873322c32598b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:21:00 +0700 Subject: [PATCH 5/8] fix(linux): name SQLite's empty main with no schema in the sidebar --- core/crates/core/src/dialect/sql_dialect.rs | 7 +++++++ core/crates/drivers/sqlite/src/dialect.rs | 5 +++++ .../app/src/ui/workspace/sidebar/mod.rs | 10 +++++++++- .../src/ui/workspace/sidebar/sidebar_index.rs | 19 +++++++++++++++++++ 4 files changed, 40 insertions(+), 1 deletion(-) diff --git a/core/crates/core/src/dialect/sql_dialect.rs b/core/crates/core/src/dialect/sql_dialect.rs index 79bdb80e61..ba9f9acce8 100644 --- a/core/crates/core/src/dialect/sql_dialect.rs +++ b/core/crates/core/src/dialect/sql_dialect.rs @@ -36,6 +36,13 @@ pub trait SqlDialect: Send + Sync + std::fmt::Debug { DatabaseLayout::Single } + /// Whether the catalog names the default schema's objects with no + /// schema, as SQLite names main's, so an action on that schema names + /// none either and lands on the same table the list shows. + fn unqualified_default_schema(&self) -> bool { + false + } + /// Bind one value, giving back the SQL that names it. /// /// `ordinal` is the number of parameters the statement already diff --git a/core/crates/drivers/sqlite/src/dialect.rs b/core/crates/drivers/sqlite/src/dialect.rs index d5e57a29bc..c6963c42fe 100644 --- a/core/crates/drivers/sqlite/src/dialect.rs +++ b/core/crates/drivers/sqlite/src/dialect.rs @@ -33,6 +33,11 @@ impl SqliteDialect { } impl SqlDialect for SqliteDialect { + /// main's objects are listed with no schema. + fn unqualified_default_schema(&self) -> bool { + true + } + fn capabilities(&self) -> DialectCapabilities { DialectCapabilities { exact_row_counts: true, diff --git a/linux/crates/app/src/ui/workspace/sidebar/mod.rs b/linux/crates/app/src/ui/workspace/sidebar/mod.rs index 97b48083d4..9c886343cf 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/mod.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/mod.rs @@ -131,8 +131,16 @@ pub(super) async fn list_catalog( let database = session.database().map(str::to_owned); tasks .spawn_blocking_task(move || { - let mut index = SidebarIndex::build(&objects, schemas.as_deref().unwrap_or(&known)); + let listed = schemas.as_deref().unwrap_or(&known); + let mut index = SidebarIndex::build(&objects, listed); index.withhold_drops(|name| kept(dialect.as_deref(), database.as_deref(), name)); + if dialect + .as_deref() + .is_some_and(|dialect| dialect.unqualified_default_schema()) + && let Some(default) = listed.iter().find(|schema| schema.is_default) + { + index.unqualify(&default.name); + } SidebarListing { index, schemas } }) .await diff --git a/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs b/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs index 5ea65bd66f..bd46fca4b3 100644 --- a/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs +++ b/linux/crates/app/src/ui/workspace/sidebar/sidebar_index.rs @@ -217,6 +217,16 @@ impl SidebarIndex { } } + /// Have the actions of `schema`'s section name no schema, where the + /// engine lists that schema's objects with none. + pub(crate) fn unqualify(&mut self, schema: &str) { + for section in &mut self.sections { + if section.schema.as_deref() == Some(schema) { + section.schema = None; + } + } + } + /// Whether the list offers to drop the schema `name`. pub(crate) fn droppable(&self, name: &str) -> bool { self.sections @@ -721,6 +731,15 @@ mod tests { .collect() } + #[test] + fn an_empty_main_names_no_schema_where_its_objects_name_none() { + let mut index = SidebarIndex::build(&[], &[schema("main", true, false)]); + + index.unqualify("main"); + + assert_eq!(sections(&index), [("main".to_owned(), None, false, None)]); + } + #[test] fn every_listed_schema_of_the_users_own_has_a_section_even_empty() { let schemas = [ From cf01e38a0edc8fd46cddcccb10c1d102a0c76ecc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:21:56 +0700 Subject: [PATCH 6/8] docs(linux): changelog fragments for catalog operations --- linux/changelog.d/4050.added.md | 1 + linux/changelog.d/4050.fixed.1.md | 1 + linux/changelog.d/4050.fixed.2.md | 1 + linux/changelog.d/4050.fixed.3.md | 1 + linux/changelog.d/4050.fixed.4.md | 1 + linux/changelog.d/4050.fixed.md | 1 + 6 files changed, 6 insertions(+) create mode 100644 linux/changelog.d/4050.added.md create mode 100644 linux/changelog.d/4050.fixed.1.md create mode 100644 linux/changelog.d/4050.fixed.2.md create mode 100644 linux/changelog.d/4050.fixed.3.md create mode 100644 linux/changelog.d/4050.fixed.4.md create mode 100644 linux/changelog.d/4050.fixed.md diff --git a/linux/changelog.d/4050.added.md b/linux/changelog.d/4050.added.md new file mode 100644 index 0000000000..128b1bffcc --- /dev/null +++ b/linux/changelog.d/4050.added.md @@ -0,0 +1 @@ +Truncate a table, and create or drop a database or schema, from the sidebar. diff --git a/linux/changelog.d/4050.fixed.1.md b/linux/changelog.d/4050.fixed.1.md new file mode 100644 index 0000000000..4c9f6c44c5 --- /dev/null +++ b/linux/changelog.d/4050.fixed.1.md @@ -0,0 +1 @@ +A connection whose database was dropped kept trying to reconnect. diff --git a/linux/changelog.d/4050.fixed.2.md b/linux/changelog.d/4050.fixed.2.md new file mode 100644 index 0000000000..989655d31f --- /dev/null +++ b/linux/changelog.d/4050.fixed.2.md @@ -0,0 +1 @@ +A schema or database with no tables was missing from the sidebar. diff --git a/linux/changelog.d/4050.fixed.3.md b/linux/changelog.d/4050.fixed.3.md new file mode 100644 index 0000000000..582faceb6c --- /dev/null +++ b/linux/changelog.d/4050.fixed.3.md @@ -0,0 +1 @@ +Query tabs kept listing databases that were dropped and missed new ones until reopened. diff --git a/linux/changelog.d/4050.fixed.4.md b/linux/changelog.d/4050.fixed.4.md new file mode 100644 index 0000000000..ce7ff86fdd --- /dev/null +++ b/linux/changelog.d/4050.fixed.4.md @@ -0,0 +1 @@ +Reopen Closed Tab brought back tabs of a table that was dropped. diff --git a/linux/changelog.d/4050.fixed.md b/linux/changelog.d/4050.fixed.md new file mode 100644 index 0000000000..eadd0a37d1 --- /dev/null +++ b/linux/changelog.d/4050.fixed.md @@ -0,0 +1 @@ +Drop Table left no entry in the query history. From 3b207b49fb52b5018b56e4e15c398f802f84955e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:46:15 +0700 Subject: [PATCH 7/8] fix(core): leave cluster-wide and Cassandra truncate failures open, reload tabs after a schema drop --- .../drivers/cassandra/src/write_batch.rs | 10 ++++++- .../drivers/clickhouse/src/write_batch.rs | 26 +++++++++++++++--- .../app/src/ui/workspace/catalog_ops.rs | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/core/crates/drivers/cassandra/src/write_batch.rs b/core/crates/drivers/cassandra/src/write_batch.rs index 88510c15e0..a6ec964c05 100644 --- a/core/crates/drivers/cassandra/src/write_batch.rs +++ b/core/crates/drivers/cassandra/src/write_batch.rs @@ -119,7 +119,14 @@ async fn count(link: &CqlLink, probe: &Statement) -> Result { /// nothing either way. fn may_have_applied(error: &DriverError) -> bool { match error { - DriverError::Server(diagnostics) => matches!(diagnostics.code, Some(ServerCode::Cql { code: 0x1100 | 0x1500 })), + // 0x1003 is a TRUNCATE some nodes may have carried out before + // another failed or timed out. + DriverError::Server(diagnostics) => matches!( + diagnostics.code, + Some(ServerCode::Cql { + code: 0x1003 | 0x1100 | 0x1500 + }) + ), DriverError::ConnectionLost { .. } | DriverError::Timeout { .. } | DriverError::Transport(_) => true, _ => false, } @@ -196,6 +203,7 @@ mod tests { assert!(may_have_applied(&server(0x1100))); assert!(may_have_applied(&server(0x1500))); + assert!(may_have_applied(&server(0x1003)), "a truncate other nodes may have run"); assert!(!may_have_applied(&server(0x2200))); assert!( !may_have_applied(&server(0x1000)), diff --git a/core/crates/drivers/clickhouse/src/write_batch.rs b/core/crates/drivers/clickhouse/src/write_batch.rs index 960569a066..89138aea7c 100644 --- a/core/crates/drivers/clickhouse/src/write_batch.rs +++ b/core/crates/drivers/clickhouse/src/write_batch.rs @@ -93,9 +93,9 @@ pub(crate) async fn perform( } Err(error) => { let mapped = map_error(&error); - return Err(match classify(&error) { - WriteErrorClass::MayHaveApplied => ledger.interrupted(mapped, SessionFate::Discarded), - WriteErrorClass::Refused => ledger.failed(index, mapped, SessionFate::RolledBack), + return Err(match (classify(&error), on_cluster(&step.statement)) { + (WriteErrorClass::Refused, false) => ledger.failed(index, mapped, SessionFate::RolledBack), + _ => ledger.interrupted(mapped, SessionFate::Discarded), }); } } @@ -103,6 +103,14 @@ pub(crate) async fn perform( Ok(WriteReport::new(report)) } +/// Whether the statement runs on every server of a cluster. One server +/// can refuse it after others applied it, so a refusal leaves its +/// outcome open. A name that holds the words only makes a refusal read +/// as open, never the other way round. +fn on_cluster(statement: &Statement) -> bool { + statement.sql().contains(" ON CLUSTER ") +} + /// Count the rows step `index` would change, and refuse the step unless /// they are the ones it was written for. Nothing is in flight while a /// probe runs, so the ledger reports what the earlier steps applied. @@ -223,4 +231,16 @@ mod tests { Err(DriverError::ReadOnly(ReadOnlyRefusal::ClientGuard)) )); } + + #[test] + fn a_statement_on_a_cluster_is_known_by_its_clause() { + let sql = |text: &str| { + let mut builder = tablepro_core::statement::StatementBuilder::new(); + builder.push_sql(text); + builder.finish() + }; + + assert!(on_cluster(&sql("TRUNCATE TABLE `d`.`t` ON CLUSTER `main`"))); + assert!(!on_cluster(&sql("TRUNCATE TABLE `d`.`t`"))); + } } diff --git a/linux/crates/app/src/ui/workspace/catalog_ops.rs b/linux/crates/app/src/ui/workspace/catalog_ops.rs index 3ec16c0d4b..a28cfc3051 100644 --- a/linux/crates/app/src/ui/workspace/catalog_ops.rs +++ b/linux/crates/app/src/ui/workspace/catalog_ops.rs @@ -31,6 +31,7 @@ use crate::i18n::{gettext, gettext_f, ngettext_f}; use crate::runtime::DeliverInput; use crate::ui::SessionWindowInput; use crate::ui::error_text; +use crate::ui::structure_tab::StructureMode; use crate::ui::write_confirmation::asks_before_writes; use crate::workspace::{CatalogChange, PendingState, database_target, run_catalog_change}; @@ -358,6 +359,24 @@ impl WorkspaceModel { .collect() } + /// The table of every table or structure tab open. + fn open_tables(&self) -> Vec { + let mut open: Vec = Vec::new(); + for tab in self.workspace_tabs.borrow().values() { + let table = match tab { + WorkspaceTab::Table(slot) => TableRef::new(slot.schema.clone(), slot.table.clone()), + WorkspaceTab::Structure(slot) if slot.mode == StructureMode::Edit => { + TableRef::new(slot.schema.clone(), slot.table.clone()) + } + _ => continue, + }; + if !open.contains(&table) { + open.push(table); + } + } + open + } + /// The tables of the table tabs that hold no unsaved edits, other /// than those in `known`. fn clean_table_tabs_besides(&self, known: &[TableRef]) -> Vec { @@ -651,6 +670,14 @@ impl WorkspaceModel { CatalogLanded::SchemaCreated => self.catalog_shape_changed(), CatalogLanded::SchemaDropped(schema) => { self.close_tabs_in(&schema); + // A cascade also drops what other schemas' tables held of + // it, such as a column of one of its types. + if let Some(session) = self.context.active.get() { + session.invalidate_columns(); + } + let open = self.open_tables(); + self.input + .deliver(WorkspaceInput::Catalog(CatalogInput::ReloadTables(open))); self.catalog_shape_changed(); } CatalogLanded::DatabaseCreated(database) => { From 948894c39a6b210544c45fa1eddfaf190725c030 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 5 Nov 2026 13:56:30 +0700 Subject: [PATCH 8/8] fix(linux): leave data centres out of a keyspace and keep ScyllaDB tablets off for Simple --- core/crates/drivers/cassandra/src/ddl.rs | 30 +++++++++++ .../app/src/ui/workspace/catalog_dialogs.rs | 51 +++++++++++++++++-- 2 files changed, 76 insertions(+), 5 deletions(-) diff --git a/core/crates/drivers/cassandra/src/ddl.rs b/core/crates/drivers/cassandra/src/ddl.rs index 5e0bfe1d6d..bf8c82f894 100644 --- a/core/crates/drivers/cassandra/src/ddl.rs +++ b/core/crates/drivers/cassandra/src/ddl.rs @@ -210,6 +210,15 @@ impl DdlDialect for CqlDialect { self.quote_identifier(name), replication_map(replication)? ); + // ScyllaDB keeps tablets only for NetworkTopologyStrategy, and turns + // them on by default, so SimpleStrategy needs them off. + let simple = matches!(replication, KeyspaceReplication::Simple { .. }); + if simple && capabilities.database_fields.tablets && draft.tablets != Some(false) { + return Err(BuildDdlError::InvalidOption { + option: CatalogOption::ReplicationFactor, + value: "SimpleStrategy with tablets".to_owned(), + }); + } if !draft.durable_writes { sql.push_str(" AND durable_writes = false"); } @@ -552,6 +561,27 @@ mod tests { assert!(!dialect().catalog_capabilities().database_fields.tablets); } + #[test] + fn scylladb_takes_simple_replication_only_with_tablets_off() { + let mut draft = keyspace(KeyspaceReplication::Simple { factor: 1 }); + + for tablets in [None, Some(true)] { + draft.tablets = tablets; + assert_eq!( + invalid(scylla().plan_create_database(&draft)), + Some(CatalogOption::ReplicationFactor), + "{tablets:?}" + ); + } + draft.tablets = Some(false); + assert!(scylla().plan_create_database(&draft).is_ok()); + draft.tablets = None; + assert!( + dialect().plan_create_database(&draft).is_ok(), + "Cassandra has no tablets" + ); + } + #[test] fn a_keyspace_the_server_would_refuse_is_not_planned() { let dialect = dialect(); diff --git a/linux/crates/app/src/ui/workspace/catalog_dialogs.rs b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs index 6cf995e234..93b8b20743 100644 --- a/linux/crates/app/src/ui/workspace/catalog_dialogs.rs +++ b/linux/crates/app/src/ui/workspace/catalog_dialogs.rs @@ -154,6 +154,7 @@ impl WorkspaceModel { let (form, dialog) = FormDialog::new(&title, &gettext("_Create")); let state = Rc::new(database_form(form, choices, dialect, capabilities)); state.follow_character_set(); + state.follow_strategy(); connect_database_form(&state); let (input, parent, asks) = (self.input.clone(), self.parent_ref(), asks_first(&session)); let submitting = Rc::downgrade(&state); @@ -402,6 +403,7 @@ fn connect_database_form(state: &Rc) { if let Some(replication) = &state.replication { replication.show_strategy(); } + state.follow_strategy(); state.check(); } }); @@ -471,6 +473,19 @@ impl DatabaseForm { /// while it is left to the server. Left to the server, neither option /// is sent and the server's own collation holds, which need not be its /// character set's default. + /// ScyllaDB keeps tablets only for network topology replication, so + /// Simple turns them off and holds the switch there. + fn follow_strategy(&self) { + let (Some(replication), Some(tablets)) = (&self.replication, &self.tablets) else { + return; + }; + let simple = replication.simple(); + if simple { + tablets.set_active(false); + } + tablets.set_sensitive(!simple); + } + fn follow_character_set(&self) { let (Some(character_set), Some(collation)) = (&self.character_set, &self.collation) else { return; @@ -579,11 +594,12 @@ impl Replication { .title(gettext("Replication")) .model(>k::StringList::new(&strategies)) .build(); - let factor = replicas(&gettext("Replication factor")); + let factor = replicas(&gettext("Replication factor"), 1.0); + // 0 leaves a data centre out, so a keyspace can live in some only. let centres: Vec<(String, adw::SpinRow)> = data_centers .into_iter() .map(|centre| { - let row = replicas(&gettext_f("Replicas in {dc}", &[("dc", ¢re)])); + let row = replicas(&gettext_f("Replicas in {dc}", &[("dc", ¢re)]), 0.0); (centre, row) }) .collect(); @@ -633,6 +649,7 @@ impl Replication { .centres .iter() .map(|(centre, row)| (centre.clone(), replica_count(row))) + .filter(|(_, count)| *count > 0) .collect(), }, } @@ -652,8 +669,8 @@ impl Replication { /// One replica is all a single-node cluster can hold; a real cluster /// raises it. -fn replicas(title: &str) -> adw::SpinRow { - let row = adw::SpinRow::with_range(1.0, 99.0, 1.0); +fn replicas(title: &str, least: f64) -> adw::SpinRow { + let row = adw::SpinRow::with_range(least, 99.0, 1.0); // A data centre's name is never markup. row.set_use_markup(false); row.set_title(title); @@ -661,7 +678,31 @@ fn replicas(title: &str) -> adw::SpinRow { row } -/// The row's range keeps the count from 1 to 99. +/// The row's range keeps the count from 0 to 99. fn replica_count(row: &adw::SpinRow) -> u32 { row.value() as u32 } + +#[cfg(test)] +mod tests { + use super::*; + + #[gtk4::test] + fn a_data_centre_left_at_zero_holds_no_replicas() { + adw::init().expect("libadwaita initialises under the test backend"); + let group = adw::PreferencesGroup::new(); + let replication = Replication::new(&group, vec!["dc1".to_owned(), "dc2".to_owned()]); + + replication.centres[0].1.set_value(3.0); + replication.centres[1].1.set_value(0.0); + + assert_eq!( + replication.chosen(), + KeyspaceReplication::NetworkTopology { + factors: vec![("dc1".to_owned(), 3)] + } + ); + replication.strategy.set_selected(SIMPLE); + assert_eq!(replication.chosen(), KeyspaceReplication::Simple { factor: 1 }); + } +}