1use super::clauses;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum RowSeparator {
14 #[default]
16 LF,
17 CR,
19 CRLF,
21}
22
23impl RowSeparator {
24 pub fn to_sql(&self) -> &'static str {
26 match self {
27 RowSeparator::LF => "LF",
28 RowSeparator::CR => "CR",
29 RowSeparator::CRLF => "CRLF",
30 }
31 }
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
36pub enum Compression {
37 #[default]
39 None,
40 Gzip,
42 Bzip2,
44}
45
46impl Compression {
47 pub fn extension(&self) -> &'static str {
49 match self {
50 Compression::None => ".csv",
51 Compression::Gzip => ".csv.gz",
52 Compression::Bzip2 => ".csv.bz2",
53 }
54 }
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
59pub enum ImportFormat {
60 #[default]
62 Csv,
63 Parquet,
66}
67
68#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
70pub enum TrimMode {
71 #[default]
73 None,
74 LTrim,
76 RTrim,
78 Trim,
80}
81
82impl TrimMode {
83 pub fn to_sql(&self) -> Option<&'static str> {
85 match self {
86 TrimMode::None => None,
87 TrimMode::LTrim => Some("LTRIM"),
88 TrimMode::RTrim => Some("RTRIM"),
89 TrimMode::Trim => Some("TRIM"),
90 }
91 }
92}
93
94#[derive(Debug, Clone)]
99pub struct ImportFileEntry {
100 pub address: String,
102 pub file_name: String,
104 pub public_key: Option<String>,
106}
107
108impl ImportFileEntry {
109 pub fn new(address: String, file_name: String, public_key: Option<String>) -> Self {
117 Self {
118 address,
119 file_name,
120 public_key,
121 }
122 }
123}
124
125#[derive(Debug, Clone)]
147pub struct ImportQuery {
148 table: String,
150 schema: Option<String>,
152 columns: Option<Vec<String>>,
154 address: Option<String>,
156 public_key: Option<String>,
158 file_name: String,
160 file_entries: Option<Vec<ImportFileEntry>>,
162 column_separator: char,
164 column_delimiter: char,
166 row_separator: RowSeparator,
168 encoding: String,
170 skip: u32,
172 null_value: Option<String>,
174 trim: TrimMode,
176 compression: Compression,
178 reject_limit: Option<u32>,
180 format: ImportFormat,
182}
183
184impl ImportQuery {
185 pub fn new(table: &str) -> Self {
191 Self {
192 table: table.to_string(),
193 schema: None,
194 columns: None,
195 address: None,
196 public_key: None,
197 file_name: "001.csv".to_string(),
198 file_entries: None,
199 column_separator: ',',
200 column_delimiter: '"',
201 row_separator: RowSeparator::default(),
202 encoding: "UTF-8".to_string(),
203 skip: 0,
204 null_value: None,
205 trim: TrimMode::default(),
206 compression: Compression::default(),
207 reject_limit: None,
208 format: ImportFormat::default(),
209 }
210 }
211
212 pub fn schema(mut self, schema: &str) -> Self {
217 self.schema = Some(schema.to_string());
218 self
219 }
220
221 pub fn columns(mut self, cols: Vec<&str>) -> Self {
228 self.columns = Some(cols.into_iter().map(String::from).collect());
229 self
230 }
231
232 pub fn at_address(mut self, addr: &str) -> Self {
237 self.address = Some(addr.to_string());
238 self
239 }
240
241 pub fn with_public_key(mut self, fingerprint: &str) -> Self {
248 self.public_key = Some(fingerprint.to_string());
249 self
250 }
251
252 pub fn file_name(mut self, name: &str) -> Self {
257 self.file_name = name.to_string();
258 self
259 }
260
261 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 {
275 self.column_delimiter = delim;
276 self
277 }
278
279 pub fn row_separator(mut self, sep: RowSeparator) -> Self {
284 self.row_separator = sep;
285 self
286 }
287
288 pub fn encoding(mut self, enc: &str) -> Self {
293 self.encoding = enc.to_string();
294 self
295 }
296
297 pub fn skip(mut self, rows: u32) -> Self {
302 self.skip = rows;
303 self
304 }
305
306 pub fn null_value(mut self, val: &str) -> Self {
311 self.null_value = Some(val.to_string());
312 self
313 }
314
315 pub fn trim(mut self, trim: TrimMode) -> Self {
320 self.trim = trim;
321 self
322 }
323
324 pub fn compressed(mut self, compression: Compression) -> Self {
331 self.compression = compression;
332 self
333 }
334
335 pub fn reject_limit(mut self, limit: u32) -> Self {
340 self.reject_limit = Some(limit);
341 self
342 }
343
344 pub fn with_format(mut self, format: ImportFormat) -> Self {
354 self.format = format;
355 self
356 }
357
358 pub fn with_files(mut self, entries: Vec<ImportFileEntry>) -> Self {
371 self.file_entries = Some(entries);
372 self
373 }
374
375 pub fn build(&self) -> String {
381 let mut sql = String::with_capacity(512);
382 sql.push_str(&self.target_clause());
383 sql.push_str(&self.source_clause());
384 if !self.is_parquet() {
389 sql.push_str(&self.csv_option_clauses());
390 }
391 sql
392 }
393
394 fn target_clause(&self) -> String {
396 let mut clause = String::from("IMPORT INTO ");
397 if let Some(ref schema) = self.schema {
398 clause.push_str(schema);
399 clause.push('.');
400 }
401 clause.push_str(&self.table);
402
403 if let Some(ref cols) = self.columns {
404 clause.push_str(" (");
405 clause.push_str(&cols.join(", "));
406 clause.push(')');
407 }
408 clause
409 }
410
411 fn source_clause(&self) -> String {
417 let mut clause = String::from("\nFROM ");
418 clause.push_str(if self.is_parquet() { "PARQUET" } else { "CSV" });
419
420 match self.file_entries {
421 Some(ref entries) => {
422 for entry in entries {
423 clause.push_str(" AT ");
424 clause.push_str(&self.endpoint_of(&entry.address, entry.public_key.as_deref()));
425 clause.push_str(" FILE '");
426 clause.push_str(&self.file_name_for(&entry.file_name));
427 clause.push('\'');
428 }
429 }
430 None => {
431 clause.push_str(" AT ");
432 clause.push_str(&self.endpoint_of(
433 self.address.as_deref().unwrap_or_default(),
434 self.public_key.as_deref(),
435 ));
436 clause.push_str("\nFILE '");
437 clause.push_str(&self.file_name_for(&self.file_name));
438 clause.push('\'');
439 }
440 }
441 clause
442 }
443
444 fn endpoint_of(&self, address: &str, public_key: Option<&str>) -> String {
450 let url_suffix = if self.is_parquet() {
451 ";MaxConcurrentReads=1"
452 } else {
453 ""
454 };
455 clauses::quoted_endpoint(address, url_suffix, public_key)
456 }
457
458 fn csv_option_clauses(&self) -> String {
460 let mut clause = clauses::CsvFraming {
461 encoding: &self.encoding,
462 column_separator: self.column_separator,
463 column_delimiter: self.column_delimiter,
464 row_separator: self.row_separator.to_sql(),
465 }
466 .to_sql();
467
468 if self.skip > 0 {
469 clause.push_str(&format!("\nSKIP = {}", self.skip));
470 }
471 clause.push_str(&clauses::null_clause(self.null_value.as_deref()));
472 if let Some(trim_sql) = self.trim.to_sql() {
473 clause.push_str(&format!("\nTRIM = '{}'", trim_sql));
474 }
475 if let Some(limit) = self.reject_limit {
476 clause.push_str(&format!("\nREJECT LIMIT {}", limit));
477 }
478 clause
479 }
480
481 fn is_parquet(&self) -> bool {
482 matches!(self.format, ImportFormat::Parquet)
483 }
484
485 fn file_name_for(&self, base_name: &str) -> String {
491 let base = strip_known_extensions(base_name);
492 match self.format {
493 ImportFormat::Parquet => format!("{}.parquet", base),
494 ImportFormat::Csv => format!("{}{}", base, self.compression.extension()),
495 }
496 }
497}
498
499fn strip_known_extensions(name: &str) -> &str {
500 let mut current = name;
501 loop {
502 let stripped = current
503 .strip_suffix(".gz")
504 .or_else(|| current.strip_suffix(".bz2"))
505 .or_else(|| current.strip_suffix(".csv"))
506 .or_else(|| current.strip_suffix(".parquet"));
507 match stripped {
508 Some(next) if next.len() < current.len() => current = next,
509 _ => return current,
510 }
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 use super::*;
517
518 #[test]
519 fn test_basic_import_statement() {
520 let sql = ImportQuery::new("users")
521 .at_address("192.168.1.1:8080")
522 .build();
523
524 assert!(sql.contains("IMPORT INTO users"));
525 assert!(sql.contains("FROM CSV AT 'http://192.168.1.1:8080'"));
526 assert!(sql.contains("FILE '001.csv'"));
527 assert!(sql.contains("ENCODING = 'UTF-8'"));
528 assert!(sql.contains("COLUMN SEPARATOR = ','"));
529 assert!(sql.contains("COLUMN DELIMITER = '\"'"));
530 assert!(sql.contains("ROW SEPARATOR = 'LF'"));
531 }
532
533 #[test]
534 fn test_import_with_schema() {
535 let sql = ImportQuery::new("users")
536 .schema("myschema")
537 .at_address("192.168.1.1:8080")
538 .build();
539
540 assert!(sql.contains("IMPORT INTO myschema.users"));
541 }
542
543 #[test]
544 fn test_import_with_columns() {
545 let sql = ImportQuery::new("users")
546 .columns(vec!["id", "name", "email"])
547 .at_address("192.168.1.1:8080")
548 .build();
549
550 assert!(sql.contains("IMPORT INTO users (id, name, email)"));
551 }
552
553 #[test]
554 fn test_import_with_all_format_options() {
555 let sql = ImportQuery::new("data")
556 .at_address("10.0.0.1:9000")
557 .column_separator(';')
558 .column_delimiter('\'')
559 .row_separator(RowSeparator::CRLF)
560 .encoding("ISO-8859-1")
561 .skip(2)
562 .null_value("NULL")
563 .trim(TrimMode::Trim)
564 .reject_limit(100)
565 .build();
566
567 assert!(sql.contains("COLUMN SEPARATOR = ';'"));
568 assert!(sql.contains("COLUMN DELIMITER = '''"));
569 assert!(sql.contains("ROW SEPARATOR = 'CRLF'"));
570 assert!(sql.contains("ENCODING = 'ISO-8859-1'"));
571 assert!(sql.contains("SKIP = 2"));
572 assert!(sql.contains("NULL = 'NULL'"));
573 assert!(sql.contains("TRIM = 'TRIM'"));
574 assert!(sql.contains("REJECT LIMIT 100"));
575 }
576
577 #[test]
578 fn test_import_with_use_tls() {
579 let fingerprint = "SHA256:abc123def456";
580 let sql = ImportQuery::new("secure_data")
581 .at_address("192.168.1.1:8443")
582 .with_public_key(fingerprint)
583 .build();
584
585 assert!(sql.contains("FROM CSV AT 'https://192.168.1.1:8443'"));
586 assert!(sql.contains(&format!("PUBLIC KEY '{}'", fingerprint)));
587 }
588
589 #[test]
590 fn test_import_with_gzip_compression() {
591 let sql = ImportQuery::new("compressed_data")
592 .at_address("192.168.1.1:8080")
593 .compressed(Compression::Gzip)
594 .build();
595
596 assert!(sql.contains("FILE '001.csv.gz'"));
597 }
598
599 #[test]
600 fn test_import_with_bzip2_compression() {
601 let sql = ImportQuery::new("compressed_data")
602 .at_address("192.168.1.1:8080")
603 .compressed(Compression::Bzip2)
604 .build();
605
606 assert!(sql.contains("FILE '001.csv.bz2'"));
607 }
608
609 #[test]
610 fn test_import_custom_file_name() {
611 let sql = ImportQuery::new("data")
612 .at_address("192.168.1.1:8080")
613 .file_name("custom_file")
614 .build();
615
616 assert!(sql.contains("FILE 'custom_file.csv'"));
617 }
618
619 #[test]
620 fn test_import_custom_file_name_with_compression() {
621 let sql = ImportQuery::new("data")
622 .at_address("192.168.1.1:8080")
623 .file_name("custom_file")
624 .compressed(Compression::Gzip)
625 .build();
626
627 assert!(sql.contains("FILE 'custom_file.csv.gz'"));
628 }
629
630 #[test]
631 fn test_row_separator_to_sql() {
632 assert_eq!(RowSeparator::LF.to_sql(), "LF");
633 assert_eq!(RowSeparator::CR.to_sql(), "CR");
634 assert_eq!(RowSeparator::CRLF.to_sql(), "CRLF");
635 }
636
637 #[test]
638 fn test_compression_extension() {
639 assert_eq!(Compression::None.extension(), ".csv");
640 assert_eq!(Compression::Gzip.extension(), ".csv.gz");
641 assert_eq!(Compression::Bzip2.extension(), ".csv.bz2");
642 }
643
644 #[test]
645 fn test_trim_mode_to_sql() {
646 assert_eq!(TrimMode::None.to_sql(), None);
647 assert_eq!(TrimMode::LTrim.to_sql(), Some("LTRIM"));
648 assert_eq!(TrimMode::RTrim.to_sql(), Some("RTRIM"));
649 assert_eq!(TrimMode::Trim.to_sql(), Some("TRIM"));
650 }
651
652 #[test]
653 fn test_defaults() {
654 assert_eq!(RowSeparator::default(), RowSeparator::LF);
655 assert_eq!(Compression::default(), Compression::None);
656 assert_eq!(TrimMode::default(), TrimMode::None);
657 }
658
659 #[test]
660 fn test_import_file_entry() {
661 let entry = ImportFileEntry::new(
662 "10.0.0.5:8563".to_string(),
663 "001.csv".to_string(),
664 Some("sha256//abc123".to_string()),
665 );
666
667 assert_eq!(entry.address, "10.0.0.5:8563");
668 assert_eq!(entry.file_name, "001.csv");
669 assert_eq!(entry.public_key, Some("sha256//abc123".to_string()));
670 }
671
672 #[test]
673 fn test_multi_file_import_basic() {
674 let entries = vec![
675 ImportFileEntry::new("10.0.0.5:8563".to_string(), "001.csv".to_string(), None),
676 ImportFileEntry::new("10.0.0.6:8564".to_string(), "002.csv".to_string(), None),
677 ];
678
679 let sql = ImportQuery::new("my_table").with_files(entries).build();
680
681 assert!(sql.contains("IMPORT INTO my_table"));
682 assert!(sql.contains("FROM CSV"));
683 assert!(sql.contains("AT 'http://10.0.0.5:8563' FILE '001.csv'"));
684 assert!(sql.contains("AT 'http://10.0.0.6:8564' FILE '002.csv'"));
685 }
686
687 #[test]
688 fn test_multi_file_import_with_tls() {
689 let entries = vec![
690 ImportFileEntry::new(
691 "10.0.0.5:8563".to_string(),
692 "001.csv".to_string(),
693 Some("sha256//fingerprint1".to_string()),
694 ),
695 ImportFileEntry::new(
696 "10.0.0.6:8564".to_string(),
697 "002.csv".to_string(),
698 Some("sha256//fingerprint2".to_string()),
699 ),
700 ];
701
702 let sql = ImportQuery::new("secure_table").with_files(entries).build();
703
704 assert!(sql.contains("AT 'https://10.0.0.5:8563' PUBLIC KEY 'sha256//fingerprint1'"));
705 assert!(sql.contains("AT 'https://10.0.0.6:8564' PUBLIC KEY 'sha256//fingerprint2'"));
706 }
707
708 #[test]
709 fn test_multi_file_import_with_compression() {
710 let entries = vec![
711 ImportFileEntry::new("10.0.0.5:8563".to_string(), "001.csv".to_string(), None),
712 ImportFileEntry::new("10.0.0.6:8564".to_string(), "002.csv".to_string(), None),
713 ];
714
715 let sql = ImportQuery::new("compressed_table")
716 .with_files(entries)
717 .compressed(Compression::Gzip)
718 .build();
719
720 assert!(sql.contains("FILE '001.csv.gz'"));
721 assert!(sql.contains("FILE '002.csv.gz'"));
722 }
723
724 #[test]
725 fn test_multi_file_import_three_files() {
726 let entries = vec![
727 ImportFileEntry::new("10.0.0.5:8563".to_string(), "001.csv".to_string(), None),
728 ImportFileEntry::new("10.0.0.6:8564".to_string(), "002.csv".to_string(), None),
729 ImportFileEntry::new("10.0.0.7:8565".to_string(), "003.csv".to_string(), None),
730 ];
731
732 let sql = ImportQuery::new("data").with_files(entries).build();
733
734 assert!(sql.contains("AT 'http://10.0.0.5:8563' FILE '001.csv'"));
735 assert!(sql.contains("AT 'http://10.0.0.6:8564' FILE '002.csv'"));
736 assert!(sql.contains("AT 'http://10.0.0.7:8565' FILE '003.csv'"));
737 }
738
739 #[test]
740 fn test_multi_file_import_with_schema_and_columns() {
741 let entries = vec![
742 ImportFileEntry::new("10.0.0.5:8563".to_string(), "001.csv".to_string(), None),
743 ImportFileEntry::new("10.0.0.6:8564".to_string(), "002.csv".to_string(), None),
744 ];
745
746 let sql = ImportQuery::new("my_table")
747 .schema("my_schema")
748 .columns(vec!["id", "name", "value"])
749 .with_files(entries)
750 .build();
751
752 assert!(sql.contains("IMPORT INTO my_schema.my_table (id, name, value)"));
753 }
754
755 #[test]
756 fn test_multi_file_import_with_all_options() {
757 let entries = vec![
758 ImportFileEntry::new("10.0.0.5:8563".to_string(), "001.csv".to_string(), None),
759 ImportFileEntry::new("10.0.0.6:8564".to_string(), "002.csv".to_string(), None),
760 ];
761
762 let sql = ImportQuery::new("data")
763 .with_files(entries)
764 .encoding("ISO-8859-1")
765 .column_separator(';')
766 .skip(1)
767 .null_value("NULL")
768 .reject_limit(100)
769 .build();
770
771 assert!(sql.contains("ENCODING = 'ISO-8859-1'"));
772 assert!(sql.contains("COLUMN SEPARATOR = ';'"));
773 assert!(sql.contains("SKIP = 1"));
774 assert!(sql.contains("NULL = 'NULL'"));
775 assert!(sql.contains("REJECT LIMIT 100"));
776 }
777
778 #[test]
779 fn test_import_no_skip_when_zero() {
780 let sql = ImportQuery::new("data")
781 .at_address("192.168.1.1:8080")
782 .build();
783
784 assert!(!sql.contains("SKIP"));
786 }
787
788 #[test]
789 fn test_import_skip_header_row() {
790 let sql = ImportQuery::new("data")
791 .at_address("192.168.1.1:8080")
792 .skip(1)
793 .build();
794
795 assert!(sql.contains("SKIP = 1"));
796 }
797
798 #[test]
799 fn test_complete_import_statement_format() {
800 let sql = ImportQuery::new("employees")
801 .schema("hr")
802 .columns(vec!["id", "first_name", "last_name", "department"])
803 .at_address("10.20.30.40:8080")
804 .with_public_key("SHA256:fingerprint123")
805 .skip(1)
806 .reject_limit(10)
807 .build();
808
809 let expected_parts = [
811 "IMPORT INTO hr.employees (id, first_name, last_name, department)",
812 "FROM CSV AT 'https://10.20.30.40:8080' PUBLIC KEY 'SHA256:fingerprint123'",
813 "FILE '001.csv'",
814 "ENCODING = 'UTF-8'",
815 "COLUMN SEPARATOR = ','",
816 "COLUMN DELIMITER = '\"'",
817 "ROW SEPARATOR = 'LF'",
818 "SKIP = 1",
819 "REJECT LIMIT 10",
820 ];
821
822 for part in expected_parts {
823 assert!(sql.contains(part), "SQL should contain: {}", part);
824 }
825 }
826
827 #[test]
828 fn test_import_query_format_parquet_single_file() {
829 let sql = ImportQuery::new("my_table")
830 .at_address("10.0.0.5:8563")
831 .with_format(ImportFormat::Parquet)
832 .build();
833 assert!(sql.contains("FROM PARQUET AT 'http://10.0.0.5:8563;MaxConcurrentReads=1'"));
834 assert!(sql.contains("FILE '001.parquet'"));
835 assert!(!sql.contains("ENCODING"));
836 }
837
838 #[test]
839 fn test_import_query_format_parquet_multi_file() {
840 let entries = vec![
841 ImportFileEntry::new("10.0.0.5:8563".to_string(), "001.parquet".to_string(), None),
842 ImportFileEntry::new("10.0.0.6:8564".to_string(), "002.parquet".to_string(), None),
843 ];
844 let sql = ImportQuery::new("my_table")
845 .with_files(entries)
846 .with_format(ImportFormat::Parquet)
847 .build();
848 assert!(sql.contains("FROM PARQUET"));
849 assert!(sql.contains("AT 'http://10.0.0.5:8563;MaxConcurrentReads=1'"));
850 assert!(sql.contains("AT 'http://10.0.0.6:8564;MaxConcurrentReads=1'"));
851 assert!(sql.contains("FILE '001.parquet'"));
852 assert!(sql.contains("FILE '002.parquet'"));
853 assert!(!sql.contains("MULTIPLE LOCAL FILES"));
854 }
855
856 #[test]
857 fn test_import_query_format_parquet_with_tls_emits_public_key() {
858 let sql = ImportQuery::new("t")
859 .at_address("10.0.0.5:8563")
860 .with_public_key("sha256//abc")
861 .with_format(ImportFormat::Parquet)
862 .build();
863 assert!(sql
864 .contains("AT 'https://10.0.0.5:8563;MaxConcurrentReads=1' PUBLIC KEY 'sha256//abc'"));
865 }
866
867 #[test]
868 fn test_import_query_format_parquet_omits_csv_format_options() {
869 let sql = ImportQuery::new("t")
870 .at_address("addr:1234")
871 .with_format(ImportFormat::Parquet)
872 .skip(2)
873 .null_value("NULL")
874 .trim(TrimMode::Trim)
875 .reject_limit(100)
876 .build();
877 for keyword in &[
878 "ENCODING",
879 "COLUMN SEPARATOR",
880 "COLUMN DELIMITER",
881 "ROW SEPARATOR",
882 "SKIP",
883 "NULL",
884 "TRIM",
885 "REJECT LIMIT",
886 ] {
887 assert!(
888 !sql.contains(keyword),
889 "SQL should NOT contain {} for Parquet format",
890 keyword
891 );
892 }
893 }
894
895 #[test]
896 fn test_import_query_parquet_ignores_compression() {
897 let sql = ImportQuery::new("t")
898 .at_address("addr:1234")
899 .with_format(ImportFormat::Parquet)
900 .compressed(Compression::Gzip)
901 .build();
902 assert!(sql.contains("FILE '001.parquet'"));
903 assert!(!sql.contains(".gz"));
904 }
905
906 #[test]
907 fn test_import_query_format_csv_unchanged() {
908 let sql = ImportQuery::new("t").at_address("192.168.1.1:8080").build();
909 assert!(sql.contains("FROM CSV AT 'http://192.168.1.1:8080'"));
910 assert!(sql.contains("ENCODING = 'UTF-8'"));
911 assert!(sql.contains("COLUMN SEPARATOR = ','"));
912 }
913
914 #[test]
919 fn test_build_renders_the_full_default_csv_statement_verbatim() {
920 let sql = ImportQuery::new("users")
921 .at_address("192.168.1.1:8080")
922 .build();
923
924 assert_eq!(
925 sql,
926 "IMPORT INTO users\n\
927 FROM CSV AT 'http://192.168.1.1:8080'\n\
928 FILE '001.csv'\n\
929 ENCODING = 'UTF-8'\n\
930 COLUMN SEPARATOR = ','\n\
931 COLUMN DELIMITER = '\"'\n\
932 ROW SEPARATOR = 'LF'"
933 );
934 }
935
936 #[test]
937 fn test_build_renders_every_csv_option_in_order() {
938 let sql = ImportQuery::new("data")
939 .schema("hr")
940 .columns(vec!["a", "b"])
941 .at_address("10.0.0.1:9000")
942 .with_public_key("SHA256:fp")
943 .column_separator(';')
944 .column_delimiter('|')
945 .row_separator(RowSeparator::CRLF)
946 .encoding("ISO-8859-1")
947 .skip(2)
948 .null_value("\\N")
949 .trim(TrimMode::Trim)
950 .reject_limit(100)
951 .compressed(Compression::Gzip)
952 .build();
953
954 assert_eq!(
955 sql,
956 "IMPORT INTO hr.data (a, b)\n\
957 FROM CSV AT 'https://10.0.0.1:9000' PUBLIC KEY 'SHA256:fp'\n\
958 FILE '001.csv.gz'\n\
959 ENCODING = 'ISO-8859-1'\n\
960 COLUMN SEPARATOR = ';'\n\
961 COLUMN DELIMITER = '|'\n\
962 ROW SEPARATOR = 'CRLF'\n\
963 SKIP = 2\n\
964 NULL = '\\N'\n\
965 TRIM = 'TRIM'\n\
966 REJECT LIMIT 100"
967 );
968 }
969
970 #[test]
971 fn test_build_renders_multi_file_entries_on_one_from_line() {
972 let entries = vec![
973 ImportFileEntry::new("10.0.0.5:8563".to_string(), "001.csv".to_string(), None),
974 ImportFileEntry::new(
975 "10.0.0.6:8564".to_string(),
976 "002.csv".to_string(),
977 Some("sha256//fp2".to_string()),
978 ),
979 ];
980
981 let sql = ImportQuery::new("my_table").with_files(entries).build();
982
983 assert_eq!(
984 sql,
985 "IMPORT INTO my_table\n\
986 FROM CSV AT 'http://10.0.0.5:8563' FILE '001.csv' \
987 AT 'https://10.0.0.6:8564' PUBLIC KEY 'sha256//fp2' FILE '002.csv'\n\
988 ENCODING = 'UTF-8'\n\
989 COLUMN SEPARATOR = ','\n\
990 COLUMN DELIMITER = '\"'\n\
991 ROW SEPARATOR = 'LF'"
992 );
993 }
994
995 #[test]
996 fn test_build_renders_the_full_parquet_statement_verbatim() {
997 let sql = ImportQuery::new("my_table")
998 .at_address("10.0.0.5:8563")
999 .with_format(ImportFormat::Parquet)
1000 .build();
1001
1002 assert_eq!(
1003 sql,
1004 "IMPORT INTO my_table\n\
1005 FROM PARQUET AT 'http://10.0.0.5:8563;MaxConcurrentReads=1'\n\
1006 FILE '001.parquet'"
1007 );
1008 }
1009
1010 #[test]
1011 fn test_build_without_address_still_renders_a_scheme_only_url() {
1012 let sql = ImportQuery::new("t").build();
1013
1014 assert!(sql.contains("FROM CSV AT 'http://'"), "got: {}", sql);
1015 }
1016
1017 #[test]
1018 fn test_strip_known_extensions_unwinds_stacked_suffixes() {
1019 assert_eq!(strip_known_extensions("001"), "001");
1020 assert_eq!(strip_known_extensions("001.csv"), "001");
1021 assert_eq!(strip_known_extensions("001.csv.gz"), "001");
1022 assert_eq!(strip_known_extensions("001.csv.bz2"), "001");
1023 assert_eq!(strip_known_extensions("001.parquet"), "001");
1024 assert_eq!(strip_known_extensions("archive.tar"), "archive.tar");
1025 }
1026}