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