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