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