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
+2 -1
View File
@@ -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)
- [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()
}
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(())
}
}
+191 -35
View File
@@ -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<Database>) -> 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<Database>) -> 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<Database>) -> 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<Database>) -> 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<Database>) -> 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<AccessMode>,
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<String>>(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<PathBuf>,
@@ -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<Config> {
@@ -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 {
+218 -12
View File
@@ -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<String>,
pub page: Page,
pub affected_rows: Option<u64>,
}
impl Database {
pub fn connect(connection: &str, root_cert: Option<&Path>) -> Result<Self> {
Self::connect_postgres(connection.parse()?, root_cert)
pub fn connect(connection: &str, root_cert: Option<&Path>, access: AccessMode) -> Result<Self> {
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() {
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<Self> {
pub fn open_sqlite(path: &Path, access: AccessMode) -> Result<Self> {
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::<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()?;
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<SslConnector> {
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> {
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::<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]
#[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()
+108 -18
View File
@@ -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<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.
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<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 headers = original
.column_names()
@@ -126,18 +156,20 @@ pub(super) fn query(connection: &Connection, sql: &str) -> Result<QueryResult> {
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<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 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<Page
}
Ok(Page {
rows,
has_next: false,
has_next,
})
}
@@ -183,7 +215,7 @@ mod tests {
let writer = Connection::open(&path)?;
writer
.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(
" \nSELECT NULL AS \"same\", X'00FF' AS \"same\", char(27) || '[2J' AS \"line
@@ -238,6 +270,62 @@ break\", 2.5 AS amount; \n",
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]
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()
);
+55 -23
View File
@@ -12,12 +12,12 @@ use crossterm::event::{
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:
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,
)
}
})