Skip to main content

ravenrs_widgets/
filter_builder.rs

1//! Writing a filter without knowing the expression language.
2
3use std::fmt;
4use std::rc::Rc;
5
6use ravenrs_core::Insets;
7use ravenrs_criteria::{BinaryOp, Criteria, Function, GroupOp, UnaryOp, Value as Literal};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10
11use crate::context::{BuildCx, NodeId, Paint};
12use crate::{
13    Button, ButtonSize, ButtonStyle, IconButton, IconName, LookupItem, Select, TagBox, TextField, View,
14};
15
16/// What a field of a [`FilterBuilder`] holds, which says how its values are written.
17#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
18#[non_exhaustive]
19#[cfg_attr(feature = "live", ravenrs_live::expose(part = "variants"))]
20pub enum FilterFieldKind {
21    /// Text.
22    #[default]
23    Text,
24    /// A number.
25    Number,
26    /// Yes or no.
27    Boolean,
28    /// A date, written `2026-11-02`.
29    Date,
30}
31
32/// A field a [`FilterBuilder`] offers.
33#[derive(Clone, Debug, PartialEq, Eq)]
34#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
35pub struct FilterField {
36    /// The name in the expression.
37    pub name: String,
38    /// What people see.
39    pub caption: String,
40    /// What it holds.
41    pub kind: FilterFieldKind,
42    /// Values to pick from for "is any of": the key as the expression writes it
43    /// (`2026-11-02` for a date) and the label people see.
44    pub values: Vec<LookupItem>,
45}
46
47#[cfg_attr(feature = "live", ravenrs_live::expose)]
48impl FilterField {
49    /// A field with this name in the expression, and this caption on the screen.
50    pub fn new(name: impl Into<String>, caption: impl Into<String>, kind: FilterFieldKind) -> Self {
51        Self { name: name.into(), caption: caption.into(), kind, values: Vec::new() }
52    }
53
54    /// The values offered for "is any of" and "is none of". Others can still be typed.
55    pub fn values(mut self, values: impl IntoIterator<Item = LookupItem>) -> Self {
56        self.values = values.into_iter().collect();
57        self
58    }
59}
60
61#[derive(Clone, Copy, Debug, PartialEq, Eq)]
62enum Op {
63    Equal,
64    NotEqual,
65    Greater,
66    GreaterOrEqual,
67    Less,
68    LessOrEqual,
69    Contains,
70    StartsWith,
71    AnyOf,
72    NoneOf,
73    IsEmpty,
74    IsNotEmpty,
75}
76
77const ALL_OPS: [Op; 12] = [
78    Op::Equal,
79    Op::NotEqual,
80    Op::Greater,
81    Op::GreaterOrEqual,
82    Op::Less,
83    Op::LessOrEqual,
84    Op::Contains,
85    Op::StartsWith,
86    Op::AnyOf,
87    Op::NoneOf,
88    Op::IsEmpty,
89    Op::IsNotEmpty,
90];
91
92#[cfg_attr(feature = "live", ravenrs_live::expose)]
93impl Op {
94    fn takes_value(self) -> bool {
95        !matches!(self, Op::IsEmpty | Op::IsNotEmpty)
96    }
97
98    /// It compares with a list of values rather than one.
99    fn takes_list(self) -> bool {
100        matches!(self, Op::AnyOf | Op::NoneOf)
101    }
102
103    fn fits(self, kind: FilterFieldKind) -> bool {
104        match kind {
105            FilterFieldKind::Text => true,
106            FilterFieldKind::Boolean => {
107                matches!(self, Op::Equal | Op::NotEqual | Op::IsEmpty | Op::IsNotEmpty)
108            }
109            _ => !matches!(self, Op::Contains | Op::StartsWith),
110        }
111    }
112
113    fn label(self, labels: &crate::Labels) -> &str {
114        match self {
115            Op::Equal => &labels.filter_equal,
116            Op::NotEqual => &labels.filter_not_equal,
117            Op::Greater => &labels.filter_greater,
118            Op::GreaterOrEqual => &labels.filter_greater_or_equal,
119            Op::Less => &labels.filter_less,
120            Op::LessOrEqual => &labels.filter_less_or_equal,
121            Op::Contains => &labels.filter_contains,
122            Op::StartsWith => &labels.filter_starts_with,
123            Op::AnyOf => &labels.filter_any_of,
124            Op::NoneOf => &labels.filter_none_of,
125            Op::IsEmpty => &labels.filter_is_empty,
126            Op::IsNotEmpty => &labels.filter_is_not_empty,
127        }
128    }
129}
130
131/// One line or group of the builder. What it cannot show as a line stays as `Raw`,
132/// so opening someone's expression never loses any of it. `values` is what a list
133/// comparison holds; the others use `value`.
134#[derive(Clone, Debug, PartialEq)]
135enum Node {
136    Condition { field: String, op: Op, value: String, values: Vec<String> },
137    Raw(String),
138    Group { all: bool, items: Vec<Node> },
139}
140
141fn literal_text(value: &Literal) -> Option<String> {
142    match value {
143        Literal::Text(text) => Some(text.clone()),
144        Literal::Number(n) => Some(n.to_string()),
145        Literal::Bool(b) => Some(b.to_string()),
146        Literal::Date(date) => Some(date.to_string()),
147        _ => None,
148    }
149}
150
151fn field_name(criteria: &Criteria) -> Option<String> {
152    if let Criteria::Property(path) = criteria { Some(path.join(".")) } else { None }
153}
154
155fn read_node(criteria: &Criteria) -> Node {
156    let condition = |field: &Criteria, op: Op, value: &Criteria| -> Option<Node> {
157        let Criteria::Value(value) = value else { return None };
158        Some(Node::Condition {
159            field: field_name(field)?,
160            op,
161            value: literal_text(value)?,
162            values: Vec::new(),
163        })
164    };
165    let list = |field: &Criteria, op: Op, items: &[Criteria]| -> Option<Node> {
166        let values = items
167            .iter()
168            .map(|item| if let Criteria::Value(value) = item { literal_text(value) } else { None })
169            .collect::<Option<Vec<String>>>()?;
170        Some(Node::Condition { field: field_name(field)?, op, value: String::new(), values })
171    };
172    let read = match criteria {
173        Criteria::Group(op, items) => {
174            return Node::Group { all: *op == GroupOp::And, items: items.iter().map(read_node).collect() };
175        }
176        Criteria::Binary(op, field, value) => {
177            let op = match op {
178                BinaryOp::Eq => Some(Op::Equal),
179                BinaryOp::Ne => Some(Op::NotEqual),
180                BinaryOp::Gt => Some(Op::Greater),
181                BinaryOp::Ge => Some(Op::GreaterOrEqual),
182                BinaryOp::Lt => Some(Op::Less),
183                BinaryOp::Le => Some(Op::LessOrEqual),
184                _ => None,
185            };
186            op.and_then(|op| condition(field, op, value))
187        }
188        Criteria::Function(function, arguments) => match (function, arguments.as_slice()) {
189            (Function::Contains, [field, value]) => condition(field, Op::Contains, value),
190            (Function::StartsWith, [field, value]) => condition(field, Op::StartsWith, value),
191            _ => None,
192        },
193        Criteria::In(field, items) => list(field, Op::AnyOf, items),
194        Criteria::IsNull(field) => field_name(field).map(|field| Node::Condition {
195            field,
196            op: Op::IsEmpty,
197            value: String::new(),
198            values: Vec::new(),
199        }),
200        Criteria::Unary(UnaryOp::Not, inner) => match inner.as_ref() {
201            Criteria::IsNull(field) => field_name(field).map(|field| Node::Condition {
202                field,
203                op: Op::IsNotEmpty,
204                value: String::new(),
205                values: Vec::new(),
206            }),
207            Criteria::In(field, items) => list(field, Op::NoneOf, items),
208            _ => None,
209        },
210        _ => None,
211    };
212    read.unwrap_or_else(|| Node::Raw(criteria.to_string()))
213}
214
215/// What the expression says, as the tree the builder edits. A lone line goes
216/// inside a group, so there is always a place to add another.
217fn read_filter(text: &str) -> Node {
218    if text.trim().is_empty() {
219        return Node::Group { all: true, items: Vec::new() };
220    }
221    match Criteria::parse(text) {
222        Ok(criteria) => match read_node(&criteria) {
223            group @ Node::Group { .. } => group,
224            line => Node::Group { all: true, items: vec![line] },
225        },
226        Err(_) => Node::Group { all: true, items: vec![Node::Raw(text.to_owned())] },
227    }
228}
229
230fn write_value(value: &str, kind: FilterFieldKind) -> Option<String> {
231    let value = value.trim();
232    match kind {
233        FilterFieldKind::Text => Some(format!("'{}'", value.replace('\'', "''"))),
234        FilterFieldKind::Number => {
235            value.replace(',', ".").parse::<f64>().ok().filter(|n| n.is_finite()).map(|n| n.to_string())
236        }
237        FilterFieldKind::Boolean => match value.to_lowercase().as_str() {
238            "true" => Some("True".to_owned()),
239            "false" => Some("False".to_owned()),
240            _ => None,
241        },
242        FilterFieldKind::Date => {
243            let looks_right = value.len() >= 8 && value.chars().all(|c| c.is_ascii_digit() || c == '-');
244            looks_right.then(|| format!("#{value}#"))
245        }
246    }
247}
248
249/// The expression of a node; `None` for one that is not finished yet.
250fn write_node(node: &Node, fields: &[FilterField]) -> Option<String> {
251    match node {
252        Node::Raw(text) => Some(text.trim().to_owned()).filter(|t| !t.is_empty()),
253        Node::Condition { field, op, value, values } => {
254            let kind = fields.iter().find(|f| f.name == *field).map_or(FilterFieldKind::Text, |f| f.kind);
255            let written = if op.takes_list() {
256                let list = values.iter().map(|v| write_value(v, kind)).collect::<Option<Vec<String>>>()?;
257                if list.is_empty() {
258                    return None;
259                }
260                list.join(", ")
261            } else if op.takes_value() {
262                if value.trim().is_empty() {
263                    return None;
264                }
265                write_value(value, kind)?
266            } else {
267                String::new()
268            };
269            Some(match op {
270                Op::Equal => format!("[{field}] = {written}"),
271                Op::NotEqual => format!("[{field}] <> {written}"),
272                Op::Greater => format!("[{field}] > {written}"),
273                Op::GreaterOrEqual => format!("[{field}] >= {written}"),
274                Op::Less => format!("[{field}] < {written}"),
275                Op::LessOrEqual => format!("[{field}] <= {written}"),
276                Op::Contains => format!("Contains([{field}], {written})"),
277                Op::StartsWith => format!("StartsWith([{field}], {written})"),
278                Op::AnyOf => format!("[{field}] In ({written})"),
279                Op::NoneOf => format!("Not [{field}] In ({written})"),
280                Op::IsEmpty => format!("[{field}] Is Null"),
281                Op::IsNotEmpty => format!("Not [{field}] Is Null"),
282            })
283        }
284        Node::Group { all, items } => {
285            let parts: Vec<String> = items
286                .iter()
287                .filter_map(|item| {
288                    let text = write_node(item, fields)?;
289                    let nested = matches!(item, Node::Group { items, .. } if items.len() > 1);
290                    Some(if nested { format!("({text})") } else { text })
291                })
292                .collect();
293            if parts.is_empty() { None } else { Some(parts.join(if *all { " And " } else { " Or " })) }
294        }
295    }
296}
297
298/// Walks to the node at `path`, for an edit coming from a button or a field.
299fn node_at<'a>(root: &'a mut Node, path: &[usize]) -> Option<&'a mut Node> {
300    let mut node = root;
301    for &i in path {
302        let Node::Group { items, .. } = node else { return None };
303        node = items.get_mut(i)?;
304    }
305    Some(node)
306}
307
308/// Builds a filter out of lines of field, comparison and value, in groups that
309/// need all or any of their lines. It reads and writes the same expression language
310/// as the rest of RavenRS, and a part it cannot show as a line stays editable as text.
311///
312/// ```
313/// use ravenrs_reactive::State;
314/// use ravenrs_widgets::{FilterBuilder, FilterField, FilterFieldKind, Ui};
315///
316/// let filter = State::new(String::from("[Price] > 10"));
317/// let builder = FilterBuilder::new(
318///     filter.binding(),
319///     [FilterField::new("Price", "Price", FilterFieldKind::Number)],
320/// );
321/// let mut ui = Ui::new(builder, ravenrs_style::Theme::raven_dark());
322/// ui.update();
323/// ```
324#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
325pub struct FilterBuilder {
326    value: Binding<String>,
327    fields: Rc<Vec<FilterField>>,
328}
329
330impl fmt::Debug for FilterBuilder {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        f.debug_struct("FilterBuilder").field("fields", &self.fields).finish_non_exhaustive()
333    }
334}
335
336#[cfg_attr(feature = "live", ravenrs_live::expose)]
337impl FilterBuilder {
338    /// A builder over the expression in `value`, offering `fields`.
339    pub fn new(value: Binding<String>, fields: impl IntoIterator<Item = FilterField>) -> Self {
340        Self { value, fields: Rc::new(fields.into_iter().collect()) }
341    }
342}
343
344/// What the rows of the builder need to edit the tree.
345#[derive(Clone)]
346struct Editor {
347    tree: State<Node>,
348    value: Binding<String>,
349    fields: Rc<Vec<FilterField>>,
350}
351
352#[cfg_attr(feature = "live", ravenrs_live::expose)]
353impl Editor {
354    fn change(&self, path: &[usize], f: impl FnOnce(&mut Node)) {
355        let mut tree = self.tree.get();
356        if let Some(node) = node_at(&mut tree, path) {
357            f(node);
358        }
359        let text = write_node(&tree, &self.fields).unwrap_or_default();
360        self.tree.set(tree);
361        if text != self.value.get() {
362            self.value.set(text);
363        }
364    }
365
366    fn remove(&self, path: &[usize]) {
367        let Some((last, parent)) = path.split_last() else { return };
368        self.change(parent, |node| {
369            if let Node::Group { items, .. } = node
370                && *last < items.len()
371            {
372                items.remove(*last);
373            }
374        });
375    }
376
377    fn new_line(&self) -> Node {
378        let first = self.fields.first();
379        Node::Condition {
380            field: first.map_or_else(String::new, |f| f.name.clone()),
381            op: if first.is_some_and(|f| f.kind == FilterFieldKind::Text) { Op::Contains } else { Op::Equal },
382            value: String::new(),
383            values: Vec::new(),
384        }
385    }
386}
387
388fn row_style() -> LayoutStyle {
389    LayoutStyle {
390        spacing: 8.0,
391        align: Align::Center,
392        align_self: Some(Align::Stretch),
393        wrap: true,
394        ..LayoutStyle::stack(Axis::Horizontal)
395    }
396}
397
398fn remove_button(cx: &mut BuildCx<'_>, editor: &Editor, path: &[usize]) -> NodeId {
399    let (editor, path) = (editor.clone(), path.to_vec());
400    cx.child(
401        &IconButton::new(IconName::X)
402            .label(cx.labels().filter_remove.clone())
403            .size(ButtonSize::Small)
404            .on_click(move || editor.remove(&path)),
405    )
406}
407
408fn build_condition(
409    cx: &mut BuildCx<'_>,
410    editor: &Editor,
411    path: &[usize],
412    field: &str,
413    op: Op,
414    value: &str,
415    values: &[String],
416) -> NodeId {
417    let labels = cx.labels();
418    let fields = editor.fields.clone();
419    let at = fields.iter().position(|f| f.name == field);
420    let kind = at.map_or(FilterFieldKind::Text, |i| fields[i].kind);
421
422    let mut children = Vec::new();
423    // A field no longer offered stays selectable, under its own name.
424    let mut names: Vec<String> = fields.iter().map(|f| f.caption.clone()).collect();
425    if at.is_none() {
426        names.push(field.to_owned());
427    }
428    let chosen = at.unwrap_or(names.len() - 1);
429    let (set, there, offered) = (editor.clone(), path.to_vec(), fields.clone());
430    children.push(
431        cx.child(
432            &Select::new(
433                Binding::new(
434                    move || chosen,
435                    move |i: usize| {
436                        let Some(next) = offered.get(i).cloned() else { return };
437                        set.change(&there, |node| {
438                            if let Node::Condition { field, op, value, .. } = node {
439                                *field = next.name.clone();
440                                if !op.fits(next.kind) {
441                                    *op = Op::Equal;
442                                }
443                                if next.kind == FilterFieldKind::Boolean {
444                                    *value = "true".to_owned();
445                                } else if value == "true" || value == "false" {
446                                    value.clear();
447                                }
448                            }
449                        });
450                    },
451                ),
452                names,
453            )
454            .label(labels.filter_field.clone())
455            .width(170.0),
456        ),
457    );
458
459    let ops: Vec<Op> = ALL_OPS.iter().copied().filter(|o| o.fits(kind) || *o == op).collect();
460    let current = ops.iter().position(|o| *o == op).unwrap_or(0);
461    let names: Vec<String> = ops.iter().map(|o| o.label(&labels).to_owned()).collect();
462    let (set, there) = (editor.clone(), path.to_vec());
463    children.push(
464        cx.child(
465            &Select::new(
466                Binding::new(
467                    move || current,
468                    move |i: usize| {
469                        let Some(next) = ops.get(i).copied() else { return };
470                        set.change(&there, |node| {
471                            if let Node::Condition { op, value, values, .. } = node {
472                                // What was written goes along to the other kind of comparison.
473                                if next.takes_list() && !op.takes_list() {
474                                    *values = Some(value.trim().to_owned())
475                                        .filter(|v| !v.is_empty())
476                                        .into_iter()
477                                        .collect();
478                                } else if !next.takes_list() && op.takes_list() {
479                                    *value = values.first().cloned().unwrap_or_default();
480                                }
481                                *op = next;
482                            }
483                        });
484                    },
485                ),
486                names,
487            )
488            .label(labels.filter_comparison.clone())
489            .width(170.0),
490        ),
491    );
492
493    if op.takes_list() {
494        let (set, there, shown) = (editor.clone(), path.to_vec(), values.to_vec());
495        let chosen = Binding::new(
496            move || shown.clone(),
497            move |picked: Vec<String>| {
498                set.change(&there, |node| {
499                    if let Node::Condition { values, .. } = node {
500                        *values = picked;
501                    }
502                });
503            },
504        );
505        let options = at.map(|i| fields[i].values.clone()).unwrap_or_default();
506        children.push(
507            cx.child(
508                &TagBox::new(chosen, options)
509                    .allow_new(true)
510                    .placeholder(labels.filter_value.clone())
511                    .label(labels.filter_value.clone())
512                    .width(190.0),
513            ),
514        );
515    } else if op.takes_value() {
516        let (set, there) = (editor.clone(), path.to_vec());
517        let shown = value.to_owned();
518        if kind == FilterFieldKind::Boolean {
519            children.push(
520                cx.child(
521                    &Select::new(
522                        Binding::new(
523                            move || usize::from(shown != "true"),
524                            move |i: usize| {
525                                set.change(&there, |node| {
526                                    if let Node::Condition { value, .. } = node {
527                                        *value = if i == 0 { "true" } else { "false" }.to_owned();
528                                    }
529                                });
530                            },
531                        ),
532                        [labels.filter_yes.clone(), labels.filter_no.clone()],
533                    )
534                    .label(labels.filter_value.clone())
535                    .width(120.0),
536                ),
537            );
538        } else {
539            let text = Binding::new(
540                move || shown.clone(),
541                move |text: String| {
542                    set.change(&there, |node| {
543                        if let Node::Condition { value, .. } = node {
544                            *value = text;
545                        }
546                    });
547                },
548            );
549            children
550                .push(cx.child(&TextField::new(text).placeholder(labels.filter_value.clone()).width(180.0)));
551        }
552    }
553    children.push(remove_button(cx, editor, path));
554    cx.container(row_style(), &children, Paint::None)
555}
556
557fn build_raw(cx: &mut BuildCx<'_>, editor: &Editor, path: &[usize], text: &str) -> NodeId {
558    let (set, there, shown) = (editor.clone(), path.to_vec(), text.to_owned());
559    let binding = Binding::new(
560        move || shown.clone(),
561        move |text: String| {
562            set.change(&there, |node| *node = Node::Raw(text));
563        },
564    );
565    let field = cx.child(&TextField::new(binding).placeholder(cx.labels().filter_expression.clone()));
566    let field = cx.container(
567        LayoutStyle {
568            grow: 1.0,
569            min_width: Some(0.0),
570            align: Align::Stretch,
571            ..LayoutStyle::stack(Axis::Vertical)
572        },
573        &[field],
574        Paint::None,
575    );
576    let remove = remove_button(cx, editor, path);
577    cx.container(row_style(), &[field, remove], Paint::None)
578}
579
580fn build_group(cx: &mut BuildCx<'_>, editor: &Editor, path: &[usize], all: bool, items: &[Node]) -> NodeId {
581    let labels = cx.labels();
582    let (set, there) = (editor.clone(), path.to_vec());
583    let mode = cx.child(
584        &Select::new(
585            Binding::new(
586                move || usize::from(!all),
587                move |i: usize| {
588                    set.change(&there, |node| {
589                        if let Node::Group { all, .. } = node {
590                            *all = i == 0;
591                        }
592                    });
593                },
594            ),
595            [labels.filter_all.clone(), labels.filter_any.clone()],
596        )
597        .label(labels.filter_match.clone())
598        .width(200.0),
599    );
600    let (set, there) = (editor.clone(), path.to_vec());
601    let add = cx.child(
602        &Button::new(labels.filter_add_condition.clone())
603            .style(ButtonStyle::Ghost)
604            .size(ButtonSize::Small)
605            .icon(IconName::Plus)
606            .on_click(move || {
607                let line = set.new_line();
608                set.change(&there, |node| {
609                    if let Node::Group { items, .. } = node {
610                        items.push(line);
611                    }
612                });
613            }),
614    );
615    let (set, there) = (editor.clone(), path.to_vec());
616    let add_group = cx.child(
617        &Button::new(labels.filter_add_group.clone())
618            .style(ButtonStyle::Ghost)
619            .size(ButtonSize::Small)
620            .icon(IconName::Plus)
621            .on_click(move || {
622                let line = set.new_line();
623                set.change(&there, |node| {
624                    if let Node::Group { items, .. } = node {
625                        items.push(Node::Group { all: true, items: vec![line] });
626                    }
627                });
628            }),
629    );
630    let mut heading = vec![mode, add, add_group];
631    if !path.is_empty() {
632        heading.push(remove_button(cx, editor, path));
633    }
634    let heading = cx.container(row_style(), &heading, Paint::None);
635    let mut lines = vec![heading];
636    for (i, item) in items.iter().enumerate() {
637        let mut child = path.to_vec();
638        child.push(i);
639        lines.push(match item {
640            Node::Condition { field, op, value, values } => {
641                build_condition(cx, editor, &child, field, *op, value, values)
642            }
643            Node::Raw(text) => build_raw(cx, editor, &child, text),
644            Node::Group { all, items } => build_group(cx, editor, &child, *all, items),
645        });
646    }
647    cx.container(
648        LayoutStyle {
649            spacing: 8.0,
650            padding: Insets { left: if path.is_empty() { 0.0 } else { 16.0 }, ..Insets::default() },
651            align: Align::Stretch,
652            align_self: Some(Align::Stretch),
653            ..LayoutStyle::stack(Axis::Vertical)
654        },
655        &lines,
656        Paint::None,
657    )
658}
659
660impl View for FilterBuilder {
661    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
662        let text = self.value.get();
663        let tree: State<Node> = {
664            let first = text.clone();
665            cx.use_state(move || read_filter(&first))
666        };
667        // Lines still being written say nothing yet, so only a real difference counts.
668        let mine = write_node(&tree.peek(), &self.fields).unwrap_or_default();
669        if mine != text {
670            tree.set(read_filter(&text));
671        }
672        let editor = Editor { tree: tree.clone(), value: self.value.clone(), fields: self.fields.clone() };
673        let Node::Group { all, items } = tree.get() else { return cx.empty() };
674        build_group(cx, &editor, &[], all, &items)
675    }
676}
677
678#[cfg(test)]
679mod tests {
680    use super::*;
681
682    fn fields() -> Vec<FilterField> {
683        vec![
684            FilterField::new("Name", "Name", FilterFieldKind::Text),
685            FilterField::new("Price", "Price", FilterFieldKind::Number),
686            FilterField::new("Active", "Active", FilterFieldKind::Boolean),
687            FilterField::new("Due", "Due", FilterFieldKind::Date),
688        ]
689    }
690
691    /// What goes in comes out as the same expression, and what comes out is valid.
692    fn round_trip(text: &str) -> String {
693        let written = write_node(&read_filter(text), &fields()).unwrap_or_default();
694        if !written.is_empty() {
695            assert!(Criteria::parse(&written).is_ok(), "{written}");
696        }
697        written
698    }
699
700    #[test]
701    fn lines_read_and_write_the_language() {
702        for text in [
703            "[Price] > 10",
704            "[Price] <= 2.5",
705            "[Name] = 'Acme'",
706            "[Name] <> 'It''s'",
707            "Contains([Name], 'ac')",
708            "StartsWith([Name], 'ac')",
709            "[Name] Is Null",
710            "Not [Name] Is Null",
711            "[Active] = True",
712            "[Due] >= #2026-11-02#",
713            "[Name] In ('Acme', 'It''s')",
714            "Not [Name] In ('Acme')",
715            "[Price] In (1, 2.5)",
716            "[Due] In (#2026-11-02#)",
717            "[Name] In ('Acme') And [Price] > 1",
718            "[Price] > 1 And [Price] < 5",
719            "[Price] > 1 Or [Name] = 'x'",
720            "[Price] > 1 And ([Name] = 'x' Or [Name] = 'y')",
721        ] {
722            assert_eq!(round_trip(text), text);
723        }
724        assert_eq!(round_trip(""), "");
725    }
726
727    #[test]
728    fn what_cannot_be_a_line_is_kept() {
729        let kept = round_trip("Upper([Name]) = 'X' And [Price] > 1");
730        assert!(kept.contains("Upper"), "{kept}");
731        assert!(kept.contains("[Price] > 1"));
732        let group = read_filter("Upper([Name]) = 'X'");
733        assert!(matches!(&group, Node::Group { items, .. } if matches!(items[0], Node::Raw(_))));
734        let broken = read_filter("[Name] = ");
735        assert!(
736            matches!(&broken, Node::Group { items, .. } if matches!(items[0], Node::Raw(_))),
737            "{broken:?}"
738        );
739    }
740
741    #[test]
742    fn unfinished_lines_say_nothing() {
743        let line = |field: &str, op, value: &str| Node::Condition {
744            field: field.to_owned(),
745            op,
746            value: value.to_owned(),
747            values: Vec::new(),
748        };
749        let tree = Node::Group {
750            all: true,
751            items: vec![
752                line("Name", Op::Contains, ""),
753                line("Price", Op::Greater, "abc"),
754                line("Price", Op::Greater, "1,5"),
755                Node::Group { all: false, items: vec![] },
756            ],
757        };
758        assert_eq!(write_node(&tree, &fields()).as_deref(), Some("[Price] > 1.5"));
759    }
760
761    #[test]
762    fn a_list_reads_as_one_line() {
763        let read = read_filter("[Name] In ('a', 'b')");
764        let Node::Group { items, .. } = &read else { panic!("{read:?}") };
765        assert_eq!(
766            items[0],
767            Node::Condition {
768                field: "Name".to_owned(),
769                op: Op::AnyOf,
770                value: String::new(),
771                values: vec!["a".to_owned(), "b".to_owned()],
772            }
773        );
774        // A list of anything but plain values stays an expression.
775        let read = read_filter("[Name] In ([Other], 'b')");
776        assert!(matches!(&read, Node::Group { items, .. } if matches!(items[0], Node::Raw(_))));
777        let empty = Node::Condition {
778            field: "Name".to_owned(),
779            op: Op::AnyOf,
780            value: String::new(),
781            values: Vec::new(),
782        };
783        assert_eq!(write_node(&empty, &fields()), None, "nothing picked says nothing yet");
784    }
785
786    #[test]
787    fn comparisons_fit_the_field() {
788        assert!(Op::AnyOf.fits(FilterFieldKind::Date));
789        assert!(!Op::AnyOf.fits(FilterFieldKind::Boolean));
790        assert!(Op::Contains.fits(FilterFieldKind::Text));
791        assert!(!Op::Contains.fits(FilterFieldKind::Number));
792        assert!(!Op::Greater.fits(FilterFieldKind::Boolean));
793        assert!(Op::IsEmpty.fits(FilterFieldKind::Date));
794    }
795}