Compare commits

..

11 Commits

Author SHA1 Message Date
hl-valdemar e0ed84bbfa chore: update todo 2026-10-25 23:17:11 +01:00
Valdemar Lorenzen 32db904fa4 feat: stage and atomically apply row edits 2026-10-21 11:09:50 +02:00
Valdemar Lorenzen 138490eecd fix: quote CSV fields only when required 2026-10-21 10:53:39 +02:00
Valdemar Lorenzen c533e71ecc feat: select and copy rectangular row ranges 2026-10-21 10:20:54 +02:00
Valdemar Lorenzen 0442e4b153 fix: remove duplicate gap before footer access badge 2026-10-21 10:17:09 +02:00
Valdemar Lorenzen d0c75fb723 fix: use readonly and readwrite footer labels 2026-10-20 17:04:26 +02:00
Valdemar Lorenzen d3bb1e0e45 fix: add one-column margins around footer access badge 2026-10-20 17:02:42 +02:00
Valdemar Lorenzen 35bbb27271 fix: show lowercase access as a flat footer badge 2026-10-20 16:57:47 +02:00
Valdemar Lorenzen cad3d768e3 fix: omit disconnected database label from the footer 2026-10-20 16:53:56 +02:00
Valdemar Lorenzen 77a0ac373f fix: style top-right access as a matching corner badge 2026-10-20 16:48:52 +02:00
Valdemar Lorenzen 1ed981b43d fix: right-align connection access in the header 2026-10-20 16:47:34 +02:00
7 changed files with 2465 additions and 200 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name = "dbdash"
version = "0.1.0"
edition = "2024"
description = "Read-only PostgreSQL and SQLite inspection in your terminal"
description = "PostgreSQL and SQLite terminal browser with opt-in staged editing"
[dependencies]
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
-5
View File
@@ -1,5 +0,0 @@
- [x] feature: ability to give the connection configs a custom name (control how they're displayed in the sidebar)
- [x] feature: ability to remove a database connection
- [x] feature: line numbers in gutter of sql editor
- [x] bug: database type should display nothing instead of `-` when no database is connected. (it should only show the type when it's available)
- [x] feature: per-connection read-only/read-write access, sidebar badges, and atomic single-statement SQL editing with affected counts and RETURNING
+1034 -16
View File
File diff suppressed because it is too large Load Diff
+416 -76
View File
@@ -33,6 +33,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
let mut info: Option<usize> = None;
let mut help: Option<u16> = None;
let mut remove: Option<usize> = None;
let mut pending_external: Option<KeyEvent> = None;
loop {
let entries = explorer_entries(
profiles.entries.len(),
@@ -42,20 +43,25 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
);
terminal.draw(|frame| {
let [top, workspace, bottom] = shell_areas(frame.area());
let position = if info.is_some() || help.is_some() || remove.is_some() {
let position = if info.is_some() || help.is_some() || remove.is_some()
|| browser.as_ref().is_some_and(App::discard_prompt_active) {
None
} else if show_form {
form.input().map(Input::position)
} else {
browser.as_ref().filter(|_| focus == Focus::Query).map(App::query_position)
browser.as_ref().and_then(|app| {
app.cell_editor_position().or_else(|| (focus == Focus::Query).then(|| app.query_position()))
})
};
let insert = if show_form {
form.input().is_some()
} else {
info.is_none() && focus == Focus::Query
&& browser.as_ref().is_some_and(App::query_insert_mode)
info.is_none() && browser.as_ref().is_some_and(|app| {
app.cell_editor_position().is_some()
|| (focus == Focus::Query && 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("", App::database_name));
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);
@@ -111,9 +117,48 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
profiles.entries[index].label()))
.wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ").title_bottom(" y: remove · n/Esc: cancel ")), area);
}
if let Some(browser) = &browser {
browser.draw_discard_prompt(frame, workspace);
}
})?;
match event::read()? {
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
Event::Key(mut key)
if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) =>
{
if let Some(app) = browser.as_mut().filter(|app| app.discard_prompt_active()) {
if app.resolve_discard(key) {
key = pending_external.take().unwrap();
} else {
if !app.discard_prompt_active() {
pending_external = None;
}
continue;
}
}
let ctrl_c =
key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL);
let entry = selection.selected().and_then(|index| entries.get(index));
let external = ctrl_c
|| (info.is_none()
&& help.is_none()
&& remove.is_none()
&& if show_form {
key.code == KeyCode::Enter && form.reconnect && form.focus != 0
} else {
leaves_drafts(
key,
focus,
entry.copied(),
active,
browser
.as_ref()
.is_some_and(|app| app.cell_editor_position().is_some()),
)
});
if external && browser.as_mut().is_some_and(App::request_external_discard) {
pending_external = Some(key);
continue;
}
if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(());
}
@@ -184,7 +229,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
.map(|connected| {
browser = Some(connected);
connection_label = Some(form.connection_label());
active = profiles.entries.iter().position(|entry| entry.id == form.id);
active = profiles
.entries
.iter()
.position(|entry| entry.id == form.id);
selection.select(active);
expanded = true;
focus = Focus::Explorer;
@@ -221,6 +269,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}
} else {
match key.code {
_ if focus == Focus::Rows
&& browser
.as_mut()
.is_some_and(|app| app.handle_rows_command(key)) => {}
KeyCode::Char('?')
if focus != Focus::Query
|| !browser.as_ref().is_some_and(App::query_insert_mode) =>
@@ -290,7 +342,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
selection.selected().and_then(|index| entries.get(index))
{
let index = match *entry {
ExplorerEntry::Relation(_) if key.code == KeyCode::Char('R') => {
ExplorerEntry::Relation(_)
if key.code == KeyCode::Char('R') =>
{
let Some(index) = active else { continue };
index
}
@@ -300,7 +354,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
continue;
}
ExplorerEntry::Database(index)
if browser.is_some() && index == active && key.code == KeyCode::Enter =>
if browser.is_some()
&& index == active
&& key.code == KeyCode::Enter =>
{
expanded = !expanded;
continue;
@@ -377,7 +433,14 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}
}
}
Event::Paste(mut text) if info.is_some() || help.is_some() || remove.is_some() => text.zeroize(),
Event::Paste(mut text)
if info.is_some()
|| help.is_some()
|| remove.is_some()
|| browser.as_ref().is_some_and(App::discard_prompt_active) =>
{
text.zeroize()
}
Event::Paste(text) if show_form => {
let text = Zeroizing::new(text);
form.error = None;
@@ -391,12 +454,44 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
browser.paste_query(&text);
}
}
Event::Paste(text) if focus == Focus::Rows => {
let text = Zeroizing::new(text);
if let Some(browser) = &mut browser {
browser.paste_cell(&text);
}
}
Event::Paste(mut text) => text.zeroize(),
_ => {}
}
}
}
fn leaves_drafts(
key: KeyEvent,
focus: Focus,
entry: Option<ExplorerEntry>,
active: Option<usize>,
editing: bool,
) -> bool {
if key.code == KeyCode::Char('q') && focus != Focus::Query && !editing {
return true;
}
focus == Focus::Explorer
&& match key.code {
KeyCode::Delete | KeyCode::Char('d') => {
matches!(entry, Some(ExplorerEntry::Database(Some(_))))
}
KeyCode::Char('R') => {
matches!(entry, Some(ExplorerEntry::Database(Some(_))))
|| (active.is_some() && matches!(entry, Some(ExplorerEntry::Relation(_))))
}
KeyCode::Enter => {
matches!(entry, Some(ExplorerEntry::Database(Some(index))) if Some(index) != active)
}
_ => false,
}
}
fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
let width = screen
.width
@@ -409,8 +504,8 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
Tab / Shift-Tab Cycle explorer, query, and rows/schema\n\
Esc Return to explorer\n\
c Add a configured database\n\
q Quit outside the query editor\n\
Ctrl-C Quit from any surface\n\n\
q Quit outside query/cell editors\n\
Ctrl-C Quit from any surface (unsaved edits prompt)\n\n\
Explorer and rows/schema (Normal mode)\n\
Up/Down, j/k Move selection\n\
Home/End, g/G First / last item\n\
@@ -423,6 +518,30 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
s Toggle rows / column schema\n\
n/p, PgDn/PgUp Next / previous 100-row relation page\n\
r Refresh relations and active relation\n\n\
Rows / query results\n\
v / Ctrl-V Toggle rectangular block; hjkl/arrows extend it\n\
ym / yc Copy Markdown / CSV with column headers\n\
Ym / Yc Copy Markdown / CSV without column names\n\
Esc Cancel pending copy or block before leaving rows\n\
With no block, copy uses the current cell. Blocks stay within the loaded page.\n\
Paging, refresh, schema/relation changes, and successful queries clear blocks.\n\
Copy uses full displayed values; NULL/control escapes are preserved.\n\
Headerless Markdown has blank headings. CSV uses CRLF and quotes only fields\n\
containing commas, double quotes, or line breaks.\n\
Clipboard: macOS pbcopy; Windows PowerShell; Wayland wl-copy; X11 xclip.\n\n\
Staged cell editing (READ WRITE base tables with primary keys)\n\
e / Enter Open selected cell; Enter stages; Esc cancels editor\n\
Alt/Shift-Enter Insert newline; Tab inserts a literal tab\n\
Ctrl-N Toggle explicit NULL (not empty text or string NULL)\n\
F5 / Ctrl-Enter Stage active editor and atomically apply all drafts\n\
D Discard drafts (y confirms; n/Esc cancels)\n\
Yellow cells and counts show drafts across pages; copies include them.\n\
Raw tabs/control characters are preserved; rendering uses safe escapes.\n\
Views/results, keyless tables, generated columns and rows with NULL keys\n\
are read-only. SQLite numbers/BLOBs retain their original storage class.\n\
Apply checks original keys/values; any error rolls back and keeps edits.\n\
Refresh, relation/connection changes, SQL execution, and quit prompt before\n\
discarding. Cancel keeps drafts/editor; success reloads trigger/server values.\n\n\
Query editor\n\
i/a/I/A Enter Insert mode (at/after/start/end)\n\
hjkl / arrows Move cursor in Normal mode\n\
@@ -440,7 +559,7 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
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\
Result limits never limit the rows written. Rows editing never runs SQL text.\n\n\
Connection / password windows\n\
Tab / Shift-Tab Change fields\n\
Arrows Choose provider; Left/Right changes TLS, CA, or Access\n\
@@ -451,7 +570,7 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
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 stay right-aligned; * before the badge marks the active connection.\n\
Header/connected badge show live mode; info shows saved Access.\n\
Footer/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\
@@ -480,7 +599,13 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
);
}
fn database_label(label: &str, access: AccessMode, 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]",
@@ -550,8 +675,6 @@ fn draw_bars(
Paragraph::new(Line::from(vec![
Span::styled(" dbdash ", badge),
Span::styled("▓░", edge),
Span::raw(" "),
Span::raw(access.map_or("NOT CONNECTED", AccessMode::label)),
]))
.style(base),
top,
@@ -561,12 +684,24 @@ fn draw_bars(
let position = position.as_deref().unwrap_or(" N/A ");
let position_width = (position.len() as u16 + 2).min(bottom.width);
let mode_width = bottom.width.saturating_sub(position_width).min(10);
let provider_width = (Span::raw(provider).width() as u16 + 2)
let access = match access {
Some(AccessMode::ReadOnly) => " readonly ",
Some(AccessMode::ReadWrite) => " readwrite ",
None => "",
};
let access_width = (access.len() as u16 + if access.is_empty() { 0 } else { 2 })
.min(bottom.width.saturating_sub(position_width + mode_width));
let [mode, name_area, provider_area, cursor] = Layout::horizontal([
let provider_width =
(Span::raw(provider).width() as u16 + if access.is_empty() { 2 } else { 1 }).min(
bottom
.width
.saturating_sub(position_width + mode_width + access_width),
);
let [mode, name_area, provider_area, access_area, cursor] = Layout::horizontal([
Constraint::Length(mode_width),
Constraint::Min(0),
Constraint::Length(provider_width),
Constraint::Length(access_width),
Constraint::Length(position_width),
])
.areas(bottom);
@@ -580,9 +715,26 @@ fn draw_bars(
);
frame.render_widget(Paragraph::new(format!(" {name}")).style(base), name_area);
frame.render_widget(
Paragraph::new(provider).centered().style(base),
Paragraph::new(provider)
.alignment(if access.is_empty() {
ratatui::layout::Alignment::Center
} else {
ratatui::layout::Alignment::Right
})
.style(base),
provider_area,
);
if !access.is_empty() {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw(" "),
Span::styled(access, badge),
Span::raw(" "),
]))
.style(base),
access_area,
);
}
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::styled("░▓", edge),
@@ -706,7 +858,9 @@ impl Profiles {
}
}),
tls: form.tls,
ca_bundle: (form.provider == 0).then(|| form.ca_bundle.clone()).flatten(),
ca_bundle: (form.provider == 0)
.then(|| form.ca_bundle.clone())
.flatten(),
path: form.path.clone(),
error: None,
reconnect: false,
@@ -833,7 +987,10 @@ impl Form {
if !choices.contains(&self.ca_bundle) {
choices.push(self.ca_bundle.clone());
}
let current = choices.iter().position(|path| path == &self.ca_bundle).unwrap();
let current = choices
.iter()
.position(|path| path == &self.ca_bundle)
.unwrap();
let next = (current + if forward { 1 } else { choices.len() - 1 }) % choices.len();
self.ca_bundle = choices.swap_remove(next);
if self.ca_bundle.is_some() {
@@ -849,12 +1006,18 @@ impl Form {
}
return Database::open_sqlite(Path::new(&self.path.value), self.access);
}
Database::connect_postgres(self.postgres_config()?, self.ca_bundle.as_deref(), self.access)
Database::connect_postgres(
self.postgres_config()?,
self.ca_bundle.as_deref(),
self.access,
)
}
fn postgres_config(&self) -> Result<Config> {
if self.ca_bundle.is_some() && self.tls != 0 {
return Err("A CA bundle requires Require TLS; plaintext fallback is not allowed.".into());
return Err(
"A CA bundle requires Require TLS; plaintext fallback is not allowed.".into(),
);
}
let [host, port, database, user, password] = &self.postgres;
if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() {
@@ -995,7 +1158,10 @@ impl Form {
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.ca_bundle.as_ref().map_or_else(
|| "System trust".into(),
|path| crate::db::display_text(&path.to_string_lossy())
),
self.access.label(),
)
}
@@ -1077,13 +1243,13 @@ impl Form {
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() {
Block::bordered()
.title(" Access · ←/→ change ")
.border_style(Style::default().fg(if self.focus == self.access_focus() {
Color::Cyan
} else {
Color::DarkGray
}),
),
})),
),
access_area,
);
@@ -1180,17 +1346,24 @@ impl Form {
);
let ca_label = self.ca_bundle.as_ref().map_or_else(
|| "System trust".into(),
|path| crate::db::display_text(&path.file_name().unwrap_or(path.as_os_str()).to_string_lossy()),
|path| {
crate::db::display_text(
&path
.file_name()
.unwrap_or(path.as_os_str())
.to_string_lossy(),
)
},
);
frame.render_widget(
Paragraph::new(ca_label).block(
Block::bordered().title(" CA bundle · ←/→ choose ").border_style(
Style::default().fg(if self.focus == self.ca_focus() {
Block::bordered()
.title(" CA bundle · ←/→ choose ")
.border_style(Style::default().fg(if self.focus == self.ca_focus() {
Color::Cyan
} else {
Color::DarkGray
}),
),
})),
),
rows[3],
);
@@ -1252,6 +1425,15 @@ impl From<&str> for Input {
}
}
impl From<String> for Input {
fn from(value: String) -> Self {
Self {
cursor: value.len(),
value,
}
}
}
impl Input {
pub(crate) fn vim_motion(&mut self, motion: char) {
let class = |character: char| {
@@ -1446,6 +1628,11 @@ impl Input {
self.cursor += text.len();
}
pub(crate) fn paste_raw(&mut self, value: &str) {
self.value.insert_str(self.cursor, value);
self.cursor += value.len();
}
pub(crate) fn draw(
&self,
frame: &mut Frame,
@@ -1459,7 +1646,14 @@ impl Input {
let text = if secret {
Text::raw("•".repeat(self.value.chars().count()))
} else {
highlighted.unwrap_or_else(|| Text::raw(&self.value))
highlighted.unwrap_or_else(|| {
Text::from(
self.value
.split('\n')
.map(|line| Line::raw(crate::db::display_text(line)))
.collect::<Vec<_>>(),
)
})
};
let line_count = text.lines.len().max(1);
let block = Block::bordered()
@@ -1488,7 +1682,10 @@ impl Input {
let before_cursor = if secret {
self.value[..self.cursor].chars().count()
} else {
Line::raw(prefix.rsplit('\n').next().unwrap_or("")).width()
Line::raw(crate::db::display_text(
prefix.rsplit('\n').next().unwrap_or(""),
))
.width()
};
let cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX);
let offset = cursor.saturating_sub(content.width.saturating_sub(1));
@@ -1519,6 +1716,92 @@ impl Input {
mod tests {
use super::*;
#[test]
fn connection_and_quit_guards_exclude_expanding_and_literal_editor_keys() {
let key = |code| KeyEvent::new(code, KeyModifiers::NONE);
for code in [
KeyCode::Enter,
KeyCode::Char('R'),
KeyCode::Char('d'),
KeyCode::Delete,
] {
assert!(leaves_drafts(
key(code),
Focus::Explorer,
Some(ExplorerEntry::Database(Some(1))),
Some(0),
false
));
}
assert!(!leaves_drafts(
key(KeyCode::Enter),
Focus::Explorer,
Some(ExplorerEntry::Database(Some(0))),
Some(0),
false
));
assert!(!leaves_drafts(
key(KeyCode::Enter),
Focus::Explorer,
Some(ExplorerEntry::Relation(1)),
Some(0),
false
));
assert!(leaves_drafts(
key(KeyCode::Char('R')),
Focus::Explorer,
Some(ExplorerEntry::Relation(1)),
Some(0),
false
));
assert!(leaves_drafts(
key(KeyCode::Char('q')),
Focus::Rows,
None,
None,
false
));
assert!(!leaves_drafts(
key(KeyCode::Char('q')),
Focus::Rows,
None,
None,
true
));
assert!(!leaves_drafts(
key(KeyCode::Char('q')),
Focus::Query,
None,
None,
false
));
}
#[test]
fn raw_cell_input_preserves_controls_and_renders_escaped_cursor() -> Result<()> {
let original = "a\t\r\u{1b}\0界";
let mut input = Input::from(original);
input.paste_raw("\nsecond\tline");
assert_eq!(input.value, format!("{original}\nsecond\tline"));
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(60, 6))?;
terminal.draw(|frame| input.draw(frame, frame.area(), " Cell ", false, true, None))?;
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains(&crate::db::display_text(original)));
assert!(rendered.contains(&crate::db::display_text("second\tline")));
assert_eq!(
terminal.get_cursor_position()?.x,
1 + crate::db::display_text("second\tline").len() as u16
);
assert_eq!(input.value, format!("{original}\nsecond\tline"));
Ok(())
}
#[test]
fn legacy_profiles_default_to_read_only_and_access_edits_persist() -> Result<()> {
let directory = tempfile::tempdir()?;
@@ -1558,12 +1841,19 @@ mod tests {
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"));
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);
assert_eq!(
Profiles::open(&path)?.entries[0].access,
AccessMode::ReadOnly
);
Ok(())
}
@@ -1589,11 +1879,66 @@ mod tests {
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);
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"));
let footer: String = (0..40)
.map(|x| terminal.backend().buffer()[(x, 5)].symbol())
.collect();
assert!(footer.contains("readwrite"));
assert!(!footer.contains("readonly"));
Ok(())
}
#[test]
fn access_badge_sits_before_position_in_lowercase() -> Result<()> {
for access in [
Some(AccessMode::ReadOnly),
Some(AccessMode::ReadWrite),
None,
] {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 6))?;
terminal.draw(|frame| {
let [top, _, bottom] = shell_areas(frame.area());
draw_bars(
frame,
top,
bottom,
"SQLite",
"",
access,
false,
Some((1, 1)),
);
})?;
let buffer = terminal.backend().buffer();
let footer: String = (0..80).map(|x| buffer[(x, 5)].symbol()).collect();
let expected = match access {
Some(AccessMode::ReadOnly) => "SQLite readonly ░▓ 1:1 ",
Some(AccessMode::ReadWrite) => "SQLite readwrite ░▓ 1:1 ",
None => "SQLite ░▓ 1:1 ",
};
assert!(footer.ends_with(expected), "{footer}");
if access.is_some() {
let x = Line::raw(&footer[..footer.find("read").unwrap()]).width() as u16;
assert_eq!(buffer[(x, 5)].bg, Color::Cyan);
assert_eq!(buffer[(x, 5)].fg, Color::Black);
assert_eq!(buffer[(x - 1, 5)].bg, Color::Cyan);
assert_eq!(buffer[(x - 2, 5)].bg, Color::Reset);
assert_eq!(buffer[(x - 3, 5)].symbol(), "e");
let end = x + access.map_or("", AccessMode::label).len() as u16 - 1;
assert_eq!(buffer[(end, 5)].bg, Color::Cyan);
assert_eq!(buffer[(end + 1, 5)].bg, Color::Reset);
}
}
Ok(())
}
@@ -1636,8 +1981,14 @@ mod tests {
drop(profiles);
std::fs::remove_file(&first)?;
let profiles = Profiles::open(&config)?;
assert_eq!(profiles.entries[0].ca_bundle.as_deref(), Some(first.as_path()));
assert_eq!(profiles.entries[1].ca_bundle.as_deref(), Some(second.as_path()));
assert_eq!(
profiles.entries[0].ca_bundle.as_deref(),
Some(first.as_path())
);
assert_eq!(
profiles.entries[1].ca_bundle.as_deref(),
Some(second.as_path())
);
assert!(profiles.entries[2].ca_bundle.is_none());
assert!(profiles.entries[0].connect().is_err());
let mut missing = profiles.entries[0].clone();
@@ -1753,7 +2104,7 @@ mod tests {
assert_eq!(terminal.backend().buffer(), &last);
terminal = Terminal::new(TestBackend::new(100, 60))?;
terminal.draw(|frame| draw_help(frame, frame.area(), &mut offset))?;
assert_eq!(offset, 0);
assert!(offset < end);
Ok(())
}
@@ -1761,7 +2112,13 @@ 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(), AccessMode::ReadWrite, 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()),
@@ -1769,20 +2126,29 @@ mod tests {
);
})?;
assert_eq!(terminal.backend().buffer()[(25, 1)].symbol(), "*");
let row: String = (1..31).map(|x| terminal.backend().buffer()[(x, 1)].symbol()).collect();
let row: String = (1..31)
.map(|x| terminal.backend().buffer()[(x, 1)].symbol())
.collect();
assert!(row.ends_with("* [RW]"));
let inactive = database_label("inactive", AccessMode::ReadOnly, false, false, 30);
let active_badge_column = (1..31)
.find(|&x| terminal.backend().buffer()[(x, 1)].symbol() == "[")
.unwrap() - 1;
.unwrap()
- 1;
assert_eq!(
active_badge_column as usize,
Line::raw(&inactive[..inactive.find("[RO]").unwrap()]).width()
);
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, width),
"[RO]"
);
}
assert_eq!(database_label("long", AccessMode::ReadOnly, true, true, 1), "*");
assert_eq!(
database_label("long", AccessMode::ReadOnly, true, true, 1),
"*"
);
Ok(())
}
@@ -1849,32 +2215,6 @@ mod tests {
Ok(())
}
#[test]
fn provider_sits_before_position_and_displaces_the_database_label() -> Result<()> {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 8))?;
terminal.draw(|frame| {
let [top, _, bottom] = shell_areas(frame.area());
draw_bars(
frame,
top,
bottom,
"PostgreSQL",
"a very long database name",
Some(AccessMode::ReadOnly),
false,
None,
);
})?;
let buffer = terminal.backend().buffer();
let top: String = (0..32).map(|x| buffer[(x, 0)].symbol()).collect();
let footer: String = (0..32).map(|x| buffer[(x, 7)].symbol()).collect();
assert!(!top.contains("PostgreSQL"));
assert!(footer.starts_with(" NORMAL ▓░"));
assert!(footer.ends_with(" PostgreSQL ░▓ N/A "));
assert_eq!(buffer[(24, 7)].bg, Color::Reset);
Ok(())
}
#[test]
fn shell_keeps_position_when_name_is_long_and_yields_on_short_screens() -> Result<()> {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(24, 8))?;
+597 -48
View File
@@ -1,4 +1,4 @@
use std::{path::Path, time::Duration};
use std::{fmt::Write, path::Path, time::Duration};
use native_tls::TlsConnector;
use openssl::{
@@ -66,6 +66,8 @@ pub struct Column {
pub nullable: bool,
pub primary_key: bool,
pub default: String,
pub generated: bool,
write_type: String,
}
pub struct Page {
@@ -79,6 +81,99 @@ pub struct QueryResult {
pub affected_rows: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum RawValue {
Null,
Text(Vec<u8>),
Integer(i64),
Real(u64),
Blob(Vec<u8>),
}
impl RawValue {
pub fn input_text(&self) -> Result<String> {
Ok(match self {
Self::Null => String::new(),
Self::Text(bytes) => std::str::from_utf8(bytes)?.into(),
Self::Integer(value) => value.to_string(),
Self::Real(bits) => f64::from_bits(*bits).to_string(),
Self::Blob(bytes) => blob_text(bytes),
})
}
pub fn edited(&self, input: Option<&str>) -> Result<Self> {
let Some(input) = input else {
return Ok(Self::Null);
};
Ok(match self {
Self::Integer(_) => Self::Integer(input.parse()?),
Self::Real(_) => {
let value: f64 = input.parse()?;
if value.is_nan() {
return Err("SQLite cannot store NaN without changing it to NULL".into());
}
Self::Real(value.to_bits())
}
Self::Blob(_) => {
let hex = input
.strip_prefix("X'")
.and_then(|text| text.strip_suffix('\''))
.ok_or("BLOB input must use X'HEX' notation")?;
if hex.len() % 2 != 0 || !hex.is_ascii() {
return Err("BLOB input requires pairs of hexadecimal digits".into());
}
Self::Blob(
(0..hex.len())
.step_by(2)
.map(|index| u8::from_str_radix(&hex[index..index + 2], 16))
.collect::<std::result::Result<_, _>>()?,
)
}
Self::Null | Self::Text(_) => Self::Text(input.as_bytes().to_vec()),
})
}
pub fn display(&self) -> Option<String> {
match self {
Self::Null => None,
Self::Text(bytes) => Some(match std::str::from_utf8(bytes) {
Ok(text) => display_text(text),
Err(_) => bytes.escape_ascii().to_string(),
}),
_ => Some(
self.input_text()
.expect("numeric and BLOB values are valid text"),
),
}
}
}
fn blob_text(bytes: &[u8]) -> String {
let mut text = String::with_capacity(bytes.len() * 2 + 3);
text.push_str("X'");
for byte in bytes {
write!(text, "{byte:02X}").expect("writing to String cannot fail");
}
text.push('\'');
text
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RowSnapshot {
pub values: Vec<RawValue>,
}
pub struct RowEdit {
pub original: RowSnapshot,
pub changes: Vec<(usize, RawValue)>,
}
pub struct EditPage {
pub page: Page,
pub rows: Vec<RowSnapshot>,
pub editable: bool,
}
impl Database {
pub fn connect(connection: &str, root_cert: Option<&Path>, access: AccessMode) -> Result<Self> {
Self::connect_postgres(connection.parse()?, root_cert, access)
@@ -93,7 +188,8 @@ impl Database {
config.connect_timeout(Duration::from_secs(5));
}
let mut client = if let Some(path) = root_cert {
let tls = postgres_openssl::MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), path)?);
let tls =
postgres_openssl::MakeTlsConnector::new(postgres_tls(config.get_ssl_mode(), path)?);
config.connect(tls)?
} else {
config.connect(MakeTlsConnector::new(TlsConnector::new()?))?
@@ -197,7 +293,9 @@ impl Database {
EXISTS (SELECT FROM pg_catalog.pg_index i
WHERE i.indrelid = a.attrelid AND i.indisprimary
AND a.attnum = ANY(i.indkey)),
COALESCE(pg_catalog.pg_get_expr(d.adbin, d.adrelid), '')
COALESCE(pg_catalog.pg_get_expr(d.adbin, d.adrelid), ''),
a.attgenerated <> '' OR a.attidentity <> '',
pg_catalog.format_type(a.atttypid, NULL)
FROM pg_catalog.pg_attribute a
LEFT JOIN pg_catalog.pg_attrdef d
ON d.adrelid = a.attrelid AND d.adnum = a.attnum
@@ -213,9 +311,11 @@ impl Database {
label: display_text(&name),
name,
data_type: display_text(row.get(1)),
write_type: row.get(6),
nullable: row.get(2),
primary_key: row.get(3),
default: display_text(row.get(4)),
generated: row.get(5),
}
})
.collect())
@@ -318,16 +418,22 @@ impl Database {
})
}
pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> {
pub fn edit_page(
&mut self,
relation: &Relation,
columns: &[Column],
page: usize,
) -> Result<EditPage> {
let editable = editable(relation, columns, self.access_mode);
let client = match &mut self.backend {
Backend::Postgres(client) => client,
Backend::Sqlite(connection) => {
return sqlite::page(connection, relation, columns, page);
return sqlite::edit_page(connection, relation, columns, page, editable);
}
};
let projection = columns
.iter()
.map(|column| format!("t.{}::text", quote_identifier(&column.name)))
.map(|column| postgres_text(&format!("t.{}", quote_identifier(&column.name))))
.collect::<Vec<_>>()
.join(", ");
let primary_key = columns
@@ -353,27 +459,168 @@ impl Database {
let offset = page_offset(page)?;
let result = client.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?;
let has_next = result.len() > PAGE_SIZE;
let rows = result
let rows: Vec<RowSnapshot> = result
.into_iter()
.take(PAGE_SIZE)
.map(|row| {
(0..columns.len())
.map(|index| row.get::<_, Option<&str>>(index).map(display_text))
.collect()
.map(|row| RowSnapshot {
values: (0..columns.len())
.map(|index| {
row.get::<_, Option<&str>>(index)
.map_or(RawValue::Null, |text| {
RawValue::Text(text.as_bytes().to_vec())
})
})
.collect(),
})
.collect();
Ok(Page { rows, has_next })
Ok(EditPage {
page: Page {
rows: rows
.iter()
.map(|row| row.values.iter().map(RawValue::display).collect())
.collect(),
has_next,
},
rows,
editable,
})
}
pub fn apply_edits(&mut self, relation: &Relation, edits: &[RowEdit]) -> Result<()> {
if self.access_mode != AccessMode::ReadWrite {
return Err("This connection is read-only".into());
}
let current = self
.relations()?
.into_iter()
.find(|current| {
current.id == relation.id
&& current.schema == relation.schema
&& current.name == relation.name
})
.ok_or("The original table no longer exists")?;
let columns = self.columns(&current)?;
if !editable(&current, &columns, self.access_mode) {
return Err("Editing requires a base table with a non-null primary key".into());
}
for (row_index, edit) in edits.iter().enumerate() {
if edit.original.values.len() != columns.len()
|| columns
.iter()
.zip(&edit.original.values)
.any(|(column, value)| column.primary_key && *value == RawValue::Null)
{
return Err("The original row has no usable primary key".into());
}
// ponytail: drafts are small; use a key set if very large batches make this quadratic scan matter.
if edits[..row_index].iter().any(|previous| {
columns
.iter()
.enumerate()
.filter(|(_, column)| column.primary_key)
.all(|(index, _)| {
previous.original.values[index] == edit.original.values[index]
})
}) {
return Err("A batch must contain only one edit per original row".into());
}
for (position, (index, _)) in edit.changes.iter().enumerate() {
if columns.get(*index).is_none_or(|column| column.generated)
|| edit.changes[..position]
.iter()
.any(|(previous, _)| previous == index)
{
return Err("The edit targets a generated, missing, or duplicate column".into());
}
}
}
let client = match &mut self.backend {
Backend::Postgres(client) => client,
Backend::Sqlite(connection) => {
return sqlite::apply_edits(connection, &current, &columns, edits);
}
};
let mut transaction = client.build_transaction().read_only(false).start()?;
transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?;
for edit in edits.iter().filter(|edit| !edit.changes.is_empty()) {
let values = edit
.changes
.iter()
.map(|(_, value)| value)
.chain(&edit.original.values)
.map(|value| match value {
RawValue::Null => Ok(None),
RawValue::Text(bytes) => Ok(Some(std::str::from_utf8(bytes)?)),
_ => Err("PostgreSQL edits require original text values".into()),
})
.collect::<Result<Vec<Option<&str>>>>()?;
let params: Vec<&(dyn postgres::types::ToSql + Sync)> = values
.iter()
.map(|value| value as &(dyn postgres::types::ToSql + Sync))
.collect();
let assignments = edit
.changes
.iter()
.enumerate()
.map(|(position, (index, _))| {
format!(
"{} = (${}::text)::{}",
quote_identifier(&columns[*index].name),
position + 1,
columns[*index].write_type
)
})
.collect::<Vec<_>>()
.join(", ");
let guard = columns
.iter()
.enumerate()
.map(|(index, column)| {
format!(
"{} COLLATE \"C\" IS NOT DISTINCT FROM ${}::text COLLATE \"C\"",
postgres_text(&quote_identifier(&column.name)),
edit.changes.len() + index + 1
)
})
.collect::<Vec<_>>()
.join(" AND ");
let affected = transaction.execute(
&format!(
"UPDATE {}.{} SET {assignments} WHERE {guard}",
quote_identifier(&current.schema),
quote_identifier(&current.name)
),
&params,
)?;
if affected != 1 {
return Err(
"Edit conflict: a row changed or disappeared; no edits were saved".into(),
);
}
}
transaction.commit()?;
Ok(())
}
}
fn editable(relation: &Relation, columns: &[Column], access: AccessMode) -> bool {
access == AccessMode::ReadWrite
&& matches!(relation.kind.as_str(), "table" | "partitioned table")
&& columns.iter().any(|column| column.primary_key)
&& columns
.iter()
.filter(|column| column.primary_key)
.all(|column| !column.nullable)
}
fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result<SslConnector> {
if mode != SslMode::Require {
return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into());
}
let pem = std::fs::read(root_cert)
.map_err(|error| format!("Cannot read CA bundle: {error}"))?;
let certificates = X509::stack_from_pem(&pem)
.map_err(|error| format!("Invalid PEM CA bundle: {error}"))?;
let pem =
std::fs::read(root_cert).map_err(|error| format!("Cannot read CA bundle: {error}"))?;
let certificates =
X509::stack_from_pem(&pem).map_err(|error| format!("Invalid PEM CA bundle: {error}"))?;
if certificates.is_empty() {
return Err("CA bundle must contain at least one PEM CA certificate.".into());
}
@@ -418,7 +665,10 @@ fn checked_statement(sql: &str, sqlite: bool) -> Result<Statement> {
| Statement::AttachDuckDBDatabase { .. }
| Statement::DetachDuckDBDatabase { .. }
) {
return Err("Transaction, session, and attached-database controls are not allowed in the editor.".into());
return Err(
"Transaction, session, and attached-database controls are not allowed in the editor."
.into(),
);
}
Ok(statement)
}
@@ -426,7 +676,9 @@ fn checked_statement(sql: &str, sqlite: bool) -> Result<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::Insert(body)
| SetExpr::Update(body)
| SetExpr::Delete(body)
| SetExpr::Merge(body) => return body,
_ => {}
}
@@ -440,6 +692,13 @@ fn page_offset(page: usize) -> Result<i64> {
.ok_or_else(|| "page offset overflow".into())
}
fn postgres_text(column: &str) -> String {
// Native output preserves CHAR padding; ::text would strip it. Composite NULL tests must test the whole value.
format!(
"CASE WHEN {column} IS NOT DISTINCT FROM NULL THEN NULL ELSE pg_catalog.format('%s', {column}) END"
)
}
fn quote_identifier(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\""))
}
@@ -461,6 +720,229 @@ pub fn display_text(value: &str) -> String {
mod tests {
use super::*;
#[test]
#[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"]
fn staged_postgres_edits_preserve_text_and_rollback_entire_batches() -> 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_drafts_{}", 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 mut cleanup = Cleanup(fixture, schema.clone());
cleanup.0.batch_execute(&format!(
"CREATE SCHEMA {schema};
CREATE TYPE {schema}.\"odd
mood\" AS ENUM ('ok', 'great');
CREATE TYPE {schema}.pair AS (first integer, second text);
CREATE TABLE {schema}.\"odd\"\"table\" (
tenant text NOT NULL, id integer NOT NULL, missing text, empty text, multiline text,
amount numeric, bytes bytea, mood {schema}.\"odd
mood\", generated integer GENERATED ALWAYS AS (id + 1) STORED,
padded character(5), bounded varchar(3), pair {schema}.pair,
PRIMARY KEY (tenant, id));
INSERT INTO {schema}.\"odd\"\"table\"
(tenant, id, missing, empty, multiline, amount, bytes, mood, padded, bounded, pair)
SELECT 'a', n, NULL, '', E'one\\ntwo', 12.3400, decode('00ff', 'hex'), 'ok',
'x', 'abc', ROW(NULL, NULL)::{schema}.pair
FROM generate_series(1, 101) n;
CREATE TABLE {schema}.keyless (value text);
INSERT INTO {schema}.keyless VALUES ('x');
CREATE VIEW {schema}.sample_view AS SELECT * FROM {schema}.\"odd\"\"table\";
CREATE TABLE {schema}.identity_key (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, value text);
INSERT INTO {schema}.identity_key (value) VALUES ('x');
CREATE TABLE {schema}.audit (id integer);
CREATE FUNCTION {schema}.audit_update() RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN INSERT INTO {schema}.audit VALUES (NEW.id); RETURN NEW; END $$;
CREATE TRIGGER edited AFTER UPDATE ON {schema}.\"odd\"\"table\"
FOR EACH ROW EXECUTE FUNCTION {schema}.audit_update();"
))?;
let mut db = Database::connect(&connection, None, AccessMode::ReadWrite)?;
let relations = db.relations()?;
let table = relations
.iter()
.find(|table| table.schema == schema && table.name == "odd\"table")
.unwrap();
let columns = db.columns(table)?;
assert!(columns[8].generated);
let first = db.edit_page(table, &columns, 0)?;
let last = db.edit_page(table, &columns, 1)?;
assert!(first.editable && first.page.has_next && !last.page.has_next);
let original = first.rows[0].clone();
assert_eq!(original.values[2], RawValue::Null);
assert_eq!(original.values[3], RawValue::Text(Vec::new()));
assert_eq!(original.values[4].input_text()?, "one\ntwo");
assert_eq!(first.page.rows[0][4].as_deref(), Some("one\\ntwo"));
assert_eq!(original.values[5].input_text()?, "12.3400");
assert_eq!(original.values[6].input_text()?, "\\x00ff");
assert_eq!(original.values[9].input_text()?, "x ");
assert_eq!(original.values[11].input_text()?, "(,)");
let mut edits = vec![
RowEdit {
original: original.clone(),
changes: vec![
(0, original.values[0].edited(Some("bound'\"tenant"))?),
(1, original.values[1].edited(Some("500"))?),
(2, original.values[2].edited(Some(""))?),
(3, original.values[3].edited(None)?),
(5, original.values[5].edited(Some("9.8700"))?),
(6, original.values[6].edited(Some("\\xfe00"))?),
(7, original.values[7].edited(Some("great"))?),
],
},
RowEdit {
original: last.rows[0].clone(),
changes: vec![(4, last.rows[0].values[4].edited(Some("new\nlines"))?)],
},
];
cleanup.0.execute(
&format!("UPDATE {schema}.\"odd\"\"table\" SET missing = '' WHERE id = 101"),
&[],
)?;
cleanup
.0
.execute(&format!("DELETE FROM {schema}.audit"), &[])?;
assert!(db.apply_edits(table, &edits).is_err());
assert_eq!(db.edit_page(table, &columns, 0)?.rows[0], original);
assert_eq!(
db.query(&format!("SELECT COUNT(*) FROM {schema}.audit"))?
.page
.rows,
[vec![Some("0".into())]]
);
edits[1].original = db.edit_page(table, &columns, 1)?.rows[0].clone();
db.apply_edits(table, &edits)?;
assert_eq!(
db.query(&format!("SELECT COUNT(*) FROM {schema}.audit"))?
.page
.rows,
[vec![Some("2".into())]]
);
assert_eq!(
db.query(&format!(
"SELECT tenant, id, missing, empty, multiline, amount, bytes, mood, generated
FROM {schema}.\"odd\"\"table\" WHERE id = 500"
))?
.page
.rows,
[vec![
Some("bound'\"tenant".into()),
Some("500".into()),
Some("".into()),
None,
Some("one\\ntwo".into()),
Some("9.8700".into()),
Some("\\xfe00".into()),
Some("great".into()),
Some("501".into()),
]]
);
assert_eq!(
db.query(&format!(
"SELECT multiline FROM {schema}.\"odd\"\"table\" WHERE id = 101"
))?
.page
.rows,
[vec![Some("new\\nlines".into())]]
);
let fresh = db.edit_page(table, &columns, 0)?;
let before = fresh.rows[0].clone();
assert!(
db.apply_edits(
table,
&[
RowEdit {
original: before.clone(),
changes: vec![(5, RawValue::Text(b"77".to_vec()))]
},
RowEdit {
original: fresh.rows[1].clone(),
changes: vec![(1, RawValue::Text(b"2".to_vec()))]
},
]
)
.is_err()
);
assert_eq!(db.edit_page(table, &columns, 0)?.rows[0], before);
assert!(
db.apply_edits(
table,
&[RowEdit {
original: before.clone(),
changes: vec![(10, RawValue::Text(b"too long".to_vec()))],
}]
)
.is_err()
);
assert_eq!(db.edit_page(table, &columns, 0)?.rows[0], before);
assert!(
db.apply_edits(
table,
&[RowEdit {
original: before.clone(),
changes: vec![(8, RawValue::Text(b"0".to_vec()))],
}]
)
.is_err()
);
for name in ["keyless", "sample_view"] {
let readonly = relations
.iter()
.find(|table| table.schema == schema && table.name == name)
.unwrap();
let columns = db.columns(readonly)?;
let page = db.edit_page(readonly, &columns, 0)?;
assert!(!page.editable);
assert!(
db.apply_edits(
readonly,
&[RowEdit {
original: page.rows[0].clone(),
changes: vec![(0, RawValue::Text(b"changed".to_vec()))],
}]
)
.is_err()
);
}
let identity = relations
.iter()
.find(|table| table.schema == schema && table.name == "identity_key")
.unwrap();
let identity_columns = db.columns(identity)?;
assert!(identity_columns[0].generated);
let identity_page = db.edit_page(identity, &identity_columns, 0)?;
assert!(identity_page.editable);
db.apply_edits(
identity,
&[RowEdit {
original: identity_page.rows[0].clone(),
changes: vec![(1, RawValue::Text(b"edited".to_vec()))],
}],
)?;
let mut readonly = Database::connect(&connection, None, AccessMode::ReadOnly)?;
assert!(!readonly.edit_page(table, &columns, 0)?.editable);
assert!(
readonly
.apply_edits(
table,
&[RowEdit {
original: before,
changes: vec![(5, RawValue::Text(b"0".to_vec()))],
}]
)
.is_err()
);
Ok(())
}
#[test]
fn private_ca_tls_accepts_absent_eku_but_rejects_wrong_usage_host_and_expiry() -> Result<()> {
use openssl::{
@@ -470,7 +952,10 @@ mod tests {
pkey::PKey,
rsa::Rsa,
ssl::SslAcceptor,
x509::{X509NameBuilder, extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName}},
x509::{
X509NameBuilder,
extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName},
},
};
use std::net::{TcpListener, TcpStream};
@@ -516,7 +1001,8 @@ mod tests {
leaf.set_not_before(&before)?;
leaf.set_not_after(&after)?;
leaf.append_extension(BasicConstraints::new().critical().build()?)?;
let san = SubjectAlternativeName::new().dns("localhost")
let san = SubjectAlternativeName::new()
.dns("localhost")
.build(&leaf.x509v3_context(Some(&ca), None))?;
leaf.append_extension(san)?;
if client_only {
@@ -533,8 +1019,12 @@ mod tests {
socket.set_write_timeout(Some(Duration::from_secs(3)))?;
let server = std::thread::spawn(move || {
let (socket, _) = listener.accept().unwrap();
socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
socket.set_write_timeout(Some(Duration::from_secs(3))).unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
socket
.set_write_timeout(Some(Duration::from_secs(3)))
.unwrap();
let _ = acceptor.accept(socket);
});
let connector = postgres_tls(SslMode::Require, bundle.path())?;
@@ -542,8 +1032,11 @@ mod tests {
configuration.param_mut().set_time(clock);
let result = configuration.connect(hostname, socket);
server.join().unwrap();
assert_eq!(result.is_ok(), accepted,
"client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}");
assert_eq!(
result.is_ok(),
accepted,
"client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}"
);
}
Ok(())
}
@@ -554,7 +1047,11 @@ mod tests {
for mode in [SslMode::Prefer, SslMode::Disable] {
assert!(postgres_tls(mode, bundle.path()).is_err());
}
for contents in ["", "not a PEM certificate", "-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----"] {
for contents in [
"",
"not a PEM certificate",
"-----BEGIN CERTIFICATE-----\ninvalid\n-----END CERTIFICATE-----",
] {
std::fs::write(bundle.path(), contents)?;
assert!(postgres_tls(SslMode::Require, bundle.path()).is_err());
}
@@ -584,7 +1081,8 @@ mod tests {
#[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<()> {
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>()?
@@ -593,16 +1091,24 @@ mod tests {
struct Cleanup(Client, String);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = self.0.batch_execute(&format!("DROP SCHEMA {} CASCADE", self.1));
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));
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,
db.query(&format!(
"CREATE TABLE {schema}.saved (id INTEGER PRIMARY KEY, value INTEGER UNIQUE)"
))?
.affected_rows,
Some(0)
);
let inserted = db.query(&format!(
@@ -618,14 +1124,21 @@ mod tests {
assert!(inserted.page.has_next);
assert_eq!(
inserted.headers,
["id", "amount", "details", "tags", "bytes", "happened", "mood", "missing", "note"]
[
"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("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())
]
);
@@ -633,32 +1146,68 @@ mod tests {
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(),
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!(
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,
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"))?;
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));
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,
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());
assert!(
reopened
.query(&format!("DELETE FROM {schema}.saved"))
.is_err()
);
Ok(())
}
@@ -712,7 +1261,7 @@ mod tests {
assert!(columns[1].nullable);
assert_eq!(columns[2].data_type, "numeric");
assert_eq!(columns[2].default, "1.25");
let first = db.page(table, &columns, 0)?;
let first = db.edit_page(table, &columns, 0)?.page;
assert!(first.has_next);
assert_eq!(first.rows.len(), 100);
assert_eq!(
@@ -729,7 +1278,7 @@ mod tests {
);
assert_eq!(first.rows[1][1].as_deref(), Some("line\\nnext"));
assert_eq!(first.rows[2][1].as_deref(), Some("NULL"));
let second = db.page(table, &columns, 1)?;
let second = db.edit_page(table, &columns, 1)?.page;
assert!(!second.has_next);
assert_eq!(second.rows[0][0].as_deref(), Some("101"));
let empty = relations
@@ -737,7 +1286,7 @@ mod tests {
.find(|r| r.schema == schema && r.name == "empty_table")
.unwrap();
let columns = db.columns(empty)?;
let page = db.page(empty, &columns, 0)?;
let page = db.edit_page(empty, &columns, 0)?.page;
assert!(page.rows.is_empty());
assert!(!page.has_next);
let query = db.query(&format!(
+373 -41
View File
@@ -1,11 +1,15 @@
use std::{fmt::Write, path::Path, time::Duration};
use std::{path::Path, time::Duration};
use rusqlite::{Connection, OpenFlags, params, types::ValueRef};
use rusqlite::{
Connection, OpenFlags, params, params_from_iter,
types::{ToSqlOutput, Value, ValueRef},
};
use sqlparser::ast::Statement;
use super::{
AccessMode, Column, PAGE_SIZE, Page, QueryResult, Relation, checked_statement, display_text,
page_offset, quote_identifier, statement_body,
AccessMode, Column, EditPage, PAGE_SIZE, Page, QueryResult, RawValue, Relation, RowEdit,
RowSnapshot, blob_text, checked_statement, display_text, page_offset, quote_identifier,
statement_body,
};
use crate::Result;
@@ -29,7 +33,7 @@ pub(super) fn open(path: &Path, access: AccessMode) -> Result<Connection> {
pub(super) fn relations(connection: &Connection) -> Result<Vec<Relation>> {
let mut statement = connection.prepare(
"SELECT s.rowid, s.name, s.type, t.wr
"SELECT s.rowid, s.name, s.type, t.wr, t.type
FROM main.sqlite_schema s
JOIN pragma_table_list t ON t.schema = 'main' AND t.name = s.name
WHERE s.type IN ('table', 'view') AND s.name NOT GLOB 'sqlite_*'
@@ -46,7 +50,11 @@ pub(super) fn relations(connection: &Connection) -> Result<Vec<Relation>> {
label: display_text(&format!("main.{name}")),
name,
physical_order: kind == "table" && !row.get::<_, bool>(3)?,
kind,
kind: if row.get::<_, String>(4)? == "virtual" {
"virtual table".into()
} else {
kind
},
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -58,7 +66,7 @@ pub(super) fn columns(connection: &Connection, relation: &Relation) -> Result<Ve
"SELECT name, type, \"notnull\", dflt_value, pk,
pk > 0 AND upper(type) = 'INTEGER' AND NOT EXISTS (
SELECT 1 FROM pragma_index_list(?1, 'main') WHERE origin = 'pk'
) AS rowid_alias
) AS rowid_alias, hidden
FROM pragma_table_xinfo(?1, 'main')
WHERE hidden <> 1 ORDER BY cid",
)?;
@@ -76,18 +84,21 @@ pub(super) fn columns(connection: &Connection, relation: &Relation) -> Result<Ve
.map(display_text)
.unwrap_or_default(),
primary_key: row.get::<_, i64>(4)? > 0,
generated: row.get::<_, i64>(6)? != 0,
write_type: String::new(),
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(columns)
}
pub(super) fn page(
pub(super) fn edit_page(
connection: &Connection,
relation: &Relation,
columns: &[Column],
page: usize,
) -> Result<Page> {
editable: bool,
) -> Result<EditPage> {
let projection = columns
.iter()
.map(|column| format!("t.{}", quote_identifier(&column.name)))
@@ -119,7 +130,27 @@ 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(), false)
let mut rows = Vec::with_capacity(PAGE_SIZE);
while let Some(row) = query.next()? {
rows.push(RowSnapshot {
values: (0..columns.len())
.map(|index| row.get_ref(index).map(raw_value))
.collect::<rusqlite::Result<_>>()?,
});
}
let has_next = rows.len() > PAGE_SIZE;
rows.truncate(PAGE_SIZE);
Ok(EditPage {
page: Page {
rows: rows
.iter()
.map(|row| row.values.iter().map(RawValue::display).collect())
.collect(),
has_next,
},
rows,
editable,
})
}
pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> Result<QueryResult> {
@@ -128,7 +159,11 @@ pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> R
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 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)?;
@@ -137,13 +172,20 @@ pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> R
let body = statement_body(&parsed);
let affected_rows = if matches!(body, Statement::Query(_)) {
None
} else if matches!(body, Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_)) {
} 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 });
return Ok(QueryResult {
headers,
page,
affected_rows,
});
}
let original = connection.prepare(sql)?;
let headers = original
@@ -157,7 +199,11 @@ pub(super) fn query(connection: &Connection, sql: &str, access: AccessMode) -> R
))?;
let column_count = statement.column_count();
let page = read_page(&mut statement.query([])?, column_count, false)?;
Ok(QueryResult { headers, page, affected_rows: None })
Ok(QueryResult {
headers,
page,
affected_rows: None,
})
}
fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize, exhaust: bool) -> Result<Page> {
@@ -177,30 +223,87 @@ fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize, exhaust: bool)
ValueRef::Null => None,
ValueRef::Integer(value) => Some(value.to_string()),
ValueRef::Real(value) => Some(value.to_string()),
ValueRef::Text(value) => Some(match std::str::from_utf8(value) {
ValueRef::Text(bytes) => Some(match std::str::from_utf8(bytes) {
Ok(text) => display_text(text),
Err(_) => value.escape_ascii().to_string(),
Err(_) => bytes.escape_ascii().to_string(),
}),
ValueRef::Blob(value) => Some(blob_text(value)),
ValueRef::Blob(bytes) => Some(blob_text(bytes)),
})
})
.collect::<rusqlite::Result<Vec<_>>>()?;
rows.push(values);
}
Ok(Page {
rows,
has_next,
})
Ok(Page { rows, has_next })
}
fn blob_text(bytes: &[u8]) -> String {
let mut output = String::with_capacity(bytes.len() * 2 + 3);
output.push_str("X'");
for byte in bytes {
write!(output, "{byte:02X}").expect("writing to a String cannot fail");
fn raw_value(value: ValueRef<'_>) -> RawValue {
match value {
ValueRef::Null => RawValue::Null,
ValueRef::Integer(value) => RawValue::Integer(value),
ValueRef::Real(value) => RawValue::Real(value.to_bits()),
ValueRef::Text(value) => RawValue::Text(value.to_vec()),
ValueRef::Blob(value) => RawValue::Blob(value.to_vec()),
}
output.push('\'');
output
}
impl rusqlite::ToSql for RawValue {
fn to_sql(&self) -> rusqlite::Result<ToSqlOutput<'_>> {
Ok(match self {
Self::Null => ToSqlOutput::Borrowed(ValueRef::Null),
Self::Text(bytes) => ToSqlOutput::Borrowed(ValueRef::Text(bytes)),
Self::Blob(bytes) => ToSqlOutput::Borrowed(ValueRef::Blob(bytes)),
Self::Integer(value) => ToSqlOutput::Borrowed(ValueRef::Integer(*value)),
Self::Real(bits) => ToSqlOutput::Owned(Value::Real(f64::from_bits(*bits))),
})
}
}
pub(super) fn apply_edits(
connection: &mut Connection,
relation: &Relation,
columns: &[Column],
edits: &[RowEdit],
) -> Result<()> {
let transaction =
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
for edit in edits.iter().filter(|edit| !edit.changes.is_empty()) {
let assignments = edit
.changes
.iter()
.enumerate()
.map(|(position, (index, _))| {
format!(
"{} = ?{}",
quote_identifier(&columns[*index].name),
position + 1
)
})
.collect::<Vec<_>>()
.join(", ");
let guard = columns.iter().enumerate().map(|(index, column)| {
let name = quote_identifier(&column.name);
let parameter = edit.changes.len() + index + 1;
format!("typeof({name}) = typeof(?{parameter}) AND {name} IS ?{parameter} COLLATE BINARY")
}).collect::<Vec<_>>().join(" AND ");
let parameters = edit
.changes
.iter()
.map(|(_, value)| value)
.chain(&edit.original.values);
let affected = transaction.execute(
&format!(
"UPDATE {}.{} SET {assignments} WHERE {guard}",
quote_identifier(&relation.schema),
quote_identifier(&relation.name)
),
params_from_iter(parameters),
)?;
if affected != 1 {
return Err("Edit conflict: a row changed or disappeared; no edits were saved".into());
}
}
transaction.commit()?;
Ok(())
}
#[cfg(test)]
@@ -208,6 +311,214 @@ mod tests {
use super::*;
use crate::db::{Backend, Database};
#[test]
fn staged_edits_preserve_raw_storage_and_commit_once_per_row_across_pages() -> Result<()> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("drafts.sqlite");
let writer = Connection::open(&path)?;
writer.execute_batch(
"CREATE TABLE \"odd\"\"table\" (
tenant TEXT NOT NULL, id INTEGER NOT NULL, missing TEXT, empty TEXT,
multiline TEXT, integer_value INTEGER, real_value REAL, bytes BLOB, invalid TEXT,
generated INTEGER GENERATED ALWAYS AS (id + 1),
PRIMARY KEY (tenant, id)) WITHOUT ROWID;
WITH RECURSIVE numbers(n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 101
) INSERT INTO \"odd\"\"table\"
(tenant, id, missing, empty, multiline, integer_value, real_value, bytes, invalid)
SELECT 'a', n, NULL, '', 'one' || char(10) || 'two', 7, 2.5, X'00FF',
CAST(X'FF' AS TEXT) FROM numbers;
CREATE TABLE audit (id INTEGER);
CREATE TRIGGER updated AFTER UPDATE ON \"odd\"\"table\"
BEGIN INSERT INTO audit VALUES (new.id); END;",
)?;
let mut db = Database::open_sqlite(&path, AccessMode::ReadWrite)?;
let table = db
.relations()?
.into_iter()
.find(|table| table.name == "odd\"table")
.unwrap();
let columns = db.columns(&table)?;
assert!(columns[9].generated);
let first = db.edit_page(&table, &columns, 0)?;
let last = db.edit_page(&table, &columns, 1)?;
assert!(first.editable && first.page.has_next && !last.page.has_next);
let original = first.rows[0].clone();
assert_eq!(
original.values[2..9],
[
RawValue::Null,
RawValue::Text(Vec::new()),
RawValue::Text(b"one\ntwo".to_vec()),
RawValue::Integer(7),
RawValue::Real(2.5_f64.to_bits()),
RawValue::Blob(vec![0, 255]),
RawValue::Text(vec![255]),
]
);
assert_eq!(first.page.rows[0][4].as_deref(), Some("one\\ntwo"));
assert_eq!(original.values[4].input_text()?, "one\ntwo");
assert!(original.values[8].input_text().is_err());
assert!(original.values[5].edited(Some("not an integer")).is_err());
assert!(original.values[7].edited(Some("X'0Z'")).is_err());
let edits = vec![
RowEdit {
original: original.clone(),
changes: vec![
(0, original.values[0].edited(Some("bound'\"tenant"))?),
(1, original.values[1].edited(Some("500"))?),
(2, original.values[2].edited(Some(""))?),
(3, original.values[3].edited(None)?),
(5, original.values[5].edited(Some("42"))?),
(6, original.values[6].edited(Some("3.25"))?),
(7, original.values[7].edited(Some("X'FE00'"))?),
],
},
RowEdit {
original: last.rows[0].clone(),
changes: vec![(4, last.rows[0].values[4].edited(Some("new\nlines"))?)],
},
];
// A concurrent NULL -> empty change must conflict, not overwrite the newer row.
writer.execute(
"UPDATE \"odd\"\"table\" SET missing = '' WHERE id = 101",
[],
)?;
writer.execute("DELETE FROM audit", [])?;
assert!(db.apply_edits(&table, &edits).is_err());
assert_eq!(db.edit_page(&table, &columns, 0)?.rows[0], original);
assert_eq!(
db.query("SELECT COUNT(*) FROM audit")?.page.rows,
[vec![Some("0".into())]]
);
let mut edits = edits;
edits[1].original = db.edit_page(&table, &columns, 1)?.rows[0].clone();
db.apply_edits(&table, &edits)?;
assert_eq!(
db.query("SELECT COUNT(*) FROM audit")?.page.rows,
[vec![Some("2".into())]]
);
assert_eq!(db.query(
"SELECT tenant, id, missing, empty, multiline, typeof(integer_value), integer_value,
typeof(real_value), real_value, typeof(bytes), hex(bytes), typeof(invalid), hex(invalid),
generated FROM \"odd\"\"table\" WHERE id = 500"
)?.page.rows, [vec![
Some("bound'\"tenant".into()), Some("500".into()), Some("".into()), None,
Some("one\\ntwo".into()), Some("integer".into()), Some("42".into()),
Some("real".into()), Some("3.25".into()), Some("blob".into()), Some("FE00".into()),
Some("text".into()), Some("FF".into()), Some("501".into()),
]]);
assert_eq!(
db.query("SELECT multiline FROM \"odd\"\"table\" WHERE id = 101")?
.page
.rows,
[vec![Some("new\\nlines".into())]]
);
let fresh = db.edit_page(&table, &columns, 0)?;
let before = fresh.rows[0].clone();
let failing = [
RowEdit {
original: before.clone(),
changes: vec![(5, RawValue::Integer(99))],
},
RowEdit {
original: fresh.rows[1].clone(),
changes: vec![(1, RawValue::Integer(2))],
},
];
assert!(db.apply_edits(&table, &failing).is_err());
assert_eq!(db.edit_page(&table, &columns, 0)?.rows[0], before);
writer.execute_batch(
"CREATE TRIGGER skip_update BEFORE UPDATE ON \"odd\"\"table\"
WHEN new.integer_value = 999 BEGIN SELECT RAISE(IGNORE); END;",
)?;
let skipped = [
RowEdit {
original: before.clone(),
changes: vec![(5, RawValue::Integer(99))],
},
RowEdit {
original: fresh.rows[1].clone(),
changes: vec![(5, RawValue::Integer(999))],
},
];
assert!(db.apply_edits(&table, &skipped).is_err());
assert_eq!(db.edit_page(&table, &columns, 0)?.rows[0], before);
assert!(
db.apply_edits(
&table,
&[RowEdit {
original: before.clone(),
changes: vec![(9, RawValue::Integer(0))],
}]
)
.is_err()
);
assert!(
db.apply_edits(
&table,
&[
RowEdit {
original: before.clone(),
changes: vec![(5, RawValue::Integer(7))]
},
RowEdit {
original: before.clone(),
changes: vec![(4, RawValue::Text(b"duplicate".to_vec()))]
},
]
)
.is_err()
);
let mut readonly = Database::open_sqlite(&path, AccessMode::ReadOnly)?;
assert!(!readonly.edit_page(&table, &columns, 0)?.editable);
assert!(
readonly
.apply_edits(
&table,
&[RowEdit {
original: before,
changes: vec![(5, RawValue::Integer(0))],
}]
)
.is_err()
);
Ok(())
}
#[test]
fn keyless_nullable_keys_and_views_cannot_be_edited() -> Result<()> {
let directory = tempfile::tempdir()?;
let path = directory.path().join("readonly.sqlite");
let writer = Connection::open(&path)?;
writer.execute_batch(
"CREATE TABLE keyless (value TEXT); INSERT INTO keyless VALUES ('x');
CREATE TABLE nullable_key (id TEXT PRIMARY KEY, value TEXT);
INSERT INTO nullable_key VALUES (NULL, 'x');
CREATE VIEW sample_view AS SELECT * FROM nullable_key;",
)?;
let mut db = Database::open_sqlite(&path, AccessMode::ReadWrite)?;
for table in db.relations()? {
let columns = db.columns(&table)?;
let page = db.edit_page(&table, &columns, 0)?;
assert!(!page.editable, "{}", table.name);
assert!(
db.apply_edits(
&table,
&[RowEdit {
original: page.rows[0].clone(),
changes: vec![(columns.len() - 1, RawValue::Text(b"changed".to_vec()))],
}]
)
.is_err(),
"{}",
table.name
);
}
Ok(())
}
#[test]
fn query_sqlite_read_only() -> Result<()> {
let directory = tempfile::tempdir()?;
@@ -278,7 +589,8 @@ break\", 2.5 AS amount; \n",
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,
db.query("CREATE TABLE saved (id INTEGER PRIMARY KEY, value INTEGER UNIQUE)")?
.affected_rows,
Some(0)
);
let inserted = db.query(
@@ -299,27 +611,47 @@ break\", 2.5 AS amount; \n",
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",
"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,
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));
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,
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());
@@ -373,7 +705,7 @@ break\", 2.5 AS amount; \n",
assert!(!columns[2].nullable);
assert_eq!(columns[2].default, "1.25");
assert_eq!(columns[5].name, "generated");
let first = db.page(table, &columns, 0)?;
let first = db.edit_page(table, &columns, 0)?.page;
assert!(first.has_next);
assert_eq!(first.rows.len(), 100);
assert_eq!(
@@ -392,7 +724,7 @@ break\", 2.5 AS amount; \n",
assert_eq!(first.rows[1][4].as_deref(), Some("2.5"));
assert_eq!(first.rows[2][4].as_deref(), Some("X'0080'"));
assert_eq!(first.rows[3][4].as_deref(), Some("\\xff"));
let second = db.page(table, &columns, 1)?;
let second = db.edit_page(table, &columns, 1)?.page;
assert!(!second.has_next);
assert_eq!(
second.rows,
@@ -412,7 +744,7 @@ break\", 2.5 AS amount; \n",
assert_eq!(view.kind, "view");
let view_columns = db.columns(view)?;
assert_eq!(
db.page(view, &view_columns, 1)?.rows[0][0].as_deref(),
db.edit_page(view, &view_columns, 1)?.page.rows[0][0].as_deref(),
Some("101")
);
let empty = relations
@@ -420,7 +752,7 @@ break\", 2.5 AS amount; \n",
.find(|relation| relation.name == "empty_table")
.unwrap();
let empty_columns = db.columns(empty)?;
let empty_page = db.page(empty, &empty_columns, 0)?;
let empty_page = db.edit_page(empty, &empty_columns, 0)?.page;
assert!(empty_page.rows.is_empty());
assert!(!empty_page.has_next);
let keyed = relations
@@ -430,7 +762,7 @@ break\", 2.5 AS amount; \n",
let keyed_columns = db.columns(keyed)?;
assert!(!keyed_columns[0].nullable);
assert_eq!(
db.page(keyed, &keyed_columns, 0)?.rows,
db.edit_page(keyed, &keyed_columns, 0)?.page.rows,
vec![
vec![Some("a".into()), Some("1".into())],
vec![Some("b".into()), Some("2".into())]
@@ -446,7 +778,7 @@ break\", 2.5 AS amount; \n",
.find(|relation| relation.name == "keyless")
.unwrap();
let keyless_columns = db.columns(keyless)?;
let page = db.page(keyless, &keyless_columns, 0)?;
let page = db.edit_page(keyless, &keyless_columns, 0)?.page;
assert!(!page.has_next);
assert_eq!(page.rows.len(), 100);
assert_eq!(page.rows[0][2].as_deref(), Some("100"));
+38 -7
View File
@@ -12,7 +12,7 @@ use crossterm::event::{
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
const HELP: &str = r#"dbdash — PostgreSQL and SQLite inspection with opt-in SQL writes
const HELP: &str = r#"dbdash — PostgreSQL and SQLite inspection with opt-in staged and SQL writes
Usage:
dbdash [--read-write] [POSTGRES_CONNECTION_STRING]
@@ -72,6 +72,17 @@ Navigation:
s Toggle rows and column schema
Left/Right Collapse/expand in explorer; select cells in rows/schema (h/l too)
n/p, PgDn/Up Next / previous 100-row relation page
v / Ctrl-V Toggle rectangular selection in rows/results; move with hjkl/arrows
ym / yc Copy the block (or current cell) as Markdown / CSV with column headers
Ym / Yc Copy as Markdown / CSV without column names
Esc Cancel a pending copy or block selection before returning to explorer
Blocks cover the current loaded page, including cells scrolled out of view.
Paging, refreshing, opening a relation, schema changes, and successful queries clear blocks.
Copies use full displayed cell values (NULL and escaped control characters included).
Headerless Markdown uses blank headings; CSV quotes only fields containing commas,
double quotes, or line breaks, and uses CRLF line endings.
Clipboard tools: pbcopy on macOS, PowerShell on Windows, wl-copy on Wayland,
or xclip on X11. A missing/failing tool reports an error without clearing the block.
c Add a configured database in a floating window
i Show configured database info (Esc/Enter closes; passwords omitted)
Works on a saved database entry or any of its table/view children.
@@ -82,10 +93,10 @@ Navigation:
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/live READ ONLY or READ WRITE above and mode/database below.
Full-width bars show dbdash 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.
The footer shows provider, a readonly/readwrite badge with one-column outer margins, then position.
Only badges have colored backgrounds; the remaining bar area uses terminal defaults.
Focused inputs show one-based line:column (Unicode characters, not screen cells).
The position badge shows N/A outside focused inputs, including in info/help popups.
@@ -103,17 +114,37 @@ 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 SQL statement on the current connection, showing up
In Query, 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.
Rows in READ WRITE: e / Enter edits the selected cell; Enter stages, Esc cancels
only that editor. Alt-Enter / Shift-Enter inserts a newline; Tab inserts a literal tab.
Ctrl-N toggles explicit SQL NULL (distinct from empty text and the string NULL).
Existing and pasted tabs, newlines, and control bytes are preserved on save;
control characters render as safe escapes. Ctrl-U clears the editor.
Yellow cells are staged; the rows header counts changed rows/cells across pages.
F5 / Ctrl-Enter in Rows stages the active editor and atomically applies all drafts.
Editing/applying never runs query text. Query focus keeps F5 / Ctrl-Enter for SQL.
D in Rows explicitly discards drafts after confirmation. Refresh, relation changes,
connection switches/reconnect/removal, SQL execution, and quitting also prompt:
y discards; n/Esc cancels the action and keeps drafts and the active editor.
Drafts belong to one table and survive paging; copy includes their displayed values.
Only keyed base tables can be edited. Views, query results, keyless tables,
generated columns, and rows with NULL keys are read-only.
SQLite edits preserve the original value's storage class; numeric/blob inputs must
be valid, and BLOBs use X'hex'. Empty text is not NULL. An original NULL becomes
text when edited without Ctrl-N; the database's column affinity still applies.
Applying uses original composite keys and checks original values for conflicts.
Any conflict, conversion, or constraint error rolls back the batch and keeps drafts
and the active editor. Success reloads server values, including trigger changes.
Drafts are session-only, not an undo log; discarding never changes the database.
Ctrl-Enter/Shift-Enter need terminal keyboard support; use F5/Alt-Enter 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)
q quits outside query/cell editors. Ctrl-C quits from any surface; unsaved edits prompt.
New database window: Up/Down chooses a provider; Enter starts editing.
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.