Multiple views and tables
This commit is contained in:
parent
e12e57aaa7
commit
d0e95733ec
@ -8,4 +8,5 @@ askama = "0.15.6"
|
|||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
clap = { version = "4.6.1", features = ["derive"] }
|
clap = { version = "4.6.1", features = ["derive"] }
|
||||||
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
rusqlite = { version = "0.39.0", features = ["bundled"] }
|
||||||
|
serde = { version = "1.0.228", features = ["derive"] }
|
||||||
tokio = { version = "1.52.1", features = ["full"] }
|
tokio = { version = "1.52.1", features = ["full"] }
|
||||||
|
|||||||
103
src/db.rs
103
src/db.rs
@ -2,42 +2,61 @@ use rusqlite::types::Value;
|
|||||||
use rusqlite::{Connection, Result};
|
use rusqlite::{Connection, Result};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
/// Thin wrapper around a `SQLite` connection.
|
/// Shared `SQLite` access layer.
|
||||||
/// Ensures safe access via Mutex (one query at a time).
|
///
|
||||||
|
/// Internally holds a single `SQLite` connection protected by a Mutex.
|
||||||
|
/// This ensures only one query runs at a time, which is required because
|
||||||
|
/// `rusqlite::Connection` is not thread-safe.
|
||||||
|
///
|
||||||
|
/// This struct is wrapped in `Arc` at the application level.
|
||||||
pub struct SqliteDb {
|
pub struct SqliteDb {
|
||||||
connection: Mutex<Connection>,
|
/// Single `SQLite` connection guarded by a Mutex.
|
||||||
|
conn: Mutex<Connection>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SqliteDb {
|
impl SqliteDb {
|
||||||
/// Open a database file and create a shared DB handle.
|
/// Open a `SQLite` database file and create a DB handle.
|
||||||
pub fn open(db_path: &str) -> Result<Self> {
|
pub fn open(db_path: &str) -> Result<Self> {
|
||||||
|
let connection = Connection::open(db_path)?;
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
connection: Mutex::new(Connection::open(db_path)?),
|
conn: Mutex::new(connection),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch full table data (columns + rows as strings).
|
/// Acquire a locked connection.
|
||||||
|
///
|
||||||
|
/// This is the only place where `.lock()` is called, so all queries
|
||||||
|
/// consistently follow the same pattern.
|
||||||
|
fn lock_conn(&self) -> std::sync::MutexGuard<'_, Connection> {
|
||||||
|
self.conn.lock().expect("failed to lock DB connection")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all rows from a table.
|
||||||
|
///
|
||||||
|
/// Returns:
|
||||||
|
/// - column names
|
||||||
|
/// - rows (each row = Vec<String>)
|
||||||
|
///
|
||||||
|
/// NOTE: table name is interpolated directly → must be trusted input.
|
||||||
pub fn fetch_table(&self, table_name: &str) -> Result<(Vec<String>, Vec<Vec<String>>)> {
|
pub fn fetch_table(&self, table_name: &str) -> Result<(Vec<String>, Vec<Vec<String>>)> {
|
||||||
// Lock ensures only one request uses the connection at a time
|
let conn = self.lock_conn();
|
||||||
let conn = self.connection.lock().unwrap();
|
|
||||||
|
|
||||||
// SQL query
|
|
||||||
let query = format!("SELECT * FROM {table_name}");
|
let query = format!("SELECT * FROM {table_name}");
|
||||||
|
|
||||||
let mut stmt = conn.prepare(&query)?;
|
let mut stmt = conn.prepare(&query)?;
|
||||||
|
|
||||||
let column_names = stmt
|
let column_names = stmt
|
||||||
.column_names()
|
.column_names()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| s.to_string())
|
.map(|name| name.to_string())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
let column_count = stmt.column_count();
|
let col_count = stmt.column_count();
|
||||||
|
|
||||||
let rows_iter = stmt.query_map([], move |row| {
|
let rows_iter = stmt.query_map([], move |row| {
|
||||||
let mut row_values = Vec::with_capacity(column_count);
|
let mut values = Vec::with_capacity(col_count);
|
||||||
|
|
||||||
for i in 0..column_count {
|
for i in 0..col_count {
|
||||||
let value: Value = row.get(i)?;
|
let value: Value = row.get(i)?;
|
||||||
|
|
||||||
let cell = match value {
|
let cell = match value {
|
||||||
@ -48,14 +67,66 @@ impl SqliteDb {
|
|||||||
Value::Blob(b) => format!("<blob {} bytes>", b.len()),
|
Value::Blob(b) => format!("<blob {} bytes>", b.len()),
|
||||||
};
|
};
|
||||||
|
|
||||||
row_values.push(cell);
|
values.push(cell);
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(row_values)
|
Ok(values)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let rows = rows_iter.collect::<Result<Vec<_>>>()?;
|
let rows = rows_iter.collect::<Result<Vec<_>>>()?;
|
||||||
|
|
||||||
Ok((column_names, rows))
|
Ok((column_names, rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all tables in the database.
|
||||||
|
pub fn list_tables(&self) -> Result<Vec<String>> {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map([], |row| row.get(0))?;
|
||||||
|
|
||||||
|
rows.collect::<Result<Vec<_>>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get raw CREATE TABLE SQL for a table.
|
||||||
|
pub fn get_table_schema(&self, table_name: &str) -> Result<String> {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
|
||||||
|
let mut stmt =
|
||||||
|
conn.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?1")?;
|
||||||
|
|
||||||
|
let schema = stmt.query_row([table_name], |row| row.get(0))?;
|
||||||
|
|
||||||
|
Ok(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get column metadata using PRAGMA `table_info`.
|
||||||
|
///
|
||||||
|
/// Returns: `(name, type, is_primary_key)`
|
||||||
|
pub fn get_table_columns(&self, table_name: &str) -> Result<Vec<(String, String, bool)>> {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare(&format!("PRAGMA table_info({table_name})"))?;
|
||||||
|
|
||||||
|
let rows = stmt.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(1)?, // name
|
||||||
|
row.get::<_, String>(2)?, // type
|
||||||
|
row.get::<_, i64>(5)? == 1, // pk flag
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
rows.collect::<Result<Vec<_>>>()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Count number of rows in a table.
|
||||||
|
pub fn count_rows(&self, table_name: &str) -> Result<i64> {
|
||||||
|
let conn = self.lock_conn();
|
||||||
|
|
||||||
|
let query = format!("SELECT COUNT(*) FROM {table_name}");
|
||||||
|
|
||||||
|
conn.query_row(&query, [], |row| row.get(0))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,97 @@
|
|||||||
use askama::Template;
|
use askama::Template;
|
||||||
use axum::{extract::State, response::Html};
|
use axum::{
|
||||||
|
extract::{Query, State},
|
||||||
|
response::Html,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
/// Askama template for rendering a table page.
|
/// Template context for the main table page.
|
||||||
/// Field names must match variables used in `templates/table.html`.
|
///
|
||||||
|
/// This struct is passed to Askama and directly maps to variables
|
||||||
|
/// used in `templates/table.html`.
|
||||||
#[derive(Template)]
|
#[derive(Template)]
|
||||||
#[template(path = "table.html")]
|
#[template(path = "table.html")]
|
||||||
pub struct TableTemplate {
|
pub struct TableTemplate {
|
||||||
|
/// All table names (for dropdown)
|
||||||
|
pub tables: Vec<String>,
|
||||||
|
|
||||||
|
/// Currently selected table
|
||||||
pub table_name: String,
|
pub table_name: String,
|
||||||
|
|
||||||
|
/// Active view: "data" or "structure"
|
||||||
|
pub view: String,
|
||||||
|
|
||||||
|
/// Data view
|
||||||
pub columns: Vec<String>,
|
pub columns: Vec<String>,
|
||||||
pub rows: Vec<Vec<String>>,
|
pub rows: Vec<Vec<String>>,
|
||||||
|
|
||||||
|
/// Structure view
|
||||||
|
pub schema: Option<String>,
|
||||||
|
pub row_count: Option<i64>,
|
||||||
|
pub columns_info: Option<Vec<(String, String, bool)>>, // (name, type, pk)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// HTTP handler for GET /
|
/// Query parameters for `/`
|
||||||
/// `State<AppState>` is extracted by Axum from the router.
|
#[derive(Deserialize)]
|
||||||
/// This gives the handler access to shared application state.
|
pub struct TableQuery {
|
||||||
pub async fn index(State(app_state): State<AppState>) -> Html<String> {
|
pub table: Option<String>,
|
||||||
// `table_name` hardcoded for now; will become dynamic later
|
pub view: Option<String>,
|
||||||
let table_name = "users";
|
}
|
||||||
|
|
||||||
// Query DB: returns (column names, row data as strings)
|
/// GET /
|
||||||
let (columns, rows) = app_state // Access shared SQLite handle from AppState
|
///
|
||||||
.db // Shared SQLite wrapper handle
|
/// Flow:
|
||||||
.fetch_table(table_name)
|
/// 1. Read query params
|
||||||
.unwrap(); // Fetch data from the table (column names, rows) as strings
|
/// 2. Resolve selected table + view
|
||||||
|
/// 3. Fetch required data depending on view
|
||||||
|
/// 4. Render template
|
||||||
|
pub async fn index(
|
||||||
|
State(app_state): State<AppState>,
|
||||||
|
Query(query): Query<TableQuery>,
|
||||||
|
) -> Html<String> {
|
||||||
|
// Fetch available tables (for dropdown)
|
||||||
|
let tables = app_state.db.list_tables().unwrap();
|
||||||
|
|
||||||
// Prepare data for HTML template
|
// Determine selected table
|
||||||
let template = TableTemplate {
|
let table_name = query
|
||||||
table_name: table_name.into(),
|
.table
|
||||||
columns,
|
.unwrap_or_else(|| tables.first().cloned().unwrap_or_default());
|
||||||
rows,
|
|
||||||
|
// Determine active view (default = data)
|
||||||
|
let view = query.view.unwrap_or_else(|| "data".to_string());
|
||||||
|
|
||||||
|
// Data view
|
||||||
|
let (columns, rows) = if view == "data" {
|
||||||
|
app_state.db.fetch_table(&table_name).unwrap()
|
||||||
|
} else {
|
||||||
|
(Vec::new(), Vec::new())
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render template → HTML string → wrap as HTTP response
|
// Structure view
|
||||||
|
let (schema, row_count, columns_info) = if view == "structure" {
|
||||||
|
(
|
||||||
|
Some(app_state.db.get_table_schema(&table_name).unwrap()),
|
||||||
|
Some(app_state.db.count_rows(&table_name).unwrap()),
|
||||||
|
Some(app_state.db.get_table_columns(&table_name).unwrap()),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build template context
|
||||||
|
let template = TableTemplate {
|
||||||
|
tables,
|
||||||
|
table_name,
|
||||||
|
view,
|
||||||
|
columns,
|
||||||
|
rows,
|
||||||
|
schema,
|
||||||
|
row_count,
|
||||||
|
columns_info,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render HTML response
|
||||||
Html(template.render().unwrap())
|
Html(template.render().unwrap())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,27 +6,100 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h1>{{ table_name }}</h1>
|
<!-- Top bar: table selector -->
|
||||||
|
<form method="get">
|
||||||
|
<label>
|
||||||
|
Table:
|
||||||
|
<select name="table" onchange="this.form.submit()">
|
||||||
|
{% for t in tables %}
|
||||||
|
<option value="{{ t }}" {% if t == &table_name %}selected{% endif %}>
|
||||||
|
{{ t }}
|
||||||
|
</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
<table border="1" cellspacing="0" cellpadding="4">
|
<!-- Preserve current view when switching tables -->
|
||||||
<thead>
|
<input type="hidden" name="view" value="{{ view }}">
|
||||||
<tr>
|
</form>
|
||||||
{% for col in columns %}
|
|
||||||
<th>{{ col }}</th>
|
|
||||||
{% endfor %}
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
|
|
||||||
<tbody>
|
<br>
|
||||||
{% for row in rows %}
|
|
||||||
<tr>
|
<!-- Tabs -->
|
||||||
{% for cell in row %}
|
<p>
|
||||||
<td>{{ cell }}</td>
|
<a href="/?table={{ table_name }}&view=data">
|
||||||
{% endfor %}
|
{% if view == "data" %}<b>Data</b>{% else %}Data{% endif %}
|
||||||
</tr>
|
</a>
|
||||||
{% endfor %}
|
|
|
||||||
</tbody>
|
<a href="/?table={{ table_name }}&view=structure">
|
||||||
</table>
|
{% if view == "structure" %}<b>Structure</b>{% else %}Structure{% endif %}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- DATA VIEW -->
|
||||||
|
{% if view == "data" %}
|
||||||
|
|
||||||
|
<table border="1" cellspacing="0" cellpadding="4">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{% for col in columns %}
|
||||||
|
<th>{{ col }}</th>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for row in rows %}
|
||||||
|
<tr>
|
||||||
|
{% for cell in row %}
|
||||||
|
<td>{{ cell }}</td>
|
||||||
|
{% endfor %}
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<!-- STRUCTURE VIEW -->
|
||||||
|
{% elif view == "structure" %}
|
||||||
|
|
||||||
|
<h2>Table: {{ table_name }}</h2>
|
||||||
|
|
||||||
|
{% if let Some(count) = row_count %}
|
||||||
|
<p><b>Rows:</b> {{ count }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Columns -->
|
||||||
|
{% if let Some(cols) = columns_info %}
|
||||||
|
<details>
|
||||||
|
<summary><b>Columns</b></summary>
|
||||||
|
|
||||||
|
<table border="1" cellspacing="0" cellpadding="4">
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>PK</th>
|
||||||
|
</tr>
|
||||||
|
{% for c in cols %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ c.0 }}</td>
|
||||||
|
<td>{{ c.1 }}</td>
|
||||||
|
<td>{% if c.2 %}✓{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<!-- Schema -->
|
||||||
|
{% if let Some(s) = schema %}
|
||||||
|
<details>
|
||||||
|
<summary><b>Schema</b></summary>
|
||||||
|
<pre>{{ s }}</pre>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user