Skip to main content

nexql_tools/
export.rs

1//! Result formatting for `export_query` (CSV / JSON / SQL-INSERT).
2//! Ported from core `CoreHandlers.rowsToCsv` / `rowsToSqlInsert`.
3
4use serde_json::{Map, Value};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum ExportFormat {
8    Csv,
9    Json,
10    SqlInsert,
11}
12
13impl ExportFormat {
14    pub fn parse(s: &str) -> Option<Self> {
15        match s.trim().to_ascii_lowercase().as_str() {
16            "csv" => Some(Self::Csv),
17            "json" => Some(Self::Json),
18            "sqlinsert" | "sql_insert" | "insert" => Some(Self::SqlInsert),
19            _ => None,
20        }
21    }
22
23    pub fn as_str(self) -> &'static str {
24        match self {
25            Self::Csv => "csv",
26            Self::Json => "json",
27            Self::SqlInsert => "sqlinsert",
28        }
29    }
30}
31
32/// Extract column order from the first row object; empty if no rows.
33pub fn columns_from_rows(rows: &[Value]) -> Vec<String> {
34    rows.first()
35        .and_then(|r| r.as_object())
36        .map(|obj| obj.keys().cloned().collect())
37        .unwrap_or_default()
38}
39
40pub fn rows_to_csv(rows: &[Value], columns: &[String]) -> String {
41    let mut out = String::new();
42    out.push_str(
43        &columns
44            .iter()
45            .map(|c| csv_quote(c))
46            .collect::<Vec<_>>()
47            .join(","),
48    );
49    out.push('\n');
50    for row in rows {
51        let obj = row.as_object();
52        let line = columns
53            .iter()
54            .map(|col| {
55                let val = obj.and_then(|m| m.get(col)).unwrap_or(&Value::Null);
56                csv_cell(val)
57            })
58            .collect::<Vec<_>>()
59            .join(",");
60        out.push_str(&line);
61        out.push('\n');
62    }
63    out
64}
65
66fn csv_quote(s: &str) -> String {
67    format!("\"{}\"", s.replace('"', "\"\""))
68}
69
70fn csv_cell(val: &Value) -> String {
71    if val.is_null() {
72        return String::new();
73    }
74    let str = match val {
75        Value::String(s) => s.clone(),
76        Value::Bool(b) => b.to_string(),
77        Value::Number(n) => n.to_string(),
78        other => other.to_string(),
79    };
80    if str.contains(',') || str.contains('\n') || str.contains('"') {
81        csv_quote(&str)
82    } else {
83        str
84    }
85}
86
87pub fn rows_to_sql_insert(rows: &[Value], columns: &[String], schema: &str, table: &str) -> String {
88    let table_name = format!(
89        "\"{}\".\"{}\"",
90        schema.replace('"', "\"\""),
91        table.replace('"', "\"\"")
92    );
93    let cols = columns
94        .iter()
95        .map(|c| format!("\"{}\"", c.replace('"', "\"\"")))
96        .collect::<Vec<_>>()
97        .join(", ");
98
99    rows.iter()
100        .map(|row| {
101            let obj = row.as_object().cloned().unwrap_or_else(Map::new);
102            let values = columns
103                .iter()
104                .map(|col| sql_literal(obj.get(col).unwrap_or(&Value::Null)))
105                .collect::<Vec<_>>()
106                .join(", ");
107            format!("INSERT INTO {table_name} ({cols}) VALUES ({values});")
108        })
109        .collect::<Vec<_>>()
110        .join("\n")
111}
112
113fn sql_literal(val: &Value) -> String {
114    match val {
115        Value::Null => "NULL".into(),
116        Value::Bool(b) => {
117            if *b {
118                "TRUE".into()
119            } else {
120                "FALSE".into()
121            }
122        }
123        Value::Number(n) => n.to_string(),
124        Value::String(s) => format!("'{}'", s.replace('\'', "''")),
125        other => format!("'{}'", other.to_string().replace('\'', "''")),
126    }
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use serde_json::json;
133
134    #[test]
135    fn csv_escapes_quotes_and_commas() {
136        let rows = [json!({ "a": "hello, world", "b": "say \"hi\"" })];
137        let cols = vec!["a".into(), "b".into()];
138        let csv = rows_to_csv(&rows, &cols);
139        assert!(csv.contains("\"hello, world\""));
140        assert!(csv.contains("\"say \"\"hi\"\"\""));
141    }
142
143    #[test]
144    fn sql_insert_null_bool_number() {
145        let rows = [json!({ "id": 1, "ok": true, "note": null })];
146        let cols = vec!["id".into(), "ok".into(), "note".into()];
147        let sql = rows_to_sql_insert(&rows, &cols, "public", "t");
148        assert_eq!(
149            sql,
150            "INSERT INTO \"public\".\"t\" (\"id\", \"ok\", \"note\") VALUES (1, TRUE, NULL);"
151        );
152    }
153
154    #[test]
155    fn format_parse() {
156        assert_eq!(ExportFormat::parse("CSV"), Some(ExportFormat::Csv));
157        assert_eq!(
158            ExportFormat::parse("sql_insert"),
159            Some(ExportFormat::SqlInsert)
160        );
161        assert!(ExportFormat::parse("xlsx").is_none());
162    }
163}