Skip to main content

velesdb_core/velesql/
validation.rs

1//! Query validation for VelesQL (EPIC-044 US-007).
2//!
3//! Type definitions (`ValidationError`, `ValidationErrorKind`, `ValidationConfig`,
4//! `ComplexityStats`) live in `validation_types.rs` and the V011 MATCH anchor
5//! rule in `validation_anchor.rs`, to keep each file under the 500 NLOC limit.
6
7use super::ast::{ArithmeticExpr, Condition, DmlStatement, OrderByExpr, Query, SelectColumns};
8use super::error::{ParseError, ParseErrorKind};
9
10// Re-export types so that existing `use crate::velesql::validation::*` paths
11// continue to work without changes.
12pub use super::validation_types::{
13    ComplexityStats, ValidationConfig, ValidationError, ValidationErrorKind,
14};
15
16/// Stateless validator for VelesQL semantic and complexity checks.
17///
18/// Performs validation passes on parsed [`Query`] ASTs:
19/// - LET binding legality (DDL/DML/admin cannot use LET)
20/// - Similarity context (similarity() requires a score-producing WHERE clause)
21/// - Qualified wildcard alias resolution
22/// - Compound query validation across UNION/INTERSECT/EXCEPT operands
23/// - Complexity budget enforcement (AST depth, LIKE/ILIKE terms, graph hops)
24pub struct QueryValidator;
25
26impl QueryValidator {
27    /// Validates a query with default configuration.
28    ///
29    /// # Errors
30    ///
31    /// Returns `ValidationError` if the query fails semantic validation.
32    pub fn validate(query: &Query) -> Result<(), ValidationError> {
33        Self::validate_with_config(query, &ValidationConfig::default())
34    }
35
36    /// Validates a query with custom semantic configuration.
37    ///
38    /// # Errors
39    ///
40    /// Returns `ValidationError` if the query fails semantic validation.
41    pub fn validate_with_config(
42        query: &Query,
43        config: &ValidationConfig,
44    ) -> Result<(), ValidationError> {
45        // Subqueries parse but are not executable; reject them in any WHERE
46        // clause (SELECT, compound operands, or DML) before further validation.
47        reject_subqueries(query)?;
48
49        // Non-SELECT statements: only check LET bindings.
50        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    /// Validates a single SELECT statement (main or compound operand).
66    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            // V011 anchor rule (explicit and implicit binding, guards
78            // G1/G2/G3) lives in `validation_anchor.rs`.
79            super::validation_anchor::walk_graph_match_anchors(condition, &stmt.from_alias)
80        })
81    }
82
83    /// Validates that `similarity()` in SELECT or ORDER BY has a score context.
84    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    /// Validates a single ORDER BY expression for similarity context issues.
111    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    /// Recursively validates similarity() usage inside arithmetic expressions.
130    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    /// Returns true if `SelectColumns` references `similarity()`.
159    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    /// Validates that qualified wildcard aliases are declared in FROM/JOIN.
170    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    /// Enforces complexity budgets and returns parse errors on overflow.
208    ///
209    /// # Errors
210    ///
211    /// Returns `ParseError` if the query exceeds configured complexity limits.
212    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    /// Returns a complexity-limit error when `actual > max`.
246    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    /// Returns a complexity-limit error when `actual > max` (u32 variant).
259    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    /// Builds a [`ParseError`] for a complexity-limit violation.
278    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    /// Extracts complexity statistics from a parsed query.
295    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    /// Returns true if the condition contains any score-producing search
360    /// (vector, similarity, fused, or sparse).
361    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    /// Validates vector-search GROUP BY semantic constraints.
427    ///
428    /// - `FIRST(column)` requires GROUP BY
429    /// - `MAX(score)` / `AVG(score)` requires vector NEAR in WHERE
430    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    /// Validates a single aggregate function for vector GROUP BY constraints.
445    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        // MAX(score)/AVG(score) without NEAR is only an error when GROUP BY is present,
459        // because without GROUP BY, "score" is treated as a regular payload field.
460        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    /// Returns `true` if the argument references the score pseudo-column.
477    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    /// Collects aggregate functions from `SelectColumns`.
483    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
492/// Returns `true` if the query requires SELECT-specific validation passes.
493///
494/// DDL, DML, introspection, admin, and TRAIN statements bypass SELECT
495/// validation (no FROM clause, no similarity conditions, etc.).
496fn 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
504/// Rejects subqueries appearing in any WHERE or HAVING clause of the query.
505///
506/// Subqueries are parsed but not yet executed: left unchecked, a predicate like
507/// `WHERE price > (SELECT AVG(price) FROM products)` silently evaluates to NULL,
508/// yielding wrong/empty results (or a silently no-op UPDATE) instead of an error.
509/// `HAVING COUNT(*) > (SELECT ...)` would likewise silently filter every group.
510fn 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
526/// Collects every WHERE clause in the query: the main SELECT, compound
527/// operands (UNION/INTERSECT/EXCEPT), and DML statements (UPDATE/DELETE/SELECT EDGES).
528fn 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
542/// Returns the optional WHERE clauses carried by a DML statement.
543fn 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
552/// Returns `Ok(())` if there are no LET bindings, otherwise rejects.
553fn 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}