#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum RowSeparator {
#[default]
LF,
CR,
CRLF,
}
impl RowSeparator {
pub fn as_sql(&self) -> &'static str {
match self {
RowSeparator::LF => "LF",
RowSeparator::CR => "CR",
RowSeparator::CRLF => "CRLF",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Compression {
#[default]
None,
Gzip,
Bzip2,
}
impl Compression {
pub fn extension(&self) -> &'static str {
match self {
Compression::None => "",
Compression::Gzip => ".gz",
Compression::Bzip2 => ".bz2",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum DelimitMode {
#[default]
Auto,
Always,
Never,
}
impl DelimitMode {
pub fn as_sql(&self) -> &'static str {
match self {
DelimitMode::Auto => "AUTO",
DelimitMode::Always => "ALWAYS",
DelimitMode::Never => "NEVER",
}
}
}
#[derive(Debug, Clone)]
pub enum ExportSource {
Table {
schema: Option<String>,
name: String,
columns: Vec<String>,
},
Query {
sql: String,
},
}
#[derive(Debug, Clone)]
pub struct ExportQuery {
source: ExportSource,
address: String,
public_key: Option<String>,
file_name: String,
column_separator: char,
column_delimiter: char,
row_separator: RowSeparator,
encoding: String,
null_value: Option<String>,
delimit_mode: DelimitMode,
compression: Compression,
with_column_names: bool,
}
impl ExportQuery {
pub fn from_table(table: &str) -> Self {
Self {
source: ExportSource::Table {
schema: None,
name: table.to_string(),
columns: Vec::new(),
},
address: String::new(),
public_key: None,
file_name: "001.csv".to_string(),
column_separator: ',',
column_delimiter: '"',
row_separator: RowSeparator::default(),
encoding: "UTF-8".to_string(),
null_value: None,
delimit_mode: DelimitMode::default(),
compression: Compression::default(),
with_column_names: false,
}
}
pub fn from_query(sql: &str) -> Self {
Self {
source: ExportSource::Query {
sql: sql.to_string(),
},
address: String::new(),
public_key: None,
file_name: "001.csv".to_string(),
column_separator: ',',
column_delimiter: '"',
row_separator: RowSeparator::default(),
encoding: "UTF-8".to_string(),
null_value: None,
delimit_mode: DelimitMode::default(),
compression: Compression::default(),
with_column_names: false,
}
}
pub fn schema(mut self, schema: &str) -> Self {
if let ExportSource::Table {
schema: ref mut s, ..
} = self.source
{
*s = Some(schema.to_string());
}
self
}
pub fn columns(mut self, cols: Vec<&str>) -> Self {
if let ExportSource::Table {
columns: ref mut c, ..
} = self.source
{
*c = cols.into_iter().map(String::from).collect();
}
self
}
pub fn at_address(mut self, addr: &str) -> Self {
self.address = addr.to_string();
self
}
pub fn with_public_key(mut self, fingerprint: &str) -> Self {
self.public_key = Some(fingerprint.to_string());
self
}
pub fn file_name(mut self, name: &str) -> Self {
self.file_name = name.to_string();
self
}
pub fn column_separator(mut self, sep: char) -> Self {
self.column_separator = sep;
self
}
pub fn column_delimiter(mut self, delim: char) -> Self {
self.column_delimiter = delim;
self
}
pub fn row_separator(mut self, sep: RowSeparator) -> Self {
self.row_separator = sep;
self
}
pub fn encoding(mut self, enc: &str) -> Self {
self.encoding = enc.to_string();
self
}
pub fn null_value(mut self, val: &str) -> Self {
self.null_value = Some(val.to_string());
self
}
pub fn delimit_mode(mut self, mode: DelimitMode) -> Self {
self.delimit_mode = mode;
self
}
pub fn compressed(mut self, compression: Compression) -> Self {
self.compression = compression;
self
}
pub fn with_column_names(mut self, include: bool) -> Self {
self.with_column_names = include;
self
}
pub fn build(&self) -> String {
let mut sql = String::new();
match &self.source {
ExportSource::Table {
schema,
name,
columns,
} => {
sql.push_str("EXPORT ");
if let Some(s) = schema {
sql.push_str(s);
sql.push('.');
}
sql.push_str(name);
if !columns.is_empty() {
sql.push_str(" (");
sql.push_str(&columns.join(", "));
sql.push(')');
}
}
ExportSource::Query { sql: query } => {
sql.push_str("EXPORT (");
sql.push_str(query);
sql.push(')');
}
}
sql.push_str("\nINTO CSV AT '");
if self.public_key.is_some() {
sql.push_str("https://");
} else {
sql.push_str("http://");
}
sql.push_str(&self.address);
sql.push('\'');
if let Some(ref fingerprint) = self.public_key {
sql.push_str(" PUBLIC KEY '");
sql.push_str(fingerprint);
sql.push('\'');
}
sql.push_str("\nFILE '");
sql.push_str(&self.file_name);
sql.push_str(self.compression.extension());
sql.push('\'');
sql.push_str("\nENCODING = '");
sql.push_str(&self.encoding);
sql.push('\'');
sql.push_str("\nCOLUMN SEPARATOR = '");
sql.push(self.column_separator);
sql.push('\'');
sql.push_str("\nCOLUMN DELIMITER = '");
sql.push(self.column_delimiter);
sql.push('\'');
sql.push_str("\nROW SEPARATOR = '");
sql.push_str(self.row_separator.as_sql());
sql.push('\'');
if let Some(ref null_val) = self.null_value {
sql.push_str("\nNULL = '");
sql.push_str(null_val);
sql.push('\'');
}
if self.with_column_names {
sql.push_str("\nWITH COLUMN NAMES");
}
sql.push_str("\nDELIMIT = ");
sql.push_str(self.delimit_mode.as_sql());
sql
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_export_from_table_basic() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.build();
assert!(sql.starts_with("EXPORT users"));
assert!(sql.contains("INTO CSV AT 'http://192.168.1.100:8080'"));
assert!(sql.contains("FILE '001.csv'"));
assert!(sql.contains("ENCODING = 'UTF-8'"));
assert!(sql.contains("COLUMN SEPARATOR = ','"));
assert!(sql.contains("COLUMN DELIMITER = '\"'"));
assert!(sql.contains("ROW SEPARATOR = 'LF'"));
assert!(sql.contains("DELIMIT = AUTO"));
}
#[test]
fn test_export_from_table_with_schema() {
let sql = ExportQuery::from_table("users")
.schema("my_schema")
.at_address("192.168.1.100:8080")
.build();
assert!(sql.starts_with("EXPORT my_schema.users"));
}
#[test]
fn test_export_from_table_with_columns() {
let sql = ExportQuery::from_table("users")
.columns(vec!["id", "name", "email"])
.at_address("192.168.1.100:8080")
.build();
assert!(sql.starts_with("EXPORT users (id, name, email)"));
}
#[test]
fn test_export_from_table_with_schema_and_columns() {
let sql = ExportQuery::from_table("users")
.schema("my_schema")
.columns(vec!["id", "name"])
.at_address("192.168.1.100:8080")
.build();
assert!(sql.starts_with("EXPORT my_schema.users (id, name)"));
}
#[test]
fn test_export_from_query() {
let sql = ExportQuery::from_query("SELECT * FROM users WHERE active = true")
.at_address("192.168.1.100:8080")
.build();
assert!(sql.starts_with("EXPORT (SELECT * FROM users WHERE active = true)"));
assert!(sql.contains("INTO CSV AT 'http://192.168.1.100:8080'"));
}
#[test]
fn test_export_from_query_complex() {
let sql = ExportQuery::from_query(
"SELECT u.id, u.name, COUNT(o.id) FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name",
)
.at_address("192.168.1.100:8080")
.build();
assert!(sql.contains("EXPORT (SELECT u.id, u.name, COUNT(o.id) FROM users u JOIN orders o ON u.id = o.user_id GROUP BY u.id, u.name)"));
}
#[test]
fn test_export_with_all_format_options() {
let sql = ExportQuery::from_table("data")
.at_address("192.168.1.100:8080")
.column_separator(';')
.column_delimiter('\'')
.row_separator(RowSeparator::CRLF)
.encoding("ISO-8859-1")
.null_value("NULL")
.delimit_mode(DelimitMode::Always)
.build();
assert!(sql.contains("COLUMN SEPARATOR = ';'"));
assert!(sql.contains("COLUMN DELIMITER = '''"));
assert!(sql.contains("ROW SEPARATOR = 'CRLF'"));
assert!(sql.contains("ENCODING = 'ISO-8859-1'"));
assert!(sql.contains("NULL = 'NULL'"));
assert!(sql.contains("DELIMIT = ALWAYS"));
}
#[test]
fn test_export_row_separator_cr() {
let sql = ExportQuery::from_table("data")
.at_address("192.168.1.100:8080")
.row_separator(RowSeparator::CR)
.build();
assert!(sql.contains("ROW SEPARATOR = 'CR'"));
}
#[test]
fn test_export_delimit_mode_never() {
let sql = ExportQuery::from_table("data")
.at_address("192.168.1.100:8080")
.delimit_mode(DelimitMode::Never)
.build();
assert!(sql.contains("DELIMIT = NEVER"));
}
#[test]
fn test_export_with_public_key() {
let fingerprint = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90";
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.with_public_key(fingerprint)
.build();
assert!(sql.contains("INTO CSV AT 'https://192.168.1.100:8080'"));
assert!(sql.contains(&format!("PUBLIC KEY '{}'", fingerprint)));
}
#[test]
fn test_export_without_public_key_uses_http() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.build();
assert!(sql.contains("INTO CSV AT 'http://192.168.1.100:8080'"));
assert!(!sql.contains("PUBLIC KEY"));
}
#[test]
fn test_export_with_column_names() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.with_column_names(true)
.build();
assert!(sql.contains("WITH COLUMN NAMES"));
}
#[test]
fn test_export_without_column_names() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.with_column_names(false)
.build();
assert!(!sql.contains("WITH COLUMN NAMES"));
}
#[test]
fn test_export_no_compression() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.compressed(Compression::None)
.build();
assert!(sql.contains("FILE '001.csv'"));
assert!(!sql.contains(".gz"));
assert!(!sql.contains(".bz2"));
}
#[test]
fn test_export_gzip_compression() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.compressed(Compression::Gzip)
.build();
assert!(sql.contains("FILE '001.csv.gz'"));
}
#[test]
fn test_export_bzip2_compression() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.compressed(Compression::Bzip2)
.build();
assert!(sql.contains("FILE '001.csv.bz2'"));
}
#[test]
fn test_export_custom_file_name_with_compression() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.file_name("export_data.csv")
.compressed(Compression::Gzip)
.build();
assert!(sql.contains("FILE 'export_data.csv.gz'"));
}
#[test]
fn test_export_complete_statement() {
let sql = ExportQuery::from_table("orders")
.schema("sales")
.columns(vec!["order_id", "customer_id", "total"])
.at_address("10.0.0.1:3000")
.with_public_key("SHA256:fingerprint123")
.file_name("orders_export.csv")
.column_separator('|')
.column_delimiter('"')
.row_separator(RowSeparator::LF)
.encoding("UTF-8")
.null_value("\\N")
.with_column_names(true)
.delimit_mode(DelimitMode::Always)
.compressed(Compression::Gzip)
.build();
assert!(sql.starts_with("EXPORT sales.orders (order_id, customer_id, total)"));
assert!(sql.contains("INTO CSV AT 'https://10.0.0.1:3000'"));
assert!(sql.contains("PUBLIC KEY 'SHA256:fingerprint123'"));
assert!(sql.contains("FILE 'orders_export.csv.gz'"));
assert!(sql.contains("ENCODING = 'UTF-8'"));
assert!(sql.contains("COLUMN SEPARATOR = '|'"));
assert!(sql.contains("COLUMN DELIMITER = '\"'"));
assert!(sql.contains("ROW SEPARATOR = 'LF'"));
assert!(sql.contains("NULL = '\\N'"));
assert!(sql.contains("WITH COLUMN NAMES"));
assert!(sql.contains("DELIMIT = ALWAYS"));
}
#[test]
fn test_row_separator_as_sql() {
assert_eq!(RowSeparator::LF.as_sql(), "LF");
assert_eq!(RowSeparator::CR.as_sql(), "CR");
assert_eq!(RowSeparator::CRLF.as_sql(), "CRLF");
}
#[test]
fn test_compression_extension() {
assert_eq!(Compression::None.extension(), "");
assert_eq!(Compression::Gzip.extension(), ".gz");
assert_eq!(Compression::Bzip2.extension(), ".bz2");
}
#[test]
fn test_delimit_mode_as_sql() {
assert_eq!(DelimitMode::Auto.as_sql(), "AUTO");
assert_eq!(DelimitMode::Always.as_sql(), "ALWAYS");
assert_eq!(DelimitMode::Never.as_sql(), "NEVER");
}
#[test]
fn test_default_values() {
assert_eq!(RowSeparator::default(), RowSeparator::LF);
assert_eq!(Compression::default(), Compression::None);
assert_eq!(DelimitMode::default(), DelimitMode::Auto);
}
#[test]
fn test_schema_columns_ignored_for_query() {
let sql = ExportQuery::from_query("SELECT 1")
.schema("ignored_schema")
.columns(vec!["ignored_col"])
.at_address("192.168.1.100:8080")
.build();
assert!(sql.starts_with("EXPORT (SELECT 1)"));
assert!(!sql.contains("ignored_schema"));
assert!(!sql.contains("ignored_col"));
}
}