feat: support explicit read-write database connections

This commit is contained in:
Valdemar Lorenzen
2026-10-20 13:57:51 +02:00
parent 2aac7361d6
commit 4f472b55f3
6 changed files with 617 additions and 98 deletions
+1
View File
@@ -2,3 +2,4 @@
- [x] feature: ability to remove a database connection - [x] feature: ability to remove a database connection
- [x] feature: line numbers in gutter of sql editor - [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) - [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
+43 -9
View File
@@ -73,6 +73,10 @@ impl App {
self.db.name() self.db.name()
} }
pub(crate) fn access_mode(&self) -> crate::db::AccessMode {
self.db.access_mode()
}
pub(crate) fn database_name(&self) -> &str { pub(crate) fn database_name(&self) -> &str {
self.db.display_name() self.db.display_name()
} }
@@ -200,13 +204,16 @@ impl App {
fn execute_query(&mut self) { fn execute_query(&mut self) {
match self.db.query(&self.query.value) { match self.db.query(&self.query.value) {
Ok(result) => { Ok(result) => {
self.error = None;
if self.db.access_mode() == crate::db::AccessMode::ReadWrite {
self.refresh();
}
self.rows_state = TableState::default() self.rows_state = TableState::default()
.with_selected((!result.page.rows.is_empty()).then_some(0)) .with_selected((!result.page.rows.is_empty()).then_some(0))
.with_selected_column((!result.headers.is_empty()).then_some(0)); .with_selected_column((!result.headers.is_empty()).then_some(0));
self.column_offset = 0; self.column_offset = 0;
self.show_schema = false; self.show_schema = false;
self.query_result = Some(result); self.query_result = Some(result);
self.error = None;
} }
Err(error) => self.error = Some(error_text(error.as_ref())), 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 { let description = if let Some(result) = &self.query_result {
format!( format!(
" Query · {} rows{} · columns {}–{}/{}", " Query · {} rows{} · columns {}–{}/{}{}",
result.page.rows.len(), result.page.rows.len(),
if result.page.has_next { if result.page.has_next {
" (first 100)" " (first 100)"
@@ -373,6 +380,7 @@ impl App {
if count > 0 { self.column_offset + 1 } else { 0 }, if count > 0 { self.column_offset + 1 } else { 0 },
self.column_offset + visible, self.column_offset + visible,
count, count,
result.affected_rows.map_or_else(String::new, |rows| format!(" · {rows} rows affected")),
) )
} else if let Some(index) = self.active { } else if let Some(index) = self.active {
let relation = &self.relations[index]; let relation = &self.relations[index];
@@ -549,7 +557,7 @@ mod tests {
fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> { fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?; 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); let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
app.handle_query_key(key('i')); app.handle_query_key(key('i'));
app.paste_query(" SELECT café,界\n\n FROM data"); 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<()> { fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?; 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); let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
assert!(!app.query_insert_mode()); assert!(!app.query_insert_mode());
app.paste_query("ignored"); app.paste_query("ignored");
@@ -642,7 +650,7 @@ mod tests {
fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> { fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?; 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.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.paste_query("SELECT\n42"); app.paste_query("SELECT\n42");
for insert in [true, false] { 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) 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;", 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 mut terminal = Terminal::new(TestBackend::new(60, 20))?;
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
assert_eq!(app.rows_state.selected_cell(), Some((0, 0))); assert_eq!(app.rows_state.selected_cell(), Some((0, 0)));
@@ -717,7 +725,7 @@ mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{Terminal, backend::TestBackend, style::Color}; use ratatui::{Terminal, backend::TestBackend, style::Color};
let file = tempfile::NamedTempFile::new()?; 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))?; let mut terminal = Terminal::new(TestBackend::new(60, 14))?;
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.paste_query("SELECT '界', 42;\n-- comment"); app.paste_query("SELECT '界', 42;\n-- comment");
@@ -749,7 +757,7 @@ mod tests {
rusqlite::Connection::open(file.path())?.execute_batch( rusqlite::Connection::open(file.path())?.execute_batch(
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);", "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.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.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;");
app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
@@ -783,7 +791,7 @@ mod tests {
rusqlite::Connection::open(file.path())?.execute_batch( rusqlite::Connection::open(file.path())?.execute_batch(
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (1);", "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))?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20))?;
for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] { for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] {
app.show_schema = schema; app.show_schema = schema;
@@ -809,4 +817,30 @@ mod tests {
} }
Ok(()) 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(())
}
} }
+191 -35
View File
@@ -14,7 +14,7 @@ use zeroize::{Zeroize, Zeroizing};
use crate::{ use crate::{
Result, Result,
app::{App, Focus}, app::{App, Focus},
db::Database, db::{AccessMode, Database},
error_text, error_text,
}; };
@@ -56,7 +56,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
&& browser.as_ref().is_some_and(App::query_insert_mode) && 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)); 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)]) let [sidebar, content] = Layout::horizontal([Constraint::Length(32), Constraint::Min(1)])
.areas(workspace); .areas(workspace);
let items = entries.iter().map(|entry| { let items = entries.iter().map(|entry| {
@@ -64,7 +64,12 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
ExplorerEntry::Database(index) => { ExplorerEntry::Database(index) => {
let connected = browser.is_some() && index == active; let connected = browser.is_some() && index == active;
let label = index.map_or_else(|| "Session database".into(), |index| profiles.entries[index].label()); 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) => { ExplorerEntry::Relation(index) => {
let relation = &browser.as_ref().unwrap().relations[index]; let relation = &browser.as_ref().unwrap().relations[index];
@@ -81,7 +86,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
if let Some(browser) = &mut browser { if let Some(browser) = &mut browser {
browser.draw(frame, content, if help.is_some() { Focus::Explorer } else { focus }); browser.draw(frame, content, if help.is_some() { Focus::Explorer } else { focus });
} else { } 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 { 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)); 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<Database>) -> Result<
.map(|connected| { .map(|connected| {
browser = Some(connected); browser = Some(connected);
connection_label = Some(form.connection_label()); connection_label = Some(form.connection_label());
active = active = profiles.entries.iter().position(|entry| entry.id == form.id);
selection.selected().and_then(|index| {
match entries[index] {
ExplorerEntry::Database(index) => index,
_ => None,
}
});
selection.select(active); selection.select(active);
expanded = true; expanded = true;
focus = Focus::Explorer; focus = Focus::Explorer;
@@ -286,18 +285,22 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
_ => None, _ => None,
}); });
} }
KeyCode::Enter if focus == Focus::Explorer => { KeyCode::Enter | KeyCode::Char('R') if focus == Focus::Explorer => {
if let Some(entry) = if let Some(entry) =
selection.selected().and_then(|index| entries.get(index)) selection.selected().and_then(|index| entries.get(index))
{ {
let index = match *entry { let index = match *entry {
ExplorerEntry::Relation(_) if key.code == KeyCode::Char('R') => {
let Some(index) = active else { continue };
index
}
ExplorerEntry::Relation(index) => { ExplorerEntry::Relation(index) => {
browser.as_mut().unwrap().open_relation(Some(index)); browser.as_mut().unwrap().open_relation(Some(index));
focus = Focus::Rows; focus = Focus::Rows;
continue; continue;
} }
ExplorerEntry::Database(index) ExplorerEntry::Database(index)
if browser.is_some() && index == active => if browser.is_some() && index == active && key.code == KeyCode::Enter =>
{ {
expanded = !expanded; expanded = !expanded;
continue; continue;
@@ -413,6 +416,7 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
Home/End, g/G First / last item\n\ Home/End, g/G First / last item\n\
Left/Right, h/l Collapse/expand explorer or select cells\n\ Left/Right, h/l Collapse/expand explorer or select cells\n\
Enter Connect/expand database or open relation\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\ i Saved connection info on database or child\n\
e Edit saved configuration/name 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\ 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\ Backspace/Delete Remove text in Insert mode\n\
Ctrl-U Clear query in Insert mode\n\ Ctrl-U Clear query in Insert mode\n\
Esc Leave Insert first, then return to explorer\n\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\ Connection / password windows\n\
Tab / Shift-Tab Change fields\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\ Enter Edit provider / save settings / connect\n\
Ctrl-U / paste Clear / enter text in a focused field\n\ Ctrl-U / paste Clear / enter text in a focused field\n\
Esc Cancel and discard edits\n\ Esc Cancel and discard edits\n\
Optional Name sets the sidebar label; blank uses the default.\n\ Optional Name sets the sidebar label; blank uses the default.\n\
Connect saves settings only on success; e edits save without connecting.\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\ 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\ Choosing a bundle requires TLS; choose System trust to unlock TLS mode.\n\
Passwords are never saved.", 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 { if width < 4 {
return if connected && width > 0 { return if connected && width > 0 {
"*".into() "*".into()
@@ -476,12 +492,16 @@ fn database_label(label: &str, connected: bool, expanded: bool, width: u16) -> S
String::new() 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 { let mut result = String::from(if connected && expanded {
"▼ " "▼ "
} else { } 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 span = Span::raw(label);
let mut used = 0; let mut used = 0;
for grapheme in span.styled_graphemes(Style::default()) { 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; used += width;
result.push_str(grapheme.symbol); result.push_str(grapheme.symbol);
} }
for _ in used..available {
result.push(' ');
}
result.push(' ');
result.push_str(badge);
if connected { if connected {
result.push_str(" *"); result.push_str(" *");
} }
@@ -514,6 +539,7 @@ fn draw_bars(
bottom: Rect, bottom: Rect,
provider: &str, provider: &str,
name: &str, name: &str,
access: Option<AccessMode>,
insert: bool, insert: bool,
position: Option<(usize, usize)>, position: Option<(usize, usize)>,
) { ) {
@@ -524,7 +550,8 @@ fn draw_bars(
Paragraph::new(Line::from(vec![ Paragraph::new(Line::from(vec![
Span::styled(" dbdash ", badge), Span::styled(" dbdash ", badge),
Span::styled("▓░", edge), Span::styled("▓░", edge),
Span::raw(" READ ONLY"), Span::raw(" "),
Span::raw(access.map_or("NOT CONNECTED", AccessMode::label)),
])) ]))
.style(base), .style(base),
top, top,
@@ -635,7 +662,15 @@ impl Profiles {
{ {
storage.execute_batch("ALTER TABLE connections ADD COLUMN ca_bundle TEXT")?; 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| { .query_map([], |row| {
let mut form = Form { let mut form = Form {
id: Some(row.get(7)?), id: Some(row.get(7)?),
@@ -644,6 +679,7 @@ impl Profiles {
tls: usize::from(row.get::<_, u8>(5)?), tls: usize::from(row.get::<_, u8>(5)?),
path: Input::from(row.get::<_, String>(6)?.as_str()), path: Input::from(row.get::<_, String>(6)?.as_str()),
ca_bundle: row.get::<_, Option<String>>(9)?.map(PathBuf::from), ca_bundle: row.get::<_, Option<String>>(9)?.map(PathBuf::from),
access: if row.get::<_, bool>(10)? { AccessMode::ReadWrite } else { AccessMode::ReadOnly },
..Form::default() ..Form::default()
}; };
for index in 0..4 { for index in 0..4 {
@@ -661,6 +697,7 @@ impl Profiles {
name: Input::from(form.name.value.trim()), name: Input::from(form.name.value.trim()),
provider: form.provider, provider: form.provider,
focus: form.focus, focus: form.focus,
access: form.access,
postgres: std::array::from_fn(|index| { postgres: std::array::from_fn(|index| {
if index == 4 { if index == 4 {
Input::default() Input::default()
@@ -688,12 +725,13 @@ impl Profiles {
saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?); saved.path = Input::from(absolute.to_str().ok_or("SQLite path must be UTF-8")?);
} }
self.storage.execute( self.storage.execute(
"INSERT INTO connections (provider, host, port, database_name, username, tls, path, id, name, ca_bundle) "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) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
provider = excluded.provider, host = excluded.host, port = excluded.port, provider = excluded.provider, host = excluded.host, port = excluded.port,
database_name = excluded.database_name, username = excluded.username, 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![ rusqlite::params![
saved.provider as u8, saved.provider as u8,
saved.postgres[0].value, saved.postgres[0].value,
@@ -704,7 +742,8 @@ impl Profiles {
saved.path.value, saved.path.value,
saved.id, saved.id,
saved.name.value, 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()); saved.id.get_or_insert(self.storage.last_insert_rowid());
@@ -732,6 +771,7 @@ struct Form {
name: Input, name: Input,
provider: usize, provider: usize,
focus: usize, focus: usize,
access: AccessMode,
postgres: [Input; 5], postgres: [Input; 5],
tls: usize, tls: usize,
ca_bundle: Option<PathBuf>, ca_bundle: Option<PathBuf>,
@@ -747,6 +787,7 @@ impl Default for Form {
name: Input::default(), name: Input::default(),
provider: 0, provider: 0,
focus: 0, focus: 0,
access: AccessMode::ReadOnly,
tls: 0, tls: 0,
ca_bundle: None, ca_bundle: None,
postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from), postgres: ["localhost", "5432", "postgres", "", ""].map(Input::from),
@@ -770,6 +811,10 @@ impl Form {
6 + usize::from(self.reconnect) 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<()> { fn select_ca_bundle(&mut self, directory: &Path, forward: bool) -> Result<()> {
let mut bundles = Vec::new(); let mut bundles = Vec::new();
for entry in std::fs::read_dir(directory)? { for entry in std::fs::read_dir(directory)? {
@@ -802,9 +847,9 @@ impl Form {
if self.path.value.is_empty() { if self.path.value.is_empty() {
return Err("Enter an existing SQLite database file path.".into()); 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<Config> { fn postgres_config(&self) -> Result<Config> {
@@ -872,13 +917,19 @@ impl Form {
fn edit(&mut self, key: KeyEvent) { fn edit(&mut self, key: KeyEvent) {
self.error = None; self.error = None;
let count = self.name_focus() + 1; let count = self.access_focus() + 1;
match key.code { match key.code {
KeyCode::Tab => self.focus = (self.focus + 1) % count, KeyCode::Tab => self.focus = (self.focus + 1) % count,
KeyCode::BackTab => self.focus = (self.focus + count - 1) % count, KeyCode::BackTab => self.focus = (self.focus + count - 1) % count,
KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => { KeyCode::Up | KeyCode::Down | KeyCode::Left | KeyCode::Right if self.focus == 0 => {
self.provider = 1 - self.provider 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 KeyCode::Left | KeyCode::Right
if self.provider == 0 && self.focus == 5 + usize::from(self.reconnect) => if self.provider == 0 && self.focus == 5 + usize::from(self.reconnect) =>
{ {
@@ -932,18 +983,20 @@ impl Form {
fn info_text(&self) -> String { fn info_text(&self) -> String {
if self.provider == 1 { if self.provider == 1 {
format!( format!(
"Provider: SQLite\nPath: {}\nAccess: READ ONLY", "Provider: SQLite\nPath: {}\nAccess: {} (saved setting; reconnect to apply)",
crate::db::display_text(&self.path.value) crate::db::display_text(&self.path.value),
self.access.label()
) )
} else { } else {
format!( 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[0].value),
crate::db::display_text(&self.postgres[1].value), crate::db::display_text(&self.postgres[1].value),
crate::db::display_text(&self.postgres[2].value), crate::db::display_text(&self.postgres[2].value),
crate::db::display_text(&self.postgres[3].value), crate::db::display_text(&self.postgres[3].value),
["Require", "Prefer", "Disable"][self.tls], ["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(),
) )
} }
} }
@@ -1013,13 +1066,27 @@ impl Form {
Paragraph::new(if self.reconnect { Paragraph::new(if self.reconnect {
self.label() self.label()
} else { } else {
"Configure a database · READ ONLY".into() "Configure a database".into()
}) })
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(Color::Cyan)),
header, header,
); );
let [name_area, body] = let [name_area, body] =
Layout::vertical([Constraint::Length(3), Constraint::Min(0)]).areas(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( self.name.draw(
frame, frame,
name_area, name_area,
@@ -1063,7 +1130,7 @@ impl Form {
self.focus == 1, self.focus == 1,
None, 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 { } else {
let rows = Layout::vertical([ let rows = Layout::vertical([
Constraint::Length(3), Constraint::Length(3),
@@ -1144,7 +1211,7 @@ impl Form {
let message = self.error.as_deref().unwrap_or(if self.reconnect { 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." "Edit settings and enter a password if needed. Changes are saved only after connecting."
} else { } else {
"Enter saves this configuration without connecting." "Enter saves without connecting. Existing sessions keep their mode; R reconnects."
}); });
frame.render_widget( frame.render_widget(
Paragraph::new(message) Paragraph::new(message)
@@ -1157,9 +1224,9 @@ impl Form {
status, status,
); );
frame.render_widget(Paragraph::new(if self.reconnect { 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 { } 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); }).style(Style::default().fg(Color::DarkGray)), help);
} }
} }
@@ -1452,6 +1519,85 @@ impl Input {
mod tests { mod tests {
use super::*; 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] #[test]
fn ca_selector_persists_per_profile_and_keeps_missing_selections_fail_closed() -> Result<()> { fn ca_selector_persists_per_profile_and_keeps_missing_selections_fail_closed() -> Result<()> {
let directory = tempfile::tempdir()?; let directory = tempfile::tempdir()?;
@@ -1616,7 +1762,7 @@ mod tests {
fn connected_marker_survives_long_wide_profile_labels() -> Result<()> { fn connected_marker_survives_long_wide_profile_labels() -> Result<()> {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?;
terminal.draw(|frame| { 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); assert_eq!(Line::raw(&label).width(), 30);
frame.render_widget( frame.render_widget(
List::new([ListItem::new(label)]).block(Block::bordered()), List::new([ListItem::new(label)]).block(Block::bordered()),
@@ -1624,7 +1770,12 @@ mod tests {
); );
})?; })?;
assert_eq!(terminal.backend().buffer()[(30, 1)].symbol(), "*"); 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(()) Ok(())
} }
@@ -1675,6 +1826,7 @@ mod tests {
bottom, bottom,
"SQLite", "SQLite",
"example.sqlite", "example.sqlite",
Some(AccessMode::ReadOnly),
false, false,
Some((1, 1)), Some((1, 1)),
); );
@@ -1701,6 +1853,7 @@ mod tests {
bottom, bottom,
"PostgreSQL", "PostgreSQL",
"a very long database name", "a very long database name",
Some(AccessMode::ReadOnly),
false, false,
None, None,
); );
@@ -1727,6 +1880,7 @@ mod tests {
bottom, bottom,
"SQLite", "SQLite",
"a very long database name", "a very long database name",
Some(AccessMode::ReadOnly),
true, true,
Some((12, 34)), Some((12, 34)),
); );
@@ -1751,6 +1905,7 @@ mod tests {
bottom, bottom,
"SQLite", "SQLite",
"long database name", "long database name",
Some(AccessMode::ReadOnly),
false, false,
None, None,
); );
@@ -1919,6 +2074,7 @@ mod tests {
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
form.input_mut().unwrap().paste("Warehouse"); form.input_mut().unwrap().paste("Warehouse");
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
for value in ["db.example", "5440", "warehouse", "inspector"] { for value in ["db.example", "5440", "warehouse", "inspector"] {
form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); form.edit(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
if form.focus == 0 { if form.focus == 0 {
+218 -12
View File
@@ -5,8 +5,13 @@ use openssl::{
ssl::{SslConnector, SslMethod, SslVerifyMode, SslVersion}, ssl::{SslConnector, SslMethod, SslVerifyMode, SslVersion},
x509::{X509, store::X509StoreBuilder}, x509::{X509, store::X509StoreBuilder},
}; };
use postgres::{Client, Config, config::SslMode}; use postgres::{Client, Config, SimpleQueryMessage, config::SslMode};
use postgres_native_tls::MakeTlsConnector; use postgres_native_tls::MakeTlsConnector;
use sqlparser::{
ast::{SetExpr, Statement},
dialect::{Dialect, PostgreSqlDialect, SQLiteDialect},
parser::Parser,
};
use crate::Result; use crate::Result;
@@ -14,9 +19,26 @@ mod sqlite;
pub const PAGE_SIZE: usize = 100; 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 { pub struct Database {
backend: Backend, backend: Backend,
display_name: String, display_name: String,
access_mode: AccessMode,
} }
#[allow( #[allow(
@@ -54,14 +76,19 @@ pub struct Page {
pub struct QueryResult { pub struct QueryResult {
pub headers: Vec<String>, pub headers: Vec<String>,
pub page: Page, pub page: Page,
pub affected_rows: Option<u64>,
} }
impl Database { impl Database {
pub fn connect(connection: &str, root_cert: Option<&Path>) -> Result<Self> { pub fn connect(connection: &str, root_cert: Option<&Path>, access: AccessMode) -> Result<Self> {
Self::connect_postgres(connection.parse()?, root_cert) Self::connect_postgres(connection.parse()?, root_cert, access)
} }
pub fn connect_postgres(mut config: Config, root_cert: Option<&Path>) -> Result<Self> { pub fn connect_postgres(
mut config: Config,
root_cert: Option<&Path>,
access: AccessMode,
) -> Result<Self> {
if config.get_connect_timeout().is_none() { if config.get_connect_timeout().is_none() {
config.connect_timeout(Duration::from_secs(5)); config.connect_timeout(Duration::from_secs(5));
} }
@@ -72,25 +99,32 @@ impl Database {
config.connect(MakeTlsConnector::new(TlsConnector::new()?))? config.connect(MakeTlsConnector::new(TlsConnector::new()?))?
}; };
// Views can invoke functions: enforce read-only on the server, not just in the UI. // Views can invoke functions: enforce read-only on the server, not just in the UI.
client.batch_execute( client.batch_execute(match access {
"SET default_transaction_read_only = on; SET statement_timeout = '5s';", 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); let display_name: String = client.query_one("SELECT current_database()", &[])?.get(0);
Ok(Self { Ok(Self {
backend: Backend::Postgres(client), backend: Backend::Postgres(client),
display_name: display_text(&display_name), display_name: display_text(&display_name),
access_mode: access,
}) })
} }
pub fn open_sqlite(path: &Path) -> Result<Self> { pub fn open_sqlite(path: &Path, access: AccessMode) -> Result<Self> {
Ok(Self { Ok(Self {
backend: Backend::Sqlite(sqlite::open(path)?), backend: Backend::Sqlite(sqlite::open(path, access)?),
display_name: display_text( display_name: display_text(
&path &path
.file_name() .file_name()
.unwrap_or(path.as_os_str()) .unwrap_or(path.as_os_str())
.to_string_lossy(), .to_string_lossy(),
), ),
access_mode: access,
}) })
} }
@@ -98,6 +132,10 @@ impl Database {
&self.display_name &self.display_name
} }
pub fn access_mode(&self) -> AccessMode {
self.access_mode
}
pub fn name(&self) -> &'static str { pub fn name(&self) -> &'static str {
match self.backend { match self.backend {
Backend::Postgres(_) => "PostgreSQL", Backend::Postgres(_) => "PostgreSQL",
@@ -188,8 +226,51 @@ impl Database {
let sql = sql.strip_suffix(';').unwrap_or(sql).trim_end(); let sql = sql.strip_suffix(';').unwrap_or(sql).trim_end();
let client = match &mut self.backend { let client = match &mut self.backend {
Backend::Postgres(client) => client, 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::<Vec<_>>();
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()?; let mut transaction = client.build_transaction().read_only(true).start()?;
transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?; transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?;
let statement = transaction.prepare(&format!( let statement = transaction.prepare(&format!(
@@ -233,6 +314,7 @@ impl Database {
Ok(QueryResult { Ok(QueryResult {
headers, headers,
page: Page { rows, has_next }, page: Page { rows, has_next },
affected_rows: None,
}) })
} }
@@ -308,6 +390,50 @@ fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result<SslConnector> {
Ok(tls.build()) Ok(tls.build())
} }
fn checked_statement(sql: &str, sqlite: bool) -> Result<Statement> {
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> { fn page_offset(page: usize) -> Result<i64> {
i64::try_from(page)? i64::try_from(page)?
.checked_mul(PAGE_SIZE as i64) .checked_mul(PAGE_SIZE as i64)
@@ -442,7 +568,7 @@ mod tests {
let directory = tempfile::tempdir()?; let directory = tempfile::tempdir()?;
let path = directory.path().join("accounts\nprivate.sqlite"); let path = directory.path().join("accounts\nprivate.sqlite");
drop(rusqlite::Connection::open(&path)?); 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"); assert_eq!(db.display_name(), "accounts\\nprivate.sqlite");
Ok(()) Ok(())
} }
@@ -456,6 +582,86 @@ mod tests {
assert_eq!(display_text("é🙂\n\t\r\u{1b}[2J"), "é🙂\\n\\t\\r\\u{1b}[2J"); 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::<Config>()?
.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] #[test]
#[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"] #[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"]
fn inspect_postgres_read_only() -> Result<()> { 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 VIEW {schema}.sample_view AS SELECT id FROM {schema}.\"odd\"\"table\";
CREATE TABLE {schema}.empty_table (id integer);" 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 relations = db.relations()?;
let table = relations let table = relations
.iter() .iter()
+108 -18
View File
@@ -1,21 +1,29 @@
use std::{fmt::Write, path::Path, time::Duration}; use std::{fmt::Write, path::Path, time::Duration};
use rusqlite::{Connection, OpenFlags, params, types::ValueRef}; use rusqlite::{Connection, OpenFlags, params, types::ValueRef};
use sqlparser::ast::Statement;
use super::{ 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; use crate::Result;
pub(super) fn open(path: &Path) -> Result<Connection> { pub(super) fn open(path: &Path, access: AccessMode) -> Result<Connection> {
// An absolute existing path also prevents SQLite's special :memory: filename from creating a database. // An absolute existing path also prevents SQLite's special :memory: filename from creating a database.
let path = shellexpand::path::full(path)?.canonicalize()?; let path = shellexpand::path::full(path)?.canonicalize()?;
let connection = Connection::open_with_flags( let connection = Connection::open_with_flags(
path, 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.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) Ok(connection)
} }
@@ -111,10 +119,32 @@ pub(super) fn page(
); );
let mut statement = connection.prepare(&sql)?; let mut statement = connection.prepare(&sql)?;
let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?; 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<QueryResult> { pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> Result<QueryResult> {
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 original = connection.prepare(sql)?;
let headers = original let headers = original
.column_names() .column_names()
@@ -126,18 +156,20 @@ pub(super) fn query(connection: &Connection, sql: &str) -> Result<QueryResult> {
PAGE_SIZE + 1 PAGE_SIZE + 1
))?; ))?;
let column_count = statement.column_count(); let column_count = statement.column_count();
let page = read_page(&mut statement.query([])?, column_count)?; let page = read_page(&mut statement.query([])?, column_count, false)?;
Ok(QueryResult { headers, page }) Ok(QueryResult { headers, page, affected_rows: None })
} }
fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize) -> Result<Page> { fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize, exhaust: bool) -> Result<Page> {
let mut rows = Vec::with_capacity(PAGE_SIZE); let mut rows = Vec::with_capacity(PAGE_SIZE);
let mut has_next = false;
while let Some(row) = query.next()? { while let Some(row) = query.next()? {
if rows.len() == PAGE_SIZE { if rows.len() == PAGE_SIZE {
return Ok(Page { has_next = true;
rows, if exhaust {
has_next: true, continue;
}); }
break;
} }
let values = (0..column_count) let values = (0..column_count)
.map(|index| { .map(|index| {
@@ -157,7 +189,7 @@ fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize) -> Result<Page
} }
Ok(Page { Ok(Page {
rows, rows,
has_next: false, has_next,
}) })
} }
@@ -183,7 +215,7 @@ mod tests {
let writer = Connection::open(&path)?; let writer = Connection::open(&path)?;
writer writer
.execute_batch("CREATE TABLE saved (value INTEGER); INSERT INTO saved VALUES (7);")?; .execute_batch("CREATE TABLE saved (value INTEGER); INSERT INTO saved VALUES (7);")?;
let mut db = Database::open_sqlite(&path)?; let mut db = Database::open_sqlite(&path, AccessMode::ReadOnly)?;
let result = db.query( let result = db.query(
" \nSELECT NULL AS \"same\", X'00FF' AS \"same\", char(27) || '[2J' AS \"line " \nSELECT NULL AS \"same\", X'00FF' AS \"same\", char(27) || '[2J' AS \"line
@@ -238,6 +270,62 @@ break\", 2.5 AS amount; \n",
Ok(()) Ok(())
} }
#[test]
fn query_sqlite_read_write_commits_complete_statements_and_rolls_back_failures() -> 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] #[test]
fn inspect_sqlite_read_only() -> Result<()> { fn inspect_sqlite_read_only() -> Result<()> {
let directory = tempfile::tempdir()?; 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 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;" ) 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()?; let relations = db.relations()?;
assert!( assert!(
!relations !relations
@@ -374,12 +462,14 @@ break\", 2.5 AS amount; \n",
matches!(error, rusqlite::Error::SqliteFailure(error, _) if error.code == rusqlite::ErrorCode::ReadOnly) matches!(error, rusqlite::Error::SqliteFailure(error, _) if error.code == rusqlite::ErrorCode::ReadOnly)
); );
let missing = directory.path().join("missing.sqlite"); 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()); assert!(!missing.exists());
let path = directory.path().join("invalid.sqlite"); let path = directory.path().join("invalid.sqlite");
std::fs::write(&path, b"not a SQLite database")?; std::fs::write(&path, b"not a SQLite database")?;
assert!( assert!(
Database::open_sqlite(&path) Database::open_sqlite(&path, AccessMode::ReadOnly)
.and_then(|mut db| db.relations()) .and_then(|mut db| db.relations())
.is_err() .is_err()
); );
+55 -23
View File
@@ -12,12 +12,12 @@ use crossterm::event::{
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>; type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
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: Usage:
dbdash [POSTGRES_CONNECTION_STRING] dbdash [--read-write] [POSTGRES_CONNECTION_STRING]
dbdash --ca-bundle PATH POSTGRES_CONNECTION_STRING dbdash [--read-write] --ca-bundle PATH POSTGRES_CONNECTION_STRING
dbdash --sqlite PATH dbdash [--read-write] --sqlite PATH
dbdash --help dbdash --help
Run without a connection argument to open the saved database list: 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. SQLite paths expand leading ~, $VAR, and ${VAR}; undefined variables are errors.
Expansion never runs shell commands. Quote CLI paths to let dbdash expand them. 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. PostgreSQL URLs and libpq-style key=value strings are accepted.
Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
Use sslmode=disable only for trusted local connections. Use sslmode=disable only for trusted local connections.
@@ -68,12 +76,13 @@ Navigation:
i Show configured database info (Esc/Enter closes; passwords omitted) i Show configured database info (Esc/Enter closes; passwords omitted)
Works on a saved database entry or any of its table/view children. Works on a saved database entry or any of its table/view children.
r Refresh relations and the active relation 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. Unnamed SQLite entries show only the filename; the info window shows the full path.
Enter on the connected database collapses/expands it without reconnecting. Enter on the connected database collapses/expands it without reconnecting.
Keybindings float above the workspace; Up/Down/jk scroll, Home/End/g/G jump. 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. 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, Saved PostgreSQL connections show database · user@host:port beside the mode,
regardless of custom sidebar names; passwords are never included. regardless of custom sidebar names; passwords are never included.
The database provider sits immediately left of the bottom-right position badge. 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. Connection/password fields remain direct-entry; query paste requires INSERT.
SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect, SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect,
including quoted identifiers, literals, and multiline comments/strings as you type. including quoted identifiers, literals, and multiline comments/strings as you type.
F5 / Ctrl-Enter executes one SELECT/CTE on the current connection, showing up F5 / Ctrl-Enter executes one SQL statement on the current connection, showing up
to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more. 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. Ctrl-Enter needs terminal support for enhanced keyboard reporting; use F5 otherwise.
Failed queries keep the previous results. Opening a relation restores browsing Failed queries keep the previous results. Opening a relation restores browsing
without clearing the query text. Query text is not saved across connections. 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 / Ctrl-C Quit outside the query editor (q inserts text only in INSERT)
New database window: Up/Down chooses a provider; Enter starts editing. 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. Enter saves without connecting, including when the database is offline.
Optional Name controls the sidebar label; leave it blank for the default label. 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. 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). 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. 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 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; 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. only on success; failures keep the previous settings and connection. Esc discards edits.
Passwords are masked, never saved, and clear on success or cancellation. 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. 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. back to ~/.config/dbdash/connections.sqlite3. SQLite paths are saved absolute.
CLI connections are not saved. Failed switches keep the current database open. 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. PostgreSQL statements have a 5-second timeout. READ ONLY sessions enforce read-only
SQLite files are opened read-only, with a 5-second lock wait but no query timeout. transactions; READ WRITE statements commit on success. SQLite READ ONLY opens files
The interface waits for reads to complete. NULL cells are dimmed; SQLite BLOBs read-only; READ WRITE permits writes to existing files. Missing files are never created.
are shown as X'hex'. Only SQLite's main database is inspected. 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. 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. 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() { 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()); 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 mut access = db::AccessMode::ReadOnly;
let path = args.next().ok_or("--ca-bundle requires a PEM file path")?; let mut root_cert = None;
argument = Some(args.next().ok_or("--ca-bundle requires a PostgreSQL connection string")?); loop {
Some(path) match argument.as_deref().and_then(|arg| arg.to_str()) {
} else { Some("--read-write") => access = db::AccessMode::ReadWrite,
None 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"); let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite");
if sqlite && root_cert.is_some() { if sqlite && root_cert.is_some() {
return Err("--ca-bundle is only supported for PostgreSQL".into()); return Err("--ca-bundle is only supported for PostgreSQL".into());
@@ -180,13 +211,14 @@ fn run() -> Result<()> {
let database = connection let database = connection
.map(|connection| { .map(|connection| {
if sqlite { if sqlite {
db::Database::open_sqlite(Path::new(&connection)) db::Database::open_sqlite(Path::new(&connection), access)
} else { } else {
db::Database::connect( db::Database::connect(
connection connection
.to_str() .to_str()
.ok_or("connection string must be UTF-8")?, .ok_or("connection string must be UTF-8")?,
root_cert.as_deref().map(Path::new), root_cert.as_deref().map(Path::new),
access,
) )
} }
}) })