From c3253db4a08a0d811bba67c9145dadf8193e7a7c Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:13:44 +0700 Subject: [PATCH 01/14] fix(linux-sqlite): refuse to drop a table another table's key points at --- linux/crates/drivers/sqlite/src/ddl.rs | 53 +++++++++++- .../drivers/sqlite/tests/integration/ddl.rs | 86 ++++++++++++++++++- 2 files changed, 133 insertions(+), 6 deletions(-) diff --git a/linux/crates/drivers/sqlite/src/ddl.rs b/linux/crates/drivers/sqlite/src/ddl.rs index ead259296a..f0c62d09ed 100644 --- a/linux/crates/drivers/sqlite/src/ddl.rs +++ b/linux/crates/drivers/sqlite/src/ddl.rs @@ -11,19 +11,22 @@ mod rebuild_plan; mod source_element; mod source_read_error; -use tablepro_core::ForeignKeyInfo; -use tablepro_core::column::ColumnDefault; +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, DdlPlan, DraftColumn, - GeneratedStorage, Permission, RefusalReason, SchemaObjectKind, TableChanges, TableDefinition, TableDraft, + BuildDdlError, ColumnCapabilities, ColumnDefinition, DdlCapabilities, DdlDialect, DdlOperation, DdlPlan, + DdlPrecondition, DraftColumn, DropTableOptions, GeneratedStorage, Permission, RefusalReason, SchemaObjectKind, + TableChanges, TableDefinition, TableDraft, }; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{IndexInfo, TableRef}; +use tablepro_core::statement::{Statement, StatementBuilder}; +use tablepro_core::{ForeignKeyInfo, Value}; use self::create_table_source::CreateTableSource; pub(crate) use self::create_table_source::{declared_descending_key, declared_foreign_key_names}; use self::declared_shape::readable; +use crate::catalog::MAIN_SCHEMA; use crate::dialect::SqliteDialect; /// A change ALTER TABLE cannot make rebuilds the table, so every change @@ -175,6 +178,25 @@ impl DdlDialect for SqliteDialect { rebuild_plan::plan(self, original, &source, changes) } + /// With foreign keys on, SQLite empties a table with an implicit + /// DELETE before it drops it, which runs the ON DELETE actions of + /// every key pointing at it: a CASCADE deletes the child rows and a + /// SET NULL clears them. PostgreSQL, MySQL and SQL Server refuse to + /// drop a table another table's key points at, and the precondition + /// makes SQLite refuse too, counted in the drop's own transaction. + fn plan_drop_table(&self, table: &TableRef, options: DropTableOptions) -> Result { + if options.cascade { + return Err(BuildDdlError::Unsupported(DdlOperation::DropTableCascade)); + } + let if_exists = if options.if_exists { "IF EXISTS " } else { "" }; + let mut plan = DdlPlan::from_sql([format!("DROP TABLE {if_exists}{}", 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. @@ -196,6 +218,29 @@ impl DdlDialect for SqliteDialect { } impl SqliteDialect { + /// A count of the foreign keys on other tables of the schema that + /// point at `table`. A key of the table on itself goes with it. + /// SQLite matches table names without regard to ASCII case. + fn inbound_keys_probe(&self, table: &TableRef) -> Result { + let schema = table.schema.clone().filter(|schema| !schema.is_empty()); + let catalogue = self.qualify(&TableRef::new(schema.clone(), "sqlite_schema")); + let literal = |text: &str| { + self.literal(&Value::Text(text.to_owned()), &ColumnType::unknown()) + .map_err(|error| BuildDdlError::Literal { + column: table.name.clone(), + error, + }) + }; + let name = literal(&table.name)?; + let schema = literal(schema.as_deref().unwrap_or(MAIN_SCHEMA))?; + let mut builder = StatementBuilder::new(); + builder.push_sql(&format!( + "SELECT count(*) FROM {catalogue} m, pragma_foreign_key_list(m.name, {schema}) f WHERE m.type = \ + 'table' AND f.\"table\" = {name} COLLATE NOCASE AND m.name <> {name} COLLATE NOCASE" + )); + Ok(builder.finish()) + } + /// One native ALTER TABLE per change, inside the write's /// transaction. fn native_plan(&self, original: &TableDefinition, changes: &TableChanges) -> Result { diff --git a/linux/crates/drivers/sqlite/tests/integration/ddl.rs b/linux/crates/drivers/sqlite/tests/integration/ddl.rs index 7e2bcf65b7..12a0b5e85a 100644 --- a/linux/crates/drivers/sqlite/tests/integration/ddl.rs +++ b/linux/crates/drivers/sqlite/tests/integration/ddl.rs @@ -4,8 +4,8 @@ use tablepro_core::column::ColumnDefault; use tablepro_core::ddl::{ - BuildDdlError, DdlOperation, DraftColumn, DraftType, ReferentialAction, RefusalReason, SchemaObjectKind, - TableDefinition, TableDraft, diff_table, + BuildDdlError, DdlOperation, DraftColumn, DraftType, DropTableOptions, ReferentialAction, RefusalReason, + SchemaObjectKind, TableDefinition, TableDraft, diff_table, }; use tablepro_core::meta::{IndexKey, TableRef}; use tablepro_core::statement::GuardedStatement; @@ -1407,3 +1407,85 @@ async fn a_descending_integer_key_keeps_its_rowids_through_a_rebuild() { ); connection.close().await.unwrap(); } + +/// Drop Table as the workspace plans and runs it. +async fn drop_table(connection: &dyn Connection, table: &TableRef) -> Result { + let options = DropTableOptions { + if_exists: true, + cascade: false, + }; + let plan = connection + .dialect() + .ddl() + .unwrap() + .plan_drop_table(table, options) + .unwrap(); + connection + .write_batch(&plan.into_write_batch(table.clone()), call()) + .await +} + +/// SQLite's implicit DELETE before a drop would run these keys' ON +/// DELETE actions, deleting the order and clearing the note's key. +#[tokio::test] +async fn a_table_another_tables_key_points_at_is_not_dropped() { + 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 = drop_table(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(); +} + +#[tokio::test] +async fn a_table_only_its_own_key_points_at_is_dropped() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE tree (id INTEGER PRIMARY KEY, parent_id INTEGER REFERENCES Tree ON DELETE CASCADE)", + "CREATE TABLE other (id INTEGER PRIMARY KEY, kept_id INTEGER REFERENCES kept)", + "INSERT INTO tree VALUES (1, NULL), (2, 1)", + ], + ) + .await + .unwrap(); + + drop_table(connection.as_ref(), &named("tree")).await.unwrap(); + + let tables = query( + connection.as_ref(), + "SELECT name FROM sqlite_schema WHERE type = 'table' ORDER BY name", + ) + .await; + assert_eq!(tables.rows, [vec![text("other")]]); + connection.close().await.unwrap(); +} From 7d939892de30b1406ab9879712f0c176e61c3ff4 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:13:48 +0700 Subject: [PATCH 02/14] fix(linux): name the keys that block Drop Table and hold it like a save --- linux/crates/app/src/ui/workspace/mod.rs | 7 + .../crates/app/src/ui/workspace/structure.rs | 203 ++++++++++++++++-- 2 files changed, 191 insertions(+), 19 deletions(-) diff --git a/linux/crates/app/src/ui/workspace/mod.rs b/linux/crates/app/src/ui/workspace/mod.rs index f39644dee9..75129d87ea 100644 --- a/linux/crates/app/src/ui/workspace/mod.rs +++ b/linux/crates/app/src/ui/workspace/mod.rs @@ -460,6 +460,12 @@ pub(crate) enum WorkspaceInput { schema: Option, table: String, }, + /// The foreign keys on other tables that point at the table, read + /// before the prompt asks to drop it. + DropTableChecked { + table: tablepro_core::TableRef, + referencing: Vec, + }, DropTableConfirmed { schema: Option, table: String, @@ -937,6 +943,7 @@ impl WorkspaceModel { WorkspaceInput::ShowCreateTableForExisting { schema, table } => self.on_show_create_table(schema, table), WorkspaceInput::ShowCreateTableLoaded(sql) => self.open_editor_tab(Some(sql)), WorkspaceInput::DropTablePrompt { schema, table } => self.on_drop_table_prompt(schema, table), + 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::SchemaChanged { schema, table } => self.on_schema_changed(schema, table), diff --git a/linux/crates/app/src/ui/workspace/structure.rs b/linux/crates/app/src/ui/workspace/structure.rs index 913262aa14..3c1791c972 100644 --- a/linux/crates/app/src/ui/workspace/structure.rs +++ b/linux/crates/app/src/ui/workspace/structure.rs @@ -8,12 +8,12 @@ use libadwaita as adw; use libadwaita::prelude::*; use uuid::Uuid; -use tablepro_core::ddl::{BuildDdlError, DdlOperation, DropTableOptions, TableDefinition}; +use tablepro_core::ddl::{BuildDdlError, DdlOperation, DropTableOptions, InboundForeignKey, TableDefinition}; use tablepro_core::dialect::SqlDialect; use tablepro_core::meta::{ObjectScope, TableRef}; +use tablepro_core::{DriverError, WriteKind}; use tablepro_session::runtime::TaskFailure; use tablepro_session::{OperationError, OperationKind, Session}; -use tokio_util::sync::CancellationToken; use crate::runtime::DeliverInput; use crate::ui::error_text; @@ -65,14 +65,50 @@ impl WorkspaceModel { }); } - /// Right-click → "Drop Table…", or in-tab destructive button. + /// Right-click → "Drop Table…", or in-tab destructive button. The + /// table's definition is read first, so a table other tables point + /// at is refused before the user is asked. pub(super) fn on_drop_table_prompt(&mut self, schema: Option, table: String) { - // The sidebar lists every schema, so two tables can share a name. - let named = match &schema { - Some(schema) => format!("{schema}.{table}"), - None => table.clone(), + let table = TableRef::new(schema, table); + let Some(session) = self.context.active.get() else { + self.present_drop_prompt(&table); + return; }; - let title = crate::i18n::gettext_f("Drop {table}?", &[("table", &named)]); + let work = read_definition(session, table.clone()); + let input = self.input.clone(); + crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { + // A definition that cannot be read leaves the refusal to the + // server, or to the precondition SQLite's plan carries. + let referencing = match settled(result) { + Ok((definition, _)) => referencing_keys(&definition), + Err(error) => { + tracing::debug!(%error, "the table's foreign keys could not be read before a drop"); + Vec::new() + } + }; + input.deliver(WorkspaceInput::DropTableChecked { table, referencing }); + }); + } + + pub(super) fn on_drop_table_checked(&mut self, table: TableRef, referencing: &[InboundForeignKey]) { + if referencing.is_empty() { + self.present_drop_prompt(&table); + return; + } + self.show_error_alert( + &crate::i18n::gettext("Cannot Drop Table"), + &crate::i18n::gettext_f( + "Foreign keys on other tables point at {table}: {keys}. Drop those foreign keys first.", + &[ + ("table", &display_name(&table)), + ("keys", &key_list(&table, referencing)), + ], + ), + ); + } + + fn present_drop_prompt(&self, table: &TableRef) { + let title = crate::i18n::gettext_f("Drop {table}?", &[("table", &display_name(table))]); let body = crate::i18n::gettext( "All rows and the table definition will be removed. This can't be undone from inside TablePro.", ); @@ -83,13 +119,12 @@ impl WorkspaceModel { dialog.set_default_response(Some("cancel")); dialog.set_close_response("cancel"); let input = self.input.clone(); - let schema_for_resp = schema.clone(); - let table_for_resp = table.clone(); + let table = table.clone(); dialog.connect_response(None, move |_, response| { if response == "drop" { input.deliver(WorkspaceInput::DropTableConfirmed { - schema: schema_for_resp.clone(), - table: table_for_resp.clone(), + schema: table.schema.clone(), + table: table.name.clone(), }); } }); @@ -101,6 +136,9 @@ impl WorkspaceModel { /// 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. 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( @@ -109,20 +147,24 @@ impl WorkspaceModel { ); 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 = DropTableOptions { if_exists: true, cascade: false, }; let table = dropped.clone(); + let cancel = ticket.cancel.clone(); let work = async move { - run_schema_plan( - session, - table, - DdlOperation::DropTable, - &CancellationToken::new(), - move |ddl, table| ddl.plan_drop_table(table, options), - ) + 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 @@ -131,11 +173,24 @@ impl WorkspaceModel { crate::runtime::spawn_and_deliver(&self.context.tasks, work, move |result| { match settled(result).and_then(|(_, written)| written) { Ok(_) => input.deliver(WorkspaceInput::DropTableSucceeded(dropped)), + // 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 { title: crate::i18n::gettext("Drop Failed"), body: error_text::operation_error_message(&error), }), } + handle.ledger().settle(ticket.id); }); } @@ -331,6 +386,40 @@ async fn read_definition( .await } +/// The sidebar lists every schema, so two tables can share a name. +fn display_name(table: &TableRef) -> String { + match &table.schema { + Some(schema) => format!("{schema}.{}", table.name), + None => table.name.clone(), + } +} + +/// 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 { + definition + .inbound_foreign_keys + .iter() + .filter(|key| key.table != definition.table) + .cloned() + .collect() +} + +/// 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 { + keys.iter() + .map(|key| { + let table = match key.table.schema == dropped.schema { + true => key.table.name.clone(), + false => display_name(&key.table), + }; + format!("{table} ({})", key.columns.join(", ")) + }) + .collect::>() + .join(", ") +} + /// The CREATE statements for a table as it is defined now, joined so /// the editor shows each on its own. `None` for a table with no /// columns, which has nothing to create. @@ -496,4 +585,80 @@ mod tests { assert_eq!(heading.as_deref(), Some("Drop sales.orders?")); assert!(answer(&test.window, "cancel"), "the prompt has no Cancel button"); } + + #[gtk4::test] + fn a_table_other_tables_point_at_is_refused_before_the_prompt() { + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use crate::workspace::{answer, visible_alert}; + use tablepro_core::ddl::InboundForeignKey; + use tablepro_test_support::FakeReply; + + let mut users = definition(vec![text_column("id")]); + let key = |name: &str, table: TableRef, column: &str| InboundForeignKey { + name: name.to_owned(), + table, + columns: vec![column.to_owned()], + referenced_columns: vec!["id".to_owned()], + }; + users.inbound_foreign_keys = vec![ + key( + "orders_user", + TableRef::new(Some("public".to_owned()), "orders"), + "user_id", + ), + key("audit_user", TableRef::new(Some("audit".to_owned()), "log"), "actor_id"), + key("users_parent", users.table.clone(), "parent_id"), + ]; + let test = TestWindow::connected_with("DropRefusedForInboundKeys", |script| { + script.fetch_table_definition.always(FakeReply::ok(users.clone())); + }); + + test.workspace() + .with_model(|model| model.on_drop_table_prompt(Some("public".to_owned()), "users".to_owned())); + wait_until(WAIT, || visible_alert(&test.window).is_some()).expect("an alert"); + + let alert = visible_alert(&test.window).expect("the alert"); + assert_eq!(alert.heading().as_deref(), Some("Cannot Drop Table")); + let body = alert.body(); + assert!(body.contains("orders (user_id), audit.log (actor_id)"), "{body}"); + assert!( + !body.contains("parent_id"), + "a key of the table on itself was named: {body}" + ); + assert!(!alert.has_response("drop"), "the refusal offered the drop"); + assert!(answer(&test.window, "close"), "the refusal has no Close button"); + } + + #[gtk4::test] + fn a_drop_runs_as_a_save_that_holds_the_process() { + use std::sync::Arc; + + use crate::test_support::wait_until; + use crate::ui::session_window::tests::{TestWindow, WAIT}; + use tablepro_core::WriteReport; + use tablepro_test_support::FakeReply; + use tokio_util::sync::CancellationToken; + + let release = CancellationToken::new(); + let script_release = release.clone(); + let test = TestWindow::connected_with("DropHeld", |script| { + script.dialect = Arc::new(postgres_dialect()); + script.write_batch.once(FakeReply::UntilReleased { + release: script_release, + outcome: Ok(WriteReport::new(vec![None])), + }); + }); + wait_until(WAIT, || test.hold.live() == 0).expect("nothing else to hold the process"); + let handle = test.workspace().handle().expect("the workspace handle"); + + test.workspace() + .with_model(|model| model.on_drop_table_confirmed(Some("public".to_owned()), "users".to_owned())); + + assert_eq!(handle.ledger().in_flight(), 1, "a close would not wait for the drop"); + assert_eq!(test.hold.live(), 1, "the drop started without holding the process"); + release.cancel(); + wait_until(WAIT, || handle.ledger().in_flight() == 0).expect("the drop to settle"); + assert_eq!(test.hold.live(), 0, "the settled drop still held the process"); + } } From 28da2dd0d53f845ea8ecdaa7a39c741aa19fddc1 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:13:52 +0700 Subject: [PATCH 03/14] fix(linux-editor): refuse the run after a session lost its transaction --- .../app/src/ui/editor/editor_session_slot.rs | 79 ++++++- linux/crates/app/src/ui/editor/mod.rs | 2 +- linux/crates/app/src/ui/editor/run_log.rs | 41 +++- .../crates/app/src/ui/editor/script_runner.rs | 200 +++++++++++++++--- .../app/src/ui/editor/transaction_dirty.rs | 21 +- .../test-support/src/fake_editor_session.rs | 6 + 6 files changed, 300 insertions(+), 49 deletions(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index d5b8597856..e2c993a2be 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -1,7 +1,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError, Weak}; use std::time::Duration; -use tablepro_core::{EditorSession, EditorSessionOptions, EditorSessionState}; +use tablepro_core::{EditorSession, EditorSessionOptions, EditorSessionState, TransactionState}; use tablepro_session::{OperationError, Session}; use tokio_util::sync::CancellationToken; @@ -10,6 +10,12 @@ use tokio_util::sync::CancellationToken; /// The run opens it and the tab closes it, possibly while a run is /// still opening one, so the slot remembers it was closed and a session /// opened after that is handed back to be closed rather than kept. +/// +/// A session whose connection went with a transaction open is never +/// replaced on the same run: the next statement would commit on its +/// own, outside the transaction the user thinks is still open. That run +/// is refused with the reason, and only the one after it opens a new +/// session. #[derive(Default)] pub struct EditorSessionSlot { state: Mutex, @@ -18,6 +24,9 @@ pub struct EditorSessionSlot { #[derive(Default)] struct SlotState { open: Option, + /// A session was lost with a transaction open, and no run has been + /// told yet. + rolled_back: bool, closed: bool, } @@ -26,6 +35,8 @@ struct OpenEditor { /// to another database must not run on the old one. owner: Weak, editor: Arc, + /// Where the transaction stood after the last run that reported it. + transaction: Option, } enum Lookup { @@ -34,6 +45,11 @@ enum Lookup { Missing { stale: Option>, }, + /// The session went with a transaction open; `stale` is it, when it + /// is still to be closed. + RolledBack { + stale: Option>, + }, Closed, } @@ -42,6 +58,9 @@ enum Lookup { pub enum AcquireError { /// The tab closed or the run was stopped while it opened. Cancelled, + /// The session's connection was lost with a transaction open, which + /// the server rolled back. Nothing was opened in its place. + TransactionRolledBack, Failed(String), } @@ -63,6 +82,12 @@ impl EditorSessionSlot { let stale = match self.lookup(session) { Lookup::Ready(editor) => return Ok(editor), Lookup::Closed => return Err(AcquireError::Cancelled), + Lookup::RolledBack { stale } => { + if let Some(stale) = stale { + close_quietly(stale, close_timeout).await; + } + return Err(AcquireError::TransactionRolledBack); + } Lookup::Missing { stale } => stale, }; if let Some(stale) = stale { @@ -90,7 +115,17 @@ impl EditorSessionSlot { if state.closed { return Lookup::Closed; } + if std::mem::take(&mut state.rolled_back) { + return Lookup::RolledBack { + stale: state.open.take().map(|open| open.editor), + }; + } match state.open.take() { + Some(open) if open.editor.state() == EditorSessionState::Lost && open.held_transaction() => { + Lookup::RolledBack { + stale: Some(open.editor), + } + } Some(open) if open.owner.ptr_eq(&Arc::downgrade(owner)) && open.editor.state() != EditorSessionState::Lost => { @@ -104,6 +139,24 @@ impl EditorSessionSlot { } } + /// Where the transaction of `editor` stood after its last run. + pub fn transaction(&self, editor: &Arc) -> Option { + let state = lock(&self.state); + state + .open + .as_ref() + .filter(|open| open.editor.id() == editor.id()) + .and_then(|open| open.transaction) + } + + /// Record where a run left the transaction of `editor`. + pub fn note_transaction(&self, editor: &Arc, transaction: Option) { + let mut state = lock(&self.state); + if let Some(open) = state.open.as_mut().filter(|open| open.editor.id() == editor.id()) { + open.transaction = transaction; + } + } + /// Keep a newly opened session, or hand it back when the tab closed /// while it was opening. fn adopt(&self, owner: &Arc, editor: Arc) -> Result<(), Arc> { @@ -114,17 +167,21 @@ impl EditorSessionSlot { state.open = Some(OpenEditor { owner: Arc::downgrade(owner), editor, + transaction: None, }); Ok(()) } - /// Let go of a session whose connection is gone, so the next run - /// opens a new one. Returns whether this slot held it. - pub fn forget(&self, editor: &Arc) -> bool { + /// Let go of a session whose connection went during a run that left + /// the transaction at `transaction`, as far as is known. The next run + /// opens a new session, or, when a transaction was open, is refused + /// first. Returns whether this slot held it. + pub fn forget(&self, editor: &Arc, transaction: Option) -> bool { let mut state = lock(&self.state); let held = state.open.as_ref().is_some_and(|open| open.editor.id() == editor.id()); if held { state.open = None; + state.rolled_back |= transaction_open(transaction); } held } @@ -148,6 +205,20 @@ impl std::fmt::Debug for EditorSessionSlot { } } +impl OpenEditor { + fn held_transaction(&self) -> bool { + transaction_open(self.transaction) + } +} + +/// A transaction the server rolls back when the connection goes. +pub fn transaction_open(transaction: Option) -> bool { + matches!( + transaction, + Some(TransactionState::InTransaction | TransactionState::Failed) + ) +} + pub async fn close_quietly(editor: Arc, close_timeout: Duration) { if tokio::time::timeout(close_timeout, editor.close()).await.is_err() { tracing::warn!("an editor session did not close in time"); diff --git a/linux/crates/app/src/ui/editor/mod.rs b/linux/crates/app/src/ui/editor/mod.rs index f427c7721a..280b7c1619 100644 --- a/linux/crates/app/src/ui/editor/mod.rs +++ b/linux/crates/app/src/ui/editor/mod.rs @@ -494,7 +494,7 @@ impl SqlEditor { }; ( log.summary(), - transaction_dirty::transaction_label(log.transaction()), + transaction_dirty::transaction_label(log.lost(), log.transaction()), transaction_dirty::transaction_dirty(log.lost(), log.transaction(), log.has_run()), log.first_to_show(), run_history::history_entry(query, metadata, started_at, log), diff --git a/linux/crates/app/src/ui/editor/run_log.rs b/linux/crates/app/src/ui/editor/run_log.rs index 861aeeb35d..cdbde839e3 100644 --- a/linux/crates/app/src/ui/editor/run_log.rs +++ b/linux/crates/app/src/ui/editor/run_log.rs @@ -170,15 +170,21 @@ impl BatchCollector { } } - /// Close the run with how it ended. `error_base` is where the batch - /// starts in the buffer, so a server's error position becomes a - /// buffer position. + /// Close the run with how it ended. `in_transaction` says a + /// transaction was open when the batch was sent. `error_base` is + /// where the batch starts in the buffer, so a server's error position + /// becomes a buffer position. pub fn finish( mut self, outcome: Result, state: EditorSessionState, + in_transaction: bool, error_base: usize, ) -> (Vec, Verdict, Option) { + let lost = SessionLoss { + lost: state == EditorSessionState::Lost, + in_transaction, + }; let (verdict, transaction) = match outcome { Ok(summary) => { let verdict = match summary.end { @@ -191,7 +197,7 @@ impl BatchCollector { Verdict::Completed } RunEnd::Failed(error) => { - self.entries.push(failed(&error, state, error_base)); + self.entries.push(failed(&error, lost, error_base)); Verdict::Failed } RunEnd::Cancelled => { @@ -210,7 +216,7 @@ impl BatchCollector { (Verdict::Stopped, None) } Err(error) => { - self.entries.push(failed(&error, state, error_base)); + self.entries.push(failed(&error, lost, error_base)); (Verdict::Stopped, None) } }; @@ -231,11 +237,25 @@ impl BatchCollector { } } -fn failed(error: &DriverError, state: EditorSessionState, error_base: usize) -> EntryKind { +/// Whether the session's connection went with this batch, and whether +/// a transaction was open on it. +#[derive(Debug, Clone, Copy)] +struct SessionLoss { + lost: bool, + in_transaction: bool, +} + +fn failed(error: &DriverError, lost: SessionLoss, error_base: usize) -> EntryKind { let mut message = crate::ui::error_text::driver_message(error); - if state == EditorSessionState::Lost { - // An editor session is never reopened behind the user's back: - // its open transaction and temporary tables went with it. + // An editor session is never reopened behind the user's back: its + // open transaction and temporary tables went with it. With a + // transaction open, the next run is refused rather than reopening. + if lost.lost && lost.in_transaction { + message = crate::i18n::gettext_f( + "{error} This tab's connection was lost, so the server rolled back its open transaction.", + &[("error", &message)], + ); + } else if lost.lost { message = crate::i18n::gettext_f( "{error} This tab's connection was lost; the next run opens a new one.", &[("error", &message)], @@ -463,7 +483,7 @@ mod tests { for event in events { collector.record(event); } - let (entries, verdict, _) = collector.finish(summary(end), EditorSessionState::Ready, 0); + let (entries, verdict, _) = collector.finish(summary(end), EditorSessionState::Ready, false, 0); (entries, verdict) } @@ -643,6 +663,7 @@ mod tests { let (entries, verdict, _) = collector.finish( summary(RunEnd::Failed(DriverError::reported(diagnostics))), EditorSessionState::Ready, + false, 100, ); diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs index dc486e4e02..e6688b1e35 100644 --- a/linux/crates/app/src/ui/editor/script_runner.rs +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -8,13 +8,14 @@ use tablepro_core::sql_syntax::script::{ BatchErrorPolicy, LexicalSettings, ScriptDiagnostic, ScriptPlan, text_offsets, }; use tablepro_core::{ - EditorSession, EditorSessionState, ResultEvent, ResultSink, RowLimit, RunOptions, SinkFlow, UserSql, + EditorSession, EditorSessionState, ResultEvent, ResultSink, RowLimit, RunOptions, SinkFlow, TransactionState, + UserSql, }; use tablepro_session::{MonitorSignal, Session}; use tokio::sync::mpsc; use tokio_util::sync::CancellationToken; -use super::editor_session_slot::{AcquireError, EditorSessionSlot, close_quietly}; +use super::editor_session_slot::{AcquireError, EditorSessionSlot, close_quietly, transaction_open}; use super::run_log::{BatchCollector, BatchReport, EntryKind, RunEvent, Verdict, continues}; use super::run_scope::ScopedText; @@ -87,13 +88,16 @@ pub fn sql_preview(sql: &str) -> String { } } -/// Send each batch in turn, reporting each one as it ends. +/// Send each batch in turn, reporting each one as it ends. `transaction` +/// is where the session's transaction stood before the run; the return +/// is where it stands after, as far as a connection still there said. pub async fn run_batches( editor: &Arc, run: &ScriptRun, cancel: &CancellationToken, events: &mpsc::Sender, -) { + mut transaction: Option, +) -> Option { 'batches: for (index, batch) in run.batches.iter().enumerate() { for _ in 0..batch.repeat.max(1) { if cancel.is_cancelled() { @@ -112,18 +116,25 @@ pub async fn run_batches( let elapsed_ms = started.elapsed().as_millis(); let collected = std::mem::replace(&mut *lock(&collector), BatchCollector::new(sql)); let state = editor.state(); - let (entries, verdict, transaction) = collected.finish(outcome, state, batch.error_base); + let lost = state == EditorSessionState::Lost; + let (entries, verdict, reported) = + collected.finish(outcome, state, transaction_open(transaction), batch.error_base); + // A lost session's last word describes a connection that is + // gone, not the transaction the user had. + if !lost && reported.is_some() { + transaction = reported; + } let report = BatchReport { batch: index, entries, verdict, - transaction, - lost: state == EditorSessionState::Lost, + transaction: reported, + lost, elapsed_ms, }; if events.send(RunEvent::Batch(report)).await.is_err() { // The tab went away; nobody is left to show the rest. - return; + return transaction; } if !continues(verdict, run.policy) { break 'batches; @@ -131,6 +142,7 @@ pub async fn run_batches( } } let _ = events.send(RunEvent::Finished).await; + transaction } /// Run a script on the tab's editor session, opening one first when @@ -153,33 +165,48 @@ pub async fn run_in_tab( let _ = events.send(RunEvent::Finished).await; return; } + Err(AcquireError::TransactionRolledBack) => { + let message = crate::i18n::gettext( + "This tab's connection was lost, so the server rolled back its open transaction. Nothing was run; run again to open a new connection.", + ); + refuse(&events, message, true).await; + return; + } Err(AcquireError::Failed(message)) => { - let report = BatchReport { - batch: 0, - entries: vec![EntryKind::Failed { - message, - error_char: None, - }], - verdict: Verdict::Stopped, - transaction: None, - lost: false, - elapsed_ms: 0, - }; - let _ = events.send(RunEvent::Batch(report)).await; - let _ = events.send(RunEvent::Finished).await; + refuse(&events, message, false).await; return; } }; - run_batches(&editor, &run, &cancel, &events).await; - if editor.state() == EditorSessionState::Lost { - let generation = *session.link_changes().borrow(); - session.signal(MonitorSignal::ProbeRequested { generation }); - if slot.forget(&editor) { - close_quietly(editor, close_timeout).await; - } + let before = slot.transaction(&editor); + let after = run_batches(&editor, &run, &cancel, &events, before).await; + if editor.state() != EditorSessionState::Lost { + slot.note_transaction(&editor, after); + return; + } + let generation = *session.link_changes().borrow(); + session.signal(MonitorSignal::ProbeRequested { generation }); + if slot.forget(&editor, after) { + close_quietly(editor, close_timeout).await; } } +/// End a run that sent nothing, with `message` as its one line. +async fn refuse(events: &mpsc::Sender, message: String, lost: bool) { + let report = BatchReport { + batch: 0, + entries: vec![EntryKind::Failed { + message, + error_char: None, + }], + verdict: Verdict::Stopped, + transaction: None, + lost, + elapsed_ms: 0, + }; + let _ = events.send(RunEvent::Batch(report)).await; + let _ = events.send(RunEvent::Finished).await; +} + struct CollectingSink { collector: Arc>, } @@ -207,6 +234,7 @@ mod tests { use tablepro_core::{DriverError, LossPhase, RunEnd, TransactionState}; use tablepro_test_support::{FakeEditorSession, FakeRun, Replies}; + use super::super::editor_session_slot::tests::session_opening; use super::super::run_log::RunLog; use super::*; @@ -263,7 +291,7 @@ mod tests { } log }; - let (_, log) = tokio::join!(run_batches(&session, &run, cancel, &events), collect); + let (_, log) = tokio::join!(run_batches(&session, &run, cancel, &events, None), collect); log } @@ -444,6 +472,119 @@ mod tests { assert!(log.lost()); } + /// Run the script the way a tab does, through its slot, and fold + /// every event into a results list. + async fn run_in_slot(session: &Arc, slot: &Arc, text: &str) -> RunLog { + let (run, statements, warnings) = script(text, SqlGrammar::PostgreSql); + let (events, mut received) = mpsc::channel(8); + let running = run_in_tab( + session.clone(), + slot.clone(), + run, + Duration::from_secs(10), + CancellationToken::new(), + events, + ); + let collect = async move { + let mut log = RunLog::new(statements, warnings); + while let Some(event) = received.recv().await { + let finished = matches!(event, RunEvent::Finished); + log.apply(event); + if finished { + break; + } + } + log + }; + let ((), log) = tokio::join!(running, collect); + log + } + + fn first_failure(log: &RunLog) -> String { + match &log.entries()[0].kind { + EntryKind::Failed { message, .. } => message.clone(), + other => panic!("expected a failure: {other:?}"), + } + } + + fn opening(transaction: TransactionState) -> Arc { + let mut runs = Replies::default(); + runs.always(FakeRun::completed(Vec::new()).leaving(transaction)); + Arc::new(FakeEditorSession::new(runs)) + } + + #[tokio::test] + async fn a_session_lost_idle_in_a_transaction_refuses_the_next_run() { + let first = opening(TransactionState::InTransaction); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "BEGIN").await; + first.lose(); + let refused = run_in_slot(&session, &slot, "UPDATE a SET x = 0 WHERE id = 2").await; + let reopened = run_in_slot(&session, &slot, "UPDATE a SET x = 0 WHERE id = 2").await; + + assert!( + first_failure(&refused).contains("rolled back"), + "{:?}", + refused.entries() + ); + assert!(refused.lost()); + assert_eq!(refused.transaction(), None); + assert_eq!( + first.texts(), + [UserSql::new("BEGIN")], + "the lost session ran the statement" + ); + assert_eq!(first.close_count(), 1, "the lost session was left open"); + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("UPDATE a SET x = 0 WHERE id = 2")]); + } + + #[tokio::test] + async fn a_session_lost_mid_run_in_a_transaction_says_so_and_refuses_the_next_run() { + let mut runs = Replies::default(); + runs.once(FakeRun::completed(Vec::new()).leaving(TransactionState::Failed)); + runs.always(FakeRun::session_error(DriverError::ConnectionLost { + during: LossPhase::Statement, + })); + let first = Arc::new(FakeEditorSession::new(runs)); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "BEGIN").await; + let lost = run_in_slot(&session, &slot, "ROLLBACK").await; + let refused = run_in_slot(&session, &slot, "SELECT 1").await; + let reopened = run_in_slot(&session, &slot, "SELECT 1").await; + + let message = first_failure(&lost); + assert!( + message.contains("rolled back") && !message.contains("next run"), + "{message}" + ); + assert!(lost.lost()); + assert!(first_failure(&refused).contains("rolled back")); + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("SELECT 1")]); + } + + #[tokio::test] + async fn a_session_lost_with_no_transaction_reopens_on_the_next_run() { + let first = opening(TransactionState::Idle); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "SELECT 1").await; + first.lose(); + let reopened = run_in_slot(&session, &slot, "SELECT 2").await; + + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("SELECT 2")]); + } + #[test] fn batches_know_where_they_start_in_the_buffer() { let scoped = ScopedText { @@ -468,6 +609,7 @@ mod tests { transaction_state: TransactionState::Idle, }), EditorSessionState::Ready, + false, 0, ); assert_eq!(verdict, Verdict::Stopped); diff --git a/linux/crates/app/src/ui/editor/transaction_dirty.rs b/linux/crates/app/src/ui/editor/transaction_dirty.rs index b99becf925..8e8cca1af4 100644 --- a/linux/crates/app/src/ui/editor/transaction_dirty.rs +++ b/linux/crates/app/src/ui/editor/transaction_dirty.rs @@ -16,7 +16,11 @@ pub fn transaction_dirty(lost: bool, state: Option, has_run: b } /// The line above the results that says a transaction is still open. -pub fn transaction_label(state: Option) -> Option { +/// A lost session has none, whatever its last run said before it went. +pub fn transaction_label(lost: bool, state: Option) -> Option { + if lost { + return None; + } match state { Some(TransactionState::InTransaction) => Some(crate::i18n::gettext("Transaction open")), Some(TransactionState::Failed) => Some(crate::i18n::gettext("Transaction failed, roll back to continue")), @@ -46,9 +50,16 @@ mod tests { #[test] fn only_an_open_or_failed_transaction_is_labelled() { - assert!(transaction_label(Some(TransactionState::InTransaction)).is_some()); - assert!(transaction_label(Some(TransactionState::Failed)).is_some()); - assert_eq!(transaction_label(Some(TransactionState::Idle)), None); - assert_eq!(transaction_label(Some(TransactionState::Unknown)), None); + assert!(transaction_label(false, Some(TransactionState::InTransaction)).is_some()); + assert!(transaction_label(false, Some(TransactionState::Failed)).is_some()); + assert_eq!(transaction_label(false, Some(TransactionState::Idle)), None); + assert_eq!(transaction_label(false, Some(TransactionState::Unknown)), None); + } + + #[test] + fn a_lost_session_shows_no_open_transaction() { + for state in [TransactionState::InTransaction, TransactionState::Failed] { + assert_eq!(transaction_label(true, Some(state)), None, "{state:?}"); + } } } diff --git a/linux/crates/test-support/src/fake_editor_session.rs b/linux/crates/test-support/src/fake_editor_session.rs index d51d1ec195..731505ad38 100644 --- a/linux/crates/test-support/src/fake_editor_session.rs +++ b/linux/crates/test-support/src/fake_editor_session.rs @@ -75,6 +75,12 @@ impl FakeEditorSession { *lock(&self.transaction_state) = state; } + /// The connection goes while no run is on it, as when the server + /// ends a session left idle in a transaction. + pub fn lose(&self) { + *lock(&self.state) = EditorSessionState::Lost; + } + fn begin(&self) -> Result, DriverError> { let mut state = lock(&self.state); match *state { From b0f6260d0ef47ff538f46b612c3adbbf5548a59e Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:13:57 +0700 Subject: [PATCH 04/14] fix(linux-clickhouse): probe each step right before its own statement --- .../drivers/clickhouse/src/write_batch.rs | 61 +++++++++++++------ .../clickhouse/tests/integration/writes.rs | 58 +++++++++++++++++- 2 files changed, 98 insertions(+), 21 deletions(-) diff --git a/linux/crates/drivers/clickhouse/src/write_batch.rs b/linux/crates/drivers/clickhouse/src/write_batch.rs index d71d349869..93a5c2ef5e 100644 --- a/linux/crates/drivers/clickhouse/src/write_batch.rs +++ b/linux/crates/drivers/clickhouse/src/write_batch.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use clickhouse::error::Error as ClickHouseError; use tablepro_core::meta::TableRef; -use tablepro_core::statement::{RowGuard, Statement}; +use tablepro_core::statement::{GuardedStatement, RowGuard, Statement}; use tablepro_core::{ CommitToken, DriverError, ReadOnlyRefusal, SessionFate, StopRequest, WriteBatch, WriteLedger, WriteReport, }; @@ -49,11 +49,14 @@ pub(crate) struct WritePlan { /// Apply the batch statement by statement. /// /// Every probe runs first, so a row that moved stops the save before -/// any mutation is sent. Each statement then carries a query id of its -/// own, kept in the ledger's token, so a statement whose answer never -/// arrived can be looked up in `system.query_log` afterwards. A -/// statement in flight is never stopped: a KILL does not undo a -/// mutation, it only hides what it did. +/// any mutation is sent. A step's probe runs again right before its own +/// statement once an earlier one has landed, since that statement can +/// change what the probe counts: an INSERT of a key a later UPDATE also +/// names would otherwise be changed by the UPDATE too. Each statement +/// carries a query id of its own, kept in the ledger's token, so a +/// statement whose answer never arrived can be looked up in +/// `system.query_log` afterwards. A statement in flight is never +/// stopped: a KILL does not undo a mutation, it only hides what it did. pub(crate) async fn perform( plan: &WritePlan, request: &StopRequest, @@ -62,25 +65,16 @@ pub(crate) async fn perform( let ledger = &plan.ledger; let probes = CallClient::new(plan.probes.clone(), stop.clone(), request.clone()); for (index, step) in plan.batch.steps.iter().enumerate() { - let Some((probe, expected)) = step.guard.probe() else { - continue; - }; - if request.is_requested() { - return Err(ledger.interrupted(stop.interrupted(request), SessionFate::RolledBack)); - } - match count(&probes, probe).await { - Ok(actual) if actual == expected => {} - Ok(actual) => return Err(ledger.guard_failed(index, expected, actual, SessionFate::RolledBack)), - Err(error) if request.is_requested() && acknowledges_stop(&error) => { - return Err(ledger.interrupted(acknowledged(stop.interrupted(request)), SessionFate::RolledBack)); - } - Err(error) => return Err(ledger.failed(index, error, SessionFate::RolledBack)), - } + prove(&probes, ledger, request, stop, index, step).await?; } stop.clear(); let query_ids: Vec = plan.batch.steps.iter().map(|_| Uuid::new_v4()).collect(); let mut report = Vec::with_capacity(plan.batch.steps.len()); for (index, (step, query_id)) in plan.batch.steps.iter().zip(&query_ids).enumerate() { + if index > 0 { + prove(&probes, ledger, request, stop, index, step).await?; + stop.clear(); + } if request.is_requested() { return Err(ledger.interrupted(stop.interrupted(request), SessionFate::Discarded)); } @@ -107,6 +101,33 @@ pub(crate) async fn perform( Ok(WriteReport::new(report)) } +/// 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. +async fn prove( + probes: &CallClient, + ledger: &WriteLedger, + request: &StopRequest, + stop: &ClickHouseStop, + index: usize, + step: &GuardedStatement, +) -> Result<(), DriverError> { + let Some((probe, expected)) = step.guard.probe() else { + return Ok(()); + }; + if request.is_requested() { + return Err(ledger.interrupted(stop.interrupted(request), SessionFate::RolledBack)); + } + match count(probes, probe).await { + Ok(actual) if actual == expected => Ok(()), + Ok(actual) => Err(ledger.guard_failed(index, expected, actual, SessionFate::RolledBack)), + Err(error) if request.is_requested() && acknowledges_stop(&error) => { + Err(ledger.interrupted(acknowledged(stop.interrupted(request)), SessionFate::RolledBack)) + } + Err(error) => Err(ledger.failed(index, error, SessionFate::RolledBack)), + } +} + /// A stop the server acknowledged reads as a timeout it enforced. fn acknowledged(interrupted: DriverError) -> DriverError { match interrupted { diff --git a/linux/crates/drivers/clickhouse/tests/integration/writes.rs b/linux/crates/drivers/clickhouse/tests/integration/writes.rs index f4207dfaca..11da08fdda 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/writes.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/writes.rs @@ -3,6 +3,7 @@ use std::time::Duration; +use tablepro_core::edit::CellInput; use tablepro_core::save::{ChangeSet, RowChange, RowUpdate}; use tablepro_core::{ CommitResolution, CommitToken, Connection, DriverError, ReadOnlyRefusal, RowLimit, RunEnd, ServerCode, @@ -13,7 +14,7 @@ use tokio::time::Instant; use crate::support::{ CALL, DATABASE, admin, apply, batch_on, connect, connect_with, count, direct, flush_logs, options_over, password, - planned, quoted, run, scalar, session, setup, start, statement, table, text, unique, within, + planned, quoted, rows, run, scalar, session, setup, start, statement, table, text, unique, within, }; /// A mutation that takes seconds on a small table, so it cannot finish @@ -97,6 +98,61 @@ async fn duplicate_sorting_key_edit_sends_no_mutation_row_guard_failed() { connection.close().await.expect("a close"); } +/// The probe of a step runs after the steps before it landed, so an +/// insert of a key a later update names makes the update match two rows +/// and stops it, rather than the update changing the new row too. +#[tokio::test] +#[ignore = "requires docker"] +async fn an_insert_then_an_update_of_the_same_key_stops_the_update() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let name = unique("same_key"); + let qualified = quoted(&name); + setup( + connection.as_ref(), + &[ + &format!("CREATE TABLE {qualified} (sensor_id Int32, v String) ENGINE = MergeTree ORDER BY sensor_id"), + &format!("INSERT INTO {qualified} VALUES (5, 'old')"), + ], + ) + .await; + + let changes = ChangeSet { + changes: vec![ + RowChange::Insert { + cells: vec![ + CellInput::Value(Value::Int(5)), + CellInput::Value(Value::Text("new".to_owned())), + ], + }, + RowChange::Update(RowUpdate { + key: vec![Value::Int(5)], + assignments: vec![(1, Value::Text("edited".to_owned()))], + }), + ], + }; + let batch = planned(connection.as_ref(), &table(&name), &changes).await; + let outcome = connection.write_batch(&batch, within(CALL)).await; + + assert!( + matches!( + &outcome, + Err(DriverError::PartiallyApplied { + applied: 1, + statement_index: 1, + failed_outcome: StatementOutcome::Failed, + source, + }) if **source == DriverError::RowGuardFailed { statement_index: 1, expected: 1, actual: 2 } + ), + "{outcome:?}" + ); + assert_eq!( + rows(connection.as_ref(), &format!("SELECT v FROM {qualified} ORDER BY v")).await, + [vec![Value::Text("new".to_owned())], vec![Value::Text("old".to_owned())]] + ); + connection.close().await.expect("a close"); +} + #[tokio::test] #[ignore = "requires docker"] async fn three_inserts_second_fails_partially_applied_applied_1() { From ad5fff98c951b8f94fdda08b4e7bed2341f4ee8a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:14:02 +0700 Subject: [PATCH 05/14] fix(linux-sqlite): stop a key change that would number a column's NULLs --- .../app/src/ui/structure_tab/review_dialog.rs | 22 +++++++++++ linux/crates/core/src/ddl/table_rebuild.rs | 4 ++ linux/crates/core/src/ddl/tests.rs | 1 + .../drivers/sqlite/src/ddl/rebuild_plan.rs | 28 +++++++++++-- linux/crates/drivers/sqlite/src/ddl/tests.rs | 1 + .../drivers/sqlite/tests/integration/ddl.rs | 39 +++++++++++++++++++ 6 files changed, 92 insertions(+), 3 deletions(-) diff --git a/linux/crates/app/src/ui/structure_tab/review_dialog.rs b/linux/crates/app/src/ui/structure_tab/review_dialog.rs index 48e5898ba8..1e7b7ec7d1 100644 --- a/linux/crates/app/src/ui/structure_tab/review_dialog.rs +++ b/linux/crates/app/src/ui/structure_tab/review_dialog.rs @@ -105,6 +105,12 @@ fn rebuild_text(rebuild: &TableRebuild) -> String { if rebuild.renumbers_rowids { sentences.push(crate::i18n::gettext("The rows get new rowids.")); } + if let Some(column) = &rebuild.numbered_column { + sentences.push(crate::i18n::gettext_f( + "“{column}” becomes the rowid, so the save stops if any row has no value in it.", + &[("column", column)], + )); + } sentences.join(" ") } @@ -249,6 +255,7 @@ mod tests { }, ], renumbers_rowids: true, + numbered_column: None, }); assert_eq!( @@ -257,4 +264,19 @@ mod tests { trigger “stamp”, view “big”. The rows get new rowids." ); } + + #[test] + fn a_rebuild_says_a_numbered_column_must_hold_values() { + let text = rebuild_text(&TableRebuild { + recreated: Vec::new(), + renumbers_rowids: true, + numbered_column: Some("code".to_owned()), + }); + + assert_eq!( + text, + "Rebuilds the table: copies its rows into a new table and drops the old one. The rows get new rowids. \ + “code” becomes the rowid, so the save stops if any row has no value in it." + ); + } } diff --git a/linux/crates/core/src/ddl/table_rebuild.rs b/linux/crates/core/src/ddl/table_rebuild.rs index ad813a3891..02d8c1501d 100644 --- a/linux/crates/core/src/ddl/table_rebuild.rs +++ b/linux/crates/core/src/ddl/table_rebuild.rs @@ -10,6 +10,10 @@ pub struct TableRebuild { /// The copied rows get new rowids, so anything outside the schema /// that kept one no longer finds its row. pub renumbers_rowids: bool, + /// A column that becomes the rowid, by its final name. SQLite gives a + /// NULL copied into it a new number, so the plan stops instead when + /// the column holds one. + pub numbered_column: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/linux/crates/core/src/ddl/tests.rs b/linux/crates/core/src/ddl/tests.rs index f7203e0e31..968759bfc7 100644 --- a/linux/crates/core/src/ddl/tests.rs +++ b/linux/crates/core/src/ddl/tests.rs @@ -783,6 +783,7 @@ fn a_dropped_foreign_key_and_a_rebuild_are_named() { name: "active_users".to_owned(), }], renumbers_rowids: false, + numbered_column: None, }; plan.rebuild = Some(rebuild.clone()); diff --git a/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs b/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs index 50bbb358fa..77d1a3c393 100644 --- a/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs +++ b/linux/crates/drivers/sqlite/src/ddl/rebuild_plan.rs @@ -270,6 +270,7 @@ impl Rebuild<'_> { }) .collect(), renumbers_rowids: self.rowids_renumbered(), + numbered_column: self.numbered_column().map(|(_, final_name)| final_name), }); Ok(plan) } @@ -278,6 +279,7 @@ impl Rebuild<'_> { /// from and the one there now: the table's CREATE TABLE, its own /// indexes and triggers, the views and triggers elsewhere the rebuild /// makes again, and the key columns of other tables that point at it. + /// Added to it, the NULLs of a column the rebuild makes the rowid. /// A view or trigger that came since and names the table makes the /// rename refuse on its own. /// @@ -348,15 +350,35 @@ impl Rebuild<'_> { }) .collect(); - let mut builder = StatementBuilder::new(); - builder.push_sql(&format!( + let mut sql = format!( "SELECT {} + {}", differences(&objects_now, &objects), differences(&children_now, &children) - )); + ); + if let Some((column, _)) = self.numbered_column() { + sql.push_str(&format!( + " + (SELECT count(*) FROM {} WHERE {} IS NULL)", + self.dialect.qualify(table), + self.dialect.quote_identifier(column.name()) + )); + } + let mut builder = StatementBuilder::new(); + builder.push_sql(&sql); builder.finish() } + /// A kept column that may hold NULL and becomes the rowid, with its + /// final name. SQLite gives a NULL copied into the rowid a new + /// number rather than refusing it, so the precondition counts them. + fn numbered_column(&self) -> Option<(&ColumnDefinition, String)> { + let alias = self.rowid_alias_after()?; + let column = self.kept(&alias).filter(|column| column.info.nullable)?; + let numbered_before = self + .rowid_alias_before() + .is_some_and(|before| before.eq_ignore_ascii_case(column.name())); + (!numbered_before).then_some((column, alias)) + } + /// The rebuilt table under both its names, and every table whose /// foreign keys point at it, since their rows now point into a new /// table. diff --git a/linux/crates/drivers/sqlite/src/ddl/tests.rs b/linux/crates/drivers/sqlite/src/ddl/tests.rs index a5f6b22bb6..8df802fe29 100644 --- a/linux/crates/drivers/sqlite/src/ddl/tests.rs +++ b/linux/crates/drivers/sqlite/src/ddl/tests.rs @@ -1435,6 +1435,7 @@ fn a_rebuild_names_what_it_drops_and_makes_again() { recreated(SchemaObjectKind::Trigger, "customers_cleanup"), ], renumbers_rowids: false, + numbered_column: None, }) ); assert!(keyed.rebuild.is_some_and(|rebuild| rebuild.renumbers_rowids)); diff --git a/linux/crates/drivers/sqlite/tests/integration/ddl.rs b/linux/crates/drivers/sqlite/tests/integration/ddl.rs index 12a0b5e85a..1ea12c39ca 100644 --- a/linux/crates/drivers/sqlite/tests/integration/ddl.rs +++ b/linux/crates/drivers/sqlite/tests/integration/ddl.rs @@ -1489,3 +1489,42 @@ async fn a_table_only_its_own_key_points_at_is_dropped() { assert_eq!(tables.rows, [vec![text("other")]]); connection.close().await.unwrap(); } + +/// SQLite gives a NULL copied into the rowid the next number instead of +/// refusing it, so a column holding one stays as it is. +#[tokio::test] +async fn an_integer_column_holding_nulls_is_not_made_the_rowid() { + let file = TestFile::new().await; + let connection = file.open().await; + apply( + connection.as_ref(), + &[ + "CREATE TABLE kept (a INTEGER, b TEXT)", + "INSERT INTO kept VALUES (NULL, 'x'), (5, 'y')", + ], + ) + .await + .unwrap(); + + let outcome = save(connection.as_ref(), &kept(), |draft| { + let key = column(draft, "a"); + key.primary_key = true; + key.nullable = false; + }) + .await; + + assert_eq!( + outcome, + Err(DriverError::RowGuardFailed { + statement_index: 0, + expected: 0, + actual: 1 + }) + ); + let rows = query(connection.as_ref(), "SELECT a, b FROM kept ORDER BY b").await; + assert_eq!( + rows.rows, + [vec![Value::Null, text("x")], vec![Value::Int(5), text("y")]] + ); + connection.close().await.unwrap(); +} From 665ee2c2ec3b4d57f6b7c76fb9f002397ab7bd75 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:14:26 +0700 Subject: [PATCH 06/14] fix(linux-sqlite): keep a typed date or time as written in the cell --- .../core/src/edit/parse_literal_text.rs | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/linux/crates/core/src/edit/parse_literal_text.rs b/linux/crates/core/src/edit/parse_literal_text.rs index 743374528d..de10f9041b 100644 --- a/linux/crates/core/src/edit/parse_literal_text.rs +++ b/linux/crates/core/src/edit/parse_literal_text.rs @@ -33,12 +33,28 @@ enum Purpose { fn parse(input: &str, column: &ColumnType, purpose: Purpose) -> Result { match strict(input, column, purpose) { + Ok(_) if column.dynamic_storage && keeps_text_when_valid(&column.kind) => { + Ok(Value::Text(input.trim().to_owned())) + } Ok(value) => Ok(value), Err(_) if column.dynamic_storage && keeps_text_on_failure(&column.kind) => Ok(Value::Text(input.to_owned())), Err(error) => Err(error), } } +/// Whether a value that parsed is still stored as the text typed. +/// +/// A column whose server stores whatever text it is given keeps a date +/// or time in the spelling it was written in, such as SQLite's +/// `2024-01-01T11:00:00`. The parse only checks the text: binding the +/// parsed value would store the app's own spelling instead. +fn keeps_text_when_valid(kind: &ColumnKind) -> bool { + matches!( + kind, + ColumnKind::Date | ColumnKind::Time { .. } | ColumnKind::Timestamp { .. } + ) +} + /// Whether a failed parse should fall back to the text as typed. /// /// Only for kinds whose text form is what the server stores anyway. A @@ -744,4 +760,33 @@ mod tests { assert_eq!(other.text, "10.0.0.0/8"); } + + #[test] + fn a_dynamic_column_keeps_a_valid_date_or_time_as_typed() { + let stamp = ColumnKind::Timestamp { + fractional_digits: None, + zone: TemporalZone::Naive, + }; + + assert_eq!( + parse_literal_text(" 2024-01-01T11:00:00 ", &column(stamp.clone(), true)), + Ok(Value::Text("2024-01-01T11:00:00".to_owned())) + ); + assert_eq!( + parse_literal_text("11:00", &column(time(false, TimeRange::TimeOfDay), true)), + Ok(Value::Text("11:00".to_owned())) + ); + assert_eq!( + parse_literal_text("2024-02-30", &column(ColumnKind::Date, true)), + Err(EditParseError::InvalidDate), + "an invalid date was kept as text" + ); + assert!( + matches!( + parse_literal_text("2024-01-01T11:00:00", &column(stamp, false)), + Ok(Value::Timestamp(_)) + ), + "a declared column lost its typed value" + ); + } } From 5b6cb153d1d5b0f167269b44c2cfd7afaa228702 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:14:38 +0700 Subject: [PATCH 07/14] fix(linux-clickhouse): key and lock grid rows by every key column --- .../src/catalog/clickhouse_column_row.rs | 37 ++++++++++++++++++ .../drivers/clickhouse/src/connection.rs | 2 +- .../clickhouse/tests/integration/metadata.rs | 39 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/linux/crates/drivers/clickhouse/src/catalog/clickhouse_column_row.rs b/linux/crates/drivers/clickhouse/src/catalog/clickhouse_column_row.rs index ac668b2eb8..21221e21d0 100644 --- a/linux/crates/drivers/clickhouse/src/catalog/clickhouse_column_row.rs +++ b/linux/crates/drivers/clickhouse/src/catalog/clickhouse_column_row.rs @@ -66,6 +66,18 @@ impl ClickHouseColumnRow { } } + /// The column as the grid reads a page by: every column of the + /// sorting and partition keys counts as the key. The server refuses + /// an UPDATE of any of them, and the PRIMARY KEY is only a prefix of + /// the sorting key, so naming rows by it alone would offer edits the + /// server refuses and match more rows than the one shown. + pub(crate) fn row_info(&self) -> ColumnInfo { + ColumnInfo { + primary_key: self.in_sorting_key || self.in_partition_key, + ..self.info() + } + } + pub(crate) fn attributes(&self) -> ColumnAttributes { let expression = || SqlExpression::from_catalog_text(self.default_expression.as_str()); let generated = match self.default_kind.as_str() { @@ -125,6 +137,31 @@ mod tests { use super::*; + #[test] + fn a_page_keys_rows_by_the_sorting_and_partition_keys() { + let keyed = |primary: bool, sorting: bool, partition: bool| ClickHouseColumnRow { + in_primary_key: primary, + in_sorting_key: sorting, + in_partition_key: partition, + ..row("c", "Int32", "", "") + }; + + assert!(keyed(true, true, false).row_info().primary_key); + assert!( + keyed(false, true, false).row_info().primary_key, + "a sorting key column past the PRIMARY KEY" + ); + assert!( + keyed(false, false, true).row_info().primary_key, + "a partition key column" + ); + assert!(!keyed(false, false, false).row_info().primary_key); + assert!( + !keyed(false, true, false).info().primary_key, + "the definition's PRIMARY KEY changed" + ); + } + fn row(name: &str, type_text: &str, kind: &str, expression: &str) -> ClickHouseColumnRow { ClickHouseColumnRow { name: name.to_owned(), diff --git a/linux/crates/drivers/clickhouse/src/connection.rs b/linux/crates/drivers/clickhouse/src/connection.rs index 103bacf63a..a45a4dd5c1 100644 --- a/linux/crates/drivers/clickhouse/src/connection.rs +++ b/linux/crates/drivers/clickhouse/src/connection.rs @@ -278,7 +278,7 @@ impl Connection for ClickHouseConnection { let table = self.catalog_table(table); self.workspace_read(options, move |calls| async move { let rows = catalog::column_rows(&calls, &table).await?; - Ok(rows.iter().map(catalog::ClickHouseColumnRow::info).collect()) + Ok(rows.iter().map(catalog::ClickHouseColumnRow::row_info).collect()) }) .await } diff --git a/linux/crates/drivers/clickhouse/tests/integration/metadata.rs b/linux/crates/drivers/clickhouse/tests/integration/metadata.rs index a3db1359c2..1b9788ee3f 100644 --- a/linux/crates/drivers/clickhouse/tests/integration/metadata.rs +++ b/linux/crates/drivers/clickhouse/tests/integration/metadata.rs @@ -92,6 +92,45 @@ async fn catalog_classifies_datetime64_decimal76() { connection.close().await.expect("a close"); } +/// The server refuses an UPDATE of any sorting or partition key column, +/// so the grid locks and keys rows by all of them, past the PRIMARY KEY. +#[tokio::test] +#[ignore = "requires docker"] +async fn every_sorting_and_partition_key_column_keys_the_grid() { + let fixture = start().await; + let connection = connect(&fixture, false).await; + let name = unique("keyed"); + setup( + connection.as_ref(), + &[&format!( + "CREATE TABLE {} (region String, a Int32, b Int32, v String) ENGINE = MergeTree \ + PARTITION BY region ORDER BY (a, b) PRIMARY KEY a", + quoted(&name) + )], + ) + .await; + + let columns = connection + .fetch_columns(&table(&name), within(CALL)) + .await + .expect("the columns"); + let definition = connection + .fetch_table_definition(&table(&name), within(CALL)) + .await + .expect("the definition"); + + let keyed: Vec<(&str, bool)> = columns + .iter() + .map(|column| (column.name.as_str(), column.primary_key)) + .collect(); + assert_eq!(keyed, [("region", true), ("a", true), ("b", true), ("v", false)]); + assert_eq!( + definition.primary_key.map(|key| key.columns), + Some(vec!["a".to_owned()]) + ); + connection.close().await.expect("a close"); +} + #[tokio::test] #[ignore = "requires docker"] async fn fetch_table_definition_reports_keys_comment_and_source() { From 479c5222bc8b781fefbb2cb7d4b91ce689c606b6 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:14:43 +0700 Subject: [PATCH 08/14] docs(linux-clickhouse): say why a save waits for one replica only --- linux/crates/drivers/clickhouse/src/client_factory.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/linux/crates/drivers/clickhouse/src/client_factory.rs b/linux/crates/drivers/clickhouse/src/client_factory.rs index 275615edd1..3ea374d511 100644 --- a/linux/crates/drivers/clickhouse/src/client_factory.rs +++ b/linux/crates/drivers/clickhouse/src/client_factory.rs @@ -42,7 +42,11 @@ const COMMON_SETTINGS: [(&str, &str); 7] = [ // UTF-8 is read back exactly instead of with replacement characters. ("output_format_json_validate_utf8", "0"), // A save waits for its mutation, so the grid refetches what the save - // wrote rather than what was there before. + // wrote rather than what was there before. Not 2, which waits for + // every replica: with one down, each save would wait out + // `replication_wait_for_inactive_replica_timeout`, two minutes by + // default, and then end with UNFINISHED although the live replicas + // applied it. ("mutations_sync", "1"), // A read whose connection the app dropped stops on the server even // when no KILL could be sent. From 6d0599c47dbe0f7a9e18b6f56c8b9b638bce7366 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:14:43 +0700 Subject: [PATCH 09/14] fix(linux-clickhouse): read and edit a Time up to 999 hours --- linux/crates/core/src/column/kind_parts.rs | 17 ++++++++++++ .../core/src/edit/parse_literal_text.rs | 21 ++++++++++----- linux/crates/core/src/value/time.rs | 26 +++++++++++++++---- .../drivers/clickhouse/src/cell_seed.rs | 4 +++ .../drivers/clickhouse/src/clickhouse_type.rs | 4 +-- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/linux/crates/core/src/column/kind_parts.rs b/linux/crates/core/src/column/kind_parts.rs index fae997c62f..e66c42b125 100644 --- a/linux/crates/core/src/column/kind_parts.rs +++ b/linux/crates/core/src/column/kind_parts.rs @@ -1,3 +1,5 @@ +use crate::value::SqlTime; + /// How a boolean column stores its value. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BoolStorage { @@ -29,6 +31,21 @@ pub enum TimeRange { TimeOfDay, /// MySQL TIME, which runs from -838:59:59 to 838:59:59. Duration, + /// ClickHouse Time and Time64, which run to 999:59:59 either side of + /// zero. + LongDuration, +} + +impl TimeRange { + /// Whether a column of this range can hold `time`. + pub fn holds(self, time: &SqlTime) -> bool { + match self { + Self::TimeOfDay => time.to_time_of_day().is_some(), + Self::Duration => time.within_hours(838), + // A `SqlTime` reaches no further than ClickHouse's Time64. + Self::LongDuration => true, + } + } } /// How a timestamp column relates to a time zone. diff --git a/linux/crates/core/src/edit/parse_literal_text.rs b/linux/crates/core/src/edit/parse_literal_text.rs index de10f9041b..cbe0bdc586 100644 --- a/linux/crates/core/src/edit/parse_literal_text.rs +++ b/linux/crates/core/src/edit/parse_literal_text.rs @@ -95,9 +95,9 @@ fn strict(input: &str, column: &ColumnType, purpose: Purpose) -> Result parse_date(trimmed), ColumnKind::Time { with_offset: true, .. } => parse_time_with_offset(trimmed), ColumnKind::Time { - range: TimeRange::Duration, + range: range @ (TimeRange::Duration | TimeRange::LongDuration), .. - } => parse_duration(trimmed), + } => parse_duration(trimmed, *range), ColumnKind::Time { .. } => parse_time(trimmed), ColumnKind::Timestamp { zone: TemporalZone::Offset, @@ -250,13 +250,17 @@ fn parse_time(input: &str) -> Result { Err(EditParseError::InvalidTime) } -/// MySQL TIME, which measures an amount of time and so runs past 24 -/// hours and below zero. -fn parse_duration(input: &str) -> Result { - input.parse::().map(Value::Time).map_err(|error| match error { +/// MySQL TIME or ClickHouse Time, which measure an amount of time and so +/// run past 24 hours and below zero, each to its own bound. +fn parse_duration(input: &str, range: TimeRange) -> Result { + let time = input.parse::().map_err(|error| match error { TimeRangeError::OutOfRange => EditParseError::TimeOutOfRange, TimeRangeError::InvalidComponent | TimeRangeError::InvalidSyntax => EditParseError::InvalidTime, - }) + })?; + match range.holds(&time) { + true => Ok(Value::Time(time)), + false => Err(EditParseError::TimeOutOfRange), + } } fn parse_timestamp(input: &str) -> Result { @@ -745,6 +749,9 @@ mod tests { Err(EditParseError::TimeOutOfRange) ); assert_eq!(parse(duration, "noon"), Err(EditParseError::InvalidTime)); + let long = time(false, TimeRange::LongDuration); + assert!(matches!(parse(long.clone(), "-999:59:59"), Ok(Value::Time(_)))); + assert_eq!(parse(long, "1000:00:00"), Err(EditParseError::TimeOutOfRange)); assert_eq!( parse(time(false, TimeRange::TimeOfDay), "25:00:00"), Err(EditParseError::InvalidTime) diff --git a/linux/crates/core/src/value/time.rs b/linux/crates/core/src/value/time.rs index 5ed0e330de..48b4e0a5ba 100644 --- a/linux/crates/core/src/value/time.rs +++ b/linux/crates/core/src/value/time.rs @@ -4,8 +4,11 @@ use chrono::{NaiveTime, Timelike}; use thiserror::Error; const NANOS_PER_SECOND: u64 = 1_000_000_000; -const MAX_HOURS: u32 = 838; -const MAX_MAGNITUDE_NANOS: u128 = (838 * 3600 + 59 * 60 + 59) * NANOS_PER_SECOND as u128; +/// The widest any engine keeps: ClickHouse Time64, to 999:59:59 and a +/// fraction either side of zero. A column's own range is narrower where +/// its `TimeRange` says so. +const MAX_HOURS: u32 = 999; +const MAX_MAGNITUDE_NANOS: u128 = (999 * 3600 + 59 * 60 + 60) * NANOS_PER_SECOND as u128 - 1; #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct SqlTime { @@ -20,7 +23,7 @@ pub struct SqlTime { pub enum TimeRangeError { #[error("minutes and seconds must be below 60 and fractions below one second")] InvalidComponent, - #[error("the time is outside -838:59:59 to 838:59:59")] + #[error("the time is outside -999:59:59 to 999:59:59")] OutOfRange, #[error("the time is not in H:MM[:SS[.fraction]] form")] InvalidSyntax, @@ -101,6 +104,13 @@ impl SqlTime { text } + /// Whether the time lies within `hours`:59:59 either side of zero, + /// with no fraction past either end. + pub fn within_hours(&self, hours: u32) -> bool { + let seconds = u128::from(hours) * 3600 + 59 * 60 + 59; + self.magnitude_nanos() <= seconds * u128::from(NANOS_PER_SECOND) + } + fn magnitude_nanos(&self) -> u128 { let seconds = u128::from(self.hours) * 3600 + u128::from(self.minutes) * 60 + u128::from(self.seconds); seconds * u128::from(NANOS_PER_SECOND) + u128::from(self.nanos) @@ -163,8 +173,14 @@ mod tests { let lowest: SqlTime = "-838:59:59".parse().unwrap(); assert_eq!(lowest.total_micros(), Some(-3_020_399_000_000)); assert_eq!(SqlTime::from_micros(-3_020_399_000_000), Ok(lowest)); - assert_eq!("839:00:00".parse::(), Err(TimeRangeError::OutOfRange)); - assert_eq!("838:59:59.5".parse::(), Err(TimeRangeError::OutOfRange)); + assert!(lowest.within_hours(838)); + assert!(!"839:00:00".parse::().unwrap().within_hours(838)); + assert!(!"838:59:59.5".parse::().unwrap().within_hours(838)); + assert_eq!( + "-999:59:59.999999999".parse::().map(|time| time.format(None)), + Ok("-999:59:59.999999999".to_owned()) + ); + assert_eq!("1000:00:00".parse::(), Err(TimeRangeError::OutOfRange)); assert_eq!("12:60".parse::(), Err(TimeRangeError::InvalidComponent)); assert_eq!("12:5".parse::(), Err(TimeRangeError::InvalidSyntax)); } diff --git a/linux/crates/drivers/clickhouse/src/cell_seed.rs b/linux/crates/drivers/clickhouse/src/cell_seed.rs index 744ac9c4fb..a3d88276ae 100644 --- a/linux/crates/drivers/clickhouse/src/cell_seed.rs +++ b/linux/crates/drivers/clickhouse/src/cell_seed.rs @@ -406,6 +406,10 @@ mod tests { read(&CellPlan::Time, b"\"-100:00:01.5\""), Value::Time(SqlTime::new(true, 100, 0, 1, 500_000_000).expect("a time")) ); + assert_eq!( + read(&CellPlan::Time, b"\"-999:59:59.999\""), + Value::Time(SqlTime::new(true, 999, 59, 59, 999_000_000).expect("the longest ClickHouse time")) + ); assert!(matches!(read(&CellPlan::Date, b"\"not a date\""), Value::Other(_))); } diff --git a/linux/crates/drivers/clickhouse/src/clickhouse_type.rs b/linux/crates/drivers/clickhouse/src/clickhouse_type.rs index acc05596f5..59b8bbd22b 100644 --- a/linux/crates/drivers/clickhouse/src/clickhouse_type.rs +++ b/linux/crates/drivers/clickhouse/src/clickhouse_type.rs @@ -146,12 +146,12 @@ fn kind_of(node: &DataTypeNode) -> ColumnKind { DataTypeNode::Time => ColumnKind::Time { fractional_digits: Some(0), with_offset: false, - range: TimeRange::Duration, + range: TimeRange::LongDuration, }, DataTypeNode::Time64(precision) => ColumnKind::Time { fractional_digits: Some(digits(precision)), with_offset: false, - range: TimeRange::Duration, + range: TimeRange::LongDuration, }, DataTypeNode::IPv4 => ColumnKind::Network(NetworkFamily::Ipv4), DataTypeNode::IPv6 => ColumnKind::Network(NetworkFamily::Ipv6), From fa3f6e8a41a0c3a6cf6baf494fa35737d3315a02 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:14:46 +0700 Subject: [PATCH 10/14] fix(linux-net): report a trust store failure apart from a client cert --- linux/crates/net/src/tls/client_config.rs | 15 ++++++++------ linux/crates/net/src/tls/setup_error.rs | 8 ++++++- linux/crates/net/tests/empty_trust_store.rs | 23 +++++++++++++++++++++ 3 files changed, 39 insertions(+), 7 deletions(-) create mode 100644 linux/crates/net/tests/empty_trust_store.rs diff --git a/linux/crates/net/src/tls/client_config.rs b/linux/crates/net/src/tls/client_config.rs index 80c439b5bf..43cd4c40ca 100644 --- a/linux/crates/net/src/tls/client_config.rs +++ b/linux/crates/net/src/tls/client_config.rs @@ -18,20 +18,23 @@ pub fn build_client_config(tls: &TlsConfig) -> Result>, Some(path) => read_certificates(path)?, None => Vec::new(), }; - Arc::new(rustls_platform_verifier::Verifier::new_with_extra_roots( - extra_roots, - Arc::clone(&provider), - )?) + Arc::new( + rustls_platform_verifier::Verifier::new_with_extra_roots(extra_roots, Arc::clone(&provider)) + .map_err(TlsSetupError::Setup)?, + ) } }; let builder = ClientConfig::builder_with_provider(provider) - .with_protocol_versions(rustls::DEFAULT_VERSIONS)? + .with_protocol_versions(rustls::DEFAULT_VERSIONS) + .map_err(TlsSetupError::Setup)? .dangerous() .with_custom_certificate_verifier(verifier); let config = match &tls.client_identity { Some(identity) => { let (certificates, key) = read_identity(identity)?; - builder.with_client_auth_cert(certificates, key)? + builder + .with_client_auth_cert(certificates, key) + .map_err(TlsSetupError::Rustls)? } None => builder.with_no_client_auth(), }; diff --git a/linux/crates/net/src/tls/setup_error.rs b/linux/crates/net/src/tls/setup_error.rs index d79e5ba6d6..c0e7281abc 100644 --- a/linux/crates/net/src/tls/setup_error.rs +++ b/linux/crates/net/src/tls/setup_error.rs @@ -5,8 +5,14 @@ use thiserror::Error; pub enum TlsSetupError { #[error(transparent)] Config(#[from] ConfigError), + /// rustls refused the client certificate or its key. #[error("TLS setup failed: {0}")] - Rustls(#[from] rustls::Error), + Rustls(rustls::Error), + /// TLS could not be set up for a reason other than the client + /// certificate, as when the system trust store holds no certificate + /// authority. + #[error("TLS setup failed: {0}")] + Setup(rustls::Error), #[error("could not encode a certificate as PEM: {0}")] PemEncoding(pem_rfc7468::Error), } diff --git a/linux/crates/net/tests/empty_trust_store.rs b/linux/crates/net/tests/empty_trust_store.rs new file mode 100644 index 0000000000..41b130f428 --- /dev/null +++ b/linux/crates/net/tests/empty_trust_store.rs @@ -0,0 +1,23 @@ +//! A system trust store with no certificate authority in it. The case +//! has a binary of its own because it points the whole process's trust +//! store at an empty file. + +use tablepro_core::TlsConfig; +use tablepro_net::tls::{TlsSetupError, build_client_config}; + +#[test] +fn an_empty_trust_store_is_not_a_client_certificate_problem() { + let dir = tempfile::tempdir().unwrap(); + let empty = dir.path().join("empty.pem"); + std::fs::write(&empty, "").unwrap(); + // SAFETY: the only test in this binary, so no other thread reads the + // environment while it changes. + unsafe { + std::env::set_var("SSL_CERT_FILE", &empty); + std::env::remove_var("SSL_CERT_DIR"); + } + + let outcome = build_client_config(&TlsConfig::verify_full()); + + assert!(matches!(outcome, Err(TlsSetupError::Setup(_))), "{outcome:?}"); +} From 4cc8896654ad2f15fd07c1eafa6347b81daf23be Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 16:56:37 +0700 Subject: [PATCH 11/14] fix(linux-editor): refuse a run only after a loss nothing announced --- .../app/src/ui/editor/editor_session_slot.rs | 18 ++--- .../crates/app/src/ui/editor/script_runner.rs | 78 +++++++++++++++---- .../test-support/src/fake_editor_session.rs | 4 + linux/crates/test-support/src/fake_run.rs | 11 +++ 4 files changed, 88 insertions(+), 23 deletions(-) diff --git a/linux/crates/app/src/ui/editor/editor_session_slot.rs b/linux/crates/app/src/ui/editor/editor_session_slot.rs index e2c993a2be..5e18a149e8 100644 --- a/linux/crates/app/src/ui/editor/editor_session_slot.rs +++ b/linux/crates/app/src/ui/editor/editor_session_slot.rs @@ -13,9 +13,9 @@ use tokio_util::sync::CancellationToken; /// /// A session whose connection went with a transaction open is never /// replaced on the same run: the next statement would commit on its -/// own, outside the transaction the user thinks is still open. That run -/// is refused with the reason, and only the one after it opens a new -/// session. +/// own, outside the transaction the user thinks is still open. Unless +/// the run that lost it already said so, that run is refused with the +/// reason, and only the one after it opens a new session. #[derive(Default)] pub struct EditorSessionSlot { state: Mutex, @@ -172,16 +172,16 @@ impl EditorSessionSlot { Ok(()) } - /// Let go of a session whose connection went during a run that left - /// the transaction at `transaction`, as far as is known. The next run - /// opens a new session, or, when a transaction was open, is refused - /// first. Returns whether this slot held it. - pub fn forget(&self, editor: &Arc, transaction: Option) -> bool { + /// Let go of a session whose connection went during a run. The next + /// run opens a new session, or with `refuse_next`, when a transaction + /// went with the connection unannounced, is refused first. Returns + /// whether this slot held it. + pub fn forget(&self, editor: &Arc, refuse_next: bool) -> bool { let mut state = lock(&self.state); let held = state.open.as_ref().is_some_and(|open| open.editor.id() == editor.id()); if held { state.open = None; - state.rolled_back |= transaction_open(transaction); + state.rolled_back |= refuse_next; } held } diff --git a/linux/crates/app/src/ui/editor/script_runner.rs b/linux/crates/app/src/ui/editor/script_runner.rs index e6688b1e35..4e4097eadc 100644 --- a/linux/crates/app/src/ui/editor/script_runner.rs +++ b/linux/crates/app/src/ui/editor/script_runner.rs @@ -88,16 +88,30 @@ pub fn sql_preview(sql: &str) -> String { } } +/// Where a run left the session. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct AfterRun { + /// Where the transaction stands, as far as a connection still there + /// said. + pub transaction: Option, + /// The run lost the connection with a transaction open, and its + /// results already say the transaction was rolled back. + pub rollback_told: bool, +} + /// Send each batch in turn, reporting each one as it ends. `transaction` -/// is where the session's transaction stood before the run; the return -/// is where it stands after, as far as a connection still there said. +/// is where the session's transaction stood before the run. pub async fn run_batches( editor: &Arc, run: &ScriptRun, cancel: &CancellationToken, events: &mpsc::Sender, - mut transaction: Option, -) -> Option { + transaction: Option, +) -> AfterRun { + let mut after = AfterRun { + transaction, + rollback_told: false, + }; 'batches: for (index, batch) in run.batches.iter().enumerate() { for _ in 0..batch.repeat.max(1) { if cancel.is_cancelled() { @@ -117,13 +131,15 @@ pub async fn run_batches( let collected = std::mem::replace(&mut *lock(&collector), BatchCollector::new(sql)); let state = editor.state(); let lost = state == EditorSessionState::Lost; - let (entries, verdict, reported) = - collected.finish(outcome, state, transaction_open(transaction), batch.error_base); + let in_transaction = transaction_open(after.transaction); + let (entries, verdict, reported) = collected.finish(outcome, state, in_transaction, batch.error_base); // A lost session's last word describes a connection that is // gone, not the transaction the user had. if !lost && reported.is_some() { - transaction = reported; + after.transaction = reported; } + after.rollback_told |= + lost && in_transaction && entries.iter().any(|entry| matches!(entry, EntryKind::Failed { .. })); let report = BatchReport { batch: index, entries, @@ -134,7 +150,7 @@ pub async fn run_batches( }; if events.send(RunEvent::Batch(report)).await.is_err() { // The tab went away; nobody is left to show the rest. - return transaction; + return after; } if !continues(verdict, run.policy) { break 'batches; @@ -142,7 +158,7 @@ pub async fn run_batches( } } let _ = events.send(RunEvent::Finished).await; - transaction + after } /// Run a script on the tab's editor session, opening one first when @@ -180,12 +196,13 @@ pub async fn run_in_tab( let before = slot.transaction(&editor); let after = run_batches(&editor, &run, &cancel, &events, before).await; if editor.state() != EditorSessionState::Lost { - slot.note_transaction(&editor, after); + slot.note_transaction(&editor, after.transaction); return; } let generation = *session.link_changes().borrow(); session.signal(MonitorSignal::ProbeRequested { generation }); - if slot.forget(&editor, after) { + let refuse_next = transaction_open(after.transaction) && !after.rollback_told; + if slot.forget(&editor, refuse_next) { close_quietly(editor, close_timeout).await; } } @@ -543,7 +560,7 @@ mod tests { } #[tokio::test] - async fn a_session_lost_mid_run_in_a_transaction_says_so_and_refuses_the_next_run() { + async fn a_session_lost_mid_run_in_a_transaction_says_so_and_the_next_run_reopens() { let mut runs = Replies::default(); runs.once(FakeRun::completed(Vec::new()).leaving(TransactionState::Failed)); runs.always(FakeRun::session_error(DriverError::ConnectionLost { @@ -556,7 +573,6 @@ mod tests { run_in_slot(&session, &slot, "BEGIN").await; let lost = run_in_slot(&session, &slot, "ROLLBACK").await; - let refused = run_in_slot(&session, &slot, "SELECT 1").await; let reopened = run_in_slot(&session, &slot, "SELECT 1").await; let message = first_failure(&lost); @@ -565,11 +581,45 @@ mod tests { "{message}" ); assert!(lost.lost()); - assert!(first_failure(&refused).contains("rolled back")); assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); assert_eq!(second.texts(), [UserSql::new("SELECT 1")]); } + /// A stop the server never acknowledged gives the connection up, and + /// the run's results say only that it timed out. + #[tokio::test] + async fn a_stop_that_gives_up_a_session_in_a_transaction_refuses_the_next_run() { + let mut runs = Replies::default(); + runs.once(FakeRun::completed(Vec::new()).leaving(TransactionState::InTransaction)); + runs.always( + FakeRun { + end: Ok(RunEnd::TimedOut { + server_cancelled: false, + }), + ..FakeRun::completed(Vec::new()) + } + .losing_the_session(), + ); + let first = Arc::new(FakeEditorSession::new(runs)); + let second = Arc::new(FakeEditorSession::new(answering())); + let (session, _) = session_opening(vec![first.clone(), second.clone()]); + let slot = Arc::new(EditorSessionSlot::new()); + + run_in_slot(&session, &slot, "BEGIN").await; + let stopped = run_in_slot(&session, &slot, "UPDATE a SET x = 0").await; + let refused = run_in_slot(&session, &slot, "UPDATE a SET x = 1").await; + let reopened = run_in_slot(&session, &slot, "UPDATE a SET x = 1").await; + + assert!(matches!(stopped.entries()[0].kind, EntryKind::TimedOut { .. })); + assert!( + first_failure(&refused).contains("rolled back"), + "{:?}", + refused.entries() + ); + assert!(matches!(reopened.entries()[0].kind, EntryKind::Command { .. })); + assert_eq!(second.texts(), [UserSql::new("UPDATE a SET x = 1")]); + } + #[tokio::test] async fn a_session_lost_with_no_transaction_reopens_on_the_next_run() { let first = opening(TransactionState::Idle); diff --git a/linux/crates/test-support/src/fake_editor_session.rs b/linux/crates/test-support/src/fake_editor_session.rs index 731505ad38..b53d4666f8 100644 --- a/linux/crates/test-support/src/fake_editor_session.rs +++ b/linux/crates/test-support/src/fake_editor_session.rs @@ -163,7 +163,11 @@ impl EditorSession for FakeEditorSession { "FakeEditorSession has no scripted run".to_owned(), )); }; + let loses_session = run.loses_session; let end = self.play(run, &options, sink.as_mut()).await; + if loses_session { + *lock(&self.state) = EditorSessionState::Lost; + } match &end { Ok(RunEnd::Cancelled | RunEnd::TimedOut { .. }) => { self.stopped_runs.fetch_add(1, Ordering::SeqCst); diff --git a/linux/crates/test-support/src/fake_run.rs b/linux/crates/test-support/src/fake_run.rs index ddc11e6228..92b9377bcb 100644 --- a/linux/crates/test-support/src/fake_run.rs +++ b/linux/crates/test-support/src/fake_run.rs @@ -21,6 +21,9 @@ pub struct FakeRun { /// error; `Ok` is how the statements ended. pub end: Result, pub transaction_state: TransactionState, + /// The connection is given up as the run ends, as a driver does with + /// one whose stopped statement never acknowledged the stop. + pub loses_session: bool, } impl FakeRun { @@ -30,6 +33,7 @@ impl FakeRun { wait: RunWait::None, end: Ok(RunEnd::Completed), transaction_state: TransactionState::Idle, + loses_session: false, } } @@ -70,4 +74,11 @@ impl FakeRun { ..self } } + + pub fn losing_the_session(self) -> Self { + Self { + loses_session: true, + ..self + } + } } From c80c0ca90ee7d101b6024c7219a829f5ea1f6130 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 18:45:37 +0700 Subject: [PATCH 12/14] chore(linux): regenerate the translation template --- linux/po/tablepro.pot | 46 +++++++++++++++++++++++++++++++++++++------ 1 file changed, 40 insertions(+), 6 deletions(-) diff --git a/linux/po/tablepro.pot b/linux/po/tablepro.pot index 7547cbe495..4536cf87ef 100644 --- a/linux/po/tablepro.pot +++ b/linux/po/tablepro.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: tablepro 0.1.0\n" "Report-Msgid-Bugs-To: https://github.com/TableProApp/TablePro/issues\n" -"POT-Creation-Date: 2026-10-17 09:29+0000\n" +"POT-Creation-Date: 2026-10-17 11:42+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -894,6 +894,13 @@ msgstr "" msgid "The statement ran past the time limit set in Preferences." msgstr "" +#: crates/app/src/ui/editor/run_log.rs +#, rust-format +msgid "" +"{error} This tab's connection was lost, so the server rolled back its open " +"transaction." +msgstr "" + #: crates/app/src/ui/editor/run_log.rs #, rust-format msgid "{error} This tab's connection was lost; the next run opens a new one." @@ -913,6 +920,12 @@ msgid_plural "{n} failed" msgstr[0] "" msgstr[1] "" +#: crates/app/src/ui/editor/script_runner.rs +msgid "" +"This tab's connection was lost, so the server rolled back its open " +"transaction. Nothing was run; run again to open a new connection." +msgstr "" + #: crates/app/src/ui/editor/sql_file.rs msgid "Only regular files can be opened in the editor." msgstr "" @@ -3468,6 +3481,13 @@ msgstr "" msgid "The rows get new rowids." msgstr "" +#: crates/app/src/ui/structure_tab/review_dialog.rs +#, rust-format +msgid "" +"“{column}” becomes the rowid, so the save stops if any row has no value in " +"it." +msgstr "" + #: crates/app/src/ui/structure_tab/review_dialog.rs #, rust-format msgid "index “{name}”" @@ -3772,6 +3792,17 @@ msgstr "" msgid "Tables on this database are created in the SQL editor." msgstr "" +#: crates/app/src/ui/workspace/structure.rs +msgid "Cannot Drop Table" +msgstr "" + +#: crates/app/src/ui/workspace/structure.rs +#, rust-format +msgid "" +"Foreign keys on other tables point at {table}: {keys}. Drop those foreign " +"keys first." +msgstr "" + #: crates/app/src/ui/workspace/structure.rs #, rust-format msgid "Drop {table}?" @@ -3787,10 +3818,6 @@ msgstr "" msgid "Drop" msgstr "" -#: crates/app/src/ui/workspace/structure.rs -msgid "Cannot Drop Table" -msgstr "" - #: crates/app/src/ui/workspace/structure.rs #: crates/app/src/workspace/save_rejection.rs msgid "No active connection." @@ -3800,6 +3827,13 @@ msgstr "" msgid "Drop Failed" msgstr "" +#: crates/app/src/ui/workspace/structure.rs +#, rust-format +msgid "" +"A foreign key on another table points at {table}, so it was not dropped. " +"Drop that foreign key first." +msgstr "" + #: crates/app/src/ui/workspace/structure.rs #, rust-format msgid "Table {table} has no columns." @@ -4286,7 +4320,7 @@ msgid "Verify TLS, keep connections read-only, and reconnect on drop" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in -msgid "Export results to CSV, TSV, JSON and SQL" +msgid "Export results to CSV, JSON, SQL, Excel, Markdown, HTML and XML" msgstr "" #: data/app.tablepro.TablePro.metainfo.xml.in.in From 6859ff0d7203b9a2baab32027a14e222d0af2c5b Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 22:21:59 +0700 Subject: [PATCH 13/14] fix(linux): pass the table and generated columns to the array export test --- .../crates/drivers/postgres/tests/integration/decode.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/linux/crates/drivers/postgres/tests/integration/decode.rs b/linux/crates/drivers/postgres/tests/integration/decode.rs index 00c388f41f..e498735747 100644 --- a/linux/crates/drivers/postgres/tests/integration/decode.rs +++ b/linux/crates/drivers/postgres/tests/integration/decode.rs @@ -176,8 +176,13 @@ async fn an_exported_array_inserts_back_as_it_was() { .await .expect("the read"); - let insert = - tablepro_core::export::render_sql_insert(connection.dialect().as_ref(), "copied", &read.columns, &read.rows); + let insert = tablepro_core::export::render_sql_insert( + connection.dialect().as_ref(), + &tablepro_core::TableRef::unqualified("copied"), + &read.columns, + &[], + &read.rows, + ); crate::support::apply( connection.as_ref(), tablepro_core::WriteKind::RowChanges, From 7dc519cdea0d75b8ac5d4fa0d5f967c1fe998593 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Sat, 17 Oct 2026 22:25:46 +0700 Subject: [PATCH 14/14] chore(linux): regenerate the translation template --- linux/po/tablepro.pot | 44 ++++++++++++++++++++++++++++++++++++++----- 1 file changed, 39 insertions(+), 5 deletions(-) diff --git a/linux/po/tablepro.pot b/linux/po/tablepro.pot index e0a927bfdc..27434b5918 100644 --- a/linux/po/tablepro.pot +++ b/linux/po/tablepro.pot @@ -8,7 +8,7 @@ msgid "" msgstr "" "Project-Id-Version: tablepro 0.1.0\n" "Report-Msgid-Bugs-To: https://github.com/TableProApp/TablePro/issues\n" -"POT-Creation-Date: 2026-10-17 11:45+0000\n" +"POT-Creation-Date: 2026-10-17 15:23+0000\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -967,6 +967,13 @@ msgstr "" msgid "The statement ran past the time limit set in Preferences." msgstr "" +#: crates/app/src/ui/editor/run_log.rs +#, rust-format +msgid "" +"{error} This tab's connection was lost, so the server rolled back its open " +"transaction." +msgstr "" + #: crates/app/src/ui/editor/run_log.rs #, rust-format msgid "{error} This tab's connection was lost; the next run opens a new one." @@ -986,6 +993,12 @@ msgid_plural "{n} failed" msgstr[0] "" msgstr[1] "" +#: crates/app/src/ui/editor/script_runner.rs +msgid "" +"This tab's connection was lost, so the server rolled back its open " +"transaction. Nothing was run; run again to open a new connection." +msgstr "" + #: crates/app/src/ui/editor/sql_file.rs msgid "Only regular files can be opened in the editor." msgstr "" @@ -3628,6 +3641,13 @@ msgstr "" msgid "The rows get new rowids." msgstr "" +#: crates/app/src/ui/structure_tab/review_dialog.rs +#, rust-format +msgid "" +"“{column}” becomes the rowid, so the save stops if any row has no value in " +"it." +msgstr "" + #: crates/app/src/ui/structure_tab/review_dialog.rs #, rust-format msgid "index “{name}”" @@ -3990,6 +4010,17 @@ msgstr "" msgid "Tables on this database are created in the SQL editor." msgstr "" +#: crates/app/src/ui/workspace/structure.rs +msgid "Cannot Drop Table" +msgstr "" + +#: crates/app/src/ui/workspace/structure.rs +#, rust-format +msgid "" +"Foreign keys on other tables point at {table}: {keys}. Drop those foreign " +"keys first." +msgstr "" + #: crates/app/src/ui/workspace/structure.rs #, rust-format msgid "Drop {table}?" @@ -4005,10 +4036,6 @@ msgstr "" msgid "Drop" msgstr "" -#: crates/app/src/ui/workspace/structure.rs -msgid "Cannot Drop Table" -msgstr "" - #: crates/app/src/ui/workspace/structure.rs #: crates/app/src/workspace/save_rejection.rs msgid "No active connection." @@ -4018,6 +4045,13 @@ msgstr "" msgid "Drop Failed" msgstr "" +#: crates/app/src/ui/workspace/structure.rs +#, rust-format +msgid "" +"A foreign key on another table points at {table}, so it was not dropped. " +"Drop that foreign key first." +msgstr "" + #: crates/app/src/ui/workspace/structure.rs #, rust-format msgid "Table {table} has no columns."