From 32db904fa47d78523e0f854772be5936234c4684 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Wed, 21 Oct 2026 11:02:11 +0200 Subject: [PATCH] feat: stage and atomically apply row edits --- Cargo.toml | 2 +- TODO.md | 4 +- src/app.rs | 771 ++++++++++++++++++++++++++++++++++++++++++++-- src/connection.rs | 409 ++++++++++++++++++++---- src/db.rs | 645 +++++++++++++++++++++++++++++++++++--- src/db/sqlite.rs | 414 ++++++++++++++++++++++--- src/main.rs | 30 +- 7 files changed, 2092 insertions(+), 183 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 54556f5..9e85911 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "dbdash" version = "0.1.0" edition = "2024" -description = "Read-only PostgreSQL and SQLite inspection in your terminal" +description = "PostgreSQL and SQLite terminal browser with opt-in staged editing" [dependencies] ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } diff --git a/TODO.md b/TODO.md index f1ef5de..cd2e63e 100644 --- a/TODO.md +++ b/TODO.md @@ -9,4 +9,6 @@ - should be able to copy without row names (`Y` keybinding) - `Ym` should copy to markdown format - `Yc` should copy to csv format -- [ ] add ability to update values from the row view \ No newline at end of file +- [x] add ability to update values from the row view + - edits remain drafts across pages; Ctrl-Enter / F5 applies the table's changes atomically + - explicit NULL, conflict detection, and discard/cancel guards protect unsaved work \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 3805590..1b39ab8 100644 --- a/src/app.rs +++ b/src/app.rs @@ -4,13 +4,13 @@ use ratatui::{ layout::{Constraint, Layout, Rect}, style::{Color, Modifier, Style}, text::Line, - widgets::{Block, Cell, Paragraph, Row, Table, TableState}, + widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState, Wrap}, }; use crate::{ Result, connection::Input, - db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation}, + db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation, RowEdit, RowSnapshot}, error_text, sql, }; @@ -21,6 +21,22 @@ pub(crate) enum Focus { Rows, } +struct CellEditor { + original: RowSnapshot, + column: usize, + input: Input, + null: bool, +} + +#[derive(Clone, Copy)] +enum DiscardAction { + Refresh, + Relation(Option), + Query, + Discard, + External, +} + pub struct App { db: Database, pub(crate) relations: Vec, @@ -41,6 +57,11 @@ pub struct App { query_highlights: sql::Highlights, query_result: Option, error: Option, + snapshots: Vec, + editable: bool, + drafts: Vec, + editor: Option, + discard: Option, } impl App { @@ -70,6 +91,11 @@ impl App { query_highlights: Vec::new(), query_result: None, error: None, + snapshots: Vec::new(), + editable: false, + drafts: Vec::new(), + editor: None, + discard: None, }; app.open_relation(selected); Ok(app) @@ -99,13 +125,267 @@ impl App { pub(crate) fn query_position(&self) -> (usize, usize) { self.query.position() } + pub(crate) fn cell_editor_position(&self) -> Option<(usize, usize)> { + self.editor.as_ref().map(|editor| editor.input.position()) + } + + pub(crate) fn discard_prompt_active(&self) -> bool { + self.discard.is_some() + } + + pub(crate) fn request_external_discard(&mut self) -> bool { + self.defer_discard(DiscardAction::External) + } + + fn defer_discard(&mut self, action: DiscardAction) -> bool { + if self.editor.is_some() || !self.drafts.is_empty() { + self.discard = Some(action); + true + } else { + false + } + } + + // Returns true only when an external action may resume. + pub(crate) fn resolve_discard(&mut self, key: KeyEvent) -> bool { + if matches!(key.code, KeyCode::Esc | KeyCode::Char('n')) { + self.discard = None; + } else if key.code == KeyCode::Char('y') + && let Some(action) = self.discard.take() + { + self.editor = None; + self.overlay_drafts(true); + self.drafts.clear(); + match action { + DiscardAction::Refresh => self.refresh(), + DiscardAction::Relation(index) => self.open_relation(index), + DiscardAction::Query => self.execute_query(), + DiscardAction::Discard => {} + DiscardAction::External => return true, + } + } + false + } + + fn draft_index(&self, snapshot: &RowSnapshot) -> Option { + self.drafts + .iter() + .position(|draft| same_key(&self.columns, &draft.original, snapshot)) + } + + fn overlay_drafts(&mut self, restore: bool) { + for row in 0..self.snapshots.len() { + if let Some(index) = self.draft_index(&self.snapshots[row]) { + for (column, value) in &self.drafts[index].changes { + let value = if restore { + &self.snapshots[row].values[*column] + } else { + value + }; + self.page.rows[row][*column] = value.display(); + } + } + } + } + + fn start_cell_edit(&mut self) { + let reason = if self.query_result.is_some() { + Some("Query results are read-only; open a keyed base table to edit.") + } else if self.show_schema { + Some("Column schema is read-only; press s to return to rows.") + } else if self.access_mode() == crate::db::AccessMode::ReadOnly { + Some("READ ONLY connection; reconnect with READ WRITE access to edit.") + } else if !self.editable { + Some( + "Editing requires a base table with a non-NULL primary key; views and keyless tables are read-only.", + ) + } else { + None + }; + if let Some(reason) = reason { + self.error = Some(reason.into()); + return; + } + let Some((row, column)) = self.rows_state.selected_cell() else { + return; + }; + if self.columns[column].generated { + self.error = Some("Generated columns are read-only.".into()); + return; + } + let snapshot = &self.snapshots[row]; + let draft = self.draft_index(snapshot).map(|index| &self.drafts[index]); + let original = draft.map_or(snapshot, |draft| &draft.original); + let value = draft + .and_then(|draft| { + draft + .changes + .iter() + .find(|(index, _)| *index == column) + .map(|(_, value)| value) + }) + .unwrap_or(&original.values[column]); + match value.input_text() { + Ok(text) => { + self.editor = Some(CellEditor { + original: original.clone(), + column, + input: Input::from(text), + null: matches!(value, crate::db::RawValue::Null), + }); + self.clear_selection(); + self.error = None; + } + Err(error) => self.error = Some(error_text(error.as_ref())), + } + } + + fn stage_editor(&mut self, keep: bool) -> bool { + let Some(editor) = &self.editor else { + return true; + }; + let value = match editor.original.values[editor.column] + .edited((!editor.null).then_some(editor.input.value.as_str())) + { + Ok(value) => value, + Err(error) => { + self.error = Some(error_text(error.as_ref())); + return false; + } + }; + let column = editor.column; + let index = self.draft_index(&editor.original); + let unchanged = value == editor.original.values[column]; + self.page.rows[self.rows_state.selected().unwrap()][column] = value.display(); + if unchanged { + if let Some(index) = index { + self.drafts[index] + .changes + .retain(|(index, _)| *index != column); + if self.drafts[index].changes.is_empty() { + self.drafts.remove(index); + } + } + } else if let Some(index) = index { + let changes = &mut self.drafts[index].changes; + if let Some((_, changed)) = changes.iter_mut().find(|(index, _)| *index == column) { + *changed = value; + } else { + changes.push((column, value)); + } + } else { + let original = if keep { + self.editor.as_ref().unwrap().original.clone() + } else { + self.editor.take().unwrap().original + }; + self.drafts.push(RowEdit { + original, + changes: vec![(column, value)], + }); + } + if !keep { + self.editor = None; + } + self.error = None; + true + } + + fn apply_drafts(&mut self) { + if !self.stage_editor(true) { + return; + } + if self.drafts.is_empty() { + self.editor = None; + return; + } + let Some(active) = self.active else { return }; + match self.db.apply_edits(&self.relations[active], &self.drafts) { + Ok(()) => { + self.drafts.clear(); + self.editor = None; + self.load_page(self.page_index); + } + Err(error) => self.error = Some(error_text(error.as_ref())), + } + } + + fn edit_cell_key(&mut self, key: KeyEvent) { + match key.code { + KeyCode::Esc => self.editor = None, + KeyCode::F(5) => self.apply_drafts(), + KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.apply_drafts(), + KeyCode::Enter + if key + .modifiers + .intersects(KeyModifiers::ALT | KeyModifiers::SHIFT) => + { + let editor = self.editor.as_mut().unwrap(); + editor.null = false; + editor.input.paste_raw("\n"); + } + KeyCode::Enter => { + self.stage_editor(false); + } + KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => { + let editor = self.editor.as_mut().unwrap(); + editor.null = !editor.null; + } + KeyCode::Tab => self.paste_cell("\t"), + _ => { + let editor = self.editor.as_mut().unwrap(); + if matches!( + key.code, + KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete + ) { + editor.null = false; + } + editor.input.edit(key); + } + } + } + + pub(crate) fn paste_cell(&mut self, text: &str) { + if let Some(editor) = &mut self.editor { + editor.null = false; + editor.input.paste_raw(text); + } + } pub(crate) fn handle_rows_command(&mut self, key: KeyEvent) -> bool { + if self.editor.is_some() { + self.edit_cell_key(key); + return true; + } + if key.code == KeyCode::F(5) + || (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL)) + { + self.apply_drafts(); + return true; + } + if !key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + { + match key.code { + KeyCode::Char('e') | KeyCode::Enter => { + self.start_cell_edit(); + return true; + } + KeyCode::Char('D') => { + self.defer_discard(DiscardAction::Discard); + return true; + } + _ => {} + } + } let pending = self.copy_headers.take(); if self.show_schema { return false; } - if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) + if key + .modifiers + .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) && !(key.code == KeyCode::Char('v') && key.modifiers == KeyModifiers::CONTROL) { return false; @@ -124,7 +404,8 @@ impl App { )); } Err(error) => { - self.error = Some(format!("Clipboard: {}", error_text(error.as_ref()))); + self.error = + Some(format!("Clipboard: {}", error_text(error.as_ref()))); } } } @@ -182,7 +463,10 @@ impl App { fn selection_text(&self, headers: bool, markdown: bool) -> Option { let (top, bottom, left, right) = self.selection_bounds()?; - let page = self.query_result.as_ref().map_or(&self.page, |result| &result.page); + let page = self + .query_result + .as_ref() + .map_or(&self.page, |result| &result.page); let rows = page.rows.get(top..=bottom)?; let mut output = String::new(); if headers { @@ -205,7 +489,9 @@ impl App { for row in rows { append_record( &mut output, - row.get(left..=right)?.iter().map(|value| value.as_deref().unwrap_or("NULL")), + row.get(left..=right)? + .iter() + .map(|value| value.as_deref().unwrap_or("NULL")), markdown, ); } @@ -322,6 +608,9 @@ impl App { } fn execute_query(&mut self) { + if self.defer_discard(DiscardAction::Query) { + return; + } match self.db.query(&self.query.value) { Ok(result) => { self.clear_selection(); @@ -375,10 +664,15 @@ impl App { } pub(crate) fn open_relation(&mut self, index: Option) { + if self.defer_discard(DiscardAction::Relation(index)) { + return; + } self.clear_selection(); self.active = index; self.query_result = None; self.columns.clear(); + self.snapshots.clear(); + self.editable = false; self.page = Page { rows: Vec::new(), has_next: false, @@ -406,8 +700,14 @@ impl App { fn load_page(&mut self, index: usize) { if let Some(active) = self.active { // ponytail: synchronous reads block input; use a worker if responsiveness matters. - match self.db.page(&self.relations[active], &self.columns, index) { - Ok(page) => { + match self + .db + .edit_page(&self.relations[active], &self.columns, index) + { + Ok(edit_page) => { + let page = edit_page.page; + self.snapshots = edit_page.rows; + self.editable = edit_page.editable; self.clear_selection(); self.rows_state = TableState::default() .with_selected((!page.rows.is_empty()).then_some(0)) @@ -417,6 +717,7 @@ impl App { .or_else(|| (!self.columns.is_empty()).then_some(0)), ); self.page = page; + self.overlay_drafts(false); self.page_index = index; self.error = None; } @@ -426,6 +727,9 @@ impl App { } fn refresh(&mut self) { + if self.defer_discard(DiscardAction::Refresh) { + return; + } match self.db.relations() { Ok(relations) => { let id = self.active.map(|index| self.relations[index].id); @@ -463,9 +767,17 @@ impl App { focus == Focus::Query, Some(sql::text(&self.query.value, &self.query_highlights)), ); + let draft_cells: usize = self.drafts.iter().map(|draft| draft.changes.len()).sum(); + let draft_status = format!( + " Rows · {} rows/{draft_cells} cells staged · F5 apply · D discard ", + self.drafts.len() + ); let title = if let Some(headers) = self.copy_headers { - if headers { " Copy with headers · m Markdown / c CSV " } - else { " Copy without headers · m Markdown / c CSV " } + if headers { + " Copy with headers · m Markdown / c CSV " + } else { + " Copy without headers · m Markdown / c CSV " + } } else if self.selection_anchor.is_some() { " VISUAL BLOCK · ym/yc with headers · Ym/Yc without " } else if let Some(status) = &self.copy_status { @@ -474,8 +786,10 @@ impl App { " Results " } else if self.show_schema { " Schema · s for rows " + } else if !self.drafts.is_empty() { + draft_status.as_str() } else { - " Rows · s for schema " + " Rows · e/Enter edit · s schema " }; let count = self.column_count(); let capacity = usize::from(content.width.saturating_sub(2) / 21).max(1); @@ -510,7 +824,9 @@ impl App { if count > 0 { self.column_offset + 1 } else { 0 }, self.column_offset + visible, count, - result.affected_rows.map_or_else(String::new, |rows| format!(" · {rows} rows affected")), + result + .affected_rows + .map_or_else(String::new, |rows| format!(" · {rows} rows affected")), ) } else if let Some(index) = self.active { let relation = &self.relations[index]; @@ -605,7 +921,19 @@ impl App { ) }); let bounds = self.selection_anchor.and_then(|_| self.selection_bounds()); + let columns = &self.columns; + let drafts = &self.drafts; + let snapshots = &self.snapshots; + let relation_rows = self.query_result.is_none(); let rows = page.rows.iter().enumerate().map(|(row_index, row)| { + let draft = relation_rows + .then(|| snapshots.get(row_index)) + .flatten() + .and_then(|snapshot| { + drafts + .iter() + .find(|draft| same_key(columns, &draft.original, snapshot)) + }); Row::new( row.iter() .enumerate() @@ -625,6 +953,17 @@ impl App { && (left..=right).contains(&column_index) }) { cell.style(selected_style()) + } else if draft.is_some_and(|draft| { + draft + .changes + .iter() + .any(|(column, _)| *column == column_index) + }) { + cell.style( + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + ) } else { cell } @@ -646,14 +985,67 @@ impl App { error_area, ); } + if let Some(editor) = &self.editor { + let popup = centered(area, 90, 12); + frame.render_widget(Clear, popup); + let [input, hints] = + Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(popup); + let title = format!( + " Edit {} · {} ", + self.columns[editor.column].label, + if editor.null { "NULL" } else { "value" } + ); + editor.input.draw(frame, input, &title, false, true, None); + frame.render_widget( + Paragraph::new( + "Enter stage · Esc cancel · Alt/Shift-Enter newline · Ctrl-N NULL · F5 apply", + ), + hints, + ); + } + } + + pub(crate) fn draw_discard_prompt(&self, frame: &mut Frame, area: Rect) { + if let Some(action) = self.discard { + let verb = match action { + DiscardAction::Refresh => "refreshing", + DiscardAction::Relation(_) => "opening a relation", + DiscardAction::Query => "executing SQL", + DiscardAction::Discard => "continuing", + DiscardAction::External => "leaving this work", + }; + let cells: usize = self.drafts.iter().map(|draft| draft.changes.len()).sum(); + let popup = centered(area, 76, 7); + frame.render_widget(Clear, popup); + frame.render_widget(Paragraph::new(format!( + "Discard {} staged rows / {cells} cells{} before {verb}?\nNothing has been written to the database.", + self.drafts.len(), if self.editor.is_some() { " and the active editor" } else { "" })) + .wrap(Wrap { trim: false }) + .block(Block::bordered().title(" Unsaved edits ").title_bottom(" y: discard · n/Esc: cancel ")), popup); + } } } -fn append_record<'a>( - output: &mut String, - values: impl Iterator, - markdown: bool, -) { +fn same_key(columns: &[Column], left: &RowSnapshot, right: &RowSnapshot) -> bool { + columns + .iter() + .enumerate() + .filter(|(_, column)| column.primary_key) + .all(|(index, _)| left.values[index] == right.values[index]) +} + +fn centered(area: Rect, width: u16, height: u16) -> Rect { + let width = width.min(area.width); + let height = height.min(area.height); + Rect::new( + area.x + (area.width - width) / 2, + area.y + (area.height - height) / 2, + width, + height, + ) +} + +fn append_record<'a>(output: &mut String, values: impl Iterator, markdown: bool) { if markdown { output.push('|'); } @@ -695,13 +1087,22 @@ fn append_record<'a>( } fn copy_to_clipboard(text: &str) -> Result<()> { - use std::{io::Write, process::{Command, Stdio}}; + use std::{ + io::Write, + process::{Command, Stdio}, + }; let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") { ("pbcopy", &[]) } else if cfg!(target_os = "windows") { - ("powershell.exe", &["-NoProfile", "-Command", - "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())"]) + ( + "powershell.exe", + &[ + "-NoProfile", + "-Command", + "[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())", + ], + ) } else if std::env::var_os("WAYLAND_DISPLAY").is_some() { ("wl-copy", &[]) } else { @@ -759,6 +1160,278 @@ fn moved_index(selected: Option, count: usize, delta: isize) -> Option crossterm::event::KeyEvent { + crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE) + } + + fn edit_fixture() -> crate::Result<(tempfile::NamedTempFile, rusqlite::Connection, super::App)> + { + let file = tempfile::NamedTempFile::new()?; + let connection = rusqlite::Connection::open(file.path())?; + connection.execute_batch( + "CREATE TABLE edits ( + id INTEGER NOT NULL, kind TEXT NOT NULL, body TEXT CHECK(body <> 'bad'), + nullable TEXT, derived TEXT GENERATED ALWAYS AS (body || '!') VIRTUAL, + PRIMARY KEY(id, kind)); + WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101) + INSERT INTO edits(id,kind,body) SELECT x,'a','old' FROM n; + CREATE TABLE keyless (body TEXT); + INSERT INTO keyless VALUES ('old'); + CREATE VIEW view_edits AS SELECT * FROM edits;", + )?; + let app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + crate::db::AccessMode::ReadWrite, + )?)?; + Ok((file, connection, app)) + } + + fn replace_cell(app: &mut super::App, row: usize, column: usize, value: &str) { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + app.rows_state.select(Some(row)); + app.rows_state.select_column(Some(column)); + app.handle_rows_command(key(KeyCode::Char('e'))); + app.handle_rows_command(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.paste_cell(value); + } + + #[test] + fn staged_raw_cells_survive_pages_copy_revert_and_reload_triggers() -> crate::Result<()> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let (_file, connection, mut app) = edit_fixture()?; + let raw = "a\tb\r\nc\u{1b}\0界"; + connection.execute("UPDATE edits SET body=? WHERE id=1", [raw])?; + connection.execute_batch( + "CREATE TRIGGER decorate AFTER UPDATE OF body ON edits BEGIN + UPDATE edits SET body=new.body || '!' WHERE id=new.id AND kind=new.kind; + END;", + )?; + app.refresh(); + app.rows_state.select(Some(0)); + app.rows_state.select_column(Some(2)); + app.handle_rows_command(key(KeyCode::Enter)); + assert_eq!(app.editor.as_ref().unwrap().input.value, raw); + app.handle_rows_command(key(KeyCode::Enter)); + assert!( + app.drafts.is_empty(), + "unchanged raw controls must be a no-op" + ); + replace_cell(&mut app, 0, 2, "temporary"); + app.handle_rows_command(key(KeyCode::Enter)); + replace_cell(&mut app, 0, 2, raw); + app.handle_rows_command(key(KeyCode::Enter)); + assert!(app.drafts.is_empty(), "reverting removes the draft"); + let changed = format!("{raw} changed"); + replace_cell(&mut app, 0, 2, &changed); + app.handle_rows_command(key(KeyCode::Enter)); + assert_eq!( + app.selection_text(false, false), + Some(format!("{}\r\n", crate::db::display_text(&changed))) + ); + replace_cell(&mut app, 0, 1, "b"); + app.handle_rows_command(key(KeyCode::Enter)); + app.handle_key(key(KeyCode::Char('n'))); + replace_cell(&mut app, 0, 3, ""); + app.handle_rows_command(key(KeyCode::Enter)); + assert_eq!(app.drafts.len(), 2); + app.handle_key(key(KeyCode::Char('p'))); + assert!(app.draft_index(&app.snapshots[0]).is_some()); + assert_eq!(app.page.rows[0][2], Some(crate::db::display_text(&changed))); + assert_eq!(app.page.rows[0][1].as_deref(), Some("b")); + app.rows_state.select(Some(1)); + app.rows_state.select_column(Some(2)); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24))?; + terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?; + assert!( + terminal + .backend() + .buffer() + .content + .iter() + .any(|cell| cell.fg == ratatui::style::Color::Yellow) + ); + let rendered: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(rendered.contains("2 rows/3 cells staged")); + // Applying from Rows never executes query text, and reload includes trigger output. + app.query.value = "DELETE FROM edits".into(); + app.handle_rows_command(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL)); + assert!(app.drafts.is_empty(), "{:?}", app.error); + assert_eq!( + app.page.rows[0][2], + Some(crate::db::display_text(&format!("{changed}!"))) + ); + assert_eq!( + connection.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))?, + 101 + ); + assert_eq!( + connection.query_row("SELECT body FROM edits WHERE id=1", [], |row| row + .get::<_, String>(0))?, + format!("{changed}!") + ); + assert_eq!( + connection.query_row("SELECT kind FROM edits WHERE id=1", [], |row| row + .get::<_, String>(0))?, + "b" + ); + assert_eq!( + connection.query_row("SELECT nullable FROM edits WHERE id=101", [], |row| row + .get::<_, String>( + 0 + ))?, + "" + ); + Ok(()) + } + + #[test] + fn apply_errors_keep_drafts_editor_and_roll_back_every_row() -> crate::Result<()> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let (_file, connection, mut app) = edit_fixture()?; + replace_cell(&mut app, 0, 2, "first"); + app.handle_rows_command(key(KeyCode::Enter)); + replace_cell(&mut app, 1, 2, "bad"); + app.handle_rows_command(key(KeyCode::F(5))); + assert!(app.error.is_some()); + assert_eq!(app.drafts.len(), 2); + assert_eq!(app.editor.as_ref().unwrap().input.value, "bad"); + assert_eq!( + connection.query_row("SELECT count(*) FROM edits WHERE body='old'", [], |row| row + .get::<_, i64>(0))?, + 101 + ); + app.handle_rows_command(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL)); + app.paste_cell("second"); + app.handle_rows_command(key(KeyCode::F(5))); + assert!(app.error.is_none(), "{:?}", app.error); + assert!(app.editor.is_none()); + assert!(app.drafts.is_empty()); + // Invalid numeric input remains an editor, never a lossy string key. + replace_cell(&mut app, 0, 0, "not an integer"); + app.handle_rows_command(key(KeyCode::Enter)); + assert!(app.editor.is_some()); + assert!(app.drafts.is_empty()); + app.handle_rows_command(key(KeyCode::Esc)); + replace_cell(&mut app, 0, 2, "mine"); + connection.execute("UPDATE edits SET body='theirs' WHERE id=1", [])?; + app.handle_rows_command(key(KeyCode::F(5))); + assert!(app.error.is_some()); + assert_eq!(app.drafts.len(), 1); + assert_eq!(app.editor.as_ref().unwrap().input.value, "mine"); + Ok(()) + } + + #[test] + fn discard_guards_preserve_editor_until_confirmed_and_block_sql() -> crate::Result<()> { + use crossterm::event::KeyCode; + let (_file, connection, mut app) = edit_fixture()?; + replace_cell(&mut app, 0, 2, "draft"); + app.handle_rows_command(key(KeyCode::Enter)); + replace_cell(&mut app, 1, 2, "active"); + app.query.value = "DELETE FROM edits".into(); + for action in [ + super::DiscardAction::Refresh, + super::DiscardAction::Relation(Some(1)), + super::DiscardAction::Query, + super::DiscardAction::External, + super::DiscardAction::Discard, + ] { + match action { + super::DiscardAction::Refresh => app.refresh(), + super::DiscardAction::Relation(index) => app.open_relation(index), + super::DiscardAction::Query => app.execute_query(), + super::DiscardAction::External => { + assert!(app.request_external_discard()); + } + super::DiscardAction::Discard => { + app.handle_rows_command(key(KeyCode::Esc)); + app.handle_rows_command(key(KeyCode::Char('D'))); + } + } + assert!(app.discard_prompt_active()); + app.resolve_discard(key(KeyCode::Esc)); + assert_eq!(app.drafts.len(), 1); + // D is invoked outside the editor, so restore it for the next transition. + if app.editor.is_none() { + replace_cell(&mut app, 1, 2, "active"); + } + assert_eq!(app.editor.as_ref().unwrap().input.value, "active"); + assert_eq!(app.active, Some(0)); + assert!(app.query_result.is_none()); + } + assert_eq!( + connection.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))?, + 101 + ); + assert!(app.request_external_discard()); + app.resolve_discard(key(KeyCode::Char('n'))); + assert!(app.editor.is_some()); + app.request_external_discard(); + assert!(app.resolve_discard(key(KeyCode::Char('y')))); + assert!(app.editor.is_none()); + assert!(app.drafts.is_empty()); + assert_eq!(app.page.rows[0][2].as_deref(), Some("old")); + replace_cell(&mut app, 0, 2, "discard me"); + app.handle_rows_command(key(KeyCode::Enter)); + app.open_relation(Some(1)); + app.resolve_discard(key(KeyCode::Char('y'))); + assert_eq!(app.active, Some(1)); + assert!(app.drafts.is_empty()); + Ok(()) + } + + #[test] + fn explicit_null_is_distinct_and_uneditable_sources_explain_why() -> crate::Result<()> { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + let (file, _connection, mut app) = edit_fixture()?; + replace_cell(&mut app, 0, 3, "NULL"); + app.handle_rows_command(key(KeyCode::Enter)); + assert_eq!(app.page.rows[0][3].as_deref(), Some("NULL")); + app.handle_rows_command(key(KeyCode::Enter)); + app.handle_rows_command(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL)); + app.handle_rows_command(key(KeyCode::Enter)); + assert!(app.drafts.is_empty(), "explicit NULL reverts original NULL"); + replace_cell(&mut app, 0, 3, "multi"); + app.handle_rows_command(key(KeyCode::Tab)); + app.handle_rows_command(KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)); + app.handle_rows_command(key(KeyCode::Char('q'))); + assert_eq!(app.editor.as_ref().unwrap().input.value, "multi\t\nq"); + app.handle_rows_command(key(KeyCode::Esc)); + assert!(app.drafts.is_empty(), "Esc cancels only the active cell"); + app.rows_state.select(Some(0)); + app.rows_state.select_column(Some(4)); + app.handle_rows_command(key(KeyCode::Enter)); + assert!(app.error.as_ref().unwrap().contains("Generated")); + for name in ["keyless", "view_edits"] { + let index = app + .relations + .iter() + .position(|relation| relation.name == name) + .unwrap(); + app.open_relation(Some(index)); + app.handle_rows_command(key(KeyCode::Enter)); + assert!(app.editor.is_none()); + assert!(app.error.as_ref().unwrap().contains("primary key")); + } + app.query.value = "SELECT 1".into(); + app.execute_query(); + app.handle_rows_command(key(KeyCode::Enter)); + assert!(app.error.as_ref().unwrap().contains("Query results")); + let mut readonly = super::App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; + readonly.handle_rows_command(key(KeyCode::Enter)); + assert!(readonly.error.as_ref().unwrap().contains("READ ONLY")); + Ok(()) + } + #[test] fn csv_quotes_only_fields_with_delimiters_quotes_or_line_breaks() { let mut output = String::new(); @@ -767,7 +1440,10 @@ mod tests { ["42", "00123", "界", "", "a,b", "a\"b", "a\rb", "a\nb"].into_iter(), false, ); - assert_eq!(output, "42,00123,界,,\"a,b\",\"a\"\"b\",\"a\rb\",\"a\nb\"\r\n"); + assert_eq!( + output, + "42,00123,界,,\"a,b\",\"a\"\"b\",\"a\rb\",\"a\nb\"\r\n" + ); } #[test] @@ -786,7 +1462,10 @@ mod tests { WITH RECURSIVE n(x) AS (VALUES(4) UNION ALL SELECT x+1 FROM n WHERE x<101) INSERT INTO cells SELECT x,x,x,x FROM n;", )?; - let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); // Anchor at the bottom right, extend up and left across a scrolled viewport. for character in ['j', 'l', 'l'] { @@ -806,11 +1485,15 @@ mod tests { assert_ne!(buffer[(1, 14)].bg, Color::Blue); assert_eq!( app.selection_text(true, true).as_deref(), - Some("| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n") + Some( + "| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n" + ) ); assert_eq!( app.selection_text(false, true).as_deref(), - Some("| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n") + Some( + "| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\tail |\n" + ) ); assert_eq!( app.selection_text(true, false).as_deref(), @@ -839,7 +1522,10 @@ mod tests { app.query.value = "SELECT 42 AS answer".into(); app.execute_query(); assert!(app.selection_anchor.is_none()); - assert_eq!(app.selection_text(true, false).as_deref(), Some("answer\r\n42\r\n")); + assert_eq!( + app.selection_text(true, false).as_deref(), + Some("answer\r\n42\r\n") + ); app.handle_rows_command(key('v')); app.query.value = "SELECT missing FROM nowhere".into(); app.execute_query(); @@ -868,7 +1554,10 @@ mod tests { fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let file = tempfile::NamedTempFile::new()?; - let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); app.handle_query_key(key('i')); app.paste_query(" SELECT café,界\n\n FROM data"); @@ -916,7 +1605,10 @@ mod tests { fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let file = tempfile::NamedTempFile::new()?; - let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); assert!(!app.query_insert_mode()); app.paste_query("ignored"); @@ -961,7 +1653,10 @@ mod tests { fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; let file = tempfile::NamedTempFile::new()?; - let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT\n42"); for insert in [true, false] { @@ -994,7 +1689,10 @@ mod tests { WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101) INSERT INTO cells SELECT x, x, x, x, x, x FROM n;", )?; - let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; let mut terminal = Terminal::new(TestBackend::new(60, 20))?; let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); assert_eq!(app.rows_state.selected_cell(), Some((0, 0))); @@ -1036,7 +1734,10 @@ mod tests { use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{Terminal, backend::TestBackend, style::Color}; let file = tempfile::NamedTempFile::new()?; - let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; let mut terminal = Terminal::new(TestBackend::new(60, 14))?; app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT '界', 42;\n-- comment"); @@ -1068,7 +1769,10 @@ mod tests { rusqlite::Connection::open(file.path())?.execute_batch( "CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);", )?; - let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;"); app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); @@ -1102,7 +1806,10 @@ mod tests { rusqlite::Connection::open(file.path())?.execute_batch( "CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (1);", )?; - let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; + let mut app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + Default::default(), + )?)?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20))?; for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] { app.show_schema = schema; diff --git a/src/connection.rs b/src/connection.rs index ff440a3..330a1d4 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -33,6 +33,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< let mut info: Option = None; let mut help: Option = None; let mut remove: Option = None; + let mut pending_external: Option = None; loop { let entries = explorer_entries( profiles.entries.len(), @@ -42,18 +43,23 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< ); terminal.draw(|frame| { let [top, workspace, bottom] = shell_areas(frame.area()); - let position = if info.is_some() || help.is_some() || remove.is_some() { + let position = if info.is_some() || help.is_some() || remove.is_some() + || browser.as_ref().is_some_and(App::discard_prompt_active) { None } else if show_form { form.input().map(Input::position) } else { - browser.as_ref().filter(|_| focus == Focus::Query).map(App::query_position) + browser.as_ref().and_then(|app| { + app.cell_editor_position().or_else(|| (focus == Focus::Query).then(|| app.query_position())) + }) }; let insert = if show_form { form.input().is_some() } else { - info.is_none() && focus == Focus::Query - && browser.as_ref().is_some_and(App::query_insert_mode) + info.is_none() && browser.as_ref().is_some_and(|app| { + app.cell_editor_position().is_some() + || (focus == Focus::Query && app.query_insert_mode()) + }) }; let name = connection_label.as_deref().unwrap_or_else(|| browser.as_ref().map_or("", App::database_name)); draw_bars(frame, top, bottom, browser.as_ref().map_or("", App::provider), name, browser.as_ref().map(App::access_mode), insert, position); @@ -111,9 +117,48 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< profiles.entries[index].label())) .wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ").title_bottom(" y: remove · n/Esc: cancel ")), area); } + if let Some(browser) = &browser { + browser.draw_discard_prompt(frame, workspace); + } })?; match event::read()? { - Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => { + Event::Key(mut key) + if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => + { + if let Some(app) = browser.as_mut().filter(|app| app.discard_prompt_active()) { + if app.resolve_discard(key) { + key = pending_external.take().unwrap(); + } else { + if !app.discard_prompt_active() { + pending_external = None; + } + continue; + } + } + let ctrl_c = + key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL); + let entry = selection.selected().and_then(|index| entries.get(index)); + let external = ctrl_c + || (info.is_none() + && help.is_none() + && remove.is_none() + && if show_form { + key.code == KeyCode::Enter && form.reconnect && form.focus != 0 + } else { + leaves_drafts( + key, + focus, + entry.copied(), + active, + browser + .as_ref() + .is_some_and(|app| app.cell_editor_position().is_some()), + ) + }); + if external && browser.as_mut().is_some_and(App::request_external_discard) { + pending_external = Some(key); + continue; + } if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) { return Ok(()); } @@ -184,7 +229,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< .map(|connected| { browser = Some(connected); connection_label = Some(form.connection_label()); - active = profiles.entries.iter().position(|entry| entry.id == form.id); + active = profiles + .entries + .iter() + .position(|entry| entry.id == form.id); selection.select(active); expanded = true; focus = Focus::Explorer; @@ -222,7 +270,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } else { match key.code { _ if focus == Focus::Rows - && browser.as_mut().is_some_and(|app| app.handle_rows_command(key)) => {} + && browser + .as_mut() + .is_some_and(|app| app.handle_rows_command(key)) => {} KeyCode::Char('?') if focus != Focus::Query || !browser.as_ref().is_some_and(App::query_insert_mode) => @@ -292,7 +342,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< selection.selected().and_then(|index| entries.get(index)) { let index = match *entry { - ExplorerEntry::Relation(_) if key.code == KeyCode::Char('R') => { + ExplorerEntry::Relation(_) + if key.code == KeyCode::Char('R') => + { let Some(index) = active else { continue }; index } @@ -302,7 +354,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< continue; } ExplorerEntry::Database(index) - if browser.is_some() && index == active && key.code == KeyCode::Enter => + if browser.is_some() + && index == active + && key.code == KeyCode::Enter => { expanded = !expanded; continue; @@ -379,7 +433,14 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< } } } - Event::Paste(mut text) if info.is_some() || help.is_some() || remove.is_some() => text.zeroize(), + Event::Paste(mut text) + if info.is_some() + || help.is_some() + || remove.is_some() + || browser.as_ref().is_some_and(App::discard_prompt_active) => + { + text.zeroize() + } Event::Paste(text) if show_form => { let text = Zeroizing::new(text); form.error = None; @@ -393,12 +454,44 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< browser.paste_query(&text); } } + Event::Paste(text) if focus == Focus::Rows => { + let text = Zeroizing::new(text); + if let Some(browser) = &mut browser { + browser.paste_cell(&text); + } + } Event::Paste(mut text) => text.zeroize(), _ => {} } } } +fn leaves_drafts( + key: KeyEvent, + focus: Focus, + entry: Option, + active: Option, + editing: bool, +) -> bool { + if key.code == KeyCode::Char('q') && focus != Focus::Query && !editing { + return true; + } + focus == Focus::Explorer + && match key.code { + KeyCode::Delete | KeyCode::Char('d') => { + matches!(entry, Some(ExplorerEntry::Database(Some(_)))) + } + KeyCode::Char('R') => { + matches!(entry, Some(ExplorerEntry::Database(Some(_)))) + || (active.is_some() && matches!(entry, Some(ExplorerEntry::Relation(_)))) + } + KeyCode::Enter => { + matches!(entry, Some(ExplorerEntry::Database(Some(index))) if Some(index) != active) + } + _ => false, + } +} + fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { let width = screen .width @@ -411,8 +504,8 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Tab / Shift-Tab Cycle explorer, query, and rows/schema\n\ Esc Return to explorer\n\ c Add a configured database\n\ - q Quit outside the query editor\n\ - Ctrl-C Quit from any surface\n\n\ + q Quit outside query/cell editors\n\ + Ctrl-C Quit from any surface (unsaved edits prompt)\n\n\ Explorer and rows/schema (Normal mode)\n\ Up/Down, j/k Move selection\n\ Home/End, g/G First / last item\n\ @@ -436,6 +529,19 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Headerless Markdown has blank headings. CSV uses CRLF and quotes only fields\n\ containing commas, double quotes, or line breaks.\n\ Clipboard: macOS pbcopy; Windows PowerShell; Wayland wl-copy; X11 xclip.\n\n\ + Staged cell editing (READ WRITE base tables with primary keys)\n\ + e / Enter Open selected cell; Enter stages; Esc cancels editor\n\ + Alt/Shift-Enter Insert newline; Tab inserts a literal tab\n\ + Ctrl-N Toggle explicit NULL (not empty text or string NULL)\n\ + F5 / Ctrl-Enter Stage active editor and atomically apply all drafts\n\ + D Discard drafts (y confirms; n/Esc cancels)\n\ + Yellow cells and counts show drafts across pages; copies include them.\n\ + Raw tabs/control characters are preserved; rendering uses safe escapes.\n\ + Views/results, keyless tables, generated columns and rows with NULL keys\n\ + are read-only. SQLite numbers/BLOBs retain their original storage class.\n\ + Apply checks original keys/values; any error rolls back and keeps edits.\n\ + Refresh, relation/connection changes, SQL execution, and quit prompt before\n\ + discarding. Cancel keeps drafts/editor; success reloads trigger/server values.\n\n\ Query editor\n\ i/a/I/A Enter Insert mode (at/after/start/end)\n\ hjkl / arrows Move cursor in Normal mode\n\ @@ -453,7 +559,7 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Execute one statement: SELECT/CTE in RO; SQL writes in RW.\n\ Success commits atomically; failure rolls back. No transaction scripts.\n\ Writes report affected rows; RETURNING shows up to 100 result rows.\n\ - Result limits never limit the rows written. No inline cell editing.\n\n\ + Result limits never limit the rows written. Rows editing never runs SQL text.\n\n\ Connection / password windows\n\ Tab / Shift-Tab Change fields\n\ Arrows Choose provider; Left/Right changes TLS, CA, or Access\n\ @@ -493,7 +599,13 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { ); } -fn database_label(label: &str, access: AccessMode, connected: bool, expanded: bool, width: u16) -> String { +fn database_label( + label: &str, + access: AccessMode, + connected: bool, + expanded: bool, + width: u16, +) -> String { let badge = match access { AccessMode::ReadOnly => "[RO]", AccessMode::ReadWrite => "[RW]", @@ -579,8 +691,12 @@ fn draw_bars( }; let access_width = (access.len() as u16 + if access.is_empty() { 0 } else { 2 }) .min(bottom.width.saturating_sub(position_width + mode_width)); - let provider_width = (Span::raw(provider).width() as u16 + if access.is_empty() { 2 } else { 1 }) - .min(bottom.width.saturating_sub(position_width + mode_width + access_width)); + let provider_width = + (Span::raw(provider).width() as u16 + if access.is_empty() { 2 } else { 1 }).min( + bottom + .width + .saturating_sub(position_width + mode_width + access_width), + ); let [mode, name_area, provider_area, access_area, cursor] = Layout::horizontal([ Constraint::Length(mode_width), Constraint::Min(0), @@ -599,11 +715,13 @@ fn draw_bars( ); frame.render_widget(Paragraph::new(format!(" {name}")).style(base), name_area); frame.render_widget( - Paragraph::new(provider).alignment(if access.is_empty() { - ratatui::layout::Alignment::Center - } else { - ratatui::layout::Alignment::Right - }).style(base), + Paragraph::new(provider) + .alignment(if access.is_empty() { + ratatui::layout::Alignment::Center + } else { + ratatui::layout::Alignment::Right + }) + .style(base), provider_area, ); if !access.is_empty() { @@ -612,7 +730,8 @@ fn draw_bars( Span::raw(" "), Span::styled(access, badge), Span::raw(" "), - ])).style(base), + ])) + .style(base), access_area, ); } @@ -739,7 +858,9 @@ impl Profiles { } }), tls: form.tls, - ca_bundle: (form.provider == 0).then(|| form.ca_bundle.clone()).flatten(), + ca_bundle: (form.provider == 0) + .then(|| form.ca_bundle.clone()) + .flatten(), path: form.path.clone(), error: None, reconnect: false, @@ -866,7 +987,10 @@ impl Form { if !choices.contains(&self.ca_bundle) { choices.push(self.ca_bundle.clone()); } - let current = choices.iter().position(|path| path == &self.ca_bundle).unwrap(); + let current = choices + .iter() + .position(|path| path == &self.ca_bundle) + .unwrap(); let next = (current + if forward { 1 } else { choices.len() - 1 }) % choices.len(); self.ca_bundle = choices.swap_remove(next); if self.ca_bundle.is_some() { @@ -882,12 +1006,18 @@ impl Form { } return Database::open_sqlite(Path::new(&self.path.value), self.access); } - Database::connect_postgres(self.postgres_config()?, self.ca_bundle.as_deref(), self.access) + Database::connect_postgres( + self.postgres_config()?, + self.ca_bundle.as_deref(), + self.access, + ) } fn postgres_config(&self) -> Result { if self.ca_bundle.is_some() && self.tls != 0 { - return Err("A CA bundle requires Require TLS; plaintext fallback is not allowed.".into()); + return Err( + "A CA bundle requires Require TLS; plaintext fallback is not allowed.".into(), + ); } let [host, port, database, user, password] = &self.postgres; if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() { @@ -1028,7 +1158,10 @@ impl Form { crate::db::display_text(&self.postgres[2].value), crate::db::display_text(&self.postgres[3].value), ["Require", "Prefer", "Disable"][self.tls], - self.ca_bundle.as_ref().map_or_else(|| "System trust".into(), |path| crate::db::display_text(&path.to_string_lossy())), + self.ca_bundle.as_ref().map_or_else( + || "System trust".into(), + |path| crate::db::display_text(&path.to_string_lossy()) + ), self.access.label(), ) } @@ -1110,13 +1243,13 @@ impl Form { Layout::horizontal([Constraint::Min(1), Constraint::Length(28)]).areas(name_area); frame.render_widget( Paragraph::new(self.access.label()).block( - Block::bordered().title(" Access · ←/→ change ").border_style( - Style::default().fg(if self.focus == self.access_focus() { + Block::bordered() + .title(" Access · ←/→ change ") + .border_style(Style::default().fg(if self.focus == self.access_focus() { Color::Cyan } else { Color::DarkGray - }), - ), + })), ), access_area, ); @@ -1213,17 +1346,24 @@ impl Form { ); let ca_label = self.ca_bundle.as_ref().map_or_else( || "System trust".into(), - |path| crate::db::display_text(&path.file_name().unwrap_or(path.as_os_str()).to_string_lossy()), + |path| { + crate::db::display_text( + &path + .file_name() + .unwrap_or(path.as_os_str()) + .to_string_lossy(), + ) + }, ); frame.render_widget( Paragraph::new(ca_label).block( - Block::bordered().title(" CA bundle · ←/→ choose ").border_style( - Style::default().fg(if self.focus == self.ca_focus() { + Block::bordered() + .title(" CA bundle · ←/→ choose ") + .border_style(Style::default().fg(if self.focus == self.ca_focus() { Color::Cyan } else { Color::DarkGray - }), - ), + })), ), rows[3], ); @@ -1285,6 +1425,15 @@ impl From<&str> for Input { } } +impl From for Input { + fn from(value: String) -> Self { + Self { + cursor: value.len(), + value, + } + } +} + impl Input { pub(crate) fn vim_motion(&mut self, motion: char) { let class = |character: char| { @@ -1479,6 +1628,11 @@ impl Input { self.cursor += text.len(); } + pub(crate) fn paste_raw(&mut self, value: &str) { + self.value.insert_str(self.cursor, value); + self.cursor += value.len(); + } + pub(crate) fn draw( &self, frame: &mut Frame, @@ -1492,7 +1646,14 @@ impl Input { let text = if secret { Text::raw("•".repeat(self.value.chars().count())) } else { - highlighted.unwrap_or_else(|| Text::raw(&self.value)) + highlighted.unwrap_or_else(|| { + Text::from( + self.value + .split('\n') + .map(|line| Line::raw(crate::db::display_text(line))) + .collect::>(), + ) + }) }; let line_count = text.lines.len().max(1); let block = Block::bordered() @@ -1521,7 +1682,10 @@ impl Input { let before_cursor = if secret { self.value[..self.cursor].chars().count() } else { - Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width() + Line::raw(crate::db::display_text( + prefix.rsplit('\n').next().unwrap_or(""), + )) + .width() }; let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX); let offset = cursor.saturating_sub(content.width.saturating_sub(1)); @@ -1552,6 +1716,92 @@ impl Input { mod tests { use super::*; + #[test] + fn connection_and_quit_guards_exclude_expanding_and_literal_editor_keys() { + let key = |code| KeyEvent::new(code, KeyModifiers::NONE); + for code in [ + KeyCode::Enter, + KeyCode::Char('R'), + KeyCode::Char('d'), + KeyCode::Delete, + ] { + assert!(leaves_drafts( + key(code), + Focus::Explorer, + Some(ExplorerEntry::Database(Some(1))), + Some(0), + false + )); + } + assert!(!leaves_drafts( + key(KeyCode::Enter), + Focus::Explorer, + Some(ExplorerEntry::Database(Some(0))), + Some(0), + false + )); + assert!(!leaves_drafts( + key(KeyCode::Enter), + Focus::Explorer, + Some(ExplorerEntry::Relation(1)), + Some(0), + false + )); + assert!(leaves_drafts( + key(KeyCode::Char('R')), + Focus::Explorer, + Some(ExplorerEntry::Relation(1)), + Some(0), + false + )); + assert!(leaves_drafts( + key(KeyCode::Char('q')), + Focus::Rows, + None, + None, + false + )); + assert!(!leaves_drafts( + key(KeyCode::Char('q')), + Focus::Rows, + None, + None, + true + )); + assert!(!leaves_drafts( + key(KeyCode::Char('q')), + Focus::Query, + None, + None, + false + )); + } + + #[test] + fn raw_cell_input_preserves_controls_and_renders_escaped_cursor() -> Result<()> { + let original = "a\t\r\u{1b}\0界"; + let mut input = Input::from(original); + input.paste_raw("\nsecond\tline"); + assert_eq!(input.value, format!("{original}\nsecond\tline")); + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 6))?; + terminal.draw(|frame| input.draw(frame, frame.area(), " Cell ", false, true, None))?; + let rendered: String = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect(); + assert!(rendered.contains(&crate::db::display_text(original))); + assert!(rendered.contains(&crate::db::display_text("second\tline"))); + assert_eq!( + terminal.get_cursor_position()?.x, + 1 + crate::db::display_text("second\tline").len() as u16 + ); + assert_eq!(input.value, format!("{original}\nsecond\tline")); + Ok(()) + } + #[test] fn legacy_profiles_default_to_read_only_and_access_edits_persist() -> Result<()> { let directory = tempfile::tempdir()?; @@ -1591,12 +1841,19 @@ mod tests { let mut profiles = Profiles::open(&path)?; assert_eq!(profiles.entries.len(), 1); assert_eq!(profiles.entries[0].access, AccessMode::ReadWrite); - assert!(profiles.entries[0].info_text().contains("Access: READ WRITE")); + assert!( + profiles.entries[0] + .info_text() + .contains("Access: READ WRITE") + ); let mut form = profiles.entries[0].clone(); form.access = AccessMode::ReadOnly; profiles.save(&form)?; drop(profiles); - assert_eq!(Profiles::open(&path)?.entries[0].access, AccessMode::ReadOnly); + assert_eq!( + Profiles::open(&path)?.entries[0].access, + AccessMode::ReadOnly + ); Ok(()) } @@ -1622,9 +1879,20 @@ mod tests { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 6))?; terminal.draw(|frame| { let [top, _, bottom] = shell_areas(frame.area()); - draw_bars(frame, top, bottom, "SQLite", "data", Some(live.access_mode()), false, None); + draw_bars( + frame, + top, + bottom, + "SQLite", + "data", + Some(live.access_mode()), + false, + None, + ); })?; - let footer: String = (0..40).map(|x| terminal.backend().buffer()[(x, 5)].symbol()).collect(); + let footer: String = (0..40) + .map(|x| terminal.backend().buffer()[(x, 5)].symbol()) + .collect(); assert!(footer.contains("readwrite")); assert!(!footer.contains("readonly")); Ok(()) @@ -1632,13 +1900,24 @@ mod tests { #[test] fn access_badge_sits_before_position_in_lowercase() -> Result<()> { - for access in [Some(AccessMode::ReadOnly), Some(AccessMode::ReadWrite), None] { - let mut terminal = ratatui::Terminal::new( - ratatui::backend::TestBackend::new(80, 6), - )?; + for access in [ + Some(AccessMode::ReadOnly), + Some(AccessMode::ReadWrite), + None, + ] { + let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 6))?; terminal.draw(|frame| { let [top, _, bottom] = shell_areas(frame.area()); - draw_bars(frame, top, bottom, "SQLite", "", access, false, Some((1, 1))); + draw_bars( + frame, + top, + bottom, + "SQLite", + "", + access, + false, + Some((1, 1)), + ); })?; let buffer = terminal.backend().buffer(); let footer: String = (0..80).map(|x| buffer[(x, 5)].symbol()).collect(); @@ -1702,8 +1981,14 @@ mod tests { drop(profiles); std::fs::remove_file(&first)?; let profiles = Profiles::open(&config)?; - assert_eq!(profiles.entries[0].ca_bundle.as_deref(), Some(first.as_path())); - assert_eq!(profiles.entries[1].ca_bundle.as_deref(), Some(second.as_path())); + assert_eq!( + profiles.entries[0].ca_bundle.as_deref(), + Some(first.as_path()) + ); + assert_eq!( + profiles.entries[1].ca_bundle.as_deref(), + Some(second.as_path()) + ); assert!(profiles.entries[2].ca_bundle.is_none()); assert!(profiles.entries[0].connect().is_err()); let mut missing = profiles.entries[0].clone(); @@ -1827,7 +2112,13 @@ mod tests { fn connected_marker_survives_long_wide_profile_labels() -> Result<()> { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?; terminal.draw(|frame| { - let label = database_label("界".repeat(40).as_str(), AccessMode::ReadWrite, true, true, 30); + let label = database_label( + "界".repeat(40).as_str(), + AccessMode::ReadWrite, + true, + true, + 30, + ); assert_eq!(Line::raw(&label).width(), 30); frame.render_widget( List::new([ListItem::new(label)]).block(Block::bordered()), @@ -1835,20 +2126,29 @@ mod tests { ); })?; assert_eq!(terminal.backend().buffer()[(25, 1)].symbol(), "*"); - let row: String = (1..31).map(|x| terminal.backend().buffer()[(x, 1)].symbol()).collect(); + let row: String = (1..31) + .map(|x| terminal.backend().buffer()[(x, 1)].symbol()) + .collect(); assert!(row.ends_with("* [RW]")); let inactive = database_label("inactive", AccessMode::ReadOnly, false, false, 30); let active_badge_column = (1..31) .find(|&x| terminal.backend().buffer()[(x, 1)].symbol() == "[") - .unwrap() - 1; + .unwrap() + - 1; assert_eq!( active_badge_column as usize, Line::raw(&inactive[..inactive.find("[RO]").unwrap()]).width() ); for width in 4..9 { - assert_eq!(database_label("long", AccessMode::ReadOnly, true, true, width), "[RO]"); + assert_eq!( + database_label("long", AccessMode::ReadOnly, true, true, width), + "[RO]" + ); } - assert_eq!(database_label("long", AccessMode::ReadOnly, true, true, 1), "*"); + assert_eq!( + database_label("long", AccessMode::ReadOnly, true, true, 1), + "*" + ); Ok(()) } @@ -1915,7 +2215,6 @@ mod tests { Ok(()) } - #[test] fn shell_keeps_position_when_name_is_long_and_yields_on_short_screens() -> Result<()> { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(24, 8))?; diff --git a/src/db.rs b/src/db.rs index e30b6ac..1611fe7 100644 --- a/src/db.rs +++ b/src/db.rs @@ -1,4 +1,4 @@ -use std::{path::Path, time::Duration}; +use std::{fmt::Write, path::Path, time::Duration}; use native_tls::TlsConnector; use openssl::{ @@ -66,6 +66,8 @@ pub struct Column { pub nullable: bool, pub primary_key: bool, pub default: String, + pub generated: bool, + write_type: String, } pub struct Page { @@ -79,6 +81,99 @@ pub struct QueryResult { pub affected_rows: Option, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RawValue { + Null, + Text(Vec), + Integer(i64), + Real(u64), + Blob(Vec), +} + +impl RawValue { + pub fn input_text(&self) -> Result { + Ok(match self { + Self::Null => String::new(), + Self::Text(bytes) => std::str::from_utf8(bytes)?.into(), + Self::Integer(value) => value.to_string(), + Self::Real(bits) => f64::from_bits(*bits).to_string(), + Self::Blob(bytes) => blob_text(bytes), + }) + } + + pub fn edited(&self, input: Option<&str>) -> Result { + let Some(input) = input else { + return Ok(Self::Null); + }; + Ok(match self { + Self::Integer(_) => Self::Integer(input.parse()?), + Self::Real(_) => { + let value: f64 = input.parse()?; + if value.is_nan() { + return Err("SQLite cannot store NaN without changing it to NULL".into()); + } + Self::Real(value.to_bits()) + } + Self::Blob(_) => { + let hex = input + .strip_prefix("X'") + .and_then(|text| text.strip_suffix('\'')) + .ok_or("BLOB input must use X'HEX' notation")?; + if hex.len() % 2 != 0 || !hex.is_ascii() { + return Err("BLOB input requires pairs of hexadecimal digits".into()); + } + Self::Blob( + (0..hex.len()) + .step_by(2) + .map(|index| u8::from_str_radix(&hex[index..index + 2], 16)) + .collect::>()?, + ) + } + Self::Null | Self::Text(_) => Self::Text(input.as_bytes().to_vec()), + }) + } + + pub fn display(&self) -> Option { + match self { + Self::Null => None, + Self::Text(bytes) => Some(match std::str::from_utf8(bytes) { + Ok(text) => display_text(text), + Err(_) => bytes.escape_ascii().to_string(), + }), + _ => Some( + self.input_text() + .expect("numeric and BLOB values are valid text"), + ), + } + } +} + +fn blob_text(bytes: &[u8]) -> String { + let mut text = String::with_capacity(bytes.len() * 2 + 3); + text.push_str("X'"); + for byte in bytes { + write!(text, "{byte:02X}").expect("writing to String cannot fail"); + } + text.push('\''); + text +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RowSnapshot { + pub values: Vec, +} + +pub struct RowEdit { + pub original: RowSnapshot, + pub changes: Vec<(usize, RawValue)>, +} + +pub struct EditPage { + pub page: Page, + pub rows: Vec, + pub editable: bool, +} + impl Database { pub fn connect(connection: &str, root_cert: Option<&Path>, access: AccessMode) -> Result { Self::connect_postgres(connection.parse()?, root_cert, access) @@ -93,7 +188,8 @@ impl Database { config.connect_timeout(Duration::from_secs(5)); } let mut client = if let Some(path) = root_cert { - let tls = postgres_openssl::MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), path)?); + let tls = + postgres_openssl::MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), path)?); config.connect(tls)? } else { config.connect(MakeTlsConnector::new(TlsConnector::new()?))? @@ -197,7 +293,9 @@ impl Database { EXISTS (SELECT FROM pg_catalog.pg_index i WHERE i.indrelid = a.attrelid AND i.indisprimary AND a.attnum = ANY(i.indkey)), - COALESCE(pg_catalog.pg_get_expr(d.adbin, d.adrelid), '') + COALESCE(pg_catalog.pg_get_expr(d.adbin, d.adrelid), ''), + a.attgenerated <> '' OR a.attidentity <> '', + pg_catalog.format_type(a.atttypid, NULL) FROM pg_catalog.pg_attribute a LEFT JOIN pg_catalog.pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum @@ -213,9 +311,11 @@ impl Database { label: display_text(&name), name, data_type: display_text(row.get(1)), + write_type: row.get(6), nullable: row.get(2), primary_key: row.get(3), default: display_text(row.get(4)), + generated: row.get(5), } }) .collect()) @@ -318,16 +418,22 @@ impl Database { }) } - pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result { + pub fn edit_page( + &mut self, + relation: &Relation, + columns: &[Column], + page: usize, + ) -> Result { + let editable = editable(relation, columns, self.access_mode); let client = match &mut self.backend { Backend::Postgres(client) => client, Backend::Sqlite(connection) => { - return sqlite::page(connection, relation, columns, page); + return sqlite::edit_page(connection, relation, columns, page, editable); } }; let projection = columns .iter() - .map(|column| format!("t.{}::text", quote_identifier(&column.name))) + .map(|column| postgres_text(&format!("t.{}", quote_identifier(&column.name)))) .collect::>() .join(", "); let primary_key = columns @@ -353,27 +459,168 @@ impl Database { let offset = page_offset(page)?; let result = client.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?; let has_next = result.len() > PAGE_SIZE; - let rows = result + let rows: Vec = result .into_iter() .take(PAGE_SIZE) - .map(|row| { - (0..columns.len()) - .map(|index| row.get::<_, Option<&str>>(index).map(display_text)) - .collect() + .map(|row| RowSnapshot { + values: (0..columns.len()) + .map(|index| { + row.get::<_, Option<&str>>(index) + .map_or(RawValue::Null, |text| { + RawValue::Text(text.as_bytes().to_vec()) + }) + }) + .collect(), }) .collect(); - Ok(Page { rows, has_next }) + Ok(EditPage { + page: Page { + rows: rows + .iter() + .map(|row| row.values.iter().map(RawValue::display).collect()) + .collect(), + has_next, + }, + rows, + editable, + }) } + + pub fn apply_edits(&mut self, relation: &Relation, edits: &[RowEdit]) -> Result<()> { + if self.access_mode != AccessMode::ReadWrite { + return Err("This connection is read-only".into()); + } + let current = self + .relations()? + .into_iter() + .find(|current| { + current.id == relation.id + && current.schema == relation.schema + && current.name == relation.name + }) + .ok_or("The original table no longer exists")?; + let columns = self.columns(¤t)?; + if !editable(¤t, &columns, self.access_mode) { + return Err("Editing requires a base table with a non-null primary key".into()); + } + for (row_index, edit) in edits.iter().enumerate() { + if edit.original.values.len() != columns.len() + || columns + .iter() + .zip(&edit.original.values) + .any(|(column, value)| column.primary_key && *value == RawValue::Null) + { + return Err("The original row has no usable primary key".into()); + } + // ponytail: drafts are small; use a key set if very large batches make this quadratic scan matter. + if edits[..row_index].iter().any(|previous| { + columns + .iter() + .enumerate() + .filter(|(_, column)| column.primary_key) + .all(|(index, _)| { + previous.original.values[index] == edit.original.values[index] + }) + }) { + return Err("A batch must contain only one edit per original row".into()); + } + for (position, (index, _)) in edit.changes.iter().enumerate() { + if columns.get(*index).is_none_or(|column| column.generated) + || edit.changes[..position] + .iter() + .any(|(previous, _)| previous == index) + { + return Err("The edit targets a generated, missing, or duplicate column".into()); + } + } + } + let client = match &mut self.backend { + Backend::Postgres(client) => client, + Backend::Sqlite(connection) => { + return sqlite::apply_edits(connection, ¤t, &columns, edits); + } + }; + let mut transaction = client.build_transaction().read_only(false).start()?; + transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?; + for edit in edits.iter().filter(|edit| !edit.changes.is_empty()) { + let values = edit + .changes + .iter() + .map(|(_, value)| value) + .chain(&edit.original.values) + .map(|value| match value { + RawValue::Null => Ok(None), + RawValue::Text(bytes) => Ok(Some(std::str::from_utf8(bytes)?)), + _ => Err("PostgreSQL edits require original text values".into()), + }) + .collect::>>>()?; + let params: Vec<&(dyn postgres::types::ToSql + Sync)> = values + .iter() + .map(|value| value as &(dyn postgres::types::ToSql + Sync)) + .collect(); + let assignments = edit + .changes + .iter() + .enumerate() + .map(|(position, (index, _))| { + format!( + "{} = (${}::text)::{}", + quote_identifier(&columns[*index].name), + position + 1, + columns[*index].write_type + ) + }) + .collect::>() + .join(", "); + let guard = columns + .iter() + .enumerate() + .map(|(index, column)| { + format!( + "{} COLLATE \"C\" IS NOT DISTINCT FROM ${}::text COLLATE \"C\"", + postgres_text("e_identifier(&column.name)), + edit.changes.len() + index + 1 + ) + }) + .collect::>() + .join(" AND "); + let affected = transaction.execute( + &format!( + "UPDATE {}.{} SET {assignments} WHERE {guard}", + quote_identifier(¤t.schema), + quote_identifier(¤t.name) + ), + ¶ms, + )?; + if affected != 1 { + return Err( + "Edit conflict: a row changed or disappeared; no edits were saved".into(), + ); + } + } + transaction.commit()?; + Ok(()) + } +} + +fn editable(relation: &Relation, columns: &[Column], access: AccessMode) -> bool { + access == AccessMode::ReadWrite + && matches!(relation.kind.as_str(), "table" | "partitioned table") + && columns.iter().any(|column| column.primary_key) + && columns + .iter() + .filter(|column| column.primary_key) + .all(|column| !column.nullable) } fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result { if mode != SslMode::Require { return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into()); } - let pem = std::fs::read(root_cert) - .map_err(|error| format!("Cannot read CA bundle: {error}"))?; - let certificates = X509::stack_from_pem(&pem) - .map_err(|error| format!("Invalid PEM CA bundle: {error}"))?; + let pem = + std::fs::read(root_cert).map_err(|error| format!("Cannot read CA bundle: {error}"))?; + let certificates = + X509::stack_from_pem(&pem).map_err(|error| format!("Invalid PEM CA bundle: {error}"))?; if certificates.is_empty() { return Err("CA bundle must contain at least one PEM CA certificate.".into()); } @@ -418,7 +665,10 @@ fn checked_statement(sql: &str, sqlite: bool) -> Result { | Statement::AttachDuckDBDatabase { .. } | Statement::DetachDuckDBDatabase { .. } ) { - return Err("Transaction, session, and attached-database controls are not allowed in the editor.".into()); + return Err( + "Transaction, session, and attached-database controls are not allowed in the editor." + .into(), + ); } Ok(statement) } @@ -426,7 +676,9 @@ fn checked_statement(sql: &str, sqlite: bool) -> Result { fn statement_body(statement: &Statement) -> &Statement { if let Statement::Query(query) = statement { match query.body.as_ref() { - SetExpr::Insert(body) | SetExpr::Update(body) | SetExpr::Delete(body) + SetExpr::Insert(body) + | SetExpr::Update(body) + | SetExpr::Delete(body) | SetExpr::Merge(body) => return body, _ => {} } @@ -440,6 +692,13 @@ fn page_offset(page: usize) -> Result { .ok_or_else(|| "page offset overflow".into()) } +fn postgres_text(column: &str) -> String { + // Native output preserves CHAR padding; ::text would strip it. Composite NULL tests must test the whole value. + format!( + "CASE WHEN {column} IS NOT DISTINCT FROM NULL THEN NULL ELSE pg_catalog.format('%s', {column}) END" + ) +} + fn quote_identifier(value: &str) -> String { format!("\"{}\"", value.replace('"', "\"\"")) } @@ -461,6 +720,229 @@ pub fn display_text(value: &str) -> String { mod tests { use super::*; + #[test] + #[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"] + fn staged_postgres_edits_preserve_text_and_rollback_entire_batches() -> Result<()> { + let connection = std::env::var("DBDASH_TEST_DATABASE_URL")?; + let fixture = connection + .parse::()? + .connect(MakeTlsConnector::new(TlsConnector::new()?))?; + let schema = format!("dbdash_test_drafts_{}", std::process::id()); + struct Cleanup(Client, String); + impl Drop for Cleanup { + fn drop(&mut self) { + let _ = self + .0 + .batch_execute(&format!("DROP SCHEMA {} CASCADE", self.1)); + } + } + let mut cleanup = Cleanup(fixture, schema.clone()); + cleanup.0.batch_execute(&format!( + "CREATE SCHEMA {schema}; + CREATE TYPE {schema}.\"odd +mood\" AS ENUM ('ok', 'great'); + CREATE TYPE {schema}.pair AS (first integer, second text); + CREATE TABLE {schema}.\"odd\"\"table\" ( + tenant text NOT NULL, id integer NOT NULL, missing text, empty text, multiline text, + amount numeric, bytes bytea, mood {schema}.\"odd +mood\", generated integer GENERATED ALWAYS AS (id + 1) STORED, + padded character(5), bounded varchar(3), pair {schema}.pair, + PRIMARY KEY (tenant, id)); + INSERT INTO {schema}.\"odd\"\"table\" + (tenant, id, missing, empty, multiline, amount, bytes, mood, padded, bounded, pair) + SELECT 'a', n, NULL, '', E'one\\ntwo', 12.3400, decode('00ff', 'hex'), 'ok', + 'x', 'abc', ROW(NULL, NULL)::{schema}.pair + FROM generate_series(1, 101) n; + CREATE TABLE {schema}.keyless (value text); + INSERT INTO {schema}.keyless VALUES ('x'); + CREATE VIEW {schema}.sample_view AS SELECT * FROM {schema}.\"odd\"\"table\"; + CREATE TABLE {schema}.identity_key ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value text); + INSERT INTO {schema}.identity_key (value) VALUES ('x'); + CREATE TABLE {schema}.audit (id integer); + CREATE FUNCTION {schema}.audit_update() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN INSERT INTO {schema}.audit VALUES (NEW.id); RETURN NEW; END $$; + CREATE TRIGGER edited AFTER UPDATE ON {schema}.\"odd\"\"table\" + FOR EACH ROW EXECUTE FUNCTION {schema}.audit_update();" + ))?; + let mut db = Database::connect(&connection, None, AccessMode::ReadWrite)?; + let relations = db.relations()?; + let table = relations + .iter() + .find(|table| table.schema == schema && table.name == "odd\"table") + .unwrap(); + let columns = db.columns(table)?; + assert!(columns[8].generated); + let first = db.edit_page(table, &columns, 0)?; + let last = db.edit_page(table, &columns, 1)?; + assert!(first.editable && first.page.has_next && !last.page.has_next); + let original = first.rows[0].clone(); + assert_eq!(original.values[2], RawValue::Null); + assert_eq!(original.values[3], RawValue::Text(Vec::new())); + assert_eq!(original.values[4].input_text()?, "one\ntwo"); + assert_eq!(first.page.rows[0][4].as_deref(), Some("one\\ntwo")); + assert_eq!(original.values[5].input_text()?, "12.3400"); + assert_eq!(original.values[6].input_text()?, "\\x00ff"); + assert_eq!(original.values[9].input_text()?, "x "); + assert_eq!(original.values[11].input_text()?, "(,)"); + let mut edits = vec![ + RowEdit { + original: original.clone(), + changes: vec![ + (0, original.values[0].edited(Some("bound'\"tenant"))?), + (1, original.values[1].edited(Some("500"))?), + (2, original.values[2].edited(Some(""))?), + (3, original.values[3].edited(None)?), + (5, original.values[5].edited(Some("9.8700"))?), + (6, original.values[6].edited(Some("\\xfe00"))?), + (7, original.values[7].edited(Some("great"))?), + ], + }, + RowEdit { + original: last.rows[0].clone(), + changes: vec![(4, last.rows[0].values[4].edited(Some("new\nlines"))?)], + }, + ]; + cleanup.0.execute( + &format!("UPDATE {schema}.\"odd\"\"table\" SET missing = '' WHERE id = 101"), + &[], + )?; + cleanup + .0 + .execute(&format!("DELETE FROM {schema}.audit"), &[])?; + assert!(db.apply_edits(table, &edits).is_err()); + assert_eq!(db.edit_page(table, &columns, 0)?.rows[0], original); + assert_eq!( + db.query(&format!("SELECT COUNT(*) FROM {schema}.audit"))? + .page + .rows, + [vec![Some("0".into())]] + ); + edits[1].original = db.edit_page(table, &columns, 1)?.rows[0].clone(); + db.apply_edits(table, &edits)?; + assert_eq!( + db.query(&format!("SELECT COUNT(*) FROM {schema}.audit"))? + .page + .rows, + [vec![Some("2".into())]] + ); + assert_eq!( + db.query(&format!( + "SELECT tenant, id, missing, empty, multiline, amount, bytes, mood, generated + FROM {schema}.\"odd\"\"table\" WHERE id = 500" + ))? + .page + .rows, + [vec![ + Some("bound'\"tenant".into()), + Some("500".into()), + Some("".into()), + None, + Some("one\\ntwo".into()), + Some("9.8700".into()), + Some("\\xfe00".into()), + Some("great".into()), + Some("501".into()), + ]] + ); + assert_eq!( + db.query(&format!( + "SELECT multiline FROM {schema}.\"odd\"\"table\" WHERE id = 101" + ))? + .page + .rows, + [vec![Some("new\\nlines".into())]] + ); + let fresh = db.edit_page(table, &columns, 0)?; + let before = fresh.rows[0].clone(); + assert!( + db.apply_edits( + table, + &[ + RowEdit { + original: before.clone(), + changes: vec![(5, RawValue::Text(b"77".to_vec()))] + }, + RowEdit { + original: fresh.rows[1].clone(), + changes: vec![(1, RawValue::Text(b"2".to_vec()))] + }, + ] + ) + .is_err() + ); + assert_eq!(db.edit_page(table, &columns, 0)?.rows[0], before); + assert!( + db.apply_edits( + table, + &[RowEdit { + original: before.clone(), + changes: vec![(10, RawValue::Text(b"too long".to_vec()))], + }] + ) + .is_err() + ); + assert_eq!(db.edit_page(table, &columns, 0)?.rows[0], before); + assert!( + db.apply_edits( + table, + &[RowEdit { + original: before.clone(), + changes: vec![(8, RawValue::Text(b"0".to_vec()))], + }] + ) + .is_err() + ); + for name in ["keyless", "sample_view"] { + let readonly = relations + .iter() + .find(|table| table.schema == schema && table.name == name) + .unwrap(); + let columns = db.columns(readonly)?; + let page = db.edit_page(readonly, &columns, 0)?; + assert!(!page.editable); + assert!( + db.apply_edits( + readonly, + &[RowEdit { + original: page.rows[0].clone(), + changes: vec![(0, RawValue::Text(b"changed".to_vec()))], + }] + ) + .is_err() + ); + } + let identity = relations + .iter() + .find(|table| table.schema == schema && table.name == "identity_key") + .unwrap(); + let identity_columns = db.columns(identity)?; + assert!(identity_columns[0].generated); + let identity_page = db.edit_page(identity, &identity_columns, 0)?; + assert!(identity_page.editable); + db.apply_edits( + identity, + &[RowEdit { + original: identity_page.rows[0].clone(), + changes: vec![(1, RawValue::Text(b"edited".to_vec()))], + }], + )?; + let mut readonly = Database::connect(&connection, None, AccessMode::ReadOnly)?; + assert!(!readonly.edit_page(table, &columns, 0)?.editable); + assert!( + readonly + .apply_edits( + table, + &[RowEdit { + original: before, + changes: vec![(5, RawValue::Text(b"0".to_vec()))], + }] + ) + .is_err() + ); + Ok(()) + } + #[test] fn private_ca_tls_accepts_absent_eku_but_rejects_wrong_usage_host_and_expiry() -> Result<()> { use openssl::{ @@ -470,7 +952,10 @@ mod tests { pkey::PKey, rsa::Rsa, ssl::SslAcceptor, - x509::{X509NameBuilder, extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName}}, + x509::{ + X509NameBuilder, + extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName}, + }, }; use std::net::{TcpListener, TcpStream}; @@ -516,7 +1001,8 @@ mod tests { leaf.set_not_before(&before)?; leaf.set_not_after(&after)?; leaf.append_extension(BasicConstraints::new().critical().build()?)?; - let san = SubjectAlternativeName::new().dns("localhost") + let san = SubjectAlternativeName::new() + .dns("localhost") .build(&leaf.x509v3_context(Some(&ca), None))?; leaf.append_extension(san)?; if client_only { @@ -533,8 +1019,12 @@ mod tests { socket.set_write_timeout(Some(Duration::from_secs(3)))?; let server = std::thread::spawn(move || { let (socket, _) = listener.accept().unwrap(); - socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); - socket.set_write_timeout(Some(Duration::from_secs(3))).unwrap(); + socket + .set_read_timeout(Some(Duration::from_secs(3))) + .unwrap(); + socket + .set_write_timeout(Some(Duration::from_secs(3))) + .unwrap(); let _ = acceptor.accept(socket); }); let connector = postgres_tls(SslMode::Require, bundle.path())?; @@ -542,8 +1032,11 @@ mod tests { configuration.param_mut().set_time(clock); let result = configuration.connect(hostname, socket); server.join().unwrap(); - assert_eq!(result.is_ok(), accepted, - "client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}"); + assert_eq!( + result.is_ok(), + accepted, + "client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}" + ); } Ok(()) } @@ -554,7 +1047,11 @@ mod tests { for mode in [SslMode::Prefer, SslMode::Disable] { assert!(postgres_tls(mode, bundle.path()).is_err()); } - for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] { + for contents in [ + "", + "not a PEM certificate", + "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----", + ] { std::fs::write(bundle.path(), contents)?; assert!(postgres_tls(SslMode::Require, bundle.path()).is_err()); } @@ -584,7 +1081,8 @@ mod tests { #[test] #[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"] - fn query_postgres_read_write_commits_complete_statements_and_rolls_back_failures() -> Result<()> { + fn query_postgres_read_write_commits_complete_statements_and_rolls_back_failures() -> Result<()> + { let connection = std::env::var("DBDASH_TEST_DATABASE_URL")?; let fixture = connection .parse::()? @@ -593,16 +1091,24 @@ mod tests { struct Cleanup(Client, String); impl Drop for Cleanup { fn drop(&mut self) { - let _ = self.0.batch_execute(&format!("DROP SCHEMA {} CASCADE", self.1)); + let _ = self + .0 + .batch_execute(&format!("DROP SCHEMA {} CASCADE", self.1)); } } let _cleanup = Cleanup(fixture, schema.clone()); let mut db = Database::connect(&connection, None, AccessMode::ReadWrite)?; assert_eq!(db.access_mode(), AccessMode::ReadWrite); - assert_eq!(db.query(&format!("CREATE SCHEMA {schema}"))?.affected_rows, Some(0)); + assert_eq!( + db.query(&format!("CREATE SCHEMA {schema}"))?.affected_rows, + Some(0) + ); db.query(&format!("CREATE TYPE {schema}.mood AS ENUM ('ok')"))?; assert_eq!( - db.query(&format!("CREATE TABLE {schema}.saved (id INTEGER PRIMARY KEY, value INTEGER UNIQUE)"))?.affected_rows, + db.query(&format!( + "CREATE TABLE {schema}.saved (id INTEGER PRIMARY KEY, value INTEGER UNIQUE)" + ))? + .affected_rows, Some(0) ); let inserted = db.query(&format!( @@ -618,14 +1124,21 @@ mod tests { assert!(inserted.page.has_next); assert_eq!( inserted.headers, - ["id", "amount", "details", "tags", "bytes", "happened", "mood", "missing", "note"] + [ + "id", "amount", "details", "tags", "bytes", "happened", "mood", "missing", "note" + ] ); assert_eq!( inserted.page.rows[0], vec![ - Some("1".into()), Some("1.25".into()), Some("{\"ok\": true}".into()), - Some("{a,b}".into()), Some("\\x00ff".into()), - Some("2020-01-01 12:00:00".into()), Some("ok".into()), None, + Some("1".into()), + Some("1.25".into()), + Some("{\"ok\": true}".into()), + Some("{a,b}".into()), + Some("\\x00ff".into()), + Some("2020-01-01 12:00:00".into()), + Some("ok".into()), + None, Some("semi;colon".into()) ] ); @@ -633,32 +1146,68 @@ mod tests { for sql in [ format!("UPDATE {schema}.saved SET value = value + 1000; DELETE FROM {schema}.saved"), format!("INSERT INTO {schema}.saved VALUES (151, 151); SELECT 1"), - format!("UPDATE {schema}.saved SET value = CASE WHEN id = 150 THEN 1001 ELSE value + 1000 END"), - "BEGIN".into(), "COMMIT".into(), "END".into(), "ROLLBACK".into(), - "SAVEPOINT edit".into(), "RELEASE edit".into(), "SET TRANSACTION READ ONLY".into(), - "PREPARE escape AS COMMIT".into(), "EXECUTE escape".into(), + format!( + "UPDATE {schema}.saved SET value = CASE WHEN id = 150 THEN 1001 ELSE value + 1000 END" + ), + "BEGIN".into(), + "COMMIT".into(), + "END".into(), + "ROLLBACK".into(), + "SAVEPOINT edit".into(), + "RELEASE edit".into(), + "SET TRANSACTION READ ONLY".into(), + "PREPARE escape AS COMMIT".into(), + "EXECUTE escape".into(), ] { - assert!(db.query(&sql).is_err(), "accepted control, batch, or failed write: {sql}"); + assert!( + db.query(&sql).is_err(), + "accepted control, batch, or failed write: {sql}" + ); } assert_eq!( - db.query(&format!("SELECT COUNT(*), MAX(value) FROM {schema}.saved"))?.page.rows, + db.query(&format!("SELECT COUNT(*), MAX(value) FROM {schema}.saved"))? + .page + .rows, [vec![Some("150".into()), Some("150".into())]] ); - let updated = db.query(&format!("UPDATE {schema}.saved SET value = value + 1000 RETURNING value"))?; + let updated = db.query(&format!( + "UPDATE {schema}.saved SET value = value + 1000 RETURNING value" + ))?; assert_eq!(updated.affected_rows, Some(150)); assert_eq!(updated.page.rows.len(), PAGE_SIZE); assert!(updated.page.has_next); - assert_eq!(db.query(&format!("DELETE FROM {schema}.saved WHERE id > 140"))?.affected_rows, Some(10)); - assert_eq!(db.query(&format!("DELETE FROM {schema}.saved WHERE id > 140"))?.affected_rows, Some(0)); - assert_eq!(db.query(&format!("CREATE INDEX saved_value ON {schema}.saved(value)"))?.affected_rows, Some(0)); + assert_eq!( + db.query(&format!("DELETE FROM {schema}.saved WHERE id > 140"))? + .affected_rows, + Some(10) + ); + assert_eq!( + db.query(&format!("DELETE FROM {schema}.saved WHERE id > 140"))? + .affected_rows, + Some(0) + ); + assert_eq!( + db.query(&format!( + "CREATE INDEX saved_value ON {schema}.saved(value)" + ))? + .affected_rows, + Some(0) + ); drop(db); let mut reopened = Database::connect(&connection, None, AccessMode::ReadOnly)?; assert_eq!( - reopened.query(&format!("SELECT COUNT(*), MAX(value) FROM {schema}.saved"))?.page.rows, + reopened + .query(&format!("SELECT COUNT(*), MAX(value) FROM {schema}.saved"))? + .page + .rows, [vec![Some("140".into()), Some("1140".into())]] ); - assert!(reopened.query(&format!("DELETE FROM {schema}.saved")).is_err()); + assert!( + reopened + .query(&format!("DELETE FROM {schema}.saved")) + .is_err() + ); Ok(()) } @@ -712,7 +1261,7 @@ mod tests { assert!(columns[1].nullable); assert_eq!(columns[2].data_type, "numeric"); assert_eq!(columns[2].default, "1.25"); - let first = db.page(table, &columns, 0)?; + let first = db.edit_page(table, &columns, 0)?.page; assert!(first.has_next); assert_eq!(first.rows.len(), 100); assert_eq!( @@ -729,7 +1278,7 @@ mod tests { ); assert_eq!(first.rows[1][1].as_deref(), Some("line\\nnext")); assert_eq!(first.rows[2][1].as_deref(), Some("NULL")); - let second = db.page(table, &columns, 1)?; + let second = db.edit_page(table, &columns, 1)?.page; assert!(!second.has_next); assert_eq!(second.rows[0][0].as_deref(), Some("101")); let empty = relations @@ -737,7 +1286,7 @@ mod tests { .find(|r| r.schema == schema && r.name == "empty_table") .unwrap(); let columns = db.columns(empty)?; - let page = db.page(empty, &columns, 0)?; + let page = db.edit_page(empty, &columns, 0)?.page; assert!(page.rows.is_empty()); assert!(!page.has_next); let query = db.query(&format!( diff --git a/src/db/sqlite.rs b/src/db/sqlite.rs index db992e5..085bb14 100644 --- a/src/db/sqlite.rs +++ b/src/db/sqlite.rs @@ -1,11 +1,15 @@ -use std::{fmt::Write, path::Path, time::Duration}; +use std::{path::Path, time::Duration}; -use rusqlite::{Connection, OpenFlags, params, types::ValueRef}; +use rusqlite::{ + Connection, OpenFlags, params, params_from_iter, + types::{ToSqlOutput, Value, ValueRef}, +}; use sqlparser::ast::Statement; use super::{ - AccessMode, Column, PAGE_SIZE, Page, QueryResult, Relation, checked_statement, display_text, - page_offset, quote_identifier, statement_body, + AccessMode, Column, EditPage, PAGE_SIZE, Page, QueryResult, RawValue, Relation, RowEdit, + RowSnapshot, blob_text, checked_statement, display_text, page_offset, quote_identifier, + statement_body, }; use crate::Result; @@ -29,7 +33,7 @@ pub(super) fn open(path: &Path, access: AccessMode) -> Result { pub(super) fn relations(connection: &Connection) -> Result> { let mut statement = connection.prepare( - "SELECT s.rowid, s.name, s.type, t.wr + "SELECT s.rowid, s.name, s.type, t.wr, t.type FROM main.sqlite_schema s JOIN pragma_table_list t ON t.schema = 'main' AND t.name = s.name WHERE s.type IN ('table', 'view') AND s.name NOT GLOB 'sqlite_*' @@ -46,7 +50,11 @@ pub(super) fn relations(connection: &Connection) -> Result> { label: display_text(&format!("main.{name}")), name, physical_order: kind == "table" && !row.get::<_, bool>(3)?, - kind, + kind: if row.get::<_, String>(4)? == "virtual" { + "virtual table".into() + } else { + kind + }, }) })? .collect::>>()?; @@ -58,7 +66,7 @@ pub(super) fn columns(connection: &Connection, relation: &Relation) -> Result 0 AND upper(type) = 'INTEGER' AND NOT EXISTS ( SELECT 1 FROM pragma_index_list(?1, 'main') WHERE origin = 'pk' - ) AS rowid_alias + ) AS rowid_alias, hidden FROM pragma_table_xinfo(?1, 'main') WHERE hidden <> 1 ORDER BY cid", )?; @@ -76,18 +84,21 @@ pub(super) fn columns(connection: &Connection, relation: &Relation) -> Result(4)? > 0, + generated: row.get::<_, i64>(6)? != 0, + write_type: String::new(), }) })? .collect::>>()?; Ok(columns) } -pub(super) fn page( +pub(super) fn edit_page( connection: &Connection, relation: &Relation, columns: &[Column], page: usize, -) -> Result { + editable: bool, +) -> Result { let projection = columns .iter() .map(|column| format!("t.{}", quote_identifier(&column.name))) @@ -119,7 +130,27 @@ pub(super) fn page( ); let mut statement = connection.prepare(&sql)?; let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?; - read_page(&mut query, columns.len(), false) + let mut rows = Vec::with_capacity(PAGE_SIZE); + while let Some(row) = query.next()? { + rows.push(RowSnapshot { + values: (0..columns.len()) + .map(|index| row.get_ref(index).map(raw_value)) + .collect::>()?, + }); + } + let has_next = rows.len() > PAGE_SIZE; + rows.truncate(PAGE_SIZE); + Ok(EditPage { + page: Page { + rows: rows + .iter() + .map(|row| row.values.iter().map(RawValue::display).collect()) + .collect(), + has_next, + }, + rows, + editable, + }) } pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> Result { @@ -128,7 +159,11 @@ pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> R let transaction = connection.unchecked_transaction()?; let (headers, page) = { let mut statement = transaction.prepare(sql)?; - let headers = statement.column_names().into_iter().map(display_text).collect(); + let headers = statement + .column_names() + .into_iter() + .map(display_text) + .collect(); let column_count = statement.column_count(); let mut query = statement.query([])?; let page = read_page(&mut query, column_count, true)?; @@ -137,13 +172,20 @@ pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> R let body = statement_body(&parsed); let affected_rows = if matches!(body, Statement::Query(_)) { None - } else if matches!(body, Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_)) { + } else if matches!( + body, + Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_) + ) { Some(transaction.changes()) } else { Some(0) }; transaction.commit()?; - return Ok(QueryResult { headers, page, affected_rows }); + return Ok(QueryResult { + headers, + page, + affected_rows, + }); } let original = connection.prepare(sql)?; let headers = original @@ -157,7 +199,11 @@ pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> R ))?; let column_count = statement.column_count(); let page = read_page(&mut statement.query([])?, column_count, false)?; - Ok(QueryResult { headers, page, affected_rows: None }) + Ok(QueryResult { + headers, + page, + affected_rows: None, + }) } fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize, exhaust: bool) -> Result { @@ -177,30 +223,87 @@ fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize, exhaust: bool) ValueRef::Null => None, ValueRef::Integer(value) => Some(value.to_string()), ValueRef::Real(value) => Some(value.to_string()), - ValueRef::Text(value) => Some(match std::str::from_utf8(value) { + ValueRef::Text(bytes) => Some(match std::str::from_utf8(bytes) { Ok(text) => display_text(text), - Err(_) => value.escape_ascii().to_string(), + Err(_) => bytes.escape_ascii().to_string(), }), - ValueRef::Blob(value) => Some(blob_text(value)), + ValueRef::Blob(bytes) => Some(blob_text(bytes)), }) }) .collect::>>()?; rows.push(values); } - Ok(Page { - rows, - has_next, - }) + Ok(Page { rows, has_next }) } -fn blob_text(bytes: &[u8]) -> String { - let mut output = String::with_capacity(bytes.len() * 2 + 3); - output.push_str("X'"); - for byte in bytes { - write!(output, "{byte:02X}").expect("writing to a String cannot fail"); +fn raw_value(value: ValueRef<'_>) -> RawValue { + match value { + ValueRef::Null => RawValue::Null, + ValueRef::Integer(value) => RawValue::Integer(value), + ValueRef::Real(value) => RawValue::Real(value.to_bits()), + ValueRef::Text(value) => RawValue::Text(value.to_vec()), + ValueRef::Blob(value) => RawValue::Blob(value.to_vec()), } - output.push('\''); - output +} + +impl rusqlite::ToSql for RawValue { + fn to_sql(&self) -> rusqlite::Result> { + Ok(match self { + Self::Null => ToSqlOutput::Borrowed(ValueRef::Null), + Self::Text(bytes) => ToSqlOutput::Borrowed(ValueRef::Text(bytes)), + Self::Blob(bytes) => ToSqlOutput::Borrowed(ValueRef::Blob(bytes)), + Self::Integer(value) => ToSqlOutput::Borrowed(ValueRef::Integer(*value)), + Self::Real(bits) => ToSqlOutput::Owned(Value::Real(f64::from_bits(*bits))), + }) + } +} + +pub(super) fn apply_edits( + connection: &mut Connection, + relation: &Relation, + columns: &[Column], + edits: &[RowEdit], +) -> Result<()> { + let transaction = + connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?; + for edit in edits.iter().filter(|edit| !edit.changes.is_empty()) { + let assignments = edit + .changes + .iter() + .enumerate() + .map(|(position, (index, _))| { + format!( + "{} = ?{}", + quote_identifier(&columns[*index].name), + position + 1 + ) + }) + .collect::>() + .join(", "); + let guard = columns.iter().enumerate().map(|(index, column)| { + let name = quote_identifier(&column.name); + let parameter = edit.changes.len() + index + 1; + format!("typeof({name}) = typeof(?{parameter}) AND {name} IS ?{parameter} COLLATE BINARY") + }).collect::>().join(" AND "); + let parameters = edit + .changes + .iter() + .map(|(_, value)| value) + .chain(&edit.original.values); + let affected = transaction.execute( + &format!( + "UPDATE {}.{} SET {assignments} WHERE {guard}", + quote_identifier(&relation.schema), + quote_identifier(&relation.name) + ), + params_from_iter(parameters), + )?; + if affected != 1 { + return Err("Edit conflict: a row changed or disappeared; no edits were saved".into()); + } + } + transaction.commit()?; + Ok(()) } #[cfg(test)] @@ -208,6 +311,214 @@ mod tests { use super::*; use crate::db::{Backend, Database}; + #[test] + fn staged_edits_preserve_raw_storage_and_commit_once_per_row_across_pages() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("drafts.sqlite"); + let writer = Connection::open(&path)?; + writer.execute_batch( + "CREATE TABLE \"odd\"\"table\" ( + tenant TEXT NOT NULL, id INTEGER NOT NULL, missing TEXT, empty TEXT, + multiline TEXT, integer_value INTEGER, real_value REAL, bytes BLOB, invalid TEXT, + generated INTEGER GENERATED ALWAYS AS (id + 1), + PRIMARY KEY (tenant, id)) WITHOUT ROWID; + WITH RECURSIVE numbers(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 101 + ) INSERT INTO \"odd\"\"table\" + (tenant, id, missing, empty, multiline, integer_value, real_value, bytes, invalid) + SELECT 'a', n, NULL, '', 'one' || char(10) || 'two', 7, 2.5, X'00FF', + CAST(X'FF' AS TEXT) FROM numbers; + CREATE TABLE audit (id INTEGER); + CREATE TRIGGER updated AFTER UPDATE ON \"odd\"\"table\" + BEGIN INSERT INTO audit VALUES (new.id); END;", + )?; + let mut db = Database::open_sqlite(&path, AccessMode::ReadWrite)?; + let table = db + .relations()? + .into_iter() + .find(|table| table.name == "odd\"table") + .unwrap(); + let columns = db.columns(&table)?; + assert!(columns[9].generated); + let first = db.edit_page(&table, &columns, 0)?; + let last = db.edit_page(&table, &columns, 1)?; + assert!(first.editable && first.page.has_next && !last.page.has_next); + let original = first.rows[0].clone(); + assert_eq!( + original.values[2..9], + [ + RawValue::Null, + RawValue::Text(Vec::new()), + RawValue::Text(b"one\ntwo".to_vec()), + RawValue::Integer(7), + RawValue::Real(2.5_f64.to_bits()), + RawValue::Blob(vec![0, 255]), + RawValue::Text(vec![255]), + ] + ); + assert_eq!(first.page.rows[0][4].as_deref(), Some("one\\ntwo")); + assert_eq!(original.values[4].input_text()?, "one\ntwo"); + assert!(original.values[8].input_text().is_err()); + assert!(original.values[5].edited(Some("not an integer")).is_err()); + assert!(original.values[7].edited(Some("X'0Z'")).is_err()); + let edits = vec![ + RowEdit { + original: original.clone(), + changes: vec![ + (0, original.values[0].edited(Some("bound'\"tenant"))?), + (1, original.values[1].edited(Some("500"))?), + (2, original.values[2].edited(Some(""))?), + (3, original.values[3].edited(None)?), + (5, original.values[5].edited(Some("42"))?), + (6, original.values[6].edited(Some("3.25"))?), + (7, original.values[7].edited(Some("X'FE00'"))?), + ], + }, + RowEdit { + original: last.rows[0].clone(), + changes: vec![(4, last.rows[0].values[4].edited(Some("new\nlines"))?)], + }, + ]; + // A concurrent NULL -> empty change must conflict, not overwrite the newer row. + writer.execute( + "UPDATE \"odd\"\"table\" SET missing = '' WHERE id = 101", + [], + )?; + writer.execute("DELETE FROM audit", [])?; + assert!(db.apply_edits(&table, &edits).is_err()); + assert_eq!(db.edit_page(&table, &columns, 0)?.rows[0], original); + assert_eq!( + db.query("SELECT COUNT(*) FROM audit")?.page.rows, + [vec![Some("0".into())]] + ); + let mut edits = edits; + edits[1].original = db.edit_page(&table, &columns, 1)?.rows[0].clone(); + db.apply_edits(&table, &edits)?; + assert_eq!( + db.query("SELECT COUNT(*) FROM audit")?.page.rows, + [vec![Some("2".into())]] + ); + assert_eq!(db.query( + "SELECT tenant, id, missing, empty, multiline, typeof(integer_value), integer_value, + typeof(real_value), real_value, typeof(bytes), hex(bytes), typeof(invalid), hex(invalid), + generated FROM \"odd\"\"table\" WHERE id = 500" + )?.page.rows, [vec![ + Some("bound'\"tenant".into()), Some("500".into()), Some("".into()), None, + Some("one\\ntwo".into()), Some("integer".into()), Some("42".into()), + Some("real".into()), Some("3.25".into()), Some("blob".into()), Some("FE00".into()), + Some("text".into()), Some("FF".into()), Some("501".into()), + ]]); + assert_eq!( + db.query("SELECT multiline FROM \"odd\"\"table\" WHERE id = 101")? + .page + .rows, + [vec![Some("new\\nlines".into())]] + ); + + let fresh = db.edit_page(&table, &columns, 0)?; + let before = fresh.rows[0].clone(); + let failing = [ + RowEdit { + original: before.clone(), + changes: vec![(5, RawValue::Integer(99))], + }, + RowEdit { + original: fresh.rows[1].clone(), + changes: vec![(1, RawValue::Integer(2))], + }, + ]; + assert!(db.apply_edits(&table, &failing).is_err()); + assert_eq!(db.edit_page(&table, &columns, 0)?.rows[0], before); + writer.execute_batch( + "CREATE TRIGGER skip_update BEFORE UPDATE ON \"odd\"\"table\" + WHEN new.integer_value = 999 BEGIN SELECT RAISE(IGNORE); END;", + )?; + let skipped = [ + RowEdit { + original: before.clone(), + changes: vec![(5, RawValue::Integer(99))], + }, + RowEdit { + original: fresh.rows[1].clone(), + changes: vec![(5, RawValue::Integer(999))], + }, + ]; + assert!(db.apply_edits(&table, &skipped).is_err()); + assert_eq!(db.edit_page(&table, &columns, 0)?.rows[0], before); + assert!( + db.apply_edits( + &table, + &[RowEdit { + original: before.clone(), + changes: vec![(9, RawValue::Integer(0))], + }] + ) + .is_err() + ); + assert!( + db.apply_edits( + &table, + &[ + RowEdit { + original: before.clone(), + changes: vec![(5, RawValue::Integer(7))] + }, + RowEdit { + original: before.clone(), + changes: vec![(4, RawValue::Text(b"duplicate".to_vec()))] + }, + ] + ) + .is_err() + ); + let mut readonly = Database::open_sqlite(&path, AccessMode::ReadOnly)?; + assert!(!readonly.edit_page(&table, &columns, 0)?.editable); + assert!( + readonly + .apply_edits( + &table, + &[RowEdit { + original: before, + changes: vec![(5, RawValue::Integer(0))], + }] + ) + .is_err() + ); + Ok(()) + } + + #[test] + fn keyless_nullable_keys_and_views_cannot_be_edited() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("readonly.sqlite"); + let writer = Connection::open(&path)?; + writer.execute_batch( + "CREATE TABLE keyless (value TEXT); INSERT INTO keyless VALUES ('x'); + CREATE TABLE nullable_key (id TEXT PRIMARY KEY, value TEXT); + INSERT INTO nullable_key VALUES (NULL, 'x'); + CREATE VIEW sample_view AS SELECT * FROM nullable_key;", + )?; + let mut db = Database::open_sqlite(&path, AccessMode::ReadWrite)?; + for table in db.relations()? { + let columns = db.columns(&table)?; + let page = db.edit_page(&table, &columns, 0)?; + assert!(!page.editable, "{}", table.name); + assert!( + db.apply_edits( + &table, + &[RowEdit { + original: page.rows[0].clone(), + changes: vec![(columns.len() - 1, RawValue::Text(b"changed".to_vec()))], + }] + ) + .is_err(), + "{}", + table.name + ); + } + Ok(()) + } + #[test] fn query_sqlite_read_only() -> Result<()> { let directory = tempfile::tempdir()?; @@ -278,7 +589,8 @@ break\", 2.5 AS amount; \n", let mut db = Database::open_sqlite(&path, AccessMode::ReadWrite)?; assert_eq!(db.access_mode(), AccessMode::ReadWrite); assert_eq!( - db.query("CREATE TABLE saved (id INTEGER PRIMARY KEY, value INTEGER UNIQUE)")?.affected_rows, + db.query("CREATE TABLE saved (id INTEGER PRIMARY KEY, value INTEGER UNIQUE)")? + .affected_rows, Some(0) ); let inserted = db.query( @@ -299,27 +611,47 @@ break\", 2.5 AS amount; \n", for sql in [ "UPDATE saved SET value = value + 1000; DELETE FROM saved", "INSERT INTO saved VALUES (151, 151); SELECT 1", - "BEGIN", "COMMIT", "END", "ROLLBACK", "SAVEPOINT edit", "RELEASE edit", + "BEGIN", + "COMMIT", + "END", + "ROLLBACK", + "SAVEPOINT edit", + "RELEASE edit", "PRAGMA query_only = OFF", ] { assert!(db.query(sql).is_err(), "accepted control or batch: {sql}"); } assert_eq!( - db.query("SELECT COUNT(*), MAX(value) FROM saved")?.page.rows, + db.query("SELECT COUNT(*), MAX(value) FROM saved")? + .page + .rows, [vec![Some("150".into()), Some("150".into())]] ); let updated = db.query("UPDATE saved SET value = value + 1000 RETURNING value")?; assert_eq!(updated.affected_rows, Some(150)); assert_eq!(updated.page.rows.len(), PAGE_SIZE); assert!(updated.page.has_next); - assert_eq!(db.query("DELETE FROM saved WHERE id > 140")?.affected_rows, Some(10)); - assert_eq!(db.query("DELETE FROM saved WHERE id > 140")?.affected_rows, Some(0)); - assert_eq!(db.query("CREATE INDEX saved_value ON saved(value)")?.affected_rows, Some(0)); + assert_eq!( + db.query("DELETE FROM saved WHERE id > 140")?.affected_rows, + Some(10) + ); + assert_eq!( + db.query("DELETE FROM saved WHERE id > 140")?.affected_rows, + Some(0) + ); + assert_eq!( + db.query("CREATE INDEX saved_value ON saved(value)")? + .affected_rows, + Some(0) + ); drop(db); let mut reopened = Database::open_sqlite(&path, AccessMode::ReadOnly)?; assert_eq!( - reopened.query("SELECT COUNT(*), MAX(value) FROM saved")?.page.rows, + reopened + .query("SELECT COUNT(*), MAX(value) FROM saved")? + .page + .rows, [vec![Some("140".into()), Some("1140".into())]] ); assert!(reopened.query("DELETE FROM saved").is_err()); @@ -373,7 +705,7 @@ break\", 2.5 AS amount; \n", assert!(!columns[2].nullable); assert_eq!(columns[2].default, "1.25"); assert_eq!(columns[5].name, "generated"); - let first = db.page(table, &columns, 0)?; + let first = db.edit_page(table, &columns, 0)?.page; assert!(first.has_next); assert_eq!(first.rows.len(), 100); assert_eq!( @@ -392,7 +724,7 @@ break\", 2.5 AS amount; \n", assert_eq!(first.rows[1][4].as_deref(), Some("2.5")); assert_eq!(first.rows[2][4].as_deref(), Some("X'0080'")); assert_eq!(first.rows[3][4].as_deref(), Some("\\xff")); - let second = db.page(table, &columns, 1)?; + let second = db.edit_page(table, &columns, 1)?.page; assert!(!second.has_next); assert_eq!( second.rows, @@ -412,7 +744,7 @@ break\", 2.5 AS amount; \n", assert_eq!(view.kind, "view"); let view_columns = db.columns(view)?; assert_eq!( - db.page(view, &view_columns, 1)?.rows[0][0].as_deref(), + db.edit_page(view, &view_columns, 1)?.page.rows[0][0].as_deref(), Some("101") ); let empty = relations @@ -420,7 +752,7 @@ break\", 2.5 AS amount; \n", .find(|relation| relation.name == "empty_table") .unwrap(); let empty_columns = db.columns(empty)?; - let empty_page = db.page(empty, &empty_columns, 0)?; + let empty_page = db.edit_page(empty, &empty_columns, 0)?.page; assert!(empty_page.rows.is_empty()); assert!(!empty_page.has_next); let keyed = relations @@ -430,7 +762,7 @@ break\", 2.5 AS amount; \n", let keyed_columns = db.columns(keyed)?; assert!(!keyed_columns[0].nullable); assert_eq!( - db.page(keyed, &keyed_columns, 0)?.rows, + db.edit_page(keyed, &keyed_columns, 0)?.page.rows, vec![ vec![Some("a".into()), Some("1".into())], vec![Some("b".into()), Some("2".into())] @@ -446,7 +778,7 @@ break\", 2.5 AS amount; \n", .find(|relation| relation.name == "keyless") .unwrap(); let keyless_columns = db.columns(keyless)?; - let page = db.page(keyless, &keyless_columns, 0)?; + let page = db.edit_page(keyless, &keyless_columns, 0)?.page; assert!(!page.has_next); assert_eq!(page.rows.len(), 100); assert_eq!(page.rows[0][2].as_deref(), Some("100")); diff --git a/src/main.rs b/src/main.rs index e8fb233..1e988e7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,7 +12,7 @@ use crossterm::event::{ type Result> = std::result::Result; -const HELP: &str = r#"dbdash — PostgreSQL and SQLite inspection with opt-in SQL writes +const HELP: &str = r#"dbdash — PostgreSQL and SQLite inspection with opt-in staged and SQL writes Usage: dbdash [--read-write] [POSTGRES_CONNECTION_STRING] @@ -114,17 +114,37 @@ Navigation: Connection/password fields remain direct-entry; query paste requires INSERT. SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect, including quoted identifiers, literals, and multiline comments/strings as you type. - F5 / Ctrl-Enter executes one SQL statement on the current connection, showing up + In Query, F5 / Ctrl-Enter executes one SQL statement on the current connection, showing up to 100 result rows below. Results report truncation; use SQL LIMIT/OFFSET for more. READ ONLY accepts SELECT/CTE reads; READ WRITE also supports SQL writes. Each successful statement commits atomically; failures roll back. Explicit transaction control and multi-statement scripts are rejected. Writes report affected-row counts; RETURNING displays result rows (up to 100) without limiting the rows written. - Cells are never edited inline: use INSERT, UPDATE, DELETE, etc. in the SQL editor. - Ctrl-Enter needs terminal support for enhanced keyboard reporting; use F5 otherwise. + Rows in READ WRITE: e / Enter edits the selected cell; Enter stages, Esc cancels + only that editor. Alt-Enter / Shift-Enter inserts a newline; Tab inserts a literal tab. + Ctrl-N toggles explicit SQL NULL (distinct from empty text and the string NULL). + Existing and pasted tabs, newlines, and control bytes are preserved on save; + control characters render as safe escapes. Ctrl-U clears the editor. + Yellow cells are staged; the rows header counts changed rows/cells across pages. + F5 / Ctrl-Enter in Rows stages the active editor and atomically applies all drafts. + Editing/applying never runs query text. Query focus keeps F5 / Ctrl-Enter for SQL. + D in Rows explicitly discards drafts after confirmation. Refresh, relation changes, + connection switches/reconnect/removal, SQL execution, and quitting also prompt: + y discards; n/Esc cancels the action and keeps drafts and the active editor. + Drafts belong to one table and survive paging; copy includes their displayed values. + Only keyed base tables can be edited. Views, query results, keyless tables, + generated columns, and rows with NULL keys are read-only. + SQLite edits preserve the original value's storage class; numeric/blob inputs must + be valid, and BLOBs use X'hex'. Empty text is not NULL. An original NULL becomes + text when edited without Ctrl-N; the database's column affinity still applies. + Applying uses original composite keys and checks original values for conflicts. + Any conflict, conversion, or constraint error rolls back the batch and keeps drafts + and the active editor. Success reloads server values, including trigger changes. + Drafts are session-only, not an undo log; discarding never changes the database. + Ctrl-Enter/Shift-Enter need terminal keyboard support; use F5/Alt-Enter otherwise. Failed queries keep the previous results. Opening a relation restores browsing without clearing the query text. Query text is not saved across connections. - q / Ctrl-C Quit outside the query editor (q inserts text only in INSERT) + q quits outside query/cell editors. Ctrl-C quits from any surface; unsaved edits prompt. New database window: Up/Down chooses a provider; Enter starts editing. Tab/Shift-Tab changes fields; Left/Right changes TLS, CA bundle, or Access when focused. Enter saves without connecting, including when the database is offline.