Skip to main content

cqlite_cli/output/
csv.rs

1//! CSV output writer for QueryResult
2//!
3//! Implements CSV output format following QUERY_RESULT_CONTRACT.md specification.
4//!
5//! ## Format Specification
6//! - First row: column headers from `metadata.columns` in order
7//! - Subsequent rows: values stringified per ValueFormatter mapping rules
8//! - Null values: empty string (standard CSV convention)
9//! - Stable column order: always matches `metadata.columns` sequence
10//!
11//! ## Usage
12//! ```rust,ignore
13//! use cqlite_cli::output::CSVWriter;
14//! use cqlite_core::query::QueryResult;
15//!
16//! let result: QueryResult = // ... query execution
17//! let csv_output = CSVWriter::write(&result, &config)?;
18//! println!("{}", csv_output);
19//! ```
20
21// CSV writer requires query module (M2+ feature)
22#![cfg(feature = "state_machine")]
23
24use crate::config::OutputConfig;
25use crate::output::{OutputError, StreamingWriter};
26use cqlite_core::query::{QueryMetadata, QueryResult, QueryRow};
27use csv::WriterBuilder;
28use std::io::Write;
29
30use super::value_fmt::ValueFormatter;
31
32/// CSV writer for QueryResult
33#[allow(dead_code)]
34pub struct CSVWriter;
35
36impl CSVWriter {
37    /// Write QueryResult to CSV format
38    ///
39    /// # Arguments
40    /// * `result` - The query result to format as CSV
41    /// * `config` - Output configuration for row limits
42    ///
43    /// # Returns
44    /// * `Ok(String)` - CSV-formatted string with headers and data
45    /// * `Err(Box<dyn std::error::Error>)` - CSV serialization error
46    ///
47    /// # Format Guarantees
48    /// - Headers are taken from `metadata.columns` in order
49    /// - Column order is stable across all rows
50    /// - Null values render as empty strings
51    /// - Special CSV characters are properly escaped by the csv crate
52    /// - Respects row limit from config
53    #[allow(dead_code)]
54    pub fn write(
55        result: &QueryResult,
56        config: &OutputConfig,
57    ) -> Result<String, Box<dyn std::error::Error>> {
58        // Create an in-memory CSV writer
59        let mut wtr = WriterBuilder::new().from_writer(Vec::new());
60
61        // Write header row from metadata.columns
62        let headers: Vec<&str> = result
63            .metadata
64            .columns
65            .iter()
66            .map(|col| col.name.as_str())
67            .collect();
68        wtr.write_record(&headers)?;
69
70        // Apply row limit if specified in config
71        let rows_to_display = if let Some(limit) = config.limit {
72            &result.rows[..result.rows.len().min(limit)]
73        } else {
74            &result.rows
75        };
76
77        // Write data rows in stable column order
78        for row in rows_to_display {
79            let record: Vec<String> = result
80                .metadata
81                .columns
82                .iter()
83                .map(|col| {
84                    row.values
85                        .get(&col.name)
86                        .map(|v| {
87                            let formatted = ValueFormatter::format_value(v);
88                            // For CSV, convert "null" to empty string
89                            if formatted == "null" {
90                                String::new()
91                            } else {
92                                formatted
93                            }
94                        })
95                        .unwrap_or_else(String::new) // Missing column → empty
96                })
97                .collect();
98            wtr.write_record(&record)?;
99        }
100
101        // Extract the CSV data as string
102        let data = wtr.into_inner()?;
103        String::from_utf8(data).map_err(|e| e.into())
104    }
105}
106
107// ============================================================================
108// Streaming CSV Writer (Issue #280)
109// ============================================================================
110
111/// Streaming CSV writer for memory-efficient export of large datasets
112///
113/// Unlike the batch `CSVWriter`, this writer processes data incrementally,
114/// allowing export of arbitrarily large result sets within memory constraints.
115///
116/// # Example
117///
118/// ```ignore
119/// let file = File::create("output.csv")?;
120/// let mut writer = StreamingCSVWriter::new(file);
121///
122/// writer.write_header(&metadata)?;
123///
124/// for chunk in result_iterator.chunks(10_000) {
125///     writer.write_chunk(&chunk)?;
126/// }
127///
128/// writer.finalize()?;
129/// ```
130pub struct StreamingCSVWriter<W: Write> {
131    /// Inner CSV writer
132    writer: csv::Writer<W>,
133    /// Column names in order
134    columns: Vec<String>,
135    /// Count of rows written
136    rows_written: u64,
137}
138
139impl<W: Write> StreamingCSVWriter<W> {
140    /// Create a new streaming CSV writer
141    pub fn new(output: W) -> Self {
142        Self {
143            writer: WriterBuilder::new().from_writer(output),
144            columns: Vec::new(),
145            rows_written: 0,
146        }
147    }
148
149    /// Create with custom CSV options
150    #[allow(dead_code)]
151    pub fn with_options(output: W, delimiter: u8, quote_style: csv::QuoteStyle) -> Self {
152        Self {
153            writer: WriterBuilder::new()
154                .delimiter(delimiter)
155                .quote_style(quote_style)
156                .from_writer(output),
157            columns: Vec::new(),
158            rows_written: 0,
159        }
160    }
161}
162
163impl<W: Write + Send> StreamingWriter for StreamingCSVWriter<W> {
164    fn write_header(&mut self, metadata: &QueryMetadata) -> Result<(), OutputError> {
165        // Store column names for row writing
166        self.columns = metadata.columns.iter().map(|c| c.name.clone()).collect();
167
168        // Write header row
169        self.writer
170            .write_record(&self.columns)
171            .map_err(|e| OutputError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
172
173        Ok(())
174    }
175
176    fn write_chunk(&mut self, rows: &[QueryRow]) -> Result<usize, OutputError> {
177        for row in rows {
178            let record: Vec<String> = self
179                .columns
180                .iter()
181                .map(|col| {
182                    row.values
183                        .get(col)
184                        .map(|v| {
185                            let formatted = ValueFormatter::format_value(v);
186                            // For CSV, convert "null" to empty string
187                            if formatted == "null" {
188                                String::new()
189                            } else {
190                                formatted
191                            }
192                        })
193                        .unwrap_or_default()
194                })
195                .collect();
196
197            self.writer
198                .write_record(&record)
199                .map_err(|e| OutputError::Io(std::io::Error::new(std::io::ErrorKind::Other, e)))?;
200        }
201
202        self.rows_written += rows.len() as u64;
203        Ok(rows.len())
204    }
205
206    fn finalize(&mut self) -> Result<(), OutputError> {
207        self.writer.flush().map_err(OutputError::Io)
208    }
209
210    fn rows_written(&self) -> u64 {
211        self.rows_written
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218    use cqlite_core::query::{ColumnInfo, QueryMetadata, QueryRow};
219    use cqlite_core::types::DataType;
220    use cqlite_core::{RowKey, Value};
221    use std::collections::HashMap;
222
223    fn default_config() -> OutputConfig {
224        OutputConfig::default()
225    }
226
227    /// Helper to create a test QueryResult
228    fn create_test_result(
229        columns: Vec<(&str, DataType)>,
230        rows_data: Vec<Vec<(&str, Value)>>,
231    ) -> QueryResult {
232        let mut metadata = QueryMetadata::default();
233        metadata.columns = columns
234            .iter()
235            .enumerate()
236            .map(|(pos, (name, data_type))| ColumnInfo {
237                name: name.to_string(),
238                data_type: data_type.clone(),
239                nullable: true,
240                position: pos,
241                table_name: None,
242                cql_type: None,
243            })
244            .collect();
245
246        let rows = rows_data
247            .into_iter()
248            .enumerate()
249            .map(|(idx, row_data)| {
250                let mut values = HashMap::new();
251                for (col_name, value) in row_data {
252                    values.insert(col_name.to_string(), value);
253                }
254                QueryRow {
255                    values,
256                    key: RowKey::new(vec![idx as u8]),
257                    metadata: Default::default(),
258                }
259            })
260            .collect();
261
262        QueryResult {
263            rows,
264            rows_affected: 0,
265            execution_time_ms: 0,
266            metadata,
267        }
268    }
269
270    #[test]
271    fn test_csv_headers_match_column_order() {
272        let result = create_test_result(
273            vec![
274                ("id", DataType::Integer),
275                ("name", DataType::Text),
276                ("age", DataType::Integer),
277            ],
278            vec![],
279        );
280
281        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
282        let lines: Vec<&str> = csv.lines().collect();
283
284        assert_eq!(lines.len(), 1); // Only header, no data rows
285        assert_eq!(lines[0], "id,name,age");
286    }
287
288    #[test]
289    fn test_csv_basic_data() {
290        let result = create_test_result(
291            vec![("id", DataType::Integer), ("name", DataType::Text)],
292            vec![
293                vec![
294                    ("id", Value::Integer(1)),
295                    ("name", Value::Text("Alice".to_string())),
296                ],
297                vec![
298                    ("id", Value::Integer(2)),
299                    ("name", Value::Text("Bob".to_string())),
300                ],
301            ],
302        );
303
304        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
305        let lines: Vec<&str> = csv.lines().collect();
306
307        assert_eq!(lines.len(), 3); // Header + 2 data rows
308        assert_eq!(lines[0], "id,name");
309        assert_eq!(lines[1], "1,Alice");
310        assert_eq!(lines[2], "2,Bob");
311    }
312
313    #[test]
314    fn test_csv_null_values_become_empty() {
315        let result = create_test_result(
316            vec![("id", DataType::Integer), ("name", DataType::Text)],
317            vec![
318                vec![("id", Value::Integer(1)), ("name", Value::Null)],
319                vec![
320                    ("id", Value::Null),
321                    ("name", Value::Text("Bob".to_string())),
322                ],
323            ],
324        );
325
326        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
327        let lines: Vec<&str> = csv.lines().collect();
328
329        assert_eq!(lines.len(), 3);
330        assert_eq!(lines[0], "id,name");
331        assert_eq!(lines[1], "1,"); // null → empty
332        assert_eq!(lines[2], ",Bob"); // null → empty
333    }
334
335    #[test]
336    fn test_csv_missing_columns_become_empty() {
337        let result = create_test_result(
338            vec![
339                ("id", DataType::Integer),
340                ("name", DataType::Text),
341                ("email", DataType::Text),
342            ],
343            vec![
344                // First row: missing email
345                vec![
346                    ("id", Value::Integer(1)),
347                    ("name", Value::Text("Alice".to_string())),
348                ],
349                // Second row: missing name
350                vec![
351                    ("id", Value::Integer(2)),
352                    ("email", Value::Text("bob@test.com".to_string())),
353                ],
354            ],
355        );
356
357        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
358        let lines: Vec<&str> = csv.lines().collect();
359
360        assert_eq!(lines.len(), 3);
361        assert_eq!(lines[0], "id,name,email");
362        assert_eq!(lines[1], "1,Alice,"); // missing email
363        assert_eq!(lines[2], "2,,bob@test.com"); // missing name
364    }
365
366    #[test]
367    fn test_csv_special_characters_are_escaped() {
368        let result = create_test_result(
369            vec![("id", DataType::Integer), ("description", DataType::Text)],
370            vec![
371                vec![
372                    ("id", Value::Integer(1)),
373                    ("description", Value::Text("Contains, comma".to_string())),
374                ],
375                vec![
376                    ("id", Value::Integer(2)),
377                    ("description", Value::Text("Has \"quotes\"".to_string())),
378                ],
379                vec![
380                    ("id", Value::Integer(3)),
381                    ("description", Value::Text("Line\nbreak".to_string())),
382                ],
383            ],
384        );
385
386        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
387
388        // CSV crate should properly escape these
389        assert!(csv.contains("\"Contains, comma\"") || csv.contains("Contains, comma"));
390        assert!(csv.contains("\"Has \"\"quotes\"\"\"") || csv.contains("Has \"quotes\""));
391        assert!(csv.contains("Line\nbreak") || csv.contains("\"Line\nbreak\""));
392    }
393
394    #[test]
395    fn test_csv_column_order_stability() {
396        // Verify that column order matches metadata.columns, not HashMap iteration order
397        let result = create_test_result(
398            vec![
399                ("z_field", DataType::Text),
400                ("a_field", DataType::Text),
401                ("m_field", DataType::Text),
402            ],
403            vec![vec![
404                ("a_field", Value::Text("aaa".to_string())),
405                ("m_field", Value::Text("mmm".to_string())),
406                ("z_field", Value::Text("zzz".to_string())),
407            ]],
408        );
409
410        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
411        let lines: Vec<&str> = csv.lines().collect();
412
413        // Column order should be z, a, m (as defined in metadata), not alphabetical
414        assert_eq!(lines[0], "z_field,a_field,m_field");
415        assert_eq!(lines[1], "zzz,aaa,mmm");
416    }
417
418    #[test]
419    fn test_csv_config_limit() {
420        let result = create_test_result(
421            vec![("id", DataType::Integer)],
422            vec![
423                vec![("id", Value::Integer(1))],
424                vec![("id", Value::Integer(2))],
425                vec![("id", Value::Integer(3))],
426                vec![("id", Value::Integer(4))],
427                vec![("id", Value::Integer(5))],
428            ],
429        );
430
431        // Apply limit of 2 rows
432        let config = OutputConfig {
433            color_enabled: true,
434            limit: Some(2),
435            page_size: None,
436            target: crate::output::OutputTarget::Stdout,
437            overwrite: false,
438        };
439        let csv = CSVWriter::write(&result, &config).expect("CSV write failed");
440        let lines: Vec<&str> = csv.lines().collect();
441
442        // Should have header + 2 data rows (not 5)
443        assert_eq!(
444            lines.len(),
445            3,
446            "Limit should restrict output to 2 data rows"
447        );
448        assert_eq!(lines[0], "id");
449        assert_eq!(lines[1], "1");
450        assert_eq!(lines[2], "2");
451    }
452
453    #[test]
454    fn test_csv_empty_result() {
455        let result = create_test_result(
456            vec![("id", DataType::Integer), ("name", DataType::Text)],
457            vec![],
458        );
459
460        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
461        let lines: Vec<&str> = csv.lines().collect();
462
463        // Should have header but no data rows
464        assert_eq!(lines.len(), 1);
465        assert_eq!(lines[0], "id,name");
466    }
467
468    #[test]
469    fn test_csv_various_data_types() {
470        let result = create_test_result(
471            vec![
472                ("bool_col", DataType::Boolean),
473                ("int_col", DataType::Integer),
474                ("text_col", DataType::Text),
475                ("blob_col", DataType::Blob),
476            ],
477            vec![vec![
478                ("bool_col", Value::Boolean(true)),
479                ("int_col", Value::Integer(42)),
480                ("text_col", Value::Text("test".to_string())),
481                ("blob_col", Value::Blob(vec![0xDE, 0xAD])),
482            ]],
483        );
484
485        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
486        let lines: Vec<&str> = csv.lines().collect();
487
488        assert_eq!(lines.len(), 2);
489        assert_eq!(lines[0], "bool_col,int_col,text_col,blob_col");
490        assert_eq!(lines[1], "true,42,test,0xdead");
491    }
492
493    #[test]
494    fn test_csv_collections() {
495        let result = create_test_result(
496            vec![
497                ("id", DataType::Integer),
498                ("list_col", DataType::List),
499                ("set_col", DataType::Set),
500            ],
501            vec![vec![
502                ("id", Value::Integer(1)),
503                (
504                    "list_col",
505                    Value::List(vec![Value::Integer(1), Value::Integer(2)]),
506                ),
507                (
508                    "set_col",
509                    Value::Set(vec![
510                        Value::Text("a".to_string()),
511                        Value::Text("b".to_string()),
512                    ]),
513                ),
514            ]],
515        );
516
517        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
518        let lines: Vec<&str> = csv.lines().collect();
519
520        assert_eq!(lines.len(), 2);
521        assert_eq!(lines[0], "id,list_col,set_col");
522        // Collections should be formatted by ValueFormatter
523        assert!(lines[1].contains("[1, 2]"));
524        assert!(lines[1].contains("{a, b}"));
525    }
526
527    #[test]
528    fn test_csv_uuid_formatting() {
529        let uuid_bytes = [
530            0xa8, 0xf1, 0x67, 0xf0, 0xeb, 0xe7, 0x4f, 0x20, 0xa3, 0x86, 0x31, 0xff, 0x13, 0x8b,
531            0xec, 0x3b,
532        ];
533        let result = create_test_result(
534            vec![("id", DataType::Uuid)],
535            vec![vec![("id", Value::Uuid(uuid_bytes))]],
536        );
537
538        let csv = CSVWriter::write(&result, &default_config()).expect("CSV write failed");
539        let lines: Vec<&str> = csv.lines().collect();
540
541        assert_eq!(lines.len(), 2);
542        // UUID should be lowercase hyphenated per contract
543        assert_eq!(lines[1], "a8f167f0-ebe7-4f20-a386-31ff138bec3b");
544    }
545}