feat: add read-only SQLite inspection

This commit is contained in:
Valdemar Lorenzen
2026-10-19 12:06:25 +02:00
parent c73fa18bf3
commit bbc21f96cf
6 changed files with 460 additions and 43 deletions
Generated
+42 -4
View File
@@ -263,6 +263,8 @@ dependencies = [
"postgres", "postgres",
"postgres-native-tls", "postgres-native-tls",
"ratatui", "ratatui",
"rusqlite",
"tempfile",
] ]
[[package]] [[package]]
@@ -342,6 +344,18 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]] [[package]]
name = "fastrand" name = "fastrand"
version = "2.5.0" version = "2.5.0"
@@ -560,6 +574,17 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "line-clipping" name = "line-clipping"
version = "0.3.8" version = "0.3.8"
@@ -847,7 +872,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd" checksum = "33ad20e0aa0b24f5a394eab4f78c781d248982b22b25cecc7e3aa46a681605bd"
dependencies = [ dependencies = [
"bytes", "bytes",
"fallible-iterator", "fallible-iterator 0.2.0",
"futures-util", "futures-util",
"log", "log",
"tokio", "tokio",
@@ -875,7 +900,7 @@ dependencies = [
"base64", "base64",
"byteorder", "byteorder",
"bytes", "bytes",
"fallible-iterator", "fallible-iterator 0.2.0",
"hmac", "hmac",
"md-5", "md-5",
"memchr", "memchr",
@@ -891,7 +916,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be"
dependencies = [ dependencies = [
"bytes", "bytes",
"fallible-iterator", "fallible-iterator 0.2.0",
"postgres-protocol", "postgres-protocol",
] ]
@@ -1017,6 +1042,19 @@ dependencies = [
"bitflags", "bitflags",
] ]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags",
"fallible-iterator 0.3.0",
"fallible-streaming-iterator",
"libsqlite3-sys",
"smallvec",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1360,7 +1398,7 @@ dependencies = [
"async-trait", "async-trait",
"byteorder", "byteorder",
"bytes", "bytes",
"fallible-iterator", "fallible-iterator 0.2.0",
"futures-channel", "futures-channel",
"futures-util", "futures-util",
"log", "log",
+5 -1
View File
@@ -2,7 +2,7 @@
name = "dbdash" name = "dbdash"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
description = "Read-only PostgreSQL inspection in your terminal" description = "Read-only PostgreSQL and SQLite inspection in your terminal"
[dependencies] [dependencies]
ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] } ratatui = { version = "0.30.2", default-features = false, features = ["crossterm"] }
@@ -10,3 +10,7 @@ crossterm = "0.29"
postgres = "0.19" postgres = "0.19"
postgres-native-tls = "0.5" postgres-native-tls = "0.5"
native-tls = "0.2" native-tls = "0.2"
rusqlite = { version = "0.40", default-features = false, features = ["bundled"] }
[dev-dependencies]
tempfile = "3"
+9 -4
View File
@@ -3,6 +3,7 @@ use ratatui::{
DefaultTerminal, Frame, DefaultTerminal, Frame,
layout::{Constraint, Layout}, layout::{Constraint, Layout},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::Line,
widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState}, widgets::{Block, Cell, List, ListItem, ListState, Paragraph, Row, Table, TableState},
}; };
@@ -152,7 +153,7 @@ impl App {
fn load_page(&mut self, index: usize) { fn load_page(&mut self, index: usize) {
if let Some(active) = self.active { 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) { match self.db.page(&self.relations[active], &self.columns, index) {
Ok(page) => { Ok(page) => {
self.rows_state = self.rows_state =
@@ -169,10 +170,10 @@ impl App {
fn refresh(&mut self) { fn refresh(&mut self) {
match self.db.relations() { match self.db.relations() {
Ok(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 let selected = relations
.iter() .iter()
.position(|relation| Some(relation.oid) == oid) .position(|relation| Some(relation.id) == id)
.or_else(|| (!relations.is_empty()).then_some(0)); .or_else(|| (!relations.is_empty()).then_some(0));
self.relations = relations; self.relations = relations;
self.selection = ListState::default().with_selected(selected); self.selection = ListState::default().with_selected(selected);
@@ -199,7 +200,11 @@ impl App {
]) ])
.areas(area); .areas(area);
frame.render_widget( frame.render_widget(
Paragraph::new(" dbdash │ PostgreSQL │ READ ONLY") Paragraph::new(Line::from_iter([
" dbdash │ ",
self.db.name(),
" │ READ ONLY",
]))
.style(Style::default().fg(Color::Cyan)), .style(Style::default().fg(Color::Cyan)),
header, header,
); );
+61 -16
View File
@@ -1,4 +1,4 @@
use std::time::Duration; use std::{path::Path, time::Duration};
use native_tls::TlsConnector; use native_tls::TlsConnector;
use postgres::{Client, Config}; use postgres::{Client, Config};
@@ -6,14 +6,25 @@ use postgres_native_tls::MakeTlsConnector;
use crate::Result; use crate::Result;
mod sqlite;
pub const PAGE_SIZE: usize = 100; pub const PAGE_SIZE: usize = 100;
pub struct Database { 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 struct Relation {
pub oid: u32, pub id: i64,
pub schema: String, pub schema: String,
pub name: String, pub name: String,
pub label: String, pub label: String,
@@ -47,11 +58,30 @@ impl Database {
client.batch_execute( client.batch_execute(
"SET default_transaction_read_only = on; SET statement_timeout = '5s';", "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>> { 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 "SELECT c.oid, n.nspname, c.relname, c.relkind::text
FROM pg_catalog.pg_class c FROM pg_catalog.pg_class c
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
@@ -70,7 +100,7 @@ impl Database {
let name: String = row.get(2); let name: String = row.get(2);
let kind: &str = row.get(3); let kind: &str = row.get(3);
Relation { Relation {
oid: row.get(0), id: i64::from(row.get::<_, u32>(0)),
label: display_text(&format!("{schema}.{name}")), label: display_text(&format!("{schema}.{name}")),
schema, schema,
name, name,
@@ -89,7 +119,12 @@ impl Database {
} }
pub fn columns(&mut self, relation: &Relation) -> Result<Vec<Column>> { 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), "SELECT a.attname, pg_catalog.format_type(a.atttypid, a.atttypmod),
NOT a.attnotnull, NOT a.attnotnull,
EXISTS (SELECT FROM pg_catalog.pg_index i EXISTS (SELECT FROM pg_catalog.pg_index i
@@ -101,7 +136,7 @@ impl Database {
ON d.adrelid = a.attrelid AND d.adnum = a.attnum ON d.adrelid = a.attrelid AND d.adnum = a.attnum
WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped WHERE a.attrelid = $1 AND a.attnum > 0 AND NOT a.attisdropped
ORDER BY a.attnum", ORDER BY a.attnum",
&[&relation.oid], &[&oid],
)?; )?;
Ok(rows Ok(rows
.into_iter() .into_iter()
@@ -120,6 +155,12 @@ impl Database {
} }
pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> { pub fn page(&mut self, relation: &Relation, columns: &[Column], page: usize) -> Result<Page> {
let client = match &mut self.backend {
Backend::Postgres(client) => client,
Backend::Sqlite(connection) => {
return sqlite::page(connection, relation, columns, page);
}
};
let projection = columns let projection = columns
.iter() .iter()
.map(|column| format!("t.{}::text", quote_identifier(&column.name))) .map(|column| format!("t.{}::text", quote_identifier(&column.name)))
@@ -145,12 +186,8 @@ impl Database {
quote_identifier(&relation.schema), quote_identifier(&relation.schema),
quote_identifier(&relation.name), quote_identifier(&relation.name),
); );
let offset = i64::try_from(page)? let offset = page_offset(page)?;
.checked_mul(PAGE_SIZE as i64) let result = client.query(&sql, &[&((PAGE_SIZE + 1) as i64), &offset])?;
.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 has_next = result.len() > PAGE_SIZE;
let rows = result let rows = result
.into_iter() .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 { fn quote_identifier(value: &str) -> String {
format!("\"{}\"", value.replace('"', "\"\"")) format!("\"{}\"", value.replace('"', "\"\""))
} }
@@ -273,8 +316,10 @@ mod tests {
let page = db.page(empty, &columns, 0)?; let page = db.page(empty, &columns, 0)?;
assert!(page.rows.is_empty()); assert!(page.rows.is_empty());
assert!(!page.has_next); assert!(!page.has_next);
let error = db let Backend::Postgres(client) = &mut db.backend else {
.client unreachable!()
};
let error = client
.batch_execute(&format!("DELETE FROM {schema}.empty_table")) .batch_execute(&format!("DELETE FROM {schema}.empty_table"))
.unwrap_err(); .unwrap_err();
assert_eq!( assert_eq!(
+304
View File
@@ -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(())
}
}
+35 -14
View File
@@ -1,14 +1,15 @@
mod app; mod app;
mod db; 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>; 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: Usage:
dbdash [POSTGRES_CONNECTION_STRING] dbdash [POSTGRES_CONNECTION_STRING]
dbdash --sqlite PATH
dbdash --help dbdash --help
With no argument, the connection is read from DATABASE_URL. 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' export DATABASE_URL='postgresql://user@localhost:5432/database'
cargo run 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. PostgreSQL URLs and libpq-style key=value strings are accepted.
Use sslmode=require for mandatory TLS; certificates and hostnames are verified. Use sslmode=require for mandatory TLS; certificates and hostnames are verified.
Use sslmode=disable only for trusted local connections. Use sslmode=disable only for trusted local connections.
@@ -31,9 +36,11 @@ Navigation:
r Refresh relations and the active relation r Refresh relations and the active relation
q / Ctrl-C Quit q / Ctrl-C Quit
Reads are enforced read-only by PostgreSQL and have a 5-second query timeout. PostgreSQL reads are enforced read-only and have a 5-second query timeout.
The interface waits for reads to complete. NULL cells are dimmed. SQLite files are opened read-only, with a 5-second lock wait but no query timeout.
Pages use primary-key order, or physical order for keyless tables. 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. Keyless views have no guaranteed order; concurrent changes can shift pages.
"#; "#;
@@ -49,24 +56,38 @@ fn main() -> ExitCode {
fn run() -> Result<()> { fn run() -> Result<()> {
let mut args = std::env::args_os().skip(1); let mut args = std::env::args_os().skip(1);
let connection = match args.next() { let argument = args.next();
Some(argument) if argument == "--help" || argument == "-h" => { if argument
.as_ref()
.is_some_and(|arg| arg == "--help" || arg == "-h")
{
print!("{HELP}"); print!("{HELP}");
return Ok(()); return Ok(());
} }
Some(argument) => argument let sqlite = argument.as_ref().is_some_and(|arg| arg == "--sqlite");
.into_string() let connection = if sqlite {
.map_err(|_| "connection string must be UTF-8")?, args.next()
None => std::env::var("DATABASE_URL") .ok_or("--sqlite requires a database file path; see dbdash --help")?
.map_err(|_| "set DATABASE_URL or pass a connection string; 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() { 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() { if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
return Err("an interactive terminal is required; see dbdash --help".into()); 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 result = (|| {
let mut terminal = ratatui::try_init()?; let mut terminal = ratatui::try_init()?;
app.run(&mut terminal) app.run(&mut terminal)