1use 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
11const SYNTHETIC_RELATIONS: &[&str] = &[
13 "observed_as_candidate",
14 "observed_as_selected",
15 "observed_as_target",
16 "observed_as_signal",
17];
18
19pub const MAX_DEPTH: usize = 10;
21
22pub fn validate(query: &mut GqlQuery) -> Result<(), QueryError> {
30 validate_with_warnings(query).map(|_| ())
31}
32
33pub fn validate_pattern_shape(elements: &[PatternElement]) -> Result<(), QueryError> {
39 if elements.is_empty() {
40 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
63pub 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 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 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 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 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;