1use super::ast::{ArithmeticExpr, Condition, DmlStatement, OrderByExpr, Query, SelectColumns};
8use super::error::{ParseError, ParseErrorKind};
9
10pub use super::validation_types::{
13 ComplexityStats, ValidationConfig, ValidationError, ValidationErrorKind,
14};
15
16pub struct QueryValidator;
25
26impl QueryValidator {
27 pub fn validate(query: &Query) -> Result<(), ValidationError> {
33 Self::validate_with_config(query, &ValidationConfig::default())
34 }
35
36 pub fn validate_with_config(
42 query: &Query,
43 config: &ValidationConfig,
44 ) -> Result<(), ValidationError> {
45 reject_subqueries(query)?;
48
49 if !requires_select_validation(query) {
51 return reject_let_on_non_select(query);
52 }
53
54 Self::validate_select(&query.select, config)?;
55
56 if let Some(ref compound) = query.compound {
57 for (_, right_select) in &compound.operations {
58 Self::validate_select(right_select, config)?;
59 }
60 }
61
62 Ok(())
63 }
64
65 fn validate_select(
67 stmt: &super::ast::SelectStatement,
68 config: &ValidationConfig,
69 ) -> Result<(), ValidationError> {
70 if let Some(ref condition) = stmt.where_clause {
71 Self::validate_condition(condition, stmt.limit, config)?;
72 }
73 Self::validate_similarity_context(stmt)?;
74 Self::validate_qualified_wildcards(stmt)?;
75 Self::validate_vector_group_by(stmt)?;
76 super::validation_fusion::validate_fusion(stmt)?;
77 stmt.where_clause.as_ref().map_or(Ok(()), |condition| {
78 super::validation_anchor::walk_graph_match_anchors(condition, &stmt.from_alias)
81 })
82 }
83
84 fn validate_similarity_context(
86 stmt: &super::ast::SelectStatement,
87 ) -> Result<(), ValidationError> {
88 let has_score_context = stmt
89 .where_clause
90 .as_ref()
91 .is_some_and(Self::has_score_producing_condition);
92
93 if !has_score_context && Self::select_uses_similarity(&stmt.columns) {
94 return Err(ValidationError::new(
95 ValidationErrorKind::SimilarityWithoutContext,
96 None,
97 "similarity()",
98 "Add a vector NEAR or similarity() predicate in WHERE to provide a score context",
99 ));
100 }
101
102 if let Some(ref order_by) = stmt.order_by {
103 for ob in order_by {
104 Self::validate_order_by_expr(&ob.expr, has_score_context)?;
105 }
106 }
107
108 Ok(())
109 }
110
111 fn validate_order_by_expr(
113 expr: &OrderByExpr,
114 has_score_context: bool,
115 ) -> Result<(), ValidationError> {
116 match expr {
117 OrderByExpr::SimilarityBare if !has_score_context => Err(ValidationError::new(
118 ValidationErrorKind::SimilarityWithoutContext,
119 None,
120 "ORDER BY similarity()",
121 "Add a vector NEAR or similarity() predicate in WHERE to provide a score context",
122 )),
123 OrderByExpr::Arithmetic(arith) => {
124 Self::validate_arithmetic_similarity(arith, has_score_context)
125 }
126 _ => Ok(()),
127 }
128 }
129
130 fn validate_arithmetic_similarity(
132 expr: &ArithmeticExpr,
133 has_score_context: bool,
134 ) -> Result<(), ValidationError> {
135 match expr {
136 ArithmeticExpr::Similarity(inner) => match inner.as_ref() {
137 OrderByExpr::Similarity(_) => Err(ValidationError::new(
138 ValidationErrorKind::UnsupportedArithmeticSimilarity,
139 None,
140 "similarity(field, $vec) in arithmetic",
141 "Use bare similarity() instead; parameterized similarity inside arithmetic is not yet supported",
142 )),
143 OrderByExpr::SimilarityBare if !has_score_context => Err(ValidationError::new(
144 ValidationErrorKind::SimilarityWithoutContext,
145 None,
146 "similarity() in arithmetic",
147 "Add a vector NEAR or similarity() predicate in WHERE to provide a score context",
148 )),
149 _ => Ok(()),
150 },
151 ArithmeticExpr::BinaryOp { left, right, .. } => {
152 Self::validate_arithmetic_similarity(left, has_score_context)?;
153 Self::validate_arithmetic_similarity(right, has_score_context)
154 }
155 ArithmeticExpr::Literal(_) | ArithmeticExpr::Variable(_) => Ok(()),
156 }
157 }
158
159 fn select_uses_similarity(columns: &SelectColumns) -> bool {
161 match columns {
162 SelectColumns::SimilarityScore(_) => true,
163 SelectColumns::Mixed {
164 similarity_scores, ..
165 } => !similarity_scores.is_empty(),
166 _ => false,
167 }
168 }
169
170 fn validate_qualified_wildcards(
172 stmt: &super::ast::SelectStatement,
173 ) -> Result<(), ValidationError> {
174 let aliases = &stmt.from_alias;
175 let from_name = &stmt.from;
176
177 let check_alias = |alias: &str| -> Result<(), ValidationError> {
178 let is_declared = aliases.iter().any(|a| a == alias) || alias == from_name;
179 if !is_declared {
180 return Err(ValidationError::new(
181 ValidationErrorKind::UndeclaredAlias,
182 None,
183 format!("{alias}.*"),
184 format!(
185 "Alias '{alias}' is not declared in FROM or JOIN. Use FROM ... AS {alias}"
186 ),
187 ));
188 }
189 Ok(())
190 };
191
192 match &stmt.columns {
193 SelectColumns::QualifiedWildcard(alias) => check_alias(alias)?,
194 SelectColumns::Mixed {
195 qualified_wildcards,
196 ..
197 } => {
198 for alias in qualified_wildcards {
199 check_alias(alias)?;
200 }
201 }
202 _ => {}
203 }
204
205 Ok(())
206 }
207
208 pub fn enforce_query_complexity(
214 query: &Query,
215 raw_query: &str,
216 config: &ValidationConfig,
217 ) -> Result<(), ParseError> {
218 if raw_query.len() > config.max_query_length {
219 return Err(Self::complexity_error(
220 config.max_query_length,
221 raw_query.chars().take(128).collect::<String>(),
222 "Query length",
223 config.max_query_length,
224 raw_query.len(),
225 ));
226 }
227
228 let stats = Self::analyze_query_complexity(query);
229 Self::check_limit(stats.ast_depth, config.max_ast_depth, "AST depth", "WHERE")?;
230 Self::check_limit(
231 stats.like_ilike_terms,
232 config.max_like_ilike_terms,
233 "LIKE/ILIKE budget",
234 "LIKE/ILIKE",
235 )?;
236 Self::check_limit_u32(
237 stats.max_graph_hops,
238 config.max_graph_expansion,
239 "Graph expansion",
240 "MATCH",
241 )?;
242
243 Ok(())
244 }
245
246 fn check_limit(
248 actual: usize,
249 max: usize,
250 label: &str,
251 context: &str,
252 ) -> Result<(), ParseError> {
253 if actual > max {
254 return Err(Self::complexity_error(0, context, label, max, actual));
255 }
256 Ok(())
257 }
258
259 fn check_limit_u32(
261 actual: u32,
262 max: u32,
263 label: &str,
264 context: &str,
265 ) -> Result<(), ParseError> {
266 if actual > max {
267 return Err(Self::complexity_error(
268 0,
269 context,
270 label,
271 max as usize,
272 actual as usize,
273 ));
274 }
275 Ok(())
276 }
277
278 fn complexity_error(
280 position: usize,
281 context: impl Into<String>,
282 label: &str,
283 max: usize,
284 actual: usize,
285 ) -> ParseError {
286 ParseError::new(
287 ParseErrorKind::ComplexityLimit,
288 position,
289 context,
290 format!("{label} exceeded: max={max}, actual={actual}"),
291 )
292 }
293
294 #[must_use]
295 pub fn analyze_query_complexity(query: &Query) -> ComplexityStats {
297 let mut stats = ComplexityStats {
298 ast_depth: 0,
299 like_ilike_terms: 0,
300 max_graph_hops: 0,
301 };
302
303 if let Some(ref condition) = query.select.where_clause {
304 let (depth, like_count) = Self::analyze_condition(condition);
305 stats.ast_depth = stats.ast_depth.max(depth);
306 stats.like_ilike_terms += like_count;
307 }
308
309 if let Some(ref compound) = query.compound {
310 for (_, right_select) in &compound.operations {
311 if let Some(ref condition) = right_select.where_clause {
312 let (depth, like_count) = Self::analyze_condition(condition);
313 stats.ast_depth = stats.ast_depth.max(depth);
314 stats.like_ilike_terms += like_count;
315 }
316 }
317 }
318
319 if let Some(ref m) = query.match_clause {
320 for rel in m.patterns.iter().flat_map(|p| p.relationships.iter()) {
321 if let Some((_, max)) = rel.range {
322 stats.max_graph_hops = stats.max_graph_hops.max(max);
323 }
324 }
325 }
326
327 stats
328 }
329
330 fn validate_condition(
331 condition: &Condition,
332 _limit: Option<u64>,
333 _config: &ValidationConfig,
334 ) -> Result<(), ValidationError> {
335 let similarity_count = Self::count_similarity_conditions(condition);
336 if similarity_count > 1 && Self::has_multiple_similarity_in_or(condition) {
337 return Err(ValidationError::multiple_similarity(
338 "Multiple similarity() in OR are not supported. Use AND instead.",
339 ));
340 }
341 Ok(())
342 }
343
344 fn analyze_condition(condition: &Condition) -> (usize, usize) {
345 match condition {
346 Condition::Like(_) => (1, 1),
347 Condition::And(l, r) | Condition::Or(l, r) => {
348 let (ld, ll) = Self::analyze_condition(l);
349 let (rd, rl) = Self::analyze_condition(r);
350 (1 + ld.max(rd), ll + rl)
351 }
352 Condition::Not(inner) | Condition::Group(inner) => {
353 let (d, l) = Self::analyze_condition(inner);
354 (1 + d, l)
355 }
356 _ => (1, 0),
357 }
358 }
359
360 fn has_score_producing_condition(condition: &Condition) -> bool {
363 match condition {
364 Condition::Similarity(_)
365 | Condition::VectorSearch(_)
366 | Condition::VectorFusedSearch(_)
367 | Condition::SparseVectorSearch(_) => true,
368 Condition::And(l, r) | Condition::Or(l, r) => {
369 Self::has_score_producing_condition(l) || Self::has_score_producing_condition(r)
370 }
371 Condition::Not(inner) | Condition::Group(inner) => {
372 Self::has_score_producing_condition(inner)
373 }
374 _ => false,
375 }
376 }
377
378 pub(crate) fn count_similarity_conditions(condition: &Condition) -> usize {
379 match condition {
380 Condition::Similarity(_)
381 | Condition::VectorSearch(_)
382 | Condition::VectorFusedSearch(_) => 1,
383 Condition::And(l, r) | Condition::Or(l, r) => {
384 Self::count_similarity_conditions(l) + Self::count_similarity_conditions(r)
385 }
386 Condition::Not(inner) | Condition::Group(inner) => {
387 Self::count_similarity_conditions(inner)
388 }
389 _ => 0,
390 }
391 }
392
393 #[cfg(test)]
394 pub(crate) fn contains_similarity(condition: &Condition) -> bool {
395 Self::count_similarity_conditions(condition) > 0
396 }
397
398 #[cfg(test)]
399 pub(crate) fn has_not_similarity(condition: &Condition) -> bool {
400 match condition {
401 Condition::Not(inner) => Self::contains_similarity(inner),
402 Condition::And(l, r) | Condition::Or(l, r) => {
403 Self::has_not_similarity(l) || Self::has_not_similarity(r)
404 }
405 Condition::Group(inner) => Self::has_not_similarity(inner),
406 _ => false,
407 }
408 }
409
410 fn has_multiple_similarity_in_or(condition: &Condition) -> bool {
411 match condition {
412 Condition::Or(l, r) => {
413 Self::count_similarity_conditions(l) > 0 && Self::count_similarity_conditions(r) > 0
414 || Self::has_multiple_similarity_in_or(l)
415 || Self::has_multiple_similarity_in_or(r)
416 }
417 Condition::And(l, r) => {
418 Self::has_multiple_similarity_in_or(l) || Self::has_multiple_similarity_in_or(r)
419 }
420 Condition::Not(inner) | Condition::Group(inner) => {
421 Self::has_multiple_similarity_in_or(inner)
422 }
423 _ => false,
424 }
425 }
426
427 fn validate_vector_group_by(stmt: &super::ast::SelectStatement) -> Result<(), ValidationError> {
432 let aggregations = Self::collect_aggregations(&stmt.columns);
433 let has_group_by = stmt.group_by.is_some();
434 let has_vector_near = stmt
435 .where_clause
436 .as_ref()
437 .is_some_and(super::ast::condition::Condition::has_vector_search);
438
439 for agg in &aggregations {
440 Self::validate_single_aggregate(agg, has_group_by, has_vector_near)?;
441 }
442 Ok(())
443 }
444
445 fn validate_single_aggregate(
447 agg: &super::ast::AggregateFunction,
448 has_group_by: bool,
449 has_vector_near: bool,
450 ) -> Result<(), ValidationError> {
451 if matches!(agg.function_type, super::ast::AggregateType::First) && !has_group_by {
452 return Err(ValidationError::new(
453 ValidationErrorKind::InvalidLetBinding,
454 None,
455 "FIRST()",
456 "FIRST() aggregate function requires a GROUP BY clause",
457 ));
458 }
459 if has_group_by && Self::is_score_column(&agg.argument) && !has_vector_near {
462 let fn_name = format!("{:?}(score)", agg.function_type);
463 let msg = format!(
464 "{}(score) requires a vector NEAR search in the WHERE clause",
465 format!("{:?}", agg.function_type).to_uppercase()
466 );
467 return Err(ValidationError::new(
468 ValidationErrorKind::SimilarityWithoutContext,
469 None,
470 fn_name,
471 msg,
472 ));
473 }
474 Ok(())
475 }
476
477 fn is_score_column(arg: &super::ast::AggregateArg) -> bool {
479 matches!(arg, super::ast::AggregateArg::Score)
480 || matches!(arg, super::ast::AggregateArg::Column(col) if col.eq_ignore_ascii_case("score"))
481 }
482
483 fn collect_aggregations(columns: &SelectColumns) -> Vec<super::ast::AggregateFunction> {
485 match columns {
486 SelectColumns::Aggregations(aggs) => aggs.clone(),
487 SelectColumns::Mixed { aggregations, .. } => aggregations.clone(),
488 _ => Vec::new(),
489 }
490 }
491}
492
493fn requires_select_validation(query: &Query) -> bool {
498 !query.is_ddl_query()
499 && !query.is_dml_query()
500 && !query.is_train()
501 && !query.is_introspection_query()
502 && !query.is_admin_query()
503}
504
505fn reject_subqueries(query: &Query) -> Result<(), ValidationError> {
513 let in_where = where_clauses(query)
514 .iter()
515 .any(|(scope, cond)| cond.has_correlated_subquery(scope));
516 if in_where || query.has_correlated_having_subquery() {
517 return Err(ValidationError::new(
518 ValidationErrorKind::SubqueryNotExecutable,
519 None,
520 "subquery",
521 "correlated subqueries (referencing an outer column) are not supported; \
522 rewrite the predicate without a reference to the outer query",
523 ));
524 }
525 Ok(())
526}
527
528fn where_clauses(query: &Query) -> Vec<(Vec<&str>, &Condition)> {
533 let mut clauses: Vec<(Vec<&str>, &Condition)> = query
534 .select
535 .where_clause
536 .as_ref()
537 .map(|c| (query.select.outer_table_scope(), c))
538 .into_iter()
539 .collect();
540 if let Some(ref compound) = query.compound {
541 clauses.extend(compound.operations.iter().filter_map(|(_, stmt)| {
542 stmt.where_clause
543 .as_ref()
544 .map(|c| (stmt.outer_table_scope(), c))
545 }));
546 }
547 clauses.extend(dml_where_clauses(query.dml.as_ref()));
548 clauses
549}
550
551fn dml_where_clauses(dml: Option<&DmlStatement>) -> Vec<(Vec<&str>, &Condition)> {
554 match dml {
555 Some(DmlStatement::Update(u)) => u
556 .where_clause
557 .iter()
558 .map(|c| (vec![u.table.as_str()], c))
559 .collect(),
560 Some(DmlStatement::Delete(d)) => vec![(vec![d.table.as_str()], &d.where_clause)],
561 Some(DmlStatement::SelectEdges(s)) => s
562 .where_clause
563 .iter()
564 .map(|c| (vec![s.collection.as_str()], c))
565 .collect(),
566 _ => Vec::new(),
567 }
568}
569
570fn reject_let_on_non_select(query: &Query) -> Result<(), ValidationError> {
572 if query.let_bindings.is_empty() {
573 Ok(())
574 } else {
575 Err(ValidationError::new(
576 ValidationErrorKind::InvalidLetBinding,
577 None,
578 "LET clause",
579 "LET bindings are not supported with DDL, DML, introspection, or admin statements",
580 ))
581 }
582}