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