feat: add read-only SQLite inspection
This commit is contained in:
Generated
+42
-4
@@ -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
@@ -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
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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!(
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user