Skip to main content

ravenrs_widgets/grid/
query.rs

1//! The data grid query engine: filter → search → sort → group → summarize.
2//! Pure functions over rows, independent of the UI.
3
4use 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/// What the user asked the grid to show.
14#[derive(Clone, Debug, Default, PartialEq)]
15#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
16pub struct GridQuery {
17    /// Sort columns in priority order.
18    pub sort: Vec<(usize, SortDirection)>,
19    /// Filter-row text per column (index = column): the short syntax of [`matches_filter`], or
20    /// a [`ravenrs_criteria`] expression over columns named by their titles, such as
21    /// `[Seats] > 10 And [Country] = 'Brazil'`.
22    pub filters: Vec<String>,
23    /// Text searched in every visible column.
24    pub search: String,
25    /// Grouping columns, outermost first.
26    pub group_by: Vec<usize>,
27    /// Keys of collapsed groups (see [`ViewRow::Group::key`]).
28    pub collapsed: BTreeSet<String>,
29    /// Hidden columns are ignored by search.
30    pub hidden: Vec<bool>,
31}
32
33#[cfg_attr(feature = "live", ravenrs_live::expose)]
34impl GridQuery {
35    /// The filter text of `column` (zero-based), or `""`.
36    pub fn filter(&self, column: usize) -> &str {
37        self.filters.get(column).map_or("", String::as_str)
38    }
39
40    /// Sets the filter text of `column` (zero-based).
41    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    /// The column (zero-based) is hidden.
49    pub fn is_hidden(&self, column: usize) -> bool {
50        self.hidden.get(column).copied().unwrap_or(false)
51    }
52
53    /// Plain click: sort by this column only, cycling ascending → descending →
54    /// none. With `add`, the column is added to (or cycled within) a multi-column sort.
55    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    /// Adds the column to or removes it from the grouping; expands all groups.
80    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    /// Collapses or expands the group with this key.
90    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/// A visible row of the grid: a group header or a data row.
98#[derive(Clone, Debug, PartialEq)]
99#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
100pub enum ViewRow {
101    /// A group header.
102    Group {
103        /// Nesting depth, 0 for the outermost groups.
104        level: usize,
105        /// The column grouped by (zero-based).
106        column: usize,
107        /// Display text of the group's value.
108        label: String,
109        /// Unique path of this group, used to collapse it.
110        key: String,
111        /// Number of data rows in the group.
112        count: usize,
113        /// The group's rows are hidden.
114        collapsed: bool,
115        /// Aggregates per column over the group's rows.
116        summaries: Vec<Option<f64>>,
117    },
118    /// Index into the data rows.
119    Data(usize),
120}
121
122/// The output of [`run_query`]: what the grid shows.
123#[derive(Clone, Debug, Default, PartialEq)]
124#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
125pub struct QueryResult {
126    /// Visible rows in display order.
127    pub rows: Vec<ViewRow>,
128    /// Matching data rows in display order (collapsed groups included).
129    pub matched: Vec<usize>,
130    /// Aggregates per column over all matching rows.
131    pub totals: Vec<Option<f64>>,
132}
133
134/// Does `value` (shown as `display`) pass a filter-row expression?
135///
136/// Numbers accept `>`, `>=`, `<`, `<=`, `=`, `<>` and ranges `a..b`; everything
137/// else is a case-insensitive "contains". This is the short syntax of one cell; only
138/// [`run_query`] knows the columns that a filter-row expression names.
139pub 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/// A filter-row expression parsed once per query instead of once per row.
146#[derive(Debug)]
147struct Filter {
148    /// The comparison applied to numbers, when the text is one.
149    number: Option<NumberTest>,
150    /// The answer for booleans, when the text is a yes/no word.
151    boolean: Option<bool>,
152    /// The lowercased text for the "contains" fallback.
153    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    /// `display` is only called when the value has to be matched as text.
197    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
210/// The expression in a filter-row text, when it is one: it parses and every field it reads is a
211/// column title. Any other text, such as `[draft]`, stays a short filter, and so does anything a
212/// row cannot answer on its own: aggregates, parameters and functions the grid does not register.
213fn 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
225/// A grid row as expressions read it, with raw values rather than the text shown.
226struct 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
254/// Case-insensitive "contains" where `needle` is already lowercase. ASCII text,
255/// the common case, is compared in place without allocating.
256fn 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/// A precomputed sort key, so sorting does not format or allocate per comparison.
268/// Text becomes its rank among the column's distinct lowercase texts.
269#[derive(Clone, Copy, Debug, PartialEq)]
270enum Key {
271    Empty,
272    Num(f64),
273    Text(u32),
274}
275
276/// Sort keys of `column` for the rows in `indices`, in the same order.
277fn 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        // Distinct texts sorted case-insensitively; equal lowercase shares a rank.
295        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        // Empty values sort last in ascending order.
318        (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
327/// False when no number this column shows can contain `needle`, so search can
328/// skip formatting its numbers.
329fn 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
364/// A formula read and checked against the columns, or what is wrong with it.
365pub 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
378/// What a formula comes to over these rows.
379pub 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
394/// Where each group of `result` starts in its `matched` rows, and how many it has.
395pub(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
416/// Every group's key under this query, as if all were open: what collapsing all of them means.
417pub 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
426/// Runs the whole pipeline.
427pub fn run_query(columns: &[GridColumn], rows: &[Row], query: &GridQuery) -> QueryResult {
428    let search = query.search.trim().to_lowercase();
429    // Reversed so that the first of two columns with one title wins.
430    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    // Group columns sort first (in their sort direction if they have one).
475    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        // Keys only for matching rows, indexed by position in `indices`.
484        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        // Ties fall back to the original position, so the result matches a stable sort.
488        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
550/// Rows as tab-separated text, the way spreadsheets copy and paste: the
551/// shown (formatted) values of the `visible` columns, no header.
552pub 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/// A file format of a grid's Export menu.
570#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
571#[non_exhaustive]
572#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
573pub enum GridExportFormat {
574    /// An Excel workbook, with real numbers and dates.
575    Xlsx,
576    /// A PDF, in pages.
577    Pdf,
578    /// Comma-separated text.
579    Csv,
580}
581
582/// What a column holds, for a file to show it well.
583#[derive(Clone, Debug, PartialEq)]
584#[non_exhaustive]
585#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
586pub enum GridTableKind {
587    /// Text, already as shown.
588    Text,
589    /// A number, with its decimals and the text around it.
590    Number {
591        /// Decimal places.
592        decimals: usize,
593        /// Text before it, such as a currency sign.
594        prefix: String,
595        /// Text after it.
596        suffix: String,
597    },
598    /// A date.
599    Date,
600    /// Yes or no.
601    Boolean,
602}
603
604/// A column of a [`GridTable`].
605#[derive(Clone, Debug, PartialEq)]
606#[non_exhaustive]
607#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
608pub struct GridTableColumn {
609    /// Its title.
610    pub title: String,
611    /// What it holds.
612    pub kind: GridTableKind,
613    /// Whether the grid sums it.
614    pub total: bool,
615}
616
617/// The rows of a grid as it stands: the columns showing, the rows matching, in order.
618#[derive(Clone, Debug, PartialEq)]
619#[non_exhaustive]
620#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
621pub struct GridTable {
622    /// The title of the file.
623    pub title: String,
624    /// The language and region of numbers and dates, such as `pt-BR`.
625    pub culture: String,
626    /// The columns, in the order they show.
627    pub columns: Vec<GridTableColumn>,
628    /// The values, one per column.
629    pub rows: Vec<Vec<Value>>,
630    /// The column rows are grouped by, by its index in [`columns`](Self::columns).
631    pub group_by: Option<usize>,
632}
633
634/// Takes what the grid shows into a table for a file. A column with a format of its own
635/// goes as the text it shows, since only that format knows what it means.
636pub 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
694/// Matching rows as CSV (RFC 4180), visible columns only, in display order.
695pub 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            // Raw numbers export unformatted so spreadsheets read them as numbers.
713            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); // second click: descending
791        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        // Generous bound for debug builds on CI-class machines.
876        assert!(start.elapsed().as_secs_f32() < 5.0, "took {:?}", start.elapsed());
877    }
878}