Skip to main content

akar_main/connection/
query.rs

1use super::Connection;
2use super::plan_cache::{CachedPlan, normalize_query};
3use crate::database::Database;
4use crate::prepared_statement::PreparedStatement;
5use crate::query_result::QueryResult;
6use akar_binder::Binder;
7use akar_binder::bound_statement::BoundStatement;
8use akar_common::error::ProcessorError;
9use akar_common::types::Value;
10use akar_optimizer::Optimizer;
11use akar_parser::parse;
12use akar_planner::QueryPlanner;
13use akar_planner::logical_operator::LogicalOperator;
14use akar_processor::QueryProcessor;
15use akar_processor::processor::{SchemaDdlFn, SchemaDdlOp, SequenceFn, StandaloneCallHandler, SubqueryFn};
16use std::collections::HashMap;
17use std::sync::Arc;
18
19impl Connection {
20    /// Execute a Cypher query and return the result.
21    pub fn query(&self, query_str: &str) -> Result<QueryResult, String> {
22        let trimmed = query_str.trim();
23
24        // Skip empty queries
25        if trimmed.is_empty() {
26            return Ok(QueryResult::new(Vec::new()));
27        }
28
29        // Handle SET spill_threshold
30        if let Some(value) = trimmed
31            .strip_prefix("SET")
32            .and_then(|s| s.trim().strip_prefix("spill_threshold"))
33            .and_then(|s| s.trim().strip_prefix("="))
34            .map(|s| s.trim())
35        {
36            let bytes: u64 = value.parse().map_err(|_| {
37                format!("Invalid spill_threshold value '{value}'. Expected a non-negative integer (bytes).")
38            })?;
39            self.database.set_spill_threshold(bytes);
40            return Ok(QueryResult::success_message(format!(
41                "spill_threshold set to {bytes} bytes"
42            )));
43        }
44
45        if let Some(value) = trimmed
46            .strip_prefix("SET")
47            .and_then(|s| s.trim().strip_prefix("concurrent_writes"))
48            .and_then(|s| s.trim().strip_prefix("="))
49            .map(|s| s.trim())
50        {
51            let enabled = match value.to_lowercase().as_str() {
52                "true" | "1" | "yes" => true,
53                "false" | "0" | "no" => false,
54                _ => return Err("Invalid value for concurrent_writes. Use true or false.".into()),
55            };
56            self.database.transaction_manager.set_concurrent_writes(enabled);
57            return Ok(QueryResult::success_message(format!(
58                "concurrent_writes set to {enabled}"
59            )));
60        }
61
62        // Normalize the query into a stable cache key
63        let normalized = normalize_query(trimmed);
64
65        // Try the plan cache first — skips parse/bind/plan/optimize entirely.
66        // Entries are only valid if the catalog hasn't changed since build.
67        let catalog_version = self
68            .database
69            .catalog
70            .lock()
71            .map_err(|e| format!("Catalog lock error: {e}"))?
72            .version();
73
74        {
75            let mut cache = self.plan_cache.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
76            if let Some(cached) = cache.get(&normalized).filter(|c| c.catalog_version == catalog_version) {
77                // Cheap Arc bumps — no deep clone of the bound statement or the
78                // operator tree (P51.47).
79                let bound = cached.bound.clone();
80                let plan = cached.plan.clone();
81                drop(cache);
82                return self.execute_with_plan(&bound, Some(&plan));
83            }
84        }
85
86        // Cache miss: full pipeline
87        // 1. Parse
88        let statement = parse(trimmed).map_err(|e| format!("Parse error: {e}"))?;
89
90        // 2. Bind (using shared catalog Arc — DDL mutations persist)
91        let binder = Binder::new(self.database.catalog.clone());
92        let bound = binder.bind(statement).map_err(|e| format!("Bind error: {e}"))?;
93
94        // 3. Build (and cache) the optimized plan for plan-cachable statements.
95        //    DDL and other non-query statements are routed inside
96        //    execute_query_inner and never cached.
97        let plan_opt: Option<Arc<Vec<LogicalOperator>>> = if is_plan_cachable(&bound) {
98            let plan = Arc::new(self.build_optimized_plan(&bound)?);
99            let mut cache = self.plan_cache.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
100            cache.insert(
101                normalized,
102                CachedPlan {
103                    bound: Arc::new(bound.clone()),
104                    plan: Arc::clone(&plan),
105                    catalog_version,
106                },
107            );
108            Some(plan)
109        } else {
110            None
111        };
112
113        self.execute_with_plan(&bound, plan_opt.as_ref())
114    }
115
116    /// Shared execution path for `query()`: wraps write statements in an OCC
117    /// transaction (when concurrent writes are enabled) and delegates to
118    /// `execute_query_inner`, committing or rolling back as appropriate.
119    fn execute_with_plan(
120        &self,
121        bound: &BoundStatement,
122        plan: Option<&Arc<Vec<LogicalOperator>>>,
123    ) -> Result<QueryResult, String> {
124        // Read-only databases reject any write statement (DDL or DML).
125        if self.database.config.read_only && Connection::is_write_statement(bound) {
126            return Err("Database is in read-only mode; write statements are not allowed".into());
127        }
128
129        // Determine if this is a write operation and begin a transaction.
130        // Every write statement is wrapped in a transaction (both single-writer
131        // and concurrent modes) so MVCC records (VersionInfo/commit_history)
132        // are populated and rollback/conflict handling works uniformly (P52.18).
133        let is_write = Connection::is_write_statement(bound);
134        let mut txn_opt: Option<akar_transaction::Transaction> =
135            if is_write { Some(self.begin_write_txn()?) } else { None };
136
137        // Execute the query within a transaction scope
138        let query_result = self.execute_query_inner(bound, txn_opt.as_mut(), plan);
139
140        // Commit or rollback based on result
141        match (is_write, &query_result) {
142            (true, Ok(_)) => {
143                if let Some(ref mut txn) = txn_opt {
144                    self.commit_write_txn(txn)?;
145                }
146            }
147            (true, Err(e)) => {
148                if let Some(ref mut txn) = txn_opt {
149                    match self.rollback_write_txn(txn) {
150                        Ok(_records) => {
151                            tracing::warn!("Transaction rolled back due to error: {e}");
152                        }
153                        Err(rollback_err) => {
154                            tracing::error!("Transaction rollback ALSO failed: {rollback_err} (original error: {e})");
155                        }
156                    }
157                }
158            }
159            _ => {}
160        }
161
162        // P52.38: after a successful write, rebuild the HNSW graph of any vector
163        // index on the written node tables so it reflects the current rows.
164        if query_result.is_ok() && Connection::is_write_statement(bound) {
165            let written = Connection::extract_write_tables(bound);
166            if !written.is_empty() {
167                self.database.refresh_vector_indexes(&written);
168            }
169        }
170
171        query_result
172    }
173
174    /// Run planner + optimizer to produce the optimized logical plan.
175    fn build_optimized_plan(&self, bound: &BoundStatement) -> Result<Vec<LogicalOperator>, String> {
176        let planner = QueryPlanner::new();
177        let logical_plan = planner.plan(bound.clone()).map_err(|e| format!("Plan error: {e}"))?;
178        let optimizer = Optimizer::with_stats(self.database.stats_store.clone());
179        Ok(optimizer.optimize(logical_plan))
180    }
181
182    /// Inner query execution (after parsing and binding, before commit/rollback).
183    ///
184    /// `cached_plan` carries a pre-built optimized plan from the plan cache;
185    /// when `None`, the plan is built here (DDL and non-query statements
186    /// return before this point).
187    pub(crate) fn execute_query_inner(
188        &self,
189        bound: &BoundStatement,
190        mut txn_opt: Option<&mut akar_transaction::Transaction>,
191        cached_plan: Option<&Arc<Vec<LogicalOperator>>>,
192    ) -> Result<QueryResult, String> {
193        // Route: DDL vs DML (handle_ddl returns Some for DDL, None for DML)
194        if let Some(result) = self.handle_ddl(bound, txn_opt.as_deref_mut())? {
195            // DDL may have modified the catalog; persist it so schema
196            // changes survive a restart, then checkpoint if needed.
197            self.database.persist_catalog()?;
198            self.maybe_auto_checkpoint()?;
199            return Ok(result);
200        }
201
202        // Lock tables for DML writes only in single-writer mode.
203        // When concurrent_writes is enabled, OCC row-level conflict detection
204        // replaces table-level locking (see record_write / validate_write_set).
205        if let Some(ref txn) = txn_opt {
206            if !self.database.transaction_manager.allow_concurrent_writes() {
207                let write_tables = Connection::extract_write_tables(bound);
208                for tid in write_tables {
209                    self.database.transaction_manager.lock_table(txn.transaction_id, tid)?;
210                }
211            }
212        }
213
214        // Plan (from cache when available, otherwise build now). Cached plans
215        // are shared Arcs — executing through the Arc avoids a second deep
216        // clone of the operator tree (P51.47).
217        let optimized_plan: Arc<Vec<LogicalOperator>> = match cached_plan {
218            Some(plan) => plan.clone(),
219            None => Arc::new(self.build_optimized_plan(bound)?),
220        };
221
222        if optimized_plan.is_empty() {
223            return Ok(QueryResult::success_message("Query executed (no result)".into()));
224        }
225
226        // Capture MVCC snapshot for read isolation.
227        // For write transactions, use the txn's snapshot_ts.
228        // For read-only queries, capture a fresh snapshot from the transaction manager.
229        let (snapshot_ts, commit_history) = if let Some(ref txn) = txn_opt {
230            (
231                txn.snapshot_ts,
232                self.database.transaction_manager.commit_history_snapshot(),
233            )
234        } else {
235            // Read-only query: capture snapshot at current commit point
236            let ts = self.database.transaction_manager.current_commit_ts();
237            let history = self.database.transaction_manager.commit_history_snapshot();
238            (Some(ts), history)
239        };
240
241        // Execute
242        let processor = self
243            .create_processor()
244            .with_snapshot(snapshot_ts, commit_history)
245            .with_txn_id(txn_opt.as_ref().map(|t| t.transaction_id));
246        let chunks = processor
247            .execute(&optimized_plan)
248            .map_err(|e| format!("Execute error: {e}"))?;
249
250        // Record row-level writes for OCC conflict detection
251        if let Some(ref txn) = txn_opt {
252            let written_rows = processor.take_written_rows();
253            let tm = &self.database.transaction_manager;
254            for (table_id, row_id) in written_rows {
255                tm.record_write(txn.transaction_id, table_id, row_id);
256            }
257        }
258
259        // Drain undo records captured by write operators into the txn so a
260        // rollback (or OCC conflict loser) can revert the in-place writes (P52.18).
261        if let Some(ref mut txn) = txn_opt {
262            let undo = processor.take_undo_records();
263            txn.undo_records.extend(undo);
264            // Drain typed WAL records so replay is self-sufficient (P60.2).
265            let wal_records = processor.take_wal_records();
266            self.append_local_wal(txn.transaction_id, wal_records);
267        }
268
269        // Auto-checkpoint after DML execution
270        self.maybe_auto_checkpoint()?;
271
272        Ok(QueryResult::new(chunks))
273    }
274
275    /// Prepare a query for parameterized execution.
276    ///
277    /// Parses and binds the query, extracting parameter names (like `$name`).
278    /// The prepared statement can be executed multiple times with different
279    /// parameter values via [`Connection::execute`].
280    pub fn prepare(&self, query_str: &str) -> Result<PreparedStatement, String> {
281        let trimmed = query_str.trim();
282
283        // Check cache first
284        {
285            let cache = self.statement_cache.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
286            if let Some(cached) = cache.get(trimmed) {
287                return Ok(cached.clone());
288            }
289        }
290
291        // Parse
292        let statement = parse(trimmed).map_err(|e| format!("Parse error: {e}"))?;
293
294        // Bind
295        let binder = Binder::new(self.database.catalog.clone());
296        let bound = binder.bind(statement).map_err(|e| format!("Bind error: {e}"))?;
297
298        let prepared = PreparedStatement::new(trimmed.to_string(), bound);
299
300        // Cache it
301        {
302            let mut cache = self.statement_cache.lock().map_err(|e| format!("Lock poisoned: {e}"))?;
303            cache.insert(trimmed.to_string(), prepared.clone());
304        }
305
306        Ok(prepared)
307    }
308
309    /// Execute a prepared statement with the given parameter values.
310    ///
311    /// Parameters are provided as a vector of `(name, value)` pairs.
312    /// The values are substituted for `$name` references in the query before
313    /// planning and execution.
314    pub fn execute(&self, prepared: &PreparedStatement, params: Vec<(&str, Value)>) -> Result<QueryResult, String> {
315        // Build parameter map
316        let mut param_map = HashMap::new();
317        let num_expected = prepared.parameters.len();
318        for (name, value) in &params {
319            param_map.insert(name.to_string(), value.clone());
320        }
321
322        // Validate all parameters are provided
323        for p in &prepared.parameters {
324            if !param_map.contains_key(p) {
325                return Err(format!("Missing parameter: ${}", p));
326            }
327        }
328
329        // Check for unknown parameters
330        if params.len() > num_expected {
331            return Err(format!("Expected {} parameter(s), got {}", num_expected, params.len()));
332        }
333
334        // Read-only databases reject any write statement (DDL or DML).
335        if self.database.config.read_only && Connection::is_write_statement(&prepared.bound_statement) {
336            return Err("Database is in read-only mode; write statements are not allowed".into());
337        }
338
339        // Substitute parameters in the bound statement. Done before `handle_ddl`
340        // so prepared DML (CREATE/MERGE) with `$param` in pattern properties gets
341        // concrete values too (P51.31).
342        let substituted =
343            crate::connection::substitute::substitute_params_in_statement(&prepared.bound_statement, &param_map)?;
344
345        // Every write statement is wrapped in a transaction so MVCC records
346        // are populated and rollback/conflict handling works (P52.18). The
347        // txn must exist before `handle_ddl`: it attaches the WAL sink that
348        // journals a self-sufficient insert (`I`) record for prepared
349        // CREATE/MERGE DML. Previously `handle_ddl` received `None`, so the
350        // row was written live with no WAL record at all and was silently
351        // lost on crash (P60.7).
352        let is_write = Connection::is_write_statement(&prepared.bound_statement);
353        let mut txn_opt: Option<akar_transaction::Transaction> =
354            if is_write { Some(self.begin_write_txn()?) } else { None };
355
356        // Handle DDL/DML prepared statements (create/merge/etc.). Pass the live
357        // txn so journaling matches the literal `query()` path.
358        match self.handle_ddl(&substituted, txn_opt.as_mut()) {
359            Ok(Some(result)) => {
360                self.database.persist_catalog()?;
361                self.maybe_auto_checkpoint()?;
362                if is_write {
363                    if let Some(ref mut txn) = txn_opt {
364                        self.commit_write_txn(txn)?;
365                    }
366                }
367                if is_write {
368                    let written = Connection::extract_write_tables(&substituted);
369                    if !written.is_empty() {
370                        self.database.refresh_vector_indexes(&written);
371                    }
372                }
373                return Ok(result);
374            }
375            Ok(None) => {}
376            Err(e) => {
377                if is_write {
378                    if let Some(ref mut txn) = txn_opt {
379                        match self.rollback_write_txn(txn) {
380                            Ok(_) => tracing::warn!("Prepared DDL/DML rolled back due to error: {e}"),
381                            Err(rollback_err) => {
382                                tracing::error!("Prepared rollback ALSO failed: {rollback_err} (original: {e})");
383                            }
384                        }
385                    }
386                }
387                return Err(e);
388            }
389        }
390
391        // Plan
392        let planner = QueryPlanner::new();
393        let logical_plan = match planner.plan(substituted) {
394            Ok(p) => p,
395            Err(e) => {
396                if is_write {
397                    if let Some(ref mut txn) = txn_opt {
398                        let _ = self.rollback_write_txn(txn);
399                    }
400                }
401                return Err(format!("Plan error: {e}"));
402            }
403        };
404
405        if logical_plan.is_empty() {
406            if is_write {
407                if let Some(ref mut txn) = txn_opt {
408                    self.commit_write_txn(txn)?;
409                }
410            }
411            return Ok(QueryResult::success_message("Query executed (no result)".into()));
412        }
413
414        // Optimize
415        let optimizer = Optimizer::with_stats(self.database.stats_store.clone());
416        let optimized_plan = optimizer.optimize(logical_plan);
417
418        // Capture MVCC snapshot for read isolation
419        let (snapshot_ts, history) = if let Some(ref txn) = txn_opt {
420            (
421                txn.snapshot_ts,
422                self.database.transaction_manager.commit_history_snapshot(),
423            )
424        } else {
425            let ts = self.database.transaction_manager.current_commit_ts();
426            (Some(ts), self.database.transaction_manager.commit_history_snapshot())
427        };
428
429        // Execute
430        let processor = self
431            .create_processor()
432            .with_snapshot(snapshot_ts, history)
433            .with_txn_id(txn_opt.as_ref().map(|t| t.transaction_id));
434        let chunks = match processor.execute(&optimized_plan) {
435            Ok(c) => c,
436            Err(e) => {
437                if is_write {
438                    if let Some(ref mut txn) = txn_opt {
439                        match self.rollback_write_txn(txn) {
440                            Ok(_) => tracing::warn!("Prepared write rolled back due to error: {e}"),
441                            Err(rollback_err) => {
442                                tracing::error!("Prepared rollback ALSO failed: {rollback_err} (original: {e})");
443                            }
444                        }
445                    }
446                }
447                return Err(format!("Execute error: {e}"));
448            }
449        };
450
451        // Record row-level writes for OCC conflict detection
452        if let Some(ref txn) = txn_opt {
453            let written_rows = processor.take_written_rows();
454            let tm = &self.database.transaction_manager;
455            for (table_id, row_id) in written_rows {
456                tm.record_write(txn.transaction_id, table_id, row_id);
457            }
458        }
459
460        // Drain undo records into the txn for rollback/conflict handling (P52.18).
461        if let Some(ref mut txn) = txn_opt {
462            let undo = processor.take_undo_records();
463            txn.undo_records.extend(undo);
464            // Drain typed WAL records so replay is self-sufficient (P60.2).
465            let wal_records = processor.take_wal_records();
466            self.append_local_wal(txn.transaction_id, wal_records);
467        }
468
469        // Commit or rollback based on result. Since P60.2 the commit pipeline
470        // no longer persists the column mirrors per-commit: typed WAL records
471        // make replay self-sufficient, mirrors are written by checkpoints.
472        if is_write {
473            if let Some(ref mut txn) = txn_opt {
474                self.commit_write_txn(txn)?;
475            }
476        }
477
478        // After a successful write, rebuild the HNSW graph of any vector index
479        // on the written node tables so it reflects the current rows (mirrors
480        // the literal path's P52.38 refresh).
481        if is_write {
482            let written = Connection::extract_write_tables(&prepared.bound_statement);
483            if !written.is_empty() {
484                self.database.refresh_vector_indexes(&written);
485            }
486        }
487
488        // Auto-checkpoint after DML execution
489        self.maybe_auto_checkpoint()?;
490
491        Ok(QueryResult::new(chunks))
492    }
493
494    /// The checkpoint_threshold config controls this:
495    /// - -1 (default): signal checkpoint after every write (every DML/DDL).
496    /// - 0: never auto-checkpoint (manual only via `CHECKPOINT`).
497    /// - N > 0: signal checkpoint when WAL total_size exceeds N bytes.
498    pub(crate) fn maybe_auto_checkpoint(&self) -> Result<(), String> {
499        if !self.database.config.auto_checkpoint {
500            return Ok(()); // Auto-checkpoint disabled by config master switch
501        }
502        let threshold = self.database.config.checkpoint_threshold;
503        if threshold == 0 {
504            return Ok(()); // Auto-checkpoint disabled
505        }
506
507        let should_checkpoint = if threshold < 0 {
508            true
509        } else {
510            self.database.storage_manager.wal_size() > threshold as usize
511        };
512
513        if should_checkpoint {
514            // Signal the background worker rather than doing it inline.
515            self.database.transaction_manager.schedule_auto_checkpoint();
516            tracing::debug!("Auto-checkpoint signaled to background worker");
517        }
518
519        Ok(())
520    }
521
522    /// Wait for checkpoint to finish (for CHECKPOINT command).
523    pub(crate) fn do_sync_checkpoint(&self) -> Result<(), String> {
524        let tm = &self.database.transaction_manager;
525        let drain_fn = |timeout: std::time::Duration| -> bool { tm.stop_new_txns_and_wait_until_all_leave(timeout) };
526        self.database
527            .storage_manager
528            .checkpoint_with_drain(Some(&drain_fn))
529            .map_err(|e| format!("Checkpoint failed: {e}"))?;
530        tracing::debug!("Sync checkpoint completed");
531        Ok(())
532    }
533
534    /// Create a QueryProcessor configured with the shared handler callbacks.
535    ///
536    /// The callbacks (sequence, schema DDL, query, subquery, standalone-call
537    /// registry) only depend on the `Database`, so they are built once and
538    /// reused across every query — this avoids re-allocating ~30 handler Arc
539    /// closures plus the standalone-call registry per execution (P51.47).
540    pub(crate) fn create_processor(&self) -> QueryProcessor {
541        let handlers = self
542            .processor_handlers
543            .get_or_init(|| Arc::new(build_processor_handlers(&self.database)));
544
545        QueryProcessor::with_catalog(
546            self.database.function_registry.clone(),
547            self.database.table_catalog(),
548            self.database.vfs.clone(),
549        )
550        .with_sequence_fn(handlers.sequence_fn.clone())
551        .with_subquery_fn(handlers.subquery_fn.clone())
552        .with_schema_ddl_fn(handlers.schema_ddl_fn.clone())
553        .with_standalone_call_handler(handlers.standalone_call_handler.clone())
554    }
555}
556
557/// Immutable per-database processor callbacks, shared by every query through
558/// an `Arc` stored on the [`Database`]. Building them once instead of on every
559/// query removes the per-query allocation of the closure tree and the
560/// standalone-call registry (P51.47).
561pub(crate) struct ProcessorHandlers {
562    pub sequence_fn: SequenceFn,
563    pub schema_ddl_fn: SchemaDdlFn,
564    pub subquery_fn: SubqueryFn,
565    pub standalone_call_handler: Arc<dyn StandaloneCallHandler>,
566}
567
568/// Build the shared processor handlers for a database.
569fn build_processor_handlers(db: &Arc<Database>) -> ProcessorHandlers {
570    let seq_fn = super::utils::make_sequence_callback(db.catalog.clone());
571
572    // schema_ddl_fn: created before query_fn/subquery_fn so they can capture it
573    let db_sddl = db.clone();
574    let schema_ddl_fn: SchemaDdlFn = Arc::new(move |op: SchemaDdlOp| -> Result<String, ProcessorError> {
575        match op {
576            SchemaDdlOp::CreateSequence {
577                name,
578                if_not_exists,
579                start_value,
580                increment,
581                min_value,
582                max_value,
583                cycle,
584            } => {
585                let mut catalog = db_sddl.catalog.lock().map_err(|e| format!("Catalog lock: {e}"))?;
586                match catalog.create_sequence(name.clone(), start_value, increment, min_value, max_value, cycle) {
587                    akar_catalog::CatalogResult::Created { .. } => Ok(format!("Sequence '{}' created", name)),
588                    akar_catalog::CatalogResult::AlreadyExists => {
589                        if if_not_exists {
590                            Ok(format!("Sequence '{}' already exists", name))
591                        } else {
592                            Err(ProcessorError::Execution(format!("Sequence '{}' already exists", name)))
593                        }
594                    }
595                    other => Err(ProcessorError::Execution(format!(
596                        "Failed to create sequence: {:?}",
597                        other
598                    ))),
599                }
600            }
601            SchemaDdlOp::DropSequence { name, if_exists } => {
602                let mut catalog = db_sddl.catalog.lock().map_err(|e| format!("Catalog lock: {e}"))?;
603                match catalog.drop_sequence(&name) {
604                    akar_catalog::CatalogResult::Dropped { .. } => Ok(format!("Sequence '{}' dropped", name)),
605                    akar_catalog::CatalogResult::NotFound => {
606                        if if_exists {
607                            Ok(format!("Sequence '{}' not found", name))
608                        } else {
609                            Err(ProcessorError::Execution(format!("Sequence '{}' not found", name)))
610                        }
611                    }
612                    other => Err(ProcessorError::Execution(format!(
613                        "Failed to drop sequence: {:?}",
614                        other
615                    ))),
616                }
617            }
618            SchemaDdlOp::ExportDatabase {
619                file_path,
620                file_type,
621                schema_only,
622            } => {
623                // Delegate to the same implementation as the direct
624                // `BoundExportDatabase` path (connection/copy.rs) so the
625                // planner-routed EXPORT also writes the data files, not just
626                // schema.cypher/copy.cypher (DRY, P51.42).
627                let conn = super::Connection::new(&db_sddl);
628                let bound = akar_binder::bound_statement::BoundExportDatabase {
629                    file_path,
630                    file_type,
631                    schema_only,
632                    options: Default::default(),
633                };
634                let result = conn
635                    .execute_export_database(&bound)
636                    .map_err(ProcessorError::Execution)?;
637                let msg = result
638                    .and_then(|r| r.message)
639                    .unwrap_or_else(|| format!("Database exported to '{}'", bound.file_path));
640                Ok(msg)
641            }
642            SchemaDdlOp::ImportDatabase {
643                file_path,
644                query,
645                index_query,
646            } => {
647                // Execute the import through a fresh connection so every
648                // statement runs the full pipeline (P52.12: statements are
649                // split on `;` — the exporter writes multi-line DDL).
650                let conn = super::Connection::new(&db_sddl);
651                let mut executed = 0usize;
652                let mut skipped = 0usize;
653                for stmt in super::copy::split_cypher_statements(&query)
654                    .into_iter()
655                    .chain(super::copy::split_cypher_statements(&index_query))
656                {
657                    match conn.query(&stmt) {
658                        Ok(_) => executed += 1,
659                        Err(e) => {
660                            tracing::warn!("Import statement skipped (may be duplicate): {e}");
661                            skipped += 1;
662                        }
663                    }
664                }
665                Ok(format!(
666                    "Imported {executed} statement(s) from '{file_path}' ({skipped} skipped)"
667                ))
668            }
669        }
670    });
671
672    // query_fn: execute arbitrary Cypher string → QueryResult (for export_csv / export_parquet CALL)
673    let db_qf = db.clone();
674    let query_fn: crate::connection::standalone_call::QueryFn = Arc::new({
675        let schema_ddl_qf = schema_ddl_fn.clone();
676        move |query_str: &str| -> Result<crate::query_result::QueryResult, String> {
677            let stmt = akar_parser::parse(query_str).map_err(|e| format!("Parse error: {e}"))?;
678            let binder = Binder::new(db_qf.catalog.clone());
679            let bound = binder.bind(stmt).map_err(|e| format!("Bind error: {e}"))?;
680            let planner = QueryPlanner::new();
681            let logical_plan = planner.plan(bound).map_err(|e| format!("Plan error: {e}"))?;
682            let optimizer = Optimizer::with_stats(db_qf.stats_store.clone());
683            let optimized_plan = optimizer.optimize(logical_plan);
684
685            let processor = QueryProcessor::with_catalog(
686                db_qf.function_registry.clone(),
687                db_qf.table_catalog(),
688                db_qf.vfs.clone(),
689            )
690            .with_schema_ddl_fn(schema_ddl_qf.clone())
691            .with_standalone_call_handler(Arc::new(
692                crate::connection::standalone_call::DbStandaloneCallHandler::new(db_qf.clone()),
693            ))
694            .with_snapshot(
695                Some(db_qf.transaction_manager.current_commit_ts()),
696                db_qf.transaction_manager.commit_history_snapshot(),
697            );
698
699            let chunks = processor
700                .execute(&optimized_plan)
701                .map_err(|e| format!("Execute error: {e}"))?;
702
703            let num_rows: usize = chunks.iter().map(|c| c.size).sum();
704            let num_columns = chunks.first().map(|c| c.num_fields()).unwrap_or(0);
705            Ok(crate::query_result::QueryResult {
706                chunks,
707                num_rows,
708                num_columns,
709                success: true,
710                error_message: None,
711                message: None,
712                summary: None,
713            })
714        }
715    });
716
717    let db_sq = db.clone();
718    let subquery_fn: SubqueryFn = Arc::new({
719        let schema_ddl_sq = schema_ddl_fn.clone();
720        move |query: &akar_parser::ast::Query| -> Result<Vec<akar_common::vector::DataChunk>, ProcessorError> {
721            let stmt = akar_parser::ast::Statement::Query(query.clone());
722            let binder = Binder::new(db_sq.catalog.clone());
723            let bound = binder.bind(stmt).map_err(|e| format!("Bind error: {e}"))?;
724            let planner = QueryPlanner::new();
725            let logical_plan = planner.plan(bound).map_err(|e| format!("Plan error: {e}"))?;
726            let optimizer = Optimizer::with_stats(db_sq.stats_store.clone());
727            let optimized_plan = optimizer.optimize(logical_plan);
728
729            let catalog_inner = db_sq.catalog.clone();
730            let seq_fn_inner = super::utils::make_sequence_callback(catalog_inner);
731
732            let processor = QueryProcessor::with_catalog(
733                db_sq.function_registry.clone(),
734                db_sq.table_catalog(),
735                db_sq.vfs.clone(),
736            )
737            .with_sequence_fn(seq_fn_inner)
738            .with_schema_ddl_fn(schema_ddl_sq.clone())
739            .with_standalone_call_handler(Arc::new(
740                crate::connection::standalone_call::DbStandaloneCallHandler::new(db_sq.clone()),
741            ))
742            .with_snapshot(
743                Some(db_sq.transaction_manager.current_commit_ts()),
744                db_sq.transaction_manager.commit_history_snapshot(),
745            );
746
747            processor
748                .execute(&optimized_plan)
749                .map_err(|e| ProcessorError::Execution(format!("Execute error: {e}")))
750        }
751    });
752
753    let standalone_call_handler: Arc<dyn StandaloneCallHandler> = Arc::new(
754        crate::connection::standalone_call::DbStandaloneCallHandler::with_query_executor(
755            db.clone(),
756            Some(query_fn.clone()),
757        ),
758    );
759
760    ProcessorHandlers {
761        sequence_fn: seq_fn,
762        schema_ddl_fn,
763        subquery_fn,
764        standalone_call_handler,
765    }
766}
767
768/// Whether a bound statement produces a query plan that is safe to cache.
769///
770/// Only plain query-shaped statements are eligible. `BoundMerge`/
771/// `BoundCreateDml`/`BoundUnion` are executed inline by `handle_ddl` (which
772/// short-circuits before any cached plan could be used), so caching them only
773/// evicts live read-plans from the LRU (P52.25). A FOREACH-only query is also
774/// routed to `handle_foreach` and never runs its plan — excluded too.
775fn is_plan_cachable(bound: &BoundStatement) -> bool {
776    match bound {
777        BoundStatement::BoundQuery(q) => {
778            !(q.clauses.len() == 1
779                && matches!(
780                    q.clauses.first(),
781                    Some(akar_binder::bound_statement::BoundClause::BoundForeach(_))
782                ))
783        }
784        _ => false,
785    }
786}