1use std::collections::HashSet;
4use std::str::FromStr;
5
6use khive_types::EdgeRelation;
7
8use crate::ast::{CompareOp, Condition, ConditionValue, GqlQuery, PatternElement};
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_node_vars.insert(var) {
82 return Err(QueryError::Unsupported(format!(
83 "repeated node variable '{var}' (cycle / self-reachability \
84 requires alias-equality predicates not yet implemented)"
85 )));
86 }
87 }
88 }
89 PatternElement::Edge(edge) => {
90 if let Some(var) = edge.variable.as_deref() {
91 if !seen_edge_vars.insert(var) {
92 return Err(QueryError::Unsupported(format!(
93 "repeated edge variable '{var}' not supported"
94 )));
95 }
96 }
97 }
98 }
99 }
100
101 for element in &mut query.pattern.elements {
102 match element {
103 PatternElement::Node(node) => {
104 if node.properties.contains_key("namespace") {
105 return Err(QueryError::Validation(
106 "namespace is set by CompileOptions, not query text".into(),
107 ));
108 }
109 }
110 PatternElement::Edge(edge) => {
111 for relation in edge.relations.iter_mut() {
112 if relation.starts_with("observed_as_") {
114 if !SYNTHETIC_RELATIONS.contains(&relation.as_str()) {
115 return Err(QueryError::Validation(format!(
116 "unknown synthetic relation '{relation}'; valid synthetic relations: {}",
117 SYNTHETIC_RELATIONS.join(", ")
118 )));
119 }
120 continue;
121 }
122 let parsed = EdgeRelation::from_str(relation)
123 .map_err(|err| QueryError::Validation(err.to_string()))?;
124 *relation = parsed.as_str().to_string();
125 }
126 if edge.min_hops == 0 {
127 return Err(QueryError::Unsupported(
128 "zero-hop ranges (min_hops = 0) not yet supported; \
129 use a minimum of 1 hop"
130 .into(),
131 ));
132 }
133 if edge.min_hops > edge.max_hops {
135 return Err(QueryError::Validation(format!(
136 "invalid hop range: min {} > max {}",
137 edge.min_hops, edge.max_hops
138 )));
139 }
140 if edge.min_hops > MAX_DEPTH {
141 return Err(QueryError::Unsupported(format!(
142 "minimum hop count {} exceeds depth cap {}",
143 edge.min_hops, MAX_DEPTH
144 )));
145 }
146 if edge.max_hops > MAX_DEPTH {
147 return Err(QueryError::InvalidInput(format!(
148 "max_hops {} exceeds the depth cap of {}; reduce the range or use a smaller bound",
149 edge.max_hops, MAX_DEPTH
150 )));
151 }
152 }
153 }
154 }
155
156 let mut var_kinds: std::collections::HashMap<&str, VarKind> = std::collections::HashMap::new();
158 for element in &query.pattern.elements {
159 match element {
160 PatternElement::Node(n) => {
161 if let Some(v) = n.variable.as_deref() {
162 var_kinds.insert(v, VarKind::Node);
163 }
164 }
165 PatternElement::Edge(e) => {
166 if let Some(v) = e.variable.as_deref() {
167 var_kinds.insert(v, VarKind::Edge);
168 }
169 }
170 }
171 }
172
173 let mut validate_err: Option<QueryError> = None;
174 query.where_clause.for_each_condition_mut(&mut |cond| {
175 if validate_err.is_some() {
176 return;
177 }
178 let is_edge = var_kinds
179 .get(cond.variable.as_str())
180 .copied()
181 .unwrap_or(VarKind::Node)
182 == VarKind::Edge;
183 if let Err(e) = validate_condition(cond, is_edge) {
184 validate_err = Some(e);
185 }
186 });
187 if let Some(e) = validate_err {
188 return Err(e);
189 }
190
191 Ok(warnings)
192}
193
194#[derive(Clone, Copy, PartialEq, Eq)]
195enum VarKind {
196 Node,
197 Edge,
198}
199
200fn validate_condition(cond: &mut Condition, is_edge: bool) -> Result<(), QueryError> {
201 match cond.property.as_str() {
202 "namespace" => Err(QueryError::Validation(
203 "namespace is set by CompileOptions, not query text".into(),
204 )),
205 "kind" if !is_edge => Ok(()),
206 "relation" if is_edge => {
207 let normalize = |s: &mut String| -> Result<(), QueryError> {
208 let parsed = EdgeRelation::from_str(s)
209 .map_err(|err| QueryError::Validation(err.to_string()))?;
210 *s = parsed.as_str().to_string();
211 Ok(())
212 };
213 if matches!(
214 cond.op,
215 CompareOp::Contains | CompareOp::StartsWith | CompareOp::IsNotNull
216 ) {
217 return Ok(());
218 }
219 match &mut cond.value {
220 ConditionValue::String(s) => normalize(s)?,
221 ConditionValue::List(values) => {
222 for value in values {
223 match value {
224 ConditionValue::String(s) => normalize(s)?,
225 _ => {
226 return Err(QueryError::Validation(
227 "relation IN list values must be strings".into(),
228 ));
229 }
230 }
231 }
232 }
233 _ => {}
234 }
235 Ok(())
236 }
237 _ => Ok(()),
238 }
239}
240
241#[cfg(test)]
242#[path = "validate_tests.rs"]
243mod tests;