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::velesql::{
4    ActualStats, AdminStatement, DdlStatement, DmlStatement, ExplainOutput, IntrospectionStatement,
5    Query, TrainStatement,
6};
7use crate::{Error, Result, SearchResult};
8
9use super::Database;
10
11/// Statement type classification for dispatch routing.
12enum StatementType<'a> {
13    Admin(&'a AdminStatement),
14    Introspection(&'a IntrospectionStatement),
15    Ddl(&'a DdlStatement),
16    Train(&'a TrainStatement),
17    Dml(&'a DmlStatement),
18    Match,
19    Select,
20}
21
22/// Classifies a query into its statement type for routing.
23fn classify_statement(query: &Query) -> StatementType<'_> {
24    if let Some(admin) = query.admin.as_ref() {
25        return StatementType::Admin(admin);
26    }
27    if let Some(intro) = query.introspection.as_ref() {
28        return StatementType::Introspection(intro);
29    }
30    if let Some(ddl) = query.ddl.as_ref() {
31        return StatementType::Ddl(ddl);
32    }
33    if let Some(train) = query.train.as_ref() {
34        return StatementType::Train(train);
35    }
36    if let Some(dml) = query.dml.as_ref() {
37        return StatementType::Dml(dml);
38    }
39    if query.is_match_query() {
40        return StatementType::Match;
41    }
42    StatementType::Select
43}
44
45impl Database {
46    /// Produces a canonical JSON string for a `serde_json::Value`.
47    ///
48    /// Recursively sorts the keys of every JSON object so that two values
49    /// representing the same logical structure always produce identical bytes,
50    /// regardless of the `HashMap` iteration order used during serialization.
51    ///
52    /// This is required because `FusionConfig::params` and
53    /// `TrainStatement::params` are `HashMap`-backed; `serde_json` serialises
54    /// them in hash-order, which is non-deterministic across invocations.
55    fn canonical_json(value: serde_json::Value) -> serde_json::Value {
56        match value {
57            serde_json::Value::Object(map) => {
58                // Without the `preserve_order` feature flag, `serde_json::Map` is already
59                // backed by `BTreeMap` and therefore already sorted. This explicit sort
60                // step is kept as defense-in-depth: if `preserve_order` is ever enabled
61                // in `Cargo.toml` (which switches the backing store to `IndexMap` and
62                // preserves insertion order), the canonical key ordering is still upheld
63                // without any change to this function.
64                let sorted: serde_json::Map<String, serde_json::Value> = map
65                    .into_iter()
66                    .map(|(k, v)| (k, Self::canonical_json(v)))
67                    .collect::<std::collections::BTreeMap<_, _>>()
68                    .into_iter()
69                    .collect();
70                serde_json::Value::Object(sorted)
71            }
72            serde_json::Value::Array(arr) => {
73                serde_json::Value::Array(arr.into_iter().map(Self::canonical_json).collect())
74            }
75            other => other,
76        }
77    }
78
79    /// Builds a deterministic cache key for a query (CACHE-02).
80    ///
81    /// Serialises the query to canonical JSON (object keys sorted recursively),
82    /// reads the current `schema_version`, and gathers per-collection
83    /// `write_generation` counters (sorted by collection name) to form a
84    /// `PlanKey`.
85    ///
86    /// # Why canonical JSON instead of `Debug`
87    ///
88    /// `format!("{query:?}")` is non-deterministic when the `Query` AST
89    /// contains `HashMap`-backed fields (`FusionConfig::params`,
90    /// `TrainStatement::params`) because `HashMap` iteration order is not
91    /// guaranteed across invocations. Canonical JSON with sorted object keys
92    /// is stable and produces the same byte sequence for logically identical
93    /// queries.
94    #[must_use]
95    pub fn build_plan_key(&self, query: &crate::velesql::Query) -> crate::cache::PlanKey {
96        use std::hash::{BuildHasher, Hasher};
97
98        // Serialise via serde_json, then canonicalise (sort object keys) before hashing.
99        // Fallback to Debug representation if serialization fails (should never happen in
100        // practice since all Query fields are Serialize, but erring on the side of liveness).
101        let query_text = serde_json::to_value(query)
102            .map(Self::canonical_json)
103            .and_then(|v| serde_json::to_string(&v))
104            .unwrap_or_else(|_| format!("{query:?}"));
105
106        let mut hasher = rustc_hash::FxBuildHasher.build_hasher();
107        hasher.write(query_text.as_bytes());
108        let query_hash = hasher.finish();
109
110        let schema_version = self.schema_version();
111        let collection_names = Self::referenced_collection_names(query);
112
113        // Build generations vector in sorted collection order.
114        let collection_generations: smallvec::SmallVec<[u64; 4]> = collection_names
115            .iter()
116            .map(|name| self.collection_write_generation(name).unwrap_or(0))
117            .collect();
118
119        // Issue #608: parallel vector of analyze generations so that running
120        // ANALYZE alone (no data mutation) still flips the cache key and
121        // rebuilds plans with the fresh calibrated cost estimates.
122        let analyze_generations: smallvec::SmallVec<[u64; 4]> = collection_names
123            .iter()
124            .map(|name| self.collection_analyze_generation(name).unwrap_or(0))
125            .collect();
126
127        crate::cache::PlanKey {
128            // Issue #902: store the canonical text so PlanKey equality is
129            // collision-safe. query_hash stays a Hash accelerator only.
130            query_text: query_text.into(),
131            query_hash,
132            schema_version,
133            collection_generations,
134            analyze_generations,
135        }
136    }
137
138    /// Returns the query plan for a query, with cache status populated (CACHE-02).
139    ///
140    /// If the plan is cached, returns it with `cache_hit: Some(true)` and
141    /// `plan_reuse_count` set. Otherwise generates a fresh plan with
142    /// `cache_hit: Some(false)`.
143    ///
144    /// # Design decision: `explain_query` does not populate the cache
145    ///
146    /// `explain_query` intentionally does **not** insert a new plan into the
147    /// compiled plan cache. EXPLAIN is a diagnostic operation; allowing it to
148    /// influence cache state would make cache metrics (hit/miss ratios,
149    /// `plan_reuse_count`) unreliable because EXPLAIN calls would be
150    /// indistinguishable from real execution hits. Only `execute_query` is
151    /// authorised to write to the cache.
152    ///
153    /// # Errors
154    ///
155    /// Returns an error if the query is invalid.
156    pub fn explain_query(
157        &self,
158        query: &crate::velesql::Query,
159    ) -> Result<crate::velesql::QueryPlan> {
160        crate::velesql::QueryValidator::validate(query).map_err(|e| Error::Query(e.to_string()))?;
161
162        let plan_key = self.build_plan_key(query);
163
164        if let Some(cached) = self.compiled_plan_cache.get(&plan_key) {
165            let mut plan = cached.plan.clone();
166            plan.cache_hit = Some(true);
167            plan.plan_reuse_count = Some(
168                cached
169                    .reuse_count
170                    .load(std::sync::atomic::Ordering::Relaxed),
171            );
172            return Ok(plan);
173        }
174
175        let mut plan = self.build_plan_with_stats(query);
176        plan.cache_hit = Some(false);
177        plan.plan_reuse_count = Some(0);
178        Ok(plan)
179    }
180
181    /// Builds a query plan, resolving calibrated collection statistics AND
182    /// the registered secondary index set from the registry when available
183    /// (#471 — EXPLAIN real costs, #607 — `IndexLookup` wiring).
184    ///
185    /// The returned plan's `estimated_cost_ms` and `filter_strategy` are
186    /// calibrated via `CostEstimator` when stats exist for the query's
187    /// primary collection. Falls back to heuristics otherwise. The
188    /// `indexed_fields` argument is populated from
189    /// `Database::indexed_fields_for` so that `IndexLookup` nodes appear
190    /// in the EXPLAIN tree for WHERE clauses targeting indexed columns.
191    fn build_plan_with_stats(&self, query: &crate::velesql::Query) -> crate::velesql::QueryPlan {
192        let primary = &query.select.from;
193        let core_stats = self.get_collection_stats(primary).ok().flatten();
194        let indexed = self.indexed_fields_for(primary);
195        // For MATCH queries thread the live graph CollectionStats so the
196        // MatchTraversal strategy reflects the real graph shape (backlog #14).
197        let match_stats = query
198            .match_clause
199            .is_some()
200            .then(|| self.match_stats_for(primary))
201            .flatten();
202        crate::velesql::QueryPlan::from_query_with_all_stats(
203            query,
204            &indexed,
205            core_stats.as_ref(),
206            match_stats.as_ref(),
207        )
208    }
209
210    /// Executes a query with instrumentation and returns both plan and actual stats.
211    ///
212    /// Unlike `explain_query` (plan only) and `execute_query` (results only),
213    /// this method returns the full [`ExplainOutput`] with measured statistics.
214    /// The normal `execute_query` path is untouched — zero overhead on
215    /// non-ANALYZE queries.
216    ///
217    /// # Errors
218    ///
219    /// Returns an error if the query is invalid or execution fails.
220    pub fn explain_analyze_query(
221        &self,
222        query: &Query,
223        params: &std::collections::HashMap<String, serde_json::Value>,
224    ) -> Result<ExplainOutput> {
225        crate::velesql::QueryValidator::validate(query).map_err(|e| Error::Query(e.to_string()))?;
226
227        let plan = self.explain_query(query)?;
228        let start = std::time::Instant::now();
229        let (results, nodes, edges) = self.execute_query_counted(query, params)?;
230        let stats = ActualStats::from_counted(results.len() as u64, start.elapsed(), nodes, edges);
231        let node_stats = crate::velesql::build_leaf_node_stats(
232            &plan.root,
233            stats.actual_rows,
234            stats.actual_time_ms,
235        );
236        Ok(ExplainOutput::with_stats(plan, stats, node_stats))
237    }
238
239    /// Executes a `VelesQL` query with database-level JOIN resolution.
240    ///
241    /// This method resolves JOIN target collections from the database registry
242    /// and executes JOIN runtime in sequence. Query plans are cached and
243    /// reused for identical queries against unchanged collections (CACHE-02).
244    ///
245    /// # Errors
246    ///
247    /// Returns an error if the base collection or any JOIN collection is missing.
248    pub fn execute_query(
249        &self,
250        query: &crate::velesql::Query,
251        params: &std::collections::HashMap<String, serde_json::Value>,
252    ) -> Result<Vec<SearchResult>> {
253        // Resolve scalar subqueries (EPIC-039) into literals *before* validation
254        // so the validator and every downstream path see a subquery-free AST.
255        if let Some(rewritten) = self.resolve_subqueries(query, params)? {
256            return self.execute_query(&rewritten, params);
257        }
258
259        crate::velesql::QueryValidator::validate(query).map_err(|e| Error::Query(e.to_string()))?;
260
261        if let Some(results) = self.dispatch_non_select(query, params)? {
262            return Ok(results);
263        }
264
265        // Build plan key and check cache WITHOUT recording hit/miss metrics (CACHE-02).
266        //
267        // `contains()` is used instead of `get().is_some()` so that this
268        // existence check does not increment the hit/miss counters or
269        // `reuse_count`. Only `explain_query` (which surfaces these values to
270        // callers) should call `get()`.
271        let pre_exec_key = self.build_plan_key(query);
272        let is_cached = self.compiled_plan_cache.contains(&pre_exec_key);
273
274        let results = self.execute_select_query(query, params)?;
275
276        // Populate cache on miss (CACHE-02).
277        //
278        // C-1 TOCTOU fix: rebuild the plan key AFTER execution. Between the
279        // pre-execution `contains()` check and here, a concurrent writer may
280        // have bumped a collection's `write_generation` (e.g. via `upsert` on
281        // another thread). Rebuilding the key captures the post-execution
282        // state, so the cached plan is associated with the generation that was
283        // live when the plan was actually compiled — not a potentially stale
284        // pre-execution snapshot.
285        if !is_cached {
286            self.populate_plan_cache(query);
287        }
288
289        Ok(results)
290    }
291
292    /// Classifies and dispatches non-SELECT statement types.
293    ///
294    /// Returns `Ok(Some(results))` if handled, `Ok(None)` for SELECT queries.
295    fn dispatch_non_select(
296        &self,
297        query: &crate::velesql::Query,
298        params: &std::collections::HashMap<String, serde_json::Value>,
299    ) -> Result<Option<Vec<SearchResult>>> {
300        // Classify the statement type (at most one is Some).
301        let stmt_type = classify_statement(query);
302        match stmt_type {
303            StatementType::Admin(admin) => Ok(Some(self.execute_admin(admin)?)),
304            StatementType::Introspection(intro) => Ok(Some(self.execute_introspection(intro)?)),
305            StatementType::Ddl(ddl) => Ok(Some(self.execute_ddl(ddl)?)),
306            StatementType::Train(train) => Ok(Some(self.execute_train(train)?)),
307            StatementType::Dml(dml) => Ok(Some(self.execute_dml(dml, params)?)),
308            StatementType::Match => Ok(Some(self.execute_match_routed(query, params)?.0)),
309            StatementType::Select => Ok(None),
310        }
311    }
312
313    /// Resolves the target collection for a MATCH query.
314    ///
315    /// Resolution order: `SELECT ... FROM <collection> WHERE MATCH ...`, then a
316    /// `"_collection"` key in `params` (programmatic API), else a guidance error.
317    fn resolve_match_collection(
318        &self,
319        query: &crate::velesql::Query,
320        params: &std::collections::HashMap<String, serde_json::Value>,
321    ) -> Result<crate::collection::Collection> {
322        let collection_name = if !query.select.from.is_empty() {
323            query.select.from.clone()
324        } else if let Some(serde_json::Value::String(name)) = params.get("_collection") {
325            name.clone()
326        } else {
327            return Err(Error::Query(
328                "MATCH query requires a target collection. Either use \
329                 SELECT ... FROM <collection> WHERE MATCH ..., or pass \
330                 {\"_collection\": \"name\"} in params."
331                    .to_string(),
332            ));
333        };
334        self.resolve_collection(&collection_name)
335    }
336
337    /// Routes a MATCH query to its target collection and applies cross-collection
338    /// enrichment, returning results plus the graph-traversal counters
339    /// `(nodes_visited, edges_traversed)` measured during execution (for
340    /// EXPLAIN ANALYZE; the plain execution path discards them).
341    fn execute_match_routed(
342        &self,
343        query: &crate::velesql::Query,
344        params: &std::collections::HashMap<String, serde_json::Value>,
345    ) -> Result<(Vec<SearchResult>, u64, u64)> {
346        let coll = self.resolve_match_collection(query, params)?;
347        let (mut results, nodes_visited, edges_traversed) =
348            coll.execute_query_counted(query, params)?;
349        // Cross-collection enrichment: if any node pattern has a @collection
350        // annotation, look up payloads from those collections and merge them
351        // into the projected fields.
352        if let Some(mc) = &query.match_clause {
353            self.enrich_match_results_cross_collection(mc, &mut results);
354        }
355        Ok((results, nodes_visited, edges_traversed))
356    }
357
358    /// Executes a query and returns graph-traversal counters for EXPLAIN ANALYZE.
359    ///
360    /// MATCH queries report real `(nodes_visited, edges_traversed)`; every other
361    /// statement type reports `(_, 0, 0)` (no graph traversal occurred).
362    fn execute_query_counted(
363        &self,
364        query: &Query,
365        params: &std::collections::HashMap<String, serde_json::Value>,
366    ) -> Result<(Vec<SearchResult>, u64, u64)> {
367        if query.is_match_query() {
368            return self.execute_match_routed(query, params);
369        }
370        Ok((self.execute_query(query, params)?, 0, 0))
371    }
372
373    /// Executes the SELECT portion of a query, resolving JOINs if present.
374    fn execute_select_query(
375        &self,
376        query: &crate::velesql::Query,
377        params: &std::collections::HashMap<String, serde_json::Value>,
378    ) -> Result<Vec<SearchResult>> {
379        // EPIC-040 US-006: For compound queries, strip LIMIT from each operand so
380        // the set operation sees the full result sets.  The final LIMIT is applied
381        // once on the merged output (SQL-standard behaviour).
382        // Use MAX_LIMIT (not None) to avoid the default-10 cap downstream.
383        const COMPOUND_LIMIT: usize = 100_000;
384        let compound_limit = Some(COMPOUND_LIMIT as u64); // 100_000 fits u64 exactly.
385        let left_results = if query.compound.is_some() {
386            let mut left_query = query.clone();
387            left_query.select.limit = compound_limit;
388            self.execute_single_select(&left_query, params)?
389        } else {
390            return self.execute_single_select(query, params);
391        };
392
393        // compound is guaranteed Some here (non-compound returns above).
394        if let Some(ref compound) = query.compound {
395            let mut accumulated = left_results;
396            for (operator, right_select) in &compound.operations {
397                let mut right_query = crate::velesql::Query::new_select(right_select.clone());
398                right_query.select.limit = compound_limit;
399                let right_results = self.execute_single_select(&right_query, params)?;
400                accumulated = crate::collection::search::query::set_operations::apply_set_operation(
401                    accumulated,
402                    right_results,
403                    *operator,
404                    // Intermediate ops keep the server-side ceiling: truncating to the
405                    // user LIMIT here would drop rows a later chained set op still needs.
406                    COMPOUND_LIMIT,
407                );
408            }
409            // SQL-standard: LIMIT from the left (outer) SELECT applies to the final result.
410            if let Some(limit) = query.select.limit {
411                accumulated.truncate(usize::try_from(limit).unwrap_or(usize::MAX));
412            }
413            return Ok(accumulated);
414        }
415
416        Ok(left_results)
417    }
418
419    /// Collects sorted, deduplicated collection names referenced by a query,
420    /// including all compound operands (UNION, INTERSECT, EXCEPT).
421    ///
422    /// RF-DEDUP: Shared by `build_plan_key` and `populate_plan_cache`, which
423    /// both need the same sorted collection-name list from the query AST.
424    fn referenced_collection_names(query: &crate::velesql::Query) -> Vec<String> {
425        let mut names = vec![query.select.from.clone()];
426        for join in &query.select.joins {
427            names.push(join.table.clone());
428        }
429        if let Some(ref compound) = query.compound {
430            for (_, right_select) in &compound.operations {
431                names.push(right_select.from.clone());
432                for join in &right_select.joins {
433                    names.push(join.table.clone());
434                }
435            }
436        }
437        names.sort();
438        names.dedup();
439        names
440    }
441
442    /// Resolves a collection by name from all typed registries.
443    ///
444    /// Priority: vector collections first, then graph, then metadata.
445    /// Returns the inner `Collection` for query execution.
446    pub(super) fn resolve_collection(&self, name: &str) -> Result<crate::collection::Collection> {
447        if let Some(vc) = self.get_vector_collection(name) {
448            return Ok(vc.inner);
449        }
450        if let Some(gc) = self.get_graph_collection(name) {
451            return Ok(gc.inner);
452        }
453        if let Some(mc) = self.get_metadata_collection(name) {
454            return Ok(mc.inner);
455        }
456        Err(Error::CollectionNotFound(name.to_string()))
457    }
458
459    /// Resolves a collection that supports write operations (INSERT/UPDATE/TRAIN).
460    ///
461    /// Checks vector, graph, and metadata collections. Metadata-only collections
462    /// support INSERT/UPDATE for metadata fields (no vectors).
463    pub(super) fn resolve_writable_collection(
464        &self,
465        name: &str,
466    ) -> Result<crate::collection::Collection> {
467        if let Some(vc) = self.get_vector_collection(name) {
468            return Ok(vc.inner);
469        }
470        if let Some(gc) = self.get_graph_collection(name) {
471            return Ok(gc.inner);
472        }
473        if let Some(mc) = self.get_metadata_collection(name) {
474            return Ok(mc.inner);
475        }
476        Err(Error::CollectionNotFound(name.to_string()))
477    }
478
479    /// Executes a single SELECT (no compound), resolving JOINs if present.
480    ///
481    /// Orchestrates filter pushdown and join strategy selection:
482    /// 1. Analyze WHERE for pushdown-eligible conditions
483    /// 2. Strip pushed conditions from base query
484    /// 3. For each JOIN: lookup, filtered, or full `ColumnStore` path
485    /// 4. Apply post-join filters (cross-source predicates)
486    fn execute_single_select(
487        &self,
488        query: &crate::velesql::Query,
489        params: &std::collections::HashMap<String, serde_json::Value>,
490    ) -> Result<Vec<SearchResult>> {
491        let base_collection = self.resolve_collection(&query.select.from)?;
492
493        let mut single_query = query.clone();
494        single_query.compound = None;
495
496        if single_query.select.joins.is_empty() {
497            return base_collection.execute_query(&single_query, params);
498        }
499
500        let analysis = Self::prepare_join_pushdown(&mut single_query, params)?;
501        let pushed = analysis.column_store_filters.clone();
502
503        let row_budget = Self::join_row_budget(&query.select, &analysis);
504
505        let mut results = base_collection.execute_query(&single_query, params)?;
506        for join in &query.select.joins {
507            results = self.execute_single_join(&results, join, &pushed, row_budget)?;
508        }
509
510        // Apply post-join filters: cross-source predicates that reference
511        // columns from both the base collection and joined ColumnStore tables.
512        if !analysis.post_join_filters.is_empty() {
513            results = Self::apply_post_join_filters(
514                &base_collection,
515                results,
516                &analysis.post_join_filters,
517                params,
518                &query.select.from_alias,
519            )?;
520        }
521
522        Ok(results)
523    }
524
525    /// Resolves WHERE parameters, runs pushdown analysis, and strips pushed
526    /// conditions from the base query (JOIN path of `execute_single_select`).
527    ///
528    /// Parameter placeholders are resolved before the analysis so pushed-down
529    /// filters never silently convert them to NULL at the `ColumnStore` layer
530    /// (the collection pipeline resolves its own copy independently).
531    fn prepare_join_pushdown(
532        single_query: &mut crate::velesql::Query,
533        params: &std::collections::HashMap<String, serde_json::Value>,
534    ) -> Result<crate::collection::search::query::pushdown::PushdownAnalysis> {
535        if let Some(cond) = single_query.select.where_clause.take() {
536            single_query.select.where_clause = Some(
537                crate::collection::Collection::resolve_condition_params(&cond, params)?,
538            );
539        }
540
541        let analysis = Self::analyze_join_pushdown_for_select(&single_query.select);
542
543        let resolved_where = single_query.select.where_clause.clone();
544        single_query.select.joins.clear();
545        if !analysis.column_store_filters.is_empty() {
546            single_query.select.where_clause = Self::strip_pushed_conditions(
547                resolved_where.as_ref(),
548                &analysis.column_store_filters,
549            );
550        }
551        Ok(analysis)
552    }
553
554    /// Computes the bound on joined rows to materialize.
555    ///
556    /// When the query has an explicit LIMIT, no post-join filters, and no
557    /// ORDER BY (which could reorder past the window), the bound is the
558    /// effective `LIMIT + OFFSET`. GROUP BY / HAVING / DISTINCT also disqualify
559    /// the bounded shape: SQL LIMIT bounds output *groups/rows*, not input rows,
560    /// so truncating joined input to `LIMIT` would drop rows that belong to
561    /// groups still inside the window. Otherwise downstream stages may reorder or
562    /// drop rows, so we fall back to the conservative server-side ceiling
563    /// [`JOIN_ROW_CEILING`] — still bounding OOM without affecting correctness.
564    pub(super) fn join_row_budget(
565        select: &crate::velesql::SelectStatement,
566        analysis: &crate::collection::search::query::pushdown::PushdownAnalysis,
567    ) -> usize {
568        use crate::collection::search::query::JOIN_ROW_CEILING;
569        use crate::velesql::DistinctMode;
570        let bounded_shape = analysis.post_join_filters.is_empty()
571            && select.order_by.is_none()
572            && select.group_by.is_none()
573            && select.having.is_none()
574            && select.distinct == DistinctMode::None;
575        match select.limit {
576            Some(limit) if bounded_shape => {
577                let limit = usize::try_from(limit).unwrap_or(JOIN_ROW_CEILING);
578                let offset = select
579                    .offset
580                    .map_or(0, |o| usize::try_from(o).unwrap_or(JOIN_ROW_CEILING));
581                limit.saturating_add(offset).min(JOIN_ROW_CEILING)
582            }
583            _ => JOIN_ROW_CEILING,
584        }
585    }
586
587    // NOTE: analyze_join_pushdown_for_select, apply_post_join_filters
588    // moved to join_pushdown.rs (NLOC/file reduction)
589
590    /// Inserts a compiled plan into the cache after a cache miss (CACHE-02).
591    fn populate_plan_cache(&self, query: &crate::velesql::Query) {
592        let compiled = std::sync::Arc::new(crate::cache::CompiledPlan {
593            plan: self.build_plan_with_stats(query),
594            referenced_collections: Self::referenced_collection_names(query),
595            compiled_at: std::time::Instant::now(),
596            reuse_count: std::sync::atomic::AtomicU64::new(0),
597        });
598        // Rebuild key after execution to reflect current write_generation (C-1).
599        let post_exec_key = self.build_plan_key(query);
600        self.compiled_plan_cache.insert(post_exec_key, compiled);
601    }
602
603    /// Dispatches a DML statement (INSERT, UPSERT, UPDATE, DELETE, or edge mutations).
604    pub(super) fn execute_dml(
605        &self,
606        dml: &crate::velesql::DmlStatement,
607        params: &std::collections::HashMap<String, serde_json::Value>,
608    ) -> Result<Vec<SearchResult>> {
609        match dml {
610            crate::velesql::DmlStatement::Insert(stmt)
611            | crate::velesql::DmlStatement::Upsert(stmt) => self.execute_insert(stmt, params),
612            crate::velesql::DmlStatement::Update(stmt) => self.execute_update(stmt, params),
613            crate::velesql::DmlStatement::InsertEdge(stmt) => self.execute_insert_edge(stmt),
614            crate::velesql::DmlStatement::Delete(stmt) => self.execute_delete(stmt),
615            crate::velesql::DmlStatement::DeleteEdge(stmt) => self.execute_delete_edge(stmt),
616            crate::velesql::DmlStatement::SelectEdges(stmt) => self.execute_select_edges(stmt),
617            crate::velesql::DmlStatement::InsertNode(stmt) => self.execute_insert_node(stmt),
618        }
619    }
620}