use super::clauses;
#[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,
},
}
fn qualified_name(schema: Option<&str>, name: &str) -> String {
match schema {
Some(schema) => format!("{schema}.{name}"),
None => name.to_string(),
}
}
impl ExportSource {
pub(crate) fn select_statement(&self) -> String {
match self {
ExportSource::Table {
schema,
name,
columns,
} => {
let select_list = if columns.is_empty() {
"*".to_string()
} else {
columns.join(", ")
};
format!(
"SELECT {select_list} FROM {}",
qualified_name(schema.as_deref(), name)
)
}
ExportSource::Query { sql } => sql.clone(),
}
}
}
#[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::with_source(ExportSource::Table {
schema: None,
name: table.to_string(),
columns: Vec::new(),
})
}
pub fn from_query(sql: &str) -> Self {
Self::with_source(ExportSource::Query {
sql: sql.to_string(),
})
}
fn with_source(source: ExportSource) -> Self {
Self {
source,
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 = self.source_clause();
sql.push_str(&self.destination_clause());
sql.push_str(&self.format_option_clauses());
sql
}
fn source_clause(&self) -> String {
match &self.source {
ExportSource::Table {
schema,
name,
columns,
} => {
let mut clause = format!("EXPORT {}", qualified_name(schema.as_deref(), name));
if !columns.is_empty() {
clause.push_str(" (");
clause.push_str(&columns.join(", "));
clause.push(')');
}
clause
}
ExportSource::Query { sql: query } => format!("EXPORT ({})", query),
}
}
fn destination_clause(&self) -> String {
format!(
"\nINTO CSV AT {}\nFILE '{}{}'",
clauses::quoted_endpoint(&self.address, "", self.public_key.as_deref()),
self.file_name,
self.compression.extension()
)
}
fn format_option_clauses(&self) -> String {
let mut clause = clauses::CsvFraming {
encoding: &self.encoding,
column_separator: self.column_separator,
column_delimiter: self.column_delimiter,
row_separator: self.row_separator.as_sql(),
}
.to_sql();
clause.push_str(&clauses::null_clause(self.null_value.as_deref()));
if self.with_column_names {
clause.push_str("\nWITH COLUMN NAMES");
}
clause.push_str("\nDELIMIT = ");
clause.push_str(self.delimit_mode.as_sql());
clause
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_select_statement_for_table_without_schema_selects_star() {
let source = ExportSource::Table {
schema: None,
name: "users".to_string(),
columns: vec![],
};
assert_eq!(source.select_statement(), "SELECT * FROM users");
}
#[test]
fn test_select_statement_for_table_with_schema_and_columns() {
let source = ExportSource::Table {
schema: Some("sales".to_string()),
name: "orders".to_string(),
columns: vec!["id".to_string(), "total".to_string()],
};
assert_eq!(
source.select_statement(),
"SELECT id, total FROM sales.orders"
);
}
#[test]
fn test_select_statement_for_query_is_the_query_text() {
let source = ExportSource::Query {
sql: "SELECT 1 AS X".to_string(),
};
assert_eq!(source.select_statement(), "SELECT 1 AS X");
}
#[test]
fn test_select_statement_and_export_clause_name_the_same_table() {
let source = ExportSource::Table {
schema: Some("s".to_string()),
name: "t".to_string(),
columns: vec![],
};
let export = ExportQuery::with_source(source.clone()).build();
assert!(export.starts_with("EXPORT s.t"));
assert!(source.select_statement().ends_with("FROM s.t"));
}
#[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"));
}
#[test]
fn test_build_renders_the_full_default_statement_verbatim() {
let sql = ExportQuery::from_table("users")
.at_address("192.168.1.100:8080")
.build();
assert_eq!(
sql,
"EXPORT users\n\
INTO CSV AT 'http://192.168.1.100:8080'\n\
FILE '001.csv'\n\
ENCODING = 'UTF-8'\n\
COLUMN SEPARATOR = ','\n\
COLUMN DELIMITER = '\"'\n\
ROW SEPARATOR = 'LF'\n\
DELIMIT = AUTO"
);
}
#[test]
fn test_build_renders_every_option_in_order() {
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::CRLF)
.encoding("ISO-8859-1")
.null_value("\\N")
.with_column_names(true)
.delimit_mode(DelimitMode::Always)
.compressed(Compression::Gzip)
.build();
assert_eq!(
sql,
"EXPORT sales.orders (order_id, customer_id, total)\n\
INTO CSV AT 'https://10.0.0.1:3000' PUBLIC KEY 'SHA256:fingerprint123'\n\
FILE 'orders_export.csv.gz'\n\
ENCODING = 'ISO-8859-1'\n\
COLUMN SEPARATOR = '|'\n\
COLUMN DELIMITER = ''''\n\
ROW SEPARATOR = 'CRLF'\n\
NULL = '\\N'\n\
WITH COLUMN NAMES\n\
DELIMIT = ALWAYS"
);
}
#[test]
fn test_build_renders_the_full_query_source_statement_verbatim() {
let sql = ExportQuery::from_query("SELECT 1")
.at_address("127.0.0.1:9000")
.build();
assert_eq!(
sql,
"EXPORT (SELECT 1)\n\
INTO CSV AT 'http://127.0.0.1:9000'\n\
FILE '001.csv'\n\
ENCODING = 'UTF-8'\n\
COLUMN SEPARATOR = ','\n\
COLUMN DELIMITER = '\"'\n\
ROW SEPARATOR = 'LF'\n\
DELIMIT = AUTO"
);
}
}