From bd1e965f936c9fe652bd940c1f12e3327f06a222 Mon Sep 17 00:00:00 2001 From: Jesse Luehrs Date: Sat, 10 Nov 2018 00:50:24 -0500 Subject: add sql subcommand for raw queries this was more painful than i think it should have been --- src/cli.rs | 26 +++++++++++++++++++- src/cmd/mod.rs | 1 + src/cmd/sql.rs | 77 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/db.rs | 26 ++++++++++++++++++++ src/main.rs | 1 + 5 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/cmd/sql.rs diff --git a/src/cli.rs b/src/cli.rs index 1976d93..c2cae24 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,7 +3,11 @@ const _DUMMY_DEPENDENCY: &'static str = include_str!("../Cargo.toml"); pub enum Command { Sync { username: String, - } + }, + SQL { + query: String, + tsv: bool, + }, } pub fn get_options() -> failure::Fallible { @@ -17,6 +21,20 @@ pub fn get_options() -> failure::Fallible { .help("last.fm username to fetch tracks for") ) ) + .subcommand( + clap::SubCommand::with_name("sql") + .about("Run a query against the local database") + .arg( + clap::Arg::with_name("query") + .required(true) + .help("query to run") + ) + .arg( + clap::Arg::with_name("tsv") + .long("tsv") + .help("format output as tsv") + ) + ) .get_matches(); let command = match matches.subcommand() { @@ -25,6 +43,12 @@ pub fn get_options() -> failure::Fallible { username: matches.value_of("username").unwrap().to_string(), } }, + ("sql", Some(matches)) => { + Command::SQL { + query: matches.value_of("query").unwrap().to_string(), + tsv: matches.is_present("tsv"), + } + }, (name, Some(_)) => bail!("unknown subcommand: {}", name), (_, None) => bail!("no subcommand given"), }; diff --git a/src/cmd/mod.rs b/src/cmd/mod.rs index d086d5b..a25e955 100644 --- a/src/cmd/mod.rs +++ b/src/cmd/mod.rs @@ -1 +1,2 @@ +pub mod sql; pub mod sync; diff --git a/src/cmd/sql.rs b/src/cmd/sql.rs new file mode 100644 index 0000000..ae398b5 --- /dev/null +++ b/src/cmd/sql.rs @@ -0,0 +1,77 @@ +use db; +use paths; + +pub fn run(query: &str, tsv: bool) -> failure::Fallible<()> { + let db = db::DB::new(&paths::db_path()?)?; + + let rows_cell = std::cell::Cell::new(Some(vec![])); + let cols = db.query(query, |row| { + let display_row: Vec = (0..row.column_count()) + .map(|i| row.get_raw(i)) + .map(|v| format_value(&v)) + .collect(); + let mut rows = rows_cell.replace(None).unwrap(); + rows.push(display_row); + rows_cell.replace(Some(rows)); + })?; + + let rows = rows_cell.into_inner().unwrap(); + + if tsv { + print_tsv(&rows); + } + else { + print_table(&cols, &rows); + } + + Ok(()) +} + +fn print_table(cols: &[String], rows: &[Vec]) { + let widths = column_widths(&cols, &rows); + + print_row(&widths, &cols); + let border: Vec = widths.iter().map(|l| "-".repeat(*l)).collect(); + println!("{}", &border.join("-+-")); + + for row in rows { + print_row(&widths, &row); + } +} + +fn print_tsv(rows: &[Vec]) { + for row in rows { + println!("{}", row.join("\t")); + } +} + +fn format_value(v: &rusqlite::types::ValueRef) -> String { + match v { + rusqlite::types::ValueRef::Null => "null".to_string(), + rusqlite::types::ValueRef::Integer(i) => format!("{}", i), + rusqlite::types::ValueRef::Real(f) => format!("{}", f), + rusqlite::types::ValueRef::Text(s) => format!("{}", s), + rusqlite::types::ValueRef::Blob(b) => format!("{:?}", b), + } +} + +fn column_widths(cols: &[String], rows: &[Vec]) -> Vec { + let mut max_widths: Vec = cols.iter().map(|s| s.len()).collect(); + for row in rows { + for (i, col) in row.iter().enumerate() { + if col.len() > max_widths[i] { + max_widths[i] = col.len(); + } + } + } + max_widths +} + +fn print_row(widths: &[usize], row: &[String]) { + let fixed_width_row: Vec = row + .iter() + .zip(widths.iter()) + .map(|(s, width)| format!("{:width$}", s, width=width)) + .collect(); + println!("{}", &fixed_width_row.join(" | ")); +} diff --git a/src/db.rs b/src/db.rs index 056886e..97522d9 100644 --- a/src/db.rs +++ b/src/db.rs @@ -90,4 +90,30 @@ impl DB { } Ok(()) } + + pub fn query( + &self, + query: &str, + f: F + ) -> failure::Fallible> { + let mut sth = self.conn.prepare(query)?; + + let cols = sth.column_names() + .iter() + .map(|s| s.to_string()) + .collect(); + + let rows = sth.query_and_then( + rusqlite::NO_PARAMS, + |row| { f(row); Ok(()) }, + )?; + // this call to collect() forces it to actually consume the iterator + // (and therefore call the callbacks). what i really want here is for + // there to be a query_for_each or something like that, but the weird + // way lifetimes work for rows makes it difficult to emulate this any + // other way + let errs: failure::Fallible> = rows.collect(); + + errs.map(|_| cols) + } } diff --git a/src/main.rs b/src/main.rs index 4638adf..b43f310 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,6 +22,7 @@ fn run() -> failure::Fallible<()> { let command = cli::get_options()?; match command { cli::Command::Sync { username } => cmd::sync::run(&username), + cli::Command::SQL { query, tsv } => cmd::sql::run(&query, tsv), } } -- cgit v1.2.3