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}