Skip to main content

datacraft_query/
qbe.rs

1//! QBE query model (step 7.2, item H26; decision default 7).
2//!
3//! No SQL parser: a query is a source table plus per-column show/sort/
4//! criteria rows, exactly like the Access design grid. Identifiers are
5//! whitelisted against the schema at dispatch (LESSONS #5); this module
6//! carries the structure and structural validation.
7
8use serde::{Deserialize, Serialize};
9
10/// Sort state of one query column.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
12#[serde(rename_all = "snake_case")]
13pub enum SortOrder {
14    /// Not sorted (default).
15    #[default]
16    None,
17    /// Ascending.
18    Ascending,
19    /// Descending.
20    Descending,
21}
22
23impl std::fmt::Display for SortOrder {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        match self {
26            Self::None => write!(f, ""),
27            Self::Ascending => write!(f, "ascending"),
28            Self::Descending => write!(f, "descending"),
29        }
30    }
31}
32
33impl SortOrder {
34    /// Sort state after a datasheet header click (step 21.4, item U8):
35    /// unsorted columns sort ascending, then toggle descending/ascending.
36    pub const fn toggled(self) -> Self {
37        match self {
38            Self::None => Self::Ascending,
39            Self::Ascending => Self::Descending,
40            Self::Descending => Self::Ascending,
41        }
42    }
43}
44
45/// One column of the QBE grid.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct QbeField {
48    /// Source field name, resolved against the source table by name
49    /// (case-insensitive) through the schema whitelist — never by
50    /// grid position (step 42.3, item L22).
51    pub name: String,
52    /// Whether the column appears in the result.
53    pub shown: bool,
54    /// Sort order.
55    pub sort: SortOrder,
56    /// Criteria strings, OR-combined within the column (row order).
57    pub criteria: Vec<String>,
58}
59
60/// A query definition: source table plus QBE columns.
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
62pub struct QueryDef {
63    /// Query name (unique in its database, case-insensitive).
64    pub name: String,
65    /// Source table (whitelisted against the database).
66    pub table: String,
67    /// QBE columns in grid order. The list may be any subset in any
68    /// order of the source's fields: shown names resolve by name and
69    /// must be unique (case-insensitive) and known, or execution
70    /// answers a precise `EngineError`.
71    pub fields: Vec<QbeField>,
72}
73
74impl QueryDef {
75    /// Creates an empty query over `table` (may be empty until chosen).
76    pub fn new(name: impl Into<String>, table: impl Into<String>) -> Self {
77        Self {
78            name: name.into(),
79            table: table.into(),
80            fields: Vec::new(),
81        }
82    }
83
84    /// Structural cell edit (no whitelist — dispatch owns that).
85    pub fn set_cell(&mut self, row: QbeRow, col: usize, value: QbeValue) -> Result<(), QueryError> {
86        match row {
87            QbeRow::Table => match value {
88                QbeValue::Text(table) => {
89                    self.table = table;
90                    Ok(())
91                }
92                QbeValue::Bool(_) => Err(QueryError::InvalidCell {
93                    detail: "the Table row takes a name".to_owned(),
94                }),
95            },
96            QbeRow::Field => match value {
97                QbeValue::Text(name) => {
98                    if col == self.fields.len() {
99                        if self
100                            .fields
101                            .iter()
102                            .any(|f| f.name.eq_ignore_ascii_case(&name))
103                        {
104                            return Err(QueryError::DuplicateField { field: name });
105                        }
106                        self.fields.push(QbeField {
107                            name,
108                            shown: true,
109                            sort: SortOrder::None,
110                            criteria: Vec::new(),
111                        });
112                        return Ok(());
113                    }
114                    if self
115                        .fields
116                        .iter()
117                        .enumerate()
118                        .any(|(i, f)| i != col && f.name.eq_ignore_ascii_case(&name))
119                    {
120                        return Err(QueryError::DuplicateField { field: name });
121                    }
122                    let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
123                        detail: "columns extend one at a time".to_owned(),
124                    })?;
125                    field.name = name;
126                    Ok(())
127                }
128                QbeValue::Bool(_) => Err(QueryError::InvalidCell {
129                    detail: "the Field row takes a name".to_owned(),
130                }),
131            },
132            QbeRow::Show => {
133                let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
134                    detail: "no column at that position".to_owned(),
135                })?;
136                match value {
137                    QbeValue::Bool(shown) => {
138                        field.shown = shown;
139                        Ok(())
140                    }
141                    QbeValue::Text(_) => Err(QueryError::InvalidCell {
142                        detail: "the Show row takes a checkbox".to_owned(),
143                    }),
144                }
145            }
146            QbeRow::Sort => {
147                let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
148                    detail: "no column at that position".to_owned(),
149                })?;
150                match value {
151                    QbeValue::Text(text) => {
152                        field.sort = match text.as_str() {
153                            "" => SortOrder::None,
154                            "ascending" => SortOrder::Ascending,
155                            "descending" => SortOrder::Descending,
156                            other => {
157                                return Err(QueryError::InvalidCell {
158                                    detail: format!(
159                                        "sort must be ascending, descending or empty, found {other:?}"
160                                    ),
161                                });
162                            }
163                        };
164                        Ok(())
165                    }
166                    QbeValue::Bool(_) => Err(QueryError::InvalidCell {
167                        detail: "the Sort row takes text".to_owned(),
168                    }),
169                }
170            }
171            QbeRow::Criteria(or_row) => {
172                let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
173                    detail: "no column at that position".to_owned(),
174                })?;
175                let QbeValue::Text(text) = value else {
176                    return Err(QueryError::InvalidCell {
177                        detail: "the Criteria row takes text".to_owned(),
178                    });
179                };
180                if text.is_empty() {
181                    return Err(QueryError::InvalidCell {
182                        detail: "criteria must not be empty".to_owned(),
183                    });
184                }
185                if or_row == field.criteria.len() {
186                    field.criteria.push(text);
187                } else if let Some(slot) = field.criteria.get_mut(or_row) {
188                    *slot = text;
189                } else {
190                    return Err(QueryError::InvalidCell {
191                        detail: "criteria rows fill in order".to_owned(),
192                    });
193                }
194                Ok(())
195            }
196        }
197    }
198}
199
200/// Grid row kinds (the Access QBE layout).
201#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum QbeRow {
204    /// Source table row.
205    Table,
206    /// Field name row.
207    Field,
208    /// Show-checkbox row.
209    Show,
210    /// Sort row.
211    Sort,
212    /// N-th criteria ("or") row.
213    Criteria(usize),
214}
215
216/// Cell values of the QBE grid.
217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
219pub enum QbeValue {
220    /// Text cell.
221    Text(String),
222    /// Checkbox cell.
223    Bool(bool),
224}
225
226/// Structural errors of QBE edits (whitelist violations come from
227/// dispatch as `Event::Failed`).
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum QueryError {
230    /// A column references a field twice.
231    DuplicateField {
232        /// The duplicated name.
233        field: String,
234    },
235    /// The edit does not fit the grid's structure.
236    InvalidCell {
237        /// Concrete reason.
238        detail: String,
239    },
240}
241
242impl std::fmt::Display for QueryError {
243    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244        match self {
245            Self::DuplicateField { field } => {
246                write!(
247                    f,
248                    "duplicate query column {field:?}: columns are case-insensitive"
249                )
250            }
251            Self::InvalidCell { detail } => write!(f, "invalid cell: {detail}"),
252        }
253    }
254}
255
256impl std::error::Error for QueryError {}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    fn field(name: &str) -> QbeField {
263        QbeField {
264            name: name.to_owned(),
265            shown: true,
266            sort: SortOrder::None,
267            criteria: Vec::new(),
268        }
269    }
270
271    /// Every row kind rejects the wrong value type.
272    #[test]
273    fn cells_reject_the_wrong_value_type() {
274        let mut def = QueryDef::new("q", "t");
275        def.fields.push(field("a"));
276        let text = QbeValue::Text("x".into());
277        let bool_ = QbeValue::Bool(true);
278
279        assert!(def.set_cell(QbeRow::Table, 0, bool_.clone()).is_err());
280        assert!(def.set_cell(QbeRow::Field, 0, bool_.clone()).is_err());
281        assert!(def.set_cell(QbeRow::Show, 0, text).is_err());
282        assert!(def.set_cell(QbeRow::Sort, 0, bool_.clone()).is_err());
283        assert!(def.set_cell(QbeRow::Criteria(0), 0, bool_).is_err());
284    }
285
286    /// Cells beyond the grid are precise errors (columns extend one at a
287    /// time via the Field row; other rows need an existing column).
288    #[test]
289    fn cells_beyond_the_grid_are_errors() {
290        let mut def = QueryDef::new("q", "t");
291        assert!(
292            def.set_cell(QbeRow::Field, 1, QbeValue::Text("a".into()))
293                .is_err()
294        );
295        assert!(def.set_cell(QbeRow::Show, 0, QbeValue::Bool(true)).is_err());
296        assert!(
297            def.set_cell(QbeRow::Sort, 0, QbeValue::Text("".into()))
298                .is_err()
299        );
300        assert!(
301            def.set_cell(QbeRow::Criteria(0), 0, QbeValue::Text("x".into()))
302                .is_err()
303        );
304    }
305
306    /// Sort accepts exactly the three documented texts.
307    #[test]
308    fn sort_texts_parse_or_reject() {
309        let mut def = QueryDef::new("q", "t");
310        def.fields.push(field("a"));
311        for (text, expected) in [
312            ("ascending", SortOrder::Ascending),
313            ("descending", SortOrder::Descending),
314            ("", SortOrder::None),
315        ] {
316            def.set_cell(QbeRow::Sort, 0, QbeValue::Text(text.into()))
317                .expect("valid sort text");
318            assert_eq!(def.fields[0].sort, expected);
319        }
320        assert!(
321            def.set_cell(QbeRow::Sort, 0, QbeValue::Text("x".into()))
322                .is_err()
323        );
324        assert_eq!(def.fields[0].sort.to_string(), "", "none renders empty");
325    }
326
327    /// Criteria rows fill in order; re-editing overwrites in place.
328    #[test]
329    fn criteria_rows_fill_in_order_and_overwrite() {
330        let mut def = QueryDef::new("q", "t");
331        def.fields.push(field("a"));
332        def.set_cell(QbeRow::Criteria(0), 0, QbeValue::Text("1".into()))
333            .unwrap();
334        def.set_cell(QbeRow::Criteria(1), 0, QbeValue::Text("2".into()))
335            .unwrap();
336        assert!(
337            def.set_cell(QbeRow::Criteria(3), 0, QbeValue::Text("4".into()))
338                .is_err()
339        );
340        assert_eq!(def.fields[0].criteria, ["1", "2"]);
341        assert!(
342            def.set_cell(QbeRow::Criteria(0), 0, QbeValue::Text("9".into()))
343                .is_ok()
344        );
345        assert_eq!(def.fields[0].criteria, ["9", "2"]);
346    }
347
348    /// Error displays name the rule and the offending value.
349    #[test]
350    fn query_error_displays_are_precise() {
351        let dup = QueryError::DuplicateField { field: "a".into() };
352        assert!(dup.to_string().contains("a"));
353        assert!(dup.to_string().contains("duplicate"));
354        let invalid = QueryError::InvalidCell {
355            detail: "no column".into(),
356        };
357        assert!(invalid.to_string().contains("no column"));
358    }
359
360    /// A header click cycles None -> ascending -> descending -> ascending
361    /// (step 21.4, U8): the first click sorts, later clicks toggle.
362    #[test]
363    fn sort_toggle_cycles_ascending_and_descending() {
364        assert_eq!(SortOrder::None.toggled(), SortOrder::Ascending);
365        assert_eq!(SortOrder::Ascending.toggled(), SortOrder::Descending);
366        assert_eq!(SortOrder::Descending.toggled(), SortOrder::Ascending);
367    }
368
369    /// Display covers every sort state; wrong cell types stay precise.
370    #[test]
371    fn sort_display_and_wrong_cell_types_are_precise() {
372        assert_eq!(SortOrder::None.to_string(), "");
373        assert_eq!(SortOrder::Ascending.to_string(), "ascending");
374        assert_eq!(SortOrder::Descending.to_string(), "descending");
375        let mut def = QueryDef::new("q", "t");
376        def.fields.push(field("a"));
377        assert!(
378            def.set_cell(QbeRow::Show, 0, QbeValue::Text("x".into()))
379                .is_err()
380        );
381        assert!(def.set_cell(QbeRow::Sort, 0, QbeValue::Bool(true)).is_err());
382    }
383
384    /// The Field row names one source field: renaming a column to
385    /// another column's name (case-insensitive) and out-of-range columns
386    /// fail precisely; renaming itself is always allowed.
387    #[test]
388    fn field_row_rejects_duplicate_names_and_out_of_range_columns() {
389        let mut def = QueryDef::new("q", "t");
390        def.fields.push(field("a"));
391        def.fields.push(field("b"));
392        assert_eq!(
393            def.set_cell(QbeRow::Field, 0, QbeValue::Text("B".into())),
394            Err(QueryError::DuplicateField { field: "B".into() })
395        );
396        assert_eq!(
397            def.set_cell(QbeRow::Field, 5, QbeValue::Text("c".into())),
398            Err(QueryError::InvalidCell {
399                detail: "columns extend one at a time".to_owned(),
400            })
401        );
402        assert_eq!(
403            def.set_cell(QbeRow::Field, 0, QbeValue::Text("A".into())),
404            Ok(()),
405            "renaming a column to its own name (case-insensitive) is not a duplicate"
406        );
407        assert_eq!(def.fields[0].name, "A");
408        assert_eq!(def.fields[1].name, "b");
409    }
410}