use std::sync::mpsc::{self, Receiver, TryRecvError};
use std::time::{Duration, Instant};
use slipql::ast::Query;
use slipql::{Row, Tally};
use crate::i18n::{fill, t};
pub const MOST_ROWS: usize = 5_000;
const BATCH: usize = 64;
const FLUSH_AFTER: Duration = Duration::from_millis(100);
enum Message {
Rows(Vec<Row>),
Done(Notices),
}
#[derive(Debug, Default, Clone)]
pub struct Notices {
pub skipped: Vec<String>,
pub mismatches: Vec<String>,
pub truncated: bool,
}
impl Notices {
#[must_use]
pub fn is_empty(&self) -> bool {
self.skipped.is_empty() && self.mismatches.is_empty() && !self.truncated
}
#[must_use]
pub fn lines(&self) -> usize {
self.skipped.len() + self.mismatches.len()
}
}
pub struct Run {
rows: Vec<Row>,
fixed_columns: Option<Vec<String>>,
columns: Vec<String>,
inbox: Option<Receiver<Message>>,
notices: Option<Notices>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Update {
Nothing,
Rows,
Finished,
}
impl Run {
pub fn start(query: &Query, repaint: impl Fn() + Send + 'static) -> slipql::Result<Self> {
let mut results = slipql::execute(query)?;
let fixed_columns = results.columns();
let (sender, inbox) = mpsc::channel();
std::thread::Builder::new()
.name("filebase-query".to_owned())
.spawn(move || {
let mut batch = Vec::with_capacity(BATCH);
let mut last_flush = Instant::now();
let mut sent = 0usize;
let mut truncated = false;
for row in results.by_ref() {
batch.push(row);
if batch.len() >= BATCH || last_flush.elapsed() >= FLUSH_AFTER {
sent += batch.len();
if sender.send(Message::Rows(batch)).is_err() {
return;
}
batch = Vec::with_capacity(BATCH);
last_flush = Instant::now();
repaint();
}
if over_ceiling(sent + batch.len()) {
truncated = true;
break;
}
}
if !batch.is_empty() && sender.send(Message::Rows(batch)).is_err() {
return;
}
let notices = notices_of(&results.into_tally(), truncated);
let _ = sender.send(Message::Done(notices));
repaint();
})
.map_err(|source| slipql::Error::Source {
root: query.from.root.clone(),
source,
})?;
Ok(Self {
rows: Vec::new(),
columns: fixed_columns.clone().unwrap_or_default(),
fixed_columns,
inbox: Some(inbox),
notices: None,
})
}
pub fn drain(&mut self) -> Update {
let Some(inbox) = &self.inbox else {
return Update::Nothing;
};
let mut update = Update::Nothing;
loop {
match inbox.try_recv() {
Ok(Message::Rows(rows)) => {
self.rows.extend(rows);
update = Update::Rows;
}
Ok(Message::Done(notices)) => {
self.notices = Some(notices);
self.inbox = None;
update = Update::Finished;
break;
}
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
self.notices.get_or_insert_with(Notices::default);
self.inbox = None;
update = Update::Finished;
break;
}
}
}
if update != Update::Nothing && self.fixed_columns.is_none() {
self.columns = slipql::render::column_union(&self.rows);
}
update
}
#[must_use]
pub fn is_running(&self) -> bool {
self.inbox.is_some()
}
#[must_use]
pub fn rows(&self) -> &[Row] {
&self.rows
}
#[must_use]
pub fn columns(&self) -> &[String] {
&self.columns
}
#[must_use]
pub fn notices(&self) -> Option<&Notices> {
self.notices.as_ref()
}
#[must_use]
pub fn status(&self) -> String {
let n = self.rows.len();
let rows = fill(
crate::i18n::tn("{n} row", "{n} rows", n as u64),
&[("n", &n.to_string())],
);
if self.is_running() {
return fill(t("{rows} so far…"), &[("rows", &rows)]);
}
let Some(notices) = &self.notices else {
return rows;
};
let mut parts = vec![rows];
if notices.truncated {
parts.push(fill(
t("stopped at {most}; narrow the query or add a limit"),
&[("most", &MOST_ROWS.to_string())],
));
}
if !notices.skipped.is_empty() {
let n = notices.skipped.len();
parts.push(fill(
crate::i18n::tn("{n} skipped", "{n} skipped", n as u64),
&[("n", &n.to_string())],
));
}
if !notices.mismatches.is_empty() {
let n = notices.mismatches.len();
parts.push(fill(
crate::i18n::tn(
"{n} comparison across types",
"{n} comparisons across types",
n as u64,
),
&[("n", &n.to_string())],
));
}
parts.join(" · ")
}
}
fn over_ceiling(rows: usize) -> bool {
rows >= MOST_ROWS
}
fn notices_of(tally: &Tally, truncated: bool) -> Notices {
Notices {
skipped: tally
.skipped()
.iter()
.map(|skipped| {
fill(
t("skipped {path}: {reason}"),
&[
("path", &skipped.path.display().to_string()),
("reason", &skipped.reason),
],
)
})
.collect(),
mismatches: tally
.mismatches()
.into_iter()
.map(|(path, found, against, count)| {
let rows = fill(
crate::i18n::tn("{n} row", "{n} rows", count as u64),
&[("n", &count.to_string())],
);
fill(
t("{column}: {found} in {rows}, {against} in the query; not matched"),
&[
("column", &path.to_string()),
("found", &found.to_string()),
("rows", &rows),
("against", &against.to_string()),
],
)
})
.collect(),
truncated,
}
}
#[must_use]
pub fn cell(row: &Row, column: &str) -> String {
row.cells
.iter()
.find(|cell| cell.column == column)
.and_then(|cell| cell.value.as_ref())
.map_or_else(String::new, ToString::to_string)
}
#[cfg(test)]
mod tests {
use super::{cell, over_ceiling, Notices, MOST_ROWS};
use slipql::{Cell, Row};
fn row(cells: &[(&str, Option<&str>)]) -> Row {
Row {
path: "a.slpc".to_owned(),
cells: cells
.iter()
.map(|(column, value)| Cell {
column: (*column).to_owned(),
value: value.map(|v| slipql::Value::String(v.to_owned())),
})
.collect(),
}
}
#[test]
fn the_ceiling_stops_a_count_that_overshoots_it() {
assert!(over_ceiling(MOST_ROWS + 5));
assert!(over_ceiling(MOST_ROWS));
assert!(!over_ceiling(MOST_ROWS - 1));
}
#[test]
fn a_column_the_row_does_not_have_is_blank() {
let r = row(&[("title", Some("MSA")), ("owner", None)]);
assert_eq!(cell(&r, "title"), "MSA");
assert_eq!(cell(&r, "owner"), "");
assert_eq!(cell(&r, "never projected"), "");
}
#[test]
fn a_truncated_run_is_not_an_empty_set_of_notices() {
let quiet = Notices::default();
assert!(quiet.is_empty());
let cut = Notices {
truncated: true,
..Notices::default()
};
assert!(!cut.is_empty());
assert_eq!(cut.lines(), 0);
}
}