feat: add query editor above result rows
This commit is contained in:
+134
-24
@@ -1,4 +1,4 @@
|
|||||||
use crossterm::event::{KeyCode, KeyEvent};
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
Frame,
|
Frame,
|
||||||
layout::{Constraint, Layout, Rect},
|
layout::{Constraint, Layout, Rect},
|
||||||
@@ -9,10 +9,18 @@ use ratatui::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
Result,
|
Result,
|
||||||
db::{Column, Database, PAGE_SIZE, Page, Relation},
|
connection::Input,
|
||||||
|
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation},
|
||||||
error_text,
|
error_text,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub(crate) enum Focus {
|
||||||
|
Explorer,
|
||||||
|
Query,
|
||||||
|
Rows,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
db: Database,
|
db: Database,
|
||||||
pub(crate) relations: Vec<Relation>,
|
pub(crate) relations: Vec<Relation>,
|
||||||
@@ -24,6 +32,8 @@ pub struct App {
|
|||||||
schema_state: TableState,
|
schema_state: TableState,
|
||||||
column_offset: usize,
|
column_offset: usize,
|
||||||
show_schema: bool,
|
show_schema: bool,
|
||||||
|
query: Input,
|
||||||
|
query_result: Option<QueryResult>,
|
||||||
error: Option<String>,
|
error: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,6 +55,8 @@ impl App {
|
|||||||
schema_state: TableState::default(),
|
schema_state: TableState::default(),
|
||||||
column_offset: 0,
|
column_offset: 0,
|
||||||
show_schema: false,
|
show_schema: false,
|
||||||
|
query: Input::default(),
|
||||||
|
query_result: None,
|
||||||
error: None,
|
error: None,
|
||||||
};
|
};
|
||||||
app.open_relation(selected);
|
app.open_relation(selected);
|
||||||
@@ -57,7 +69,7 @@ impl App {
|
|||||||
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
|
||||||
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
|
||||||
KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX),
|
KeyCode::End | KeyCode::Char('G') => self.move_selection(isize::MAX),
|
||||||
KeyCode::Char('s') => {
|
KeyCode::Char('s') if self.query_result.is_none() => {
|
||||||
self.show_schema = !self.show_schema;
|
self.show_schema = !self.show_schema;
|
||||||
self.column_offset = 0;
|
self.column_offset = 0;
|
||||||
}
|
}
|
||||||
@@ -68,16 +80,22 @@ impl App {
|
|||||||
let count = if self.show_schema {
|
let count = if self.show_schema {
|
||||||
5
|
5
|
||||||
} else {
|
} else {
|
||||||
self.columns.len()
|
self.query_result
|
||||||
|
.as_ref()
|
||||||
|
.map_or(self.columns.len(), |result| result.headers.len())
|
||||||
};
|
};
|
||||||
if self.column_offset + 1 < count {
|
if self.column_offset + 1 < count {
|
||||||
self.column_offset += 1;
|
self.column_offset += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('n') | KeyCode::PageDown if !self.show_schema && self.page.has_next => {
|
KeyCode::Char('n') | KeyCode::PageDown
|
||||||
|
if self.query_result.is_none() && !self.show_schema && self.page.has_next =>
|
||||||
|
{
|
||||||
self.load_page(self.page_index + 1);
|
self.load_page(self.page_index + 1);
|
||||||
}
|
}
|
||||||
KeyCode::Char('p') | KeyCode::PageUp if !self.show_schema && self.page_index > 0 => {
|
KeyCode::Char('p') | KeyCode::PageUp
|
||||||
|
if self.query_result.is_none() && !self.show_schema && self.page_index > 0 =>
|
||||||
|
{
|
||||||
self.load_page(self.page_index - 1);
|
self.load_page(self.page_index - 1);
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') => self.refresh(),
|
KeyCode::Char('r') => self.refresh(),
|
||||||
@@ -85,18 +103,50 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn handle_query_key(&mut self, key: KeyEvent) {
|
||||||
|
match key.code {
|
||||||
|
KeyCode::F(5) => self.execute_query(),
|
||||||
|
KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => self.execute_query(),
|
||||||
|
KeyCode::Enter => self.query.paste_multiline("\n"),
|
||||||
|
_ => self.query.edit(key),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn paste_query(&mut self, text: &str) {
|
||||||
|
self.query.paste_multiline(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_query(&mut self) {
|
||||||
|
match self.db.query(&self.query.value) {
|
||||||
|
Ok(result) => {
|
||||||
|
self.rows_state = TableState::default()
|
||||||
|
.with_selected((!result.page.rows.is_empty()).then_some(0));
|
||||||
|
self.column_offset = 0;
|
||||||
|
self.show_schema = false;
|
||||||
|
self.query_result = Some(result);
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(error) => self.error = Some(error_text(error.as_ref())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn move_selection(&mut self, delta: isize) {
|
fn move_selection(&mut self, delta: isize) {
|
||||||
if self.show_schema {
|
if self.show_schema {
|
||||||
let next = moved_index(self.schema_state.selected(), self.columns.len(), delta);
|
let next = moved_index(self.schema_state.selected(), self.columns.len(), delta);
|
||||||
self.schema_state.select(next);
|
self.schema_state.select(next);
|
||||||
} else {
|
} else {
|
||||||
let next = moved_index(self.rows_state.selected(), self.page.rows.len(), delta);
|
let count = self
|
||||||
|
.query_result
|
||||||
|
.as_ref()
|
||||||
|
.map_or(self.page.rows.len(), |result| result.page.rows.len());
|
||||||
|
let next = moved_index(self.rows_state.selected(), count, delta);
|
||||||
self.rows_state.select(next);
|
self.rows_state.select(next);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
|
||||||
self.active = index;
|
self.active = index;
|
||||||
|
self.query_result = None;
|
||||||
self.columns.clear();
|
self.columns.clear();
|
||||||
self.page = Page {
|
self.page = Page {
|
||||||
rows: Vec::new(),
|
rows: Vec::new(),
|
||||||
@@ -151,10 +201,10 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focused: bool) {
|
pub(crate) fn draw(&mut self, frame: &mut Frame, area: Rect, focus: Focus) {
|
||||||
if area.width < 38 || area.height < 10 {
|
if area.width < 38 || area.height < 10 {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("Enlarge terminal to at least 70×12. q to quit."),
|
Paragraph::new("Enlarge terminal to at least 70×14. Ctrl-C to quit."),
|
||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -174,8 +224,18 @@ impl App {
|
|||||||
.style(Style::default().fg(Color::Cyan)),
|
.style(Style::default().fg(Color::Cyan)),
|
||||||
header,
|
header,
|
||||||
);
|
);
|
||||||
let content = body;
|
let [query, content] =
|
||||||
let title = if self.show_schema {
|
Layout::vertical([Constraint::Ratio(1, 2), Constraint::Min(4)]).areas(body);
|
||||||
|
self.query.draw(
|
||||||
|
frame,
|
||||||
|
query,
|
||||||
|
" Query · F5 execute ",
|
||||||
|
false,
|
||||||
|
focus == Focus::Query,
|
||||||
|
);
|
||||||
|
let title = if self.query_result.is_some() {
|
||||||
|
" Results "
|
||||||
|
} else if self.show_schema {
|
||||||
" Schema · s for rows "
|
" Schema · s for rows "
|
||||||
} else {
|
} else {
|
||||||
" Rows · s for schema "
|
" Rows · s for schema "
|
||||||
@@ -183,7 +243,9 @@ impl App {
|
|||||||
let count = if self.show_schema {
|
let count = if self.show_schema {
|
||||||
5
|
5
|
||||||
} else {
|
} else {
|
||||||
self.columns.len()
|
self.query_result
|
||||||
|
.as_ref()
|
||||||
|
.map_or(self.columns.len(), |result| result.headers.len())
|
||||||
};
|
};
|
||||||
let visible = usize::from(content.width.saturating_sub(4) / 21)
|
let visible = usize::from(content.width.saturating_sub(4) / 21)
|
||||||
.max(1)
|
.max(1)
|
||||||
@@ -195,7 +257,20 @@ impl App {
|
|||||||
Constraint::Length(20)
|
Constraint::Length(20)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
let description = if let Some(index) = self.active {
|
let description = if let Some(result) = &self.query_result {
|
||||||
|
format!(
|
||||||
|
" Query · {} rows{} · columns {}–{}/{}",
|
||||||
|
result.page.rows.len(),
|
||||||
|
if result.page.has_next {
|
||||||
|
" (first 100)"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
},
|
||||||
|
if count > 0 { self.column_offset + 1 } else { 0 },
|
||||||
|
self.column_offset + visible,
|
||||||
|
count,
|
||||||
|
)
|
||||||
|
} else if let Some(index) = self.active {
|
||||||
let relation = &self.relations[index];
|
let relation = &self.relations[index];
|
||||||
if self.show_schema {
|
if self.show_schema {
|
||||||
format!(
|
format!(
|
||||||
@@ -228,8 +303,12 @@ impl App {
|
|||||||
" No readable relations. r to refresh.".into()
|
" No readable relations. r to refresh.".into()
|
||||||
};
|
};
|
||||||
let content_block =
|
let content_block =
|
||||||
panel(title, focused).title_bottom(Line::from(description).left_aligned());
|
panel(title, focus == Focus::Rows).title_bottom(Line::from(description).left_aligned());
|
||||||
if self.active.is_none() {
|
let page = self
|
||||||
|
.query_result
|
||||||
|
.as_ref()
|
||||||
|
.map_or(&self.page, |result| &result.page);
|
||||||
|
if self.active.is_none() && self.query_result.is_none() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new("Choose a relation and press Enter.").block(content_block),
|
Paragraph::new("Choose a relation and press Enter.").block(content_block),
|
||||||
content,
|
content,
|
||||||
@@ -259,7 +338,7 @@ impl App {
|
|||||||
.row_highlight_style(selected_style())
|
.row_highlight_style(selected_style())
|
||||||
.highlight_symbol("› ");
|
.highlight_symbol("› ");
|
||||||
frame.render_stateful_widget(table, content, &mut self.schema_state);
|
frame.render_stateful_widget(table, content, &mut self.schema_state);
|
||||||
} else if self.page.rows.is_empty() {
|
} else if page.rows.is_empty() {
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(if self.error.is_some() {
|
Paragraph::new(if self.error.is_some() {
|
||||||
"Could not load rows. See error above."
|
"Could not load rows. See error above."
|
||||||
@@ -270,13 +349,13 @@ impl App {
|
|||||||
content,
|
content,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
let headers = self
|
let headers = (self.column_offset..self.column_offset + visible).map(|index| {
|
||||||
.columns
|
self.query_result.as_ref().map_or_else(
|
||||||
.iter()
|
|| self.columns[index].label.as_str(),
|
||||||
.skip(self.column_offset)
|
|result| result.headers[index].as_str(),
|
||||||
.take(visible)
|
)
|
||||||
.map(|column| column.label.as_str());
|
});
|
||||||
let rows = self.page.rows.iter().map(|row| {
|
let rows = page.rows.iter().map(|row| {
|
||||||
Row::new(
|
Row::new(
|
||||||
row.iter()
|
row.iter()
|
||||||
.skip(self.column_offset)
|
.skip(self.column_offset)
|
||||||
@@ -353,6 +432,37 @@ mod tests {
|
|||||||
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
|
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn queries_replace_rows_and_preserve_results_on_error() -> crate::Result<()> {
|
||||||
|
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
let file = tempfile::NamedTempFile::new()?;
|
||||||
|
rusqlite::Connection::open(file.path())?.execute_batch(
|
||||||
|
"CREATE TABLE comments (id INTEGER); INSERT INTO comments VALUES (9);",
|
||||||
|
)?;
|
||||||
|
let mut app = super::App::new(crate::db::Database::open_sqlite(file.path())?)?;
|
||||||
|
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));
|
||||||
|
let result = app.query_result.as_ref().unwrap();
|
||||||
|
assert_eq!(result.headers, ["note", "missing"]);
|
||||||
|
assert_eq!(result.page.rows[0], [Some("界é".into()), None]);
|
||||||
|
app.handle_key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||||
|
assert_eq!(app.rows_state.selected(), Some(1));
|
||||||
|
app.handle_query_key(KeyEvent::new(KeyCode::Char('u'), KeyModifiers::CONTROL));
|
||||||
|
app.paste_query("SELECT missing FROM nowhere");
|
||||||
|
app.handle_query_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL));
|
||||||
|
assert!(app.error.is_some());
|
||||||
|
assert_eq!(
|
||||||
|
app.query_result.as_ref().unwrap().page.rows[0][0].as_deref(),
|
||||||
|
Some("界é")
|
||||||
|
);
|
||||||
|
app.open_relation(Some(0));
|
||||||
|
assert!(app.query_result.is_none());
|
||||||
|
assert!(app.error.is_none());
|
||||||
|
assert_eq!(app.page.rows, [vec![Some("9".into())]]);
|
||||||
|
assert_eq!(app.query.value, "SELECT missing FROM nowhere");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn relation_status_is_in_the_bottom_border() -> crate::Result<()> {
|
fn relation_status_is_in_the_bottom_border() -> crate::Result<()> {
|
||||||
let file = tempfile::NamedTempFile::new()?;
|
let file = tempfile::NamedTempFile::new()?;
|
||||||
@@ -364,7 +474,7 @@ mod tests {
|
|||||||
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;
|
||||||
app.error = error.map(str::to_owned);
|
app.error = error.map(str::to_owned);
|
||||||
terminal.draw(|frame| app.draw(frame, frame.area(), true))?;
|
terminal.draw(|frame| app.draw(frame, frame.area(), super::Focus::Rows))?;
|
||||||
let buffer = terminal.backend().buffer();
|
let buffer = terminal.backend().buffer();
|
||||||
let border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect();
|
let border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect();
|
||||||
assert!(border.starts_with("└ main.comments [table]"), "{border}");
|
assert!(border.starts_with("└ main.comments [table]"), "{border}");
|
||||||
|
|||||||
+124
-35
@@ -13,14 +13,19 @@ use ratatui::{
|
|||||||
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{Result, app::App, db::Database, error_text};
|
use crate::{
|
||||||
|
Result,
|
||||||
|
app::{App, Focus},
|
||||||
|
db::Database,
|
||||||
|
error_text,
|
||||||
|
};
|
||||||
|
|
||||||
pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> {
|
pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<()> {
|
||||||
let mut profiles = Profiles::open(&config_path()?)?;
|
let mut profiles = Profiles::open(&config_path()?)?;
|
||||||
let mut browser = initial.map(App::new).transpose()?;
|
let mut browser = initial.map(App::new).transpose()?;
|
||||||
let mut selection =
|
let mut selection =
|
||||||
ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0));
|
ListState::default().with_selected((!profiles.entries.is_empty()).then_some(0));
|
||||||
let mut databases_focus = true;
|
let mut focus = Focus::Explorer;
|
||||||
let mut active = None;
|
let mut active = None;
|
||||||
let mut expanded = true;
|
let mut expanded = true;
|
||||||
let mut error: Option<String> = None;
|
let mut error: Option<String> = None;
|
||||||
@@ -34,9 +39,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
expanded,
|
expanded,
|
||||||
);
|
);
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
let navigation = Paragraph::new(
|
let navigation = Paragraph::new(if focus == Focus::Query && !show_form {
|
||||||
" Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit"
|
" Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit"
|
||||||
).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray));
|
} else {
|
||||||
|
" Tab/Shift-Tab focus · Esc explorer · ↑↓/jk move · Enter open · s schema/rows · ←→/hl columns · n/p page · r refresh · c add database · q quit"
|
||||||
|
}).wrap(Wrap { trim: true }).style(Style::default().fg(Color::DarkGray));
|
||||||
let help_height = navigation.line_count(frame.area().width) as u16;
|
let help_height = navigation.line_count(frame.area().width) as u16;
|
||||||
let [main, help] =
|
let [main, help] =
|
||||||
Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area());
|
Layout::vertical([Constraint::Min(1), Constraint::Length(help_height)]).areas(frame.area());
|
||||||
@@ -57,11 +64,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
});
|
});
|
||||||
let list = List::new(items)
|
let list = List::new(items)
|
||||||
.block(Block::bordered().title(" Explorer · c add ").border_style(
|
.block(Block::bordered().title(" Explorer · c add ").border_style(
|
||||||
Style::default().fg(if databases_focus { Color::Cyan } else { Color::DarkGray })))
|
Style::default().fg(if focus == Focus::Explorer { Color::Cyan } else { Color::DarkGray })))
|
||||||
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
|
.highlight_style(Style::default().bg(Color::Blue).fg(Color::White));
|
||||||
frame.render_stateful_widget(list, sidebar, &mut selection);
|
frame.render_stateful_widget(list, sidebar, &mut selection);
|
||||||
if let Some(browser) = &mut browser {
|
if let Some(browser) = &mut browser {
|
||||||
browser.draw(frame, content, !databases_focus);
|
browser.draw(frame, content, focus);
|
||||||
} else {
|
} else {
|
||||||
frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content);
|
frame.render_widget(Paragraph::new("Select a database and press Enter to connect.\n\nc: add a database · Tab: focus · q: quit").block(Block::bordered().title(" dbdash · READ ONLY ")), content);
|
||||||
}
|
}
|
||||||
@@ -97,7 +104,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
});
|
});
|
||||||
selection.select(active);
|
selection.select(active);
|
||||||
expanded = true;
|
expanded = true;
|
||||||
databases_focus = true;
|
focus = Focus::Explorer;
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
profiles.save(&form).map(|()| {
|
profiles.save(&form).map(|()| {
|
||||||
@@ -113,7 +120,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
profiles.entries.len() - 1,
|
profiles.entries.len() - 1,
|
||||||
))
|
))
|
||||||
}));
|
}));
|
||||||
databases_focus = true;
|
focus = Focus::Explorer;
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
match result {
|
match result {
|
||||||
@@ -129,25 +136,32 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match key.code {
|
match key.code {
|
||||||
|
KeyCode::Esc => focus = Focus::Explorer,
|
||||||
|
KeyCode::Tab | KeyCode::BackTab if browser.is_some() => {
|
||||||
|
focus = match (focus, key.code == KeyCode::BackTab) {
|
||||||
|
(Focus::Explorer, false) | (Focus::Rows, true) => Focus::Query,
|
||||||
|
(Focus::Query, false) | (Focus::Explorer, true) => Focus::Rows,
|
||||||
|
_ => Focus::Explorer,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
_ if focus == Focus::Query => {
|
||||||
|
if let Some(browser) = &mut browser {
|
||||||
|
browser.handle_query_key(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Char('q') => return Ok(()),
|
KeyCode::Char('q') => return Ok(()),
|
||||||
KeyCode::Char('c') => {
|
KeyCode::Char('c') => {
|
||||||
form = Form::default();
|
form = Form::default();
|
||||||
show_form = true;
|
show_form = true;
|
||||||
}
|
}
|
||||||
KeyCode::Esc => databases_focus = true,
|
KeyCode::Enter if focus == Focus::Explorer => {
|
||||||
KeyCode::Tab | KeyCode::BackTab => {
|
|
||||||
if browser.is_some() {
|
|
||||||
databases_focus = !databases_focus;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
KeyCode::Enter if databases_focus => {
|
|
||||||
if let Some(entry) =
|
if let Some(entry) =
|
||||||
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(index) => {
|
ExplorerEntry::Relation(index) => {
|
||||||
browser.as_mut().unwrap().open_relation(Some(index));
|
browser.as_mut().unwrap().open_relation(Some(index));
|
||||||
databases_focus = false;
|
focus = Focus::Rows;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
ExplorerEntry::Database(index)
|
ExplorerEntry::Database(index)
|
||||||
@@ -171,7 +185,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
active = Some(index);
|
active = Some(index);
|
||||||
selection.select(Some(index));
|
selection.select(Some(index));
|
||||||
expanded = true;
|
expanded = true;
|
||||||
databases_focus = true;
|
focus = Focus::Explorer;
|
||||||
error = None;
|
error = None;
|
||||||
}
|
}
|
||||||
Err(failure) => error = Some(error_text(failure.as_ref())),
|
Err(failure) => error = Some(error_text(failure.as_ref())),
|
||||||
@@ -179,7 +193,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Left | KeyCode::Char('h') if databases_focus => {
|
KeyCode::Left | KeyCode::Char('h') if focus == Focus::Explorer => {
|
||||||
if matches!(
|
if matches!(
|
||||||
selection.selected().and_then(|index| entries.get(index)),
|
selection.selected().and_then(|index| entries.get(index)),
|
||||||
Some(ExplorerEntry::Relation(_))
|
Some(ExplorerEntry::Relation(_))
|
||||||
@@ -194,31 +208,31 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Right | KeyCode::Char('l') if databases_focus => {
|
KeyCode::Right | KeyCode::Char('l') if focus == Focus::Explorer => {
|
||||||
if selection.selected().and_then(|index| entries.get(index))
|
if selection.selected().and_then(|index| entries.get(index))
|
||||||
== Some(&ExplorerEntry::Database(active))
|
== Some(&ExplorerEntry::Database(active))
|
||||||
{
|
{
|
||||||
expanded = true;
|
expanded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') if databases_focus => {
|
KeyCode::Char('r') if focus == Focus::Explorer => {
|
||||||
if let Some(browser) = &mut browser {
|
if let Some(browser) = &mut browser {
|
||||||
browser.handle_key(key);
|
browser.handle_key(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Down | KeyCode::Char('j') if databases_focus => {
|
KeyCode::Down | KeyCode::Char('j') if focus == Focus::Explorer => {
|
||||||
selection.select_next()
|
selection.select_next()
|
||||||
}
|
}
|
||||||
KeyCode::Up | KeyCode::Char('k') if databases_focus => {
|
KeyCode::Up | KeyCode::Char('k') if focus == Focus::Explorer => {
|
||||||
selection.select_previous()
|
selection.select_previous()
|
||||||
}
|
}
|
||||||
KeyCode::Home | KeyCode::Char('g') if databases_focus => {
|
KeyCode::Home | KeyCode::Char('g') if focus == Focus::Explorer => {
|
||||||
selection.select_first()
|
selection.select_first()
|
||||||
}
|
}
|
||||||
KeyCode::End | KeyCode::Char('G') if databases_focus => {
|
KeyCode::End | KeyCode::Char('G') if focus == Focus::Explorer => {
|
||||||
selection.select_last()
|
selection.select_last()
|
||||||
}
|
}
|
||||||
_ if !databases_focus => {
|
_ if focus == Focus::Rows => {
|
||||||
if let Some(browser) = &mut browser {
|
if let Some(browser) = &mut browser {
|
||||||
browser.handle_key(key);
|
browser.handle_key(key);
|
||||||
}
|
}
|
||||||
@@ -233,6 +247,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
|
|||||||
input.paste(&text);
|
input.paste(&text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Event::Paste(text) if focus == Focus::Query => {
|
||||||
|
if let Some(browser) = &mut browser {
|
||||||
|
browser.paste_query(&text);
|
||||||
|
}
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -617,8 +636,8 @@ impl Form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Default)]
|
#[derive(Clone, Default)]
|
||||||
struct Input {
|
pub(crate) struct Input {
|
||||||
value: String,
|
pub(crate) value: String,
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -632,7 +651,7 @@ impl From<&str> for Input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
fn edit(&mut self, key: KeyEvent) {
|
pub(crate) fn edit(&mut self, key: KeyEvent) {
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
self.cursor = self.value[..self.cursor]
|
self.cursor = self.value[..self.cursor]
|
||||||
@@ -646,8 +665,39 @@ impl Input {
|
|||||||
.next()
|
.next()
|
||||||
.map_or(0, char::len_utf8)
|
.map_or(0, char::len_utf8)
|
||||||
}
|
}
|
||||||
KeyCode::Home => self.cursor = 0,
|
KeyCode::Up | KeyCode::Down => {
|
||||||
KeyCode::End => self.cursor = self.value.len(),
|
let start = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
|
||||||
|
let column = self.value[start..self.cursor].chars().count();
|
||||||
|
let target = if key.code == KeyCode::Up {
|
||||||
|
start
|
||||||
|
.checked_sub(1)
|
||||||
|
.map(|end| (self.value[..end].rfind('\n').map_or(0, |i| i + 1), end))
|
||||||
|
} else {
|
||||||
|
self.value[self.cursor..].find('\n').map(|i| {
|
||||||
|
let start = self.cursor + i + 1;
|
||||||
|
let end = self.value[start..]
|
||||||
|
.find('\n')
|
||||||
|
.map_or(self.value.len(), |i| start + i);
|
||||||
|
(start, end)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
if let Some((start, end)) = target {
|
||||||
|
self.cursor = start
|
||||||
|
+ self.value[start..end]
|
||||||
|
.chars()
|
||||||
|
.take(column)
|
||||||
|
.map(char::len_utf8)
|
||||||
|
.sum::<usize>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Home => {
|
||||||
|
self.cursor = self.value[..self.cursor].rfind('\n').map_or(0, |i| i + 1);
|
||||||
|
}
|
||||||
|
KeyCode::End => {
|
||||||
|
self.cursor += self.value[self.cursor..]
|
||||||
|
.find('\n')
|
||||||
|
.unwrap_or(self.value.len() - self.cursor);
|
||||||
|
}
|
||||||
KeyCode::Backspace if self.cursor > 0 => {
|
KeyCode::Backspace if self.cursor > 0 => {
|
||||||
let previous = self.value[..self.cursor]
|
let previous = self.value[..self.cursor]
|
||||||
.char_indices()
|
.char_indices()
|
||||||
@@ -686,21 +736,41 @@ impl Input {
|
|||||||
self.cursor += text.len();
|
self.cursor += text.len();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw(&self, frame: &mut Frame, area: Rect, label: &str, secret: bool, focused: bool) {
|
pub(crate) fn paste_multiline(&mut self, value: &str) {
|
||||||
|
let text: String = value
|
||||||
|
.chars()
|
||||||
|
.map(|character| if character == '\t' { ' ' } else { character })
|
||||||
|
.filter(|character| *character == '\n' || !character.is_control())
|
||||||
|
.collect();
|
||||||
|
self.value.insert_str(self.cursor, &text);
|
||||||
|
self.cursor += text.len();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn draw(
|
||||||
|
&self,
|
||||||
|
frame: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
label: &str,
|
||||||
|
secret: bool,
|
||||||
|
focused: bool,
|
||||||
|
) {
|
||||||
let text: Cow<'_, str> = if secret {
|
let text: Cow<'_, str> = if secret {
|
||||||
Cow::Owned("•".repeat(self.value.chars().count()))
|
Cow::Owned("•".repeat(self.value.chars().count()))
|
||||||
} else {
|
} else {
|
||||||
Cow::Borrowed(&self.value)
|
Cow::Borrowed(&self.value)
|
||||||
};
|
};
|
||||||
|
let prefix = &self.value[..self.cursor];
|
||||||
|
let row = u16::try_from(prefix.matches('\n').count()).unwrap_or(u16::MAX);
|
||||||
|
let row_offset = row.saturating_sub(area.height.saturating_sub(3));
|
||||||
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(&self.value[..self.cursor]).width()
|
Line::raw(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(area.width.saturating_sub(3));
|
let offset = cursor.saturating_sub(area.width.saturating_sub(3));
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(text).scroll((0, offset)).block(
|
Paragraph::new(text).scroll((row_offset, offset)).block(
|
||||||
Block::bordered()
|
Block::bordered()
|
||||||
.title(label)
|
.title(label)
|
||||||
.border_style(Style::default().fg(if focused {
|
.border_style(Style::default().fg(if focused {
|
||||||
@@ -712,7 +782,8 @@ impl Input {
|
|||||||
area,
|
area,
|
||||||
);
|
);
|
||||||
if focused && area.width > 2 && area.height > 2 {
|
if focused && area.width > 2 && area.height > 2 {
|
||||||
frame.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1));
|
frame
|
||||||
|
.set_cursor_position((area.x + 1 + cursor - offset, area.y + 1 + row - row_offset));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -765,6 +836,24 @@ mod tests {
|
|||||||
assert_eq!(input.cursor, 0);
|
assert_eq!(input.cursor, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiline_editing_preserves_lines_and_unicode() {
|
||||||
|
let mut input = Input::default();
|
||||||
|
input.paste_multiline("aé界\nxy\nz\r\n\t\u{1b}");
|
||||||
|
assert_eq!(input.value, "aé界\nxy\nz\n ");
|
||||||
|
for code in [KeyCode::Up, KeyCode::Up, KeyCode::Up] {
|
||||||
|
input.edit(KeyEvent::new(code, KeyModifiers::NONE));
|
||||||
|
}
|
||||||
|
assert_eq!(&input.value[..input.cursor], "a");
|
||||||
|
input.edit(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
|
||||||
|
assert_eq!(&input.value[..input.cursor], "aé界\nxy");
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE));
|
||||||
|
input.edit(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||||
|
assert_eq!(input.value, "aé界xy\nz\n ");
|
||||||
|
assert_eq!(input.cursor, "aé界".len());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn password_never_appears_in_the_connection_screen() -> Result<()> {
|
fn password_never_appears_in_the_connection_screen() -> Result<()> {
|
||||||
let mut form = Form {
|
let mut form = Form {
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ pub struct Page {
|
|||||||
pub has_next: bool,
|
pub has_next: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct QueryResult {
|
||||||
|
pub headers: Vec<String>,
|
||||||
|
pub page: Page,
|
||||||
|
}
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
pub fn connect(connection: &str) -> Result<Self> {
|
pub fn connect(connection: &str) -> Result<Self> {
|
||||||
Self::connect_postgres(connection.parse()?)
|
Self::connect_postgres(connection.parse()?)
|
||||||
@@ -157,6 +162,59 @@ impl Database {
|
|||||||
.collect())
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn query(&mut self, sql: &str) -> Result<QueryResult> {
|
||||||
|
let sql = sql.trim();
|
||||||
|
let sql = sql.strip_suffix(';').unwrap_or(sql).trim_end();
|
||||||
|
let client = match &mut self.backend {
|
||||||
|
Backend::Postgres(client) => client,
|
||||||
|
Backend::Sqlite(connection) => return sqlite::query(connection, sql),
|
||||||
|
};
|
||||||
|
let mut transaction = client.build_transaction().read_only(true).start()?;
|
||||||
|
transaction.batch_execute("SET LOCAL statement_timeout = '5s'")?;
|
||||||
|
let statement = transaction.prepare(&format!(
|
||||||
|
"SELECT * FROM (\n{sql}\n) AS query_result LIMIT 0"
|
||||||
|
))?;
|
||||||
|
let headers = statement
|
||||||
|
.columns()
|
||||||
|
.iter()
|
||||||
|
.map(|column| display_text(column.name()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let aliases = (0..headers.len())
|
||||||
|
.map(|index| format!("c{index}"))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let projection = aliases
|
||||||
|
.iter()
|
||||||
|
.map(|alias| format!("query_result.{alias}::text"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let alias_list = if aliases.is_empty() {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("({})", aliases.join(", "))
|
||||||
|
};
|
||||||
|
let result = transaction.query(
|
||||||
|
&format!(
|
||||||
|
"SELECT {projection} FROM (\n{sql}\n) AS query_result{alias_list} LIMIT {}",
|
||||||
|
PAGE_SIZE + 1
|
||||||
|
),
|
||||||
|
&[],
|
||||||
|
)?;
|
||||||
|
let has_next = result.len() > PAGE_SIZE;
|
||||||
|
let rows = result
|
||||||
|
.into_iter()
|
||||||
|
.take(PAGE_SIZE)
|
||||||
|
.map(|row| {
|
||||||
|
(0..headers.len())
|
||||||
|
.map(|index| row.get::<_, Option<&str>>(index).map(display_text))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(QueryResult {
|
||||||
|
headers,
|
||||||
|
page: Page { rows, has_next },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> {
|
pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> {
|
||||||
let client = match &mut self.backend {
|
let client = match &mut self.backend {
|
||||||
Backend::Postgres(client) => client,
|
Backend::Postgres(client) => client,
|
||||||
@@ -319,9 +377,55 @@ mod tests {
|
|||||||
let page = db.page(empty, &columns, 0)?;
|
let page = db.page(empty, &columns, 0)?;
|
||||||
assert!(page.rows.is_empty());
|
assert!(page.rows.is_empty());
|
||||||
assert!(!page.has_next);
|
assert!(!page.has_next);
|
||||||
|
let query = db.query(&format!(
|
||||||
|
" \nWITH data AS (SELECT * FROM {schema}.\"odd\"\"table\")
|
||||||
|
SELECT * FROM data ORDER BY id; \n"
|
||||||
|
))?;
|
||||||
|
assert_eq!(query.headers[1], "odd\"column");
|
||||||
|
assert_eq!(query.page.rows, first.rows);
|
||||||
|
assert!(query.page.has_next);
|
||||||
|
let duplicate = db.query("SELECT 1 AS same, NULL::text AS same")?;
|
||||||
|
assert_eq!(duplicate.headers, ["same", "same"]);
|
||||||
|
assert_eq!(duplicate.page.rows, [vec![Some("1".into()), None]]);
|
||||||
|
assert!(!duplicate.page.has_next);
|
||||||
|
let empty = db.query("SELECT 1 AS empty WHERE false")?;
|
||||||
|
assert_eq!(empty.headers, ["empty"]);
|
||||||
|
assert!(empty.page.rows.is_empty());
|
||||||
|
assert!(!empty.page.has_next);
|
||||||
|
let controls = db.query("SELECT E'\\033[2J' AS \"line\nheader\" -- trailing comment")?;
|
||||||
|
assert_eq!(controls.headers, ["line\\nheader"]);
|
||||||
|
assert_eq!(controls.page.rows[0][0].as_deref(), Some("\\u{1b}[2J"));
|
||||||
|
assert!(db.query("SELECT 1; SELECT 2").is_err());
|
||||||
|
assert!(
|
||||||
|
db.query(&format!("DELETE FROM {schema}.empty_table RETURNING id"))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
db.query(&format!(
|
||||||
|
"WITH deleted AS (DELETE FROM {schema}.empty_table RETURNING id)
|
||||||
|
SELECT * FROM deleted"
|
||||||
|
))
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
db.query("SELECT set_config('default_transaction_read_only', 'off', false)")?;
|
||||||
|
let settings = db.query("SELECT current_setting('default_transaction_read_only')")?;
|
||||||
|
assert_eq!(settings.page.rows[0][0].as_deref(), Some("on"));
|
||||||
|
if let Backend::Postgres(client) = &mut db.backend {
|
||||||
|
client.batch_execute(
|
||||||
|
"SET default_transaction_read_only = off; SET statement_timeout = 0",
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
let settings = db.query(
|
||||||
|
"SELECT current_setting('transaction_read_only'), current_setting('statement_timeout')",
|
||||||
|
)?;
|
||||||
|
assert_eq!(
|
||||||
|
settings.page.rows[0],
|
||||||
|
[Some("on".into()), Some("5s".into())]
|
||||||
|
);
|
||||||
let Backend::Postgres(client) = &mut db.backend else {
|
let Backend::Postgres(client) = &mut db.backend else {
|
||||||
unreachable!()
|
unreachable!()
|
||||||
};
|
};
|
||||||
|
client.batch_execute("SET default_transaction_read_only = on")?;
|
||||||
let error = client
|
let error = client
|
||||||
.batch_execute(&format!("DELETE FROM {schema}.empty_table"))
|
.batch_execute(&format!("DELETE FROM {schema}.empty_table"))
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
|
|||||||
+86
-2
@@ -2,7 +2,9 @@ use std::{fmt::Write, path::Path, time::Duration};
|
|||||||
|
|
||||||
use rusqlite::{Connection, OpenFlags, params, types::ValueRef};
|
use rusqlite::{Connection, OpenFlags, params, types::ValueRef};
|
||||||
|
|
||||||
use super::{Column, PAGE_SIZE, Page, Relation, display_text, page_offset, quote_identifier};
|
use super::{
|
||||||
|
Column, PAGE_SIZE, Page, QueryResult, Relation, display_text, page_offset, quote_identifier,
|
||||||
|
};
|
||||||
use crate::Result;
|
use crate::Result;
|
||||||
|
|
||||||
pub(super) fn open(path: &Path) -> Result<Connection> {
|
pub(super) fn open(path: &Path) -> Result<Connection> {
|
||||||
@@ -109,6 +111,26 @@ 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())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn query(connection: &Connection, sql: &str) -> Result<QueryResult> {
|
||||||
|
let original = connection.prepare(sql)?;
|
||||||
|
let headers = original
|
||||||
|
.column_names()
|
||||||
|
.into_iter()
|
||||||
|
.map(display_text)
|
||||||
|
.collect();
|
||||||
|
let mut statement = connection.prepare(&format!(
|
||||||
|
"SELECT * FROM (\n{sql}\n) AS query_result LIMIT {}",
|
||||||
|
PAGE_SIZE + 1
|
||||||
|
))?;
|
||||||
|
let column_count = statement.column_count();
|
||||||
|
let page = read_page(&mut statement.query([])?, column_count)?;
|
||||||
|
Ok(QueryResult { headers, page })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_page(query: &mut rusqlite::Rows<'_>, column_count: usize) -> Result<Page> {
|
||||||
let mut rows = Vec::with_capacity(PAGE_SIZE);
|
let mut rows = Vec::with_capacity(PAGE_SIZE);
|
||||||
while let Some(row) = query.next()? {
|
while let Some(row) = query.next()? {
|
||||||
if rows.len() == PAGE_SIZE {
|
if rows.len() == PAGE_SIZE {
|
||||||
@@ -117,7 +139,7 @@ pub(super) fn page(
|
|||||||
has_next: true,
|
has_next: true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
let values = (0..columns.len())
|
let values = (0..column_count)
|
||||||
.map(|index| {
|
.map(|index| {
|
||||||
Ok(match row.get_ref(index)? {
|
Ok(match row.get_ref(index)? {
|
||||||
ValueRef::Null => None,
|
ValueRef::Null => None,
|
||||||
@@ -154,6 +176,68 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::db::{Backend, Database};
|
use crate::db::{Backend, Database};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn query_sqlite_read_only() -> Result<()> {
|
||||||
|
let directory = tempfile::tempdir()?;
|
||||||
|
let path = directory.path().join("queries.sqlite");
|
||||||
|
let writer = Connection::open(&path)?;
|
||||||
|
writer
|
||||||
|
.execute_batch("CREATE TABLE saved (value INTEGER); INSERT INTO saved VALUES (7);")?;
|
||||||
|
let mut db = Database::open_sqlite(&path)?;
|
||||||
|
|
||||||
|
let result = db.query(
|
||||||
|
" \nSELECT NULL AS \"same\", X'00FF' AS \"same\", char(27) || '[2J' AS \"line
|
||||||
|
break\", 2.5 AS amount; \n",
|
||||||
|
)?;
|
||||||
|
assert_eq!(result.headers, ["same", "same", "line\\nbreak", "amount"]);
|
||||||
|
assert_eq!(
|
||||||
|
result.page.rows,
|
||||||
|
vec![vec![
|
||||||
|
None,
|
||||||
|
Some("X'00FF'".into()),
|
||||||
|
Some("\\u{1b}[2J".into()),
|
||||||
|
Some("2.5".into())
|
||||||
|
]]
|
||||||
|
);
|
||||||
|
assert!(!result.page.has_next);
|
||||||
|
let empty = db.query("SELECT value AS original FROM saved WHERE 0")?;
|
||||||
|
assert_eq!(empty.headers, ["original"]);
|
||||||
|
assert!(empty.page.rows.is_empty());
|
||||||
|
assert!(!empty.page.has_next);
|
||||||
|
|
||||||
|
let result = db.query(
|
||||||
|
"WITH RECURSIVE numbers(n) AS (
|
||||||
|
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 101
|
||||||
|
) SELECT n FROM numbers -- trailing comment",
|
||||||
|
)?;
|
||||||
|
assert_eq!(result.headers, ["n"]);
|
||||||
|
assert_eq!(result.page.rows.len(), PAGE_SIZE);
|
||||||
|
assert_eq!(result.page.rows[99][0].as_deref(), Some("100"));
|
||||||
|
assert!(result.page.has_next);
|
||||||
|
let exact = db.query(
|
||||||
|
"WITH RECURSIVE numbers(n) AS (
|
||||||
|
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 100
|
||||||
|
) SELECT n FROM numbers",
|
||||||
|
)?;
|
||||||
|
assert_eq!(exact.page.rows.len(), PAGE_SIZE);
|
||||||
|
assert!(!exact.page.has_next);
|
||||||
|
|
||||||
|
for sql in [
|
||||||
|
"DELETE FROM saved",
|
||||||
|
"UPDATE saved SET value = 8 RETURNING value",
|
||||||
|
"DROP TABLE saved",
|
||||||
|
"SELECT value FROM saved; DELETE FROM saved",
|
||||||
|
"PRAGMA query_only = OFF",
|
||||||
|
] {
|
||||||
|
assert!(db.query(sql).is_err(), "accepted write: {sql}");
|
||||||
|
assert_eq!(
|
||||||
|
db.query("SELECT value FROM saved")?.page.rows,
|
||||||
|
vec![vec![Some("7".into())]]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inspect_sqlite_read_only() -> Result<()> {
|
fn inspect_sqlite_read_only() -> Result<()> {
|
||||||
let directory = tempfile::tempdir()?;
|
let directory = tempfile::tempdir()?;
|
||||||
|
|||||||
+12
-5
@@ -26,23 +26,30 @@ Expansion never runs shell commands. Quote CLI paths to let dbdash expand them.
|
|||||||
PostgreSQL URLs and libpq-style key=value strings are accepted.
|
PostgreSQL URLs and libpq-style key=value strings are accepted.
|
||||||
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
|
||||||
Use sslmode=disable only for trusted local connections.
|
Use sslmode=disable only for trusted local connections.
|
||||||
The inspector needs 70×12 terminal cells; the PostgreSQL window needs 74×20.
|
The inspector needs 70×14 terminal cells; the PostgreSQL window needs 74×20.
|
||||||
|
|
||||||
Navigation:
|
Navigation:
|
||||||
Tab/Shift-Tab Switch between explorer and rows/schema (Esc returns to explorer)
|
Tab/Shift-Tab Cycle explorer, query editor, and rows/schema (Esc returns to explorer)
|
||||||
Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation
|
Up/Down, j/k Move selection; Enter connects/expands a database or opens a relation
|
||||||
Home/End, g/G First / last item
|
Home/End, g/G First / last item
|
||||||
s Toggle rows and column schema
|
s Toggle rows and column schema
|
||||||
Left/Right Collapse/expand in explorer; scroll columns in rows/schema (h/l too)
|
Left/Right Collapse/expand in explorer; scroll columns in rows/schema (h/l too)
|
||||||
n/p, PgDn/Up Next / previous 100-row page
|
n/p, PgDn/Up Next / previous 100-row relation page
|
||||||
c Add a configured database in a floating window
|
c Add a configured database in a floating window
|
||||||
r Refresh relations and the active relation
|
r Refresh relations and the active relation
|
||||||
Connected databases show their tables and views beneath them in the explorer.
|
Connected databases show their tables and views beneath them in the explorer.
|
||||||
Enter on the connected database collapses/expands it without reconnecting.
|
Enter on the connected database collapses/expands it without reconnecting.
|
||||||
Navigation help spans the bottom as one line, wrapping only when needed.
|
Navigation help spans the bottom as one line, wrapping only when needed.
|
||||||
|
The query editor sits above rows/results; the explorer spans both panes.
|
||||||
Explorer and rows/schema bottom borders align; relation/page/column status
|
Explorer and rows/schema bottom borders align; relation/page/column status
|
||||||
appears in the rows/schema border. Read errors appear above that pane.
|
appears in the rows/schema border. Read errors appear above the query editor.
|
||||||
q / Ctrl-C Quit
|
In the query editor, type SQL directly; Enter inserts a newline. Arrow keys,
|
||||||
|
Home/End, Backspace/Delete, Ctrl-U (clear), and multiline pasting are supported.
|
||||||
|
F5 / Ctrl-Enter executes one SELECT/CTE on the current connection, showing up
|
||||||
|
to 100 rows below. Results report truncation; use SQL LIMIT/OFFSET for more.
|
||||||
|
Failed queries keep the previous results. Opening a relation restores browsing
|
||||||
|
without clearing the query text. Query text is not saved across connections.
|
||||||
|
q / Ctrl-C Quit (q is literal text in the query editor)
|
||||||
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 when that field is focused.
|
Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is 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