feat: add query editor above result rows

This commit is contained in:
Valdemar Lorenzen
2026-10-19 14:04:36 +02:00
parent b15460d5f8
commit 49a3dbddcf
5 changed files with 460 additions and 66 deletions
+134 -24
View File
@@ -1,4 +1,4 @@
use crossterm::event::{KeyCode, KeyEvent};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
@@ -9,10 +9,18 @@ use ratatui::{
use crate::{
Result,
db::{Column, Database, PAGE_SIZE, Page, Relation},
connection::Input,
db::{Column, Database, PAGE_SIZE, Page, QueryResult, Relation},
error_text,
};
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum Focus {
Explorer,
Query,
Rows,
}
pub struct App {
db: Database,
pub(crate) relations: Vec<Relation>,
@@ -24,6 +32,8 @@ pub struct App {
schema_state: TableState,
column_offset: usize,
show_schema: bool,
query: Input,
query_result: Option<QueryResult>,
error: Option<String>,
}
@@ -45,6 +55,8 @@ impl App {
schema_state: TableState::default(),
column_offset: 0,
show_schema: false,
query: Input::default(),
query_result: None,
error: None,
};
app.open_relation(selected);
@@ -57,7 +69,7 @@ impl App {
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Home | KeyCode::Char('g') => self.move_selection(isize::MIN),
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.column_offset = 0;
}
@@ -68,16 +80,22 @@ impl App {
let count = if self.show_schema {
5
} else {
self.columns.len()
self.query_result
.as_ref()
.map_or(self.columns.len(), |result| result.headers.len())
};
if self.column_offset + 1 < count {
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);
}
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);
}
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) {
if self.show_schema {
let next = moved_index(self.schema_state.selected(), self.columns.len(), delta);
self.schema_state.select(next);
} 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);
}
}
pub(crate) fn open_relation(&mut self, index: Option<usize>) {
self.active = index;
self.query_result = None;
self.columns.clear();
self.page = Page {
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 {
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,
);
return;
@@ -174,8 +224,18 @@ impl App {
.style(Style::default().fg(Color::Cyan)),
header,
);
let content = body;
let title = if self.show_schema {
let [query, content] =
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 "
} else {
" Rows · s for schema "
@@ -183,7 +243,9 @@ impl App {
let count = if self.show_schema {
5
} 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)
.max(1)
@@ -195,7 +257,20 @@ impl App {
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];
if self.show_schema {
format!(
@@ -228,8 +303,12 @@ impl App {
" No readable relations. r to refresh.".into()
};
let content_block =
panel(title, focused).title_bottom(Line::from(description).left_aligned());
if self.active.is_none() {
panel(title, focus == Focus::Rows).title_bottom(Line::from(description).left_aligned());
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(
Paragraph::new("Choose a relation and press Enter.").block(content_block),
content,
@@ -259,7 +338,7 @@ impl App {
.row_highlight_style(selected_style())
.highlight_symbol("› ");
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(
Paragraph::new(if self.error.is_some() {
"Could not load rows. See error above."
@@ -270,13 +349,13 @@ impl App {
content,
);
} else {
let headers = self
.columns
.iter()
.skip(self.column_offset)
.take(visible)
.map(|column| column.label.as_str());
let rows = self.page.rows.iter().map(|row| {
let headers = (self.column_offset..self.column_offset + visible).map(|index| {
self.query_result.as_ref().map_or_else(
|| self.columns[index].label.as_str(),
|result| result.headers[index].as_str(),
)
});
let rows = page.rows.iter().map(|row| {
Row::new(
row.iter()
.skip(self.column_offset)
@@ -353,6 +432,37 @@ mod tests {
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]
fn relation_status_is_in_the_bottom_border() -> crate::Result<()> {
let file = tempfile::NamedTempFile::new()?;
@@ -364,7 +474,7 @@ mod tests {
for (schema, error) in [(false, None), (true, None), (false, Some("Read failed"))] {
app.show_schema = schema;
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 border: String = (0..100).map(|x| buffer[(x, 19)].symbol()).collect();
assert!(border.starts_with("└ main.comments [table]"), "{border}");
+124 -35
View File
@@ -13,14 +13,19 @@ use ratatui::{
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<()> {
let mut profiles = Profiles::open(&config_path()?)?;
let mut browser = initial.map(App::new).transpose()?;
let mut selection =
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 expanded = true;
let mut error: Option<String> = None;
@@ -34,9 +39,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
expanded,
);
terminal.draw(|frame| {
let navigation = Paragraph::new(
" 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 navigation = Paragraph::new(if focus == Focus::Query && !show_form {
" Tab/Shift-Tab focus · Esc explorer · Enter newline · F5/Ctrl-Enter execute · Ctrl-U clear · Ctrl-C quit"
} 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 [main, help] =
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)
.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));
frame.render_stateful_widget(list, sidebar, &mut selection);
if let Some(browser) = &mut browser {
browser.draw(frame, content, !databases_focus);
browser.draw(frame, content, focus);
} 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);
}
@@ -97,7 +104,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
});
selection.select(active);
expanded = true;
databases_focus = true;
focus = Focus::Explorer;
})
} else {
profiles.save(&form).map(|()| {
@@ -113,7 +120,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
profiles.entries.len() - 1,
))
}));
databases_focus = true;
focus = Focus::Explorer;
})
};
match result {
@@ -129,25 +136,32 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
}
} else {
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('c') => {
form = Form::default();
show_form = true;
}
KeyCode::Esc => databases_focus = true,
KeyCode::Tab | KeyCode::BackTab => {
if browser.is_some() {
databases_focus = !databases_focus;
}
}
KeyCode::Enter if databases_focus => {
KeyCode::Enter if focus == Focus::Explorer => {
if let Some(entry) =
selection.selected().and_then(|index| entries.get(index))
{
let index = match *entry {
ExplorerEntry::Relation(index) => {
browser.as_mut().unwrap().open_relation(Some(index));
databases_focus = false;
focus = Focus::Rows;
continue;
}
ExplorerEntry::Database(index)
@@ -171,7 +185,7 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
active = Some(index);
selection.select(Some(index));
expanded = true;
databases_focus = true;
focus = Focus::Explorer;
error = None;
}
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!(
selection.selected().and_then(|index| entries.get(index)),
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))
== Some(&ExplorerEntry::Database(active))
{
expanded = true;
}
}
KeyCode::Char('r') if databases_focus => {
KeyCode::Char('r') if focus == Focus::Explorer => {
if let Some(browser) = &mut browser {
browser.handle_key(key);
}
}
KeyCode::Down | KeyCode::Char('j') if databases_focus => {
KeyCode::Down | KeyCode::Char('j') if focus == Focus::Explorer => {
selection.select_next()
}
KeyCode::Up | KeyCode::Char('k') if databases_focus => {
KeyCode::Up | KeyCode::Char('k') if focus == Focus::Explorer => {
selection.select_previous()
}
KeyCode::Home | KeyCode::Char('g') if databases_focus => {
KeyCode::Home | KeyCode::Char('g') if focus == Focus::Explorer => {
selection.select_first()
}
KeyCode::End | KeyCode::Char('G') if databases_focus => {
KeyCode::End | KeyCode::Char('G') if focus == Focus::Explorer => {
selection.select_last()
}
_ if !databases_focus => {
_ if focus == Focus::Rows => {
if let Some(browser) = &mut browser {
browser.handle_key(key);
}
@@ -233,6 +247,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option<Database>) -> Result<
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)]
struct Input {
value: String,
pub(crate) struct Input {
pub(crate) value: String,
cursor: usize,
}
@@ -632,7 +651,7 @@ impl From<&str> for Input {
}
impl Input {
fn edit(&mut self, key: KeyEvent) {
pub(crate) fn edit(&mut self, key: KeyEvent) {
match key.code {
KeyCode::Left => {
self.cursor = self.value[..self.cursor]
@@ -646,8 +665,39 @@ impl Input {
.next()
.map_or(0, char::len_utf8)
}
KeyCode::Home => self.cursor = 0,
KeyCode::End => self.cursor = self.value.len(),
KeyCode::Up | KeyCode::Down => {
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 => {
let previous = self.value[..self.cursor]
.char_indices()
@@ -686,21 +736,41 @@ impl Input {
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 {
Cow::Owned("•".repeat(self.value.chars().count()))
} else {
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 {
self.value[..self.cursor].chars().count()
} 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 offset = cursor.saturating_sub(area.width.saturating_sub(3));
frame.render_widget(
Paragraph::new(text).scroll((0, offset)).block(
Paragraph::new(text).scroll((row_offset, offset)).block(
Block::bordered()
.title(label)
.border_style(Style::default().fg(if focused {
@@ -712,7 +782,8 @@ impl Input {
area,
);
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);
}
#[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]
fn password_never_appears_in_the_connection_screen() -> Result<()> {
let mut form = Form {
+104
View File
@@ -46,6 +46,11 @@ pub struct Page {
pub has_next: bool,
}
pub struct QueryResult {
pub headers: Vec<String>,
pub page: Page,
}
impl Database {
pub fn connect(connection: &str) -> Result<Self> {
Self::connect_postgres(connection.parse()?)
@@ -157,6 +162,59 @@ impl Database {
.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> {
let client = match &mut self.backend {
Backend::Postgres(client) => client,
@@ -319,9 +377,55 @@ mod tests {
let page = db.page(empty, &columns, 0)?;
assert!(page.rows.is_empty());
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 {
unreachable!()
};
client.batch_execute("SET default_transaction_read_only = on")?;
let error = client
.batch_execute(&format!("DELETE FROM {schema}.empty_table"))
.unwrap_err();
+86 -2
View File
@@ -2,7 +2,9 @@ use std::{fmt::Write, path::Path, time::Duration};
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;
pub(super) fn open(path: &Path) -> Result<Connection> {
@@ -109,6 +111,26 @@ pub(super) fn page(
);
let mut statement = connection.prepare(&sql)?;
let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?;
read_page(&mut query, columns.len())
}
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);
while let Some(row) = query.next()? {
if rows.len() == PAGE_SIZE {
@@ -117,7 +139,7 @@ pub(super) fn page(
has_next: true,
});
}
let values = (0..columns.len())
let values = (0..column_count)
.map(|index| {
Ok(match row.get_ref(index)? {
ValueRef::Null => None,
@@ -154,6 +176,68 @@ mod tests {
use super::*;
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]
fn inspect_sqlite_read_only() -> Result<()> {
let directory = tempfile::tempdir()?;
+12 -5
View File
@@ -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.
Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
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:
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
Home/End, g/G First / last item
s Toggle rows and column schema
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
r Refresh relations and the active relation
Connected databases show their tables and views beneath them in the explorer.
Enter on the connected database collapses/expands it without reconnecting.
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
appears in the rows/schema border. Read errors appear above that pane.
q / Ctrl-C Quit
appears in the rows/schema border. Read errors appear above the query editor.
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.
Tab/Shift-Tab changes fields; Left/Right changes TLS when that field is focused.
Enter saves without connecting, including when the database is offline.