feat: add read-only PostgreSQL inspection TUI
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+1598
File diff suppressed because it is too large
Load Diff
+12
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "dbdash"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Read-only PostgreSQL inspection in your terminal"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
|
||||||
|
crossterm = "0.29"
|
||||||
|
postgres = "0.19"
|
||||||
|
postgres-native-tls = "0.5"
|
||||||
|
native-tls = "0.2"
|
||||||
+408
@@ -0,0 +1,408 @@
|
|||||||
|
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||||
|
use ratatui::{
|
||||||
|
DefaultTerminal, Frame,
|
||||||
|
layout::{Constraint, Layout},
|
||||||
|
style::{Color, Modifier, Style},
|
||||||
|
widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
Result,
|
||||||
|
db::{Column, Database, PAGE_SIZE, Page, Relation},
|
||||||
|
error_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
db: Database,
|
||||||
|
relations: Vec<Relation>,
|
||||||
|
selection: ListState,
|
||||||
|
active: Option<usize>,
|
||||||
|
columns: Vec<Column>,
|
||||||
|
page: Page,
|
||||||
|
page_index: usize,
|
||||||
|
rows_state: TableState,
|
||||||
|
schema_state: TableState,
|
||||||
|
column_offset: usize,
|
||||||
|
sidebar_focus: bool,
|
||||||
|
show_schema: bool,
|
||||||
|
error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(mut db: Database) -> Result<Self> {
|
||||||
|
let relations = db.relations()?;
|
||||||
|
let selected = (!relations.is_empty()).then_some(0);
|
||||||
|
let mut app = Self {
|
||||||
|
db,
|
||||||
|
relations,
|
||||||
|
selection: ListState::default().with_selected(selected),
|
||||||
|
active: None,
|
||||||
|
columns: Vec::new(),
|
||||||
|
page: Page {
|
||||||
|
rows: Vec::new(),
|
||||||
|
has_next: false,
|
||||||
|
},
|
||||||
|
page_index: 0,
|
||||||
|
rows_state: TableState::default(),
|
||||||
|
schema_state: TableState::default(),
|
||||||
|
column_offset: 0,
|
||||||
|
sidebar_focus: true,
|
||||||
|
show_schema: false,
|
||||||
|
error: None,
|
||||||
|
};
|
||||||
|
app.open_selection();
|
||||||
|
Ok(app)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(&mut self, terminal: &mut DefaultTerminal) -> Result<()> {
|
||||||
|
loop {
|
||||||
|
terminal.draw(|frame| self.draw(frame))?;
|
||||||
|
if let Event::Key(key) = event::read()?
|
||||||
|
&& matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||||
|
&& self.handle_key(key)
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_key(&mut self, key: KeyEvent) -> bool {
|
||||||
|
if key.code == KeyCode::Char('q')
|
||||||
|
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab | KeyCode::BackTab => self.sidebar_focus = !self.sidebar_focus,
|
||||||
|
KeyCode::Enter if self.sidebar_focus => {
|
||||||
|
self.open_selection();
|
||||||
|
self.sidebar_focus = false;
|
||||||
|
}
|
||||||
|
KeyCode::Esc => self.sidebar_focus = true,
|
||||||
|
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
|
||||||
|
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') => {
|
||||||
|
self.show_schema = !self.show_schema;
|
||||||
|
self.column_offset = 0;
|
||||||
|
}
|
||||||
|
KeyCode::Left | KeyCode::Char('h') if !self.sidebar_focus => {
|
||||||
|
self.column_offset = self.column_offset.saturating_sub(1);
|
||||||
|
}
|
||||||
|
KeyCode::Right | KeyCode::Char('l') if !self.sidebar_focus => {
|
||||||
|
let count = if self.show_schema {
|
||||||
|
5
|
||||||
|
} else {
|
||||||
|
self.columns.len()
|
||||||
|
};
|
||||||
|
if self.column_offset + 1 < count {
|
||||||
|
self.column_offset += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Char('n') | KeyCode::PageDown if !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 => {
|
||||||
|
self.load_page(self.page_index - 1);
|
||||||
|
}
|
||||||
|
KeyCode::Char('r') => self.refresh(),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn move_selection(&mut self, delta: isize) {
|
||||||
|
if self.sidebar_focus {
|
||||||
|
let next = moved_index(self.selection.selected(), self.relations.len(), delta);
|
||||||
|
self.selection.select(next);
|
||||||
|
} else 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);
|
||||||
|
self.rows_state.select(next);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_selection(&mut self) {
|
||||||
|
self.active = self.selection.selected();
|
||||||
|
self.columns.clear();
|
||||||
|
self.page = Page {
|
||||||
|
rows: Vec::new(),
|
||||||
|
has_next: false,
|
||||||
|
};
|
||||||
|
self.page_index = 0;
|
||||||
|
self.column_offset = 0;
|
||||||
|
self.rows_state = TableState::default();
|
||||||
|
self.schema_state = TableState::default();
|
||||||
|
self.error = None;
|
||||||
|
if let Some(index) = self.active {
|
||||||
|
match self.db.columns(&self.relations[index]) {
|
||||||
|
Ok(columns) => {
|
||||||
|
self.columns = columns;
|
||||||
|
self.schema_state
|
||||||
|
.select((!self.columns.is_empty()).then_some(0));
|
||||||
|
self.load_page(0);
|
||||||
|
}
|
||||||
|
Err(error) => self.error = Some(error_text(error.as_ref())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_page(&mut self, index: usize) {
|
||||||
|
if let Some(active) = self.active {
|
||||||
|
// ponytail: synchronous reads block input; server timeout is 5s, use a worker if responsiveness matters.
|
||||||
|
match self.db.page(&self.relations[active], &self.columns, index) {
|
||||||
|
Ok(page) => {
|
||||||
|
self.rows_state =
|
||||||
|
TableState::default().with_selected((!page.rows.is_empty()).then_some(0));
|
||||||
|
self.page = page;
|
||||||
|
self.page_index = index;
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
Err(error) => self.error = Some(error_text(error.as_ref())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&mut self) {
|
||||||
|
match self.db.relations() {
|
||||||
|
Ok(relations) => {
|
||||||
|
let oid = self.active.map(|index| self.relations[index].oid);
|
||||||
|
let selected = relations
|
||||||
|
.iter()
|
||||||
|
.position(|relation| Some(relation.oid) == oid)
|
||||||
|
.or_else(|| (!relations.is_empty()).then_some(0));
|
||||||
|
self.relations = relations;
|
||||||
|
self.selection = ListState::default().with_selected(selected);
|
||||||
|
self.open_selection();
|
||||||
|
}
|
||||||
|
Err(error) => self.error = Some(error_text(error.as_ref())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw(&mut self, frame: &mut Frame) {
|
||||||
|
let area = frame.area();
|
||||||
|
if area.width < 70 || area.height < 12 {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("Enlarge terminal to at least 70×12. q to quit."),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let [header, body, status, help] = Layout::vertical([
|
||||||
|
Constraint::Length(1),
|
||||||
|
Constraint::Min(4),
|
||||||
|
Constraint::Length(2),
|
||||||
|
Constraint::Length(2),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(" dbdash │ PostgreSQL │ READ ONLY")
|
||||||
|
.style(Style::default().fg(Color::Cyan)),
|
||||||
|
header,
|
||||||
|
);
|
||||||
|
let [sidebar, content] =
|
||||||
|
Layout::horizontal([Constraint::Length(28), Constraint::Min(1)]).areas(body);
|
||||||
|
let sidebar_title = format!(" Relations ({}) ", self.relations.len());
|
||||||
|
let sidebar_block = panel(&sidebar_title, self.sidebar_focus);
|
||||||
|
if self.relations.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("No readable tables or views.\nr to refresh.").block(sidebar_block),
|
||||||
|
sidebar,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let list = List::new(
|
||||||
|
self.relations
|
||||||
|
.iter()
|
||||||
|
.map(|relation| ListItem::new(relation.label.as_str())),
|
||||||
|
)
|
||||||
|
.block(sidebar_block)
|
||||||
|
.highlight_style(selected_style())
|
||||||
|
.highlight_symbol("› ");
|
||||||
|
frame.render_stateful_widget(list, sidebar, &mut self.selection);
|
||||||
|
}
|
||||||
|
let title = if self.show_schema {
|
||||||
|
" Schema · s for rows "
|
||||||
|
} else {
|
||||||
|
" Rows · s for schema "
|
||||||
|
};
|
||||||
|
let content_block = panel(title, !self.sidebar_focus);
|
||||||
|
let count = if self.show_schema {
|
||||||
|
5
|
||||||
|
} else {
|
||||||
|
self.columns.len()
|
||||||
|
};
|
||||||
|
let visible = usize::from(content.width.saturating_sub(4) / 21)
|
||||||
|
.max(1)
|
||||||
|
.min(count.saturating_sub(self.column_offset));
|
||||||
|
let widths = (0..visible).map(|index| {
|
||||||
|
if index + 1 == visible {
|
||||||
|
Constraint::Min(1)
|
||||||
|
} else {
|
||||||
|
Constraint::Length(20)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if self.active.is_none() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("Choose a relation and press Enter.").block(content_block),
|
||||||
|
content,
|
||||||
|
);
|
||||||
|
} else if self.show_schema {
|
||||||
|
let headers = ["Column", "Type", "Nullable", "Key", "Default"];
|
||||||
|
let rows = self.columns.iter().map(|column| {
|
||||||
|
Row::new(
|
||||||
|
[
|
||||||
|
column.label.as_str(),
|
||||||
|
column.data_type.as_str(),
|
||||||
|
if column.nullable { "yes" } else { "no" },
|
||||||
|
if column.primary_key { "PK" } else { "" },
|
||||||
|
column.default.as_str(),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.skip(self.column_offset)
|
||||||
|
.take(visible),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(
|
||||||
|
Row::new(headers.into_iter().skip(self.column_offset).take(visible))
|
||||||
|
.style(header_style()),
|
||||||
|
)
|
||||||
|
.block(content_block)
|
||||||
|
.row_highlight_style(selected_style())
|
||||||
|
.highlight_symbol("› ");
|
||||||
|
frame.render_stateful_widget(table, content, &mut self.schema_state);
|
||||||
|
} else if self.page.rows.is_empty() {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(if self.error.is_some() {
|
||||||
|
"Could not load rows. See error below."
|
||||||
|
} else {
|
||||||
|
"No rows."
|
||||||
|
})
|
||||||
|
.block(content_block),
|
||||||
|
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| {
|
||||||
|
Row::new(
|
||||||
|
row.iter()
|
||||||
|
.skip(self.column_offset)
|
||||||
|
.take(visible)
|
||||||
|
.map(|value| match value {
|
||||||
|
Some(value) => Cell::from(value.as_str()),
|
||||||
|
None => Cell::from("NULL").style(
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::DarkGray)
|
||||||
|
.add_modifier(Modifier::ITALIC),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let table = Table::new(rows, widths)
|
||||||
|
.header(Row::new(headers).style(header_style()))
|
||||||
|
.block(content_block)
|
||||||
|
.row_highlight_style(selected_style())
|
||||||
|
.highlight_symbol("› ");
|
||||||
|
frame.render_stateful_widget(table, content, &mut self.rows_state);
|
||||||
|
}
|
||||||
|
let description = if let Some(index) = self.active {
|
||||||
|
let relation = &self.relations[index];
|
||||||
|
if self.show_schema {
|
||||||
|
format!(
|
||||||
|
" {} [{}] · {} columns · fields {}–{}/5",
|
||||||
|
relation.label,
|
||||||
|
relation.kind,
|
||||||
|
self.columns.len(),
|
||||||
|
self.column_offset + 1,
|
||||||
|
self.column_offset + visible
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
let start = if self.page.rows.is_empty() {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
self.page_index * PAGE_SIZE + 1
|
||||||
|
};
|
||||||
|
let end = self.page_index * PAGE_SIZE + self.page.rows.len();
|
||||||
|
format!(
|
||||||
|
" {} [{}] · rows {start}–{end} · page {}{} · columns {}–{}/{}",
|
||||||
|
relation.label,
|
||||||
|
relation.kind,
|
||||||
|
self.page_index + 1,
|
||||||
|
if self.page.has_next { " (more)" } else { "" },
|
||||||
|
if count > 0 { self.column_offset + 1 } else { 0 },
|
||||||
|
self.column_offset + visible,
|
||||||
|
count
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
" No readable relations. r to refresh.".into()
|
||||||
|
};
|
||||||
|
let [description_area, error_area] =
|
||||||
|
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(status);
|
||||||
|
frame.render_widget(Paragraph::new(description), description_area);
|
||||||
|
if let Some(error) = &self.error {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(error.as_str()).style(Style::default().fg(Color::Red)),
|
||||||
|
error_area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
frame.render_widget(Paragraph::new(
|
||||||
|
" Tab focus · ↑↓/jk move · Enter open · s schema/rows\n ←→/hl columns · n/p page · r refresh · q/Ctrl-C quit"
|
||||||
|
).style(Style::default().fg(Color::DarkGray)), help);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn panel(title: &str, focused: bool) -> Block<'_> {
|
||||||
|
Block::bordered()
|
||||||
|
.title(title)
|
||||||
|
.border_style(Style::default().fg(if focused {
|
||||||
|
Color::Cyan
|
||||||
|
} else {
|
||||||
|
Color::DarkGray
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn selected_style() -> Style {
|
||||||
|
Style::default().bg(Color::Blue).fg(Color::White)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header_style() -> Style {
|
||||||
|
Style::default()
|
||||||
|
.fg(Color::Cyan)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn moved_index(selected: Option<usize>, count: usize, delta: isize) -> Option<usize> {
|
||||||
|
if count == 0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(
|
||||||
|
selected
|
||||||
|
.unwrap_or(0)
|
||||||
|
.saturating_add_signed(delta)
|
||||||
|
.min(count - 1),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::moved_index;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn navigation_clamps_to_available_items() {
|
||||||
|
assert_eq!(moved_index(None, 0, 1), None);
|
||||||
|
assert_eq!(moved_index(None, 3, 1), Some(1));
|
||||||
|
assert_eq!(moved_index(Some(0), 3, -1), Some(0));
|
||||||
|
assert_eq!(moved_index(Some(2), 3, 1), Some(2));
|
||||||
|
assert_eq!(moved_index(Some(1), 3, isize::MIN), Some(0));
|
||||||
|
assert_eq!(moved_index(Some(1), 3, isize::MAX), Some(2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use native_tls::TlsConnector;
|
||||||
|
use postgres::{Client, Config};
|
||||||
|
use postgres_native_tls::MakeTlsConnector;
|
||||||
|
|
||||||
|
use crate::Result;
|
||||||
|
|
||||||
|
pub const PAGE_SIZE: usize = 100;
|
||||||
|
|
||||||
|
pub struct Database {
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Relation {
|
||||||
|
pub oid: u32,
|
||||||
|
pub schema: String,
|
||||||
|
pub name: String,
|
||||||
|
pub label: String,
|
||||||
|
pub kind: String,
|
||||||
|
physical_order: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Column {
|
||||||
|
pub name: String,
|
||||||
|
pub label: String,
|
||||||
|
pub data_type: String,
|
||||||
|
pub nullable: bool,
|
||||||
|
pub primary_key: bool,
|
||||||
|
pub default: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Page {
|
||||||
|
pub rows: Vec<Vec<Option<String>>>,
|
||||||
|
pub has_next: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Database {
|
||||||
|
pub fn connect(connection: &str) -> Result<Self> {
|
||||||
|
let mut config: Config = connection.parse()?;
|
||||||
|
if config.get_connect_timeout().is_none() {
|
||||||
|
config.connect_timeout(Duration::from_secs(5));
|
||||||
|
}
|
||||||
|
let tls = MakeTlsConnector::new(TlsConnector::new()?);
|
||||||
|
let mut client = config.connect(tls)?;
|
||||||
|
// Views can invoke functions: enforce read-only on the server, not just in the UI.
|
||||||
|
client.batch_execute(
|
||||||
|
"SET default_transaction_read_only = on; SET statement_timeout = '5s';",
|
||||||
|
)?;
|
||||||
|
Ok(Self { client })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn relations(&mut self) -> Result<Vec<Relation>> {
|
||||||
|
let rows = self.client.query(
|
||||||
|
"SELECT c.oid, n.nspname, c.relname, c.relkind::text
|
||||||
|
FROM pg_catalog.pg_class c
|
||||||
|
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE c.relkind IN ('r', 'p', 'v', 'm', 'f')
|
||||||
|
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
|
||||||
|
AND n.nspname !~ '^pg_(toast|temp)'
|
||||||
|
AND pg_catalog.has_schema_privilege(n.oid, 'USAGE')
|
||||||
|
AND pg_catalog.has_table_privilege(c.oid, 'SELECT')
|
||||||
|
ORDER BY n.nspname, c.relname",
|
||||||
|
&[],
|
||||||
|
)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let schema: String = row.get(1);
|
||||||
|
let name: String = row.get(2);
|
||||||
|
let kind: &str = row.get(3);
|
||||||
|
Relation {
|
||||||
|
oid: row.get(0),
|
||||||
|
label: display_text(&format!("{schema}.{name}")),
|
||||||
|
schema,
|
||||||
|
name,
|
||||||
|
physical_order: matches!(kind, "r" | "p" | "m"),
|
||||||
|
kind: match kind {
|
||||||
|
"v" => "view",
|
||||||
|
"m" => "materialized view",
|
||||||
|
"f" => "foreign table",
|
||||||
|
"p" => "partitioned table",
|
||||||
|
_ => "table",
|
||||||
|
}
|
||||||
|
.into(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn columns(&mut self, relation: &Relation) -> Result<Vec<Column>> {
|
||||||
|
let rows = self.client.query(
|
||||||
|
"SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod),
|
||||||
|
NOT a.attnotnull,
|
||||||
|
EXISTS (SELECT FROM pg_catalog.pg_index i
|
||||||
|
WHERE i.indrelid = a.attrelid AND i.indisprimary
|
||||||
|
AND a.attnum = ANY(i.indkey)),
|
||||||
|
COALESCE(pg_catalog.pg_get_expr(d.adbin, d.adrelid), '')
|
||||||
|
FROM pg_catalog.pg_attribute a
|
||||||
|
LEFT JOIN pg_catalog.pg_attrdef d
|
||||||
|
ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
||||||
|
WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped
|
||||||
|
ORDER BY a.attnum",
|
||||||
|
&[&relation.oid],
|
||||||
|
)?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|row| {
|
||||||
|
let name: String = row.get(0);
|
||||||
|
Column {
|
||||||
|
label: display_text(&name),
|
||||||
|
name,
|
||||||
|
data_type: display_text(row.get(1)),
|
||||||
|
nullable: row.get(2),
|
||||||
|
primary_key: row.get(3),
|
||||||
|
default: display_text(row.get(4)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> {
|
||||||
|
let projection = columns
|
||||||
|
.iter()
|
||||||
|
.map(|column| format!("t.{}::text", quote_identifier(&column.name)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let primary_key = columns
|
||||||
|
.iter()
|
||||||
|
.filter(|column| column.primary_key)
|
||||||
|
.map(|column| format!("t.{}", quote_identifier(&column.name)))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let order = if !primary_key.is_empty() {
|
||||||
|
format!(" ORDER BY {primary_key}")
|
||||||
|
} else if relation.physical_order {
|
||||||
|
" ORDER BY t.tableoid, t.ctid".into()
|
||||||
|
} else {
|
||||||
|
// ponytail: keyless views have no stable row identity; use keyset paging if a key is provided.
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
// ponytail: OFFSET is simple but slows on deep pages; switch to keyset paging if that matters.
|
||||||
|
let sql = format!(
|
||||||
|
"SELECT {projection} FROM {}.{} AS t{order} LIMIT $1 OFFSET $2",
|
||||||
|
quote_identifier(&relation.schema),
|
||||||
|
quote_identifier(&relation.name),
|
||||||
|
);
|
||||||
|
let offset = i64::try_from(page)?
|
||||||
|
.checked_mul(PAGE_SIZE as i64)
|
||||||
|
.ok_or("page offset overflow")?;
|
||||||
|
let result = self
|
||||||
|
.client
|
||||||
|
.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?;
|
||||||
|
let has_next = result.len() > PAGE_SIZE;
|
||||||
|
let rows = result
|
||||||
|
.into_iter()
|
||||||
|
.take(PAGE_SIZE)
|
||||||
|
.map(|row| {
|
||||||
|
(0..columns.len())
|
||||||
|
.map(|index| row.get::<_, Option<&str>>(index).map(display_text))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Ok(Page { rows, has_next })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quote_identifier(value: &str) -> String {
|
||||||
|
format!("\"{}\"", value.replace('"', "\"\""))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Database text must never emit terminal control sequences.
|
||||||
|
pub fn display_text(value: &str) -> String {
|
||||||
|
let mut output = String::with_capacity(value.len());
|
||||||
|
for character in value.chars() {
|
||||||
|
if character.is_control() {
|
||||||
|
output.extend(character.escape_debug());
|
||||||
|
} else {
|
||||||
|
output.push(character);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn identifiers_and_terminal_controls_are_safe() {
|
||||||
|
assert_eq!(
|
||||||
|
quote_identifier("a\"; DROP TABLE x;--"),
|
||||||
|
"\"a\"\"; DROP TABLE x;--\""
|
||||||
|
);
|
||||||
|
assert_eq!(display_text("é🙂\n\t\r\u{1b}[2J"), "é🙂\\n\\t\\r\\u{1b}[2J");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
#[ignore = "requires DBDASH_TEST_DATABASE_URL pointing at a disposable PostgreSQL database"]
|
||||||
|
fn inspect_postgres_read_only() -> Result<()> {
|
||||||
|
let connection = std::env::var("DBDASH_TEST_DATABASE_URL")?;
|
||||||
|
let mut fixture = connection
|
||||||
|
.parse::<Config>()?
|
||||||
|
.connect(MakeTlsConnector::new(TlsConnector::new()?))?;
|
||||||
|
let schema = format!("dbdash_test_{}", std::process::id());
|
||||||
|
fixture.batch_execute(&format!("CREATE SCHEMA {schema}"))?;
|
||||||
|
struct Cleanup(Client, String);
|
||||||
|
impl Drop for Cleanup {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = self
|
||||||
|
.0
|
||||||
|
.batch_execute(&format!("DROP SCHEMA {} CASCADE", self.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut cleanup = Cleanup(fixture, schema.clone());
|
||||||
|
cleanup.0.batch_execute(&format!(
|
||||||
|
"CREATE TABLE {schema}.\"odd\"\"table\" (
|
||||||
|
id integer PRIMARY KEY, \"odd\"\"column\" text,
|
||||||
|
amount numeric NOT NULL DEFAULT 1.25, details jsonb,
|
||||||
|
tags text[], bytes bytea, happened timestamp);
|
||||||
|
INSERT INTO {schema}.\"odd\"\"table\"
|
||||||
|
SELECT n, CASE WHEN n = 1 THEN NULL WHEN n = 2 THEN E'line\\nnext' ELSE 'NULL' END,
|
||||||
|
1.25, '{{\"ok\": true}}', ARRAY['a', 'b'], '\\x00ff',
|
||||||
|
TIMESTAMP '2020-01-01 12:00:00'
|
||||||
|
FROM generate_series(1, 101) n;
|
||||||
|
CREATE VIEW {schema}.sample_view AS SELECT id FROM {schema}.\"odd\"\"table\";
|
||||||
|
CREATE TABLE {schema}.empty_table (id integer);"
|
||||||
|
))?;
|
||||||
|
let mut db = Database::connect(&connection)?;
|
||||||
|
let relations = db.relations()?;
|
||||||
|
let table = relations
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.schema == schema && r.name == "odd\"table")
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
relations
|
||||||
|
.iter()
|
||||||
|
.any(|r| r.schema == schema && r.kind == "view")
|
||||||
|
);
|
||||||
|
assert!(!relations.iter().any(|r| r.schema == "pg_catalog"));
|
||||||
|
let columns = db.columns(table)?;
|
||||||
|
assert!(columns[0].primary_key);
|
||||||
|
assert!(!columns[0].nullable);
|
||||||
|
assert_eq!(columns[1].name, "odd\"column");
|
||||||
|
assert!(columns[1].nullable);
|
||||||
|
assert_eq!(columns[2].data_type, "numeric");
|
||||||
|
assert_eq!(columns[2].default, "1.25");
|
||||||
|
let first = db.page(table, &columns, 0)?;
|
||||||
|
assert!(first.has_next);
|
||||||
|
assert_eq!(first.rows.len(), 100);
|
||||||
|
assert_eq!(
|
||||||
|
first.rows[0],
|
||||||
|
vec![
|
||||||
|
Some("1".into()),
|
||||||
|
None,
|
||||||
|
Some("1.25".into()),
|
||||||
|
Some("{\"ok\": true}".into()),
|
||||||
|
Some("{a,b}".into()),
|
||||||
|
Some("\\x00ff".into()),
|
||||||
|
Some("2020-01-01 12:00:00".into())
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(first.rows[1][1].as_deref(), Some("line\\nnext"));
|
||||||
|
assert_eq!(first.rows[2][1].as_deref(), Some("NULL"));
|
||||||
|
let second = db.page(table, &columns, 1)?;
|
||||||
|
assert!(!second.has_next);
|
||||||
|
assert_eq!(second.rows[0][0].as_deref(), Some("101"));
|
||||||
|
let empty = relations
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.schema == schema && r.name == "empty_table")
|
||||||
|
.unwrap();
|
||||||
|
let columns = db.columns(empty)?;
|
||||||
|
let page = db.page(empty, &columns, 0)?;
|
||||||
|
assert!(page.rows.is_empty());
|
||||||
|
assert!(!page.has_next);
|
||||||
|
let error = db
|
||||||
|
.client
|
||||||
|
.batch_execute(&format!("DELETE FROM {schema}.empty_table"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(
|
||||||
|
error.code(),
|
||||||
|
Some(&postgres::error::SqlState::READ_ONLY_SQL_TRANSACTION)
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
@@ -0,0 +1,87 @@
|
|||||||
|
mod app;
|
||||||
|
mod db;
|
||||||
|
|
||||||
|
use std::{error::Error, io::IsTerminal, process::ExitCode};
|
||||||
|
|
||||||
|
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
|
||||||
|
|
||||||
|
const HELP: &str = r#"dbdash — read-only PostgreSQL inspection
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
dbdash [POSTGRES_CONNECTION_STRING]
|
||||||
|
dbdash --help
|
||||||
|
|
||||||
|
With no argument, the connection is read from DATABASE_URL.
|
||||||
|
Prefer the environment variable to keep passwords out of process arguments:
|
||||||
|
export DATABASE_URL='postgresql://user@localhost:5432/database'
|
||||||
|
cargo run
|
||||||
|
|
||||||
|
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 terminal must be at least 70 columns wide and 12 lines high.
|
||||||
|
|
||||||
|
Navigation:
|
||||||
|
Tab / Esc Switch focus / return to relations
|
||||||
|
Up/Down, j/k Move selection (Enter opens a relation)
|
||||||
|
Home/End, g/G First / last item
|
||||||
|
s Toggle rows and column schema
|
||||||
|
Left/Right Scroll columns (h/l also work)
|
||||||
|
n/p, PgDn/Up Next / previous 100-row page
|
||||||
|
r Refresh relations and the active relation
|
||||||
|
q / Ctrl-C Quit
|
||||||
|
|
||||||
|
Reads are enforced read-only by PostgreSQL and have a 5-second query timeout.
|
||||||
|
The interface waits for reads to complete. NULL cells are dimmed.
|
||||||
|
Pages use primary-key order, or physical order for keyless tables.
|
||||||
|
Keyless views have no guaranteed order; concurrent changes can shift pages.
|
||||||
|
"#;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match run() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("dbdash: {}", error_text(error.as_ref()));
|
||||||
|
ExitCode::FAILURE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run() -> Result<()> {
|
||||||
|
let mut args = std::env::args_os().skip(1);
|
||||||
|
let connection = match args.next() {
|
||||||
|
Some(argument) if argument == "--help" || argument == "-h" => {
|
||||||
|
print!("{HELP}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Some(argument) => argument
|
||||||
|
.into_string()
|
||||||
|
.map_err(|_| "connection string must be UTF-8")?,
|
||||||
|
None => std::env::var("DATABASE_URL")
|
||||||
|
.map_err(|_| "set DATABASE_URL or pass a connection string; see dbdash --help")?,
|
||||||
|
};
|
||||||
|
if args.next().is_some() {
|
||||||
|
return Err("expected one connection string; see dbdash --help".into());
|
||||||
|
}
|
||||||
|
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
|
||||||
|
return Err("an interactive terminal is required; see dbdash --help".into());
|
||||||
|
}
|
||||||
|
let mut app = app::App::new(db::Database::connect(&connection)?)?;
|
||||||
|
let result = (|| {
|
||||||
|
let mut terminal = ratatui::try_init()?;
|
||||||
|
app.run(&mut terminal)
|
||||||
|
})();
|
||||||
|
ratatui::restore();
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_text(error: &(dyn Error + 'static)) -> String {
|
||||||
|
let mut text = error.to_string();
|
||||||
|
let mut source = error.source();
|
||||||
|
while let Some(error) = source {
|
||||||
|
text.push_str(": ");
|
||||||
|
text.push_str(&error.to_string());
|
||||||
|
source = error.source();
|
||||||
|
}
|
||||||
|
db::display_text(&text)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user