Skip to main content

khive_query/
validate.rs

1//! AST validation and relation normalization.
2
3use std::collections::HashSet;
4use std::str::FromStr;
5
6use khive_types::EdgeRelation;
7
8use crate::ast::{CompareOp, Condition, ConditionValue, GqlQuery, PatternElement, PropertyRef};
9use crate::error::QueryError;
10
11/// Closed synthetic relation set handled outside the canonical edge enum.
12pub(crate) const SYNTHETIC_RELATIONS: &[&str] = &[
13    "observed_as_candidate",
14    "observed_as_selected",
15    "observed_as_target",
16    "observed_as_signal",
17];
18
19/// Maximum accepted traversal depth, in hops.
20pub const MAX_DEPTH: usize = 10;
21
22/// Validates and normalizes `query` in place.
23///
24/// # Errors
25///
26/// Returns [`QueryError::Validation`] for structural or taxonomy violations and
27/// [`QueryError::InvalidInput`] for hop bounds above [`MAX_DEPTH`].
28/// See `crates/khive-query/docs/api/validation.md` for the full rule set.
29pub fn validate(query: &mut GqlQuery) -> Result<(), QueryError> {
30    validate_with_warnings(query).map(|_| ())
31}
32
33/// Validates that a non-empty pattern alternates node/edge/node.
34///
35/// # Errors
36///
37/// Returns [`QueryError::Validation`] for an even-length or misordered pattern.
38pub fn validate_pattern_shape(elements: &[PatternElement]) -> Result<(), QueryError> {
39    if elements.is_empty() {
40        // Compilation owns the more specific empty-pattern diagnostic.
41        return Ok(());
42    }
43    if elements.len().is_multiple_of(2) {
44        return Err(QueryError::Validation(
45            "pattern must alternate Node, Edge, Node, … (even element count is invalid)".into(),
46        ));
47    }
48    for (i, element) in elements.iter().enumerate() {
49        match (i % 2, element) {
50            (0, PatternElement::Node(_)) => {}
51            (1, PatternElement::Edge(_)) => {}
52            _ => {
53                return Err(QueryError::Validation(
54                    "pattern must alternate Node, Edge, Node, … (wrong element type at position)"
55                        .into(),
56                ))
57            }
58        }
59    }
60    Ok(())
61}
62
63/// Validates and normalizes `query`, returning non-fatal diagnostics.
64///
65/// # Errors
66///
67/// Returns the same errors as [`validate`].
68/// See `crates/khive-query/docs/api/validation.md` for mutation and warning behavior.
69pub fn validate_with_warnings(query: &mut GqlQuery) -> Result<Vec<String>, QueryError> {
70    let warnings: Vec<String> = Vec::new();
71
72    validate_pattern_shape(&query.pattern.elements)?;
73
74    // Repeated bindings require alias-equality SQL that is not yet representable.
75    let mut seen_node_vars: HashSet<&str> = HashSet::new();
76    let mut seen_edge_vars: HashSet<&str> = HashSet::new();
77    for element in &query.pattern.elements {
78        match element {
79            PatternElement::Node(node) => {
80                if let Some(var) = node.variable.as_deref() {
81                    if seen_edge_vars.contains(var) {
82                        return Err(QueryError::Validation(format!(
83                            "variable '{var}' cannot bind both a node and an edge"
84                        )));
85                    }
86                    if !seen_node_vars.insert(var) {
87                        return Err(QueryError::Unsupported(format!(
88                            "repeated node variable '{var}' (cycle / self-reachability \
89                             requires alias-equality predicates not yet implemented)"
90                        )));
91                    }
92                }
93            }
94            PatternElement::Edge(edge) => {
95                if let Some(var) = edge.variable.as_deref() {
96                    if seen_node_vars.contains(var) {
97                        return Err(QueryError::Validation(format!(
98                            "variable '{var}' cannot bind both a node and an edge"
99                        )));
100                    }
101                    if !seen_edge_vars.insert(var) {
102                        return Err(QueryError::Unsupported(format!(
103                            "repeated edge variable '{var}' not supported"
104                        )));
105                    }
106                }
107            }
108        }
109    }
110
111    for element in &mut query.pattern.elements {
112        match element {
113            PatternElement::Node(node) => {
114                if node.properties.contains_key("namespace") {
115                    return Err(QueryError::Validation(
116                        "namespace is set by CompileOptions, not query text".into(),
117                    ));
118                }
119            }
120            PatternElement::Edge(edge) => {
121                for relation in edge.relations.iter_mut() {
122                    // Synthetic projections are closed but intentionally outside EdgeRelation.
123                    if relation.starts_with("observed_as_") {
124                        if !SYNTHETIC_RELATIONS.contains(&relation.as_str()) {
125                            return Err(QueryError::Validation(format!(
126                                "unknown synthetic relation '{relation}'; valid synthetic relations: {}",
127                                SYNTHETIC_RELATIONS.join(", ")
128                            )));
129                        }
130                        continue;
131                    }
132                    let parsed = EdgeRelation::from_str(relation)
133                        .map_err(|err| QueryError::Validation(err.to_string()))?;
134                    *relation = parsed.as_str().to_string();
135                }
136                if edge.min_hops == 0 {
137                    return Err(QueryError::Unsupported(
138                        "zero-hop ranges (min_hops = 0) not yet supported; \
139                         use a minimum of 1 hop"
140                            .into(),
141                    ));
142                }
143                // Never rewrite inverted ranges; doing so changes query semantics.
144                if edge.min_hops > edge.max_hops {
145                    return Err(QueryError::Validation(format!(
146                        "invalid hop range: min {} > max {}",
147                        edge.min_hops, edge.max_hops
148                    )));
149                }
150                if edge.min_hops > MAX_DEPTH {
151                    return Err(QueryError::Unsupported(format!(
152                        "minimum hop count {} exceeds depth cap {}",
153                        edge.min_hops, MAX_DEPTH
154                    )));
155                }
156                if edge.max_hops > MAX_DEPTH {
157                    return Err(QueryError::InvalidInput(format!(
158                        "max_hops {} exceeds the depth cap of {}; reduce the range or use a smaller bound",
159                        edge.max_hops, MAX_DEPTH
160                    )));
161                }
162            }
163        }
164    }
165
166    // Taxonomy-sensitive property names apply only to their matching binding kind.
167    let mut var_kinds: std::collections::HashMap<&str, VarKind> = std::collections::HashMap::new();
168    for element in &query.pattern.elements {
169        match element {
170            PatternElement::Node(n) => {
171                if let Some(v) = n.variable.as_deref() {
172                    var_kinds.insert(v, VarKind::Node);
173                }
174            }
175            PatternElement::Edge(e) => {
176                if let Some(v) = e.variable.as_deref() {
177                    var_kinds.insert(v, VarKind::Edge);
178                }
179            }
180        }
181    }
182
183    let mut validate_err: Option<QueryError> = None;
184    query.where_clause.for_each_condition_mut(&mut |cond| {
185        if validate_err.is_some() {
186            return;
187        }
188        let is_edge = var_kinds
189            .get(cond.variable.as_str())
190            .copied()
191            .unwrap_or(VarKind::Node)
192            == VarKind::Edge;
193        if let Err(e) = validate_condition(cond, is_edge) {
194            validate_err = Some(e);
195        }
196    });
197    if let Some(e) = validate_err {
198        return Err(e);
199    }
200
201    Ok(warnings)
202}
203
204#[derive(Clone, Copy, PartialEq, Eq)]
205enum VarKind {
206    Node,
207    Edge,
208}
209
210fn validate_condition(cond: &mut Condition, is_edge: bool) -> Result<(), QueryError> {
211    match &cond.property {
212        PropertyRef::Field(property) if property == "namespace" => Err(QueryError::Validation(
213            "namespace is set by CompileOptions, not query text".into(),
214        )),
215        PropertyRef::Field(property) if property == "kind" && !is_edge => Ok(()),
216        PropertyRef::Field(property) if property == "relation" && is_edge => {
217            let normalize = |s: &mut String| -> Result<(), QueryError> {
218                let parsed = EdgeRelation::from_str(s)
219                    .map_err(|err| QueryError::Validation(err.to_string()))?;
220                *s = parsed.as_str().to_string();
221                Ok(())
222            };
223            if matches!(
224                cond.op,
225                CompareOp::Contains
226                    | CompareOp::StartsWith
227                    | CompareOp::IsNotNull
228                    | CompareOp::IsNull
229            ) {
230                return Ok(());
231            }
232            match &mut cond.value {
233                ConditionValue::String(s) => normalize(s)?,
234                ConditionValue::List(values) => {
235                    for value in values {
236                        match value {
237                            ConditionValue::String(s) => normalize(s)?,
238                            _ => {
239                                return Err(QueryError::Validation(
240                                    "relation IN list values must be strings".into(),
241                                ));
242                            }
243                        }
244                    }
245                }
246                _ => {}
247            }
248            Ok(())
249        }
250        _ => Ok(()),
251    }
252}
253
254#[cfg(test)]
255#[path = "validate_tests.rs"]
256mod tests;