Skip to main content

ravenrs_criteria/
validate.rs

1//! Static checks against a schema, before anything runs: what a designer shows
2//! while the user types, and what a server checks before it accepts a rule.
3
4use std::collections::{BTreeMap, BTreeSet};
5
6use crate::ast::Criteria;
7
8/// The kind of value a field holds.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
10#[non_exhaustive]
11pub enum FieldType {
12    /// Text.
13    Text,
14    /// A number.
15    Number,
16    /// A date and time.
17    Date,
18    /// True or false.
19    Boolean,
20    /// Any kind of value.
21    Any,
22}
23
24/// What an expression may refer to.
25///
26/// ```
27/// use ravenrs_criteria::{Criteria, FieldType, Schema, validate};
28///
29/// let schema = Schema::new().field("Price", FieldType::Number).field("Name", FieldType::Text);
30/// let ok = Criteria::parse("[Price] > 10").expect("valid");
31/// assert!(validate(&ok, &schema).is_empty());
32/// let bad = Criteria::parse("[Prize] > 10 And Upper([Price]) = 'X'").expect("valid");
33/// assert_eq!(validate(&bad, &schema).len(), 1, "the misspelled field");
34/// ```
35#[derive(Clone, Debug, Default)]
36pub struct Schema {
37    fields: BTreeMap<String, FieldType>,
38    collections: BTreeMap<String, Schema>,
39    parameters: BTreeMap<String, FieldType>,
40    functions: BTreeSet<String>,
41}
42
43impl Schema {
44    /// An empty schema.
45    pub fn new() -> Self {
46        Self::default()
47    }
48
49    /// Adds a field; a dotted path reads a nested value.
50    pub fn field(mut self, path: &str, kind: FieldType) -> Self {
51        self.fields.insert(path.to_owned(), kind);
52        self
53    }
54
55    /// Adds a nested collection with the schema of its rows.
56    pub fn collection(mut self, path: &str, rows: Schema) -> Self {
57        self.collections.insert(path.to_owned(), rows);
58        self
59    }
60
61    /// Adds a parameter.
62    pub fn parameter(mut self, name: &str, kind: FieldType) -> Self {
63        self.parameters.insert(name.to_owned(), kind);
64        self
65    }
66
67    /// Declares a custom function.
68    pub fn function(mut self, name: &str) -> Self {
69        self.functions.insert(name.to_lowercase());
70        self
71    }
72}
73
74/// Something wrong with an expression.
75#[derive(Clone, Debug, PartialEq, Eq)]
76#[non_exhaustive]
77pub enum Problem {
78    /// A field the schema does not have.
79    UnknownField(String),
80    /// A collection the schema does not have.
81    UnknownCollection(String),
82    /// A parameter the schema does not have.
83    UnknownParameter(String),
84    /// A function that is neither built in nor declared.
85    UnknownFunction(String),
86}
87
88impl std::fmt::Display for Problem {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::UnknownField(n) => write!(f, "unknown field '{n}'"),
92            Self::UnknownCollection(n) => write!(f, "unknown collection '{n}'"),
93            Self::UnknownParameter(n) => write!(f, "unknown parameter '{n}'"),
94            Self::UnknownFunction(n) => write!(f, "unknown function '{n}'"),
95        }
96    }
97}
98
99/// Every problem found in `criteria`, in order, without repeats.
100pub fn validate(criteria: &Criteria, schema: &Schema) -> Vec<Problem> {
101    let mut problems = Vec::new();
102    check(criteria, schema, None, &mut problems);
103    problems
104}
105
106fn add(problems: &mut Vec<Problem>, p: Problem) {
107    if !problems.contains(&p) {
108        problems.push(p);
109    }
110}
111
112fn check(c: &Criteria, schema: &Schema, parent: Option<&Schema>, problems: &mut Vec<Problem>) {
113    match c {
114        Criteria::Property(path) => {
115            let (owner, path) = match path.split_first() {
116                Some((first, rest)) if first == "^" => (parent.unwrap_or(schema), rest),
117                _ => (schema, path.as_slice()),
118            };
119            let joined = path.join(".");
120            if !owner.fields.contains_key(&joined) && !owner.collections.contains_key(&joined) {
121                add(problems, Problem::UnknownField(joined));
122            }
123        }
124        Criteria::Parameter(name) => {
125            if !schema.parameters.contains_key(name)
126                && !parent.is_some_and(|p| p.parameters.contains_key(name))
127            {
128                add(problems, Problem::UnknownParameter(name.clone()));
129            }
130        }
131        Criteria::Custom(name, args) => {
132            if !schema.functions.contains(&name.to_lowercase())
133                && !parent.is_some_and(|p| p.functions.contains(&name.to_lowercase()))
134            {
135                add(problems, Problem::UnknownFunction(name.clone()));
136            }
137            args.iter().for_each(|a| check(a, schema, parent, problems));
138        }
139        Criteria::Aggregate(call) => match &call.collection {
140            Some(path) => {
141                let joined = path.join(".");
142                match schema.collections.get(&joined) {
143                    Some(rows) => {
144                        let inner = call.condition.iter().chain(&call.args);
145                        inner.for_each(|a| check(a, rows, Some(schema), problems));
146                    }
147                    None => add(problems, Problem::UnknownCollection(joined)),
148                }
149            }
150            None => call.condition.iter().chain(&call.args).for_each(|a| check(a, schema, parent, problems)),
151        },
152        Criteria::Value(_) => {}
153        Criteria::Unary(_, a) | Criteria::IsNull(a) => check(a, schema, parent, problems),
154        Criteria::Binary(_, a, b) => {
155            check(a, schema, parent, problems);
156            check(b, schema, parent, problems);
157        }
158        Criteria::Group(_, items) | Criteria::Function(_, items) => {
159            items.iter().for_each(|a| check(a, schema, parent, problems));
160        }
161        Criteria::In(a, items) => {
162            check(a, schema, parent, problems);
163            items.iter().for_each(|i| check(i, schema, parent, problems));
164        }
165        Criteria::Between(a, b, c) => {
166            check(a, schema, parent, problems);
167            check(b, schema, parent, problems);
168            check(c, schema, parent, problems);
169        }
170    }
171}