feat: stage and atomically apply row edits

This commit is contained in:
Valdemar Lorenzen
2026-10-21 11:02:11 +02:00
parent 138490eecd
commit 32db904fa4
7 changed files with 2092 additions and 183 deletions
+1 -1
View File
@@ -2,7 +2,7 @@
name = "dbdash" name = "dbdash"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
description = "Read-only PostgreSQL and SQLite inspection in your terminal" description = "PostgreSQL and SQLite terminal browser with opt-in staged editing"
[dependencies] [dependencies]
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
+3 -1
View File
@@ -9,4 +9,6 @@
- should be able to copy without row names (`Y` keybinding) - should be able to copy without row names (`Y` keybinding)
- `Ym` should copy to markdown format - `Ym` should copy to markdown format
- `Yc` should copy to csv format - `Yc` should copy to csv format
- [ ] add ability to update values from the row view - [x] add ability to update values from the row view
- edits remain drafts across pages; Ctrl-Enter / F5 applies the table's changes atomically
- explicit NULL, conflict detection, and discard/cancel guards protect unsaved work
+739 -32
View File
@@ -4,13 +4,13 @@ use ratatui::{
layout::{Constraint, Layout, Rect}, layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::Line, text::Line,
widgets::{Block, Cell, Paragraph, Row, Table, TableState}, widgets::{Block, Cell, Clear, Paragraph, Row, Table, TableState, Wrap},
}; };
use crate::{ use crate::{
Result, Result,
connection::Input, connection::Input,
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation}, db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation, RowEdit, RowSnapshot},
error_text, sql, error_text, sql,
}; };
@@ -21,6 +21,22 @@ pub(crate) enum Focus {
Rows, Rows,
} }
struct CellEditor {
original: RowSnapshot,
column: usize,
input: Input,
null: bool,
}
#[derive(Clone, Copy)]
enum DiscardAction {
Refresh,
Relation(Option<usize>),
Query,
Discard,
External,
}
pub struct App { pub struct App {
db: Database, db: Database,
pub(crate) relations: Vec<Relation>, pub(crate) relations: Vec<Relation>,
@@ -41,6 +57,11 @@ pub struct App {
query_highlights: sql::Highlights, query_highlights: sql::Highlights,
query_result: Option<QueryResult>, query_result: Option<QueryResult>,
error: Option<String>, error: Option<String>,
snapshots: Vec<RowSnapshot>,
editable: bool,
drafts: Vec<RowEdit>,
editor: Option<CellEditor>,
discard: Option<DiscardAction>,
} }
impl App { impl App {
@@ -70,6 +91,11 @@ impl App {
query_highlights: Vec::new(), query_highlights: Vec::new(),
query_result: None, query_result: None,
error: None, error: None,
snapshots: Vec::new(),
editable: false,
drafts: Vec::new(),
editor: None,
discard: None,
}; };
app.open_relation(selected); app.open_relation(selected);
Ok(app) Ok(app)
@@ -99,13 +125,267 @@ impl App {
pub(crate) fn query_position(&self) -> (usize, usize) { pub(crate) fn query_position(&self) -> (usize, usize) {
self.query.position() self.query.position()
} }
pub(crate) fn cell_editor_position(&self) -> Option<(usize, usize)> {
self.editor.as_ref().map(|editor| editor.input.position())
}
pub(crate) fn discard_prompt_active(&self) -> bool {
self.discard.is_some()
}
pub(crate) fn request_external_discard(&mut self) -> bool {
self.defer_discard(DiscardAction::External)
}
fn defer_discard(&mut self, action: DiscardAction) -> bool {
if self.editor.is_some() || !self.drafts.is_empty() {
self.discard = Some(action);
true
} else {
false
}
}
// Returns true only when an external action may resume.
pub(crate) fn resolve_discard(&mut self, key: KeyEvent) -> bool {
if matches!(key.code, KeyCode::Esc | KeyCode::Char('n')) {
self.discard = None;
} else if key.code == KeyCode::Char('y')
&& let Some(action) = self.discard.take()
{
self.editor = None;
self.overlay_drafts(true);
self.drafts.clear();
match action {
DiscardAction::Refresh => self.refresh(),
DiscardAction::Relation(index) => self.open_relation(index),
DiscardAction::Query => self.execute_query(),
DiscardAction::Discard => {}
DiscardAction::External => return true,
}
}
false
}
fn draft_index(&self, snapshot: &RowSnapshot) -> Option<usize> {
self.drafts
.iter()
.position(|draft| same_key(&self.columns, &draft.original, snapshot))
}
fn overlay_drafts(&mut self, restore: bool) {
for row in 0..self.snapshots.len() {
if let Some(index) = self.draft_index(&self.snapshots[row]) {
for (column, value) in &self.drafts[index].changes {
let value = if restore {
&self.snapshots[row].values[*column]
} else {
value
};
self.page.rows[row][*column] = value.display();
}
}
}
}
fn start_cell_edit(&mut self) {
let reason = if self.query_result.is_some() {
Some("Query results are read-only; open a keyed base table to edit.")
} else if self.show_schema {
Some("Column schema is read-only; press s to return to rows.")
} else if self.access_mode() == crate::db::AccessMode::ReadOnly {
Some("READ ONLY connection; reconnect with READ WRITE access to edit.")
} else if !self.editable {
Some(
"Editing requires a base table with a non-NULL primary key; views and keyless tables are read-only.",
)
} else {
None
};
if let Some(reason) = reason {
self.error = Some(reason.into());
return;
}
let Some((row, column)) = self.rows_state.selected_cell() else {
return;
};
if self.columns[column].generated {
self.error = Some("Generated columns are read-only.".into());
return;
}
let snapshot = &self.snapshots[row];
let draft = self.draft_index(snapshot).map(|index| &self.drafts[index]);
let original = draft.map_or(snapshot, |draft| &draft.original);
let value = draft
.and_then(|draft| {
draft
.changes
.iter()
.find(|(index, _)| *index == column)
.map(|(_, value)| value)
})
.unwrap_or(&original.values[column]);
match value.input_text() {
Ok(text) => {
self.editor = Some(CellEditor {
original: original.clone(),
column,
input: Input::from(text),
null: matches!(value, crate::db::RawValue::Null),
});
self.clear_selection();
self.error = None;
}
Err(error) => self.error = Some(error_text(error.as_ref())),
}
}
fn stage_editor(&mut self, keep: bool) -> bool {
let Some(editor) = &self.editor else {
return true;
};
let value = match editor.original.values[editor.column]
.edited((!editor.null).then_some(editor.input.value.as_str()))
{
Ok(value) => value,
Err(error) => {
self.error = Some(error_text(error.as_ref()));
return false;
}
};
let column = editor.column;
let index = self.draft_index(&editor.original);
let unchanged = value == editor.original.values[column];
self.page.rows[self.rows_state.selected().unwrap()][column] = value.display();
if unchanged {
if let Some(index) = index {
self.drafts[index]
.changes
.retain(|(index, _)| *index != column);
if self.drafts[index].changes.is_empty() {
self.drafts.remove(index);
}
}
} else if let Some(index) = index {
let changes = &mut self.drafts[index].changes;
if let Some((_, changed)) = changes.iter_mut().find(|(index, _)| *index == column) {
*changed = value;
} else {
changes.push((column, value));
}
} else {
let original = if keep {
self.editor.as_ref().unwrap().original.clone()
} else {
self.editor.take().unwrap().original
};
self.drafts.push(RowEdit {
original,
changes: vec![(column, value)],
});
}
if !keep {
self.editor = None;
}
self.error = None;
true
}
fn apply_drafts(&mut self) {
if !self.stage_editor(true) {
return;
}
if self.drafts.is_empty() {
self.editor = None;
return;
}
let Some(active) = self.active else { return };
match self.db.apply_edits(&self.relations[active], &self.drafts) {
Ok(()) => {
self.drafts.clear();
self.editor = None;
self.load_page(self.page_index);
}
Err(error) => self.error = Some(error_text(error.as_ref())),
}
}
fn edit_cell_key(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Esc => self.editor = None,
KeyCode::F(5) => self.apply_drafts(),
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.apply_drafts(),
KeyCode::Enter
if key
.modifiers
.intersects(KeyModifiers::ALT | KeyModifiers::SHIFT) =>
{
let editor = self.editor.as_mut().unwrap();
editor.null = false;
editor.input.paste_raw("\n");
}
KeyCode::Enter => {
self.stage_editor(false);
}
KeyCode::Char('n') if key.modifiers.contains(KeyModifiers::CONTROL) => {
let editor = self.editor.as_mut().unwrap();
editor.null = !editor.null;
}
KeyCode::Tab => self.paste_cell("\t"),
_ => {
let editor = self.editor.as_mut().unwrap();
if matches!(
key.code,
KeyCode::Char(_) | KeyCode::Backspace | KeyCode::Delete
) {
editor.null = false;
}
editor.input.edit(key);
}
}
}
pub(crate) fn paste_cell(&mut self, text: &str) {
if let Some(editor) = &mut self.editor {
editor.null = false;
editor.input.paste_raw(text);
}
}
pub(crate) fn handle_rows_command(&mut self, key: KeyEvent) -> bool { pub(crate) fn handle_rows_command(&mut self, key: KeyEvent) -> bool {
if self.editor.is_some() {
self.edit_cell_key(key);
return true;
}
if key.code == KeyCode::F(5)
|| (key.code == KeyCode::Enter && key.modifiers.contains(KeyModifiers::CONTROL))
{
self.apply_drafts();
return true;
}
if !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
{
match key.code {
KeyCode::Char('e') | KeyCode::Enter => {
self.start_cell_edit();
return true;
}
KeyCode::Char('D') => {
self.defer_discard(DiscardAction::Discard);
return true;
}
_ => {}
}
}
let pending = self.copy_headers.take(); let pending = self.copy_headers.take();
if self.show_schema { if self.show_schema {
return false; return false;
} }
if key.modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) if key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
&& !(key.code == KeyCode::Char('v') && key.modifiers == KeyModifiers::CONTROL) && !(key.code == KeyCode::Char('v') && key.modifiers == KeyModifiers::CONTROL)
{ {
return false; return false;
@@ -124,7 +404,8 @@ impl App {
)); ));
} }
Err(error) => { Err(error) => {
self.error = Some(format!("Clipboard: {}", error_text(error.as_ref()))); self.error =
Some(format!("Clipboard: {}", error_text(error.as_ref())));
} }
} }
} }
@@ -182,7 +463,10 @@ impl App {
fn selection_text(&self, headers: bool, markdown: bool) -> Option<String> { fn selection_text(&self, headers: bool, markdown: bool) -> Option<String> {
let (top, bottom, left, right) = self.selection_bounds()?; let (top, bottom, left, right) = self.selection_bounds()?;
let page = self.query_result.as_ref().map_or(&self.page, |result| &result.page); let page = self
.query_result
.as_ref()
.map_or(&self.page, |result| &result.page);
let rows = page.rows.get(top..=bottom)?; let rows = page.rows.get(top..=bottom)?;
let mut output = String::new(); let mut output = String::new();
if headers { if headers {
@@ -205,7 +489,9 @@ impl App {
for row in rows { for row in rows {
append_record( append_record(
&mut output, &mut output,
row.get(left..=right)?.iter().map(|value| value.as_deref().unwrap_or("NULL")), row.get(left..=right)?
.iter()
.map(|value| value.as_deref().unwrap_or("NULL")),
markdown, markdown,
); );
} }
@@ -322,6 +608,9 @@ impl App {
} }
fn execute_query(&mut self) { fn execute_query(&mut self) {
if self.defer_discard(DiscardAction::Query) {
return;
}
match self.db.query(&self.query.value) { match self.db.query(&self.query.value) {
Ok(result) => { Ok(result) => {
self.clear_selection(); self.clear_selection();
@@ -375,10 +664,15 @@ impl App {
} }
pub(crate) fn open_relation(&mut self, index: Option<usize>) { pub(crate) fn open_relation(&mut self, index: Option<usize>) {
if self.defer_discard(DiscardAction::Relation(index)) {
return;
}
self.clear_selection(); self.clear_selection();
self.active = index; self.active = index;
self.query_result = None; self.query_result = None;
self.columns.clear(); self.columns.clear();
self.snapshots.clear();
self.editable = false;
self.page = Page { self.page = Page {
rows: Vec::new(), rows: Vec::new(),
has_next: false, has_next: false,
@@ -406,8 +700,14 @@ impl App {
fn load_page(&mut self, index: usize) { fn load_page(&mut self, index: usize) {
if let Some(active) = self.active { if let Some(active) = self.active {
// ponytail: synchronous reads block input; use a worker if responsiveness matters. // ponytail: synchronous reads block input; use a worker if responsiveness matters.
match self.db.page(&self.relations[active], &self.columns, index) { match self
Ok(page) => { .db
.edit_page(&self.relations[active], &self.columns, index)
{
Ok(edit_page) => {
let page = edit_page.page;
self.snapshots = edit_page.rows;
self.editable = edit_page.editable;
self.clear_selection(); self.clear_selection();
self.rows_state = TableState::default() self.rows_state = TableState::default()
.with_selected((!page.rows.is_empty()).then_some(0)) .with_selected((!page.rows.is_empty()).then_some(0))
@@ -417,6 +717,7 @@ impl App {
.or_else(|| (!self.columns.is_empty()).then_some(0)), .or_else(|| (!self.columns.is_empty()).then_some(0)),
); );
self.page = page; self.page = page;
self.overlay_drafts(false);
self.page_index = index; self.page_index = index;
self.error = None; self.error = None;
} }
@@ -426,6 +727,9 @@ impl App {
} }
fn refresh(&mut self) { fn refresh(&mut self) {
if self.defer_discard(DiscardAction::Refresh) {
return;
}
match self.db.relations() { match self.db.relations() {
Ok(relations) => { Ok(relations) => {
let id = self.active.map(|index| self.relations[index].id); let id = self.active.map(|index| self.relations[index].id);
@@ -463,9 +767,17 @@ impl App {
focus == Focus::Query, focus == Focus::Query,
Some(sql::text(&self.query.value, &self.query_highlights)), Some(sql::text(&self.query.value, &self.query_highlights)),
); );
let draft_cells: usize = self.drafts.iter().map(|draft| draft.changes.len()).sum();
let draft_status = format!(
" Rows · {} rows/{draft_cells} cells staged · F5 apply · D discard ",
self.drafts.len()
);
let title = if let Some(headers) = self.copy_headers { let title = if let Some(headers) = self.copy_headers {
if headers { " Copy with headers · m Markdown / c CSV " } if headers {
else { " Copy without headers · m Markdown / c CSV " } " Copy with headers · m Markdown / c CSV "
} else {
" Copy without headers · m Markdown / c CSV "
}
} else if self.selection_anchor.is_some() { } else if self.selection_anchor.is_some() {
" VISUAL BLOCK · ym/yc with headers · Ym/Yc without " " VISUAL BLOCK · ym/yc with headers · Ym/Yc without "
} else if let Some(status) = &self.copy_status { } else if let Some(status) = &self.copy_status {
@@ -474,8 +786,10 @@ impl App {
" Results " " Results "
} else if self.show_schema { } else if self.show_schema {
" Schema · s for rows " " Schema · s for rows "
} else if !self.drafts.is_empty() {
draft_status.as_str()
} else { } else {
" Rows · s for schema " " Rows · e/Enter edit · s schema "
}; };
let count = self.column_count(); let count = self.column_count();
let capacity = usize::from(content.width.saturating_sub(2) / 21).max(1); let capacity = usize::from(content.width.saturating_sub(2) / 21).max(1);
@@ -510,7 +824,9 @@ impl App {
if count > 0 { self.column_offset + 1 } else { 0 }, if count > 0 { self.column_offset + 1 } else { 0 },
self.column_offset + visible, self.column_offset + visible,
count, count,
result.affected_rows.map_or_else(String::new, |rows| format!(" · {rows} rows affected")), result
.affected_rows
.map_or_else(String::new, |rows| format!(" · {rows} rows affected")),
) )
} else if let Some(index) = self.active { } else if let Some(index) = self.active {
let relation = &self.relations[index]; let relation = &self.relations[index];
@@ -605,7 +921,19 @@ impl App {
) )
}); });
let bounds = self.selection_anchor.and_then(|_| self.selection_bounds()); let bounds = self.selection_anchor.and_then(|_| self.selection_bounds());
let columns = &self.columns;
let drafts = &self.drafts;
let snapshots = &self.snapshots;
let relation_rows = self.query_result.is_none();
let rows = page.rows.iter().enumerate().map(|(row_index, row)| { let rows = page.rows.iter().enumerate().map(|(row_index, row)| {
let draft = relation_rows
.then(|| snapshots.get(row_index))
.flatten()
.and_then(|snapshot| {
drafts
.iter()
.find(|draft| same_key(columns, &draft.original, snapshot))
});
Row::new( Row::new(
row.iter() row.iter()
.enumerate() .enumerate()
@@ -625,6 +953,17 @@ impl App {
&& (left..=right).contains(&column_index) && (left..=right).contains(&column_index)
}) { }) {
cell.style(selected_style()) cell.style(selected_style())
} else if draft.is_some_and(|draft| {
draft
.changes
.iter()
.any(|(column, _)| *column == column_index)
}) {
cell.style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
} else { } else {
cell cell
} }
@@ -646,14 +985,67 @@ impl App {
error_area, error_area,
); );
} }
if let Some(editor) = &self.editor {
let popup = centered(area, 90, 12);
frame.render_widget(Clear, popup);
let [input, hints] =
Layout::vertical([Constraint::Min(3), Constraint::Length(1)]).areas(popup);
let title = format!(
" Edit {} · {} ",
self.columns[editor.column].label,
if editor.null { "NULL" } else { "value" }
);
editor.input.draw(frame, input, &title, false, true, None);
frame.render_widget(
Paragraph::new(
"Enter stage · Esc cancel · Alt/Shift-Enter newline · Ctrl-N NULL · F5 apply",
),
hints,
);
} }
} }
fn append_record<'a>( pub(crate) fn draw_discard_prompt(&self, frame: &mut Frame, area: Rect) {
output: &mut String, if let Some(action) = self.discard {
values: impl Iterator<Item = &'a str>, let verb = match action {
markdown: bool, DiscardAction::Refresh => "refreshing",
) { DiscardAction::Relation(_) => "opening a relation",
DiscardAction::Query => "executing SQL",
DiscardAction::Discard => "continuing",
DiscardAction::External => "leaving this work",
};
let cells: usize = self.drafts.iter().map(|draft| draft.changes.len()).sum();
let popup = centered(area, 76, 7);
frame.render_widget(Clear, popup);
frame.render_widget(Paragraph::new(format!(
"Discard {} staged rows / {cells} cells{} before {verb}?\nNothing has been written to the database.",
self.drafts.len(), if self.editor.is_some() { " and the active editor" } else { "" }))
.wrap(Wrap { trim: false })
.block(Block::bordered().title(" Unsaved edits ").title_bottom(" y: discard · n/Esc: cancel ")), popup);
}
}
}
fn same_key(columns: &[Column], left: &RowSnapshot, right: &RowSnapshot) -> bool {
columns
.iter()
.enumerate()
.filter(|(_, column)| column.primary_key)
.all(|(index, _)| left.values[index] == right.values[index])
}
fn centered(area: Rect, width: u16, height: u16) -> Rect {
let width = width.min(area.width);
let height = height.min(area.height);
Rect::new(
area.x + (area.width - width) / 2,
area.y + (area.height - height) / 2,
width,
height,
)
}
fn append_record<'a>(output: &mut String, values: impl Iterator<Item = &'a str>, markdown: bool) {
if markdown { if markdown {
output.push('|'); output.push('|');
} }
@@ -695,13 +1087,22 @@ fn append_record<'a>(
} }
fn copy_to_clipboard(text: &str) -> Result<()> { fn copy_to_clipboard(text: &str) -> Result<()> {
use std::{io::Write, process::{Command, Stdio}}; use std::{
io::Write,
process::{Command, Stdio},
};
let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") { let (program, args): (&str, &[&str]) = if cfg!(target_os = "macos") {
("pbcopy", &[]) ("pbcopy", &[])
} else if cfg!(target_os = "windows") { } else if cfg!(target_os = "windows") {
("powershell.exe", &["-NoProfile", "-Command", (
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())"]) "powershell.exe",
&[
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.UTF8Encoding]::new(); Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
)
} else if std::env::var_os("WAYLAND_DISPLAY").is_some() { } else if std::env::var_os("WAYLAND_DISPLAY").is_some() {
("wl-copy", &[]) ("wl-copy", &[])
} else { } else {
@@ -759,6 +1160,278 @@ fn moved_index(selected: Option<usize>, count: usize, delta: isize) -> Option<us
mod tests { mod tests {
use super::moved_index; use super::moved_index;
fn key(code: crossterm::event::KeyCode) -> crossterm::event::KeyEvent {
crossterm::event::KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
fn edit_fixture() -> crate::Result<(tempfile::NamedTempFile, rusqlite::Connection, super::App)>
{
let file = tempfile::NamedTempFile::new()?;
let connection = rusqlite::Connection::open(file.path())?;
connection.execute_batch(
"CREATE TABLE edits (
id INTEGER NOT NULL, kind TEXT NOT NULL, body TEXT CHECK(body <> 'bad'),
nullable TEXT, derived TEXT GENERATED ALWAYS AS (body || '!') VIRTUAL,
PRIMARY KEY(id, kind));
WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101)
INSERT INTO edits(id,kind,body) SELECT x,'a','old' FROM n;
CREATE TABLE keyless (body TEXT);
INSERT INTO keyless VALUES ('old');
CREATE VIEW view_edits AS SELECT * FROM edits;",
)?;
let app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
crate::db::AccessMode::ReadWrite,
)?)?;
Ok((file, connection, app))
}
fn replace_cell(app: &mut super::App, row: usize, column: usize, value: &str) {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
app.rows_state.select(Some(row));
app.rows_state.select_column(Some(column));
app.handle_rows_command(key(KeyCode::Char('e')));
app.handle_rows_command(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.paste_cell(value);
}
#[test]
fn staged_raw_cells_survive_pages_copy_revert_and_reload_triggers() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (_file, connection, mut app) = edit_fixture()?;
let raw = "a\tb\r\nc\u{1b}\0界";
connection.execute("UPDATE edits SET body=? WHERE id=1", [raw])?;
connection.execute_batch(
"CREATE TRIGGER decorate AFTER UPDATE OF body ON edits BEGIN
UPDATE edits SET body=new.body || '!' WHERE id=new.id AND kind=new.kind;
END;",
)?;
app.refresh();
app.rows_state.select(Some(0));
app.rows_state.select_column(Some(2));
app.handle_rows_command(key(KeyCode::Enter));
assert_eq!(app.editor.as_ref().unwrap().input.value, raw);
app.handle_rows_command(key(KeyCode::Enter));
assert!(
app.drafts.is_empty(),
"unchanged raw controls must be a no-op"
);
replace_cell(&mut app, 0, 2, "temporary");
app.handle_rows_command(key(KeyCode::Enter));
replace_cell(&mut app, 0, 2, raw);
app.handle_rows_command(key(KeyCode::Enter));
assert!(app.drafts.is_empty(), "reverting removes the draft");
let changed = format!("{raw} changed");
replace_cell(&mut app, 0, 2, &changed);
app.handle_rows_command(key(KeyCode::Enter));
assert_eq!(
app.selection_text(false, false),
Some(format!("{}\r\n", crate::db::display_text(&changed)))
);
replace_cell(&mut app, 0, 1, "b");
app.handle_rows_command(key(KeyCode::Enter));
app.handle_key(key(KeyCode::Char('n')));
replace_cell(&mut app, 0, 3, "");
app.handle_rows_command(key(KeyCode::Enter));
assert_eq!(app.drafts.len(), 2);
app.handle_key(key(KeyCode::Char('p')));
assert!(app.draft_index(&app.snapshots[0]).is_some());
assert_eq!(app.page.rows[0][2], Some(crate::db::display_text(&changed)));
assert_eq!(app.page.rows[0][1].as_deref(), Some("b"));
app.rows_state.select(Some(1));
app.rows_state.select_column(Some(2));
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 24))?;
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
assert!(
terminal
.backend()
.buffer()
.content
.iter()
.any(|cell| cell.fg == ratatui::style::Color::Yellow)
);
let rendered: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
assert!(rendered.contains("2 rows/3 cells staged"));
// Applying from Rows never executes query text, and reload includes trigger output.
app.query.value = "DELETE FROM edits".into();
app.handle_rows_command(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(app.drafts.is_empty(), "{:?}", app.error);
assert_eq!(
app.page.rows[0][2],
Some(crate::db::display_text(&format!("{changed}!")))
);
assert_eq!(
connection.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))?,
101
);
assert_eq!(
connection.query_row("SELECT body FROM edits WHERE id=1", [], |row| row
.get::<_, String>(0))?,
format!("{changed}!")
);
assert_eq!(
connection.query_row("SELECT kind FROM edits WHERE id=1", [], |row| row
.get::<_, String>(0))?,
"b"
);
assert_eq!(
connection.query_row("SELECT nullable FROM edits WHERE id=101", [], |row| row
.get::<_, String>(
0
))?,
""
);
Ok(())
}
#[test]
fn apply_errors_keep_drafts_editor_and_roll_back_every_row() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (_file, connection, mut app) = edit_fixture()?;
replace_cell(&mut app, 0, 2, "first");
app.handle_rows_command(key(KeyCode::Enter));
replace_cell(&mut app, 1, 2, "bad");
app.handle_rows_command(key(KeyCode::F(5)));
assert!(app.error.is_some());
assert_eq!(app.drafts.len(), 2);
assert_eq!(app.editor.as_ref().unwrap().input.value, "bad");
assert_eq!(
connection.query_row("SELECT count(*) FROM edits WHERE body='old'", [], |row| row
.get::<_, i64>(0))?,
101
);
app.handle_rows_command(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
app.paste_cell("second");
app.handle_rows_command(key(KeyCode::F(5)));
assert!(app.error.is_none(), "{:?}", app.error);
assert!(app.editor.is_none());
assert!(app.drafts.is_empty());
// Invalid numeric input remains an editor, never a lossy string key.
replace_cell(&mut app, 0, 0, "not an integer");
app.handle_rows_command(key(KeyCode::Enter));
assert!(app.editor.is_some());
assert!(app.drafts.is_empty());
app.handle_rows_command(key(KeyCode::Esc));
replace_cell(&mut app, 0, 2, "mine");
connection.execute("UPDATE edits SET body='theirs' WHERE id=1", [])?;
app.handle_rows_command(key(KeyCode::F(5)));
assert!(app.error.is_some());
assert_eq!(app.drafts.len(), 1);
assert_eq!(app.editor.as_ref().unwrap().input.value, "mine");
Ok(())
}
#[test]
fn discard_guards_preserve_editor_until_confirmed_and_block_sql() -> crate::Result<()> {
use crossterm::event::KeyCode;
let (_file, connection, mut app) = edit_fixture()?;
replace_cell(&mut app, 0, 2, "draft");
app.handle_rows_command(key(KeyCode::Enter));
replace_cell(&mut app, 1, 2, "active");
app.query.value = "DELETE FROM edits".into();
for action in [
super::DiscardAction::Refresh,
super::DiscardAction::Relation(Some(1)),
super::DiscardAction::Query,
super::DiscardAction::External,
super::DiscardAction::Discard,
] {
match action {
super::DiscardAction::Refresh => app.refresh(),
super::DiscardAction::Relation(index) => app.open_relation(index),
super::DiscardAction::Query => app.execute_query(),
super::DiscardAction::External => {
assert!(app.request_external_discard());
}
super::DiscardAction::Discard => {
app.handle_rows_command(key(KeyCode::Esc));
app.handle_rows_command(key(KeyCode::Char('D')));
}
}
assert!(app.discard_prompt_active());
app.resolve_discard(key(KeyCode::Esc));
assert_eq!(app.drafts.len(), 1);
// D is invoked outside the editor, so restore it for the next transition.
if app.editor.is_none() {
replace_cell(&mut app, 1, 2, "active");
}
assert_eq!(app.editor.as_ref().unwrap().input.value, "active");
assert_eq!(app.active, Some(0));
assert!(app.query_result.is_none());
}
assert_eq!(
connection.query_row("SELECT count(*) FROM edits", [], |row| row.get::<_, i64>(0))?,
101
);
assert!(app.request_external_discard());
app.resolve_discard(key(KeyCode::Char('n')));
assert!(app.editor.is_some());
app.request_external_discard();
assert!(app.resolve_discard(key(KeyCode::Char('y'))));
assert!(app.editor.is_none());
assert!(app.drafts.is_empty());
assert_eq!(app.page.rows[0][2].as_deref(), Some("old"));
replace_cell(&mut app, 0, 2, "discard me");
app.handle_rows_command(key(KeyCode::Enter));
app.open_relation(Some(1));
app.resolve_discard(key(KeyCode::Char('y')));
assert_eq!(app.active, Some(1));
assert!(app.drafts.is_empty());
Ok(())
}
#[test]
fn explicit_null_is_distinct_and_uneditable_sources_explain_why() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let (file, _connection, mut app) = edit_fixture()?;
replace_cell(&mut app, 0, 3, "NULL");
app.handle_rows_command(key(KeyCode::Enter));
assert_eq!(app.page.rows[0][3].as_deref(), Some("NULL"));
app.handle_rows_command(key(KeyCode::Enter));
app.handle_rows_command(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::CONTROL));
app.handle_rows_command(key(KeyCode::Enter));
assert!(app.drafts.is_empty(), "explicit NULL reverts original NULL");
replace_cell(&mut app, 0, 3, "multi");
app.handle_rows_command(key(KeyCode::Tab));
app.handle_rows_command(KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT));
app.handle_rows_command(key(KeyCode::Char('q')));
assert_eq!(app.editor.as_ref().unwrap().input.value, "multi\t\nq");
app.handle_rows_command(key(KeyCode::Esc));
assert!(app.drafts.is_empty(), "Esc cancels only the active cell");
app.rows_state.select(Some(0));
app.rows_state.select_column(Some(4));
app.handle_rows_command(key(KeyCode::Enter));
assert!(app.error.as_ref().unwrap().contains("Generated"));
for name in ["keyless", "view_edits"] {
let index = app
.relations
.iter()
.position(|relation| relation.name == name)
.unwrap();
app.open_relation(Some(index));
app.handle_rows_command(key(KeyCode::Enter));
assert!(app.editor.is_none());
assert!(app.error.as_ref().unwrap().contains("primary key"));
}
app.query.value = "SELECT 1".into();
app.execute_query();
app.handle_rows_command(key(KeyCode::Enter));
assert!(app.error.as_ref().unwrap().contains("Query results"));
let mut readonly = super::App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
readonly.handle_rows_command(key(KeyCode::Enter));
assert!(readonly.error.as_ref().unwrap().contains("READ ONLY"));
Ok(())
}
#[test] #[test]
fn csv_quotes_only_fields_with_delimiters_quotes_or_line_breaks() { fn csv_quotes_only_fields_with_delimiters_quotes_or_line_breaks() {
let mut output = String::new(); let mut output = String::new();
@@ -767,7 +1440,10 @@ mod tests {
["42", "00123", "界", "", "a,b", "a\"b", "a\rb", "a\nb"].into_iter(), ["42", "00123", "界", "", "a,b", "a\"b", "a\rb", "a\nb"].into_iter(),
false, false,
); );
assert_eq!(output, "42,00123,界,,\"a,b\",\"a\"\"b\",\"a\rb\",\"a\nb\"\r\n"); assert_eq!(
output,
"42,00123,界,,\"a,b\",\"a\"\"b\",\"a\rb\",\"a\nb\"\r\n"
);
} }
#[test] #[test]
@@ -786,7 +1462,10 @@ mod tests {
WITH RECURSIVE n(x) AS (VALUES(4) UNION ALL SELECT x+1 FROM n WHERE x<101) WITH RECURSIVE n(x) AS (VALUES(4) UNION ALL SELECT x+1 FROM n WHERE x<101)
INSERT INTO cells SELECT x,x,x,x FROM n;", INSERT INTO cells SELECT x,x,x,x FROM n;",
)?; )?;
let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
// Anchor at the bottom right, extend up and left across a scrolled viewport. // Anchor at the bottom right, extend up and left across a scrolled viewport.
for character in ['j', 'l', 'l'] { for character in ['j', 'l', 'l'] {
@@ -806,11 +1485,15 @@ mod tests {
assert_ne!(buffer[(1, 14)].bg, Color::Blue); assert_ne!(buffer[(1, 14)].bg, Color::Blue);
assert_eq!( assert_eq!(
app.selection_text(true, true).as_deref(), app.selection_text(true, true).as_deref(),
Some("| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | &lt;&amp;&gt;\\\\tail |\n") Some(
"| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | &lt;&amp;&gt;\\\\tail |\n"
)
); );
assert_eq!( assert_eq!(
app.selection_text(false, true).as_deref(), app.selection_text(false, true).as_deref(),
Some("| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | &lt;&amp;&gt;\\\\tail |\n") Some(
"| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | &lt;&amp;&gt;\\\\tail |\n"
)
); );
assert_eq!( assert_eq!(
app.selection_text(true, false).as_deref(), app.selection_text(true, false).as_deref(),
@@ -839,7 +1522,10 @@ mod tests {
app.query.value = "SELECT 42 AS answer".into(); app.query.value = "SELECT 42 AS answer".into();
app.execute_query(); app.execute_query();
assert!(app.selection_anchor.is_none()); assert!(app.selection_anchor.is_none());
assert_eq!(app.selection_text(true, false).as_deref(), Some("answer\r\n42\r\n")); assert_eq!(
app.selection_text(true, false).as_deref(),
Some("answer\r\n42\r\n")
);
app.handle_rows_command(key('v')); app.handle_rows_command(key('v'));
app.query.value = "SELECT missing FROM nowhere".into(); app.query.value = "SELECT missing FROM nowhere".into();
app.execute_query(); app.execute_query();
@@ -868,7 +1554,10 @@ mod tests {
fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> { fn vim_query_motions_preserve_sql_and_handle_unicode() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?; let file = tempfile::NamedTempFile::new()?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
app.handle_query_key(key('i')); app.handle_query_key(key('i'));
app.paste_query(" SELECT café,界\n\n FROM data"); app.paste_query(" SELECT café,界\n\n FROM data");
@@ -916,7 +1605,10 @@ mod tests {
fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> { fn query_modes_preserve_literal_text_and_navigation() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?; let file = tempfile::NamedTempFile::new()?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
assert!(!app.query_insert_mode()); assert!(!app.query_insert_mode());
app.paste_query("ignored"); app.paste_query("ignored");
@@ -961,7 +1653,10 @@ mod tests {
fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> { fn execution_shortcuts_work_in_both_query_modes() -> crate::Result<()> {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let file = tempfile::NamedTempFile::new()?; let file = tempfile::NamedTempFile::new()?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.paste_query("SELECT\n42"); app.paste_query("SELECT\n42");
for insert in [true, false] { for insert in [true, false] {
@@ -994,7 +1689,10 @@ mod tests {
WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101) WITH RECURSIVE n(x) AS (VALUES(1) UNION ALL SELECT x+1 FROM n WHERE x<101)
INSERT INTO cells SELECT x, x, x, x, x, x FROM n;", INSERT INTO cells SELECT x, x, x, x, x, x FROM n;",
)?; )?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
let mut terminal = Terminal::new(TestBackend::new(60, 20))?; let mut terminal = Terminal::new(TestBackend::new(60, 20))?;
let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE); let key = |character| KeyEvent::new(KeyCode::Char(character), KeyModifiers::NONE);
assert_eq!(app.rows_state.selected_cell(), Some((0, 0))); assert_eq!(app.rows_state.selected_cell(), Some((0, 0)));
@@ -1036,7 +1734,10 @@ mod tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{Terminal, backend::TestBackend, style::Color}; use ratatui::{Terminal, backend::TestBackend, style::Color};
let file = tempfile::NamedTempFile::new()?; let file = tempfile::NamedTempFile::new()?;
let mut app = App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
let mut terminal = Terminal::new(TestBackend::new(60, 14))?; let mut terminal = Terminal::new(TestBackend::new(60, 14))?;
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.paste_query("SELECT '界', 42;\n-- comment"); app.paste_query("SELECT '界', 42;\n-- comment");
@@ -1068,7 +1769,10 @@ mod tests {
rusqlite::Connection::open(file.path())?.execute_batch( rusqlite::Connection::open(file.path())?.execute_batch(
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);", "CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);",
)?; )?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); app.handle_query_key(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE));
app.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;"); app.paste_query("SELECT '界é' AS note,\nNULL AS missing UNION ALL SELECT 'next', 2;");
app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE)); app.handle_query_key(KeyEvent::new(KeyCode::F(5), KeyModifiers::NONE));
@@ -1102,7 +1806,10 @@ mod tests {
rusqlite::Connection::open(file.path())?.execute_batch( rusqlite::Connection::open(file.path())?.execute_batch(
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (1);", "CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (1);",
)?; )?;
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path(), Default::default())?)?; let mut app = super::App::new(crate::db::Database::open_sqlite(
file.path(),
Default::default(),
)?)?;
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20))?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(100, 20))?;
for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] { for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] {
app.show_schema = schema; app.show_schema = schema;
+351 -52
View File
@@ -33,6 +33,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
let mut info: Option<usize> = None; let mut info: Option<usize> = None;
let mut help: Option<u16> = None; let mut help: Option<u16> = None;
let mut remove: Option<usize> = None; let mut remove: Option<usize> = None;
let mut pending_external: Option<KeyEvent> = None;
loop { loop {
let entries = explorer_entries( let entries = explorer_entries(
profiles.entries.len(), profiles.entries.len(),
@@ -42,18 +43,23 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
); );
terminal.draw(|frame| { terminal.draw(|frame| {
let [top, workspace, bottom] = shell_areas(frame.area()); 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 None
} else if show_form { } else if show_form {
form.input().map(Input::position) form.input().map(Input::position)
} else { } 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 { let insert = if show_form {
form.input().is_some() form.input().is_some()
} else { } else {
info.is_none() && focus == Focus::Query info.is_none() && browser.as_ref().is_some_and(|app| {
&& browser.as_ref().is_some_and(App::query_insert_mode) 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("", 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); draw_bars(frame, top, bottom, browser.as_ref().map_or("", App::provider), name, browser.as_ref().map(App::access_mode), insert, position);
@@ -111,9 +117,48 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
profiles.entries[index].label())) profiles.entries[index].label()))
.wrap(Wrap { trim: false }).block(Block::bordered().title(" Remove connection ").title_bottom(" y: remove · n/Esc: cancel ")), area); .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()? { 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) { if key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL) {
return Ok(()); return Ok(());
} }
@@ -184,7 +229,10 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
.map(|connected| { .map(|connected| {
browser = Some(connected); browser = Some(connected);
connection_label = Some(form.connection_label()); connection_label = Some(form.connection_label());
active = profiles.entries.iter().position(|entry| entry.id == form.id); active = profiles
.entries
.iter()
.position(|entry| entry.id == form.id);
selection.select(active); selection.select(active);
expanded = true; expanded = true;
focus = Focus::Explorer; focus = Focus::Explorer;
@@ -222,7 +270,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
} else { } else {
match key.code { match key.code {
_ if focus == Focus::Rows _ if focus == Focus::Rows
&& browser.as_mut().is_some_and(|app| app.handle_rows_command(key)) => {} && browser
.as_mut()
.is_some_and(|app| app.handle_rows_command(key)) => {}
KeyCode::Char('?') KeyCode::Char('?')
if focus != Focus::Query if focus != Focus::Query
|| !browser.as_ref().is_some_and(App::query_insert_mode) => || !browser.as_ref().is_some_and(App::query_insert_mode) =>
@@ -292,7 +342,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
selection.selected().and_then(|index| entries.get(index)) selection.selected().and_then(|index| entries.get(index))
{ {
let index = match *entry { let index = match *entry {
ExplorerEntry::Relation(_) if key.code == KeyCode::Char('R') => { ExplorerEntry::Relation(_)
if key.code == KeyCode::Char('R') =>
{
let Some(index) = active else { continue }; let Some(index) = active else { continue };
index index
} }
@@ -302,7 +354,9 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
continue; continue;
} }
ExplorerEntry::Database(index) 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; expanded = !expanded;
continue; continue;
@@ -379,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 => { Event::Paste(text) if show_form => {
let text = Zeroizing::new(text); let text = Zeroizing::new(text);
form.error = None; form.error = None;
@@ -393,12 +454,44 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
browser.paste_query(&text); 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(), 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) { fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
let width = screen let width = screen
.width .width
@@ -411,8 +504,8 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
Tab / Shift-Tab Cycle explorer, query, and rows/schema\n\ Tab / Shift-Tab Cycle explorer, query, and rows/schema\n\
Esc Return to explorer\n\ Esc Return to explorer\n\
c Add a configured database\n\ c Add a configured database\n\
q Quit outside the query editor\n\ q Quit outside query/cell editors\n\
Ctrl-C Quit from any surface\n\n\ Ctrl-C Quit from any surface (unsaved edits prompt)\n\n\
Explorer and rows/schema (Normal mode)\n\ Explorer and rows/schema (Normal mode)\n\
Up/Down, j/k Move selection\n\ Up/Down, j/k Move selection\n\
Home/End, g/G First / last item\n\ Home/End, g/G First / last item\n\
@@ -436,6 +529,19 @@ fn draw_help(frame: &mut Frame, screen: Rect, offset: &mut u16) {
Headerless Markdown has blank headings. CSV uses CRLF and quotes only fields\n\ Headerless Markdown has blank headings. CSV uses CRLF and quotes only fields\n\
containing commas, double quotes, or line breaks.\n\ containing commas, double quotes, or line breaks.\n\
Clipboard: macOS pbcopy; Windows PowerShell; Wayland wl-copy; X11 xclip.\n\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\ Query editor\n\
i/a/I/A Enter Insert mode (at/after/start/end)\n\ i/a/I/A Enter Insert mode (at/after/start/end)\n\
hjkl / arrows Move cursor in Normal mode\n\ hjkl / arrows Move cursor in Normal mode\n\
@@ -453,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\ Execute one statement: SELECT/CTE in RO; SQL writes in RW.\n\
Success commits atomically; failure rolls back. No transaction scripts.\n\ Success commits atomically; failure rolls back. No transaction scripts.\n\
Writes report affected rows; RETURNING shows up to 100 result rows.\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\ Connection / password windows\n\
Tab / Shift-Tab Change fields\n\ Tab / Shift-Tab Change fields\n\
Arrows Choose provider; Left/Right changes TLS, CA, or Access\n\ Arrows Choose provider; Left/Right changes TLS, CA, or Access\n\
@@ -493,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 { let badge = match access {
AccessMode::ReadOnly => "[RO]", AccessMode::ReadOnly => "[RO]",
AccessMode::ReadWrite => "[RW]", AccessMode::ReadWrite => "[RW]",
@@ -579,8 +691,12 @@ fn draw_bars(
}; };
let access_width = (access.len() as u16 + if access.is_empty() { 0 } else { 2 }) let access_width = (access.len() as u16 + if access.is_empty() { 0 } else { 2 })
.min(bottom.width.saturating_sub(position_width + mode_width)); .min(bottom.width.saturating_sub(position_width + mode_width));
let provider_width = (Span::raw(provider).width() as u16 + if access.is_empty() { 2 } else { 1 }) let provider_width =
.min(bottom.width.saturating_sub(position_width + mode_width + access_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([ let [mode, name_area, provider_area, access_area, cursor] = Layout::horizontal([
Constraint::Length(mode_width), Constraint::Length(mode_width),
Constraint::Min(0), Constraint::Min(0),
@@ -599,11 +715,13 @@ fn draw_bars(
); );
frame.render_widget(Paragraph::new(format!(" {name}")).style(base), name_area); frame.render_widget(Paragraph::new(format!(" {name}")).style(base), name_area);
frame.render_widget( frame.render_widget(
Paragraph::new(provider).alignment(if access.is_empty() { Paragraph::new(provider)
.alignment(if access.is_empty() {
ratatui::layout::Alignment::Center ratatui::layout::Alignment::Center
} else { } else {
ratatui::layout::Alignment::Right ratatui::layout::Alignment::Right
}).style(base), })
.style(base),
provider_area, provider_area,
); );
if !access.is_empty() { if !access.is_empty() {
@@ -612,7 +730,8 @@ fn draw_bars(
Span::raw(" "), Span::raw(" "),
Span::styled(access, badge), Span::styled(access, badge),
Span::raw(" "), Span::raw(" "),
])).style(base), ]))
.style(base),
access_area, access_area,
); );
} }
@@ -739,7 +858,9 @@ impl Profiles {
} }
}), }),
tls: form.tls, 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(), path: form.path.clone(),
error: None, error: None,
reconnect: false, reconnect: false,
@@ -866,7 +987,10 @@ impl Form {
if !choices.contains(&self.ca_bundle) { if !choices.contains(&self.ca_bundle) {
choices.push(self.ca_bundle.clone()); 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(); let next = (current + if forward { 1 } else { choices.len() - 1 }) % choices.len();
self.ca_bundle = choices.swap_remove(next); self.ca_bundle = choices.swap_remove(next);
if self.ca_bundle.is_some() { if self.ca_bundle.is_some() {
@@ -882,12 +1006,18 @@ impl Form {
} }
return Database::open_sqlite(Path::new(&self.path.value), self.access); 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> { fn postgres_config(&self) -> Result<Config> {
if self.ca_bundle.is_some() && self.tls != 0 { 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; let [host, port, database, user, password] = &self.postgres;
if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() { if host.value.is_empty() || database.value.is_empty() || user.value.is_empty() {
@@ -1028,7 +1158,10 @@ impl Form {
crate::db::display_text(&self.postgres[2].value), crate::db::display_text(&self.postgres[2].value),
crate::db::display_text(&self.postgres[3].value), crate::db::display_text(&self.postgres[3].value),
["Require", "Prefer", "Disable"][self.tls], ["Require", "Prefer", "Disable"][self.tls],
self.ca_bundle.as_ref().map_or_else(|| "System trust".into(), |path| crate::db::display_text(&path.to_string_lossy())), self.ca_bundle.as_ref().map_or_else(
|| "System trust".into(),
|path| crate::db::display_text(&path.to_string_lossy())
),
self.access.label(), self.access.label(),
) )
} }
@@ -1110,13 +1243,13 @@ impl Form {
Layout::horizontal([Constraint::Min(1), Constraint::Length(28)]).areas(name_area); Layout::horizontal([Constraint::Min(1), Constraint::Length(28)]).areas(name_area);
frame.render_widget( frame.render_widget(
Paragraph::new(self.access.label()).block( Paragraph::new(self.access.label()).block(
Block::bordered().title(" Access · ←/→ change ").border_style( Block::bordered()
Style::default().fg(if self.focus == self.access_focus() { .title(" Access · ←/→ change ")
.border_style(Style::default().fg(if self.focus == self.access_focus() {
Color::Cyan Color::Cyan
} else { } else {
Color::DarkGray Color::DarkGray
}), })),
),
), ),
access_area, access_area,
); );
@@ -1213,17 +1346,24 @@ impl Form {
); );
let ca_label = self.ca_bundle.as_ref().map_or_else( let ca_label = self.ca_bundle.as_ref().map_or_else(
|| "System trust".into(), || "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( frame.render_widget(
Paragraph::new(ca_label).block( Paragraph::new(ca_label).block(
Block::bordered().title(" CA bundle · ←/→ choose ").border_style( Block::bordered()
Style::default().fg(if self.focus == self.ca_focus() { .title(" CA bundle · ←/→ choose ")
.border_style(Style::default().fg(if self.focus == self.ca_focus() {
Color::Cyan Color::Cyan
} else { } else {
Color::DarkGray Color::DarkGray
}), })),
),
), ),
rows[3], rows[3],
); );
@@ -1285,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 { impl Input {
pub(crate) fn vim_motion(&mut self, motion: char) { pub(crate) fn vim_motion(&mut self, motion: char) {
let class = |character: char| { let class = |character: char| {
@@ -1479,6 +1628,11 @@ impl Input {
self.cursor += text.len(); 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( pub(crate) fn draw(
&self, &self,
frame: &mut Frame, frame: &mut Frame,
@@ -1492,7 +1646,14 @@ impl Input {
let text = if secret { let text = if secret {
Text::raw("•".repeat(self.value.chars().count())) Text::raw("•".repeat(self.value.chars().count()))
} else { } 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 line_count = text.lines.len().max(1);
let block = Block::bordered() let block = Block::bordered()
@@ -1521,7 +1682,10 @@ impl Input {
let before_cursor = if secret { let before_cursor = if secret {
self.value[..self.cursor].chars().count() self.value[..self.cursor].chars().count()
} else { } 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 cursor = u16::try_from(before_cursor).unwrap_or(u16::MAX);
let offset = cursor.saturating_sub(content.width.saturating_sub(1)); let offset = cursor.saturating_sub(content.width.saturating_sub(1));
@@ -1552,6 +1716,92 @@ impl Input {
mod tests { mod tests {
use super::*; 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] #[test]
fn legacy_profiles_default_to_read_only_and_access_edits_persist() -> Result<()> { fn legacy_profiles_default_to_read_only_and_access_edits_persist() -> Result<()> {
let directory = tempfile::tempdir()?; let directory = tempfile::tempdir()?;
@@ -1591,12 +1841,19 @@ mod tests {
let mut profiles = Profiles::open(&path)?; let mut profiles = Profiles::open(&path)?;
assert_eq!(profiles.entries.len(), 1); assert_eq!(profiles.entries.len(), 1);
assert_eq!(profiles.entries[0].access, AccessMode::ReadWrite); 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(); let mut form = profiles.entries[0].clone();
form.access = AccessMode::ReadOnly; form.access = AccessMode::ReadOnly;
profiles.save(&form)?; profiles.save(&form)?;
drop(profiles); drop(profiles);
assert_eq!(Profiles::open(&path)?.entries[0].access, AccessMode::ReadOnly); assert_eq!(
Profiles::open(&path)?.entries[0].access,
AccessMode::ReadOnly
);
Ok(()) Ok(())
} }
@@ -1622,9 +1879,20 @@ mod tests {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 6))?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(40, 6))?;
terminal.draw(|frame| { terminal.draw(|frame| {
let [top, _, bottom] = shell_areas(frame.area()); 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 footer: String = (0..40).map(|x| terminal.backend().buffer()[(x, 5)].symbol()).collect(); let footer: String = (0..40)
.map(|x| terminal.backend().buffer()[(x, 5)].symbol())
.collect();
assert!(footer.contains("readwrite")); assert!(footer.contains("readwrite"));
assert!(!footer.contains("readonly")); assert!(!footer.contains("readonly"));
Ok(()) Ok(())
@@ -1632,13 +1900,24 @@ mod tests {
#[test] #[test]
fn access_badge_sits_before_position_in_lowercase() -> Result<()> { fn access_badge_sits_before_position_in_lowercase() -> Result<()> {
for access in [Some(AccessMode::ReadOnly), Some(AccessMode::ReadWrite), None] { for access in [
let mut terminal = ratatui::Terminal::new( Some(AccessMode::ReadOnly),
ratatui::backend::TestBackend::new(80, 6), Some(AccessMode::ReadWrite),
)?; None,
] {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, 6))?;
terminal.draw(|frame| { terminal.draw(|frame| {
let [top, _, bottom] = shell_areas(frame.area()); let [top, _, bottom] = shell_areas(frame.area());
draw_bars(frame, top, bottom, "SQLite", "", access, false, Some((1, 1))); draw_bars(
frame,
top,
bottom,
"SQLite",
"",
access,
false,
Some((1, 1)),
);
})?; })?;
let buffer = terminal.backend().buffer(); let buffer = terminal.backend().buffer();
let footer: String = (0..80).map(|x| buffer[(x, 5)].symbol()).collect(); let footer: String = (0..80).map(|x| buffer[(x, 5)].symbol()).collect();
@@ -1702,8 +1981,14 @@ mod tests {
drop(profiles); drop(profiles);
std::fs::remove_file(&first)?; std::fs::remove_file(&first)?;
let profiles = Profiles::open(&config)?; let profiles = Profiles::open(&config)?;
assert_eq!(profiles.entries[0].ca_bundle.as_deref(), Some(first.as_path())); assert_eq!(
assert_eq!(profiles.entries[1].ca_bundle.as_deref(), Some(second.as_path())); 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[2].ca_bundle.is_none());
assert!(profiles.entries[0].connect().is_err()); assert!(profiles.entries[0].connect().is_err());
let mut missing = profiles.entries[0].clone(); let mut missing = profiles.entries[0].clone();
@@ -1827,7 +2112,13 @@ mod tests {
fn connected_marker_survives_long_wide_profile_labels() -> Result<()> { fn connected_marker_survives_long_wide_profile_labels() -> Result<()> {
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?; let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(32, 3))?;
terminal.draw(|frame| { terminal.draw(|frame| {
let label = database_label("界".repeat(40).as_str(), 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); assert_eq!(Line::raw(&label).width(), 30);
frame.render_widget( frame.render_widget(
List::new([ListItem::new(label)]).block(Block::bordered()), List::new([ListItem::new(label)]).block(Block::bordered()),
@@ -1835,20 +2126,29 @@ mod tests {
); );
})?; })?;
assert_eq!(terminal.backend().buffer()[(25, 1)].symbol(), "*"); 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]")); assert!(row.ends_with("* [RW]"));
let inactive = database_label("inactive", AccessMode::ReadOnly, false, false, 30); let inactive = database_label("inactive", AccessMode::ReadOnly, false, false, 30);
let active_badge_column = (1..31) let active_badge_column = (1..31)
.find(|&x| terminal.backend().buffer()[(x, 1)].symbol() == "[") .find(|&x| terminal.backend().buffer()[(x, 1)].symbol() == "[")
.unwrap() - 1; .unwrap()
- 1;
assert_eq!( assert_eq!(
active_badge_column as usize, active_badge_column as usize,
Line::raw(&inactive[..inactive.find("[RO]").unwrap()]).width() Line::raw(&inactive[..inactive.find("[RO]").unwrap()]).width()
); );
for width in 4..9 { 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(()) Ok(())
} }
@@ -1915,7 +2215,6 @@ mod tests {
Ok(()) Ok(())
} }
#[test] #[test]
fn shell_keeps_position_when_name_is_long_and_yields_on_short_screens() -> Result<()> { 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))?; 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 native_tls::TlsConnector;
use openssl::{ use openssl::{
@@ -66,6 +66,8 @@ pub struct Column {
pub nullable: bool, pub nullable: bool,
pub primary_key: bool, pub primary_key: bool,
pub default: String, pub default: String,
pub generated: bool,
write_type: String,
} }
pub struct Page { pub struct Page {
@@ -79,6 +81,99 @@ pub struct QueryResult {
pub affected_rows: Option<u64>, 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 { impl Database {
pub fn connect(connection: &str, root_cert: Option<&Path>, access: AccessMode) -> Result<Self> { pub fn connect(connection: &str, root_cert: Option<&Path>, access: AccessMode) -> Result<Self> {
Self::connect_postgres(connection.parse()?, root_cert, access) Self::connect_postgres(connection.parse()?, root_cert, access)
@@ -93,7 +188,8 @@ impl Database {
config.connect_timeout(Duration::from_secs(5)); config.connect_timeout(Duration::from_secs(5));
} }
let mut client = if let Some(path) = root_cert { 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)? config.connect(tls)?
} else { } else {
config.connect(MakeTlsConnector::new(TlsConnector::new()?))? config.connect(MakeTlsConnector::new(TlsConnector::new()?))?
@@ -197,7 +293,9 @@ impl Database {
EXISTS (SELECT FROM pg_catalog.pg_index i EXISTS (SELECT FROM pg_catalog.pg_index i
WHERE i.indrelid = a.attrelid AND i.indisprimary WHERE i.indrelid = a.attrelid AND i.indisprimary
AND a.attnum = ANY(i.indkey)), 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 FROM pg_catalog.pg_attribute a
LEFT JOIN pg_catalog.pg_attrdef d LEFT JOIN pg_catalog.pg_attrdef d
ON d.adrelid = a.attrelid AND d.adnum = a.attnum ON d.adrelid = a.attrelid AND d.adnum = a.attnum
@@ -213,9 +311,11 @@ impl Database {
label: display_text(&name), label: display_text(&name),
name, name,
data_type: display_text(row.get(1)), data_type: display_text(row.get(1)),
write_type: row.get(6),
nullable: row.get(2), nullable: row.get(2),
primary_key: row.get(3), primary_key: row.get(3),
default: display_text(row.get(4)), default: display_text(row.get(4)),
generated: row.get(5),
} }
}) })
.collect()) .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 { let client = match &mut self.backend {
Backend::Postgres(client) => client, Backend::Postgres(client) => client,
Backend::Sqlite(connection) => { Backend::Sqlite(connection) => {
return sqlite::page(connection, relation, columns, page); return sqlite::edit_page(connection, relation, columns, page, editable);
} }
}; };
let projection = columns let projection = columns
.iter() .iter()
.map(|column| format!("t.{}::text", quote_identifier(&column.name))) .map(|column| postgres_text(&format!("t.{}", quote_identifier(&column.name))))
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(", "); .join(", ");
let primary_key = columns let primary_key = columns
@@ -353,27 +459,168 @@ impl Database {
let offset = page_offset(page)?; let offset = page_offset(page)?;
let result = client.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?; let result = client.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?;
let has_next = result.len() > PAGE_SIZE; let has_next = result.len() > PAGE_SIZE;
let rows = result let rows: Vec<RowSnapshot> = result
.into_iter() .into_iter()
.take(PAGE_SIZE) .take(PAGE_SIZE)
.map(|row| { .map(|row| RowSnapshot {
(0..columns.len()) values: (0..columns.len())
.map(|index| row.get::<_, Option<&str>>(index).map(display_text)) .map(|index| {
.collect() row.get::<_, Option<&str>>(index)
.map_or(RawValue::Null, |text| {
RawValue::Text(text.as_bytes().to_vec())
})
})
.collect(),
}) })
.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> { fn postgres_tls(mode: SslMode, root_cert: &Path) -> Result<SslConnector> {
if mode != SslMode::Require { if mode != SslMode::Require {
return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into()); return Err("A CA bundle requires Require TLS (sslmode=require); plaintext fallback is not allowed.".into());
} }
let pem = std::fs::read(root_cert) let pem =
.map_err(|error| format!("Cannot read CA bundle: {error}"))?; std::fs::read(root_cert).map_err(|error| format!("Cannot read CA bundle: {error}"))?;
let certificates = X509::stack_from_pem(&pem) let certificates =
.map_err(|error| format!("Invalid PEM CA bundle: {error}"))?; X509::stack_from_pem(&pem).map_err(|error| format!("Invalid PEM CA bundle: {error}"))?;
if certificates.is_empty() { if certificates.is_empty() {
return Err("CA bundle must contain at least one PEM CA certificate.".into()); 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::AttachDuckDBDatabase { .. }
| Statement::DetachDuckDBDatabase { .. } | 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) Ok(statement)
} }
@@ -426,7 +676,9 @@ fn checked_statement(sql: &str, sqlite: bool) -> Result<Statement> {
fn statement_body(statement: &Statement) -> &Statement { fn statement_body(statement: &Statement) -> &Statement {
if let Statement::Query(query) = statement { if let Statement::Query(query) = statement {
match query.body.as_ref() { 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, | SetExpr::Merge(body) => return body,
_ => {} _ => {}
} }
@@ -440,6 +692,13 @@ fn page_offset(page: usize) -> Result<i64> {
.ok_or_else(|| "page offset overflow".into()) .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 { fn quote_identifier(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\"")) format!("\"{}\"", value.replace('"', "\"\""))
} }
@@ -461,6 +720,229 @@ pub fn display_text(value: &str) -> String {
mod tests { mod tests {
use super::*; 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] #[test]
fn private_ca_tls_accepts_absent_eku_but_rejects_wrong_usage_host_and_expiry() -> Result<()> { fn private_ca_tls_accepts_absent_eku_but_rejects_wrong_usage_host_and_expiry() -> Result<()> {
use openssl::{ use openssl::{
@@ -470,7 +952,10 @@ mod tests {
pkey::PKey, pkey::PKey,
rsa::Rsa, rsa::Rsa,
ssl::SslAcceptor, ssl::SslAcceptor,
x509::{X509NameBuilder, extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName}}, x509::{
X509NameBuilder,
extension::{BasicConstraints, ExtendedKeyUsage, SubjectAlternativeName},
},
}; };
use std::net::{TcpListener, TcpStream}; use std::net::{TcpListener, TcpStream};
@@ -516,7 +1001,8 @@ mod tests {
leaf.set_not_before(&before)?; leaf.set_not_before(&before)?;
leaf.set_not_after(&after)?; leaf.set_not_after(&after)?;
leaf.append_extension(BasicConstraints::new().critical().build()?)?; 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))?; .build(&leaf.x509v3_context(Some(&ca), None))?;
leaf.append_extension(san)?; leaf.append_extension(san)?;
if client_only { if client_only {
@@ -533,8 +1019,12 @@ mod tests {
socket.set_write_timeout(Some(Duration::from_secs(3)))?; socket.set_write_timeout(Some(Duration::from_secs(3)))?;
let server = std::thread::spawn(move || { let server = std::thread::spawn(move || {
let (socket, _) = listener.accept().unwrap(); let (socket, _) = listener.accept().unwrap();
socket.set_read_timeout(Some(Duration::from_secs(3))).unwrap(); socket
socket.set_write_timeout(Some(Duration::from_secs(3))).unwrap(); .set_read_timeout(Some(Duration::from_secs(3)))
.unwrap();
socket
.set_write_timeout(Some(Duration::from_secs(3)))
.unwrap();
let _ = acceptor.accept(socket); let _ = acceptor.accept(socket);
}); });
let connector = postgres_tls(SslMode::Require, bundle.path())?; let connector = postgres_tls(SslMode::Require, bundle.path())?;
@@ -542,8 +1032,11 @@ mod tests {
configuration.param_mut().set_time(clock); configuration.param_mut().set_time(clock);
let result = configuration.connect(hostname, socket); let result = configuration.connect(hostname, socket);
server.join().unwrap(); server.join().unwrap();
assert_eq!(result.is_ok(), accepted, assert_eq!(
"client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}"); result.is_ok(),
accepted,
"client_only={client_only}, hostname={hostname}, clock={clock}: {result:?}"
);
} }
Ok(()) Ok(())
} }
@@ -554,7 +1047,11 @@ mod tests {
for mode in [SslMode::Prefer, SslMode::Disable] { for mode in [SslMode::Prefer, SslMode::Disable] {
assert!(postgres_tls(mode, bundle.path()).is_err()); 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)?; std::fs::write(bundle.path(), contents)?;
assert!(postgres_tls(SslMode::Require, bundle.path()).is_err()); assert!(postgres_tls(SslMode::Require, bundle.path()).is_err());
} }
@@ -584,7 +1081,8 @@ mod tests {
#[test] #[test]
#[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"] #[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"]
fn 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 connection = std::env::var("DBDASH_TEST_DATABASE_URL")?;
let fixture = connection let fixture = connection
.parse::<Config>()? .parse::<Config>()?
@@ -593,16 +1091,24 @@ mod tests {
struct Cleanup(Client, String); struct Cleanup(Client, String);
impl Drop for Cleanup { impl Drop for Cleanup {
fn drop(&mut self) { 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 _cleanup = Cleanup(fixture, schema.clone());
let mut db = Database::connect(&connection, None, AccessMode::ReadWrite)?; let mut db = Database::connect(&connection, None, AccessMode::ReadWrite)?;
assert_eq!(db.access_mode(), 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')"))?; db.query(&format!("CREATE TYPE {schema}.mood AS ENUM ('ok')"))?;
assert_eq!( 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) Some(0)
); );
let inserted = db.query(&format!( let inserted = db.query(&format!(
@@ -618,14 +1124,21 @@ mod tests {
assert!(inserted.page.has_next); assert!(inserted.page.has_next);
assert_eq!( assert_eq!(
inserted.headers, inserted.headers,
["id", "amount", "details", "tags", "bytes", "happened", "mood", "missing", "note"] [
"id", "amount", "details", "tags", "bytes", "happened", "mood", "missing", "note"
]
); );
assert_eq!( assert_eq!(
inserted.page.rows[0], inserted.page.rows[0],
vec![ vec![
Some("1".into()), Some("1.25".into()), Some("{\"ok\": true}".into()), Some("1".into()),
Some("{a,b}".into()), Some("\\x00ff".into()), Some("1.25".into()),
Some("2020-01-01 12:00:00".into()), Some("ok".into()), None, 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()) Some("semi;colon".into())
] ]
); );
@@ -633,32 +1146,68 @@ mod tests {
for sql in [ for sql in [
format!("UPDATE {schema}.saved SET value = value + 1000; DELETE FROM {schema}.saved"), format!("UPDATE {schema}.saved SET value = value + 1000; DELETE FROM {schema}.saved"),
format!("INSERT INTO {schema}.saved VALUES (151, 151); SELECT 1"), 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"), format!(
"BEGIN".into(), "COMMIT".into(), "END".into(), "ROLLBACK".into(), "UPDATE {schema}.saved SET value = CASE WHEN id = 150 THEN 1001 ELSE value + 1000 END"
"SAVEPOINT edit".into(), "RELEASE edit".into(), "SET TRANSACTION READ ONLY".into(), ),
"PREPARE escape AS COMMIT".into(), "EXECUTE escape".into(), "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!( 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())]] [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.affected_rows, Some(150));
assert_eq!(updated.page.rows.len(), PAGE_SIZE); assert_eq!(updated.page.rows.len(), PAGE_SIZE);
assert!(updated.page.has_next); assert!(updated.page.has_next);
assert_eq!(db.query(&format!("DELETE FROM {schema}.saved WHERE id > 140"))?.affected_rows, Some(10)); assert_eq!(
assert_eq!(db.query(&format!("DELETE FROM {schema}.saved WHERE id > 140"))?.affected_rows, Some(0)); db.query(&format!("DELETE FROM {schema}.saved WHERE id > 140"))?
assert_eq!(db.query(&format!("CREATE INDEX saved_value ON {schema}.saved(value)"))?.affected_rows, Some(0)); .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); drop(db);
let mut reopened = Database::connect(&connection, None, AccessMode::ReadOnly)?; let mut reopened = Database::connect(&connection, None, AccessMode::ReadOnly)?;
assert_eq!( 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())]] [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(()) Ok(())
} }
@@ -712,7 +1261,7 @@ mod tests {
assert!(columns[1].nullable); assert!(columns[1].nullable);
assert_eq!(columns[2].data_type, "numeric"); assert_eq!(columns[2].data_type, "numeric");
assert_eq!(columns[2].default, "1.25"); 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!(first.has_next);
assert_eq!(first.rows.len(), 100); assert_eq!(first.rows.len(), 100);
assert_eq!( assert_eq!(
@@ -729,7 +1278,7 @@ mod tests {
); );
assert_eq!(first.rows[1][1].as_deref(), Some("line\\nnext")); assert_eq!(first.rows[1][1].as_deref(), Some("line\\nnext"));
assert_eq!(first.rows[2][1].as_deref(), Some("NULL")); 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!(!second.has_next);
assert_eq!(second.rows[0][0].as_deref(), Some("101")); assert_eq!(second.rows[0][0].as_deref(), Some("101"));
let empty = relations let empty = relations
@@ -737,7 +1286,7 @@ mod tests {
.find(|r| r.schema == schema && r.name == "empty_table") .find(|r| r.schema == schema && r.name == "empty_table")
.unwrap(); .unwrap();
let columns = db.columns(empty)?; 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.rows.is_empty());
assert!(!page.has_next); assert!(!page.has_next);
let query = db.query(&format!( 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 sqlparser::ast::Statement;
use super::{ use super::{
AccessMode, Column, PAGE_SIZE, Page, QueryResult, Relation, checked_statement, display_text, AccessMode, Column, EditPage, PAGE_SIZE, Page, QueryResult, RawValue, Relation, RowEdit,
page_offset, quote_identifier, statement_body, RowSnapshot, blob_text, checked_statement, display_text, page_offset, quote_identifier,
statement_body,
}; };
use crate::Result; 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>> { pub(super) fn relations(connection: &Connection) -> Result<Vec<Relation>> {
let mut statement = connection.prepare( 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 FROM main.sqlite_schema s
JOIN pragma_table_list t ON t.schema = 'main' AND t.name = s.name 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_*' 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}")), label: display_text(&format!("main.{name}")),
name, name,
physical_order: kind == "table" && !row.get::<_, bool>(3)?, 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<_>>>()?; .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, "SELECT name, type, \"notnull\", dflt_value, pk,
pk > 0 AND upper(type) = 'INTEGER' AND NOT EXISTS ( pk > 0 AND upper(type) = 'INTEGER' AND NOT EXISTS (
SELECT 1 FROM pragma_index_list(?1, 'main') WHERE origin = 'pk' SELECT 1 FROM pragma_index_list(?1, 'main') WHERE origin = 'pk'
) AS rowid_alias ) AS rowid_alias, hidden
FROM pragma_table_xinfo(?1, 'main') FROM pragma_table_xinfo(?1, 'main')
WHERE hidden <> 1 ORDER BY cid", WHERE hidden <> 1 ORDER BY cid",
)?; )?;
@@ -76,18 +84,21 @@ pub(super) fn columns(connection: &Connection, relation: &Relation) -> Result<Ve
.map(display_text) .map(display_text)
.unwrap_or_default(), .unwrap_or_default(),
primary_key: row.get::<_, i64>(4)? > 0, primary_key: row.get::<_, i64>(4)? > 0,
generated: row.get::<_, i64>(6)? != 0,
write_type: String::new(),
}) })
})? })?
.collect::<rusqlite::Result<Vec<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
Ok(columns) Ok(columns)
} }
pub(super) fn page( pub(super) fn edit_page(
connection: &Connection, connection: &Connection,
relation: &Relation, relation: &Relation,
columns: &[Column], columns: &[Column],
page: usize, page: usize,
) -> Result<Page> { editable: bool,
) -> Result<EditPage> {
let projection = columns let projection = columns
.iter() .iter()
.map(|column| format!("t.{}", quote_identifier(&column.name))) .map(|column| format!("t.{}", quote_identifier(&column.name)))
@@ -119,7 +130,27 @@ pub(super) fn page(
); );
let mut statement = connection.prepare(&sql)?; let mut statement = connection.prepare(&sql)?;
let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?; let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?;
read_page(&mut query, columns.len(), 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> { 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 transaction = connection.unchecked_transaction()?;
let (headers, page) = { let (headers, page) = {
let mut statement = transaction.prepare(sql)?; 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 column_count = statement.column_count();
let mut query = statement.query([])?; let mut query = statement.query([])?;
let page = read_page(&mut query, column_count, true)?; 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 body = statement_body(&parsed);
let affected_rows = if matches!(body, Statement::Query(_)) { let affected_rows = if matches!(body, Statement::Query(_)) {
None None
} else if matches!(body, Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_)) { } else if matches!(
body,
Statement::Insert(_) | Statement::Update(_) | Statement::Delete(_)
) {
Some(transaction.changes()) Some(transaction.changes())
} else { } else {
Some(0) Some(0)
}; };
transaction.commit()?; transaction.commit()?;
return Ok(QueryResult { headers, page, affected_rows }); return Ok(QueryResult {
headers,
page,
affected_rows,
});
} }
let original = connection.prepare(sql)?; let original = connection.prepare(sql)?;
let headers = original 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 column_count = statement.column_count();
let page = read_page(&mut statement.query([])?, column_count, false)?; 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> { 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::Null => None,
ValueRef::Integer(value) => Some(value.to_string()), ValueRef::Integer(value) => Some(value.to_string()),
ValueRef::Real(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), 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<_>>>()?; .collect::<rusqlite::Result<Vec<_>>>()?;
rows.push(values); rows.push(values);
} }
Ok(Page { Ok(Page { rows, has_next })
rows,
has_next,
})
} }
fn blob_text(bytes: &[u8]) -> String { fn raw_value(value: ValueRef<'_>) -> RawValue {
let mut output = String::with_capacity(bytes.len() * 2 + 3); match value {
output.push_str("X'"); ValueRef::Null => RawValue::Null,
for byte in bytes { ValueRef::Integer(value) => RawValue::Integer(value),
write!(output, "{byte:02X}").expect("writing to a String cannot fail"); 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)] #[cfg(test)]
@@ -208,6 +311,214 @@ mod tests {
use super::*; use super::*;
use crate::db::{Backend, Database}; 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] #[test]
fn query_sqlite_read_only() -> Result<()> { fn query_sqlite_read_only() -> Result<()> {
let directory = tempfile::tempdir()?; let directory = tempfile::tempdir()?;
@@ -278,7 +589,8 @@ break\", 2.5 AS amount; \n",
let mut db = Database::open_sqlite(&path, AccessMode::ReadWrite)?; let mut db = Database::open_sqlite(&path, AccessMode::ReadWrite)?;
assert_eq!(db.access_mode(), AccessMode::ReadWrite); assert_eq!(db.access_mode(), AccessMode::ReadWrite);
assert_eq!( 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) Some(0)
); );
let inserted = db.query( let inserted = db.query(
@@ -299,27 +611,47 @@ break\", 2.5 AS amount; \n",
for sql in [ for sql in [
"UPDATE saved SET value = value + 1000; DELETE FROM saved", "UPDATE saved SET value = value + 1000; DELETE FROM saved",
"INSERT INTO saved VALUES (151, 151); SELECT 1", "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", "PRAGMA query_only = OFF",
] { ] {
assert!(db.query(sql).is_err(), "accepted control or batch: {sql}"); assert!(db.query(sql).is_err(), "accepted control or batch: {sql}");
} }
assert_eq!( 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())]] [vec![Some("150".into()), Some("150".into())]]
); );
let updated = db.query("UPDATE saved SET value = value + 1000 RETURNING value")?; let updated = db.query("UPDATE saved SET value = value + 1000 RETURNING value")?;
assert_eq!(updated.affected_rows, Some(150)); assert_eq!(updated.affected_rows, Some(150));
assert_eq!(updated.page.rows.len(), PAGE_SIZE); assert_eq!(updated.page.rows.len(), PAGE_SIZE);
assert!(updated.page.has_next); assert!(updated.page.has_next);
assert_eq!(db.query("DELETE FROM saved WHERE id > 140")?.affected_rows, Some(10)); assert_eq!(
assert_eq!(db.query("DELETE FROM saved WHERE id > 140")?.affected_rows, Some(0)); db.query("DELETE FROM saved WHERE id > 140")?.affected_rows,
assert_eq!(db.query("CREATE INDEX saved_value ON saved(value)")?.affected_rows, Some(0)); 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); drop(db);
let mut reopened = Database::open_sqlite(&path, AccessMode::ReadOnly)?; let mut reopened = Database::open_sqlite(&path, AccessMode::ReadOnly)?;
assert_eq!( 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())]] [vec![Some("140".into()), Some("1140".into())]]
); );
assert!(reopened.query("DELETE FROM saved").is_err()); assert!(reopened.query("DELETE FROM saved").is_err());
@@ -373,7 +705,7 @@ break\", 2.5 AS amount; \n",
assert!(!columns[2].nullable); assert!(!columns[2].nullable);
assert_eq!(columns[2].default, "1.25"); assert_eq!(columns[2].default, "1.25");
assert_eq!(columns[5].name, "generated"); 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!(first.has_next);
assert_eq!(first.rows.len(), 100); assert_eq!(first.rows.len(), 100);
assert_eq!( 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[1][4].as_deref(), Some("2.5"));
assert_eq!(first.rows[2][4].as_deref(), Some("X'0080'")); assert_eq!(first.rows[2][4].as_deref(), Some("X'0080'"));
assert_eq!(first.rows[3][4].as_deref(), Some("\\xff")); 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!(!second.has_next);
assert_eq!( assert_eq!(
second.rows, second.rows,
@@ -412,7 +744,7 @@ break\", 2.5 AS amount; \n",
assert_eq!(view.kind, "view"); assert_eq!(view.kind, "view");
let view_columns = db.columns(view)?; let view_columns = db.columns(view)?;
assert_eq!( 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") Some("101")
); );
let empty = relations let empty = relations
@@ -420,7 +752,7 @@ break\", 2.5 AS amount; \n",
.find(|relation| relation.name == "empty_table") .find(|relation| relation.name == "empty_table")
.unwrap(); .unwrap();
let empty_columns = db.columns(empty)?; 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.rows.is_empty());
assert!(!empty_page.has_next); assert!(!empty_page.has_next);
let keyed = relations let keyed = relations
@@ -430,7 +762,7 @@ break\", 2.5 AS amount; \n",
let keyed_columns = db.columns(keyed)?; let keyed_columns = db.columns(keyed)?;
assert!(!keyed_columns[0].nullable); assert!(!keyed_columns[0].nullable);
assert_eq!( assert_eq!(
db.page(keyed, &keyed_columns, 0)?.rows, db.edit_page(keyed, &keyed_columns, 0)?.page.rows,
vec![ vec![
vec![Some("a".into()), Some("1".into())], vec![Some("a".into()), Some("1".into())],
vec![Some("b".into()), Some("2".into())] vec![Some("b".into()), Some("2".into())]
@@ -446,7 +778,7 @@ break\", 2.5 AS amount; \n",
.find(|relation| relation.name == "keyless") .find(|relation| relation.name == "keyless")
.unwrap(); .unwrap();
let keyless_columns = db.columns(keyless)?; 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!(!page.has_next);
assert_eq!(page.rows.len(), 100); assert_eq!(page.rows.len(), 100);
assert_eq!(page.rows[0][2].as_deref(), Some("100")); assert_eq!(page.rows[0][2].as_deref(), Some("100"));
+25 -5
View File
@@ -12,7 +12,7 @@ use crossterm::event::{
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>; type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
const HELP: &str = r#"dbdash — 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: Usage:
dbdash [--read-write] [POSTGRES_CONNECTION_STRING] dbdash [--read-write] [POSTGRES_CONNECTION_STRING]
@@ -114,17 +114,37 @@ Navigation:
Connection/password fields remain direct-entry; query paste requires INSERT. Connection/password fields remain direct-entry; query paste requires INSERT.
SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect, SQL syntax colors follow the connected database's PostgreSQL/SQLite dialect,
including quoted identifiers, literals, and multiline comments/strings as you type. including quoted identifiers, literals, and multiline comments/strings as you type.
F5 / Ctrl-Enter executes one 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. 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. READ ONLY accepts SELECT/CTE reads; READ WRITE also supports SQL writes.
Each successful statement commits atomically; failures roll back. Explicit transaction Each successful statement commits atomically; failures roll back. Explicit transaction
control and multi-statement scripts are rejected. Writes report affected-row counts; control and multi-statement scripts are rejected. Writes report affected-row counts;
RETURNING displays result rows (up to 100) without limiting the rows written. 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. Rows in READ WRITE: e / Enter edits the selected cell; Enter stages, Esc cancels
Ctrl-Enter needs terminal support for enhanced keyboard reporting; use F5 otherwise. 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 Failed queries keep the previous results. Opening a relation restores browsing
without clearing the query text. Query text is not saved across connections. without clearing the query text. Query text is not saved across connections.
q / Ctrl-C Quit outside the query editor (q inserts text only in INSERT) q 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. 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. Tab/Shift-Tab changes fields; Left/Right changes TLS, CA bundle, or Access when focused.
Enter saves without connecting, including when the database is offline. Enter saves without connecting, including when the database is offline.