1#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum RowSeparator {
12 #[default]
14 LF,
15 CR,
17 CRLF,
19}
20
21impl RowSeparator {
22 pub fn as_sql(&self) -> &'static str {
24 match self {
25 RowSeparator::LF => "LF",
26 RowSeparator::CR => "CR",
27 RowSeparator::CRLF => "CRLF",
28 }
29 }
30}
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum Compression {
35 #[default]
37 None,
38 Gzip,
40 Bzip2,
42}
43
44impl Compression {
45 pub fn extension(&self) -> &'static str {
47 match self {
48 Compression::None => "",
49 Compression::Gzip => ".gz",
50 Compression::Bzip2 => ".bz2",
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
57pub enum DelimitMode {
58 #[default]
60 Auto,
61 Always,
63 Never,
65}
66
67impl DelimitMode {
68 pub fn as_sql(&self) -> &'static str {
70 match self {
71 DelimitMode::Auto => "AUTO",
72 DelimitMode::Always => "ALWAYS",
73 DelimitMode::Never => "NEVER",
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
80pub enum ExportSource {
81 Table {
83 schema: Option<String>,
85 name: String,
87 columns: Vec<String>,
89 },
90 Query {
92 sql: String,
94 },
95}
96
97#[derive(Debug, Clone)]
102pub struct ExportQuery {
103 source: ExportSource,
105 address: String,
107 public_key: Option<String>,
109 file_name: String,
111 column_separator: char,
113 column_delimiter: char,
115 row_separator: RowSeparator,
117 encoding: String,
119 null_value: Option<String>,
121 delimit_mode: DelimitMode,
123 compression: Compression,
125 with_column_names: bool,
127}
128
129impl ExportQuery {
130 pub fn from_table(table: &str) -> Self {
139 Self {
140 source: ExportSource::Table {
141 schema: None,
142 name: table.to_string(),
143 columns: Vec::new(),
144 },
145 address: String::new(),
146 public_key: None,
147 file_name: "001.csv".to_string(),
148 column_separator: ',',
149 column_delimiter: '"',
150 row_separator: RowSeparator::default(),
151 encoding: "UTF-8".to_string(),
152 null_value: None,
153 delimit_mode: DelimitMode::default(),
154 compression: Compression::default(),
155 with_column_names: false,
156 }
157 }
158
159 pub fn from_query(sql: &str) -> Self {
168 Self {
169 source: ExportSource::Query {
170 sql: sql.to_string(),
171 },
172 address: String::new(),
173 public_key: None,
174 file_name: "001.csv".to_string(),
175 column_separator: ',',
176 column_delimiter: '"',
177 row_separator: RowSeparator::default(),
178 encoding: "UTF-8".to_string(),
179 null_value: None,
180 delimit_mode: DelimitMode::default(),
181 compression: Compression::default(),
182 with_column_names: false,
183 }
184 }
185
186 pub fn schema(mut self, schema: &str) -> Self {
194 if let ExportSource::Table {
195 schema: ref mut s, ..
196 } = self.source
197 {
198 *s = Some(schema.to_string());
199 }
200 self
201 }
202
203 pub fn columns(mut self, cols: Vec<&str>) -> Self {
211 if let ExportSource::Table {
212 columns: ref mut c, ..
213 } = self.source
214 {
215 *c = cols.into_iter().map(String::from).collect();
216 }
217 self
218 }
219
220 pub fn at_address(mut self, addr: &str) -> Self {
229 self.address = addr.to_string();
230 self
231 }
232
233 pub fn with_public_key(mut self, fingerprint: &str) -> Self {
241 self.public_key = Some(fingerprint.to_string());
242 self
243 }
244
245 pub fn file_name(mut self, name: &str) -> Self {
254 self.file_name = name.to_string();
255 self
256 }
257
258 pub fn column_separator(mut self, sep: char) -> Self {
266 self.column_separator = sep;
267 self
268 }
269
270 pub fn column_delimiter(mut self, delim: char) -> Self {
278 self.column_delimiter = delim;
279 self
280 }
281
282 pub fn row_separator(mut self, sep: RowSeparator) -> Self {
290 self.row_separator = sep;
291 self
292 }
293
294 pub fn encoding(mut self, enc: &str) -> Self {
302 self.encoding = enc.to_string();
303 self
304 }
305
306 pub fn null_value(mut self, val: &str) -> Self {
314 self.null_value = Some(val.to_string());
315 self
316 }
317
318 pub fn delimit_mode(mut self, mode: DelimitMode) -> Self {
326 self.delimit_mode = mode;
327 self
328 }
329
330 pub fn compressed(mut self, compression: Compression) -> Self {
338 self.compression = compression;
339 self
340 }
341
342 pub fn with_column_names(mut self, include: bool) -> Self {
350 self.with_column_names = include;
351 self
352 }
353
354 pub fn build(&self) -> String {
360 let mut sql = String::new();
361
362 match &self.source {
364 ExportSource::Table {
365 schema,
366 name,
367 columns,
368 } => {
369 sql.push_str("EXPORT ");
370 if let Some(s) = schema {
371 sql.push_str(s);
372 sql.push('.');
373 }
374 sql.push_str(name);
375 if !columns.is_empty() {
376 sql.push_str(" (");
377 sql.push_str(&columns.join(", "));
378 sql.push(')');
379 }
380 }
381 ExportSource::Query { sql: query } => {
382 sql.push_str("EXPORT (");
383 sql.push_str(query);
384 sql.push(')');
385 }
386 }
387
388 sql.push_str("\nINTO CSV AT '");
390
391 if self.public_key.is_some() {
393 sql.push_str("https://");
394 } else {
395 sql.push_str("http://");
396 }
397 sql.push_str(&self.address);
398 sql.push('\'');
399
400 if let Some(ref fingerprint) = self.public_key {
402 sql.push_str(" PUBLIC KEY '");
403 sql.push_str(fingerprint);
404 sql.push('\'');
405 }
406
407 sql.push_str("\nFILE '");
409 sql.push_str(&self.file_name);
410 sql.push_str(self.compression.extension());
411 sql.push('\'');
412
413 sql.push_str("\nENCODING = '");
415 sql.push_str(&self.encoding);
416 sql.push('\'');
417
418 sql.push_str("\nCOLUMN SEPARATOR = '");
419 sql.push(self.column_separator);
420 sql.push('\'');
421
422 sql.push_str("\nCOLUMN DELIMITER = '");
423 sql.push(self.column_delimiter);
424 sql.push('\'');
425
426 sql.push_str("\nROW SEPARATOR = '");
427 sql.push_str(self.row_separator.as_sql());
428 sql.push('\'');
429
430 if let Some(ref null_val) = self.null_value {
432 sql.push_str("\nNULL = '");
433 sql.push_str(null_val);
434 sql.push('\'');
435 }
436
437 if self.with_column_names {
439 sql.push_str("\nWITH COLUMN NAMES");
440 }
441
442 sql.push_str("\nDELIMIT = ");
444 sql.push_str(self.delimit_mode.as_sql());
445
446 sql
447 }
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
456 fn test_export_from_table_basic() {
457 let sql = ExportQuery::from_table("users")
458 .at_address("192.168.1.100:8080")
459 .build();
460
461 assert!(sql.starts_with("EXPORT users"));
462 assert!(sql.contains("INTO CSV AT 'http://192.168.1.100:8080'"));
463 assert!(sql.contains("FILE '001.csv'"));
464 assert!(sql.contains("ENCODING = 'UTF-8'"));
465 assert!(sql.contains("COLUMN SEPARATOR = ','"));
466 assert!(sql.contains("COLUMN DELIMITER = '\"'"));
467 assert!(sql.contains("ROW SEPARATOR = 'LF'"));
468 assert!(sql.contains("DELIMIT = AUTO"));
469 }
470
471 #[test]
472 fn test_export_from_table_with_schema() {
473 let sql = ExportQuery::from_table("users")
474 .schema("my_schema")
475 .at_address("192.168.1.100:8080")
476 .build();
477
478 assert!(sql.starts_with("EXPORT my_schema.users"));
479 }
480
481 #[test]
482 fn test_export_from_table_with_columns() {
483 let sql = ExportQuery::from_table("users")
484 .columns(vec!["id", "name", "email"])
485 .at_address("192.168.1.100:8080")
486 .build();
487
488 assert!(sql.starts_with("EXPORT users (id, name, email)"));
489 }
490
491 #[test]
492 fn test_export_from_table_with_schema_and_columns() {
493 let sql = ExportQuery::from_table("users")
494 .schema("my_schema")
495 .columns(vec!["id", "name"])
496 .at_address("192.168.1.100:8080")
497 .build();
498
499 assert!(sql.starts_with("EXPORT my_schema.users (id, name)"));
500 }
501
502 #[test]
504 fn test_export_from_query() {
505 let sql = ExportQuery::from_query("SELECT * FROM users WHERE active = true")
506 .at_address("192.168.1.100:8080")
507 .build();
508
509 assert!(sql.starts_with("EXPORT (SELECT * FROM users WHERE active = true)"));
510 assert!(sql.contains("INTO CSV AT 'http://192.168.1.100:8080'"));
511 }
512
513 #[test]
514 fn test_export_from_query_complex() {
515 let sql = ExportQuery::from_query(
516 "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",
517 )
518 .at_address("192.168.1.100:8080")
519 .build();
520
521 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)"));
522 }
523
524 #[test]
526 fn test_export_with_all_format_options() {
527 let sql = ExportQuery::from_table("data")
528 .at_address("192.168.1.100:8080")
529 .column_separator(';')
530 .column_delimiter('\'')
531 .row_separator(RowSeparator::CRLF)
532 .encoding("ISO-8859-1")
533 .null_value("NULL")
534 .delimit_mode(DelimitMode::Always)
535 .build();
536
537 assert!(sql.contains("COLUMN SEPARATOR = ';'"));
538 assert!(sql.contains("COLUMN DELIMITER = '''"));
539 assert!(sql.contains("ROW SEPARATOR = 'CRLF'"));
540 assert!(sql.contains("ENCODING = 'ISO-8859-1'"));
541 assert!(sql.contains("NULL = 'NULL'"));
542 assert!(sql.contains("DELIMIT = ALWAYS"));
543 }
544
545 #[test]
546 fn test_export_row_separator_cr() {
547 let sql = ExportQuery::from_table("data")
548 .at_address("192.168.1.100:8080")
549 .row_separator(RowSeparator::CR)
550 .build();
551
552 assert!(sql.contains("ROW SEPARATOR = 'CR'"));
553 }
554
555 #[test]
556 fn test_export_delimit_mode_never() {
557 let sql = ExportQuery::from_table("data")
558 .at_address("192.168.1.100:8080")
559 .delimit_mode(DelimitMode::Never)
560 .build();
561
562 assert!(sql.contains("DELIMIT = NEVER"));
563 }
564
565 #[test]
567 fn test_export_with_public_key() {
568 let fingerprint = "AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90";
569 let sql = ExportQuery::from_table("users")
570 .at_address("192.168.1.100:8080")
571 .with_public_key(fingerprint)
572 .build();
573
574 assert!(sql.contains("INTO CSV AT 'https://192.168.1.100:8080'"));
575 assert!(sql.contains(&format!("PUBLIC KEY '{}'", fingerprint)));
576 }
577
578 #[test]
579 fn test_export_without_public_key_uses_http() {
580 let sql = ExportQuery::from_table("users")
581 .at_address("192.168.1.100:8080")
582 .build();
583
584 assert!(sql.contains("INTO CSV AT 'http://192.168.1.100:8080'"));
585 assert!(!sql.contains("PUBLIC KEY"));
586 }
587
588 #[test]
590 fn test_export_with_column_names() {
591 let sql = ExportQuery::from_table("users")
592 .at_address("192.168.1.100:8080")
593 .with_column_names(true)
594 .build();
595
596 assert!(sql.contains("WITH COLUMN NAMES"));
597 }
598
599 #[test]
600 fn test_export_without_column_names() {
601 let sql = ExportQuery::from_table("users")
602 .at_address("192.168.1.100:8080")
603 .with_column_names(false)
604 .build();
605
606 assert!(!sql.contains("WITH COLUMN NAMES"));
607 }
608
609 #[test]
611 fn test_export_no_compression() {
612 let sql = ExportQuery::from_table("users")
613 .at_address("192.168.1.100:8080")
614 .compressed(Compression::None)
615 .build();
616
617 assert!(sql.contains("FILE '001.csv'"));
618 assert!(!sql.contains(".gz"));
619 assert!(!sql.contains(".bz2"));
620 }
621
622 #[test]
623 fn test_export_gzip_compression() {
624 let sql = ExportQuery::from_table("users")
625 .at_address("192.168.1.100:8080")
626 .compressed(Compression::Gzip)
627 .build();
628
629 assert!(sql.contains("FILE '001.csv.gz'"));
630 }
631
632 #[test]
633 fn test_export_bzip2_compression() {
634 let sql = ExportQuery::from_table("users")
635 .at_address("192.168.1.100:8080")
636 .compressed(Compression::Bzip2)
637 .build();
638
639 assert!(sql.contains("FILE '001.csv.bz2'"));
640 }
641
642 #[test]
643 fn test_export_custom_file_name_with_compression() {
644 let sql = ExportQuery::from_table("users")
645 .at_address("192.168.1.100:8080")
646 .file_name("export_data.csv")
647 .compressed(Compression::Gzip)
648 .build();
649
650 assert!(sql.contains("FILE 'export_data.csv.gz'"));
651 }
652
653 #[test]
655 fn test_export_complete_statement() {
656 let sql = ExportQuery::from_table("orders")
657 .schema("sales")
658 .columns(vec!["order_id", "customer_id", "total"])
659 .at_address("10.0.0.1:3000")
660 .with_public_key("SHA256:fingerprint123")
661 .file_name("orders_export.csv")
662 .column_separator('|')
663 .column_delimiter('"')
664 .row_separator(RowSeparator::LF)
665 .encoding("UTF-8")
666 .null_value("\\N")
667 .with_column_names(true)
668 .delimit_mode(DelimitMode::Always)
669 .compressed(Compression::Gzip)
670 .build();
671
672 assert!(sql.starts_with("EXPORT sales.orders (order_id, customer_id, total)"));
674 assert!(sql.contains("INTO CSV AT 'https://10.0.0.1:3000'"));
675 assert!(sql.contains("PUBLIC KEY 'SHA256:fingerprint123'"));
676 assert!(sql.contains("FILE 'orders_export.csv.gz'"));
677 assert!(sql.contains("ENCODING = 'UTF-8'"));
678 assert!(sql.contains("COLUMN SEPARATOR = '|'"));
679 assert!(sql.contains("COLUMN DELIMITER = '\"'"));
680 assert!(sql.contains("ROW SEPARATOR = 'LF'"));
681 assert!(sql.contains("NULL = '\\N'"));
682 assert!(sql.contains("WITH COLUMN NAMES"));
683 assert!(sql.contains("DELIMIT = ALWAYS"));
684 }
685
686 #[test]
688 fn test_row_separator_as_sql() {
689 assert_eq!(RowSeparator::LF.as_sql(), "LF");
690 assert_eq!(RowSeparator::CR.as_sql(), "CR");
691 assert_eq!(RowSeparator::CRLF.as_sql(), "CRLF");
692 }
693
694 #[test]
695 fn test_compression_extension() {
696 assert_eq!(Compression::None.extension(), "");
697 assert_eq!(Compression::Gzip.extension(), ".gz");
698 assert_eq!(Compression::Bzip2.extension(), ".bz2");
699 }
700
701 #[test]
702 fn test_delimit_mode_as_sql() {
703 assert_eq!(DelimitMode::Auto.as_sql(), "AUTO");
704 assert_eq!(DelimitMode::Always.as_sql(), "ALWAYS");
705 assert_eq!(DelimitMode::Never.as_sql(), "NEVER");
706 }
707
708 #[test]
710 fn test_default_values() {
711 assert_eq!(RowSeparator::default(), RowSeparator::LF);
712 assert_eq!(Compression::default(), Compression::None);
713 assert_eq!(DelimitMode::default(), DelimitMode::Auto);
714 }
715
716 #[test]
718 fn test_schema_columns_ignored_for_query() {
719 let sql = ExportQuery::from_query("SELECT 1")
720 .schema("ignored_schema")
721 .columns(vec!["ignored_col"])
722 .at_address("192.168.1.100:8080")
723 .build();
724
725 assert!(sql.starts_with("EXPORT (SELECT 1)"));
726 assert!(!sql.contains("ignored_schema"));
727 assert!(!sql.contains("ignored_col"));
728 }
729}