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. Wall-clock p50 is checked separately, against a loose floor,
511 /// by the `perf-gate-e2e` job of `.github/workflows/ci.yml`; this accessor
512 /// pins the structural "zero-overhead when no observer" half of the gate
513 /// deterministically, without a flaky timing threshold.
514 #[cfg(test)]
515 pub(crate) fn read_gate_cow_for_test<'q>(
516 &self,
517 query: &'q crate::velesql::Query,
518 ) -> Result<std::borrow::Cow<'q, crate::velesql::Query>> {
519 self.read_gate(query)
520 }
521
522 /// Returns `true` when the statement is a gated read path (SELECT or MATCH).
523 ///
524 /// Admin, introspection, DDL, TRAIN, and DML statements are excluded: they
525 /// route through their own control-plane gates and must not be double-gated
526 /// by the read-path hook.
527 fn is_read_path(query: &crate::velesql::Query) -> bool {
528 matches!(
529 classify_statement(query),
530 StatementType::Match | StatementType::Select
531 )
532 }
533
534 /// Executes a query after the read gate has resolved (Requirement 1).
535 ///
536 /// This is the single dispatch entry the gate delegates to; it fires
537 /// exactly once per top-level query and is *not* re-entered by compound /
538 /// JOIN sub-executions, so telemetry (Task 5.1) can wrap it cleanly without
539 /// double-counting.
540 fn execute_query_inner(
541 &self,
542 query: &crate::velesql::Query,
543 params: &std::collections::HashMap<String, serde_json::Value>,
544 probe: Option<&crate::database::StrategyProbeSlot>,
545 ) -> Result<Vec<SearchResult>> {
546 if let Some(results) = self.dispatch_non_select(query, params)? {
547 return Ok(results);
548 }
549
550 // Build plan key and check cache WITHOUT recording hit/miss metrics (CACHE-02).
551 //
552 // `contains()` is used instead of `get().is_some()` so that this
553 // existence check does not increment the hit/miss counters or
554 // `reuse_count`. Only `explain_query` (which surfaces these values to
555 // callers) should call `get()`.
556 let pre_exec_key = self.build_plan_key(query);
557 let is_cached = self.compiled_plan_cache.contains(&pre_exec_key);
558
559 let results = self.execute_select_query(query, params, probe)?;
560
561 // Populate cache on miss (CACHE-02).
562 //
563 // C-1 TOCTOU fix: rebuild the plan key AFTER execution. Between the
564 // pre-execution `contains()` check and here, a concurrent writer may
565 // have bumped a collection's `write_generation` (e.g. via `upsert` on
566 // another thread). Rebuilding the key captures the post-execution
567 // state, so the cached plan is associated with the generation that was
568 // live when the plan was actually compiled — not a potentially stale
569 // pre-execution snapshot.
570 if !is_cached {
571 self.populate_plan_cache(query);
572 }
573
574 Ok(results)
575 }
576
577 /// Classifies and dispatches non-SELECT statement types.
578 ///
579 /// Returns `Ok(Some(results))` if handled, `Ok(None)` for SELECT queries.
580 fn dispatch_non_select(
581 &self,
582 query: &crate::velesql::Query,
583 params: &std::collections::HashMap<String, serde_json::Value>,
584 ) -> Result<Option<Vec<SearchResult>>> {
585 // Classify the statement type (at most one is Some).
586 let stmt_type = classify_statement(query);
587 match stmt_type {
588 StatementType::Admin(admin) => Ok(Some(self.execute_admin(admin)?)),
589 StatementType::Introspection(intro) => Ok(Some(self.execute_introspection(intro)?)),
590 StatementType::Ddl(ddl) => Ok(Some(self.execute_ddl(ddl)?)),
591 StatementType::Train(train) => Ok(Some(self.execute_train(train)?)),
592 StatementType::Dml(dml) => Ok(Some(self.execute_dml(dml, params)?)),
593 StatementType::Match => Ok(Some(self.execute_match_routed(query, params)?.0)),
594 StatementType::Select => Ok(None),
595 }
596 }
597
598 /// Resolves the target collection for a MATCH query.
599 ///
600 /// Resolution order: `SELECT ... FROM <collection> WHERE MATCH ...`, then a
601 /// `"_collection"` key in `params` (programmatic API), else a guidance error.
602 fn resolve_match_collection(
603 &self,
604 query: &crate::velesql::Query,
605 params: &std::collections::HashMap<String, serde_json::Value>,
606 ) -> Result<crate::collection::Collection> {
607 let collection_name = if !query.select.from.is_empty() {
608 query.select.from.clone()
609 } else if let Some(serde_json::Value::String(name)) = params.get("_collection") {
610 name.clone()
611 } else {
612 return Err(Error::Query(
613 "MATCH query requires a target collection. Either use \
614 SELECT ... FROM <collection> WHERE MATCH ..., or pass \
615 {\"_collection\": \"name\"} in params."
616 .to_string(),
617 ));
618 };
619 self.resolve_collection(&collection_name)
620 }
621
622 /// Routes a MATCH query to its target collection and applies cross-collection
623 /// enrichment, returning results plus the graph-traversal counters
624 /// `(nodes_visited, edges_traversed)` measured during execution (for
625 /// EXPLAIN ANALYZE; the plain execution path discards them).
626 fn execute_match_routed(
627 &self,
628 query: &crate::velesql::Query,
629 params: &std::collections::HashMap<String, serde_json::Value>,
630 ) -> Result<(Vec<SearchResult>, u64, u64)> {
631 let coll = self.resolve_match_collection(query, params)?;
632 let counted = coll.execute_query_counted(query, params)?;
633 let (mut results, nodes_visited, edges_traversed) = (
634 counted.results,
635 counted.nodes_visited,
636 counted.edges_traversed,
637 );
638 // Cross-collection enrichment: if any node pattern has a @collection
639 // annotation, look up payloads from those collections and merge them
640 // into the projected fields.
641 if let Some(mc) = &query.match_clause {
642 self.enrich_match_results_cross_collection(mc, &mut results);
643 }
644 Ok((results, nodes_visited, edges_traversed))
645 }
646
647 /// Executes a query and returns graph-traversal counters for EXPLAIN ANALYZE.
648 ///
649 /// MATCH queries report real `(nodes_visited, edges_traversed)`; every other
650 /// statement type reports `(_, 0, 0)` (no graph traversal occurred).
651 fn execute_query_counted(
652 &self,
653 query: &Query,
654 params: &std::collections::HashMap<String, serde_json::Value>,
655 ) -> Result<(
656 Vec<SearchResult>,
657 u64,
658 u64,
659 Option<crate::velesql::FilterStrategy>,
660 )> {
661 if query.is_match_query() {
662 // Apply the read-path gate before the MATCH executor. The non-EXPLAIN
663 // MATCH path is gated inside `execute_query`; this counted path (used
664 // by EXPLAIN ANALYZE) routes straight to `execute_match_routed`, so
665 // without this it would let EXPLAIN ANALYZE MATCH bypass governance.
666 let gated = self.read_gate(query)?;
667 let (results, nodes, edges) = self.execute_match_routed(&gated, params)?;
668 return Ok((results, nodes, edges, None));
669 }
670 // The exact pre-steps of execute_query (shared execute_query_gated
671 // inner — the two paths cannot drift), with a probe threaded to the
672 // base-collection call so EXPLAIN ANALYZE reports the ran strategy.
673 let slot: crate::database::StrategyProbeSlot =
674 std::sync::Arc::new(crate::guardrails::ExecutedStrategyCell::default());
675 let results = self.execute_query_gated(query, params, Some(&slot))?;
676 Ok((results, 0, 0, slot.get()))
677 }
678
679 /// Executes the SELECT portion of a query, resolving JOINs if present.
680 fn execute_select_query(
681 &self,
682 query: &crate::velesql::Query,
683 params: &std::collections::HashMap<String, serde_json::Value>,
684 probe: Option<&crate::database::StrategyProbeSlot>,
685 ) -> Result<Vec<SearchResult>> {
686 // EPIC-040 US-006: For compound queries, strip LIMIT from each operand so
687 // the set operation sees the full result sets. The final LIMIT is applied
688 // once on the merged output (SQL-standard behaviour).
689 // Use MAX_LIMIT (not None) to avoid the default-10 cap downstream.
690 const COMPOUND_LIMIT: usize = 100_000;
691 let compound_limit = Some(COMPOUND_LIMIT as u64); // 100_000 fits u64 exactly.
692 let left_results = if query.compound.is_some() {
693 // Compound queries execute several selects; a single reported
694 // strategy would be ambiguous, so the probe stays untouched.
695 let mut left_query = query.clone();
696 left_query.select.limit = compound_limit;
697 self.execute_single_select(&left_query, params, None)?
698 } else {
699 return self.execute_single_select(query, params, probe);
700 };
701
702 // compound is guaranteed Some here (non-compound returns above).
703 if let Some(ref compound) = query.compound {
704 let mut accumulated = left_results;
705 for (operator, right_select) in &compound.operations {
706 let mut right_query = crate::velesql::Query::new_select(right_select.clone());
707 right_query.select.limit = compound_limit;
708 let right_results = self.execute_single_select(&right_query, params, None)?;
709 accumulated = crate::collection::search::query::set_operations::apply_set_operation(
710 accumulated,
711 right_results,
712 *operator,
713 // Intermediate ops keep the server-side ceiling: truncating to the
714 // user LIMIT here would drop rows a later chained set op still needs.
715 COMPOUND_LIMIT,
716 );
717 }
718 // SQL-standard: LIMIT from the left (outer) SELECT applies to the final result.
719 if let Some(limit) = query.select.limit {
720 accumulated.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
721 }
722 return Ok(accumulated);
723 }
724
725 Ok(left_results)
726 }
727
728 /// Collects sorted, deduplicated collection names referenced by a query,
729 /// including all compound operands (UNION, INTERSECT, EXCEPT).
730 ///
731 /// RF-DEDUP: Shared by `build_plan_key` and `populate_plan_cache`, which
732 /// both need the same sorted collection-name list from the query AST.
733 fn referenced_collection_names(query: &crate::velesql::Query) -> Vec<String> {
734 let mut names = vec![query.select.from.clone()];
735 for join in &query.select.joins {
736 names.push(join.table.clone());
737 }
738 if let Some(ref compound) = query.compound {
739 for (_, right_select) in &compound.operations {
740 names.push(right_select.from.clone());
741 for join in &right_select.joins {
742 names.push(join.table.clone());
743 }
744 }
745 }
746 names.sort();
747 names.dedup();
748 names
749 }
750
751 /// Resolves a collection by name from all typed registries.
752 ///
753 /// Priority: vector collections first, then graph, then metadata.
754 /// Returns the inner `Collection` for query execution.
755 pub(super) fn resolve_collection(&self, name: &str) -> Result<crate::collection::Collection> {
756 if let Some(vc) = self.get_vector_collection(name) {
757 return Ok(vc.inner);
758 }
759 if let Some(gc) = self.get_graph_collection(name) {
760 return Ok(gc.inner);
761 }
762 if let Some(mc) = self.get_metadata_collection(name) {
763 return Ok(mc.inner);
764 }
765 Err(Error::CollectionNotFound(name.to_string()))
766 }
767
768 /// Resolves a collection that supports write operations (INSERT/UPDATE/TRAIN).
769 ///
770 /// Resolves the same registries as `resolve_collection` (vector, then
771 /// graph, then metadata) — the distinct name exists for call-site intent
772 /// (write path vs. read path), not a difference in what gets resolved.
773 /// Metadata-only collections support INSERT/UPDATE for metadata fields
774 /// (no vectors).
775 pub(super) fn resolve_writable_collection(
776 &self,
777 name: &str,
778 ) -> Result<crate::collection::Collection> {
779 self.resolve_collection(name)
780 }
781
782 /// Executes a single SELECT (no compound), resolving JOINs if present.
783 ///
784 /// Orchestrates filter pushdown and join strategy selection:
785 /// 1. Analyze WHERE for pushdown-eligible conditions
786 /// 2. Strip pushed conditions from base query
787 /// 3. For each JOIN: lookup, filtered, or full `ColumnStore` path
788 /// 4. Apply post-join filters (cross-source predicates)
789 fn execute_single_select(
790 &self,
791 query: &crate::velesql::Query,
792 params: &std::collections::HashMap<String, serde_json::Value>,
793 probe: Option<&crate::database::StrategyProbeSlot>,
794 ) -> Result<Vec<SearchResult>> {
795 let base_collection = self.resolve_collection(&query.select.from)?;
796
797 let mut single_query = query.clone();
798 single_query.compound = None;
799
800 if single_query.select.joins.is_empty() {
801 return match probe {
802 Some(slot) => base_collection.execute_query_probed(&single_query, params, slot),
803 None => base_collection.execute_query(&single_query, params),
804 };
805 }
806
807 let analysis = Self::prepare_join_pushdown(&mut single_query, params)?;
808 let pushed = analysis.column_store_filters.clone();
809
810 let row_budget = Self::join_row_budget(&query.select, &analysis);
811
812 let mut results = match probe {
813 Some(slot) => base_collection.execute_query_probed(&single_query, params, slot)?,
814 None => base_collection.execute_query(&single_query, params)?,
815 };
816 for join in &query.select.joins {
817 results = self.execute_single_join(results, join, &pushed, row_budget)?;
818 }
819
820 // Apply post-join filters: cross-source predicates that reference
821 // columns from both the base collection and joined ColumnStore tables.
822 if !analysis.post_join_filters.is_empty() {
823 results = Self::apply_post_join_filters(
824 &base_collection,
825 results,
826 &analysis.post_join_filters,
827 params,
828 &query.select.from_alias,
829 )?;
830 }
831
832 Ok(results)
833 }
834
835 /// Resolves WHERE parameters, runs pushdown analysis, and strips pushed
836 /// conditions from the base query (JOIN path of `execute_single_select`).
837 ///
838 /// Parameter placeholders are resolved before the analysis so pushed-down
839 /// filters never silently convert them to NULL at the `ColumnStore` layer
840 /// (the collection pipeline resolves its own copy independently).
841 fn prepare_join_pushdown(
842 single_query: &mut crate::velesql::Query,
843 params: &std::collections::HashMap<String, serde_json::Value>,
844 ) -> Result<crate::collection::search::query::pushdown::PushdownAnalysis> {
845 if let Some(cond) = single_query.select.where_clause.take() {
846 single_query.select.where_clause = Some(
847 crate::collection::Collection::resolve_condition_params(&cond, params)?,
848 );
849 }
850
851 let analysis = Self::analyze_join_pushdown_for_select(&single_query.select);
852
853 let resolved_where = single_query.select.where_clause.clone();
854 single_query.select.joins.clear();
855 if !analysis.column_store_filters.is_empty() {
856 single_query.select.where_clause = Self::strip_pushed_conditions(
857 resolved_where.as_ref(),
858 &analysis.column_store_filters,
859 );
860 }
861 Ok(analysis)
862 }
863
864 /// Computes the bound on joined rows to materialize.
865 ///
866 /// When the query has an explicit LIMIT, no post-join filters, and no
867 /// ORDER BY (which could reorder past the window), the bound is the
868 /// effective `LIMIT + OFFSET`. GROUP BY / HAVING / DISTINCT also disqualify
869 /// the bounded shape: SQL LIMIT bounds output *groups/rows*, not input rows,
870 /// so truncating joined input to `LIMIT` would drop rows that belong to
871 /// groups still inside the window. Otherwise downstream stages may reorder or
872 /// drop rows, so we fall back to the conservative server-side ceiling
873 /// [`JOIN_ROW_CEILING`] — still bounding OOM without affecting correctness.
874 ///
875 /// [`JOIN_ROW_CEILING`]: crate::collection::search::query::JOIN_ROW_CEILING
876 pub(super) fn join_row_budget(
877 select: &crate::velesql::SelectStatement,
878 analysis: &crate::collection::search::query::pushdown::PushdownAnalysis,
879 ) -> usize {
880 use crate::collection::search::query::JOIN_ROW_CEILING;
881 use crate::velesql::DistinctMode;
882 let bounded_shape = analysis.post_join_filters.is_empty()
883 && select.order_by.is_none()
884 && select.group_by.is_none()
885 && select.having.is_none()
886 && select.distinct == DistinctMode::None;
887 match select.limit {
888 Some(limit) if bounded_shape => {
889 let limit = usize::try_from(limit).unwrap_or(JOIN_ROW_CEILING);
890 let offset = select
891 .offset
892 .map_or(0, |o| usize::try_from(o).unwrap_or(JOIN_ROW_CEILING));
893 limit.saturating_add(offset).min(JOIN_ROW_CEILING)
894 }
895 _ => JOIN_ROW_CEILING,
896 }
897 }
898
899 // NOTE: analyze_join_pushdown_for_select, apply_post_join_filters
900 // moved to join_pushdown.rs (NLOC/file reduction)
901
902 /// Inserts a compiled plan into the cache after a cache miss (CACHE-02).
903 fn populate_plan_cache(&self, query: &crate::velesql::Query) {
904 let compiled = std::sync::Arc::new(crate::cache::CompiledPlan {
905 plan: self.build_plan_with_stats(query),
906 referenced_collections: Self::referenced_collection_names(query),
907 compiled_at: std::time::Instant::now(),
908 reuse_count: std::sync::atomic::AtomicU64::new(0),
909 });
910 // Rebuild key after execution to reflect current write_generation (C-1).
911 let post_exec_key = self.build_plan_key(query);
912 self.compiled_plan_cache.insert(post_exec_key, compiled);
913 }
914
915 /// Dispatches a DML statement (INSERT, UPSERT, UPDATE, DELETE, or edge mutations).
916 pub(super) fn execute_dml(
917 &self,
918 dml: &crate::velesql::DmlStatement,
919 params: &std::collections::HashMap<String, serde_json::Value>,
920 ) -> Result<Vec<SearchResult>> {
921 match dml {
922 crate::velesql::DmlStatement::Insert(stmt)
923 | crate::velesql::DmlStatement::Upsert(stmt) => self.execute_insert(stmt, params),
924 crate::velesql::DmlStatement::Update(stmt) => self.execute_update(stmt, params),
925 crate::velesql::DmlStatement::InsertEdge(stmt) => self.execute_insert_edge(stmt),
926 crate::velesql::DmlStatement::Delete(stmt) => self.execute_delete(stmt),
927 crate::velesql::DmlStatement::DeleteEdge(stmt) => self.execute_delete_edge(stmt),
928 crate::velesql::DmlStatement::SelectEdges(stmt) => self.execute_select_edges(stmt),
929 crate::velesql::DmlStatement::InsertNode(stmt) => self.execute_insert_node(stmt),
930 }
931 }
932}