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_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 {
202 PropertyRef::Field(property) if property == "namespace" => Err(QueryError::Validation(
203 "namespace is set by CompileOptions, not query text".into(),
204 )),
205 PropertyRef::Field(property) if property == "kind" && !is_edge => Ok(()),
206 PropertyRef::Field(property) if property == "relation" && 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
216 | CompareOp::StartsWith
217 | CompareOp::IsNotNull
218 | CompareOp::IsNull
219 ) {
220 return Ok(());
221 }
222 match &mut cond.value {
223 ConditionValue::String(s) => normalize(s)?,
224 ConditionValue::List(values) => {
225 for value in values {
226 match value {
227 ConditionValue::String(s) => normalize(s)?,
228 _ => {
229 return Err(QueryError::Validation(
230 "relation IN list values must be strings".into(),
231 ));
232 }
233 }
234 }
235 }
236 _ => {}
237 }
238 Ok(())
239 }
240 _ => Ok(()),
241 }
242}
243
244#[cfg(test)]
245#[path = "validate_tests.rs"]
246mod tests;