From 4f472b55f3889f63bedb18ca706bd96199b8a601 Mon Sep 17 00:00:00 2001 From: Valdemar Lorenzen Date: Tue, 20 Oct 2026 13:57:51 +0200 Subject: [PATCH] feat: support explicit read-write database connections --- TODO.md | 3 +- src/app.rs | 52 +++++++++-- src/connection.rs | 226 ++++++++++++++++++++++++++++++++++++++------- src/db.rs | 230 +++++++++++++++++++++++++++++++++++++++++++--- src/db/sqlite.rs | 126 +++++++++++++++++++++---- src/main.rs | 78 +++++++++++----- 6 files changed, 617 insertions(+), 98 deletions(-) diff --git a/TODO.md b/TODO.md index 8e22fd0..24f3394 100644 --- a/TODO.md +++ b/TODO.md @@ -1,4 +1,5 @@ - [x] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar) - [x] feature: ability to remove a database connection - [x] feature: line numbers in gutter of sql editor -- [x] bug: database type should display nothing instead of `-` when no database is connected. (it should only show the type when it's available) \ No newline at end of file +- [x] bug: database type should display nothing instead of `-` when no database is connected. (it should only show the type when it's available) +- [x] feature: per-connection read-only/read-write access, sidebar badges, and atomic single-statement SQL editing with affected counts and RETURNING \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 2f16e9d..f4ae2d9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -73,6 +73,10 @@ impl App { self.db.name() } + pub(crate) fn access_mode(&self) -> crate::db::AccessMode { + self.db.access_mode() + } + pub(crate) fn database_name(&self) -> &str { self.db.display_name() } @@ -200,13 +204,16 @@ impl App { fn execute_query(&mut self) { match self.db.query(&self.query.value) { Ok(result) => { + self.error = None; + if self.db.access_mode() == crate::db::AccessMode::ReadWrite { + self.refresh(); + } self.rows_state = TableState::default() .with_selected((!result.page.rows.is_empty()).then_some(0)) .with_selected_column((!result.headers.is_empty()).then_some(0)); self.column_offset = 0; self.show_schema = false; self.query_result = Some(result); - self.error = None; } Err(error) => self.error = Some(error_text(error.as_ref())), } @@ -363,7 +370,7 @@ impl App { }); let description = if let Some(result) = &self.query_result { format!( - " Query · {} rows{} · columns {}–{}/{}", + " Query · {} rows{} · columns {}–{}/{}{}", result.page.rows.len(), if result.page.has_next { " (first 100)" @@ -373,6 +380,7 @@ 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")), ) } else if let Some(index) = self.active { let relation = &self.relations[index]; @@ -549,7 +557,7 @@ 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())?)?; + 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"); @@ -597,7 +605,7 @@ 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())?)?; + 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"); @@ -642,7 +650,7 @@ 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())?)?; + 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] { @@ -675,7 +683,7 @@ 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())?)?; + 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))); @@ -717,7 +725,7 @@ 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())?)?; + 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"); @@ -749,7 +757,7 @@ 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())?)?; + 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)); @@ -783,7 +791,7 @@ 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())?)?; + 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; @@ -809,4 +817,30 @@ mod tests { } Ok(()) } + + #[test] + fn writes_refresh_browsing_and_keep_returning_results() -> crate::Result<()> { + let file = tempfile::NamedTempFile::new()?; + let mut app = super::App::new(crate::db::Database::open_sqlite( + file.path(), + crate::db::AccessMode::ReadWrite, + )?)?; + app.query.value = "CREATE TABLE edited (id INTEGER PRIMARY KEY)".into(); + app.execute_query(); + assert!(app.error.is_none(), "{:?}", app.error); + assert_eq!(app.relations[0].name, "edited"); + app.query.value = "INSERT INTO edited VALUES (42) RETURNING id".into(); + app.execute_query(); + assert!(app.error.is_none(), "{:?}", app.error); + let result = app.query_result.as_ref().unwrap(); + assert_eq!(result.affected_rows, Some(1)); + assert_eq!(result.page.rows, [vec![Some("42".into())]]); + app.open_relation(Some(0)); + assert_eq!(app.page.rows, [vec![Some("42".into())]]); + app.query.value = "DROP TABLE edited".into(); + app.execute_query(); + assert!(app.error.is_none(), "{:?}", app.error); + assert!(app.relations.is_empty()); + Ok(()) + } } diff --git a/src/connection.rs b/src/connection.rs index 137f6d8..0319f3d 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -14,7 +14,7 @@ use zeroize::{Zeroize, Zeroizing}; use crate::{ Result, app::{App, Focus}, - db::Database, + db::{AccessMode, Database}, error_text, }; @@ -56,7 +56,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< && browser.as_ref().is_some_and(App::query_insert_mode) }; let name = connection_label.as_deref().unwrap_or_else(|| browser.as_ref().map_or("Not connected", App::database_name)); - draw_bars(frame, top, bottom, browser.as_ref().map_or("", App::provider), name, insert, position); + draw_bars(frame, top, bottom, browser.as_ref().map_or("", App::provider), name, browser.as_ref().map(App::access_mode), insert, position); let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)]) .areas(workspace); let items = entries.iter().map(|entry| { @@ -64,7 +64,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< ExplorerEntry::Database(index) => { let connected = browser.is_some() && index == active; let label = index.map_or_else(|| "Session database".into(), |index| profiles.entries[index].label()); - database_label(&label, connected, expanded, sidebar.width.saturating_sub(2)) + let access = if connected { + browser.as_ref().unwrap().access_mode() + } else { + profiles.entries[index.unwrap()].access + }; + database_label(&label, access, connected, expanded, sidebar.width.saturating_sub(2)) } ExplorerEntry::Relation(index) => { let relation = &browser.as_ref().unwrap().relations[index]; @@ -81,7 +86,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< if let Some(browser) = &mut browser { browser.draw(frame, content, if help.is_some() { Focus::Explorer } else { focus }); } else { - frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content); + frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash ")), content); } if let Some(error) = &error { frame.render_widget(Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)), Rect::new(content.x, content.bottom().saturating_sub(1), content.width, 1)); @@ -179,13 +184,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< .map(|connected| { browser = Some(connected); connection_label = Some(form.connection_label()); - active = - selection.selected().and_then(|index| { - match entries[index] { - ExplorerEntry::Database(index) => index, - _ => None, - } - }); + active = profiles.entries.iter().position(|entry| entry.id == form.id); selection.select(active); expanded = true; focus = Focus::Explorer; @@ -286,18 +285,22 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> Result< _ => None, }); } - KeyCode::Enter if focus == Focus::Explorer => { + KeyCode::Enter | KeyCode::Char('R') if focus == Focus::Explorer => { if let Some(entry) = selection.selected().and_then(|index| entries.get(index)) { let index = match *entry { + ExplorerEntry::Relation(_) if key.code == KeyCode::Char('R') => { + let Some(index) = active else { continue }; + index + } ExplorerEntry::Relation(index) => { browser.as_mut().unwrap().open_relation(Some(index)); focus = Focus::Rows; continue; } ExplorerEntry::Database(index) - if browser.is_some() && index == active => + if browser.is_some() && index == active && key.code == KeyCode::Enter => { expanded = !expanded; continue; @@ -413,6 +416,7 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Home/End, g/G First / last item\n\ Left/Right, h/l Collapse/expand explorer or select cells\n\ Enter Connect/expand database or open relation\n\ + R Reconnect saved database (or selected child)\n\ i Saved connection info on database or child\n\ e Edit saved configuration/name on database or child\n\ d / Delete Remove saved database (y confirms, n/Esc cancels)\n\ @@ -433,14 +437,22 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { Backspace/Delete Remove text in Insert mode\n\ Ctrl-U Clear query in Insert mode\n\ Esc Leave Insert first, then return to explorer\n\n\ + 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\ Connection / password windows\n\ Tab / Shift-Tab Change fields\n\ - Arrows Choose provider; Left/Right changes TLS or CA bundle\n\ + Arrows Choose provider; Left/Right changes TLS, CA, or Access\n\ Enter Edit provider / save settings / connect\n\ Ctrl-U / paste Clear / enter text in a focused field\n\ Esc Cancel and discard edits\n\ Optional Name sets the sidebar label; blank uses the default.\n\ Connect saves settings only on success; e edits save without connecting.\n\ + Access defaults to READ ONLY, including older saved connections.\n\ + [RO]/[RW] badges remain visible when sidebar names truncate.\n\ + Header/connected badge show live mode; info shows saved Access.\n\ + Saved mode edits need R to reconnect before they take effect.\n\ CA bundles come from .pem/.crt files in the dbdash config directory.\n\ Choosing a bundle requires TLS; choose System trust to unlock TLS mode.\n\ Passwords are never saved.", @@ -468,7 +480,11 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) { ); } -fn database_label(label: &str, 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]", + }; if width < 4 { return if connected && width > 0 { "*".into() @@ -476,12 +492,16 @@ fn database_label(label: &str, connected: bool, expanded: bool, width: u16) -> S String::new() }; } + let suffix_width = 5 + if connected { 2 } else { 0 }; + if usize::from(width) < 2 + suffix_width { + return badge.into(); + } let mut result = String::from(if connected && expanded { "▼ " } else { "▶ " }); - let available = usize::from(width) - 2 - if connected { 2 } else { 0 }; + let available = usize::from(width) - 2 - suffix_width; let span = Span::raw(label); let mut used = 0; for grapheme in span.styled_graphemes(Style::default()) { @@ -492,6 +512,11 @@ fn database_label(label: &str, connected: bool, expanded: bool, width: u16) -> S used += width; result.push_str(grapheme.symbol); } + for _ in used..available { + result.push(' '); + } + result.push(' '); + result.push_str(badge); if connected { result.push_str(" *"); } @@ -514,6 +539,7 @@ fn draw_bars( bottom: Rect, provider: &str, name: &str, + access: Option, insert: bool, position: Option<(usize, usize)>, ) { @@ -524,7 +550,8 @@ fn draw_bars( Paragraph::new(Line::from(vec![ Span::styled(" dbdash ", badge), Span::styled("▓░", edge), - Span::raw(" READ ONLY"), + Span::raw(" "), + Span::raw(access.map_or("NOT CONNECTED", AccessMode::label)), ])) .style(base), top, @@ -635,7 +662,15 @@ impl Profiles { { storage.execute_batch("ALTER TABLE connections ADD COLUMN ca_bundle TEXT")?; } - let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name, ca_bundle FROM connections ORDER BY id")? + if !storage + .prepare("SELECT name FROM pragma_table_info('connections') WHERE name = 'read_write'")? + .exists([])? + { + storage.execute_batch( + "ALTER TABLE connections ADD COLUMN read_write INTEGER NOT NULL DEFAULT 0 CHECK(read_write IN (0, 1))", + )?; + } + let entries = storage.prepare("SELECT provider, host, port, database_name, username, tls, path, id, name, ca_bundle, read_write FROM connections ORDER BY id")? .query_map([], |row| { let mut form = Form { id: Some(row.get(7)?), @@ -644,6 +679,7 @@ impl Profiles { tls: usize::from(row.get::<_, u8>(5)?), path: Input::from(row.get::<_, String>(6)?.as_str()), ca_bundle: row.get::<_, Option>(9)?.map(PathBuf::from), + access: if row.get::<_, bool>(10)? { AccessMode::ReadWrite } else { AccessMode::ReadOnly }, ..Form::default() }; for index in 0..4 { @@ -661,6 +697,7 @@ impl Profiles { name: Input::from(form.name.value.trim()), provider: form.provider, focus: form.focus, + access: form.access, postgres: std::array::from_fn(|index| { if index == 4 { Input::default() @@ -688,12 +725,13 @@ impl Profiles { saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?); } self.storage.execute( - "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name, ca_bundle) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + "INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name, ca_bundle, read_write) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) ON CONFLICT(id) DO UPDATE SET provider = excluded.provider, host = excluded.host, port = excluded.port, database_name = excluded.database_name, username = excluded.username, - tls = excluded.tls, path = excluded.path, name = excluded.name, ca_bundle = excluded.ca_bundle", + tls = excluded.tls, path = excluded.path, name = excluded.name, ca_bundle = excluded.ca_bundle, + read_write = excluded.read_write", rusqlite::params![ saved.provider as u8, saved.postgres[0].value, @@ -704,7 +742,8 @@ impl Profiles { saved.path.value, saved.id, saved.name.value, - saved.ca_bundle.as_ref().map(|path| path.to_str().ok_or("CA bundle path must be UTF-8")).transpose()? + saved.ca_bundle.as_ref().map(|path| path.to_str().ok_or("CA bundle path must be UTF-8")).transpose()?, + saved.access == AccessMode::ReadWrite ], )?; saved.id.get_or_insert(self.storage.last_insert_rowid()); @@ -732,6 +771,7 @@ struct Form { name: Input, provider: usize, focus: usize, + access: AccessMode, postgres: [Input; 5], tls: usize, ca_bundle: Option, @@ -747,6 +787,7 @@ impl Default for Form { name: Input::default(), provider: 0, focus: 0, + access: AccessMode::ReadOnly, tls: 0, ca_bundle: None, postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from), @@ -770,6 +811,10 @@ impl Form { 6 + usize::from(self.reconnect) } + fn access_focus(&self) -> usize { + self.name_focus() + 1 + } + fn select_ca_bundle(&mut self, directory: &Path, forward: bool) -> Result<()> { let mut bundles = Vec::new(); for entry in std::fs::read_dir(directory)? { @@ -802,9 +847,9 @@ impl Form { if self.path.value.is_empty() { return Err("Enter an existing SQLite database file path.".into()); } - return Database::open_sqlite(Path::new(&self.path.value)); + return Database::open_sqlite(Path::new(&self.path.value), self.access); } - Database::connect_postgres(self.postgres_config()?, self.ca_bundle.as_deref()) + Database::connect_postgres(self.postgres_config()?, self.ca_bundle.as_deref(), self.access) } fn postgres_config(&self) -> Result { @@ -872,13 +917,19 @@ impl Form { fn edit(&mut self, key: KeyEvent) { self.error = None; - let count = self.name_focus() + 1; + let count = self.access_focus() + 1; match key.code { KeyCode::Tab => self.focus = (self.focus + 1) % count, KeyCode::BackTab => self.focus = (self.focus + count - 1) % count, KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => { self.provider = 1 - self.provider } + KeyCode::Left | KeyCode::Right if self.focus == self.access_focus() => { + self.access = match self.access { + AccessMode::ReadOnly => AccessMode::ReadWrite, + AccessMode::ReadWrite => AccessMode::ReadOnly, + }; + } KeyCode::Left | KeyCode::Right if self.provider == 0 && self.focus == 5 + usize::from(self.reconnect) => { @@ -932,18 +983,20 @@ impl Form { fn info_text(&self) -> String { if self.provider == 1 { format!( - "Provider: SQLite\nPath: {}\nAccess: READ ONLY", - crate::db::display_text(&self.path.value) + "Provider: SQLite\nPath: {}\nAccess: {} (saved setting; reconnect to apply)", + crate::db::display_text(&self.path.value), + self.access.label() ) } else { format!( - "Provider: PostgreSQL\nHost: {}\nPort: {}\nDatabase: {}\nUser: {}\nTLS: {}\nCA bundle: {}\nAccess: READ ONLY", + "Provider: PostgreSQL\nHost: {}\nPort: {}\nDatabase: {}\nUser: {}\nTLS: {}\nCA bundle: {}\nAccess: {} (saved setting; reconnect to apply)", crate::db::display_text(&self.postgres[0].value), crate::db::display_text(&self.postgres[1].value), 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.access.label(), ) } } @@ -1013,13 +1066,27 @@ impl Form { Paragraph::new(if self.reconnect { self.label() } else { - "Configure a database · READ ONLY".into() + "Configure a database".into() }) .style(Style::default().fg(Color::Cyan)), header, ); let [name_area, body] = Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(body); + let [name_area, access_area] = + 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() { + Color::Cyan + } else { + Color::DarkGray + }), + ), + ), + access_area, + ); self.name.draw( frame, name_area, @@ -1063,7 +1130,7 @@ impl Form { self.focus == 1, None, ); - frame.render_widget(Paragraph::new("Save an existing SQLite file path.\nFiles are never created or modified.\n\nSelect the saved database to connect.").wrap(Wrap { trim: false }), description); + frame.render_widget(Paragraph::new("Save an existing SQLite file path.\nMissing files are never created.\nREAD WRITE allows SQL writes.\n\nSelect the saved database to connect.").wrap(Wrap { trim: false }), description); } else { let rows = Layout::vertical([ Constraint::Length(3), @@ -1144,7 +1211,7 @@ impl Form { let message = self.error.as_deref().unwrap_or(if self.reconnect { "Edit settings and enter a password if needed. Changes are saved only after connecting." } else { - "Enter saves this configuration without connecting." + "Enter saves without connecting. Existing sessions keep their mode; R reconnects." }); frame.render_widget( Paragraph::new(message) @@ -1157,9 +1224,9 @@ impl Form { status, ); frame.render_widget(Paragraph::new(if self.reconnect { - " Tab/Shift-Tab fields · ↑↓ provider · Enter connect\n ←→ edit/TLS/CA · Ctrl-U clear · Esc cancel · Ctrl-C quit" + " Tab/Shift-Tab fields · ↑↓ provider · Enter connect\n ←→ edit/TLS/CA/access · Ctrl-U clear · Esc cancel · Ctrl-C quit" } else { - " Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS/CA · Ctrl-U clear · Esc cancel · Ctrl-C quit" + " Tab/Shift-Tab fields · ↑↓ provider · Enter save\n ←→ edit/TLS/CA/access · Ctrl-U clear · Esc cancel · Ctrl-C quit" }).style(Style::default().fg(Color::DarkGray)), help); } } @@ -1452,6 +1519,85 @@ impl Input { mod tests { use super::*; + #[test] + fn legacy_profiles_default_to_read_only_and_access_edits_persist() -> Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("connections.sqlite3"); + let storage = rusqlite::Connection::open(&path)?; + storage.execute_batch( + "CREATE TABLE connections ( + id INTEGER PRIMARY KEY, provider INTEGER NOT NULL, + host TEXT NOT NULL, port TEXT NOT NULL, database_name TEXT NOT NULL, + username TEXT NOT NULL, tls INTEGER NOT NULL, path TEXT NOT NULL + ); + INSERT INTO connections VALUES (1, 0, 'localhost', '5432', 'postgres', 'reader', 0, '')", + )?; + drop(storage); + let mut profiles = Profiles::open(&path)?; + assert_eq!(Form::default().access, AccessMode::ReadOnly); + assert_eq!(profiles.entries[0].access, AccessMode::ReadOnly); + let mut form = profiles.entries[0].clone(); + for provider in [0, 1] { + for reconnect in [false, true] { + form.provider = provider; + form.reconnect = reconnect; + form.focus = form.name_focus(); + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); + assert_eq!(form.focus, form.access_focus()); + assert!(form.input().is_none()); + form.edit(KeyEvent::new(KeyCode::Right, KeyModifiers::NONE)); + assert_eq!(form.access, AccessMode::ReadWrite); + form.edit(KeyEvent::new(KeyCode::Left, KeyModifiers::NONE)); + assert_eq!(form.access, AccessMode::ReadOnly); + } + } + form.provider = 0; + form.access = AccessMode::ReadWrite; + profiles.save(&form)?; + drop(profiles); + 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")); + 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); + Ok(()) + } + + #[test] + fn saved_mode_edits_do_not_relabel_a_live_read_write_session() -> Result<()> { + let directory = tempfile::tempdir()?; + let database_path = directory.path().join("data.sqlite"); + drop(rusqlite::Connection::open(&database_path)?); + let mut profiles = Profiles::open(&directory.path().join("connections.sqlite3"))?; + let form = Form { + provider: 1, + path: Input::from(database_path.to_str().unwrap()), + access: AccessMode::ReadWrite, + ..Form::default() + }; + profiles.save(&form)?; + let live = form.connect()?; + let mut saved = profiles.entries[0].clone(); + saved.access = AccessMode::ReadOnly; + profiles.save(&saved)?; + assert_eq!(profiles.entries[0].access, AccessMode::ReadOnly); + assert_eq!(live.access_mode(), AccessMode::ReadWrite); + assert!(database_label("data", live.access_mode(), true, true, 30).ends_with("[RW] *")); + 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); + })?; + let header: String = (0..40).map(|x| terminal.backend().buffer()[(x, 0)].symbol()).collect(); + assert!(header.contains("READ WRITE")); + assert!(!header.contains("READ ONLY")); + Ok(()) + } + #[test] fn ca_selector_persists_per_profile_and_keeps_missing_selections_fail_closed() -> Result<()> { let directory = tempfile::tempdir()?; @@ -1616,7 +1762,7 @@ 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(), 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()), @@ -1624,7 +1770,12 @@ mod tests { ); })?; assert_eq!(terminal.backend().buffer()[(30, 1)].symbol(), "*"); - assert_eq!(database_label("long", true, true, 1), "*"); + let row: String = (1..31).map(|x| terminal.backend().buffer()[(x, 1)].symbol()).collect(); + assert!(row.ends_with("[RW] *")); + 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, 1), "*"); Ok(()) } @@ -1675,6 +1826,7 @@ mod tests { bottom, "SQLite", "example.sqlite", + Some(AccessMode::ReadOnly), false, Some((1, 1)), ); @@ -1701,6 +1853,7 @@ mod tests { bottom, "PostgreSQL", "a very long database name", + Some(AccessMode::ReadOnly), false, None, ); @@ -1727,6 +1880,7 @@ mod tests { bottom, "SQLite", "a very long database name", + Some(AccessMode::ReadOnly), true, Some((12, 34)), ); @@ -1751,6 +1905,7 @@ mod tests { bottom, "SQLite", "long database name", + Some(AccessMode::ReadOnly), false, None, ); @@ -1919,6 +2074,7 @@ mod tests { form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.input_mut().unwrap().paste("Warehouse"); + form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); for value in ["db.example", "5440", "warehouse", "inspector"] { form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); if form.focus == 0 { diff --git a/src/db.rs b/src/db.rs index 8d20ebf..e30b6ac 100644 --- a/src/db.rs +++ b/src/db.rs @@ -5,8 +5,13 @@ use openssl::{ ssl::{SslConnector, SslMethod, SslVerifyMode, SslVersion}, x509::{X509, store::X509StoreBuilder}, }; -use postgres::{Client, Config, config::SslMode}; +use postgres::{Client, Config, SimpleQueryMessage, config::SslMode}; use postgres_native_tls::MakeTlsConnector; +use sqlparser::{ + ast::{SetExpr, Statement}, + dialect::{Dialect, PostgreSqlDialect, SQLiteDialect}, + parser::Parser, +}; use crate::Result; @@ -14,9 +19,26 @@ mod sqlite; pub const PAGE_SIZE: usize = 100; +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum AccessMode { + #[default] + ReadOnly, + ReadWrite, +} + +impl AccessMode { + pub fn label(self) -> &'static str { + match self { + Self::ReadOnly => "READ ONLY", + Self::ReadWrite => "READ WRITE", + } + } +} + pub struct Database { backend: Backend, display_name: String, + access_mode: AccessMode, } #[allow( @@ -54,14 +76,19 @@ pub struct Page { pub struct QueryResult { pub headers: Vec, pub page: Page, + pub affected_rows: Option, } impl Database { - pub fn connect(connection: &str, root_cert: Option<&Path>) -> Result { - Self::connect_postgres(connection.parse()?, root_cert) + pub fn connect(connection: &str, root_cert: Option<&Path>, access: AccessMode) -> Result { + Self::connect_postgres(connection.parse()?, root_cert, access) } - pub fn connect_postgres(mut config: Config, root_cert: Option<&Path>) -> Result { + pub fn connect_postgres( + mut config: Config, + root_cert: Option<&Path>, + access: AccessMode, + ) -> Result { if config.get_connect_timeout().is_none() { config.connect_timeout(Duration::from_secs(5)); } @@ -72,25 +99,32 @@ impl Database { config.connect(MakeTlsConnector::new(TlsConnector::new()?))? }; // Views can invoke functions: enforce read-only on the server, not just in the UI. - client.batch_execute( - "SET default_transaction_read_only = on; SET statement_timeout = '5s';", - )?; + client.batch_execute(match access { + AccessMode::ReadOnly => { + "SET default_transaction_read_only = on; SET statement_timeout = '5s';" + } + AccessMode::ReadWrite => { + "SET default_transaction_read_only = off; SET statement_timeout = '5s';" + } + })?; let display_name: String = client.query_one("SELECT current_database()", &[])?.get(0); Ok(Self { backend: Backend::Postgres(client), display_name: display_text(&display_name), + access_mode: access, }) } - pub fn open_sqlite(path: &Path) -> Result { + pub fn open_sqlite(path: &Path, access: AccessMode) -> Result { Ok(Self { - backend: Backend::Sqlite(sqlite::open(path)?), + backend: Backend::Sqlite(sqlite::open(path, access)?), display_name: display_text( &path .file_name() .unwrap_or(path.as_os_str()) .to_string_lossy(), ), + access_mode: access, }) } @@ -98,6 +132,10 @@ impl Database { &self.display_name } + pub fn access_mode(&self) -> AccessMode { + self.access_mode + } + pub fn name(&self) -> &'static str { match self.backend { Backend::Postgres(_) => "PostgreSQL", @@ -188,8 +226,51 @@ impl Database { let sql = sql.strip_suffix(';').unwrap_or(sql).trim_end(); let client = match &mut self.backend { Backend::Postgres(client) => client, - Backend::Sqlite(connection) => return sqlite::query(connection, sql), + Backend::Sqlite(connection) => return sqlite::query(connection, sql, self.access_mode), }; + if self.access_mode == AccessMode::ReadWrite { + let parsed = checked_statement(sql, false)?; + let mut transaction = client.build_transaction().read_only(false).start()?; + transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?; + // The server's extended protocol independently rejects multiple statements. + let statement = transaction.prepare(sql)?; + let headers = statement + .columns() + .iter() + .map(|column| display_text(column.name())) + .collect::>(); + let mut rows = Vec::with_capacity(PAGE_SIZE); + let mut has_next = false; + let mut affected_rows = None; + // ponytail: this driver buffers text-protocol rows; use a streaming driver if large RETURNING results matter. + for message in transaction.simple_query(sql)? { + match message { + SimpleQueryMessage::Row(row) => { + if rows.len() == PAGE_SIZE { + has_next = true; + continue; + } + rows.push( + (0..row.len()) + .map(|index| row.get(index).map(display_text)) + .collect(), + ); + } + SimpleQueryMessage::CommandComplete(count) => { + if !matches!(statement_body(&parsed), Statement::Query(_)) { + affected_rows = Some(count); + } + } + _ => {} + } + } + transaction.commit()?; + return Ok(QueryResult { + headers, + page: Page { rows, has_next }, + affected_rows, + }); + } let mut transaction = client.build_transaction().read_only(true).start()?; transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?; let statement = transaction.prepare(&format!( @@ -233,6 +314,7 @@ impl Database { Ok(QueryResult { headers, page: Page { rows, has_next }, + affected_rows: None, }) } @@ -308,6 +390,50 @@ fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result { Ok(tls.build()) } +fn checked_statement(sql: &str, sqlite: bool) -> Result { + let dialect: &dyn Dialect = if sqlite { + &SQLiteDialect {} + } else { + &PostgreSqlDialect {} + }; + let mut statements = Parser::parse_sql(dialect, sql)?; + if statements.len() != 1 { + return Err("Run exactly one SQL statement at a time.".into()); + } + let statement = statements.pop().expect("one statement"); + if matches!( + statement, + Statement::StartTransaction { .. } + | Statement::Commit { .. } + | Statement::Rollback { .. } + | Statement::Savepoint { .. } + | Statement::ReleaseSavepoint { .. } + | Statement::Set(_) + | Statement::Prepare { .. } + | Statement::Execute { .. } + | Statement::Deallocate { .. } + | Statement::Discard { .. } + | Statement::Pragma { .. } + | Statement::AttachDatabase { .. } + | Statement::AttachDuckDBDatabase { .. } + | Statement::DetachDuckDBDatabase { .. } + ) { + return Err("Transaction, session, and attached-database controls are not allowed in the editor.".into()); + } + Ok(statement) +} + +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::Merge(body) => return body, + _ => {} + } + } + statement +} + fn page_offset(page: usize) -> Result { i64::try_from(page)? .checked_mul(PAGE_SIZE as i64) @@ -442,7 +568,7 @@ mod tests { let directory = tempfile::tempdir()?; let path = directory.path().join("accounts\nprivate.sqlite"); drop(rusqlite::Connection::open(&path)?); - let db = Database::open_sqlite(&path)?; + let db = Database::open_sqlite(&path, AccessMode::ReadOnly)?; assert_eq!(db.display_name(), "accounts\\nprivate.sqlite"); Ok(()) } @@ -456,6 +582,86 @@ mod tests { assert_eq!(display_text("é🙂\n\t\r\u{1b}[2J"), "é🙂\\n\\t\\r\\u{1b}[2J"); } + #[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<()> { + let connection = std::env::var("DBDASH_TEST_DATABASE_URL")?; + let fixture = connection + .parse::()? + .connect(MakeTlsConnector::new(TlsConnector::new()?))?; + let schema = format!("dbdash_test_rw_{}", 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 _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)); + 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, + Some(0) + ); + let inserted = db.query(&format!( + "WITH numbers AS (SELECT generate_series(1, 150) AS n) + INSERT INTO {schema}.saved SELECT n, n FROM numbers + RETURNING id, 1.25::numeric AS amount, jsonb_build_object('ok', true) AS details, + ARRAY['a', 'b'] AS tags, decode('00ff', 'hex') AS bytes, + TIMESTAMP '2020-01-01 12:00:00' AS happened, 'ok'::{schema}.mood AS mood, + NULL::text AS missing, 'semi;colon' AS note" + ))?; + assert_eq!(inserted.affected_rows, Some(150)); + assert_eq!(inserted.page.rows.len(), PAGE_SIZE); + assert!(inserted.page.has_next); + assert_eq!( + inserted.headers, + ["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("semi;colon".into()) + ] + ); + assert_eq!(db.query("SELECT 1")?.affected_rows, None); + 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(), + ] { + 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, + [vec![Some("150".into()), Some("150".into())]] + ); + 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)); + 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, + [vec![Some("140".into()), Some("1140".into())]] + ); + assert!(reopened.query(&format!("DELETE FROM {schema}.saved")).is_err()); + Ok(()) + } + #[test] #[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"] fn inspect_postgres_read_only() -> Result<()> { @@ -487,7 +693,7 @@ mod tests { CREATE VIEW {schema}.sample_view AS SELECT id FROM {schema}.\"odd\"\"table\"; CREATE TABLE {schema}.empty_table (id integer);" ))?; - let mut db = Database::connect(&connection, None)?; + let mut db = Database::connect(&connection, None, AccessMode::ReadOnly)?; let relations = db.relations()?; let table = relations .iter() diff --git a/src/db/sqlite.rs b/src/db/sqlite.rs index 206d700..db992e5 100644 --- a/src/db/sqlite.rs +++ b/src/db/sqlite.rs @@ -1,21 +1,29 @@ use std::{fmt::Write, path::Path, time::Duration}; use rusqlite::{Connection, OpenFlags, params, types::ValueRef}; +use sqlparser::ast::Statement; use super::{ - Column, PAGE_SIZE, Page, QueryResult, Relation, display_text, page_offset, quote_identifier, + AccessMode, Column, PAGE_SIZE, Page, QueryResult, Relation, checked_statement, display_text, + page_offset, quote_identifier, statement_body, }; use crate::Result; -pub(super) fn open(path: &Path) -> Result { +pub(super) fn open(path: &Path, access: AccessMode) -> Result { // An absolute existing path also prevents SQLite's special :memory: filename from creating a database. let path = shellexpand::path::full(path)?.canonicalize()?; let connection = Connection::open_with_flags( path, - OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, + match access { + AccessMode::ReadOnly => OpenFlags::SQLITE_OPEN_READ_ONLY, + AccessMode::ReadWrite => OpenFlags::SQLITE_OPEN_READ_WRITE, + } | OpenFlags::SQLITE_OPEN_NO_MUTEX, )?; connection.busy_timeout(Duration::from_secs(5))?; - connection.execute_batch("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;")?; + connection.execute_batch("PRAGMA trusted_schema = OFF;")?; + if access == AccessMode::ReadOnly { + connection.execute_batch("PRAGMA query_only = ON;")?; + } Ok(connection) } @@ -111,10 +119,32 @@ 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()) + read_page(&mut query, columns.len(), false) } -pub(super) fn query(connection: &Connection, sql: &str) -> Result { +pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> Result { + if access == AccessMode::ReadWrite { + let parsed = checked_statement(sql, true)?; + 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 column_count = statement.column_count(); + let mut query = statement.query([])?; + let page = read_page(&mut query, column_count, true)?; + (headers, page) + }; + let body = statement_body(&parsed); + let affected_rows = if matches!(body, Statement::Query(_)) { + None + } 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 }); + } let original = connection.prepare(sql)?; let headers = original .column_names() @@ -126,18 +156,20 @@ pub(super) fn query(connection: &Connection, sql: &str) -> Result { PAGE_SIZE + 1 ))?; let column_count = statement.column_count(); - let page = read_page(&mut statement.query([])?, column_count)?; - Ok(QueryResult { headers, page }) + let page = read_page(&mut statement.query([])?, column_count, false)?; + Ok(QueryResult { headers, page, affected_rows: None }) } -fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize) -> Result { +fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize, exhaust: bool) -> Result { let mut rows = Vec::with_capacity(PAGE_SIZE); + let mut has_next = false; while let Some(row) = query.next()? { if rows.len() == PAGE_SIZE { - return Ok(Page { - rows, - has_next: true, - }); + has_next = true; + if exhaust { + continue; + } + break; } let values = (0..column_count) .map(|index| { @@ -157,7 +189,7 @@ fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize) -> Result Result<()> { + let directory = tempfile::tempdir()?; + let path = directory.path().join("writes.sqlite"); + drop(Connection::open(&path)?); + 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, + Some(0) + ); + let inserted = db.query( + "WITH RECURSIVE numbers(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 150 + ) INSERT INTO saved SELECT n, n FROM numbers RETURNING id, value, 'semi;colon' AS note", + )?; + assert_eq!(inserted.affected_rows, Some(150)); + assert_eq!(inserted.headers, ["id", "value", "note"]); + assert_eq!(inserted.page.rows.len(), PAGE_SIZE); + assert!(inserted.page.has_next); + assert_eq!(inserted.page.rows[0][2].as_deref(), Some("semi;colon")); + assert_eq!(db.query("SELECT COUNT(*) FROM saved")?.affected_rows, None); + + assert!(db.query( + "UPDATE OR FAIL saved SET value = CASE WHEN id = 150 THEN 1001 ELSE value + 1000 END" + ).is_err()); + 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", + "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, + [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)); + drop(db); + + let mut reopened = Database::open_sqlite(&path, AccessMode::ReadOnly)?; + assert_eq!( + 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()); + Ok(()) + } + #[test] fn inspect_sqlite_read_only() -> Result<()> { let directory = tempfile::tempdir()?; @@ -265,7 +353,7 @@ break\", 2.5 AS amount; \n", SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 100 ) INSERT INTO keyless SELECT CAST(n AS TEXT), CAST(n AS TEXT), n FROM numbers ORDER BY n DESC;" )?; - let mut db = Database::open_sqlite(&path)?; + let mut db = Database::open_sqlite(&path, AccessMode::ReadOnly)?; let relations = db.relations()?; assert!( !relations @@ -374,12 +462,14 @@ break\", 2.5 AS amount; \n", matches!(error, rusqlite::Error::SqliteFailure(error, _) if error.code == rusqlite::ErrorCode::ReadOnly) ); let missing = directory.path().join("missing.sqlite"); - assert!(Database::open_sqlite(&missing).is_err()); + for access in [AccessMode::ReadOnly, AccessMode::ReadWrite] { + assert!(Database::open_sqlite(&missing, access).is_err()); + } assert!(!missing.exists()); let path = directory.path().join("invalid.sqlite"); std::fs::write(&path, b"not a SQLite database")?; assert!( - Database::open_sqlite(&path) + Database::open_sqlite(&path, AccessMode::ReadOnly) .and_then(|mut db| db.relations()) .is_err() ); diff --git a/src/main.rs b/src/main.rs index ad52f95..383f86c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,12 +12,12 @@ use crossterm::event::{ type Result> = std::result::Result; -const HELP: &str = r#"dbdash — read-only PostgreSQL and SQLite inspection +const HELP: &str = r#"dbdash — PostgreSQL and SQLite inspection with opt-in SQL writes Usage: - dbdash [POSTGRES_CONNECTION_STRING] - dbdash --ca-bundle PATH POSTGRES_CONNECTION_STRING - dbdash --sqlite PATH + dbdash [--read-write] [POSTGRES_CONNECTION_STRING] + dbdash [--read-write] --ca-bundle PATH POSTGRES_CONNECTION_STRING + dbdash [--read-write] --sqlite PATH dbdash --help Run without a connection argument to open the saved database list: @@ -30,6 +30,14 @@ SQLite paths can be relative or absolute; quote paths containing spaces. SQLite paths expand leading ~, $VAR, and ${VAR}; undefined variables are errors. Expansion never runs shell commands. Quote CLI paths to let dbdash expand them. +Connections default to READ ONLY, including existing saved configurations. +Choose Access in the connection window to save READ ONLY or READ WRITE per database. +For an unsaved CLI session, opt in explicitly: + cargo run -- --read-write --sqlite ./database.sqlite +--read-write requires a direct connection; it does not change saved profiles. +The header and connected sidebar badge reflect the live session, not saved edits. +Changing saved Access requires reconnecting (R on the saved database or a child). + PostgreSQL URLs and libpq-style key=value strings are accepted. Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=disable only for trusted local connections. @@ -68,12 +76,13 @@ Navigation: i Show configured database info (Esc/Enter closes; passwords omitted) Works on a saved database entry or any of its table/view children. r Refresh relations and the active relation - Connected databases have a trailing * and show their tables/views beneath them. + Databases always show [RO]/[RW], even when their labels truncate; connected ones + have a trailing * and show their tables/views beneath them. R reconnects a saved database. Unnamed SQLite entries show only the filename; the info window shows the full path. Enter on the connected database collapses/expands it without reconnecting. Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump. Query Insert mode and connection/password fields keep ? as literal text. - Full-width bars show dbdash/READ ONLY above and mode/database below. + Full-width bars show dbdash/live READ ONLY or READ WRITE above and mode/database below. Saved PostgreSQL connections show database · user@host:port beside the mode, regardless of custom sidebar names; passwords are never included. The database provider sits immediately left of the bottom-right position badge. @@ -94,23 +103,29 @@ 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 SELECT/CTE on the current connection, showing up - to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more. + 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. 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) New database window: Up/Down chooses a provider; Enter starts editing. - Tab/Shift-Tab changes fields; Left/Right changes TLS or the CA bundle when focused. + 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. Optional Name controls the sidebar label; leave it blank for the default label. e on a saved database or its child edits the configuration without connecting. - Enter saves edits; Esc discards them. Connected sessions keep their actual identity. + Enter saves edits; Esc discards them. Connected sessions keep their actual identity + and access mode until R reconnects; Enter on an active database still only expands it. d / Delete on a saved database asks for y to remove its configuration (n/Esc cancels). Database files are never deleted. Removing the active profile keeps its session open. Select a saved database and press Enter to connect. PostgreSQL opens an editable connection window, initially focused on the password. Tab/Shift-Tab changes fields; - Left/Right changes TLS or the CA bundle when focused. Enter connects and updates that saved entry + Left/Right changes TLS, CA bundle, or Access when focused. Enter connects and updates that saved entry only on success; failures keep the previous settings and connection. Esc discards edits. Passwords are masked, never saved, and clear on success or cancellation. Owned input buffers are zeroized on clear/drop; profile saving does not copy passwords. @@ -121,10 +136,12 @@ Navigation: back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute. CLI connections are not saved. Failed switches keep the current database open. -PostgreSQL reads are enforced read-only and have a 5-second query timeout. -SQLite files are opened read-only, with a 5-second lock wait but no query timeout. -The interface waits for reads to complete. NULL cells are dimmed; SQLite BLOBs -are shown as X'hex'. Only SQLite's main database is inspected. +PostgreSQL statements have a 5-second timeout. READ ONLY sessions enforce read-only +transactions; READ WRITE statements commit on success. SQLite READ ONLY opens files +read-only; READ WRITE permits writes to existing files. Missing files are never created. +SQLite has a 5-second lock wait but no query timeout. The interface waits for statements +to complete. NULL cells are dimmed; SQLite BLOBs are shown as X'hex'. +Only SQLite's main database is inspected. Pages use primary-key order, or physical row order for keyless tables when available. Keyless views have no guaranteed order; concurrent changes can shift pages. "#; @@ -152,13 +169,27 @@ fn run() -> Result<()> { if std::env::var_os("PGSSLROOTCERT").is_some() { return Err("Unset PGSSLROOTCERT and select a CA bundle per connection, or use --ca-bundle PATH on the CLI.".into()); } - let root_cert = if argument.as_ref().is_some_and(|arg| arg == "--ca-bundle") { - let path = args.next().ok_or("--ca-bundle requires a PEM file path")?; - argument = Some(args.next().ok_or("--ca-bundle requires a PostgreSQL connection string")?); - Some(path) - } else { - None - }; + let mut access = db::AccessMode::ReadOnly; + let mut root_cert = None; + loop { + match argument.as_deref().and_then(|arg| arg.to_str()) { + Some("--read-write") => access = db::AccessMode::ReadWrite, + Some("--ca-bundle") => { + if root_cert.is_some() { + return Err("--ca-bundle may only be specified once".into()); + } + root_cert = Some(args.next().ok_or("--ca-bundle requires a PEM file path")?); + } + _ => break, + } + argument = args.next(); + } + if root_cert.is_some() && argument.is_none() { + return Err("--ca-bundle requires a PostgreSQL connection string".into()); + } + if access == db::AccessMode::ReadWrite && argument.is_none() { + return Err("--read-write requires a direct database connection; change saved Access in the connection window".into()); + } let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite"); if sqlite && root_cert.is_some() { return Err("--ca-bundle is only supported for PostgreSQL".into()); @@ -180,13 +211,14 @@ fn run() -> Result<()> { let database = connection .map(|connection| { if sqlite { - db::Database::open_sqlite(Path::new(&connection)) + db::Database::open_sqlite(Path::new(&connection), access) } else { db::Database::connect( connection .to_str() .ok_or("connection string must be UTF-8")?, root_cert.as_deref().map(Path::new), + access, ) } })