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        super::validation_fusion::validate_fusion(stmt)?;
77        stmt.where_clause.as_ref().map_or(Ok(()), |condition| {
78            // V011 anchor rule (explicit and implicit binding, guards
79            // G1/G2/G3) lives in `validation_anchor.rs`.
80            super::validation_anchor::walk_graph_match_anchors(condition, &stmt.from_alias)
81        })
82    }
83
84    /// Validates that `similarity()` in SELECT or ORDER BY has a score context.
85    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    /// Validates a single ORDER BY expression for similarity context issues.
112    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    /// Recursively validates similarity() usage inside arithmetic expressions.
131    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    /// Returns true if `SelectColumns` references `similarity()`.
160    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    /// Validates that qualified wildcard aliases are declared in FROM/JOIN.
171    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    /// Enforces complexity budgets and returns parse errors on overflow.
209    ///
210    /// # Errors
211    ///
212    /// Returns `ParseError` if the query exceeds configured complexity limits.
213    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    /// Returns a complexity-limit error when `actual > max`.
247    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    /// Returns a complexity-limit error when `actual > max` (u32 variant).
260    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    /// Builds a [`ParseError`] for a complexity-limit violation.
279    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    /// Extracts complexity statistics from a parsed query.
296    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    /// Returns true if the condition contains any score-producing search
361    /// (vector, similarity, fused, or sparse).
362    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    /// Validates vector-search GROUP BY semantic constraints.
428    ///
429    /// - `FIRST(column)` requires GROUP BY
430    /// - `MAX(score)` / `AVG(score)` requires vector NEAR in WHERE
431    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    /// Validates a single aggregate function for vector GROUP BY constraints.
446    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        // MAX(score)/AVG(score) without NEAR is only an error when GROUP BY is present,
460        // because without GROUP BY, "score" is treated as a regular payload field.
461        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    /// Returns `true` if the argument references the score pseudo-column.
478    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    /// Collects aggregate functions from `SelectColumns`.
484    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
493/// Returns `true` if the query requires SELECT-specific validation passes.
494///
495/// DDL, DML, introspection, admin, and TRAIN statements bypass SELECT
496/// validation (no FROM clause, no similarity conditions, etc.).
497fn 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
505/// Rejects **correlated** subqueries in any WHERE or HAVING clause.
506///
507/// Scalar (non-correlated) subqueries are now executed and substituted as
508/// literals before validation runs (EPIC-039), so a well-formed predicate like
509/// `WHERE price > (SELECT AVG(price) FROM products)` is accepted. A *correlated*
510/// subquery (one referencing an outer column) is not yet executable and is
511/// rejected here so it never silently evaluates to NULL.
512fn 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
528/// Collects every WHERE clause in the query — the main SELECT, compound operands
529/// (UNION/INTERSECT/EXCEPT), and DML statements (UPDATE/DELETE/SELECT EDGES) —
530/// each paired with its **outer-table scope** (the names a nested subquery would
531/// have to reference to be correlated).
532fn 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
551/// Returns the WHERE clauses carried by a DML statement, each paired with the
552/// target table as its outer-table scope.
553fn 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
570/// Returns `Ok(())` if there are no LET bindings, otherwise rejects.
571fn 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}