3694e73de8
run(Base, view, NoteSource, Env) with base and view filters (and/or/not), stable multi-key sort evaluating only the sort keys, groupBy and groupOrder (null group, hidden groups), limit across groups, built-in and custom summaries (Average, Min, Max, Sum, Range, Median, Stddev, Earliest, Latest, Checked, Unchecked, Empty, Filled, Unique, expressions over values), lazily memoised cells, dependency tracking so unused properties are never read; list methods sum, mean, median, min, max. 15000 rows with one filter and one sort run in well under the 500 ms bound. Bases task 7 (Claude Sonnet). Known gap: an empty groupOrder shows all groups (needs Option<Vec> in the model). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
294 lines
10 KiB
Rust
294 lines
10 KiB
Rust
//! The query pipeline: filter, sort, group, limit, summaries, laziness and speed.
|
|
|
|
mod common;
|
|
|
|
use std::cell::Cell;
|
|
use std::time::Instant;
|
|
|
|
use common::{NOW, Rows, TestRow, n, s};
|
|
use notescraft_bases::{Base, Env, NoteSource, QueryResult, RowRef, Value, run};
|
|
|
|
const BOOKS: [(&str, Option<&str>, f64, f64, bool); 12] = [
|
|
("Alpha", Some("sf"), 2001.0, 300.0, true),
|
|
("Bravo", Some("sf"), 1999.0, 150.0, false),
|
|
("Charlie", Some("fantasy"), 2010.0, 400.0, true),
|
|
("Delta", Some("fantasy"), 2010.0, 90.0, false),
|
|
("Echo", None, 2005.0, 200.0, true),
|
|
("Foxtrot", Some("sf"), 2015.0, 250.0, true),
|
|
("Golf", Some("skip"), 2000.0, 500.0, true),
|
|
("Hotel", Some("fantasy"), 1990.0, 120.0, false),
|
|
("India", None, 1980.0, 110.0, false),
|
|
("Juliet", Some("sf"), 2001.0, 180.0, true),
|
|
("Kilo", Some("fantasy"), 2010.0, 310.0, true),
|
|
("Lima", Some("sf"), 1999.0, 101.0, false),
|
|
];
|
|
|
|
fn books() -> Rows {
|
|
Rows(
|
|
BOOKS
|
|
.iter()
|
|
.map(|(name, genre, year, pages, read)| {
|
|
let row = TestRow::new(&format!("Books/{name}.md")).prop("year", n(*year)).prop("pages", n(*pages)).prop("read", Value::Bool(*read));
|
|
match genre {
|
|
Some(g) => row.prop("genre", s(g)),
|
|
None => row,
|
|
}
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
const BASE: &str = "
|
|
filters:
|
|
and:
|
|
- pages > 100
|
|
summaries:
|
|
customAverage: 'values.mean().round(3)'
|
|
formulas:
|
|
bad: nope(1)
|
|
half: pages / 2
|
|
views:
|
|
- type: table
|
|
name: Main
|
|
filters:
|
|
not:
|
|
- genre == \"skip\"
|
|
groupBy:
|
|
property: genre
|
|
direction: ASC
|
|
sort:
|
|
- property: year
|
|
direction: DESC
|
|
order:
|
|
- file.name
|
|
- year
|
|
- pages
|
|
summaries:
|
|
pages: Sum
|
|
year: customAverage
|
|
";
|
|
|
|
fn query<'a>(base: &'a Base, view: impl Into<notescraft_bases::ViewSel<'a>>, rows: &'a Rows) -> QueryResult<'a> {
|
|
run(base, view, rows, &Env::new(NOW, 7)).unwrap_or_else(|e| panic!("{e}"))
|
|
}
|
|
|
|
fn base(text: &str) -> Base {
|
|
Base::parse(text).unwrap_or_else(|e| panic!("{e}"))
|
|
}
|
|
|
|
/// `key: name name ...` per group.
|
|
fn table(r: &QueryResult<'_>) -> Vec<String> {
|
|
r.groups
|
|
.iter()
|
|
.map(|g| {
|
|
let names: Vec<String> = g.rows.iter().map(|id| r.cell(*id, 0).to_display_string().replace("Books/", "").replace(".md", "")).collect();
|
|
format!("{}: {}", g.key.to_display_string(), names.join(" "))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[test]
|
|
fn filter_sort_group_summary_golden() {
|
|
let (rows, b) = (books(), base(BASE));
|
|
let r = query(&b, "Main", &rows);
|
|
assert_eq!(r.total, 10);
|
|
assert_eq!(table(&r), [": Echo India", "fantasy: Charlie Kilo Hotel", "sf: Foxtrot Alpha Juliet Bravo Lima"]);
|
|
assert_eq!(r.columns.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(), ["name", "year", "pages"]);
|
|
let sums: Vec<_> = r.groups.iter().map(|g| g.summaries.clone()).collect();
|
|
assert_eq!(sums[0], [Value::Null, n(1992.5), n(310.0)]);
|
|
assert_eq!(sums[1], [Value::Null, n(2003.333), n(830.0)]);
|
|
assert_eq!(sums[2][2], n(981.0));
|
|
assert_eq!(r.summaries, [Value::Null, n(2001.0), n(2121.0)]);
|
|
assert_eq!(r.errors(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn group_order_hides_and_places_null() {
|
|
let (rows, b) = (books(), base(&BASE.replace(" sort:", " groupOrder:\n - sf\n - null\n sort:")));
|
|
assert_eq!(table(&query(&b, 0, &rows)), ["sf: Foxtrot Alpha Juliet Bravo Lima", ": Echo India"]);
|
|
}
|
|
|
|
#[test]
|
|
fn desc_groups_limit_and_default_column() {
|
|
let rows = books();
|
|
let b = base(
|
|
"views:\n - type: table\n limit: 3\n groupBy:\n property: genre\n direction: DESC\n sort:\n - property: pages\n direction: ASC\n",
|
|
);
|
|
let r = query(&b, 0, &rows);
|
|
assert_eq!(r.total, 12);
|
|
assert_eq!(r.columns.len(), 1);
|
|
assert_eq!(r.columns[0].id, "file.name");
|
|
assert_eq!(table(&r), ["skip: Golf", "sf: Lima Bravo"]);
|
|
}
|
|
|
|
#[test]
|
|
fn multi_key_sort_is_stable_and_missing_view_is_an_error() {
|
|
let rows = books();
|
|
let b = base(
|
|
"views:\n - type: table\n filters: genre == \"sf\"\n sort:\n - property: year\n direction: ASC\n - property: pages\n direction: DESC\n",
|
|
);
|
|
let r = query(&b, 0, &rows);
|
|
assert_eq!(table(&r), [": Bravo Lima Alpha Juliet Foxtrot"]);
|
|
assert!(run(&b, 5, &rows, &Env::new(NOW, 7)).is_err());
|
|
assert!(run(&b, "nope", &rows, &Env::new(NOW, 7)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn default_summaries() {
|
|
let rows = books();
|
|
let b = base(
|
|
"views:\n - type: table\n order: [pages, read, genre, year]\n summaries:\n pages: Median\n read: Checked\n genre: Unique\n year: Range\n",
|
|
);
|
|
let r = query(&b, 0, &rows);
|
|
assert_eq!(r.summaries[0], n(190.0)); // median of 12 values: (180 + 200) / 2
|
|
assert_eq!(r.summaries[1], n(7.0));
|
|
assert_eq!(r.summaries[2], n(4.0)); // null, fantasy, sf, skip
|
|
assert_eq!(r.summaries[3], n(35.0));
|
|
for (name, want) in [("Min", n(90.0)), ("Max", n(500.0)), ("Unchecked", n(5.0)), ("Filled", n(12.0)), ("Empty", n(0.0)), ("Nope", Value::Null)] {
|
|
let col = if name == "Unchecked" { "read" } else { "pages" };
|
|
let b = base(&format!("views:\n - type: table\n order: [{col}]\n summaries:\n {col}: {name}\n"));
|
|
let got = query(&b, 0, &rows).summaries[0].clone();
|
|
if name == "Nope" {
|
|
assert!(matches!(got, Value::Error(_)));
|
|
} else {
|
|
assert_eq!(got, want, "{name}");
|
|
}
|
|
}
|
|
let b = base("views:\n - type: table\n order: [genre, pages]\n summaries:\n genre: Empty\n pages: Stddev\n");
|
|
let r = query(&b, 0, &rows);
|
|
assert_eq!(r.summaries[0], n(2.0));
|
|
assert!(matches!(&r.summaries[1], Value::Number(x) if (x - 123.0).abs() < 50.0));
|
|
}
|
|
|
|
#[test]
|
|
fn date_summaries() {
|
|
let rows = Rows(vec![
|
|
TestRow::new("a.md").prop("d", Value::Date(notescraft_bases::Date::new(0, false))),
|
|
TestRow::new("b.md").prop("d", Value::Date(notescraft_bases::Date::new(86_400_000, false))),
|
|
]);
|
|
let b = base("views:\n - type: table\n order: [d]\n summaries:\n d: Range\n");
|
|
assert_eq!(query(&b, 0, &rows).summaries[0].to_display_string(), "1d");
|
|
let b = base("views:\n - type: table\n order: [d]\n summaries:\n d: Latest\n");
|
|
assert_eq!(query(&b, 0, &rows).summaries[0], Value::Date(notescraft_bases::Date::new(86_400_000, false)));
|
|
}
|
|
|
|
#[test]
|
|
fn errors_are_counted_and_do_not_stop_the_table() {
|
|
let rows = books();
|
|
let b = base(&format!("{BASE}\n").replace(" - pages\n summaries", " - formula.bad\n - formula.half\n summaries"));
|
|
let r = query(&b, 0, &rows);
|
|
let id = r.groups[0].rows[0];
|
|
assert!(matches!(r.cell(id, 2), Value::Error(_)));
|
|
assert_eq!(r.cell(id, 3), &n(100.0));
|
|
assert!(r.errors() >= 1);
|
|
let b = base("filters: nope(1)\nviews:\n - type: table\n");
|
|
let r = query(&b, 0, &rows);
|
|
assert_eq!(r.total, 0);
|
|
assert_eq!(r.errors(), 12);
|
|
}
|
|
|
|
#[test]
|
|
fn display_name_and_formula_dependencies() {
|
|
let rows = books();
|
|
let b = base(
|
|
"properties:\n year:\n displayName: Year\nformulas:\n half: pages / 2\nviews:\n - type: table\n order: [year, formula.half]\n sort:\n - property: file.name\n direction: ASC\n",
|
|
);
|
|
let r = query(&b, 0, &rows);
|
|
assert_eq!(r.columns[0].name, "Year");
|
|
assert_eq!(r.columns[1].name, "half");
|
|
assert_eq!(r.reads.note, ["pages", "year"]);
|
|
assert_eq!(r.reads.file, ["name"]);
|
|
assert!(!r.reads.uses_this);
|
|
}
|
|
|
|
struct Counted {
|
|
rows: Rows,
|
|
backlinks: Box<dyn Fn() -> Vec<String>>,
|
|
}
|
|
|
|
impl NoteSource for Counted {
|
|
fn len(&self) -> usize {
|
|
self.rows.len()
|
|
}
|
|
fn row(&self, i: usize) -> Option<RowRef<'_>> {
|
|
self.rows.row(i).map(|r| RowRef { backlinks: Some(&*self.backlinks), ..r })
|
|
}
|
|
fn find(&self, path: &str) -> Option<RowRef<'_>> {
|
|
self.rows.find(path)
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unreferenced_properties_are_never_read() {
|
|
let calls = std::rc::Rc::new(Cell::new(0));
|
|
let c = calls.clone();
|
|
let src = Counted {
|
|
rows: books(),
|
|
backlinks: Box::new(move || {
|
|
c.set(c.get() + 1);
|
|
vec![]
|
|
}),
|
|
};
|
|
let b = base(
|
|
"filters: year > 1990\nviews:\n - type: table\n order: [file.name]\n sort:\n - property: year\n direction: ASC\n - type: table\n order: [file.backlinks.length]\n",
|
|
);
|
|
let env = Env::new(NOW, 7);
|
|
let r = run(&b, 0, &src, &env).unwrap_or_else(|e| panic!("{e}"));
|
|
for id in r.rows() {
|
|
let _ = r.cell(id, 0);
|
|
}
|
|
assert_eq!(calls.get(), 0);
|
|
let r = run(&b, 1, &src, &env).unwrap_or_else(|e| panic!("{e}"));
|
|
assert_eq!(r.cells_computed(), 0);
|
|
assert_eq!(r.cell(0, 0), &n(0.0));
|
|
assert_eq!(calls.get(), 1);
|
|
assert_eq!(r.reads.file, ["backlinks.length"]);
|
|
}
|
|
|
|
fn synthetic(count: usize) -> Rows {
|
|
Rows(
|
|
(0..count)
|
|
.map(|i| {
|
|
TestRow::new(&format!("Notes/n{i}.md"))
|
|
.prop("year", n(f64::from(u16::try_from(i % 60).unwrap_or(0)) + 1970.0))
|
|
.prop("rank", n(f64::from(u32::try_from(i.wrapping_mul(7919) % 10_007).unwrap_or(0))))
|
|
})
|
|
.collect(),
|
|
)
|
|
}
|
|
|
|
const PERF_BASE: &str =
|
|
"views:\n - type: table\n filters: year >= 1990\n order: [file.name, year]\n sort:\n - property: rank\n direction: DESC\n";
|
|
|
|
#[test]
|
|
fn no_cell_is_computed_for_rows_not_asked_for() {
|
|
let rows = synthetic(15_000);
|
|
let b = base(PERF_BASE);
|
|
let start = Instant::now();
|
|
let r = query(&b, 0, &rows);
|
|
let took = start.elapsed();
|
|
assert!(took.as_millis() < 500, "15 000 rows took {took:?}");
|
|
assert_eq!(r.total, 10_000);
|
|
assert_eq!(r.cells_computed(), 0);
|
|
let top: Vec<usize> = r.rows().take(5).collect();
|
|
for id in &top {
|
|
let _ = r.cell(*id, 0);
|
|
let _ = r.cell(*id, 0);
|
|
}
|
|
assert_eq!(r.cells_computed(), 5);
|
|
assert_eq!(r.cell(top[0], 1), &r.cell(top[0], 1).clone());
|
|
assert_eq!(r.cell(999_999, 0), &Value::Null);
|
|
}
|
|
|
|
#[test]
|
|
#[ignore = "perf: run with --release -- --ignored"]
|
|
fn perf_15000_rows_filter_and_sort_under_60ms() {
|
|
let rows = synthetic(15_000);
|
|
let b = base(PERF_BASE);
|
|
let start = Instant::now();
|
|
let r = query(&b, 0, &rows);
|
|
let took = start.elapsed();
|
|
assert_eq!(r.total, 10_000);
|
|
assert!(took.as_millis() < 60, "took {took:?}");
|
|
}
|