1use std::borrow::Cow;
5use std::cmp::Ordering;
6use std::collections::{BTreeSet, HashMap};
7
8use ravenrs_core::csv::push_field;
9use ravenrs_criteria::{Criteria, Environment, FieldType, Record, Schema, Scope, validate};
10
11use super::model::{GridColumn, Row, SortDirection, Summary, Value};
12
13#[derive(Clone, Debug, Default, PartialEq)]
15#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
16pub struct GridQuery {
17 pub sort: Vec<(usize, SortDirection)>,
19 pub filters: Vec<String>,
23 pub search: String,
25 pub group_by: Vec<usize>,
27 pub collapsed: BTreeSet<String>,
29 pub hidden: Vec<bool>,
31}
32
33#[cfg_attr(feature = "live", ravenrs_live::expose)]
34impl GridQuery {
35 pub fn filter(&self, column: usize) -> &str {
37 self.filters.get(column).map_or("", String::as_str)
38 }
39
40 pub fn set_filter(&mut self, column: usize, text: String) {
42 if self.filters.len() <= column {
43 self.filters.resize(column + 1, String::new());
44 }
45 self.filters[column] = text;
46 }
47
48 pub fn is_hidden(&self, column: usize) -> bool {
50 self.hidden.get(column).copied().unwrap_or(false)
51 }
52
53 pub fn toggle_sort(&mut self, column: usize, add: bool) {
56 let current = self.sort.iter().position(|(c, _)| *c == column);
57 let next = match current.map(|i| self.sort[i].1) {
58 None => Some(SortDirection::Ascending),
59 Some(SortDirection::Ascending) => Some(SortDirection::Descending),
60 Some(SortDirection::Descending) => None,
61 };
62 if !add {
63 self.sort.clear();
64 if let Some(d) = next {
65 self.sort.push((column, d));
66 }
67 return;
68 }
69 match (current, next) {
70 (Some(i), Some(d)) => self.sort[i].1 = d,
71 (Some(i), None) => {
72 self.sort.remove(i);
73 }
74 (None, Some(d)) => self.sort.push((column, d)),
75 (None, None) => {}
76 }
77 }
78
79 pub fn toggle_group(&mut self, column: usize) {
81 if let Some(i) = self.group_by.iter().position(|c| *c == column) {
82 self.group_by.remove(i);
83 } else {
84 self.group_by.push(column);
85 }
86 self.collapsed.clear();
87 }
88
89 pub fn toggle_collapsed(&mut self, key: &str) {
91 if !self.collapsed.remove(key) {
92 self.collapsed.insert(key.to_owned());
93 }
94 }
95}
96
97#[derive(Clone, Debug, PartialEq)]
99#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
100pub enum ViewRow {
101 Group {
103 level: usize,
105 column: usize,
107 label: String,
109 key: String,
111 count: usize,
113 collapsed: bool,
115 summaries: Vec<Option<f64>>,
117 },
118 Data(usize),
120}
121
122#[derive(Clone, Debug, Default, PartialEq)]
124#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
125pub struct QueryResult {
126 pub rows: Vec<ViewRow>,
128 pub matched: Vec<usize>,
130 pub totals: Vec<Option<f64>>,
132}
133
134pub fn matches_filter(value: &Value, display: &str, filter: &str) -> bool {
140 Filter::parse(filter).matches(value, || Cow::Borrowed(display))
141}
142
143type NumTest = fn(f64, f64) -> bool;
144
145#[derive(Debug)]
147struct Filter {
148 number: Option<NumberTest>,
150 boolean: Option<bool>,
152 text: String,
154}
155
156#[derive(Debug)]
157enum NumberTest {
158 Range(f64, f64),
159 Compare(NumTest, f64),
160}
161
162#[cfg_attr(feature = "live", ravenrs_live::expose)]
163impl Filter {
164 fn parse(filter: &str) -> Self {
165 let f = filter.trim();
166 let num = |s: &str| s.trim().replace(',', "").parse::<f64>().ok();
167 let mut number = None;
168 if let Some((a, b)) = f.split_once("..")
169 && let (Some(a), Some(b)) = (num(a), num(b))
170 {
171 number = Some(NumberTest::Range(a, b));
172 }
173 if number.is_none() {
174 let ops: [(&str, NumTest); 7] = [
175 (">=", |n, x| n >= x),
176 ("<=", |n, x| n <= x),
177 ("<>", |n, x| (n - x).abs() > f64::EPSILON),
178 ("!=", |n, x| (n - x).abs() > f64::EPSILON),
179 (">", |n, x| n > x),
180 ("<", |n, x| n < x),
181 ("=", |n, x| (n - x).abs() < 1e-9),
182 ];
183 number = ops.into_iter().find_map(|(op, test)| {
184 f.strip_prefix(op).and_then(num).map(|x| NumberTest::Compare(test, x))
185 });
186 }
187 let text = f.to_lowercase();
188 let boolean = match text.as_str() {
189 "yes" | "true" | "1" | "y" | "sim" => Some(true),
190 "no" | "false" | "0" | "n" | "não" | "nao" => Some(false),
191 _ => None,
192 };
193 Self { number, boolean, text }
194 }
195
196 fn matches<'a>(&self, value: &Value, display: impl FnOnce() -> Cow<'a, str>) -> bool {
198 if self.text.is_empty() {
199 return true;
200 }
201 match (value, &self.number, self.boolean) {
202 (Value::Number(n), Some(NumberTest::Range(a, b)), _) => *n >= *a && *n <= *b,
203 (Value::Number(n), Some(NumberTest::Compare(test, x)), _) => test(*n, *x),
204 (Value::Bool(b), _, Some(want)) => *b == want,
205 _ => contains_lowercase(&display(), &self.text),
206 }
207 }
208}
209
210fn expression_of(filter: &str, titles: &HashMap<&str, usize>) -> Option<Criteria> {
214 if !filter.contains('[') {
215 return None;
216 }
217 let criteria = Criteria::parse(filter).ok()?;
218 let fields = criteria.properties();
219 let answerable = !criteria
220 .any(&|c| matches!(c, Criteria::Aggregate(_) | Criteria::Parameter(_) | Criteria::Custom(..)));
221 (answerable && !fields.is_empty() && fields.iter().all(|f| titles.contains_key(f.as_str())))
222 .then_some(criteria)
223}
224
225struct GridRecord<'a> {
227 titles: &'a HashMap<&'a str, usize>,
228 row: &'a Row,
229}
230
231impl Record for GridRecord<'_> {
232 fn field(&self, path: &[String]) -> Option<ravenrs_criteria::Value> {
233 use ravenrs_criteria::Value as Field;
234 let column = match path {
235 [title] => self.titles.get(title.as_str()),
236 _ => self.titles.get(path.join(".").as_str()),
237 }?;
238 Some(match self.row.get(*column) {
239 Some(Value::Text(s)) => Field::Text(s.clone()),
240 Some(Value::Number(n)) => Field::Number(*n),
241 Some(Value::Bool(b)) => Field::Bool(*b),
242 Some(Value::Date(date)) => {
243 Field::Date(ravenrs_criteria::DateTime { days: date.to_days(), seconds: 0 })
244 }
245 Some(Value::Empty) | None => Field::Null,
246 })
247 }
248
249 fn collection(&self, _path: &[String]) -> Option<Vec<&Self>> {
250 None
251 }
252}
253
254fn contains_lowercase(haystack: &str, needle: &str) -> bool {
257 if needle.is_empty() {
258 return true;
259 }
260 if haystack.is_ascii() && needle.is_ascii() {
261 let (h, n) = (haystack.as_bytes(), needle.as_bytes());
262 return h.windows(n.len()).any(|w| w.iter().zip(n).all(|(a, b)| a.to_ascii_lowercase() == *b));
263 }
264 haystack.to_lowercase().contains(needle)
265}
266
267#[derive(Clone, Copy, Debug, PartialEq)]
270enum Key {
271 Empty,
272 Num(f64),
273 Text(u32),
274}
275
276fn keys_of(column: &GridColumn, c: usize, rows: &[Row], indices: &[usize]) -> Vec<Key> {
278 let empty = Value::Empty;
279 let mut texts: HashMap<Cow<'_, str>, u32> = HashMap::new();
280 let mut keys: Vec<Key> = indices
281 .iter()
282 .map(|&r| match rows[r].get(c).unwrap_or(&empty) {
283 Value::Number(n) => Key::Num(*n),
284 Value::Bool(b) => Key::Num(f64::from(u8::from(*b))),
285 Value::Date(date) => Key::Num(date.to_days() as f64),
286 v @ Value::Text(_) => {
287 let next = texts.len() as u32;
288 Key::Text(*texts.entry(column.display_cow(v)).or_insert(next))
289 }
290 _ => Key::Empty,
291 })
292 .collect();
293 if !texts.is_empty() {
294 let mut distinct: Vec<(String, u32)> = texts.iter().map(|(t, &id)| (t.to_lowercase(), id)).collect();
296 distinct.sort_unstable();
297 let mut rank_of = vec![0u32; distinct.len()];
298 let mut rank = 0;
299 for (i, (text, id)) in distinct.iter().enumerate() {
300 if i > 0 && *text != distinct[i - 1].0 {
301 rank += 1;
302 }
303 rank_of[*id as usize] = rank;
304 }
305 for key in &mut keys {
306 if let Key::Text(id) = key {
307 *id = rank_of[*id as usize];
308 }
309 }
310 }
311 keys
312}
313
314fn cmp_keys(a: &Key, b: &Key) -> Ordering {
315 match (a, b) {
316 (Key::Empty, Key::Empty) => Ordering::Equal,
317 (Key::Empty, _) => Ordering::Greater,
319 (_, Key::Empty) => Ordering::Less,
320 (Key::Num(x), Key::Num(y)) => x.total_cmp(y),
321 (Key::Text(x), Key::Text(y)) => x.cmp(y),
322 (Key::Num(_), Key::Text(_)) => Ordering::Less,
323 (Key::Text(_), Key::Num(_)) => Ordering::Greater,
324 }
325}
326
327fn numbers_may_contain(column: &GridColumn, needle: &str) -> bool {
330 if column.format.is_some() {
331 return true;
332 }
333 let (extra, thousands, decimal) = match &column.number {
334 Some(f) => (format!("{}{}", f.prefix, f.suffix).to_lowercase(), f.thousands, f.decimal),
335 None => (String::new(), ',', '.'),
336 };
337 needle.chars().all(|ch| {
338 ch.is_ascii_digit() || "-.infa".contains(ch) || ch == thousands || ch == decimal || extra.contains(ch)
339 })
340}
341
342fn summarize(summary: Summary, column: usize, rows: &[Row], indices: &[usize]) -> Option<f64> {
343 let values = indices.iter().filter_map(|&i| rows[i].get(column)).filter(|v| !v.is_empty());
344 match summary {
345 Summary::Count => Some(values.count() as f64),
346 Summary::Sum => Some(values.filter_map(Value::as_f64).sum()),
347 Summary::Average => {
348 let (sum, n) = values.filter_map(Value::as_f64).fold((0.0, 0usize), |(s, n), v| (s + v, n + 1));
349 (n > 0).then(|| sum / n as f64)
350 }
351 Summary::Min => values.filter_map(Value::as_f64).reduce(f64::min),
352 Summary::Max => values.filter_map(Value::as_f64).reduce(f64::max),
353 }
354}
355
356fn summaries(columns: &[GridColumn], rows: &[Row], indices: &[usize]) -> Vec<Option<f64>> {
357 columns
358 .iter()
359 .enumerate()
360 .map(|(c, col)| col.summary.and_then(|s| summarize(s, c, rows, indices)))
361 .collect()
362}
363
364pub fn check_formula(columns: &[GridColumn], text: &str) -> Result<Criteria, String> {
366 let criteria = Criteria::parse(text).map_err(|e| e.to_string())?;
367 let mut schema = Schema::new();
368 for column in columns {
369 let kind = if column.number.is_some() { FieldType::Number } else { FieldType::Any };
370 schema = schema.field(&column.title, kind);
371 }
372 match validate(&criteria, &schema).first() {
373 Some(problem) => Err(problem.to_string()),
374 None => Ok(criteria),
375 }
376}
377
378pub fn formula_value(
380 columns: &[GridColumn],
381 rows: &[Row],
382 indices: &[usize],
383 formula: &Criteria,
384) -> Result<ravenrs_criteria::Value, String> {
385 let titles: HashMap<&str, usize> =
386 columns.iter().enumerate().rev().map(|(c, col)| (col.title.as_str(), c)).collect();
387 let records: Vec<GridRecord<'_>> =
388 indices.iter().map(|&r| GridRecord { titles: &titles, row: &rows[r] }).collect();
389 let scope: Vec<&GridRecord<'_>> = records.iter().collect();
390 let env = Environment::new();
391 Scope::new(&env).rows(&scope).evaluate(formula).map_err(|e| e.to_string())
392}
393
394pub(crate) fn group_ranges(result: &QueryResult) -> Vec<(usize, usize)> {
396 let mut next = 0;
397 result
398 .rows
399 .iter()
400 .map(|row| match row {
401 ViewRow::Data(_) => {
402 next += 1;
403 (next - 1, 1)
404 }
405 ViewRow::Group { count, collapsed, .. } => {
406 let range = (next, *count);
407 if *collapsed {
408 next += *count;
409 }
410 range
411 }
412 })
413 .collect()
414}
415
416pub fn group_keys(columns: &[GridColumn], rows: &[Row], query: &GridQuery) -> BTreeSet<String> {
418 let open = GridQuery { collapsed: BTreeSet::new(), ..query.clone() };
419 run_query(columns, rows, &open)
420 .rows
421 .into_iter()
422 .filter_map(|row| if let ViewRow::Group { key, .. } = row { Some(key) } else { None })
423 .collect()
424}
425
426pub fn run_query(columns: &[GridColumn], rows: &[Row], query: &GridQuery) -> QueryResult {
428 let search = query.search.trim().to_lowercase();
429 let titles: HashMap<&str, usize> =
431 columns.iter().enumerate().rev().map(|(c, col)| (col.title.as_str(), c)).collect();
432 let mut active_filters: Vec<(usize, Filter)> = Vec::new();
433 let mut expressions: Vec<Criteria> = Vec::new();
434 for (c, f) in (0..columns.len()).map(|c| (c, query.filter(c))) {
435 if f.trim().is_empty() || !columns[c].filterable {
436 continue;
437 }
438 match expression_of(f, &titles) {
439 Some(expression) => expressions.push(expression),
440 None => active_filters.push((c, Filter::parse(f))),
441 }
442 }
443 let env = Environment::new();
444 let searched: Vec<(usize, bool)> = (0..columns.len())
445 .filter(|&c| !query.is_hidden(c))
446 .map(|c| (c, numbers_may_contain(&columns[c], &search)))
447 .collect();
448 let empty = Value::Empty;
449 let cell = |r: usize, c: usize| rows[r].get(c).unwrap_or(&empty);
450
451 let mut indices: Vec<usize> = (0..rows.len())
452 .filter(|&r| {
453 active_filters.iter().all(|(c, f)| {
454 let v = cell(r, *c);
455 f.matches(v, || columns[*c].display_cow(v))
456 })
457 })
458 .filter(|&r| {
459 expressions.iter().all(|expression| {
460 let record = GridRecord { titles: &titles, row: &rows[r] };
461 Scope::new(&env).row(&record).matches(expression)
462 })
463 })
464 .filter(|&r| {
465 search.is_empty()
466 || searched.iter().any(|&(c, numbers)| {
467 let v = cell(r, c);
468 (numbers || !matches!(v, Value::Number(_)))
469 && contains_lowercase(&columns[c].display_cow(v), &search)
470 })
471 })
472 .collect();
473
474 let mut order: Vec<(usize, SortDirection)> = query
476 .group_by
477 .iter()
478 .map(|&g| (g, query.sort.iter().find(|(c, _)| *c == g).map_or(SortDirection::Ascending, |s| s.1)))
479 .collect();
480 order.extend(query.sort.iter().filter(|(c, _)| !query.group_by.contains(c)).copied());
481 order.retain(|(c, _)| *c < columns.len());
482 if !order.is_empty() {
483 let keys: Vec<Vec<Key>> =
485 order.iter().map(|&(c, _)| keys_of(&columns[c], c, rows, &indices)).collect();
486 let mut positions: Vec<usize> = (0..indices.len()).collect();
487 positions.sort_unstable_by(|&a, &b| {
489 for (k, (_, dir)) in order.iter().enumerate() {
490 let o = cmp_keys(&keys[k][a], &keys[k][b]);
491 if o != Ordering::Equal {
492 return dir.apply(o);
493 }
494 }
495 a.cmp(&b)
496 });
497 indices = positions.into_iter().map(|p| indices[p]).collect();
498 }
499
500 let totals = summaries(columns, rows, &indices);
501 let mut out = Vec::with_capacity(indices.len());
502 let groups: Vec<usize> = query.group_by.iter().copied().filter(|&g| g < columns.len()).collect();
503 emit_groups(columns, rows, query, &groups, 0, &indices, "", &mut out);
504 QueryResult { rows: out, matched: indices, totals }
505}
506
507#[allow(clippy::too_many_arguments)]
508fn emit_groups(
509 columns: &[GridColumn],
510 rows: &[Row],
511 query: &GridQuery,
512 groups: &[usize],
513 level: usize,
514 indices: &[usize],
515 parent_key: &str,
516 out: &mut Vec<ViewRow>,
517) {
518 let Some(&column) = groups.get(level) else {
519 out.extend(indices.iter().map(|&i| ViewRow::Data(i)));
520 return;
521 };
522 let empty = Value::Empty;
523 let label_of = |r: usize| columns[column].display_cow(rows[r].get(column).unwrap_or(&empty));
524 let mut start = 0;
525 while start < indices.len() {
526 let label = label_of(indices[start]).into_owned();
527 let mut end = start + 1;
528 while end < indices.len() && *label_of(indices[end]) == *label {
529 end += 1;
530 }
531 let run = &indices[start..end];
532 let key = format!("{parent_key}\u{1f}{column}:{label}");
533 let collapsed = query.collapsed.contains(&key);
534 out.push(ViewRow::Group {
535 level,
536 column,
537 label: label.clone(),
538 key: key.clone(),
539 count: run.len(),
540 collapsed,
541 summaries: summaries(columns, rows, run),
542 });
543 if !collapsed {
544 emit_groups(columns, rows, query, groups, level + 1, run, &key, out);
545 }
546 start = end;
547 }
548}
549
550pub fn to_tsv(columns: &[GridColumn], rows: &[Row], visible: &[usize], selected: &[usize]) -> String {
553 let empty = Value::Empty;
554 let clean = |s: String| s.replace(['\t', '\n', '\r'], " ");
555 selected
556 .iter()
557 .filter_map(|&r| rows.get(r))
558 .map(|row| {
559 visible
560 .iter()
561 .map(|&c| clean(columns[c].display(row.get(c).unwrap_or(&empty))))
562 .collect::<Vec<_>>()
563 .join("\t")
564 })
565 .collect::<Vec<_>>()
566 .join("\n")
567}
568
569#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
571#[non_exhaustive]
572#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
573pub enum GridExportFormat {
574 Xlsx,
576 Pdf,
578 Csv,
580}
581
582#[derive(Clone, Debug, PartialEq)]
584#[non_exhaustive]
585#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
586pub enum GridTableKind {
587 Text,
589 Number {
591 decimals: usize,
593 prefix: String,
595 suffix: String,
597 },
598 Date,
600 Boolean,
602}
603
604#[derive(Clone, Debug, PartialEq)]
606#[non_exhaustive]
607#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
608pub struct GridTableColumn {
609 pub title: String,
611 pub kind: GridTableKind,
613 pub total: bool,
615}
616
617#[derive(Clone, Debug, PartialEq)]
619#[non_exhaustive]
620#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
621pub struct GridTable {
622 pub title: String,
624 pub culture: String,
626 pub columns: Vec<GridTableColumn>,
628 pub rows: Vec<Vec<Value>>,
630 pub group_by: Option<usize>,
632}
633
634pub fn to_table(
637 title: &str,
638 culture: &str,
639 columns: &[GridColumn],
640 rows: &[Row],
641 query: &GridQuery,
642 matched: &[usize],
643) -> GridTable {
644 let visible: Vec<usize> = (0..columns.len()).filter(|&c| !query.is_hidden(c)).collect();
645 let plain = |c: usize| columns[c].format.is_none();
646 let empty = Value::Empty;
647 let kind = |c: usize| {
648 let cells = || matched.iter().filter_map(|&r| rows[r].get(c)).filter(|v| !v.is_empty());
649 if !plain(c) {
650 GridTableKind::Text
651 } else if cells().next().is_some() && cells().all(|v| matches!(v, Value::Number(_))) {
652 let format = columns[c].number.clone().unwrap_or_default();
653 GridTableKind::Number {
654 decimals: format.decimals,
655 prefix: format.prefix.clone(),
656 suffix: format.suffix.clone(),
657 }
658 } else if cells().next().is_some() && cells().all(|v| matches!(v, Value::Date(_))) {
659 GridTableKind::Date
660 } else if cells().next().is_some() && cells().all(|v| matches!(v, Value::Bool(_))) {
661 GridTableKind::Boolean
662 } else {
663 GridTableKind::Text
664 }
665 };
666 GridTable {
667 title: title.to_owned(),
668 culture: culture.to_owned(),
669 columns: visible
670 .iter()
671 .map(|&c| {
672 let kind = kind(c);
673 let total =
674 matches!(kind, GridTableKind::Number { .. }) && columns[c].summary == Some(Summary::Sum);
675 GridTableColumn { title: columns[c].title.clone(), kind, total }
676 })
677 .collect(),
678 rows: matched
679 .iter()
680 .map(|&r| {
681 visible
682 .iter()
683 .map(|&c| {
684 let value = rows[r].get(c).unwrap_or(&empty);
685 if plain(c) { value.clone() } else { Value::Text(columns[c].display(value)) }
686 })
687 .collect()
688 })
689 .collect(),
690 group_by: query.group_by.first().and_then(|g| visible.iter().position(|c| c == g)),
691 }
692}
693
694pub fn to_csv(columns: &[GridColumn], rows: &[Row], query: &GridQuery, matched: &[usize]) -> String {
696 let visible: Vec<usize> = (0..columns.len()).filter(|&c| !query.is_hidden(c)).collect();
697 let mut out = String::new();
698 for (i, &c) in visible.iter().enumerate() {
699 if i > 0 {
700 out.push(',');
701 }
702 push_field(&mut out, &columns[c].title, ',');
703 }
704 out.push_str("\r\n");
705 let empty = Value::Empty;
706 for &r in matched {
707 for (i, &c) in visible.iter().enumerate() {
708 if i > 0 {
709 out.push(',');
710 }
711 let v = rows[r].get(c).unwrap_or(&empty);
712 match v {
714 Value::Number(n) => out.push_str(&n.to_string()),
715 _ => push_field(&mut out, &columns[c].display_cow(v), ','),
716 }
717 }
718 out.push_str("\r\n");
719 }
720 out
721}
722
723#[cfg(test)]
724mod tests {
725 use super::*;
726 use crate::grid::model::Summary;
727
728 fn columns() -> Vec<GridColumn> {
729 vec![
730 GridColumn::text("Name"),
731 GridColumn::text("Country"),
732 GridColumn::number("Seats").summary(Summary::Sum),
733 GridColumn::boolean("Active"),
734 ]
735 }
736
737 fn rows() -> Vec<Row> {
738 vec![
739 vec!["Acme".into(), "Brazil".into(), 10.into(), true.into()],
740 vec!["Globex".into(), "Chile".into(), 25.into(), false.into()],
741 vec!["Initech".into(), "Brazil".into(), 5.into(), true.into()],
742 vec!["umbrella".into(), "Argentina".into(), 40.into(), true.into()],
743 vec!["Hooli".into(), Value::Empty, 15.into(), false.into()],
744 ]
745 }
746
747 fn names(result: &QueryResult, rows: &[Row]) -> Vec<String> {
748 result
749 .rows
750 .iter()
751 .map(|r| match r {
752 ViewRow::Data(i) => match &rows[*i][0] {
753 Value::Text(s) => s.clone(),
754 _ => String::new(),
755 },
756 ViewRow::Group { label, count, .. } => format!("[{label} {count}]"),
757 })
758 .collect()
759 }
760
761 #[test]
762 fn filters_with_operators_ranges_and_text() {
763 let (c, r) = (columns(), rows());
764 let mut q = GridQuery::default();
765 q.set_filter(2, ">=15".into());
766 assert_eq!(names(&run_query(&c, &r, &q), &r), ["Globex", "umbrella", "Hooli"]);
767 q.set_filter(2, "5..12".into());
768 assert_eq!(names(&run_query(&c, &r, &q), &r), ["Acme", "Initech"]);
769 q.set_filter(2, String::new());
770 q.set_filter(1, "BRA".into());
771 q.set_filter(3, "yes".into());
772 assert_eq!(names(&run_query(&c, &r, &q), &r), ["Acme", "Initech"]);
773 }
774
775 #[test]
776 fn search_ignores_hidden_columns() {
777 let (c, r) = (columns(), rows());
778 let mut q = GridQuery { search: "chile".into(), ..Default::default() };
779 assert_eq!(names(&run_query(&c, &r, &q), &r), ["Globex"]);
780 q.hidden = vec![false, true];
781 assert!(run_query(&c, &r, &q).rows.is_empty());
782 }
783
784 #[test]
785 fn multi_column_sort_is_case_insensitive_with_empties_last() {
786 let (c, r) = (columns(), rows());
787 let mut q = GridQuery::default();
788 q.toggle_sort(1, false);
789 q.toggle_sort(2, true);
790 q.toggle_sort(2, true); assert_eq!(q.sort, vec![(1, SortDirection::Ascending), (2, SortDirection::Descending)]);
792 assert_eq!(names(&run_query(&c, &r, &q), &r), ["umbrella", "Acme", "Initech", "Globex", "Hooli"]);
793 q.toggle_sort(0, false);
794 assert_eq!(q.sort, vec![(0, SortDirection::Ascending)], "plain click replaces the sort");
795 assert_eq!(names(&run_query(&c, &r, &q), &r), ["Acme", "Globex", "Hooli", "Initech", "umbrella"]);
796 }
797
798 #[test]
799 fn groups_with_counts_summaries_and_collapse() {
800 let (c, r) = (columns(), rows());
801 let mut q = GridQuery::default();
802 q.toggle_group(1);
803 let result = run_query(&c, &r, &q);
804 assert_eq!(
805 names(&result, &r),
806 [
807 "[Argentina 1]",
808 "umbrella",
809 "[Brazil 2]",
810 "Acme",
811 "Initech",
812 "[Chile 1]",
813 "Globex",
814 "[ 1]",
815 "Hooli"
816 ]
817 );
818 let brazil = result.rows.iter().find_map(|row| match row {
819 ViewRow::Group { label, key, summaries, .. } if label == "Brazil" => {
820 Some((key.clone(), summaries[2]))
821 }
822 _ => None,
823 });
824 let (key, seats) = brazil.expect("Brazil group");
825 assert_eq!(seats, Some(15.0));
826 assert_eq!(result.totals[2], Some(95.0));
827 q.toggle_collapsed(&key);
828 assert_eq!(
829 names(&run_query(&c, &r, &q), &r),
830 ["[Argentina 1]", "umbrella", "[Brazil 2]", "[Chile 1]", "Globex", "[ 1]", "Hooli"]
831 );
832 }
833
834 #[test]
835 fn nested_groups() {
836 let (c, r) = (columns(), rows());
837 let q = GridQuery { group_by: vec![3, 1], ..Default::default() };
838 let result = run_query(&c, &r, &q);
839 let levels: Vec<usize> = result
840 .rows
841 .iter()
842 .filter_map(|row| if let ViewRow::Group { level, .. } = row { Some(*level) } else { None })
843 .collect();
844 assert_eq!(levels, [0, 1, 1, 0, 1, 1]);
845 }
846
847 #[test]
848 fn csv_quotes_and_keeps_raw_numbers() {
849 let c = vec![GridColumn::text("Name"), GridColumn::currency("Amount", "$", 2)];
850 let r = vec![vec!["Smith, \"J\"".into(), 1234.5.into()]];
851 let q = GridQuery::default();
852 let csv = to_csv(&c, &r, &q, &[0]);
853 assert_eq!(csv, "Name,Amount\r\n\"Smith, \"\"J\"\"\",1234.5\r\n");
854 }
855
856 #[test]
857 fn hundred_thousand_rows_query_quickly() {
858 let c = columns();
859 let r: Vec<Row> = (0..100_000)
860 .map(|i| {
861 vec![
862 format!("Company {i}").into(),
863 ["Brazil", "Chile", "Peru"][i % 3].into(),
864 (i % 97).into(),
865 (i % 2 == 0).into(),
866 ]
867 })
868 .collect();
869 let q =
870 GridQuery { sort: vec![(0, SortDirection::Descending)], group_by: vec![1], ..Default::default() };
871 let start = std::time::Instant::now();
872 let result = run_query(&c, &r, &q);
873 assert_eq!(result.matched.len(), 100_000);
874 assert_eq!(result.rows.len(), 100_003);
875 assert!(start.elapsed().as_secs_f32() < 5.0, "took {:?}", start.elapsed());
877 }
878}