feat: stage and atomically apply row edits
This commit is contained in:
+1
-1
@@ -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"] }
|
||||||
|
|||||||
@@ -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
@@ -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 | <&>\\\\tail |\n")
|
Some(
|
||||||
|
"| b | c |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\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 | <&>\\\\tail |\n")
|
Some(
|
||||||
|
"| | |\n| --- | --- |\n| a\\|b | say \"hi\",界 |\n| NULL | <&>\\\\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
@@ -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))?;
|
||||||
|
|||||||
@@ -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(¤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> {
|
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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user