sql builder (#333)

* start

* clean up

* refactor globs
This commit is contained in:
Conrad Ludgate 2022-04-23 18:34:41 +01:00 committed by GitHub
parent 22a7d8866b
commit eab1dbf414
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 141 additions and 131 deletions

18
Cargo.lock generated
View file

@ -31,6 +31,12 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "anyhow"
version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08f9b8508dccb7687a1d6c4ce66b2b0ecef467c94667de27d8d7fe1f8d2a9cdc"
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.53" version = "0.1.53"
@ -110,6 +116,7 @@ dependencies = [
"eyre", "eyre",
"fs-err", "fs-err",
"itertools", "itertools",
"lazy_static",
"log", "log",
"minspan", "minspan",
"parse_duration", "parse_duration",
@ -121,6 +128,7 @@ dependencies = [
"serde_json", "serde_json",
"shellexpand", "shellexpand",
"sodiumoxide", "sodiumoxide",
"sql-builder",
"sqlx", "sqlx",
"tokio", "tokio",
"urlencoding", "urlencoding",
@ -2095,6 +2103,16 @@ dependencies = [
"lock_api", "lock_api",
] ]
[[package]]
name = "sql-builder"
version = "3.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1008d95d2ec2d062959352527be30e10fec42a1aa5e5a48d990a5ff0fb9bdc0"
dependencies = [
"anyhow",
"thiserror",
]
[[package]] [[package]]
name = "sqlformat" name = "sqlformat"
version = "0.1.8" version = "0.1.8"

View file

@ -46,6 +46,8 @@ sqlx = { version = "0.5", features = [
minspan = "0.1.1" minspan = "0.1.1"
regex = "1.5.4" regex = "1.5.4"
fs-err = "2.7" fs-err = "2.7"
sql-builder = "3"
lazy_static = "1"
# sync # sync
urlencoding = { version = "2.1.0", optional = true } urlencoding = { version = "2.1.0", optional = true }

View file

@ -5,11 +5,11 @@ use std::str::FromStr;
use async_trait::async_trait; use async_trait::async_trait;
use chrono::prelude::*; use chrono::prelude::*;
use chrono::Utc; use chrono::Utc;
use itertools::Itertools;
use regex::Regex;
use fs_err as fs; use fs_err as fs;
use itertools::Itertools;
use lazy_static::lazy_static;
use regex::Regex;
use sql_builder::{esc, quote, SqlBuilder, SqlName};
use sqlx::{ use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow}, sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePool, SqlitePoolOptions, SqliteRow},
Result, Row, Result, Row,
@ -219,50 +219,30 @@ impl Database for Sqlite {
) -> Result<Vec<History>> { ) -> Result<Vec<History>> {
debug!("listing history"); debug!("listing history");
// gotta get that query builder in soon cuz I kinda hate this let mut query = SqlBuilder::select_from(SqlName::new("history").alias("h").baquoted());
let query = if unique { query.field("*").order_desc("timestamp");
"where timestamp = (
select max(timestamp) from history
where h.command = history.command
)"
} else {
""
}
.to_string();
let mut join = if unique { "and" } else { "where" }.to_string(); match filter {
FilterMode::Global => &mut query,
let filter_query = match filter { FilterMode::Host => query.and_where_eq("hostname", quote(&context.hostname)),
FilterMode::Global => { FilterMode::Session => query.and_where_eq("session", quote(&context.session)),
join = "".to_string(); FilterMode::Directory => query.and_where_eq("cwd", quote(&context.cwd)),
"".to_string()
}
FilterMode::Host => format!("hostname = '{}'", context.hostname).to_string(),
FilterMode::Session => format!("session = '{}'", context.session).to_string(),
FilterMode::Directory => format!("cwd = '{}'", context.cwd).to_string(),
}; };
let filter = if filter_query.is_empty() { if unique {
"".to_string() query.and_where_eq(
} else { "timestamp",
format!("{} {}", join, filter_query) "(select max(timestamp) from history where h.command = history.command)",
};
let limit = if let Some(max) = max {
format!("limit {}", max)
} else {
"".to_string()
};
let query = format!(
"select * from history h
{} {}
order by timestamp desc
{}",
query, filter, limit,
); );
}
let res = sqlx::query(query.as_str()) if let Some(max) = max {
query.limit(max);
}
let query = query.sql().expect("bug in list query. please report");
let res = sqlx::query(&query)
.map(Self::query_history) .map(Self::query_history)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
@ -339,108 +319,78 @@ impl Database for Sqlite {
context: &Context, context: &Context,
query: &str, query: &str,
) -> Result<Vec<History>> { ) -> Result<Vec<History>> {
let orig_query = query; let mut sql = SqlBuilder::select_from("history");
let query = query.to_string().replace('*', "%"); // allow wildcard char
let limit = limit.map_or("".to_owned(), |l| format!("limit {}", l));
let (query_sql, query_params) = match search_mode { sql.group_by("command")
SearchMode::Prefix => ("command like ?1".to_string(), vec![format!("{}%", query)]), .having("max(timestamp)")
SearchMode::FullText => ("command like ?1".to_string(), vec![format!("%{}%", query)]), .order_desc("timestamp");
SearchMode::Fuzzy => {
let split_regex = Regex::new(r" +").unwrap(); if let Some(limit) = limit {
let terms: Vec<&str> = split_regex.split(query.as_str()).collect(); sql.limit(limit);
let mut query_sql = std::string::String::new(); }
let mut query_params = Vec::with_capacity(terms.len());
let mut was_or = false; match filter {
for (i, query_part) in terms.into_iter().enumerate() { FilterMode::Global => &mut sql,
// TODO smart case mode could be made configurable like in fzf FilterMode::Host => sql.and_where_eq("hostname", quote(&context.hostname)),
let (operator, glob) = if query_part.contains(char::is_uppercase) { FilterMode::Session => sql.and_where_eq("session", quote(&context.session)),
("glob", '*') FilterMode::Directory => sql.and_where_eq("cwd", quote(&context.cwd)),
} else {
("like", '%')
}; };
let orig_query = query;
let query = query.replace('*', "%"); // allow wildcard char
match search_mode {
SearchMode::Prefix => sql.and_where_like_left("command", query),
SearchMode::FullText => sql.and_where_like_any("command", query),
SearchMode::Fuzzy => {
// don't recompile the regex on successive calls!
lazy_static! {
static ref SPLIT_REGEX: Regex = Regex::new(r" +").unwrap();
}
let mut is_or = false;
for query_part in SPLIT_REGEX.split(query.as_str()) {
// TODO smart case mode could be made configurable like in fzf
let (is_glob, glob) = if query_part.contains(char::is_uppercase) {
(true, "*")
} else {
(false, "%")
};
let (is_inverse, query_part) = match query_part.strip_prefix('!') { let (is_inverse, query_part) = match query_part.strip_prefix('!') {
Some(stripped) => (true, stripped), Some(stripped) => (true, stripped),
None => (false, query_part), None => (false, query_part),
}; };
match query_part {
"|" => { let param = if query_part == "|" {
if !was_or { if !is_or {
query_sql.push_str(" OR "); is_or = true;
was_or = true;
continue; continue;
} else { } else {
query_params.push(format!("{glob}|{glob}")); format!("{glob}|{glob}")
} }
} } else if let Some(term) = query_part.strip_prefix('^') {
exact_prefix if query_part.starts_with('^') => query_params.push(format!( format!("{term}{glob}")
"{term}{glob}", } else if let Some(term) = query_part.strip_suffix('$') {
term = exact_prefix.strip_prefix('^').unwrap() format!("{glob}{term}")
)), } else if let Some(term) = query_part.strip_prefix('\'') {
exact_suffix if query_part.ends_with('$') => query_params.push(format!( format!("{glob}{term}{glob}")
"{glob}{term}", } else if is_inverse {
term = exact_suffix.strip_suffix('$').unwrap() format!("{glob}{term}{glob}", term = query_part)
)),
exact if query_part.starts_with('\'') => query_params.push(format!(
"{glob}{term}{glob}",
term = exact.strip_prefix('\'').unwrap()
)),
exact if is_inverse => {
query_params.push(format!("{glob}{term}{glob}", term = exact))
}
_ => {
query_params.push(query_part.split("").join(glob.to_string().as_str()))
}
}
if i > 0 && !was_or {
query_sql.push_str(" AND ");
}
if is_inverse {
query_sql.push_str("NOT ");
}
query_sql
.push_str(format!("command {} ?{}", operator, query_params.len()).as_str());
was_or = false;
}
(query_sql, query_params)
}
};
let filter_base = if query_sql.is_empty() {
"".to_string()
} else { } else {
"and".to_string() query_part.split("").join(glob)
}; };
let filter_query = match filter { sql.fuzzy_condition("command", param, is_inverse, is_glob, is_or);
FilterMode::Global => String::from(""), is_or = false;
FilterMode::Session => format!("session = '{}'", context.session), }
FilterMode::Directory => format!("cwd = '{}'", context.cwd), &mut sql
FilterMode::Host => format!("hostname = '{}'", context.hostname), }
}; };
let filter_sql = if filter_query.is_empty() { let query = sql.sql().expect("bug in search query. please report");
"".to_string()
} else {
format!("{} {}", filter_base, filter_query)
};
let sql = format!( let res = sqlx::query(&query)
"select * from history h
where {} {}
group by command
having max(timestamp)
order by timestamp desc {}",
query_sql.as_str(),
filter_sql.as_str(),
limit.clone()
);
let res = query_params
.iter()
.fold(sqlx::query(sql.as_str()), |query, query_param| {
query.bind(query_param)
})
.map(Self::query_history) .map(Self::query_history)
.fetch_all(&self.pool) .fetch_all(&self.pool)
.await?; .await?;
@ -687,3 +637,43 @@ mod test {
assert!(duration < Duration::from_secs(15)); assert!(duration < Duration::from_secs(15));
} }
} }
trait SqlBuilderExt {
fn fuzzy_condition<S: ToString, T: ToString>(
&mut self,
field: S,
mask: T,
inverse: bool,
glob: bool,
is_or: bool,
) -> &mut Self;
}
impl SqlBuilderExt for SqlBuilder {
/// adapted from the sql-builder *like functions
fn fuzzy_condition<S: ToString, T: ToString>(
&mut self,
field: S,
mask: T,
inverse: bool,
glob: bool,
is_or: bool,
) -> &mut Self {
let mut cond = field.to_string();
if inverse {
cond.push_str(" NOT");
}
if glob {
cond.push_str(" GLOB '");
} else {
cond.push_str(" LIKE '");
}
cond.push_str(&esc(&mask.to_string()));
cond.push('\'');
if is_or {
self.or_where(cond)
} else {
self.and_where(cond)
}
}
}