diff --git a/Cargo.toml b/Cargo.toml index 8ff1918..d1bfa00 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,4 +8,5 @@ askama = "0.15.6" axum = "0.8.9" clap = { version = "4.6.1", features = ["derive"] } rusqlite = { version = "0.39.0", features = ["bundled"] } +serde = { version = "1.0.228", features = ["derive"] } tokio = { version = "1.52.1", features = ["full"] } diff --git a/src/db.rs b/src/db.rs index 12b353a..a74415e 100644 --- a/src/db.rs +++ b/src/db.rs @@ -2,42 +2,61 @@ use rusqlite::types::Value; use rusqlite::{Connection, Result}; use std::sync::Mutex; -/// Thin wrapper around a `SQLite` connection. -/// Ensures safe access via Mutex (one query at a time). +/// Shared `SQLite` access layer. +/// +/// 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 { - connection: Mutex, + /// Single `SQLite` connection guarded by a Mutex. + conn: Mutex, } 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 { + let connection = Connection::open(db_path)?; + 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) + /// + /// NOTE: table name is interpolated directly → must be trusted input. pub fn fetch_table(&self, table_name: &str) -> Result<(Vec, Vec>)> { - // Lock ensures only one request uses the connection at a time - let conn = self.connection.lock().unwrap(); + let conn = self.lock_conn(); - // SQL query let query = format!("SELECT * FROM {table_name}"); - let mut stmt = conn.prepare(&query)?; let column_names = stmt .column_names() .iter() - .map(|s| s.to_string()) + .map(|name| name.to_string()) .collect::>(); - let column_count = stmt.column_count(); + let col_count = stmt.column_count(); 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 cell = match value { @@ -48,14 +67,66 @@ impl SqliteDb { Value::Blob(b) => format!("", b.len()), }; - row_values.push(cell); + values.push(cell); } - Ok(row_values) + Ok(values) })?; let rows = rows_iter.collect::>>()?; Ok((column_names, rows)) } + + /// List all tables in the database. + pub fn list_tables(&self) -> Result> { + 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::>>() + } + + /// Get raw CREATE TABLE SQL for a table. + pub fn get_table_schema(&self, table_name: &str) -> Result { + 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> { + 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::>>() + } + + /// Count number of rows in a table. + pub fn count_rows(&self, table_name: &str) -> Result { + let conn = self.lock_conn(); + + let query = format!("SELECT COUNT(*) FROM {table_name}"); + + conn.query_row(&query, [], |row| row.get(0)) + } } diff --git a/src/routes.rs b/src/routes.rs index 69ade8d..460d6ff 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,38 +1,97 @@ use askama::Template; -use axum::{extract::State, response::Html}; +use axum::{ + extract::{Query, State}, + response::Html, +}; +use serde::Deserialize; use crate::AppState; -/// Askama template for rendering a table page. -/// Field names must match variables used in `templates/table.html`. +/// Template context for the main table page. +/// +/// This struct is passed to Askama and directly maps to variables +/// used in `templates/table.html`. #[derive(Template)] #[template(path = "table.html")] pub struct TableTemplate { + /// All table names (for dropdown) + pub tables: Vec, + + /// Currently selected table pub table_name: String, + + /// Active view: "data" or "structure" + pub view: String, + + /// Data view pub columns: Vec, pub rows: Vec>, + + /// Structure view + pub schema: Option, + pub row_count: Option, + pub columns_info: Option>, // (name, type, pk) } -/// HTTP handler for GET / -/// `State` is extracted by Axum from the router. -/// This gives the handler access to shared application state. -pub async fn index(State(app_state): State) -> Html { - // `table_name` hardcoded for now; will become dynamic later - let table_name = "users"; +/// Query parameters for `/` +#[derive(Deserialize)] +pub struct TableQuery { + pub table: Option, + pub view: Option, +} - // Query DB: returns (column names, row data as strings) - let (columns, rows) = app_state // Access shared SQLite handle from AppState - .db // Shared SQLite wrapper handle - .fetch_table(table_name) - .unwrap(); // Fetch data from the table (column names, rows) as strings +/// GET / +/// +/// Flow: +/// 1. Read query params +/// 2. Resolve selected table + view +/// 3. Fetch required data depending on view +/// 4. Render template +pub async fn index( + State(app_state): State, + Query(query): Query, +) -> Html { + // Fetch available tables (for dropdown) + let tables = app_state.db.list_tables().unwrap(); - // Prepare data for HTML template - let template = TableTemplate { - table_name: table_name.into(), - columns, - rows, + // Determine selected table + let table_name = query + .table + .unwrap_or_else(|| tables.first().cloned().unwrap_or_default()); + + // 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()) } diff --git a/templates/table.html b/templates/table.html index 04a8aee..d5e2fbc 100644 --- a/templates/table.html +++ b/templates/table.html @@ -6,27 +6,100 @@ -

{{ table_name }}

+ +
+ - - - - {% for col in columns %} - - {% endfor %} - - + + + - - {% for row in rows %} - - {% for cell in row %} - - {% endfor %} - - {% endfor %} - -
{{ col }}
{{ cell }}
+
+ + +

+ + {% if view == "data" %}Data{% else %}Data{% endif %} + + | + + {% if view == "structure" %}Structure{% else %}Structure{% endif %} + +

+ + + {% if view == "data" %} + + + + + {% for col in columns %} + + {% endfor %} + + + + {% for row in rows %} + + {% for cell in row %} + + {% endfor %} + + {% endfor %} + +
{{ col }}
{{ cell }}
+ + + {% elif view == "structure" %} + +

Table: {{ table_name }}

+ + {% if let Some(count) = row_count %} +

Rows: {{ count }}

+ {% endif %} + + + {% if let Some(cols) = columns_info %} +
+ Columns + + + + + + + + {% for c in cols %} + + + + + + {% endfor %} +
NameTypePK
{{ c.0 }}{{ c.1 }}{% if c.2 %}✓{% endif %}
+
+ {% endif %} + +
+ + + {% if let Some(s) = schema %} +
+ Schema +
{{ s }}
+
+ {% endif %} + + {% endif %} \ No newline at end of file