diff --git a/src/app.rs b/src/app.rs index 351ddd1..acd3369 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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, @@ -24,6 +32,8 @@ pub struct App { schema_state: TableState, column_offset: usize, show_schema: bool, + query: Input, + query_result: Option, error: Option, } @@ -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) { 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}"); diff --git a/src/connection.rs b/src/connection.rs index 3f2a321..4621cf2 100644 --- a/src/connection.rs +++ b/src/connection.rs @@ -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) -> 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 = None; @@ -34,9 +39,11 @@ pub fn run(terminal: &mut DefaultTerminal, initial: Option) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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::(); + } + } + 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 { diff --git a/src/db.rs b/src/db.rs index a6eef52..a4dedf3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -46,6 +46,11 @@ pub struct Page { pub has_next: bool, } +pub struct QueryResult { + pub headers: Vec, + pub page: Page, +} + impl Database { pub fn connect(connection: &str) -> Result { Self::connect_postgres(connection.parse()?) @@ -157,6 +162,59 @@ impl Database { .collect()) } + pub fn query(&mut self, sql: &str) -> Result { + 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::>(); + let aliases = (0..headers.len()) + .map(|index| format!("c{index}")) + .collect::>(); + let projection = aliases + .iter() + .map(|alias| format!("query_result.{alias}::text")) + .collect::>() + .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 { 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(); diff --git a/src/db/sqlite.rs b/src/db/sqlite.rs index ea26eea..206d700 100644 --- a/src/db/sqlite.rs +++ b/src/db/sqlite.rs @@ -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 { @@ -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 { + 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 { 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()?; diff --git a/src/main.rs b/src/main.rs index 9f39ed6..586870e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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.