Skip to main content

velesdb_core/database/
query_engine.rs

1//! Query execution: `execute_query`, `explain_query`, `explain_analyze_query`, plan caching, and DML dispatch.
2
3use crate::observer::{AccessDecision, AccessScope, QueryAccessContext, QueryOperationKind};
4use crate::velesql::{
5    ActualStats, AdminStatement, Condition, DdlStatement, DmlStatement, ExplainOutput,
6    IntrospectionStatement, Query, TrainStatement,
7};
8use crate::{Error, Result, SearchResult};
9
10use super::Database;
11
12/// Statement type classification for dispatch routing.
13enum StatementType<'a> {
14    Admin(&'a AdminStatement),
15    Introspection(&'a IntrospectionStatement),
16    Ddl(&'a DdlStatement),
17    Train(&'a TrainStatement),
18    Dml(&'a DmlStatement),
19    Match,
20    Select,
21}
22
23/// Classifies a query into its statement type for routing.
24fn classify_statement(query: &Query) -> StatementType<'_> {
25    if let Some(admin) = query.admin.as_ref() {
26        return StatementType::Admin(admin);
27    }
28    if let Some(intro) = query.introspection.as_ref() {
29        return StatementType::Introspection(intro);
30    }
31    if let Some(ddl) = query.ddl.as_ref() {
32        return StatementType::Ddl(ddl);
33    }
34    if let Some(train) = query.train.as_ref() {
35        return StatementType::Train(train);
36    }
37    if let Some(dml) = query.dml.as_ref() {
38        return StatementType::Dml(dml);
39    }
40    if query.is_match_query() {
41        return StatementType::Match;
42    }
43    StatementType::Select
44}
45
46/// Returns `true` if `cond` (or any nested sub-condition) contains a full-text
47/// `MATCH` search.
48///
49/// Mirrors [`Condition::has_vector_search`] but for the BM25/full-text path.
50/// `CONTAINS_TEXT` is intentionally excluded: it is a strict substring metadata
51/// filter, not a scored full-text search.
52fn condition_has_text_search(cond: &Condition) -> bool {
53    match cond {
54        Condition::Match(_) => true,
55        Condition::And(left, right) | Condition::Or(left, right) => {
56            condition_has_text_search(left) || condition_has_text_search(right)
57        }
58        Condition::Group(inner) | Condition::Not(inner) => condition_has_text_search(inner),
59        _ => false,
60    }
61}
62
63/// Derives the [`QueryOperationKind`] for a resolved query.
64///
65/// MATCH queries are graph traversals. Otherwise the WHERE clause is inspected:
66/// vector + text ⇒ hybrid, vector only ⇒ vector, text only ⇒ text, neither ⇒
67/// a plain relational SELECT.
68fn derive_operation_kind(query: &Query) -> QueryOperationKind {
69    if query.is_match_query() {
70        return QueryOperationKind::GraphTraversal;
71    }
72    let where_clause = query.select.where_clause.as_ref();
73    let has_vector = where_clause.is_some_and(Condition::has_vector_search);
74    let has_text = where_clause.is_some_and(condition_has_text_search);
75    match (has_vector, has_text) {
76        (true, true) => QueryOperationKind::HybridSearch,
77        (true, false) => QueryOperationKind::VectorSearch,
78        (false, true) => QueryOperationKind::TextSearch,
79        (false, false) => QueryOperationKind::Select,
80    }
81}
82
83impl<'a> QueryAccessContext<'a> {
84    /// Builds a read-path [`QueryAccessContext`] from a resolved query.
85    ///
86    /// Borrows the collection name from `query.select.from` and derives the
87    /// [`QueryOperationKind`] from the query shape. `principal` and
88    /// `tenant_hint` are left unset here: they are opaque, caller-supplied
89    /// hints that core never derives from the query itself, so callers that
90    /// have them populate the context after construction.
91    #[must_use]
92    pub fn from_query(query: &'a Query) -> Self {
93        Self {
94            collection: query.select.from.as_str(),
95            operation: derive_operation_kind(query),
96            principal: None,
97            tenant_hint: None,
98        }
99    }
100}
101
102impl Database {
103    /// AND-composes a control-plane [`AccessScope`]'s filter into a query's
104    /// WHERE clause, returning a narrowed clone (Requirement 1.5).
105    ///
106    /// The scope filter is combined with any existing WHERE predicate via
107    /// [`Condition::And`], with the pre-existing predicate as the left operand
108    /// so it is never rewritten or removed — the composition can only *narrow*
109    /// the result set, never widen it. When the query has no WHERE clause, the
110    /// scope filter becomes the WHERE clause. A scope with no filter returns an
111    /// unmodified clone.
112    ///
113    /// `scope.tenant` is deliberately **not** turned into a data-plane
114    /// predicate here: it is an opaque hint the observer/adapter layer records
115    /// and forwards for audit and routing, kept policy-free in core.
116    #[must_use]
117    pub fn apply_scope(query: &Query, scope: &AccessScope) -> Query {
118        let mut narrowed = query.clone();
119        if let Some(filter) = scope.filter.clone() {
120            let scoped_where = match narrowed.select.where_clause.take() {
121                Some(existing) => Condition::And(Box::new(existing), Box::new(filter)),
122                None => filter,
123            };
124            narrowed.select.where_clause = Some(scoped_where);
125        }
126        narrowed
127    }
128
129    /// Produces a canonical JSON string for a `serde_json::Value`.
130    ///
131    /// Recursively sorts the keys of every JSON object so that two values
132    /// representing the same logical structure always produce identical bytes,
133    /// regardless of the `HashMap` iteration order used during serialization.
134    ///
135    /// This is required because `FusionConfig::params` and
136    /// `TrainStatement::params` are `HashMap`-backed; `serde_json` serialises
137    /// them in hash-order, which is non-deterministic across invocations.
138    fn canonical_json(value: serde_json::Value) -> serde_json::Value {
139        match value {
140            serde_json::Value::Object(map) => {
141                // Without the `preserve_order` feature flag, `serde_json::Map` is already
142                // backed by `BTreeMap` and therefore already sorted. This explicit sort
143                // step is kept as defense-in-depth: if `preserve_order` is ever enabled
144                // in `Cargo.toml` (which switches the backing store to `IndexMap` and
145                // preserves insertion order), the canonical key ordering is still upheld
146                // without any change to this function.
147                let sorted: serde_json::Map<String, serde_json::Value> = map
148                    .into_iter()
149                    .map(|(k, v)| (k, Self::canonical_json(v)))
150                    .collect::<std::collections::BTreeMap<_, _>>()
151                    .into_iter()
152                    .collect();
153                serde_json::Value::Object(sorted)
154            }
155            serde_json::Value::Array(arr) => {
156                serde_json::Value::Array(arr.into_iter().map(Self::canonical_json).collect())
157            }
158            other => other,
159        }
160    }
161
162    /// Builds a deterministic cache key for a query (CACHE-02).
163    ///
164    /// Serialises the query to canonical JSON (object keys sorted recursively),
165    /// reads the current `schema_version`, and gathers per-collection
166    /// `write_generation` counters (sorted by collection name) to form a
167    /// `PlanKey`.
168    ///
169    /// # Why canonical JSON instead of `Debug`
170    ///
171    /// `format!("{query:?}")` is non-deterministic when the `Query` AST
172    /// contains `HashMap`-backed fields (`FusionConfig::params`,
173    /// `TrainStatement::params`) because `HashMap` iteration order is not
174    /// guaranteed across invocations. Canonical JSON with sorted object keys
175    /// is stable and produces the same byte sequence for logically identical
176    /// queries.
177    #[must_use]
178    pub fn build_plan_key(&self, query: &crate::velesql::Query) -> crate::cache::PlanKey {
179        use std::hash::{BuildHasher, Hasher};
180
181        // Serialise via serde_json, then canonicalise (sort object keys) before hashing.
182        // Fallback to Debug representation if serialization fails (should never happen in
183        // practice since all Query fields are Serialize, but erring on the side of liveness).
184        let query_text = serde_json::to_value(query)
185            .map(Self::canonical_json)
186            .and_then(|v| serde_json::to_string(&v))
187            .unwrap_or_else(|_| format!("{query:?}"));
188
189        let mut hasher = rustc_hash::FxBuildHasher.build_hasher();
190        hasher.write(query_text.as_bytes());
191        let query_hash = hasher.finish();
192
193        let schema_version = self.schema_version();
194        let collection_names = Self::referenced_collection_names(query);
195
196        // Build generations vector in sorted collection order.
197        let collection_generations: smallvec::SmallVec<[u64; 4]> = collection_names
198            .iter()
199            .map(|name| self.collection_write_generation(name).unwrap_or(0))
200            .collect();
201
202        // Issue #608: parallel vector of analyze generations so that running
203        // ANALYZE alone (no data mutation) still flips the cache key and
204        // rebuilds plans with the fresh calibrated cost estimates.
205        let analyze_generations: smallvec::SmallVec<[u64; 4]> = collection_names
206            .iter()
207            .map(|name| self.collection_analyze_generation(name).unwrap_or(0))
208            .collect();
209
210        crate::cache::PlanKey {
211            // Issue #902: store the canonical text so PlanKey equality is
212            // collision-safe. query_hash stays a Hash accelerator only.
213            query_text: query_text.into(),
214            query_hash,
215            schema_version,
216            collection_generations,
217            analyze_generations,
218        }
219    }
220
221    /// Returns the query plan for a query, with cache status populated (CACHE-02).
222    ///
223    /// If the plan is cached, returns it with `cache_hit: Some(true)` and
224    /// `plan_reuse_count` set. Otherwise generates a fresh plan with
225    /// `cache_hit: Some(false)`.
226    ///
227    /// # Design decision: `explain_query` does not populate the cache
228    ///
229    /// `explain_query` intentionally does **not** insert a new plan into the
230    /// compiled plan cache. EXPLAIN is a diagnostic operation; allowing it to
231    /// influence cache state would make cache metrics (hit/miss ratios,
232    /// `plan_reuse_count`) unreliable because EXPLAIN calls would be
233    /// indistinguishable from real execution hits. Only `execute_query` is
234    /// authorised to write to the cache.
235    ///
236    /// # Errors
237    ///
238    /// Returns an error if the query is invalid.
239    pub fn explain_query(
240        &self,
241        query: &crate::velesql::Query,
242    ) -> Result<crate::velesql::QueryPlan> {
243        crate::velesql::QueryValidator::validate(query).map_err(|e| Error::Query(e.to_string()))?;
244
245        let plan_key = self.build_plan_key(query);
246
247        if let Some(cached) = self.compiled_plan_cache.get(&plan_key) {
248            let mut plan = cached.plan.clone();
249            plan.cache_hit = Some(true);
250            plan.plan_reuse_count = Some(
251                cached
252                    .reuse_count
253                    .load(std::sync::atomic::Ordering::Relaxed),
254            );
255            return Ok(plan);
256        }
257
258        let mut plan = self.build_plan_with_stats(query);
259        plan.cache_hit = Some(false);
260        plan.plan_reuse_count = Some(0);
261        Ok(plan)
262    }
263
264    /// Builds a query plan, resolving calibrated collection statistics AND
265    /// the registered secondary index set from the registry when available
266    /// (#471 — EXPLAIN real costs, #607 — `IndexLookup` wiring).
267    ///
268    /// The returned plan's `estimated_cost_ms` and `filter_strategy` are
269    /// calibrated via `CostEstimator` when stats exist for the query's
270    /// primary collection. Falls back to heuristics otherwise. The
271    /// `indexed_fields` argument is populated from
272    /// `Database::indexed_fields_for` so that `IndexLookup` nodes appear
273    /// in the EXPLAIN tree for WHERE clauses targeting indexed columns.
274    fn build_plan_with_stats(&self, query: &crate::velesql::Query) -> crate::velesql::QueryPlan {
275        let primary = &query.select.from;
276        let core_stats = self.get_collection_stats(primary).ok().flatten();
277        let indexed = self.indexed_fields_for(primary);
278        // For MATCH queries thread the live graph CollectionStats so the
279        // MatchTraversal strategy reflects the real graph shape (backlog #14).
280        let match_stats = query
281            .match_clause
282            .is_some()
283            .then(|| self.match_stats_for(primary))
284            .flatten();
285        crate::velesql::QueryPlan::from_query_with_all_stats(
286            query,
287            &indexed,
288            core_stats.as_ref(),
289            match_stats.as_ref(),
290        )
291    }
292
293    /// Executes a query with instrumentation and returns both plan and actual stats.
294    ///
295    /// Unlike `explain_query` (plan only) and `execute_query` (results only),
296    /// this method returns the full [`ExplainOutput`] with measured statistics.
297    /// The normal `execute_query` path is untouched — zero overhead on
298    /// non-ANALYZE queries.
299    ///
300    /// # Errors
301    ///
302    /// Returns an error if the query is invalid or execution fails.
303    pub fn explain_analyze_query(
304        &self,
305        query: &Query,
306        params: &std::collections::HashMap<String, serde_json::Value>,
307    ) -> Result<ExplainOutput> {
308        crate::velesql::QueryValidator::validate(query).map_err(|e| Error::Query(e.to_string()))?;
309
310        let plan = self.explain_query(query)?;
311        let start = std::time::Instant::now();
312        let (results, nodes, edges) = self.execute_query_counted(query, params)?;
313        let stats = ActualStats::from_counted(results.len() as u64, start.elapsed(), nodes, edges);
314        let node_stats = crate::velesql::build_leaf_node_stats(
315            &plan.root,
316            stats.actual_rows,
317            stats.actual_time_ms,
318        );
319        Ok(ExplainOutput::with_stats(plan, stats, node_stats))
320    }
321
322    /// Executes a `VelesQL` query with database-level JOIN resolution.
323    ///
324    /// This method resolves JOIN target collections from the database registry
325    /// and executes JOIN runtime in sequence. Query plans are cached and
326    /// reused for identical queries against unchanged collections (CACHE-02).
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if the base collection or any JOIN collection is missing.
331    pub fn execute_query(
332        &self,
333        query: &crate::velesql::Query,
334        params: &std::collections::HashMap<String, serde_json::Value>,
335    ) -> Result<Vec<SearchResult>> {
336        // Resolve scalar subqueries (EPIC-039) into literals *before* validation
337        // so the validator and every downstream path see a subquery-free AST.
338        if let Some(rewritten) = self.resolve_subqueries(query, params)? {
339            return self.execute_query(&rewritten, params);
340        }
341
342        crate::velesql::QueryValidator::validate(query).map_err(|e| Error::Query(e.to_string()))?;
343
344        // Requirement 1: read-path control-plane gate. Fires exactly once here
345        // at the `Database` facade for read paths (SELECT + MATCH). Compound /
346        // JOIN sub-executions re-enter `execute_single_select`, not
347        // `execute_query`, so the gate never double-fires. Non-read statements
348        // (DDL/DML/admin/train/introspection) keep their own gates and are not
349        // gated here (Requirement 3.5).
350        //
351        // Requirement 2: `execute_query_timed` wraps the single top-level
352        // `execute_query_inner` call so `on_query` telemetry fires exactly once
353        // after the data-plane op completes. Resolving the `Cow` to a `&Query`
354        // (via deref coercion on `&gated`) keeps the timing in one place rather
355        // than duplicated across the borrowed / owned arms.
356        let gated = self.read_gate(query)?;
357        self.execute_query_timed(&gated, params)
358    }
359
360    /// Executes the resolved (post-gate) query and fires the `on_query`
361    /// telemetry hook exactly once after the data-plane op completes
362    /// (Requirement 2.2, 2.5).
363    ///
364    /// The timer wraps only the single top-level `execute_query_inner` call, so
365    /// compound / UNION / INTERSECT / EXCEPT and JOIN sub-executions that
366    /// re-enter `execute_single_select` are folded into this one measurement
367    /// and never fire their own telemetry.
368    ///
369    /// When no observer is registered, this is a single `Option` presence check
370    /// with no timer and no notification beyond that check (Requirement 2.4).
371    /// The duration is reported in microseconds; `elapsed().as_micros()` is a
372    /// `u128`, converted with a bounds-guarded `try_from` that saturates at
373    /// `u64::MAX` rather than panicking (no `unwrap`/`expect`).
374    fn execute_query_timed(
375        &self,
376        query: &crate::velesql::Query,
377        params: &std::collections::HashMap<String, serde_json::Value>,
378    ) -> Result<Vec<SearchResult>> {
379        let Some(observer) = self.observer.as_ref() else {
380            return self.execute_query_inner(query, params); // zero-overhead fast path
381        };
382        let started = std::time::Instant::now();
383        let results = self.execute_query_inner(query, params)?;
384        let duration_us = u64::try_from(started.elapsed().as_micros()).unwrap_or(u64::MAX);
385        observer.on_query(query.select.from.as_str(), duration_us);
386        Ok(results)
387    }
388
389    /// Applies the read-path control-plane gate (Requirement 1).
390    ///
391    /// Fast path: a single `Option` presence check when no observer is
392    /// registered returns [`Cow::Borrowed`](std::borrow::Cow::Borrowed) with
393    /// zero allocation and zero query clone (Requirement 1.8). When an observer
394    /// is present it is consulted only for read paths (SELECT + MATCH); other
395    /// statement types pass through borrowed because they carry their own
396    /// DDL/DML gates.
397    ///
398    /// * [`AccessDecision::Allow`] ⇒ borrowed, unmodified query (Requirement 1.6).
399    /// * [`AccessDecision::Deny`] ⇒ the supplied error, no results (Requirement 1.4).
400    /// * [`AccessDecision::AllowWithScope`] ⇒ an owned, scope-narrowed clone
401    ///   (Requirement 1.5).
402    ///
403    /// # Errors
404    ///
405    /// Returns the observer's `Err` for an internal failure, or the
406    /// `Deny`-supplied error when access is refused.
407    fn read_gate<'q>(
408        &self,
409        query: &'q crate::velesql::Query,
410    ) -> Result<std::borrow::Cow<'q, crate::velesql::Query>> {
411        let Some(observer) = self.observer.as_ref() else {
412            return Ok(std::borrow::Cow::Borrowed(query)); // zero-overhead fast path
413        };
414        if !Self::is_read_path(query) {
415            return Ok(std::borrow::Cow::Borrowed(query));
416        }
417        let ctx = QueryAccessContext::from_query(query);
418        match observer.on_query_request(&ctx)? {
419            AccessDecision::Allow => Ok(std::borrow::Cow::Borrowed(query)),
420            AccessDecision::Deny(err) => Err(err),
421            AccessDecision::AllowWithScope(scope) => {
422                Ok(std::borrow::Cow::Owned(Self::apply_scope(query, &scope)))
423            }
424        }
425    }
426
427    /// Test-only accessor exposing [`read_gate`](Self::read_gate)'s [`Cow`]
428    /// result so tests can assert the no-observer read path is a single pointer
429    /// check that returns [`Cow::Borrowed`](std::borrow::Cow::Borrowed) with no
430    /// query clone (Requirement 8.2 — Quality Bar Gate 2, p50 latency).
431    ///
432    /// Compiled only under `cfg(test)`, so it adds nothing to the production
433    /// surface. The full ≤ 450 µs wall-clock p50 contract is enforced
434    /// separately by the `Perf Gate (E2E)` workflow
435    /// (`.github/workflows/perf-gate-e2e.yml`); this accessor pins the
436    /// structural "zero-overhead when no observer" half of the gate
437    /// deterministically, without a flaky timing threshold.
438    #[cfg(test)]
439    pub(crate) fn read_gate_cow_for_test<'q>(
440        &self,
441        query: &'q crate::velesql::Query,
442    ) -> Result<std::borrow::Cow<'q, crate::velesql::Query>> {
443        self.read_gate(query)
444    }
445
446    /// Returns `true` when the statement is a gated read path (SELECT or MATCH).
447    ///
448    /// Admin, introspection, DDL, TRAIN, and DML statements are excluded: they
449    /// route through their own control-plane gates and must not be double-gated
450    /// by the read-path hook.
451    fn is_read_path(query: &crate::velesql::Query) -> bool {
452        matches!(
453            classify_statement(query),
454            StatementType::Match | StatementType::Select
455        )
456    }
457
458    /// Executes a query after the read gate has resolved (Requirement 1).
459    ///
460    /// This is the single dispatch entry the gate delegates to; it fires
461    /// exactly once per top-level query and is *not* re-entered by compound /
462    /// JOIN sub-executions, so telemetry (Task 5.1) can wrap it cleanly without
463    /// double-counting.
464    fn execute_query_inner(
465        &self,
466        query: &crate::velesql::Query,
467        params: &std::collections::HashMap<String, serde_json::Value>,
468    ) -> Result<Vec<SearchResult>> {
469        if let Some(results) = self.dispatch_non_select(query, params)? {
470            return Ok(results);
471        }
472
473        // Build plan key and check cache WITHOUT recording hit/miss metrics (CACHE-02).
474        //
475        // `contains()` is used instead of `get().is_some()` so that this
476        // existence check does not increment the hit/miss counters or
477        // `reuse_count`. Only `explain_query` (which surfaces these values to
478        // callers) should call `get()`.
479        let pre_exec_key = self.build_plan_key(query);
480        let is_cached = self.compiled_plan_cache.contains(&pre_exec_key);
481
482        let results = self.execute_select_query(query, params)?;
483
484        // Populate cache on miss (CACHE-02).
485        //
486        // C-1 TOCTOU fix: rebuild the plan key AFTER execution. Between the
487        // pre-execution `contains()` check and here, a concurrent writer may
488        // have bumped a collection's `write_generation` (e.g. via `upsert` on
489        // another thread). Rebuilding the key captures the post-execution
490        // state, so the cached plan is associated with the generation that was
491        // live when the plan was actually compiled — not a potentially stale
492        // pre-execution snapshot.
493        if !is_cached {
494            self.populate_plan_cache(query);
495        }
496
497        Ok(results)
498    }
499
500    /// Classifies and dispatches non-SELECT statement types.
501    ///
502    /// Returns `Ok(Some(results))` if handled, `Ok(None)` for SELECT queries.
503    fn dispatch_non_select(
504        &self,
505        query: &crate::velesql::Query,
506        params: &std::collections::HashMap<String, serde_json::Value>,
507    ) -> Result<Option<Vec<SearchResult>>> {
508        // Classify the statement type (at most one is Some).
509        let stmt_type = classify_statement(query);
510        match stmt_type {
511            StatementType::Admin(admin) => Ok(Some(self.execute_admin(admin)?)),
512            StatementType::Introspection(intro) => Ok(Some(self.execute_introspection(intro)?)),
513            StatementType::Ddl(ddl) => Ok(Some(self.execute_ddl(ddl)?)),
514            StatementType::Train(train) => Ok(Some(self.execute_train(train)?)),
515            StatementType::Dml(dml) => Ok(Some(self.execute_dml(dml, params)?)),
516            StatementType::Match => Ok(Some(self.execute_match_routed(query, params)?.0)),
517            StatementType::Select => Ok(None),
518        }
519    }
520
521    /// Resolves the target collection for a MATCH query.
522    ///
523    /// Resolution order: `SELECT ... FROM <collection> WHERE MATCH ...`, then a
524    /// `"_collection"` key in `params` (programmatic API), else a guidance error.
525    fn resolve_match_collection(
526        &self,
527        query: &crate::velesql::Query,
528        params: &std::collections::HashMap<String, serde_json::Value>,
529    ) -> Result<crate::collection::Collection> {
530        let collection_name = if !query.select.from.is_empty() {
531            query.select.from.clone()
532        } else if let Some(serde_json::Value::String(name)) = params.get("_collection") {
533            name.clone()
534        } else {
535            return Err(Error::Query(
536                "MATCH query requires a target collection. Either use \
537                 SELECT ... FROM <collection> WHERE MATCH ..., or pass \
538                 {\"_collection\": \"name\"} in params."
539                    .to_string(),
540            ));
541        };
542        self.resolve_collection(&collection_name)
543    }
544
545    /// Routes a MATCH query to its target collection and applies cross-collection
546    /// enrichment, returning results plus the graph-traversal counters
547    /// `(nodes_visited, edges_traversed)` measured during execution (for
548    /// EXPLAIN ANALYZE; the plain execution path discards them).
549    fn execute_match_routed(
550        &self,
551        query: &crate::velesql::Query,
552        params: &std::collections::HashMap<String, serde_json::Value>,
553    ) -> Result<(Vec<SearchResult>, u64, u64)> {
554        let coll = self.resolve_match_collection(query, params)?;
555        let (mut results, nodes_visited, edges_traversed) =
556            coll.execute_query_counted(query, params)?;
557        // Cross-collection enrichment: if any node pattern has a @collection
558        // annotation, look up payloads from those collections and merge them
559        // into the projected fields.
560        if let Some(mc) = &query.match_clause {
561            self.enrich_match_results_cross_collection(mc, &mut results);
562        }
563        Ok((results, nodes_visited, edges_traversed))
564    }
565
566    /// Executes a query and returns graph-traversal counters for EXPLAIN ANALYZE.
567    ///
568    /// MATCH queries report real `(nodes_visited, edges_traversed)`; every other
569    /// statement type reports `(_, 0, 0)` (no graph traversal occurred).
570    fn execute_query_counted(
571        &self,
572        query: &Query,
573        params: &std::collections::HashMap<String, serde_json::Value>,
574    ) -> Result<(Vec<SearchResult>, u64, u64)> {
575        if query.is_match_query() {
576            return self.execute_match_routed(query, params);
577        }
578        Ok((self.execute_query(query, params)?, 0, 0))
579    }
580
581    /// Executes the SELECT portion of a query, resolving JOINs if present.
582    fn execute_select_query(
583        &self,
584        query: &crate::velesql::Query,
585        params: &std::collections::HashMap<String, serde_json::Value>,
586    ) -> Result<Vec<SearchResult>> {
587        // EPIC-040 US-006: For compound queries, strip LIMIT from each operand so
588        // the set operation sees the full result sets.  The final LIMIT is applied
589        // once on the merged output (SQL-standard behaviour).
590        // Use MAX_LIMIT (not None) to avoid the default-10 cap downstream.
591        const COMPOUND_LIMIT: usize = 100_000;
592        let compound_limit = Some(COMPOUND_LIMIT as u64); // 100_000 fits u64 exactly.
593        let left_results = if query.compound.is_some() {
594            let mut left_query = query.clone();
595            left_query.select.limit = compound_limit;
596            self.execute_single_select(&left_query, params)?
597        } else {
598            return self.execute_single_select(query, params);
599        };
600
601        // compound is guaranteed Some here (non-compound returns above).
602        if let Some(ref compound) = query.compound {
603            let mut accumulated = left_results;
604            for (operator, right_select) in &compound.operations {
605                let mut right_query = crate::velesql::Query::new_select(right_select.clone());
606                right_query.select.limit = compound_limit;
607                let right_results = self.execute_single_select(&right_query, params)?;
608                accumulated = crate::collection::search::query::set_operations::apply_set_operation(
609                    accumulated,
610                    right_results,
611                    *operator,
612                    // Intermediate ops keep the server-side ceiling: truncating to the
613                    // user LIMIT here would drop rows a later chained set op still needs.
614                    COMPOUND_LIMIT,
615                );
616            }
617            // SQL-standard: LIMIT from the left (outer) SELECT applies to the final result.
618            if let Some(limit) = query.select.limit {
619                accumulated.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
620            }
621            return Ok(accumulated);
622        }
623
624        Ok(left_results)
625    }
626
627    /// Collects sorted, deduplicated collection names referenced by a query,
628    /// including all compound operands (UNION, INTERSECT, EXCEPT).
629    ///
630    /// RF-DEDUP: Shared by `build_plan_key` and `populate_plan_cache`, which
631    /// both need the same sorted collection-name list from the query AST.
632    fn referenced_collection_names(query: &crate::velesql::Query) -> Vec<String> {
633        let mut names = vec![query.select.from.clone()];
634        for join in &query.select.joins {
635            names.push(join.table.clone());
636        }
637        if let Some(ref compound) = query.compound {
638            for (_, right_select) in &compound.operations {
639                names.push(right_select.from.clone());
640                for join in &right_select.joins {
641                    names.push(join.table.clone());
642                }
643            }
644        }
645        names.sort();
646        names.dedup();
647        names
648    }
649
650    /// Resolves a collection by name from all typed registries.
651    ///
652    /// Priority: vector collections first, then graph, then metadata.
653    /// Returns the inner `Collection` for query execution.
654    pub(super) fn resolve_collection(&self, name: &str) -> Result<crate::collection::Collection> {
655        if let Some(vc) = self.get_vector_collection(name) {
656            return Ok(vc.inner);
657        }
658        if let Some(gc) = self.get_graph_collection(name) {
659            return Ok(gc.inner);
660        }
661        if let Some(mc) = self.get_metadata_collection(name) {
662            return Ok(mc.inner);
663        }
664        Err(Error::CollectionNotFound(name.to_string()))
665    }
666
667    /// Resolves a collection that supports write operations (INSERT/UPDATE/TRAIN).
668    ///
669    /// Checks vector, graph, and metadata collections. Metadata-only collections
670    /// support INSERT/UPDATE for metadata fields (no vectors).
671    pub(super) fn resolve_writable_collection(
672        &self,
673        name: &str,
674    ) -> Result<crate::collection::Collection> {
675        if let Some(vc) = self.get_vector_collection(name) {
676            return Ok(vc.inner);
677        }
678        if let Some(gc) = self.get_graph_collection(name) {
679            return Ok(gc.inner);
680        }
681        if let Some(mc) = self.get_metadata_collection(name) {
682            return Ok(mc.inner);
683        }
684        Err(Error::CollectionNotFound(name.to_string()))
685    }
686
687    /// Executes a single SELECT (no compound), resolving JOINs if present.
688    ///
689    /// Orchestrates filter pushdown and join strategy selection:
690    /// 1. Analyze WHERE for pushdown-eligible conditions
691    /// 2. Strip pushed conditions from base query
692    /// 3. For each JOIN: lookup, filtered, or full `ColumnStore` path
693    /// 4. Apply post-join filters (cross-source predicates)
694    fn execute_single_select(
695        &self,
696        query: &crate::velesql::Query,
697        params: &std::collections::HashMap<String, serde_json::Value>,
698    ) -> Result<Vec<SearchResult>> {
699        let base_collection = self.resolve_collection(&query.select.from)?;
700
701        let mut single_query = query.clone();
702        single_query.compound = None;
703
704        if single_query.select.joins.is_empty() {
705            return base_collection.execute_query(&single_query, params);
706        }
707
708        let analysis = Self::prepare_join_pushdown(&mut single_query, params)?;
709        let pushed = analysis.column_store_filters.clone();
710
711        let row_budget = Self::join_row_budget(&query.select, &analysis);
712
713        let mut results = base_collection.execute_query(&single_query, params)?;
714        for join in &query.select.joins {
715            results = self.execute_single_join(&results, join, &pushed, row_budget)?;
716        }
717
718        // Apply post-join filters: cross-source predicates that reference
719        // columns from both the base collection and joined ColumnStore tables.
720        if !analysis.post_join_filters.is_empty() {
721            results = Self::apply_post_join_filters(
722                &base_collection,
723                results,
724                &analysis.post_join_filters,
725                params,
726                &query.select.from_alias,
727            )?;
728        }
729
730        Ok(results)
731    }
732
733    /// Resolves WHERE parameters, runs pushdown analysis, and strips pushed
734    /// conditions from the base query (JOIN path of `execute_single_select`).
735    ///
736    /// Parameter placeholders are resolved before the analysis so pushed-down
737    /// filters never silently convert them to NULL at the `ColumnStore` layer
738    /// (the collection pipeline resolves its own copy independently).
739    fn prepare_join_pushdown(
740        single_query: &mut crate::velesql::Query,
741        params: &std::collections::HashMap<String, serde_json::Value>,
742    ) -> Result<crate::collection::search::query::pushdown::PushdownAnalysis> {
743        if let Some(cond) = single_query.select.where_clause.take() {
744            single_query.select.where_clause = Some(
745                crate::collection::Collection::resolve_condition_params(&cond, params)?,
746            );
747        }
748
749        let analysis = Self::analyze_join_pushdown_for_select(&single_query.select);
750
751        let resolved_where = single_query.select.where_clause.clone();
752        single_query.select.joins.clear();
753        if !analysis.column_store_filters.is_empty() {
754            single_query.select.where_clause = Self::strip_pushed_conditions(
755                resolved_where.as_ref(),
756                &analysis.column_store_filters,
757            );
758        }
759        Ok(analysis)
760    }
761
762    /// Computes the bound on joined rows to materialize.
763    ///
764    /// When the query has an explicit LIMIT, no post-join filters, and no
765    /// ORDER BY (which could reorder past the window), the bound is the
766    /// effective `LIMIT + OFFSET`. GROUP BY / HAVING / DISTINCT also disqualify
767    /// the bounded shape: SQL LIMIT bounds output *groups/rows*, not input rows,
768    /// so truncating joined input to `LIMIT` would drop rows that belong to
769    /// groups still inside the window. Otherwise downstream stages may reorder or
770    /// drop rows, so we fall back to the conservative server-side ceiling
771    /// [`JOIN_ROW_CEILING`] — still bounding OOM without affecting correctness.
772    pub(super) fn join_row_budget(
773        select: &crate::velesql::SelectStatement,
774        analysis: &crate::collection::search::query::pushdown::PushdownAnalysis,
775    ) -> usize {
776        use crate::collection::search::query::JOIN_ROW_CEILING;
777        use crate::velesql::DistinctMode;
778        let bounded_shape = analysis.post_join_filters.is_empty()
779            && select.order_by.is_none()
780            && select.group_by.is_none()
781            && select.having.is_none()
782            && select.distinct == DistinctMode::None;
783        match select.limit {
784            Some(limit) if bounded_shape => {
785                let limit = usize::try_from(limit).unwrap_or(JOIN_ROW_CEILING);
786                let offset = select
787                    .offset
788                    .map_or(0, |o| usize::try_from(o).unwrap_or(JOIN_ROW_CEILING));
789                limit.saturating_add(offset).min(JOIN_ROW_CEILING)
790            }
791            _ => JOIN_ROW_CEILING,
792        }
793    }
794
795    // NOTE: analyze_join_pushdown_for_select, apply_post_join_filters
796    // moved to join_pushdown.rs (NLOC/file reduction)
797
798    /// Inserts a compiled plan into the cache after a cache miss (CACHE-02).
799    fn populate_plan_cache(&self, query: &crate::velesql::Query) {
800        let compiled = std::sync::Arc::new(crate::cache::CompiledPlan {
801            plan: self.build_plan_with_stats(query),
802            referenced_collections: Self::referenced_collection_names(query),
803            compiled_at: std::time::Instant::now(),
804            reuse_count: std::sync::atomic::AtomicU64::new(0),
805        });
806        // Rebuild key after execution to reflect current write_generation (C-1).
807        let post_exec_key = self.build_plan_key(query);
808        self.compiled_plan_cache.insert(post_exec_key, compiled);
809    }
810
811    /// Dispatches a DML statement (INSERT, UPSERT, UPDATE, DELETE, or edge mutations).
812    pub(super) fn execute_dml(
813        &self,
814        dml: &crate::velesql::DmlStatement,
815        params: &std::collections::HashMap<String, serde_json::Value>,
816    ) -> Result<Vec<SearchResult>> {
817        match dml {
818            crate::velesql::DmlStatement::Insert(stmt)
819            | crate::velesql::DmlStatement::Upsert(stmt) => self.execute_insert(stmt, params),
820            crate::velesql::DmlStatement::Update(stmt) => self.execute_update(stmt, params),
821            crate::velesql::DmlStatement::InsertEdge(stmt) => self.execute_insert_edge(stmt),
822            crate::velesql::DmlStatement::Delete(stmt) => self.execute_delete(stmt),
823            crate::velesql::DmlStatement::DeleteEdge(stmt) => self.execute_delete_edge(stmt),
824            crate::velesql::DmlStatement::SelectEdges(stmt) => self.execute_select_edges(stmt),
825            crate::velesql::DmlStatement::InsertNode(stmt) => self.execute_insert_node(stmt),
826        }
827    }
828}