Skip to main content

nexql_tools/
export.rs

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