use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortOrder {
#[default]
None,
Ascending,
Descending,
}
impl std::fmt::Display for SortOrder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, ""),
Self::Ascending => write!(f, "ascending"),
Self::Descending => write!(f, "descending"),
}
}
}
impl SortOrder {
pub const fn toggled(self) -> Self {
match self {
Self::None => Self::Ascending,
Self::Ascending => Self::Descending,
Self::Descending => Self::Ascending,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QbeField {
pub name: String,
pub shown: bool,
pub sort: SortOrder,
pub criteria: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct QueryDef {
pub name: String,
pub table: String,
pub fields: Vec<QbeField>,
}
impl QueryDef {
pub fn new(name: impl Into<String>, table: impl Into<String>) -> Self {
Self {
name: name.into(),
table: table.into(),
fields: Vec::new(),
}
}
pub fn set_cell(&mut self, row: QbeRow, col: usize, value: QbeValue) -> Result<(), QueryError> {
match row {
QbeRow::Table => match value {
QbeValue::Text(table) => {
self.table = table;
Ok(())
}
QbeValue::Bool(_) => Err(QueryError::InvalidCell {
detail: "the Table row takes a name".to_owned(),
}),
},
QbeRow::Field => match value {
QbeValue::Text(name) => {
if col == self.fields.len() {
if self
.fields
.iter()
.any(|f| f.name.eq_ignore_ascii_case(&name))
{
return Err(QueryError::DuplicateField { field: name });
}
self.fields.push(QbeField {
name,
shown: true,
sort: SortOrder::None,
criteria: Vec::new(),
});
return Ok(());
}
if self
.fields
.iter()
.enumerate()
.any(|(i, f)| i != col && f.name.eq_ignore_ascii_case(&name))
{
return Err(QueryError::DuplicateField { field: name });
}
let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
detail: "columns extend one at a time".to_owned(),
})?;
field.name = name;
Ok(())
}
QbeValue::Bool(_) => Err(QueryError::InvalidCell {
detail: "the Field row takes a name".to_owned(),
}),
},
QbeRow::Show => {
let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
detail: "no column at that position".to_owned(),
})?;
match value {
QbeValue::Bool(shown) => {
field.shown = shown;
Ok(())
}
QbeValue::Text(_) => Err(QueryError::InvalidCell {
detail: "the Show row takes a checkbox".to_owned(),
}),
}
}
QbeRow::Sort => {
let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
detail: "no column at that position".to_owned(),
})?;
match value {
QbeValue::Text(text) => {
field.sort = match text.as_str() {
"" => SortOrder::None,
"ascending" => SortOrder::Ascending,
"descending" => SortOrder::Descending,
other => {
return Err(QueryError::InvalidCell {
detail: format!(
"sort must be ascending, descending or empty, found {other:?}"
),
});
}
};
Ok(())
}
QbeValue::Bool(_) => Err(QueryError::InvalidCell {
detail: "the Sort row takes text".to_owned(),
}),
}
}
QbeRow::Criteria(or_row) => {
let field = self.fields.get_mut(col).ok_or(QueryError::InvalidCell {
detail: "no column at that position".to_owned(),
})?;
let QbeValue::Text(text) = value else {
return Err(QueryError::InvalidCell {
detail: "the Criteria row takes text".to_owned(),
});
};
if text.is_empty() {
return Err(QueryError::InvalidCell {
detail: "criteria must not be empty".to_owned(),
});
}
if or_row == field.criteria.len() {
field.criteria.push(text);
} else if let Some(slot) = field.criteria.get_mut(or_row) {
*slot = text;
} else {
return Err(QueryError::InvalidCell {
detail: "criteria rows fill in order".to_owned(),
});
}
Ok(())
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum QbeRow {
Table,
Field,
Show,
Sort,
Criteria(usize),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "kind", content = "value", rename_all = "snake_case")]
pub enum QbeValue {
Text(String),
Bool(bool),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum QueryError {
DuplicateField {
field: String,
},
InvalidCell {
detail: String,
},
}
impl std::fmt::Display for QueryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::DuplicateField { field } => {
write!(
f,
"duplicate query column {field:?}: columns are case-insensitive"
)
}
Self::InvalidCell { detail } => write!(f, "invalid cell: {detail}"),
}
}
}
impl std::error::Error for QueryError {}
#[cfg(test)]
mod tests {
use super::*;
fn field(name: &str) -> QbeField {
QbeField {
name: name.to_owned(),
shown: true,
sort: SortOrder::None,
criteria: Vec::new(),
}
}
#[test]
fn cells_reject_the_wrong_value_type() {
let mut def = QueryDef::new("q", "t");
def.fields.push(field("a"));
let text = QbeValue::Text("x".into());
let bool_ = QbeValue::Bool(true);
assert!(def.set_cell(QbeRow::Table, 0, bool_.clone()).is_err());
assert!(def.set_cell(QbeRow::Field, 0, bool_.clone()).is_err());
assert!(def.set_cell(QbeRow::Show, 0, text).is_err());
assert!(def.set_cell(QbeRow::Sort, 0, bool_.clone()).is_err());
assert!(def.set_cell(QbeRow::Criteria(0), 0, bool_).is_err());
}
#[test]
fn cells_beyond_the_grid_are_errors() {
let mut def = QueryDef::new("q", "t");
assert!(
def.set_cell(QbeRow::Field, 1, QbeValue::Text("a".into()))
.is_err()
);
assert!(def.set_cell(QbeRow::Show, 0, QbeValue::Bool(true)).is_err());
assert!(
def.set_cell(QbeRow::Sort, 0, QbeValue::Text("".into()))
.is_err()
);
assert!(
def.set_cell(QbeRow::Criteria(0), 0, QbeValue::Text("x".into()))
.is_err()
);
}
#[test]
fn sort_texts_parse_or_reject() {
let mut def = QueryDef::new("q", "t");
def.fields.push(field("a"));
for (text, expected) in [
("ascending", SortOrder::Ascending),
("descending", SortOrder::Descending),
("", SortOrder::None),
] {
def.set_cell(QbeRow::Sort, 0, QbeValue::Text(text.into()))
.expect("valid sort text");
assert_eq!(def.fields[0].sort, expected);
}
assert!(
def.set_cell(QbeRow::Sort, 0, QbeValue::Text("x".into()))
.is_err()
);
assert_eq!(def.fields[0].sort.to_string(), "", "none renders empty");
}
#[test]
fn criteria_rows_fill_in_order_and_overwrite() {
let mut def = QueryDef::new("q", "t");
def.fields.push(field("a"));
def.set_cell(QbeRow::Criteria(0), 0, QbeValue::Text("1".into()))
.unwrap();
def.set_cell(QbeRow::Criteria(1), 0, QbeValue::Text("2".into()))
.unwrap();
assert!(
def.set_cell(QbeRow::Criteria(3), 0, QbeValue::Text("4".into()))
.is_err()
);
assert_eq!(def.fields[0].criteria, ["1", "2"]);
assert!(
def.set_cell(QbeRow::Criteria(0), 0, QbeValue::Text("9".into()))
.is_ok()
);
assert_eq!(def.fields[0].criteria, ["9", "2"]);
}
#[test]
fn query_error_displays_are_precise() {
let dup = QueryError::DuplicateField { field: "a".into() };
assert!(dup.to_string().contains("a"));
assert!(dup.to_string().contains("duplicate"));
let invalid = QueryError::InvalidCell {
detail: "no column".into(),
};
assert!(invalid.to_string().contains("no column"));
}
#[test]
fn sort_toggle_cycles_ascending_and_descending() {
assert_eq!(SortOrder::None.toggled(), SortOrder::Ascending);
assert_eq!(SortOrder::Ascending.toggled(), SortOrder::Descending);
assert_eq!(SortOrder::Descending.toggled(), SortOrder::Ascending);
}
#[test]
fn sort_display_and_wrong_cell_types_are_precise() {
assert_eq!(SortOrder::None.to_string(), "");
assert_eq!(SortOrder::Ascending.to_string(), "ascending");
assert_eq!(SortOrder::Descending.to_string(), "descending");
let mut def = QueryDef::new("q", "t");
def.fields.push(field("a"));
assert!(
def.set_cell(QbeRow::Show, 0, QbeValue::Text("x".into()))
.is_err()
);
assert!(def.set_cell(QbeRow::Sort, 0, QbeValue::Bool(true)).is_err());
}
#[test]
fn field_row_rejects_duplicate_names_and_out_of_range_columns() {
let mut def = QueryDef::new("q", "t");
def.fields.push(field("a"));
def.fields.push(field("b"));
assert_eq!(
def.set_cell(QbeRow::Field, 0, QbeValue::Text("B".into())),
Err(QueryError::DuplicateField { field: "B".into() })
);
assert_eq!(
def.set_cell(QbeRow::Field, 5, QbeValue::Text("c".into())),
Err(QueryError::InvalidCell {
detail: "columns extend one at a time".to_owned(),
})
);
assert_eq!(
def.set_cell(QbeRow::Field, 0, QbeValue::Text("A".into())),
Ok(()),
"renaming a column to its own name (case-insensitive) is not a duplicate"
);
assert_eq!(def.fields[0].name, "A");
assert_eq!(def.fields[1].name, "b");
}
}