gluesql_cli/
print.rs

1use {
2    crate::command::{SetOption, ShowOption},
3    gluesql_core::prelude::{Payload, PayloadVariable},
4    std::{
5        collections::{BTreeMap, HashSet},
6        fmt::Display,
7        fs::File,
8        io::{Result as IOResult, Write},
9        path::Path,
10    },
11    strum_macros::Display,
12    tabled::{Style, Table, builder::Builder},
13};
14
15pub struct Print<W: Write> {
16    pub output: W,
17    spool_file: Option<File>,
18    pub option: PrintOption,
19}
20
21pub struct PrintOption {
22    pub tabular: bool,
23    colsep: String,
24    colwrap: String,
25    heading: bool,
26}
27
28impl PrintOption {
29    pub fn tabular(&mut self, tabular: bool) {
30        if tabular {
31            self.tabular = tabular;
32            self.colsep("|".into());
33            self.colwrap(String::new());
34            self.heading(true);
35        } else {
36            self.tabular = tabular;
37        }
38    }
39
40    fn colsep(&mut self, colsep: String) {
41        self.colsep = colsep;
42    }
43
44    fn colwrap(&mut self, colwrap: String) {
45        self.colwrap = colwrap;
46    }
47
48    fn heading(&mut self, heading: bool) {
49        self.heading = heading;
50    }
51
52    fn format(&self, option: ShowOption) -> String {
53        fn string_from(value: bool) -> String {
54            if value { "ON".into() } else { "OFF".into() }
55        }
56        match option {
57            ShowOption::Tabular => format!("tabular {}", string_from(self.tabular)),
58            ShowOption::Colsep => format!("colsep \"{}\"", self.colsep),
59            ShowOption::Colwrap => format!("colwrap \"{}\"", self.colwrap),
60            ShowOption::Heading => format!("heading {}", string_from(self.heading)),
61            ShowOption::All => format!(
62                "{}\n{}\n{}\n{}",
63                self.format(ShowOption::Tabular),
64                self.format(ShowOption::Colsep),
65                self.format(ShowOption::Colwrap),
66                self.format(ShowOption::Heading),
67            ),
68        }
69    }
70}
71
72impl Default for PrintOption {
73    fn default() -> Self {
74        Self {
75            tabular: true,
76            colsep: "|".into(),
77            colwrap: String::new(),
78            heading: true,
79        }
80    }
81}
82
83impl<'a, W: Write> Print<W> {
84    pub fn new(output: W, spool_file: Option<File>, option: PrintOption) -> Self {
85        Print {
86            output,
87            spool_file,
88            option,
89        }
90    }
91
92    pub fn payloads(&mut self, payloads: &[Payload]) -> IOResult<()> {
93        payloads.iter().try_for_each(|p| self.payload(p))
94    }
95
96    pub fn payload(&mut self, payload: &Payload) -> IOResult<()> {
97        #[derive(Display)]
98        #[strum(serialize_all = "snake_case")]
99        enum Target {
100            Table,
101            Row,
102        }
103        use Target::*;
104
105        let mut affected = |n: usize, target: Target, msg: &str| -> IOResult<()> {
106            let payload = format!("{n} {target}{} {msg}", if n > 1 { "s" } else { "" });
107            self.writeln(payload)
108        };
109        match payload {
110            Payload::Create => self.writeln("Table created")?,
111            Payload::DropTable(n) => affected(*n, Table, "dropped")?,
112            Payload::DropFunction => self.writeln("Function dropped")?,
113            Payload::AlterTable => self.writeln("Table altered")?,
114            Payload::CreateIndex => self.writeln("Index created")?,
115            Payload::DropIndex => self.writeln("Index dropped")?,
116            Payload::Commit => self.writeln("Commit completed")?,
117            Payload::Rollback => self.writeln("Rollback completed")?,
118            Payload::StartTransaction => self.writeln("Transaction started")?,
119            Payload::Insert(n) => affected(*n, Row, "inserted")?,
120            Payload::Delete(n) => affected(*n, Row, "deleted")?,
121            Payload::Update(n) => affected(*n, Row, "updated")?,
122            Payload::ShowVariable(PayloadVariable::Version(v)) => self.writeln(format!("v{v}"))?,
123            Payload::ShowVariable(PayloadVariable::Tables(names)) => {
124                let mut table = Self::get_table(["tables"]);
125                for name in names {
126                    table.add_record([name]);
127                }
128                let table = Self::build_table(table);
129                self.writeln(table)?;
130            }
131            Payload::ShowVariable(PayloadVariable::Functions(names)) => {
132                let mut table = Self::get_table(["functions"]);
133                for name in names {
134                    table.add_record([name]);
135                }
136                let table = Self::build_table(table);
137                self.writeln(table)?;
138            }
139            Payload::ShowColumns(columns) => {
140                let mut table = Self::get_table(vec!["Field", "Type"]);
141                for (field, field_type) in columns {
142                    table.add_record([field, &field_type.to_string()]);
143                }
144                let table = Self::build_table(table);
145                self.writeln(table)?;
146            }
147            Payload::Select { labels, rows } => match &self.option.tabular {
148                true => {
149                    let labels = labels.iter().map(AsRef::as_ref);
150                    let mut table = Self::get_table(labels);
151                    for row in rows {
152                        let row: Vec<String> = row.iter().map(Into::into).collect();
153
154                        table.add_record(row);
155                    }
156                    let table = Self::build_table(table);
157                    self.writeln(table)?;
158                }
159                false => {
160                    self.write_header(labels.iter().map(String::as_str))?;
161                    let rows = rows.iter().map(|row| row.iter().map(String::from));
162                    self.write_rows(rows)?;
163                }
164            },
165            Payload::SelectMap(rows) => {
166                let mut labels = rows
167                    .iter()
168                    .flat_map(BTreeMap::keys)
169                    .map(AsRef::as_ref)
170                    .collect::<HashSet<&str>>()
171                    .into_iter()
172                    .collect::<Vec<_>>();
173                labels.sort_unstable();
174
175                match &self.option.tabular {
176                    true => {
177                        let mut table = Self::get_table(labels.clone());
178                        for row in rows {
179                            let row = labels
180                                .iter()
181                                .map(|label| row.get(*label).map(Into::into).unwrap_or_default())
182                                .collect::<Vec<String>>();
183
184                            table.add_record(row);
185                        }
186                        let table = Self::build_table(table);
187                        self.writeln(table)?;
188                    }
189                    false => {
190                        self.write_header(labels.iter().map(AsRef::as_ref))?;
191
192                        let rows = rows.iter().map(|row| {
193                            labels
194                                .iter()
195                                .map(|label| row.get(*label).map(String::from).unwrap_or_default())
196                        });
197                        self.write_rows(rows)?;
198                    }
199                }
200            }
201        }
202
203        Ok(())
204    }
205
206    fn write_rows(
207        &mut self,
208        rows: impl Iterator<Item = impl Iterator<Item = String>>,
209    ) -> IOResult<()> {
210        for row in rows {
211            let row = row
212                .map(|v| format!("{c}{v}{c}", c = self.option.colwrap))
213                .collect::<Vec<_>>()
214                .join(self.option.colsep.as_str());
215
216            self.write(row)?;
217        }
218
219        Ok(())
220    }
221
222    fn write_lf(&mut self, payload: impl Display, lf: &str) -> IOResult<()> {
223        if let Some(file) = &self.spool_file {
224            writeln!(file.to_owned(), "{payload}{lf}")?;
225        }
226
227        writeln!(self.output, "{payload}{lf}")
228    }
229
230    fn write(&mut self, payload: impl Display) -> IOResult<()> {
231        self.write_lf(payload, "")
232    }
233
234    fn writeln(&mut self, payload: impl Display) -> IOResult<()> {
235        self.write_lf(payload, "\n")
236    }
237
238    fn write_header<'b>(&mut self, labels: impl Iterator<Item = &'b str>) -> IOResult<()> {
239        let PrintOption {
240            heading,
241            colsep,
242            colwrap,
243            ..
244        } = &self.option;
245
246        if !heading {
247            return Ok(());
248        }
249
250        let labels = labels
251            .map(|v| format!("{colwrap}{v}{colwrap}"))
252            .collect::<Vec<_>>()
253            .join(colsep.as_str());
254
255        self.write(labels)
256    }
257
258    pub fn help(&mut self) -> IOResult<()> {
259        const HEADER: [&str; 2] = ["command", "description"];
260        const CONTENT: [[&str; 2]; 12] = [
261            [".help", "show help"],
262            [".quit", "quit program"],
263            [".tables", "show table names"],
264            [".functions", "show function names"],
265            [".columns TABLE", "show columns from TABLE"],
266            [".version", "show version"],
267            [".execute PATH", "execute SQL from PATH"],
268            [".spool PATH|off", "spool to PATH or off"],
269            [".show OPTION", "show print option eg).show all"],
270            [".set OPTION", "set print option eg).set tabular off"],
271            [".edit [PATH]", "open editor with last command or PATH"],
272            [".run ", "execute last command"],
273        ];
274
275        let mut table = Self::get_table(HEADER);
276        for row in CONTENT {
277            table.add_record(row);
278        }
279        let table = Self::build_table(table);
280
281        writeln!(self.output, "{table}\n")
282    }
283
284    pub fn spool_on<P: AsRef<Path>>(&mut self, filename: P) -> IOResult<()> {
285        let file = File::create(filename)?;
286        self.spool_file = Some(file);
287
288        Ok(())
289    }
290
291    pub fn spool_off(&mut self) {
292        self.spool_file = None;
293    }
294
295    fn get_table<T: IntoIterator<Item = &'a str>>(headers: T) -> Builder {
296        let mut table = Builder::default();
297        table.set_columns(headers);
298
299        table
300    }
301
302    fn build_table(builder: Builder) -> Table {
303        builder.build().with(Style::markdown())
304    }
305
306    pub fn set_option(&mut self, option: SetOption) {
307        match option {
308            SetOption::Tabular(value) => self.option.tabular(value),
309            SetOption::Colsep(value) => self.option.colsep(value),
310            SetOption::Colwrap(value) => self.option.colwrap(value),
311            SetOption::Heading(value) => self.option.heading(value),
312        }
313    }
314
315    pub fn show_option(&mut self, option: ShowOption) -> IOResult<()> {
316        let payload = self.option.format(option);
317        self.writeln(payload)?;
318
319        Ok(())
320    }
321}
322
323#[cfg(test)]
324mod tests {
325    use {
326        super::{Print, PrintOption},
327        crate::command::{SetOption, ShowOption},
328        std::path::PathBuf,
329    };
330
331    #[test]
332    fn print_help() {
333        let mut print = Print::new(Vec::new(), None, PrintOption::default());
334
335        let actual = {
336            print.help().unwrap();
337
338            String::from_utf8(print.output).unwrap()
339        };
340        let expected = "
341| command         | description                           |
342|-----------------|---------------------------------------|
343| .help           | show help                             |
344| .quit           | quit program                          |
345| .tables         | show table names                      |
346| .functions      | show function names                   |
347| .columns TABLE  | show columns from TABLE               |
348| .version        | show version                          |
349| .execute PATH   | execute SQL from PATH                 |
350| .spool PATH|off | spool to PATH or off                  |
351| .show OPTION    | show print option eg).show all        |
352| .set OPTION     | set print option eg).set tabular off  |
353| .edit [PATH]    | open editor with last command or PATH |
354| .run            | execute last command                  |";
355
356        assert_eq!(
357            actual.as_str().trim_matches('\n'),
358            expected.trim_matches('\n')
359        );
360    }
361
362    #[test]
363    fn print_payload() {
364        use gluesql_core::{
365            ast::DataType,
366            prelude::{Payload, PayloadVariable, Value},
367        };
368
369        let mut print = Print::new(Vec::new(), None, PrintOption::default());
370
371        macro_rules! test {
372            ($payload: expr, $expected: literal ) => {
373                print.payloads(&[$payload]).unwrap();
374
375                assert_eq!(
376                    String::from_utf8(print.output.clone())
377                        .unwrap()
378                        .as_str()
379                        .trim_matches('\n'),
380                    $expected.trim_matches('\n')
381                );
382
383                print.output.clear();
384            };
385        }
386
387        test!(Payload::Create, "Table created");
388        test!(Payload::DropTable(1), "1 table dropped");
389        test!(Payload::AlterTable, "Table altered");
390        test!(Payload::CreateIndex, "Index created");
391        test!(Payload::DropIndex, "Index dropped");
392        test!(Payload::DropFunction, "Function dropped");
393        test!(Payload::Commit, "Commit completed");
394        test!(Payload::Rollback, "Rollback completed");
395        test!(Payload::StartTransaction, "Transaction started");
396        test!(Payload::Insert(0), "0 row inserted");
397        test!(Payload::Insert(1), "1 row inserted");
398        test!(Payload::Insert(7), "7 rows inserted");
399        test!(Payload::Delete(300), "300 rows deleted");
400        test!(Payload::Update(123), "123 rows updated");
401        test!(
402            Payload::ShowVariable(PayloadVariable::Version("11.6.1989".to_owned())),
403            "v11.6.1989"
404        );
405        test!(
406            Payload::ShowVariable(PayloadVariable::Tables(Vec::new())),
407            "
408| tables |"
409        );
410        test!(
411            Payload::ShowVariable(PayloadVariable::Functions(Vec::new())),
412            "
413| functions |"
414        );
415        test!(
416            Payload::ShowVariable(PayloadVariable::Tables(
417                [
418                    "Allocator",
419                    "ExtendFromWithin",
420                    "IntoRawParts",
421                    "Reserve",
422                    "Splice",
423                ]
424                .into_iter()
425                .map(ToOwned::to_owned)
426                .collect()
427            )),
428            "
429| tables           |
430|------------------|
431| Allocator        |
432| ExtendFromWithin |
433| IntoRawParts     |
434| Reserve          |
435| Splice           |"
436        );
437        test!(
438            Payload::ShowVariable(PayloadVariable::Functions(
439                [
440                    "Allocator",
441                    "ExtendFromWithin",
442                    "IntoRawParts",
443                    "Reserve",
444                    "Splice",
445                ]
446                .into_iter()
447                .map(ToOwned::to_owned)
448                .collect()
449            )),
450            "
451| functions        |
452|------------------|
453| Allocator        |
454| ExtendFromWithin |
455| IntoRawParts     |
456| Reserve          |
457| Splice           |"
458        );
459        test!(
460            Payload::Select {
461                labels: vec!["id".to_owned()],
462                rows: [101, 202, 301, 505, 1001]
463                    .into_iter()
464                    .map(Value::I64)
465                    .map(|v| vec![v])
466                    .collect::<Vec<Vec<Value>>>(),
467            },
468            "
469| id   |
470|------|
471| 101  |
472| 202  |
473| 301  |
474| 505  |
475| 1001 |"
476        );
477        test!(
478            Payload::Select {
479                labels: ["id", "title", "valid"]
480                    .into_iter()
481                    .map(ToOwned::to_owned)
482                    .collect(),
483                rows: vec![
484                    vec![
485                        Value::I64(1),
486                        Value::Str("foo".to_owned()),
487                        Value::Bool(true)
488                    ],
489                    vec![
490                        Value::I64(2),
491                        Value::Str("bar".to_owned()),
492                        Value::Bool(false)
493                    ],
494                    vec![
495                        Value::I64(3),
496                        Value::Str("bas".to_owned()),
497                        Value::Bool(false)
498                    ],
499                    vec![
500                        Value::I64(4),
501                        Value::Str("lim".to_owned()),
502                        Value::Bool(true)
503                    ],
504                    vec![
505                        Value::I64(5),
506                        Value::Str("kim".to_owned()),
507                        Value::Bool(true)
508                    ],
509                ],
510            },
511            "
512| id | title | valid |
513|----|-------|-------|
514| 1  | foo   | TRUE  |
515| 2  | bar   | FALSE |
516| 3  | bas   | FALSE |
517| 4  | lim   | TRUE  |
518| 5  | kim   | TRUE  |"
519        );
520
521        test!(
522            Payload::SelectMap(vec![
523                [
524                    ("id".to_owned(), Value::I64(1)),
525                    ("title".to_owned(), Value::Str("foo".to_owned()))
526                ]
527                .into_iter()
528                .collect(),
529                [("id".to_owned(), Value::I64(2))].into_iter().collect(),
530                [("title".to_owned(), Value::Str("bar".to_owned()))]
531                    .into_iter()
532                    .collect(),
533            ]),
534            "
535| id | title |
536|----|-------|
537| 1  | foo   |
538| 2  |       |
539|    | bar   |"
540        );
541
542        test!(
543            Payload::ShowColumns(vec![
544                ("id".to_owned(), DataType::Int),
545                ("name".to_owned(), DataType::Text),
546                ("isabear".to_owned(), DataType::Boolean),
547            ],),
548            "
549| Field   | Type    |
550|---------|---------|
551| id      | INT     |
552| name    | TEXT    |
553| isabear | BOOLEAN |"
554        );
555
556        test!(
557            Payload::ShowColumns(vec![
558                ("id".to_owned(), DataType::Int8),
559                ("calc1".to_owned(), DataType::Float),
560                ("cost".to_owned(), DataType::Decimal),
561                ("DOB".to_owned(), DataType::Date),
562                ("clock".to_owned(), DataType::Time),
563                ("tstamp".to_owned(), DataType::Timestamp),
564                ("ival".to_owned(), DataType::Interval),
565                ("uuid".to_owned(), DataType::Uuid),
566                ("hash".to_owned(), DataType::Map),
567                ("mylist".to_owned(), DataType::List),
568            ],),
569            "
570| Field  | Type      |
571|--------|-----------|
572| id     | INT8      |
573| calc1  | FLOAT     |
574| cost   | DECIMAL   |
575| DOB    | DATE      |
576| clock  | TIME      |
577| tstamp | TIMESTAMP |
578| ival   | INTERVAL  |
579| uuid   | UUID      |
580| hash   | MAP       |
581| mylist | LIST      |"
582        );
583
584        // ".set tabular OFF" should print SELECTED payload without tabular option
585        print.set_option(SetOption::Tabular(false));
586        test!(
587            Payload::Select {
588                labels: ["id", "title", "valid"]
589                    .into_iter()
590                    .map(ToOwned::to_owned)
591                    .collect(),
592                rows: vec![
593                    vec![
594                        Value::I64(1),
595                        Value::Str("foo".to_owned()),
596                        Value::Bool(true)
597                    ],
598                    vec![
599                        Value::I64(2),
600                        Value::Str("bar".to_owned()),
601                        Value::Bool(false)
602                    ],
603                ]
604            },
605            "
606id|title|valid
6071|foo|TRUE
6082|bar|FALSE"
609        );
610
611        test!(
612            Payload::SelectMap(vec![
613                [
614                    ("id".to_owned(), Value::I64(1)),
615                    ("title".to_owned(), Value::Str("foo".to_owned()))
616                ]
617                .into_iter()
618                .collect(),
619                [("id".to_owned(), Value::I64(2))].into_iter().collect(),
620                [("title".to_owned(), Value::Str("bar".to_owned()))]
621                    .into_iter()
622                    .collect(),
623            ]),
624            "
625id|title
6261|foo
6272|
628|bar"
629        );
630
631        // ".set colsep ," should set column separator as ","
632        print.set_option(SetOption::Colsep(",".into()));
633        assert_eq!(print.option.format(ShowOption::Colsep), r#"colsep ",""#);
634
635        test!(
636            Payload::Select {
637                labels: ["id", "title", "valid"]
638                    .into_iter()
639                    .map(ToOwned::to_owned)
640                    .collect(),
641                rows: vec![
642                    vec![
643                        Value::I64(1),
644                        Value::Str("foo".to_owned()),
645                        Value::Bool(true)
646                    ],
647                    vec![
648                        Value::I64(2),
649                        Value::Str("bar".to_owned()),
650                        Value::Bool(false)
651                    ],
652                ],
653            },
654            "
655id,title,valid
6561,foo,TRUE
6572,bar,FALSE"
658        );
659
660        // ".set colwrap '" should set column separator as "'"
661        print.set_option(SetOption::Colwrap("'".into()));
662        assert_eq!(print.option.format(ShowOption::Colwrap), r#"colwrap "'""#);
663        test!(
664            Payload::Select {
665                labels: ["id", "title", "valid"]
666                    .into_iter()
667                    .map(ToOwned::to_owned)
668                    .collect(),
669                rows: vec![
670                    vec![
671                        Value::I64(1),
672                        Value::Str("foo".to_owned()),
673                        Value::Bool(true)
674                    ],
675                    vec![
676                        Value::I64(2),
677                        Value::Str("bar".to_owned()),
678                        Value::Bool(false)
679                    ],
680                ],
681            },
682            "
683'id','title','valid'
684'1','foo','TRUE'
685'2','bar','FALSE'"
686        );
687
688        // ".set header OFF should print without column name"
689        print.set_option(SetOption::Heading(false));
690        test!(
691            Payload::Select {
692                labels: ["id", "title", "valid"]
693                    .into_iter()
694                    .map(ToOwned::to_owned)
695                    .collect(),
696                rows: vec![
697                    vec![
698                        Value::I64(1),
699                        Value::Str("foo".to_owned()),
700                        Value::Bool(true)
701                    ],
702                    vec![
703                        Value::I64(2),
704                        Value::Str("bar".to_owned()),
705                        Value::Bool(false)
706                    ],
707                ],
708            },
709            "
710'1','foo','TRUE'
711'2','bar','FALSE'"
712        );
713
714        // ".set header ON should print with column name"
715        print.set_option(SetOption::Heading(true));
716        print.set_option(SetOption::Tabular(false));
717        test!(
718            Payload::Select {
719                labels: ["id", "title", "valid"]
720                    .into_iter()
721                    .map(ToOwned::to_owned)
722                    .collect(),
723                rows: vec![
724                    vec![
725                        Value::I64(1),
726                        Value::Str("foo".to_owned()),
727                        Value::Bool(true)
728                    ],
729                    vec![
730                        Value::I64(2),
731                        Value::Str("bar".to_owned()),
732                        Value::Bool(false)
733                    ],
734                ],
735            },
736            "
737'id','title','valid'
738'1','foo','TRUE'
739'2','bar','FALSE'"
740        );
741
742        print.set_option(SetOption::Heading(false));
743        print.set_option(SetOption::Tabular(false));
744        test!(
745            Payload::Select {
746                labels: ["id"].into_iter().map(ToOwned::to_owned).collect(),
747                rows: vec![vec![Value::I64(1),], vec![Value::I64(2),],],
748            },
749            "'1'\n'2'"
750        );
751
752        // ".set tabular ON" should recover default option: colsep("|"), colwrap("")
753        print.set_option(SetOption::Tabular(true));
754        assert_eq!(print.option.format(ShowOption::Tabular), "tabular ON");
755        assert_eq!(print.option.format(ShowOption::Colsep), r#"colsep "|""#);
756        assert_eq!(print.option.format(ShowOption::Colwrap), r#"colwrap """#);
757        assert_eq!(print.option.format(ShowOption::Heading), "heading ON");
758        assert_eq!(
759            print.option.format(ShowOption::All),
760            "
761tabular ON
762colsep \"|\"
763colwrap \"\"
764heading ON"
765                .trim_matches('\n')
766        );
767    }
768
769    #[test]
770    fn print_spool() {
771        use std::fs;
772
773        let mut print = Print::new(Vec::new(), None, PrintOption::default());
774
775        // Spooling on file
776        fs::create_dir_all("tmp").unwrap();
777        assert!(print.spool_on(PathBuf::from("tmp/spool.txt")).is_ok());
778        assert!(print.writeln("Test").is_ok());
779        assert!(print.show_option(ShowOption::All).is_ok());
780        print.spool_off();
781        assert!(print.writeln("Test").is_ok());
782    }
783}