feat: support explicit read-write database connections
This commit is contained in:
@@ -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 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: 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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user