vapor_cli/
export.rs

1//! # Data Import and Export
2//!
3//! This module provides functionality for importing data into and exporting data from the
4//! SQLite database. It currently focuses on the CSV format, which is a common and
5//! versatile format for data interchange.
6//!
7//! ## Key Functions:
8//! - `import_csv_to_table`: Imports data from a CSV file into a specified database table.
9//! - `export_to_csv`: Exports the results of a SQL query to a CSV file.
10//!
11//! The module includes robust error handling, input validation, and progress indicators
12//! for long-running operations to ensure a reliable user experience.
13
14use anyhow::{Context, Result};
15use rusqlite::Connection;
16use std::path::Path;
17
18/// Imports data from a CSV file into a specified database table.
19///
20/// This function reads a CSV file, using the header row to map columns to the
21/// corresponding columns in the target table. The entire import process is wrapped
22/// in a single database transaction to ensure atomicity.
23///
24/// # Arguments
25///
26/// * `conn` - A mutable reference to the active `rusqlite::Connection`.
27/// * `file_path` - The path to the CSV file to be imported.
28/// * `table_name` - The name of the database table to insert data into.
29///
30/// # Returns
31///
32/// A `Result` which is `Ok(())` on successful import, or an `Err` if the file cannot
33/// be read, the CSV is malformed, or the database insertion fails.
34pub fn import_csv_to_table(conn: &mut Connection, file_path: &str, table_name: &str) -> Result<()> {
35    let file = Path::new(file_path);
36    if !file.exists() {
37        anyhow::bail!("File not found: {}", file_path);
38    }
39
40    let mut rdr = csv::Reader::from_path(file_path)?;
41    let headers = rdr.headers()?.clone();
42
43    let tx = conn.transaction()?;
44
45    {
46        let sql = format!(
47            "INSERT INTO {} ({}) VALUES ({})",
48            table_name,
49            headers
50                .iter()
51                .map(|h| format!("\"{}\"", h))
52                .collect::<Vec<_>>()
53                .join(","),
54            headers.iter().map(|_| "?").collect::<Vec<_>>().join(",")
55        );
56
57        let mut stmt = tx.prepare(&sql)?;
58
59        for result in rdr.records() {
60            let record = result?;
61            let params: Vec<&str> = record.iter().collect();
62            stmt.execute(rusqlite::params_from_iter(params))?;
63        }
64    } // stmt is dropped here
65
66    tx.commit()?;
67    Ok(())
68}
69
70/// Exports the results of a SQL query to a CSV file.
71///
72/// This function executes a given `SELECT` query and writes the entire result set to a
73/// specified CSV file. It includes comprehensive validation of inputs, progress updates
74/// for large exports, and robust error handling during file writing.
75///
76/// # Arguments
77///
78/// * `conn` - A reference to the active `rusqlite::Connection`.
79/// * `query` - The `SELECT` SQL query whose results will be exported.
80/// * `filename` - The path to the output CSV file. The file will be overwritten if it exists.
81///
82/// # Returns
83///
84/// A `Result` which is `Ok(())` on successful export, or an `Err` if the query is invalid,
85/// the file cannot be written, or other errors occur during the process.
86pub fn export_to_csv(conn: &Connection, query: &str, filename: &str) -> Result<()> {
87    // Validate inputs
88    validate_export_inputs(query, filename)?;
89
90    // Prepare the statement with error handling
91    let mut stmt = conn.prepare(query).with_context(|| {
92        format!(
93            "Failed to prepare export query. Check SQL syntax: {}",
94            query
95        )
96    })?;
97
98    let column_names: Vec<String> = stmt.column_names().iter().map(|&s| s.to_string()).collect();
99
100    if column_names.is_empty() {
101        anyhow::bail!(
102            "Query returned no columns. Make sure your query includes SELECT statements."
103        );
104    }
105
106    // Create the CSV writer with error handling
107    let mut wtr = csv::Writer::from_path(filename).with_context(|| {
108        format!(
109            "Failed to create CSV file '{}'. Check permissions and disk space.",
110            filename
111        )
112    })?;
113
114    // Write header row
115    wtr.write_record(&column_names)
116        .with_context(|| format!("Failed to write CSV header to '{}'", filename))?;
117
118    // Execute query and write rows with progress tracking
119    let mut rows = stmt
120        .query([])
121        .with_context(|| format!("Failed to execute export query: {}", query))?;
122
123    let mut row_count = 0;
124    let mut error_count = 0;
125
126    while let Some(row) = rows
127        .next()
128        .with_context(|| format!("Failed to fetch row {} from query results", row_count + 1))?
129    {
130        match process_row(&row, &column_names) {
131            Ok(record) => {
132                if let Err(e) = wtr.write_record(&record) {
133                    error_count += 1;
134                    eprintln!("Warning: Failed to write row {}: {}", row_count + 1, e);
135
136                    if error_count > 10 {
137                        anyhow::bail!("Too many write errors ({}). Stopping export.", error_count);
138                    }
139                } else {
140                    row_count += 1;
141
142                    // Progress indicator for large exports
143                    if row_count % 10000 == 0 {
144                        println!("Exported {} rows...", row_count);
145                    }
146                }
147            }
148            Err(e) => {
149                error_count += 1;
150                eprintln!("Warning: Failed to process row {}: {}", row_count + 1, e);
151
152                if error_count > 10 {
153                    anyhow::bail!(
154                        "Too many processing errors ({}). Stopping export.",
155                        error_count
156                    );
157                }
158            }
159        }
160    }
161
162    // Ensure all data is written to disk
163    wtr.flush()
164        .with_context(|| format!("Failed to flush data to CSV file '{}'", filename))?;
165
166    // Verify the file was created successfully
167    verify_export_file(filename, row_count)?;
168
169    if error_count > 0 {
170        println!("Export completed with {} warning(s)", error_count);
171    }
172
173    println!("Successfully exported {} rows to '{}'", row_count, filename);
174
175    Ok(())
176}
177
178/// Helper function to validate the inputs for the `export_to_csv` function.
179///
180/// Performs checks for:
181/// - Non-empty query and filename.
182/// - Presence of a `SELECT` statement in the query.
183/// - Warnings for potentially destructive keywords (e.g., `DROP`, `DELETE`).
184/// - Invalid characters in the filename.
185/// - Existence of the output directory.
186fn validate_export_inputs(query: &str, filename: &str) -> Result<()> {
187    // Validate query
188    if query.trim().is_empty() {
189        anyhow::bail!("Export query cannot be empty");
190    }
191
192    let query_lower = query.to_lowercase();
193    if !query_lower.contains("select") {
194        anyhow::bail!("Export query must contain a SELECT statement");
195    }
196
197    // Check for potentially dangerous operations
198    let dangerous_keywords = ["drop", "delete", "update", "insert", "create", "alter"];
199    for keyword in &dangerous_keywords {
200        if query_lower.contains(keyword) {
201            eprintln!(
202                "Warning: Query contains '{}' - this may modify data",
203                keyword
204            );
205        }
206    }
207
208    // Validate filename
209    if filename.trim().is_empty() {
210        anyhow::bail!("Filename cannot be empty");
211    }
212
213    if filename.len() > 255 {
214        anyhow::bail!("Filename is too long (maximum 255 characters)");
215    }
216
217    // Check for invalid filename characters (but allow path separators)
218    let path = Path::new(filename);
219    if let Some(file_name) = path.file_name() {
220        let name_str = file_name.to_string_lossy();
221        // Only check the actual filename part, not the full path
222        if name_str
223            .chars()
224            .any(|c| c.is_control() || "\\:*?\"<>|".contains(c))
225        {
226            anyhow::bail!("Filename contains invalid characters. Avoid: \\ : * ? \" < > |");
227        }
228    }
229
230    // Check if file already exists and warn user
231    if Path::new(filename).exists() {
232        eprintln!(
233            "Warning: File '{}' already exists and will be overwritten",
234            filename
235        );
236    }
237
238    // Check if the directory is writable
239    if let Some(parent) = Path::new(filename).parent() {
240        if parent != Path::new("") && !parent.exists() {
241            anyhow::bail!(
242                "Directory '{}' does not exist. Create it first or use a different path.",
243                parent.display()
244            );
245        }
246    }
247
248    Ok(())
249}
250
251/// Helper function to process a single database row into a vector of strings for CSV writing.
252///
253/// Handles the conversion of different SQLite data types (`Null`, `Integer`, `Real`, `Text`, `Blob`)
254/// into their string representations. It also escapes text fields as needed for the CSV format.
255fn process_row(row: &rusqlite::Row, column_names: &[String]) -> Result<Vec<String>> {
256    let mut record = Vec::with_capacity(column_names.len());
257
258    for i in 0..column_names.len() {
259        let val: rusqlite::types::Value = row.get(i).with_context(|| {
260            format!(
261                "Failed to get value from column {} ('{}')",
262                i, column_names[i]
263            )
264        })?;
265
266        let value_str = match val {
267            rusqlite::types::Value::Null => String::new(),
268            rusqlite::types::Value::Integer(i) => i.to_string(),
269            rusqlite::types::Value::Real(f) => {
270                // Handle special float values
271                if f.is_nan() {
272                    "NaN".to_string()
273                } else if f.is_infinite() {
274                    if f.is_sign_positive() {
275                        "Infinity".to_string()
276                    } else {
277                        "-Infinity".to_string()
278                    }
279                } else {
280                    f.to_string()
281                }
282            }
283            rusqlite::types::Value::Text(t) => {
284                // Escape CSV special characters if needed
285                if t.contains(',') || t.contains('"') || t.contains('\n') {
286                    format!("\"{}\"", t.replace('"', "\"\""))
287                } else {
288                    t
289                }
290            }
291            rusqlite::types::Value::Blob(b) => {
292                // For binary data, provide a more informative representation
293                format!("[BLOB {} bytes]", b.len())
294            }
295        };
296
297        record.push(value_str);
298    }
299
300    Ok(record)
301}
302
303/// Helper function to verify that the export file was created and appears valid.
304///
305/// Checks if the file exists and if its size is non-zero when rows were expected to be written.
306fn verify_export_file(filename: &str, expected_rows: usize) -> Result<()> {
307    let path = Path::new(filename);
308
309    if !path.exists() {
310        anyhow::bail!("Export file '{}' was not created", filename);
311    }
312
313    let metadata = std::fs::metadata(path)
314        .with_context(|| format!("Cannot read metadata for export file '{}'", filename))?;
315
316    if metadata.len() == 0 && expected_rows > 0 {
317        anyhow::bail!(
318            "Export file '{}' is empty but {} rows were expected",
319            filename,
320            expected_rows
321        );
322    }
323
324    // Basic file size sanity check
325    let file_size = metadata.len();
326    if file_size > 0 {
327        println!("Export file size: {} bytes", file_size);
328    }
329
330    Ok(())
331}