vapor_cli/
display.rs

1//! # Query Result Display and Formatting
2//!
3//! This module is responsible for executing SQL queries and presenting the results in various
4//! formats. It handles the formatting of data into tables, JSON, and CSV, and also provides
5//! utility functions for displaying database metadata like table schemas and statistics.
6//!
7//! ## Core Components:
8//! - `execute_sql`: The main function that runs a SQL query and manages the display of its results.
9//! - `OutputFormat`: An enum to specify the desired output format (`Table`, `Json`, `Csv`).
10//! - `QueryOptions`: A struct to control display settings like row limits and timing information.
11//! - Schema Display: Functions like `show_table_schema` and `show_all_schemas` for inspecting the DB structure.
12//! - Database Info: `show_database_info` provides a summary of the database file and its contents.
13//!
14//! The module also includes experimental, currently unused features for result caching (`QueryCache`)
15//! and progressive data loading (`ProgressiveLoader`).
16
17use anyhow::{Context, Result};
18use prettytable::{row, Table};
19use rusqlite::{params, Connection};
20use serde_json::{json, Value};
21use std::collections::HashMap;
22use std::error::Error;
23use std::fmt;
24use std::time::{Duration, Instant};
25
26/// Specifies the output format for query results.
27#[derive(Debug, Clone)]
28pub enum OutputFormat {
29    Table,
30    Json,
31    Csv,
32}
33
34#[allow(dead_code)]
35/// Custom error types for display-related operations.
36///
37/// Note: This is currently not used extensively but is defined for future error handling enhancements.
38#[derive(Debug)]
39#[allow(dead_code)]
40pub enum DisplayError {
41    DatabaseError(String),
42    QueryError(String),
43}
44
45impl fmt::Display for DisplayError {
46    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
47        match self {
48            DisplayError::QueryError(msg) => write!(f, "Query error: {}", msg),
49            DisplayError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
50        }
51    }
52}
53
54impl Error for DisplayError {}
55
56/// Defines options for controlling how a query is executed and displayed.
57pub struct QueryOptions {
58    pub format: OutputFormat,
59    pub max_rows: Option<usize>,
60    pub show_timing: bool,
61}
62
63impl Default for QueryOptions {
64    fn default() -> Self {
65        Self {
66            format: OutputFormat::Table,
67            max_rows: Some(1000),
68            show_timing: true,
69        }
70    }
71}
72
73#[allow(dead_code)]
74/// A cache for storing and retrieving query results to improve performance for repeated queries.
75///
76/// Note: This feature is experimental and not currently integrated into the REPL or CLI.
77#[allow(dead_code)]
78pub struct QueryCache {
79    results: HashMap<String, (Vec<Vec<String>>, Instant)>,
80    max_size: usize,
81    ttl: Duration,
82}
83
84#[allow(dead_code)]
85impl QueryCache {
86    pub fn new(max_size: usize, ttl: Duration) -> Self {
87        Self {
88            results: HashMap::new(),
89            max_size,
90            ttl,
91        }
92    }
93
94    pub fn get(&self, query: &str) -> Option<&Vec<Vec<String>>> {
95        if let Some((results, timestamp)) = self.results.get(query) {
96            if timestamp.elapsed() < self.ttl {
97                return Some(results);
98            }
99        }
100        None
101    }
102
103    pub fn insert(&mut self, query: String, results: Vec<Vec<String>>) {
104        // Remove oldest entries if cache is full
105        if self.results.len() >= self.max_size {
106            let oldest_key = self
107                .results
108                .iter()
109                .min_by_key(|(_, (_, time))| time)
110                .map(|(key, _)| key.clone());
111
112            if let Some(key) = oldest_key {
113                self.results.remove(&key);
114            }
115        }
116
117        self.results.insert(query, (results, Instant::now()));
118    }
119
120    pub fn clear(&mut self) {
121        self.results.clear();
122    }
123}
124
125#[allow(dead_code)]
126/// A helper for loading and displaying large result sets in batches to avoid high memory usage.
127///
128/// Note: This feature is experimental and not currently integrated into the REPL or CLI.
129#[allow(dead_code)]
130pub struct ProgressiveLoader {
131    batch_size: usize,
132    total_rows: usize,
133    loaded_rows: usize,
134    column_names: Vec<String>,
135    current_batch: Vec<Vec<String>>,
136}
137
138#[allow(dead_code)]
139impl ProgressiveLoader {
140    pub fn new(batch_size: usize, column_names: Vec<String>) -> Self {
141        Self {
142            batch_size,
143            total_rows: 0,
144            loaded_rows: 0,
145            column_names,
146            current_batch: Vec::new(),
147        }
148    }
149
150    pub fn add_row(&mut self, row: Vec<String>) {
151        self.current_batch.push(row);
152        self.loaded_rows += 1;
153
154        if self.current_batch.len() >= self.batch_size {
155            self.flush_batch();
156        }
157    }
158
159    pub fn flush_batch(&mut self) {
160        if !self.current_batch.is_empty() {
161            display_as_table(&self.column_names, &self.current_batch);
162            println!("Loaded {}/{} rows...", self.loaded_rows, self.total_rows);
163            self.current_batch.clear();
164        }
165    }
166
167    pub fn set_total_rows(&mut self, total: usize) {
168        self.total_rows = total;
169    }
170
171    pub fn is_complete(&self) -> bool {
172        self.loaded_rows >= self.total_rows
173    }
174}
175
176/// Executes a SQL statement and displays the results according to the provided options.
177///
178/// This function handles both `SELECT` queries, which produce result sets, and other
179/// statements (e.g., `INSERT`, `UPDATE`, `CREATE`), which report the number of affected rows.
180///
181/// # Arguments
182///
183/// * `conn` - A reference to the active `rusqlite::Connection`.
184/// * `sql` - The SQL string to execute.
185/// * `options` - A `QueryOptions` struct specifying the format, row limit, and other settings.
186///
187/// # Returns
188///
189/// A `Result` which is `Ok(())` on success, or an `Err` if the query fails to prepare or execute.
190pub fn execute_sql(conn: &Connection, sql: &str, options: &QueryOptions) -> Result<()> {
191    let start_time = Instant::now();
192
193    // Execute the query
194    let mut stmt = conn
195        .prepare(sql)
196        .context("Failed to prepare SQL statement")?;
197
198    // Check if it's a SELECT query
199    let is_select = sql.trim().to_uppercase().starts_with("SELECT");
200
201    if is_select {
202        // Get column names before executing the query
203        let column_names: Vec<String> = stmt
204            .column_names()
205            .iter()
206            .map(|name| name.to_string())
207            .collect();
208
209        let mut rows = stmt.query([]).context("Failed to execute SELECT query")?;
210
211        // Collect all rows
212        let mut all_rows = Vec::new();
213        let mut row_count = 0;
214
215        while let Some(row) = rows.next()? {
216            let mut row_values = Vec::new();
217            for i in 0..column_names.len() {
218                let value = match row.get_ref(i)? {
219                    rusqlite::types::ValueRef::Null => "NULL".to_string(),
220                    rusqlite::types::ValueRef::Integer(val) => val.to_string(),
221                    rusqlite::types::ValueRef::Real(val) => val.to_string(),
222                    rusqlite::types::ValueRef::Text(val) => {
223                        String::from_utf8_lossy(val).to_string()
224                    }
225                    rusqlite::types::ValueRef::Blob(val) => {
226                        format!("<binary data: {} bytes>", val.len())
227                    }
228                };
229                row_values.push(value);
230            }
231            all_rows.push(row_values);
232            row_count += 1;
233
234            if let Some(limit) = options.max_rows {
235                if row_count >= limit {
236                    break;
237                }
238            }
239        }
240
241        // Display results based on format
242        if !all_rows.is_empty() {
243            match options.format {
244                OutputFormat::Table => display_as_table(&column_names, &all_rows),
245                OutputFormat::Json => display_as_json(&column_names, &all_rows)?,
246                OutputFormat::Csv => display_as_csv(&column_names, &all_rows),
247            }
248        }
249
250        println!("{} row(s) returned", row_count);
251
252        if let Some(limit) = options.max_rows {
253            if row_count >= limit {
254                println!(
255                    "(Limited to {} rows. Use '.limit 0' to show all rows)",
256                    limit
257                );
258            }
259        }
260    } else {
261        // For non-SELECT queries
262        let affected = stmt
263            .execute([])
264            .context("Failed to execute non-SELECT query")?;
265
266        println!("{} row(s) affected", affected);
267    }
268
269    if options.show_timing {
270        println!(
271            "Query executed in {:.3}ms",
272            start_time.elapsed().as_secs_f64() * 1000.0
273        );
274    }
275
276    Ok(())
277}
278
279/// Formats and prints query results as a bordered table to the console.
280fn display_as_table(column_names: &[String], rows: &[Vec<String>]) {
281    let mut table = Table::new();
282    table.set_format(*prettytable::format::consts::FORMAT_BOX_CHARS);
283
284    // Add header row
285    let mut header_row = prettytable::Row::empty();
286    for col_name in column_names {
287        header_row.add_cell(prettytable::Cell::new(col_name).style_spec("b"));
288    }
289    table.add_row(header_row);
290
291    // Add data rows
292    for row_values in rows {
293        let mut data_row = prettytable::Row::empty();
294        for value in row_values {
295            data_row.add_cell(prettytable::Cell::new(value));
296        }
297        table.add_row(data_row);
298    }
299
300    table.printstd();
301}
302
303/// Formats and prints query results as a JSON object to the console.
304///
305/// The JSON output includes the column names, the number of rows, and the data itself.
306/// It attempts to infer numeric types from the string values.
307fn display_as_json(column_names: &[String], rows: &[Vec<String>]) -> Result<()> {
308    let mut json_rows = Vec::new();
309
310    for row_values in rows {
311        let mut json_row = serde_json::Map::new();
312        for (i, value) in row_values.iter().enumerate() {
313            let json_value = if value == "NULL" {
314                Value::Null
315            } else if let Ok(int_val) = value.parse::<i64>() {
316                Value::Number(serde_json::Number::from(int_val))
317            } else if let Ok(float_val) = value.parse::<f64>() {
318                Value::Number(
319                    serde_json::Number::from_f64(float_val)
320                        .unwrap_or_else(|| serde_json::Number::from(0)),
321                )
322            } else {
323                Value::String(value.clone())
324            };
325            json_row.insert(column_names[i].clone(), json_value);
326        }
327        json_rows.push(Value::Object(json_row));
328    }
329
330    let output = json!({
331        "data": json_rows,
332        "columns": column_names,
333        "row_count": rows.len()
334    });
335
336    println!("{}", serde_json::to_string_pretty(&output)?);
337    Ok(())
338}
339
340/// Formats and prints query results as CSV data to the console.
341///
342/// This function handles basic CSV escaping for values containing commas or quotes.
343fn display_as_csv(column_names: &[String], rows: &[Vec<String>]) {
344    // Print header
345    println!("{}", column_names.join(","));
346
347    // Print rows
348    for row_values in rows {
349        let escaped_values: Vec<String> = row_values
350            .iter()
351            .map(|v| {
352                if v.contains(',') || v.contains('"') || v.contains('\n') {
353                    format!("\"{}\"", v.replace('"', "\"\""))
354                } else {
355                    v.clone()
356                }
357            })
358            .collect();
359        println!("{}", escaped_values.join(","));
360    }
361}
362
363/// Displays the schema for a specific table, including column names, types, and constraints.
364///
365/// It uses `PRAGMA table_info` to retrieve the schema information from SQLite.
366///
367/// # Arguments
368///
369/// * `conn` - A reference to the active `rusqlite::Connection`.
370/// * `table_name` - The name of the table to inspect.
371///
372/// # Returns
373///
374/// A `Result` which is `Ok(())` on success, or an `Err` on failure.
375pub fn show_table_schema(conn: &Connection, table_name: &str) -> Result<()> {
376    // Check if the table exists
377    let mut check_stmt = conn
378        .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = ?")
379        .context("Failed to prepare statement for checking table existence")?;
380
381    let exists: bool = check_stmt
382        .exists(params![table_name])
383        .context(format!("Failed to check if table '{}' exists", table_name))?;
384
385    if !exists {
386        println!("Table '{}' does not exist.", table_name);
387        return Ok(());
388    }
389
390    // Get the table schema
391    let pragma_sql = format!("PRAGMA table_info({})", table_name);
392    let mut stmt = conn.prepare(&pragma_sql).context(format!(
393        "Failed to prepare statement for table schema: {}",
394        table_name
395    ))?;
396
397    let columns = stmt
398        .query_map(params![], |row| {
399            Ok((
400                row.get::<_, i32>(0)?,            // cid
401                row.get::<_, String>(1)?,         // name
402                row.get::<_, String>(2)?,         // type
403                row.get::<_, bool>(3)?,           // notnull
404                row.get::<_, Option<String>>(4)?, // dflt_value
405                row.get::<_, i32>(5)?,            // pk
406            ))
407        })
408        .context(format!("Failed to query schema for table: {}", table_name))?;
409
410    // Create a pretty table for display
411    let mut table = Table::new();
412    table.set_format(*prettytable::format::consts::FORMAT_BOX_CHARS);
413    table.add_row(row![
414        "ID",
415        "Name",
416        "Type",
417        "Not Null",
418        "Default Value",
419        "Primary Key"
420    ]);
421
422    let mut has_columns = false;
423    for column_result in columns {
424        has_columns = true;
425        let (cid, name, type_name, not_null, default_value, pk) = column_result.context(
426            format!("Failed to read column info for table: {}", table_name),
427        )?;
428
429        let not_null_str = if not_null { "YES" } else { "NO" };
430        let pk_str = if pk > 0 { "YES" } else { "NO" };
431        let default_str = default_value.unwrap_or_else(|| "NULL".to_string());
432
433        table.add_row(row![
434            cid,
435            name,
436            type_name,
437            not_null_str,
438            default_str,
439            pk_str
440        ]);
441    }
442
443    if has_columns {
444        println!("Schema for table '{}':", table_name);
445        table.printstd();
446    } else {
447        println!("No columns found for table: {}", table_name);
448    }
449
450    Ok(())
451}
452
453/// Iterates through all user-defined tables in the database and displays the schema for each one.
454///
455/// It queries the `sqlite_master` table to find all tables and then calls `show_table_schema` for each.
456///
457/// # Arguments
458///
459/// * `conn` - A reference to the active `rusqlite::Connection`.
460///
461/// # Returns
462///
463/// A `Result` which is `Ok(())` on success, or an `Err` on failure.
464pub fn show_all_schemas(conn: &Connection) -> Result<()> {
465    // Get all table names
466    let mut stmt = conn
467        .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
468        .context("Failed to prepare statement for listing tables")?;
469
470    let table_names = stmt
471        .query_map(params![], |row| row.get::<_, String>(0))
472        .context("Failed to query tables")?;
473
474    let mut has_tables = false;
475    for (i, table_name_result) in table_names.enumerate() {
476        has_tables = true;
477        let table_name = table_name_result.context("Failed to read table name")?;
478        if i > 0 {
479            println!();
480        }
481        show_table_schema(conn, &table_name)?;
482    }
483
484    if !has_tables {
485        println!("No tables found in the database.");
486    }
487
488    Ok(())
489}
490
491/// Displays general information and statistics about the connected database.
492///
493/// This includes the database file path, size, SQLite version, and row counts for each table.
494///
495/// # Arguments
496///
497/// * `conn` - A reference to the active `rusqlite::Connection`.
498/// * `db_path` - The file path of the database, used to calculate its size.
499///
500/// # Returns
501///
502/// A `Result` which is `Ok(())` on success, or an `Err` on failure.
503pub fn show_database_info(conn: &Connection, db_path: &str) -> Result<()> {
504    println!("Database Information:");
505    println!("  Path: {}", db_path);
506
507    // Get database file size
508    if let Ok(metadata) = std::fs::metadata(db_path) {
509        let size_mb = metadata.len() as f64 / (1024.0 * 1024.0);
510        println!("  Size: {:.2} MB", size_mb);
511    }
512
513    // Get SQLite version
514    let version: String = conn.query_row("SELECT sqlite_version()", [], |row| row.get(0))?;
515    println!("  SQLite Version: {}", version);
516
517    // Get page size and page count
518    let page_size: i64 = conn.query_row("PRAGMA page_size", [], |row| row.get(0))?;
519    let page_count: i64 = conn.query_row("PRAGMA page_count", [], |row| row.get(0))?;
520    println!("  Page Size: {} bytes", page_size);
521    println!("  Page Count: {}", page_count);
522
523    // Get table statistics
524    let mut stmt = conn.prepare(
525        "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'",
526    )?;
527    let table_names = stmt.query_map([], |row| row.get::<_, String>(0))?;
528
529    println!("\nTable Statistics:");
530    let mut total_rows = 0;
531
532    for table_name_result in table_names {
533        let table_name = table_name_result?;
534        let count_sql = format!("SELECT COUNT(*) FROM {}", table_name);
535        let row_count: i64 = conn.query_row(&count_sql, [], |row| row.get(0))?;
536        println!("  {}: {} rows", table_name, row_count);
537        total_rows += row_count;
538    }
539
540    println!("  Total Rows: {}", total_rows);
541
542    Ok(())
543}