feat: add read-only SQLite inspection
This commit is contained in:
+10
-5
@@ -3,6 +3,7 @@ use ratatui::{
|
||||
DefaultTerminal, Frame,
|
||||
layout::{Constraint, Layout},
|
||||
style::{Color, Modifier, Style},
|
||||
text::Line,
|
||||
widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
|
||||
};
|
||||
|
||||
@@ -152,7 +153,7 @@ impl App {
|
||||
|
||||
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.
|
||||
// ponytail: synchronous reads block input; use a worker if responsiveness matters.
|
||||
match self.db.page(&self.relations[active], &self.columns, index) {
|
||||
Ok(page) => {
|
||||
self.rows_state =
|
||||
@@ -169,10 +170,10 @@ impl App {
|
||||
fn refresh(&mut self) {
|
||||
match self.db.relations() {
|
||||
Ok(relations) => {
|
||||
let oid = self.active.map(|index| self.relations[index].oid);
|
||||
let id = self.active.map(|index| self.relations[index].id);
|
||||
let selected = relations
|
||||
.iter()
|
||||
.position(|relation| Some(relation.oid) == oid)
|
||||
.position(|relation| Some(relation.id) == id)
|
||||
.or_else(|| (!relations.is_empty()).then_some(0));
|
||||
self.relations = relations;
|
||||
self.selection = ListState::default().with_selected(selected);
|
||||
@@ -199,8 +200,12 @@ impl App {
|
||||
])
|
||||
.areas(area);
|
||||
frame.render_widget(
|
||||
Paragraph::new(" dbdash │ PostgreSQL │ READ ONLY")
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
Paragraph::new(Line::from_iter([
|
||||
" dbdash │ ",
|
||||
self.db.name(),
|
||||
" │ READ ONLY",
|
||||
]))
|
||||
.style(Style::default().fg(Color::Cyan)),
|
||||
header,
|
||||
);
|
||||
let [sidebar, content] =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::time::Duration;
|
||||
use std::{path::Path, time::Duration};
|
||||
|
||||
use native_tls::TlsConnector;
|
||||
use postgres::{Client, Config};
|
||||
@@ -6,14 +6,25 @@ use postgres_native_tls::MakeTlsConnector;
|
||||
|
||||
use crate::Result;
|
||||
|
||||
mod sqlite;
|
||||
|
||||
pub const PAGE_SIZE: usize = 100;
|
||||
|
||||
pub struct Database {
|
||||
client: Client,
|
||||
backend: Backend,
|
||||
}
|
||||
|
||||
#[allow(
|
||||
clippy::large_enum_variant,
|
||||
reason = "ponytail: one connection per app; box only if storing backends in bulk"
|
||||
)]
|
||||
enum Backend {
|
||||
Postgres(Client),
|
||||
Sqlite(rusqlite::Connection),
|
||||
}
|
||||
|
||||
pub struct Relation {
|
||||
pub oid: u32,
|
||||
pub id: i64,
|
||||
pub schema: String,
|
||||
pub name: String,
|
||||
pub label: String,
|
||||
@@ -47,11 +58,30 @@ impl Database {
|
||||
client.batch_execute(
|
||||
"SET default_transaction_read_only = on; SET statement_timeout = '5s';",
|
||||
)?;
|
||||
Ok(Self { client })
|
||||
Ok(Self {
|
||||
backend: Backend::Postgres(client),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn open_sqlite(path: &Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
backend: Backend::Sqlite(sqlite::open(path)?),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self.backend {
|
||||
Backend::Postgres(_) => "PostgreSQL",
|
||||
Backend::Sqlite(_) => "SQLite",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn relations(&mut self) -> Result<Vec<Relation>> {
|
||||
let rows = self.client.query(
|
||||
let client = match &mut self.backend {
|
||||
Backend::Postgres(client) => client,
|
||||
Backend::Sqlite(connection) => return sqlite::relations(connection),
|
||||
};
|
||||
let rows = 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
|
||||
@@ -70,7 +100,7 @@ impl Database {
|
||||
let name: String = row.get(2);
|
||||
let kind: &str = row.get(3);
|
||||
Relation {
|
||||
oid: row.get(0),
|
||||
id: i64::from(row.get::<_, u32>(0)),
|
||||
label: display_text(&format!("{schema}.{name}")),
|
||||
schema,
|
||||
name,
|
||||
@@ -89,7 +119,12 @@ impl Database {
|
||||
}
|
||||
|
||||
pub fn columns(&mut self, relation: &Relation) -> Result<Vec<Column>> {
|
||||
let rows = self.client.query(
|
||||
let client = match &mut self.backend {
|
||||
Backend::Postgres(client) => client,
|
||||
Backend::Sqlite(connection) => return sqlite::columns(connection, relation),
|
||||
};
|
||||
let oid = u32::try_from(relation.id)?;
|
||||
let rows = client.query(
|
||||
"SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod),
|
||||
NOT a.attnotnull,
|
||||
EXISTS (SELECT FROM pg_catalog.pg_index i
|
||||
@@ -101,7 +136,7 @@ impl Database {
|
||||
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],
|
||||
&[&oid],
|
||||
)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
@@ -120,6 +155,12 @@ impl Database {
|
||||
}
|
||||
|
||||
pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> {
|
||||
let client = match &mut self.backend {
|
||||
Backend::Postgres(client) => client,
|
||||
Backend::Sqlite(connection) => {
|
||||
return sqlite::page(connection, relation, columns, page);
|
||||
}
|
||||
};
|
||||
let projection = columns
|
||||
.iter()
|
||||
.map(|column| format!("t.{}::text", quote_identifier(&column.name)))
|
||||
@@ -145,12 +186,8 @@ impl Database {
|
||||
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 offset = page_offset(page)?;
|
||||
let result = client.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?;
|
||||
let has_next = result.len() > PAGE_SIZE;
|
||||
let rows = result
|
||||
.into_iter()
|
||||
@@ -165,6 +202,12 @@ impl Database {
|
||||
}
|
||||
}
|
||||
|
||||
fn page_offset(page: usize) -> Result<i64> {
|
||||
i64::try_from(page)?
|
||||
.checked_mul(PAGE_SIZE as i64)
|
||||
.ok_or_else(|| "page offset overflow".into())
|
||||
}
|
||||
|
||||
fn quote_identifier(value: &str) -> String {
|
||||
format!("\"{}\"", value.replace('"', "\"\""))
|
||||
}
|
||||
@@ -273,8 +316,10 @@ mod tests {
|
||||
let page = db.page(empty, &columns, 0)?;
|
||||
assert!(page.rows.is_empty());
|
||||
assert!(!page.has_next);
|
||||
let error = db
|
||||
.client
|
||||
let Backend::Postgres(client) = &mut db.backend else {
|
||||
unreachable!()
|
||||
};
|
||||
let error = client
|
||||
.batch_execute(&format!("DELETE FROM {schema}.empty_table"))
|
||||
.unwrap_err();
|
||||
assert_eq!(
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
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 crate::Result;
|
||||
|
||||
pub(super) fn open(path: &Path) -> Result<Connection> {
|
||||
// An absolute existing path also prevents SQLite's special :memory: filename from creating a database.
|
||||
let path = path.canonicalize()?;
|
||||
let connection = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)?;
|
||||
connection.busy_timeout(Duration::from_secs(5))?;
|
||||
connection.execute_batch("PRAGMA query_only = ON; PRAGMA trusted_schema = OFF;")?;
|
||||
Ok(connection)
|
||||
}
|
||||
|
||||
pub(super) fn relations(connection: &Connection) -> Result<Vec<Relation>> {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT s.rowid, s.name, s.type, t.wr
|
||||
FROM main.sqlite_schema s
|
||||
JOIN pragma_table_list t ON t.schema = 'main' AND t.name = s.name
|
||||
WHERE s.type IN ('table', 'view') AND s.name NOT GLOB 'sqlite_*'
|
||||
AND t.type <> 'shadow'
|
||||
ORDER BY s.name",
|
||||
)?;
|
||||
let relations = statement
|
||||
.query_map([], |row| {
|
||||
let name: String = row.get(1)?;
|
||||
let kind: String = row.get(2)?;
|
||||
Ok(Relation {
|
||||
id: row.get(0)?,
|
||||
schema: "main".into(),
|
||||
label: display_text(&format!("main.{name}")),
|
||||
name,
|
||||
physical_order: kind == "table" && !row.get::<_, bool>(3)?,
|
||||
kind,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(relations)
|
||||
}
|
||||
|
||||
pub(super) fn columns(connection: &Connection, relation: &Relation) -> Result<Vec<Column>> {
|
||||
let mut statement = connection.prepare(
|
||||
"SELECT name, type, \"notnull\", dflt_value, pk,
|
||||
pk > 0 AND upper(type) = 'INTEGER' AND NOT EXISTS (
|
||||
SELECT 1 FROM pragma_index_list(?1, 'main') WHERE origin = 'pk'
|
||||
) AS rowid_alias
|
||||
FROM pragma_table_xinfo(?1, 'main')
|
||||
WHERE hidden <> 1 ORDER BY cid",
|
||||
)?;
|
||||
let columns = statement
|
||||
.query_map([&relation.name], |row| {
|
||||
let name: String = row.get(0)?;
|
||||
Ok(Column {
|
||||
label: display_text(&name),
|
||||
name,
|
||||
data_type: display_text(&row.get::<_, String>(1)?),
|
||||
nullable: !row.get::<_, bool>(2)? && !row.get::<_, bool>(5)?,
|
||||
default: row
|
||||
.get::<_, Option<String>>(3)?
|
||||
.as_deref()
|
||||
.map(display_text)
|
||||
.unwrap_or_default(),
|
||||
primary_key: row.get::<_, i64>(4)? > 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(columns)
|
||||
}
|
||||
|
||||
pub(super) fn page(
|
||||
connection: &Connection,
|
||||
relation: &Relation,
|
||||
columns: &[Column],
|
||||
page: usize,
|
||||
) -> Result<Page> {
|
||||
let projection = columns
|
||||
.iter()
|
||||
.map(|column| format!("t.{}", quote_identifier(&column.name)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
let mut order = columns
|
||||
.iter()
|
||||
.filter(|column| column.primary_key)
|
||||
.map(|column| format!("t.{}", quote_identifier(&column.name)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
if order.is_empty()
|
||||
&& relation.physical_order
|
||||
&& let Some(rowid) = ["_rowid_", "rowid", "oid"].into_iter().find(|name| {
|
||||
!columns
|
||||
.iter()
|
||||
.any(|column| column.name.eq_ignore_ascii_case(name))
|
||||
})
|
||||
{
|
||||
order = format!("t.{}", quote_identifier(rowid));
|
||||
}
|
||||
if !order.is_empty() {
|
||||
order.insert_str(0, " ORDER BY ");
|
||||
}
|
||||
let sql = format!(
|
||||
"SELECT {projection} FROM {}.{} AS t{order} LIMIT ?1 OFFSET ?2",
|
||||
quote_identifier(&relation.schema),
|
||||
quote_identifier(&relation.name)
|
||||
);
|
||||
let mut statement = connection.prepare(&sql)?;
|
||||
let mut query = statement.query(params![(PAGE_SIZE + 1) as i64, page_offset(page)?])?;
|
||||
let mut rows = Vec::with_capacity(PAGE_SIZE);
|
||||
while let Some(row) = query.next()? {
|
||||
if rows.len() == PAGE_SIZE {
|
||||
return Ok(Page {
|
||||
rows,
|
||||
has_next: true,
|
||||
});
|
||||
}
|
||||
let values = (0..columns.len())
|
||||
.map(|index| {
|
||||
Ok(match row.get_ref(index)? {
|
||||
ValueRef::Null => None,
|
||||
ValueRef::Integer(value) => Some(value.to_string()),
|
||||
ValueRef::Real(value) => Some(value.to_string()),
|
||||
ValueRef::Text(value) => Some(match std::str::from_utf8(value) {
|
||||
Ok(text) => display_text(text),
|
||||
Err(_) => value.escape_ascii().to_string(),
|
||||
}),
|
||||
ValueRef::Blob(value) => Some(blob_text(value)),
|
||||
})
|
||||
})
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
rows.push(values);
|
||||
}
|
||||
Ok(Page {
|
||||
rows,
|
||||
has_next: false,
|
||||
})
|
||||
}
|
||||
|
||||
fn blob_text(bytes: &[u8]) -> String {
|
||||
let mut output = String::with_capacity(bytes.len() * 2 + 3);
|
||||
output.push_str("X'");
|
||||
for byte in bytes {
|
||||
write!(output, "{byte:02X}").expect("writing to a String cannot fail");
|
||||
}
|
||||
output.push('\'');
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::{Backend, Database};
|
||||
|
||||
#[test]
|
||||
fn inspect_sqlite_read_only() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let path = directory.path().join("inspection.sqlite");
|
||||
let writer = Connection::open(&path)?;
|
||||
writer.execute_batch(
|
||||
"CREATE TABLE \"odd\"\"table\" (
|
||||
id INTEGER PRIMARY KEY, \"odd\"\"column\" TEXT,
|
||||
amount REAL NOT NULL DEFAULT 1.25, bytes BLOB, mixed,
|
||||
generated INTEGER GENERATED ALWAYS AS (id + 1) STORED);
|
||||
WITH RECURSIVE numbers(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 101
|
||||
) INSERT INTO \"odd\"\"table\" (id, \"odd\"\"column\", amount, bytes, mixed)
|
||||
SELECT n, CASE WHEN n = 1 THEN NULL WHEN n = 2 THEN char(27) || '[2J' ELSE 'NULL' END,
|
||||
1.25, X'00FF', CASE n WHEN 1 THEN 7 WHEN 2 THEN 2.5
|
||||
WHEN 3 THEN X'0080' WHEN 4 THEN CAST(X'FF' AS TEXT) ELSE 'text' END
|
||||
FROM numbers;
|
||||
CREATE VIEW sample_view AS SELECT id FROM \"odd\"\"table\";
|
||||
CREATE TABLE empty_table (id INTEGER);
|
||||
CREATE TABLE keyed (a TEXT, b INTEGER, PRIMARY KEY (a, b)) WITHOUT ROWID;
|
||||
INSERT INTO keyed VALUES ('b', 2), ('a', 1);
|
||||
CREATE TABLE nullable_pk (id INTEGER PRIMARY KEY DESC);
|
||||
CREATE TABLE keyless (_rowid_ TEXT, rowid TEXT, value INTEGER);
|
||||
WITH RECURSIVE numbers(n) AS (
|
||||
SELECT 1 UNION ALL SELECT n + 1 FROM numbers WHERE n < 100
|
||||
) INSERT INTO keyless SELECT CAST(n AS TEXT), CAST(n AS TEXT), n FROM numbers ORDER BY n DESC;"
|
||||
)?;
|
||||
let mut db = Database::open_sqlite(&path)?;
|
||||
let relations = db.relations()?;
|
||||
assert!(
|
||||
!relations
|
||||
.iter()
|
||||
.any(|relation| relation.name.starts_with("sqlite_"))
|
||||
);
|
||||
let table = relations
|
||||
.iter()
|
||||
.find(|relation| relation.name == "odd\"table")
|
||||
.unwrap();
|
||||
let columns = db.columns(table)?;
|
||||
assert!(!columns[0].nullable);
|
||||
assert!(columns[0].primary_key);
|
||||
assert_eq!(columns[1].name, "odd\"column");
|
||||
assert!(columns[1].nullable);
|
||||
assert_eq!(columns[2].data_type, "REAL");
|
||||
assert!(!columns[2].nullable);
|
||||
assert_eq!(columns[2].default, "1.25");
|
||||
assert_eq!(columns[5].name, "generated");
|
||||
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("X'00FF'".into()),
|
||||
Some("7".into()),
|
||||
Some("2".into())
|
||||
]
|
||||
);
|
||||
assert_eq!(first.rows[1][1].as_deref(), Some("\\u{1b}[2J"));
|
||||
assert_eq!(first.rows[2][1].as_deref(), Some("NULL"));
|
||||
assert_eq!(first.rows[1][4].as_deref(), Some("2.5"));
|
||||
assert_eq!(first.rows[2][4].as_deref(), Some("X'0080'"));
|
||||
assert_eq!(first.rows[3][4].as_deref(), Some("\\xff"));
|
||||
let second = db.page(table, &columns, 1)?;
|
||||
assert!(!second.has_next);
|
||||
assert_eq!(
|
||||
second.rows,
|
||||
vec![vec![
|
||||
Some("101".into()),
|
||||
Some("NULL".into()),
|
||||
Some("1.25".into()),
|
||||
Some("X'00FF'".into()),
|
||||
Some("text".into()),
|
||||
Some("102".into())
|
||||
]]
|
||||
);
|
||||
let view = relations
|
||||
.iter()
|
||||
.find(|relation| relation.name == "sample_view")
|
||||
.unwrap();
|
||||
assert_eq!(view.kind, "view");
|
||||
let view_columns = db.columns(view)?;
|
||||
assert_eq!(
|
||||
db.page(view, &view_columns, 1)?.rows[0][0].as_deref(),
|
||||
Some("101")
|
||||
);
|
||||
let empty = relations
|
||||
.iter()
|
||||
.find(|relation| relation.name == "empty_table")
|
||||
.unwrap();
|
||||
let empty_columns = db.columns(empty)?;
|
||||
let empty_page = db.page(empty, &empty_columns, 0)?;
|
||||
assert!(empty_page.rows.is_empty());
|
||||
assert!(!empty_page.has_next);
|
||||
let keyed = relations
|
||||
.iter()
|
||||
.find(|relation| relation.name == "keyed")
|
||||
.unwrap();
|
||||
let keyed_columns = db.columns(keyed)?;
|
||||
assert!(!keyed_columns[0].nullable);
|
||||
assert_eq!(
|
||||
db.page(keyed, &keyed_columns, 0)?.rows,
|
||||
vec![
|
||||
vec![Some("a".into()), Some("1".into())],
|
||||
vec![Some("b".into()), Some("2".into())]
|
||||
]
|
||||
);
|
||||
let nullable_pk = relations
|
||||
.iter()
|
||||
.find(|relation| relation.name == "nullable_pk")
|
||||
.unwrap();
|
||||
assert!(db.columns(nullable_pk)?[0].nullable);
|
||||
let keyless = relations
|
||||
.iter()
|
||||
.find(|relation| relation.name == "keyless")
|
||||
.unwrap();
|
||||
let keyless_columns = db.columns(keyless)?;
|
||||
let page = db.page(keyless, &keyless_columns, 0)?;
|
||||
assert!(!page.has_next);
|
||||
assert_eq!(page.rows.len(), 100);
|
||||
assert_eq!(page.rows[0][2].as_deref(), Some("100"));
|
||||
assert_eq!(page.rows[99][2].as_deref(), Some("1"));
|
||||
let Backend::Sqlite(connection) = &db.backend else {
|
||||
unreachable!()
|
||||
};
|
||||
assert!(connection.is_readonly("main")?);
|
||||
let error = connection
|
||||
.execute("DELETE FROM empty_table", [])
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, rusqlite::Error::SqliteFailure(error, _) if error.code == rusqlite::ErrorCode::ReadOnly)
|
||||
);
|
||||
let missing = directory.path().join("missing.sqlite");
|
||||
assert!(Database::open_sqlite(&missing).is_err());
|
||||
assert!(!missing.exists());
|
||||
let path = directory.path().join("invalid.sqlite");
|
||||
std::fs::write(&path, b"not a SQLite database")?;
|
||||
assert!(
|
||||
Database::open_sqlite(&path)
|
||||
.and_then(|mut db| db.relations())
|
||||
.is_err()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
+38
-17
@@ -1,14 +1,15 @@
|
||||
mod app;
|
||||
mod db;
|
||||
|
||||
use std::{error::Error, io::IsTerminal, process::ExitCode};
|
||||
use std::{error::Error, io::IsTerminal, path::Path, process::ExitCode};
|
||||
|
||||
type Result<T, E = Box<dyn Error>> = std::result::Result<T, E>;
|
||||
|
||||
const HELP: &str = r#"dbdash — read-only PostgreSQL inspection
|
||||
const HELP: &str = r#"dbdash — read-only PostgreSQL and SQLite inspection
|
||||
|
||||
Usage:
|
||||
dbdash [POSTGRES_CONNECTION_STRING]
|
||||
dbdash --sqlite PATH
|
||||
dbdash --help
|
||||
|
||||
With no argument, the connection is read from DATABASE_URL.
|
||||
@@ -16,6 +17,10 @@ Prefer the environment variable to keep passwords out of process arguments:
|
||||
export DATABASE_URL='postgresql://user@localhost:5432/database'
|
||||
cargo run
|
||||
|
||||
Open an existing SQLite database file (read-only; missing files are not created):
|
||||
cargo run -- --sqlite ./database.sqlite
|
||||
SQLite paths can be relative or absolute; quote paths containing spaces.
|
||||
|
||||
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.
|
||||
@@ -31,9 +36,11 @@ Navigation:
|
||||
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.
|
||||
PostgreSQL reads are enforced read-only and have a 5-second query timeout.
|
||||
SQLite files are opened read-only, with a 5-second lock wait but no query timeout.
|
||||
The interface waits for reads to complete. NULL cells are dimmed; SQLite BLOBs
|
||||
are shown as X'hex'. Only SQLite's main database is inspected.
|
||||
Pages use primary-key order, or physical row order for keyless tables when available.
|
||||
Keyless views have no guaranteed order; concurrent changes can shift pages.
|
||||
"#;
|
||||
|
||||
@@ -49,24 +56,38 @@ fn main() -> ExitCode {
|
||||
|
||||
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")?,
|
||||
let argument = args.next();
|
||||
if argument
|
||||
.as_ref()
|
||||
.is_some_and(|arg| arg == "--help" || arg == "-h")
|
||||
{
|
||||
print!("{HELP}");
|
||||
return Ok(());
|
||||
}
|
||||
let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite");
|
||||
let connection = if sqlite {
|
||||
args.next()
|
||||
.ok_or("--sqlite requires a database file path; see dbdash --help")?
|
||||
} else {
|
||||
argument.or_else(|| std::env::var_os("DATABASE_URL"))
|
||||
.ok_or("set DATABASE_URL, pass a PostgreSQL connection string, or use --sqlite PATH; see dbdash --help")?
|
||||
};
|
||||
if args.next().is_some() {
|
||||
return Err("expected one connection string; see dbdash --help".into());
|
||||
return Err("unexpected extra arguments; 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 database = if sqlite {
|
||||
db::Database::open_sqlite(Path::new(&connection))?
|
||||
} else {
|
||||
db::Database::connect(
|
||||
connection
|
||||
.to_str()
|
||||
.ok_or("connection string must be UTF-8")?,
|
||||
)?
|
||||
};
|
||||
let mut app = app::App::new(database)?;
|
||||
let result = (|| {
|
||||
let mut terminal = ratatui::try_init()?;
|
||||
app.run(&mut terminal)
|
||||
|
||||
Reference in New Issue
Block a user