Skip to main content

mongreldb_query/
lib.rs

1//! DataFusion SQL + Arrow frontend for MongrelDB.
2//!
3//! [`MongrelProvider`] implements DataFusion's `TableProvider`: each `scan()`
4//! takes an MVCC snapshot of the table, materializes the visible columns, and
5//! hands DataFusion a streaming `MongrelScanExec` (see `scan.rs`) that emits one
6//! `RecordBatch` per 65 536-row chunk. DataFusion then runs the SQL —
7//! projection, filter, aggregation, limit — with its own vectorized kernels,
8//! pipelined across those small batches so a `LIMIT` short-circuits and peak
9//! memory stays bounded. MongrelDB owns storage/writes/indexes; DataFusion owns
10//! the vectorized execution.
11//!
12//! Example (skipped from doctests; see `tests/sql.rs` for runnable ones):
13//! ```ignore
14//! # use mongreldb_core::Table;
15//! # use mongreldb_query::MongrelSession;
16//! # async fn run() -> anyhow::Result<()> {
17//! let db = Table::create("travel.mongreldb", /* schema */ unimplemented!(), 1)?;
18//! let session = MongrelSession::new(db);
19//! session.register("travel_trips").await?;
20//! let batches = session.run("select * from travel_trips where cost < 300").await?;
21//! # Ok(()) }
22//! ```
23
24pub mod arrow_conv;
25mod commands;
26mod error;
27pub mod extended_sql_functions;
28mod external_modules;
29mod fk_join;
30mod native_agg;
31mod percentile;
32mod query_registry;
33mod scan;
34mod scored_sql;
35mod shadow;
36mod udf;
37
38pub use error::{MongrelQueryError, Result};
39pub use external_modules::{
40    ExternalBaseWrite, ExternalModuleDescriptor, ExternalModuleIndex, ExternalModuleRegistry,
41    ExternalPlan, ExternalPlanRequest, ExternalScan, ExternalTable, ExternalTableModule,
42    ExternalTxn, ExternalWriteOp, ExternalWriteResult, ModuleConnectCtx,
43};
44pub use query_registry::{
45    CancelOutcome, CommitFenceOutcome, QueryId, QueryStatus, RegisteredQueryGuard,
46    RegisteredSqlQuery, SqlQueryOptions, SqlQueryPhase, SqlQueryRegistry,
47};
48
49/// True when SQL calls a ranked Boolean AI UDF. Remote servers use this to
50/// require the bounded scored table functions while embedded sessions retain
51/// the trusted Boolean intersection primitives.
52pub fn contains_boolean_ai_predicate(sql: &str) -> bool {
53    use sqlparser::dialect::GenericDialect;
54    use sqlparser::tokenizer::{Token, Tokenizer};
55
56    let Ok(tokens) = Tokenizer::new(&GenericDialect {}, sql).tokenize() else {
57        return false;
58    };
59    let tokens: Vec<_> = tokens
60        .iter()
61        .filter(|token| !matches!(token, Token::Whitespace(_)))
62        .collect();
63    tokens.windows(2).any(|tokens| {
64        matches!(
65            (tokens[0], tokens[1]),
66            (Token::Word(word), Token::LParen)
67                if word.value.eq_ignore_ascii_case("ann_search")
68                    || word.value.eq_ignore_ascii_case("sparse_match")
69        )
70    })
71}
72
73pub type MongrelRecordBatchStream = datafusion::physical_plan::SendableRecordBatchStream;
74
75#[doc(hidden)]
76#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum SqlTestHookPoint {
78    AfterRegistration,
79    WaitingForSqlPermit,
80    WaitingForSessionPermit,
81    Planning,
82    BeforeScanBatch,
83    AfterScanBatch,
84    BeforeCommitFence,
85    InsideCommitCritical,
86    AfterStatementStaging,
87    BeforeSerializationBatch,
88}
89
90#[doc(hidden)]
91pub type SqlTestHook = Arc<dyn Fn(SqlTestHookPoint) + Send + Sync>;
92
93tokio::task_local! {
94    // Carries one registered query through command helpers that recursively
95    // execute SQL. DataFusion operators receive control through TaskContext.
96    static CURRENT_SQL_QUERY: RegisteredSqlQuery;
97}
98
99use arrow::array::{Array, ArrayRef, Int64Array, StringArray};
100use arrow::datatypes::SchemaRef;
101use arrow::record_batch::RecordBatch;
102use datafusion::catalog::{Session, TableProvider};
103use datafusion::common::{DataFusionError, Result as DFResult};
104use datafusion::logical_expr::{AggregateUDF, Expr, ScalarUDF, TableType, WindowUDF};
105use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
106use datafusion::physical_plan::{execute_stream, ExecutionPlan};
107use datafusion::prelude::SessionContext;
108use mongreldb_core::{
109    AlterColumn, ColumnFlags, Cursor, Database, OwnedSnapshotGuard, Schema as CoreSchema, Snapshot,
110    Table, TableHandle, TypeId,
111};
112use parking_lot::Mutex;
113use std::borrow::Borrow;
114use std::collections::{HashMap, HashSet};
115use std::hash::Hash;
116use std::sync::Arc;
117
118use query_registry::SqlTaskContext;
119
120/// A MongrelDB table exposed to DataFusion. Holds the live `Table` behind a mutex;
121/// each scan takes a fresh MVCC snapshot.
122pub struct MongrelProvider {
123    db: TableHandle,
124    schema: SchemaRef,
125    core_schema: CoreSchema,
126    snapshot: Option<Snapshot>,
127    _retention: Option<Arc<OwnedSnapshotGuard>>,
128    security: Option<ProviderSecurity>,
129}
130
131#[derive(Clone)]
132struct ProviderSecurity {
133    database: Arc<Database>,
134    table: String,
135    principal: Option<mongreldb_core::Principal>,
136    principal_catalog_bound: bool,
137}
138
139impl ProviderSecurity {
140    fn principal(&self) -> mongreldb_core::Result<Option<mongreldb_core::Principal>> {
141        let Some(principal) = &self.principal else {
142            return Ok(None);
143        };
144        if self.principal_catalog_bound {
145            self.database
146                .resolve_principal(&principal.username)
147                .map(Some)
148                .ok_or(mongreldb_core::MongrelError::AuthRequired)
149        } else {
150            Ok(Some(principal.clone()))
151        }
152    }
153}
154
155#[derive(Debug, Clone)]
156pub(crate) struct ViewDef {
157    pub sql: String,
158    pub schema: CoreSchema,
159    pub input_types: HashMap<u16, Option<TypeId>>,
160}
161
162impl MongrelProvider {
163    pub fn new(db: Arc<Mutex<Table>>) -> Result<Self> {
164        Self::new_handle(db.into())
165    }
166
167    pub(crate) fn new_handle(db: TableHandle) -> Result<Self> {
168        let (schema, core_schema) = {
169            let db = db.lock();
170            (arrow_conv::arrow_schema(db.schema())?, db.schema().clone())
171        };
172        Ok(Self {
173            db,
174            schema,
175            core_schema,
176            snapshot: None,
177            _retention: None,
178            security: None,
179        })
180    }
181
182    pub(crate) fn new_secured(
183        db: TableHandle,
184        database: Arc<Database>,
185        table: String,
186        principal: Option<mongreldb_core::Principal>,
187    ) -> Result<Self> {
188        let core_schema = db.lock().schema().clone();
189        let schema = arrow_conv::arrow_schema(&core_schema)?;
190        Ok(Self {
191            db,
192            schema,
193            core_schema,
194            snapshot: None,
195            _retention: None,
196            security: Some(ProviderSecurity {
197                principal_catalog_bound: principal.as_ref().is_some_and(|principal| {
198                    database.resolve_principal(&principal.username).is_some()
199                }),
200                database,
201                table,
202                principal,
203            }),
204        })
205    }
206
207    fn new_historical(
208        db: TableHandle,
209        snapshot: Snapshot,
210        retention: OwnedSnapshotGuard,
211        security: Option<ProviderSecurity>,
212    ) -> Result<Self> {
213        let full_schema = {
214            let db = db.lock();
215            db.schema().clone()
216        };
217        let core_schema = full_schema;
218        let schema = arrow_conv::arrow_schema(&core_schema)?;
219        Ok(Self {
220            db,
221            schema,
222            core_schema,
223            snapshot: Some(snapshot),
224            _retention: Some(Arc::new(retention)),
225            security,
226        })
227    }
228
229    pub fn arrow_schema(&self) -> SchemaRef {
230        self.schema.clone()
231    }
232}
233
234impl std::fmt::Debug for MongrelProvider {
235    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236        f.debug_struct("MongrelProvider").finish_non_exhaustive()
237    }
238}
239
240struct AsOfRegistration {
241    ctx: SessionContext,
242    table_name: String,
243}
244
245impl Drop for AsOfRegistration {
246    fn drop(&mut self) {
247        let _ = self.ctx.deregister_table(&self.table_name);
248    }
249}
250
251struct AsOfQuery {
252    sql: String,
253    registration: AsOfRegistration,
254}
255
256#[async_trait::async_trait]
257impl TableProvider for MongrelProvider {
258    fn schema(&self) -> SchemaRef {
259        self.schema.clone()
260    }
261
262    fn table_type(&self) -> TableType {
263        TableType::Base
264    }
265
266    /// Tell DataFusion which filters the pushdown serves exactly so it does not
267    /// double-filter (and, for `ann_search`, never evaluates the no-op UDF).
268    /// LIKE/FM is `Inexact`: the FM pushdown is a substring *superset*, so
269    /// DataFusion must still re-apply the real wildcard semantics.
270    fn supports_filters_pushdown(
271        &self,
272        filters: &[&Expr],
273    ) -> DFResult<Vec<datafusion::logical_expr::TableProviderFilterPushDown>> {
274        use datafusion::logical_expr::TableProviderFilterPushDown;
275        if self.snapshot.is_some() {
276            return Ok(vec![
277                TableProviderFilterPushDown::Unsupported;
278                filters.len()
279            ]);
280        }
281        let schema_ref = self.db.lock().schema().clone();
282        if self.security.is_some() {
283            return Ok(filters
284                .iter()
285                .map(|filter| {
286                    if translate_ann_search(filter, &schema_ref).is_some()
287                        || translate_sparse_match(filter, &schema_ref).is_some()
288                    {
289                        TableProviderFilterPushDown::Exact
290                    } else {
291                        TableProviderFilterPushDown::Unsupported
292                    }
293                })
294                .collect());
295        }
296        Ok(filters
297            .iter()
298            .map(|f| match translate_filter(f, &schema_ref) {
299                Some(
300                    mongreldb_core::Condition::FmContains { .. }
301                    | mongreldb_core::Condition::FmContainsAll { .. },
302                ) => TableProviderFilterPushDown::Inexact,
303                Some(_) => TableProviderFilterPushDown::Exact,
304                None => match translate_ann_search(f, &schema_ref)
305                    .or_else(|| translate_sparse_match(f, &schema_ref))
306                {
307                    Some(_) => TableProviderFilterPushDown::Exact,
308                    None => TableProviderFilterPushDown::Unsupported,
309                },
310            })
311            .collect())
312    }
313
314    async fn scan(
315        &self,
316        _state: &dyn Session,
317        projection: Option<&Vec<usize>>,
318        filters: &[Expr],
319        _limit: Option<usize>,
320    ) -> DFResult<Arc<dyn ExecutionPlan>> {
321        let core_err = |e: mongreldb_core::MongrelError| {
322            DataFusionError::External(Box::new(MongrelQueryError::Core(e)))
323        };
324        if let Some(security) = &self.security {
325            let principal = security.principal().map_err(core_err)?;
326            let allowed = security
327                .database
328                .select_column_ids_for(&security.table, principal.as_ref())
329                .map_err(core_err)?;
330            let projected = projection
331                .map(|projection| {
332                    projection
333                        .iter()
334                        .filter_map(|index| self.core_schema.columns.get(*index))
335                        .map(|column| column.id)
336                        .collect::<Vec<_>>()
337                })
338                .unwrap_or_else(|| {
339                    self.core_schema
340                        .columns
341                        .iter()
342                        .map(|column| column.id)
343                        .collect()
344                });
345            if projected.iter().any(|column| !allowed.contains(column)) {
346                security
347                    .database
348                    .require_columns_for(
349                        &security.table,
350                        mongreldb_core::ColumnOperation::Select,
351                        &projected,
352                        principal.as_ref(),
353                    )
354                    .map_err(core_err)?;
355            }
356            let ai_conditions = filters
357                .iter()
358                .filter_map(|filter| {
359                    translate_ann_search(filter, &self.core_schema)
360                        .or_else(|| translate_sparse_match(filter, &self.core_schema))
361                })
362                .collect::<Vec<_>>();
363            let mut required_columns = projected.clone();
364            required_columns.extend(mongreldb_core::query::condition_columns(&ai_conditions));
365            required_columns.sort_unstable();
366            required_columns.dedup();
367            let rows = if let Some(snapshot) = self.snapshot {
368                let rows = self.db.lock().visible_rows(snapshot).map_err(core_err)?;
369                security
370                    .database
371                    .secure_rows_for(&security.table, rows, principal.as_ref())
372                    .map_err(core_err)?
373            } else {
374                security
375                    .database
376                    .with_authorized_read(
377                        &security.table,
378                        principal.as_ref(),
379                        security.principal_catalog_bound,
380                        |table, snapshot, allowed, effective_principal| {
381                            security.database.require_columns_for(
382                                &security.table,
383                                mongreldb_core::ColumnOperation::Select,
384                                &required_columns,
385                                effective_principal,
386                            )?;
387                            let rows = if ai_conditions.is_empty() {
388                                let mut rows = table.visible_rows(snapshot)?;
389                                if let Some(allowed) = allowed {
390                                    rows.retain(|row| allowed.contains(&row.row_id));
391                                }
392                                rows
393                            } else {
394                                table.query_at_with_allowed(
395                                    &mongreldb_core::Query {
396                                        conditions: ai_conditions.clone(),
397                                        limit: Some(mongreldb_core::query::MAX_FINAL_LIMIT),
398                                        offset: 0,
399                                    },
400                                    snapshot,
401                                    allowed,
402                                )?
403                            };
404                            security.database.secure_rows_for(
405                                &security.table,
406                                rows,
407                                effective_principal,
408                            )
409                        },
410                    )
411                    .map_err(core_err)?
412            };
413            if projection.is_some_and(Vec::is_empty) {
414                return Ok(Arc::new(scan::MongrelScanExec::new_row_count(rows.len())));
415            }
416            let batch = arrow_conv::rows_to_batch(&rows, &self.core_schema)
417                .map_err(|error| DataFusionError::External(Box::new(error)))?;
418            let batch = match projection {
419                Some(projection) => batch.project(projection).map_err(|error| {
420                    DataFusionError::External(Box::new(MongrelQueryError::Arrow(error.to_string())))
421                })?,
422                None => batch,
423            };
424            let schema = batch.schema();
425            let statistics = (0..batch.num_columns())
426                .map(|_| scan::to_col_statistics(None))
427                .collect();
428            return Ok(Arc::new(scan::MongrelScanExec::new_batch(
429                schema, batch, statistics,
430            )));
431        }
432        let mut db = self.db.lock();
433        // Enforce Select permission before any read path (count metadata,
434        // count_conditions, or full scan_cursor). On a credentialless database
435        // this is a no-op.
436        db.require_select().map_err(core_err)?;
437        let historical = self.snapshot.is_some();
438        let snap = self.snapshot.unwrap_or_else(|| db.snapshot());
439        let schema_ref = db.schema().clone();
440
441        // Translate WHERE filters into index-backed Conditions.
442        let translated: Vec<mongreldb_core::Condition> = if historical {
443            Vec::new()
444        } else {
445            filters
446                .iter()
447                .filter_map(|f| {
448                    translate_filter(f, &schema_ref)
449                        .or_else(|| translate_ann_search(f, &schema_ref))
450                        .or_else(|| translate_sparse_match(f, &schema_ref))
451                })
452                .collect()
453        };
454
455        // Index-served conditions require complete live indexes; a deferred
456        // bulk load pays its one-time build here (Phase 14.7 lazy contract).
457        if !translated.is_empty() {
458            db.ensure_indexes_complete().map_err(core_err)?;
459        }
460
461        // `COUNT(*)`-style queries (empty projection) need only a row count.
462        // Unfiltered ⇒ O(1) via the maintained `live_count` metadata; a pushed
463        // WHERE ⇒ decode one column through the pushdown path to count survivors.
464        let empty_proj = projection.map(|p| p.is_empty()).unwrap_or(false);
465        if empty_proj {
466            let total: usize = if historical {
467                db.visible_rows(snap).map_err(core_err)?.len()
468            } else if translated.is_empty() {
469                mongreldb_core::trace::QueryTrace::record(|t| {
470                    t.scan_mode = mongreldb_core::trace::ScanMode::CountMetadata;
471                });
472                db.count() as usize
473            } else if let Some(count) = db.count_conditions(&translated, snap).map_err(core_err)? {
474                count as usize
475            } else {
476                match schema_ref.columns.first() {
477                    Some(cdef) => {
478                        let one = [cdef.id];
479                        let cols = match db
480                            .query_columns_native_cached(&translated, Some(&one), snap)
481                            .map_err(core_err)?
482                        {
483                            Some(c) => c,
484                            None => db
485                                .visible_columns_native(snap, Some(&one))
486                                .map_err(core_err)?,
487                        };
488                        mongreldb_core::trace::QueryTrace::record(|t| {
489                            t.scan_mode = mongreldb_core::trace::ScanMode::Materialized;
490                        });
491                        cols.first().map(|(_, c)| c.len()).unwrap_or(0)
492                    }
493                    None => 0,
494                }
495            };
496            return Ok(Arc::new(scan::MongrelScanExec::new_row_count(total)));
497        }
498
499        // Output column ids + Arrow schema for this scan, in scan-field order.
500        // DataFusion's projection already includes every column a retained
501        // (Inexact / Unsupported) filter still needs, so decoding exactly this
502        // set is correct. `None` ⇒ the full schema.
503        let (col_ids, scan_schema): (Vec<u16>, SchemaRef) = match projection {
504            Some(p) if !p.is_empty() => {
505                let ids = p.iter().map(|&idx| schema_ref.columns[idx].id).collect();
506                let fields: Vec<arrow::datatypes::Field> = p
507                    .iter()
508                    .map(|&idx| self.schema.field(idx).clone())
509                    .collect();
510                (ids, Arc::new(arrow::datatypes::Schema::new(fields)))
511            }
512            _ => (
513                schema_ref.columns.iter().map(|c| c.id).collect(),
514                self.schema.clone(),
515            ),
516        };
517
518        // Projection pairs (column id, type) in scan-field order.
519        let mut proj_pairs: Vec<(u16, mongreldb_core::schema::TypeId)> =
520            Vec::with_capacity(col_ids.len());
521        let mut types: Vec<mongreldb_core::schema::TypeId> = Vec::with_capacity(col_ids.len());
522        for cid in &col_ids {
523            let ty = schema_ref
524                .columns
525                .iter()
526                .find(|c| c.id == *cid)
527                .map(|c| c.ty.clone())
528                .ok_or_else(|| {
529                    DataFusionError::External(Box::new(MongrelQueryError::Arrow(format!(
530                        "unknown column {cid}"
531                    ))))
532                })?;
533            proj_pairs.push((*cid, ty.clone()));
534            types.push(ty);
535        }
536
537        // Phase 7.1: exact per-column min/max from page stats, but only for an
538        // unfiltered full scan over an insert-only table (gated in core). A
539        // pushed WHERE or a table with deletes ⇒ all-Absent (DataFusion scans).
540        let col_stats_map = if !historical && translated.is_empty() {
541            db.exact_column_stats(snap, &col_ids).map_err(core_err)?
542        } else {
543            None
544        };
545        let column_stats: Vec<datafusion::physical_plan::ColumnStatistics> = col_ids
546            .iter()
547            .map(|cid| scan::to_col_statistics(col_stats_map.as_ref().and_then(|m| m.get(cid))))
548            .collect();
549
550        // Phase 15.5: Arrow IPC shadow — zero-copy scan for clean single-run
551        // unfiltered tables. The shadow is a derived Arrow IPC file that was
552        // written on a prior scan; reading it avoids per-column decode entirely.
553        if !historical
554            && translated.is_empty()
555            && db.run_count() == 1
556            && db.memtable_is_empty()
557            && db.mutable_run_len() == 0
558            && db.single_run_is_clean()
559        {
560            let shadow = shadow::ArrowShadow::new(db.dir());
561            let run_ids: HashSet<u128> = db.run_ids().into_iter().collect();
562            shadow.sweep(&run_ids);
563            if let Some(&run_id) = run_ids.iter().next() {
564                if let Some(batch) = shadow.try_read(run_id) {
565                    if let Some(projected) =
566                        project_batch(&batch, &col_ids, &schema_ref, &scan_schema)
567                    {
568                        mongreldb_core::trace::QueryTrace::record(|t| {
569                            t.scan_mode = mongreldb_core::trace::ScanMode::ArrowShadow;
570                        });
571                        return Ok(Arc::new(scan::MongrelScanExec::new_batch(
572                            scan_schema,
573                            projected,
574                            column_stats,
575                        )));
576                    }
577                }
578            }
579        }
580
581        // Phase 6.2 / 16.1: drive a lazy streaming cursor that fuses the
582        // predicate, skips pages with no survivors, and decodes only the
583        // projected columns of surviving pages. `scan_cursor` picks the page-plan
584        // fast path for a single run or the k-way-merge cursor for multi-run —
585        // both avoid fully materializing every row. Anything else (e.g. an empty
586        // table with only memtable rows) falls through to materialize-then-chunk.
587        let cursor: Option<Box<dyn Cursor>> = db
588            .scan_cursor(snap, proj_pairs, &translated)
589            .map_err(core_err)?;
590        if let Some(cursor) = cursor {
591            let num_rows = cursor.remaining_rows();
592            // Phase 16.3a: extract the LIKE pattern for residual pre-filtering.
593            let residual = extract_residual_filter(filters, &col_ids, &schema_ref);
594            return Ok(Arc::new(scan::MongrelScanExec::new_cursor(
595                scan_schema,
596                types,
597                cursor,
598                num_rows,
599                column_stats,
600                residual,
601            )));
602        }
603
604        // Pushdown returns exactly `col_ids` when it accepts; the full-scan
605        // fallback returns all columns, of which we keep `col_ids`.
606        let cols = if !translated.is_empty() {
607            match db
608                .query_columns_native_cached(&translated, Some(&col_ids), snap)
609                .map_err(core_err)?
610            {
611                Some(c) => c,
612                None => db
613                    .visible_columns_native(snap, Some(&col_ids))
614                    .map_err(core_err)?,
615            }
616        } else {
617            db.visible_columns_native(snap, Some(&col_ids))
618                .map_err(core_err)?
619        };
620
621        // Order the decoded columns into scan-field order for the streaming exec.
622        let mut ordered: Vec<mongreldb_core::columnar::NativeColumn> =
623            Vec::with_capacity(col_ids.len());
624        for cid in &col_ids {
625            let col = cols
626                .iter()
627                .find(|(id, _)| id == cid)
628                .map(|(_, c)| c.clone())
629                .ok_or_else(|| {
630                    DataFusionError::External(Box::new(MongrelQueryError::Arrow(format!(
631                        "missing column {cid}"
632                    ))))
633                })?;
634            ordered.push(col);
635        }
636        let num_rows = ordered.first().map(|c| c.len()).unwrap_or(0);
637
638        // Collect data needed for the shadow write before releasing the lock.
639        let shadow_write: Option<(
640            std::path::PathBuf,
641            u128,
642            Vec<arrow::array::ArrayRef>,
643            SchemaRef,
644        )> = if !historical
645            && translated.is_empty()
646            && db.run_count() == 1
647            && db.memtable_is_empty()
648            && db.mutable_run_len() == 0
649            && db.single_run_is_clean()
650        {
651            let all_schema_ids: Vec<u16> = schema_ref.columns.iter().map(|c| c.id).collect();
652            if col_ids == all_schema_ids {
653                let dir = db.dir().to_path_buf();
654                let run_id = db.run_ids().first().copied();
655                run_id.map(|rid| {
656                    let arrays = ordered
657                        .iter()
658                        .zip(types.iter())
659                        .map(|(col, ty)| arrow_conv::native_to_array(ty.clone(), col))
660                        .collect::<Result<_>>()
661                        .unwrap_or_default();
662                    (dir, rid, arrays, scan_schema.clone())
663                })
664            } else {
665                None
666            }
667        } else {
668            None
669        };
670
671        drop(db);
672
673        // Phase 15.5: write the Arrow IPC shadow for future scans (best-effort,
674        // outside the Table lock).
675        if let Some((dir, run_id, arrays, schema)) = shadow_write {
676            if let Ok(batch) = RecordBatch::try_new(schema, arrays) {
677                shadow::ArrowShadow::new(&dir).write(run_id, &batch);
678            }
679        }
680
681        mongreldb_core::trace::QueryTrace::record(|t| {
682            t.scan_mode = mongreldb_core::trace::ScanMode::Materialized;
683            t.row_materialized = true;
684        });
685        Ok(Arc::new(scan::MongrelScanExec::new(
686            scan_schema,
687            ordered,
688            types,
689            num_rows,
690            column_stats,
691        )))
692    }
693}
694
695/// Phase 15.5: project columns from a full-schema shadow `RecordBatch` to match
696/// the scan's requested column IDs and Arrow schema. Returns `None` if any
697/// requested column is not present in the shadow batch (schema mismatch → miss).
698fn project_batch(
699    batch: &RecordBatch,
700    col_ids: &[u16],
701    schema_ref: &mongreldb_core::schema::Schema,
702    scan_schema: &arrow::datatypes::SchemaRef,
703) -> Option<RecordBatch> {
704    // Map schema column ids to field names for lookup in the shadow batch.
705    let arrays: Vec<arrow::array::ArrayRef> = col_ids
706        .iter()
707        .map(|cid| {
708            // Find the column name for this id in the live schema.
709            let name = schema_ref
710                .columns
711                .iter()
712                .find(|c| c.id == *cid)
713                .map(|c| c.name.as_str())?;
714            // Look up the column in the shadow batch by name.
715            let idx = batch.schema().index_of(name).ok()?;
716            Some(batch.column(idx).clone())
717        })
718        .collect::<Option<Vec<_>>>()?;
719    RecordBatch::try_new(scan_schema.clone(), arrays).ok()
720}
721
722/// Translate a DataFusion WHERE filter expression into a MongrelDB
723/// index-backed [`Condition`]. Supported translations (all index/scan-served by
724/// `Table::query_columns_native`):
725///
726/// * `col = literal` → [`Condition::BitmapEq`] (bitmap index) or
727///   [`Condition::Pk`] (primary key).
728/// * `col <, >, <=, >= literal` and `col BETWEEN a AND b` →
729///   [`Condition::Range`] (Int64) / [`Condition::RangeF64`] (Float64).
730/// * `col LIKE '%pat%'` → [`Condition::FmContains`] (FM index). Any `%`/`_`
731///   wildcard pattern is mapped to its longest literal segment; DataFusion
732///   re-applies the real LIKE on the returned batch, so correctness is exact
733///   even though the pushdown is a substring superset.
734///
735/// Everything else is left to DataFusion's post-scan filter. Because DataFusion
736/// always re-applies the full WHERE on the returned batch, a pushdown only ever
737/// needs to return a *superset* of the survivors — it is a pure optimization,
738/// never a correctness risk.
739pub(crate) fn translate_filter(
740    expr: &Expr,
741    schema: &mongreldb_core::Schema,
742) -> Option<mongreldb_core::Condition> {
743    use datafusion::common::ScalarValue;
744    use datafusion::logical_expr::{Between, BinaryExpr, Like, Operator};
745    use mongreldb_core::{ColumnFlags, Condition, IndexKind, TypeId, Value};
746
747    // Extended int extraction: handles every integer width (narrow ints are
748    // stored widened to Int64 internally), Date32, and all Timestamp* precision
749    // variants DataFusion emits. The numeric value is the raw i64.
750    let int_val = |s: &ScalarValue| match s {
751        ScalarValue::Int8(Some(v)) => Some(*v as i64),
752        ScalarValue::Int16(Some(v)) => Some(*v as i64),
753        ScalarValue::Int32(Some(v)) => Some(*v as i64),
754        ScalarValue::Int64(Some(v)) => Some(*v),
755        ScalarValue::UInt8(Some(v)) => Some(*v as i64),
756        ScalarValue::UInt16(Some(v)) => Some(*v as i64),
757        ScalarValue::UInt32(Some(v)) => Some(*v as i64),
758        ScalarValue::UInt64(Some(v)) => Some(*v as i64),
759        ScalarValue::Date32(Some(v)) => Some(*v as i64),
760        ScalarValue::TimestampSecond(Some(v), _) => Some(*v),
761        ScalarValue::TimestampMillisecond(Some(v), _) => Some(*v),
762        ScalarValue::TimestampMicrosecond(Some(v), _) => Some(*v),
763        ScalarValue::TimestampNanosecond(Some(v), _) => Some(*v),
764        _ => None,
765    };
766    let float_val = |s: &ScalarValue| match s {
767        ScalarValue::Float32(Some(f)) => Some(*f as f64),
768        ScalarValue::Float64(Some(f)) => Some(*f),
769        _ => None,
770    };
771    let bytes_val = |s: &ScalarValue| match s {
772        ScalarValue::Utf8(Some(s)) => Some(s.as_bytes().to_vec()),
773        _ => None,
774    };
775    let _ = bytes_val; // retained for clarity; equality uses the generic `val` below.
776
777    let val = |s: &ScalarValue| -> Option<Value> {
778        // Integer literals of any width coerce to Int64 (the storage width);
779        // Float32 widens to Float64. This keeps equality pushdown working on
780        // narrow-int / float32 bitmap and primary-key columns.
781        if let Some(i) = int_val(s) {
782            return Some(Value::Int64(i));
783        }
784        match s {
785            ScalarValue::Utf8(Some(s)) => Some(Value::Bytes(s.as_bytes().to_vec())),
786            ScalarValue::Float32(Some(f)) => Some(Value::Float64(*f as f64)),
787            ScalarValue::Float64(Some(f)) => Some(Value::Float64(*f)),
788            ScalarValue::Boolean(Some(b)) => Some(Value::Bool(*b)),
789            _ => None,
790        }
791    };
792
793    let col_def = |name: &str| schema.columns.iter().find(|c| c.name == name);
794    let has_fm = |cid: u16| {
795        schema
796            .indexes
797            .iter()
798            .any(|i| i.column_id == cid && i.kind == IndexKind::FmIndex)
799    };
800    let has_bitmap = |cid: u16| {
801        schema
802            .indexes
803            .iter()
804            .any(|i| i.column_id == cid && i.kind == IndexKind::Bitmap)
805    };
806
807    match expr {
808        // `col OP literal` (and the mirrored `literal OP col`).
809        // Also handles `col = v1 OR col = v2 OR ...` → BitmapIn.
810        Expr::BinaryExpr(BinaryExpr { left, op, right }) => {
811            // OR-of-equalities on the same column → BitmapIn (Priority 6).
812            if *op == Operator::Or {
813                return try_or_as_bitmap_in(expr, schema);
814            }
815            // Unwrap single-layer Cast wrappers (canonicalization).
816            let left = peel_cast(left);
817            let right = peel_cast(right);
818            let (col_name, scalar, flipped) = match (left.as_ref(), right.as_ref()) {
819                (Expr::Column(c), Expr::Literal(s, _)) => (&c.name, s, false),
820                (Expr::Literal(s, _), Expr::Column(c)) => (&c.name, s, true),
821                _ => return None,
822            };
823            let op = if flipped { flip_op(*op)? } else { *op };
824            let cdef = col_def(col_name)?;
825
826            // Equality: bitmap index or primary key.
827            if op == Operator::Eq {
828                let v = val(scalar)?;
829                if has_bitmap(cdef.id) {
830                    return Some(Condition::BitmapEq {
831                        column_id: cdef.id,
832                        value: v.encode_key(),
833                    });
834                }
835                if cdef.flags.contains(ColumnFlags::PRIMARY_KEY) {
836                    return Some(Condition::Pk(v.encode_key()));
837                }
838                return None;
839            }
840
841            // Range on a typed numeric column. Every integer width is stored
842            // widened to Int64, so they all share the integer Range path.
843            match cdef.ty {
844                TypeId::Int8
845                | TypeId::Int16
846                | TypeId::Int32
847                | TypeId::Int64
848                | TypeId::UInt8
849                | TypeId::UInt16
850                | TypeId::UInt32
851                | TypeId::UInt64
852                | TypeId::TimestampNanos
853                | TypeId::Date32 => {
854                    let v = int_val(scalar)?;
855                    let (lo, hi) = int_bounds(op, v)?;
856                    Some(Condition::Range {
857                        column_id: cdef.id,
858                        lo,
859                        hi,
860                    })
861                }
862                TypeId::Float32 | TypeId::Float64 => {
863                    let v = float_val(scalar)?;
864                    let (lo, lo_inc, hi, hi_inc) = float_bounds(op, v)?;
865                    Some(Condition::RangeF64 {
866                        column_id: cdef.id,
867                        lo,
868                        lo_inclusive: lo_inc,
869                        hi,
870                        hi_inclusive: hi_inc,
871                    })
872                }
873                _ => None,
874            }
875        }
876
877        // `col BETWEEN low AND high` (and `col NOT BETWEEN ...` → skip).
878        Expr::Between(Between {
879            expr,
880            negated,
881            low,
882            high,
883        }) => {
884            if *negated {
885                return None;
886            }
887            let Expr::Column(c) = expr.as_ref() else {
888                return None;
889            };
890            let cdef = col_def(&c.name)?;
891            let (lo_s, hi_s) = match (low.as_ref(), high.as_ref()) {
892                (Expr::Literal(lo, _), Expr::Literal(hi, _)) => (lo, hi),
893                _ => return None,
894            };
895            match cdef.ty {
896                TypeId::Int8
897                | TypeId::Int16
898                | TypeId::Int32
899                | TypeId::Int64
900                | TypeId::UInt8
901                | TypeId::UInt16
902                | TypeId::UInt32
903                | TypeId::UInt64
904                | TypeId::TimestampNanos
905                | TypeId::Date32 => {
906                    let (Some(lo), Some(hi)) = (int_val(lo_s), int_val(hi_s)) else {
907                        return None;
908                    };
909                    Some(Condition::Range {
910                        column_id: cdef.id,
911                        lo,
912                        hi,
913                    })
914                }
915                TypeId::Float32 | TypeId::Float64 => {
916                    let (Some(lo), Some(hi)) = (float_val(lo_s), float_val(hi_s)) else {
917                        return None;
918                    };
919                    Some(Condition::RangeF64 {
920                        column_id: cdef.id,
921                        lo,
922                        lo_inclusive: true,
923                        hi,
924                        hi_inclusive: true,
925                    })
926                }
927                _ => None,
928            }
929        }
930
931        // `col LIKE pattern` → FM-index substring on the longest literal segment.
932        Expr::Like(Like {
933            negated,
934            expr,
935            pattern,
936            ..
937        }) => {
938            if *negated {
939                return None;
940            }
941            let Expr::Column(c) = expr.as_ref() else {
942                return None;
943            };
944            let Expr::Literal(ScalarValue::Utf8(Some(pat)), _) = pattern.as_ref() else {
945                return None;
946            };
947            let cdef = col_def(&c.name)?;
948            // §5.6: anchored prefix `LIKE 'literal%'` (no embedded wildcards)
949            // on a bitmap-indexed column → exact BytesPrefix, tighter than the
950            // FM substring superset. Checked before the FM path.
951            if has_bitmap(cdef.id) {
952                if let Some(prefix) = anchored_like_prefix(pat) {
953                    return Some(Condition::BytesPrefix {
954                        column_id: cdef.id,
955                        prefix: mongreldb_core::Value::Bytes(prefix.as_bytes().to_vec())
956                            .encode_key(),
957                    });
958                }
959            }
960            if !has_fm(cdef.id) {
961                return None;
962            }
963            // Priority 12: extract ALL literal segments (≥3 chars) and intersect
964            // their FM results for a much tighter superset than the single
965            // longest segment. Falls back to the longest when only one qualifies.
966            let segments: Vec<Vec<u8>> = pat
967                .split(['%', '_'])
968                .filter(|s| s.len() >= 3)
969                .map(|s| s.as_bytes().to_vec())
970                .collect();
971            match segments.len() {
972                0 => longest_like_segment(pat).map(|seg| Condition::FmContains {
973                    column_id: cdef.id,
974                    pattern: seg,
975                }),
976                1 => Some(Condition::FmContains {
977                    column_id: cdef.id,
978                    pattern: segments.into_iter().next().unwrap(),
979                }),
980                _ => Some(Condition::FmContainsAll {
981                    column_id: cdef.id,
982                    patterns: segments,
983                }),
984            }
985        }
986
987        // `col IN (lit1, lit2, …)` → BitmapIn (bitmap union). Phase 13.5:
988        // runtime-filter pushdown for semi-joins and IN-list filters. Only when
989        // the column has a bitmap index and every list entry is a literal.
990        Expr::InList(il) if !il.negated => {
991            let Expr::Column(c) = il.expr.as_ref() else {
992                return None;
993            };
994            let cdef = col_def(&c.name)?;
995            if !has_bitmap(cdef.id) {
996                return None;
997            }
998            let values: Vec<Vec<u8>> = il
999                .list
1000                .iter()
1001                .filter_map(|e| match e {
1002                    Expr::Literal(s, _) => val(s).map(|v| v.encode_key()),
1003                    _ => None,
1004                })
1005                .collect();
1006            if values.is_empty() || values.len() != il.list.len() {
1007                return None;
1008            }
1009            Some(Condition::BitmapIn {
1010                column_id: cdef.id,
1011                values,
1012            })
1013        }
1014
1015        // `col IS NULL` → page-stat-pruned column scan for null validity.
1016        Expr::IsNull(inner) => {
1017            let col_name = match inner.as_ref() {
1018                Expr::Column(c) => &c.name,
1019                _ => return None,
1020            };
1021            let cdef = col_def(col_name)?;
1022            Some(Condition::IsNull { column_id: cdef.id })
1023        }
1024
1025        // `col IS NOT NULL` → complement of IS NULL.
1026        Expr::IsNotNull(inner) => {
1027            let col_name = match inner.as_ref() {
1028                Expr::Column(c) => &c.name,
1029                _ => return None,
1030            };
1031            let cdef = col_def(col_name)?;
1032            Some(Condition::IsNotNull { column_id: cdef.id })
1033        }
1034
1035        _ => None,
1036    }
1037}
1038
1039/// Phase 16.3a: extract the SQL `LIKE` pattern from `filters` for residual
1040/// pre-filtering on `NativeColumn` buffers. Returns a `ResidualFilter` when a
1041/// non-negated LIKE on a Bytes column is found among the filters.
1042pub(crate) fn extract_residual_filter(
1043    filters: &[Expr],
1044    col_ids: &[u16],
1045    schema: &mongreldb_core::Schema,
1046) -> Option<std::sync::Arc<scan::ResidualFilter>> {
1047    use datafusion::common::ScalarValue;
1048    use datafusion::logical_expr::Like;
1049    for f in filters {
1050        if let Expr::Like(Like {
1051            negated: false,
1052            expr,
1053            pattern,
1054            ..
1055        }) = f
1056        {
1057            let Expr::Column(c) = expr.as_ref() else {
1058                continue;
1059            };
1060            let Expr::Literal(ScalarValue::Utf8(Some(pat)), _) = pattern.as_ref() else {
1061                continue;
1062            };
1063            let cdef = schema.columns.iter().find(|col| col.name == c.name)?;
1064            let col_idx = col_ids.iter().position(|&id| id == cdef.id)?;
1065            return Some(std::sync::Arc::new(scan::ResidualFilter::new(
1066                col_idx,
1067                pat.as_bytes().to_vec(),
1068            )));
1069        }
1070    }
1071    None
1072}
1073
1074/// Translate `ann_search(<embedding-col>, '<json f32 array>', k)` — the SQL hook
1075/// for HNSW semantic search — into [`Condition::Ann`]. The `ann_search` UDF is
1076/// registered by [`MongrelSession`] purely so the SQL parses; the provider's
1077/// pushdown serves the real top-k, and `supports_filters_pushdown` marks the
1078/// filter `Exact` so DataFusion never evaluates the (no-op) UDF itself.
1079pub(crate) fn translate_ann_search(
1080    expr: &Expr,
1081    schema: &mongreldb_core::Schema,
1082) -> Option<mongreldb_core::Condition> {
1083    use datafusion::common::ScalarValue;
1084    use mongreldb_core::Condition;
1085
1086    let Expr::ScalarFunction(sf) = expr else {
1087        return None;
1088    };
1089    if !sf.func.name().eq_ignore_ascii_case("ann_search") || sf.args.len() != 3 {
1090        return None;
1091    }
1092    let (Expr::Column(c), query_expr, k_expr) = (&sf.args[0], &sf.args[1], &sf.args[2]) else {
1093        return None;
1094    };
1095    let cdef = schema.columns.iter().find(|col| col.name == c.name)?;
1096    let json = match query_expr {
1097        Expr::Literal(ScalarValue::Utf8(Some(s)), _) => s.as_str(),
1098        _ => return None,
1099    };
1100    let k: usize = match k_expr {
1101        Expr::Literal(scalar, _) => match scalar {
1102            ScalarValue::Int64(Some(k)) => usize::try_from(*k).ok()?,
1103            ScalarValue::UInt64(Some(k)) => usize::try_from(*k).ok()?,
1104            ScalarValue::Int32(Some(k)) => usize::try_from(*k).ok()?,
1105            _ => return None,
1106        },
1107        _ => return None,
1108    };
1109    let query: Vec<f32> = serde_json::from_str(json).ok()?;
1110    Some(Condition::Ann {
1111        column_id: cdef.id,
1112        query,
1113        k,
1114    })
1115}
1116
1117/// Translate `sparse_match(<sparse-col>, '<json [[token, weight], …]>', k)` —
1118/// the SQL hook for SPLADE-style sparse retrieval — into
1119/// [`Condition::SparseMatch`]. The UDF is registered by [`MongrelSession`]
1120/// purely so the SQL parses; the provider's pushdown serves the real top-k.
1121pub(crate) fn translate_sparse_match(
1122    expr: &Expr,
1123    schema: &mongreldb_core::Schema,
1124) -> Option<mongreldb_core::Condition> {
1125    use datafusion::common::ScalarValue;
1126    use mongreldb_core::Condition;
1127
1128    let Expr::ScalarFunction(sf) = expr else {
1129        return None;
1130    };
1131    if !sf.func.name().eq_ignore_ascii_case("sparse_match") || sf.args.len() != 3 {
1132        return None;
1133    }
1134    let (Expr::Column(c), query_expr, k_expr) = (&sf.args[0], &sf.args[1], &sf.args[2]) else {
1135        return None;
1136    };
1137    let cdef = schema.columns.iter().find(|col| col.name == c.name)?;
1138    let json = match query_expr {
1139        Expr::Literal(ScalarValue::Utf8(Some(s)), _) => s.as_str(),
1140        _ => return None,
1141    };
1142    let k: usize = match k_expr {
1143        Expr::Literal(scalar, _) => match scalar {
1144            ScalarValue::Int64(Some(k)) => usize::try_from(*k).ok()?,
1145            ScalarValue::UInt64(Some(k)) => usize::try_from(*k).ok()?,
1146            ScalarValue::Int32(Some(k)) => usize::try_from(*k).ok()?,
1147            _ => return None,
1148        },
1149        _ => return None,
1150    };
1151    let query: Vec<(u32, f32)> = serde_json::from_str(json).ok()?;
1152    Some(Condition::SparseMatch {
1153        column_id: cdef.id,
1154        query,
1155        k,
1156    })
1157}
1158
1159/// Mirror a comparison operator for the `literal OP col` form.
1160fn flip_op(op: datafusion::logical_expr::Operator) -> Option<datafusion::logical_expr::Operator> {
1161    use datafusion::logical_expr::Operator;
1162    Some(match op {
1163        Operator::Eq => Operator::Eq,
1164        Operator::Lt => Operator::Gt,
1165        Operator::Gt => Operator::Lt,
1166        Operator::LtEq => Operator::GtEq,
1167        Operator::GtEq => Operator::LtEq,
1168        _ => return None,
1169    })
1170}
1171
1172/// Convert `col OP v` into inclusive Int64 `[lo, hi]` bounds (exact for all of
1173/// `<`, `>`, `<=`, `>=` via saturating ±1).
1174fn int_bounds(op: datafusion::logical_expr::Operator, v: i64) -> Option<(i64, i64)> {
1175    use datafusion::logical_expr::Operator;
1176    Some(match op {
1177        Operator::Gt => (v.saturating_add(1), i64::MAX),
1178        Operator::GtEq => (v, i64::MAX),
1179        Operator::Lt => (i64::MIN, v.saturating_sub(1)),
1180        Operator::LtEq => (i64::MIN, v),
1181        _ => return None,
1182    })
1183}
1184
1185/// Convert `col OP v` into Float64 bounds with per-bound inclusivity.
1186fn float_bounds(op: datafusion::logical_expr::Operator, v: f64) -> Option<(f64, bool, f64, bool)> {
1187    use datafusion::logical_expr::Operator;
1188    Some(match op {
1189        Operator::Gt => (v, false, f64::INFINITY, false),
1190        Operator::GtEq => (v, true, f64::INFINITY, false),
1191        Operator::Lt => (f64::NEG_INFINITY, false, v, false),
1192        Operator::LtEq => (f64::NEG_INFINITY, false, v, true),
1193        _ => return None,
1194    })
1195}
1196
1197/// Longest contiguous literal (non-`%`, non-`_`) segment of a SQL LIKE pattern;
1198/// `None` if the pattern is all wildcards (matches everything → no pushdown).
1199/// Splitting on BOTH wildcards (not just `%`) keeps the segment a true literal
1200/// substring of every match, so the FM-index search is a correct *superset* —
1201/// e.g. `%City_1%` ⇒ segment `City` (not the literal `City_1`, which no match
1202/// like `City11` actually contains). DataFusion re-applies the real wildcard.
1203fn longest_like_segment(pat: &str) -> Option<Vec<u8>> {
1204    pat.split(['%', '_'])
1205        .map(|s| s.as_bytes())
1206        .max_by_key(|s| s.len())
1207        .filter(|s| !s.is_empty())
1208        .map(|s| s.to_vec())
1209}
1210
1211/// Detect an anchored-prefix LIKE pattern: `literal%` with no `%` or `_` in
1212/// the literal part and a single trailing `%`. Returns the prefix (without the
1213/// `%`). Used to emit an exact `BytesPrefix` condition on bitmap-indexed
1214/// columns — tighter than the FM substring superset. (§5.6)
1215fn anchored_like_prefix(pat: &str) -> Option<&str> {
1216    let rest = pat.strip_suffix('%')?;
1217    if rest.is_empty() || rest.contains(['%', '_']) {
1218        return None;
1219    }
1220    Some(rest)
1221}
1222
1223/// Unwrap a single-layer `Expr::Cast` wrapper to enable pushdown for queries
1224/// like `WHERE CAST(col AS BIGINT) = 5` (canonicalization). Returns the
1225/// original `Box` unchanged for non-cast expressions.
1226fn peel_cast(expr: &Expr) -> std::borrow::Cow<'_, Expr> {
1227    match expr {
1228        Expr::Cast(datafusion::logical_expr::Cast { expr, .. }) => std::borrow::Cow::Borrowed(expr),
1229        _ => std::borrow::Cow::Borrowed(expr),
1230    }
1231}
1232
1233/// Flatten an OR tree of same-column equality comparisons into a `BitmapIn`.
1234/// Handles `col = v1 OR col = v2 OR ...` (and nested OR) that DataFusion's
1235/// optimizer may not have rewritten into `IN`. Returns `None` if the OR spans
1236/// different columns, non-equality comparisons, or a non-bitmap-indexed column.
1237fn try_or_as_bitmap_in(
1238    expr: &Expr,
1239    schema: &mongreldb_core::Schema,
1240) -> Option<mongreldb_core::Condition> {
1241    use datafusion::logical_expr::{BinaryExpr, Operator};
1242    let mut values: Vec<Vec<u8>> = Vec::new();
1243    let mut target_col: Option<u16> = None;
1244    let mut stack = vec![expr];
1245    while let Some(e) = stack.pop() {
1246        match e {
1247            Expr::BinaryExpr(BinaryExpr {
1248                left,
1249                op: Operator::Or,
1250                right,
1251            }) => {
1252                stack.push(left);
1253                stack.push(right);
1254            }
1255            Expr::BinaryExpr(BinaryExpr {
1256                left,
1257                op: Operator::Eq,
1258                right,
1259            }) => {
1260                let (col_name, scalar) = match (left.as_ref(), right.as_ref()) {
1261                    (Expr::Column(c), Expr::Literal(s, _)) => (&c.name, s),
1262                    (Expr::Literal(s, _), Expr::Column(c)) => (&c.name, s),
1263                    _ => return None,
1264                };
1265                let cdef = schema.columns.iter().find(|c| &c.name == col_name)?;
1266                if !schema
1267                    .indexes
1268                    .iter()
1269                    .any(|i| i.column_id == cdef.id && i.kind == mongreldb_core::IndexKind::Bitmap)
1270                {
1271                    return None;
1272                }
1273                match target_col {
1274                    None => target_col = Some(cdef.id),
1275                    Some(id) if id != cdef.id => return None,
1276                    _ => {}
1277                }
1278                let v = match scalar {
1279                    datafusion::common::ScalarValue::Int64(Some(v)) => {
1280                        mongreldb_core::Value::Int64(*v)
1281                    }
1282                    datafusion::common::ScalarValue::Utf8(Some(s)) => {
1283                        mongreldb_core::Value::Bytes(s.as_bytes().to_vec())
1284                    }
1285                    datafusion::common::ScalarValue::Float64(Some(f)) => {
1286                        mongreldb_core::Value::Float64(*f)
1287                    }
1288                    datafusion::common::ScalarValue::Boolean(Some(b)) => {
1289                        mongreldb_core::Value::Bool(*b)
1290                    }
1291                    _ => return None,
1292                };
1293                values.push(v.encode_key());
1294            }
1295            _ => return None,
1296        }
1297    }
1298    let col_id = target_col?;
1299    if values.is_empty() {
1300        return None;
1301    }
1302    Some(mongreldb_core::Condition::BitmapIn {
1303        column_id: col_id,
1304        values,
1305    })
1306}
1307
1308// ──────────────────────────────────────────────────────────────────────────
1309// §5.3 direct SQL dispatch: translate a sqlparser AST WHERE clause into the
1310// engine's exact Condition set (no DataFusion involvement). Only predicates
1311// whose Condition is EXACT are accepted; everything else returns None so the
1312// caller falls through to the DataFusion path (which re-applies residuals).
1313
1314fn sp_ident_name(expr: &sqlparser::ast::Expr) -> Option<&str> {
1315    use sqlparser::ast::Expr;
1316    match expr {
1317        Expr::Identifier(ident) => Some(ident.value.as_str()),
1318        Expr::CompoundIdentifier(idents) => idents.last().map(|i| i.value.as_str()),
1319        _ => None,
1320    }
1321}
1322
1323/// A sqlparser literal → core Value. Numbers widen to Int64 (or Float64 if they
1324/// don't fit i64); single-quoted strings → Bytes; booleans → Bool.
1325fn sp_literal(expr: &sqlparser::ast::Expr) -> Option<mongreldb_core::Value> {
1326    use sqlparser::ast::Expr;
1327    let v = match expr {
1328        Expr::Value(v) => v,
1329        _ => return None,
1330    };
1331    use sqlparser::ast::Value as SpValue;
1332    match &v.value {
1333        SpValue::Number(s, _) => s
1334            .parse::<i64>()
1335            .map(mongreldb_core::Value::Int64)
1336            .or_else(|_| s.parse::<f64>().map(mongreldb_core::Value::Float64))
1337            .ok(),
1338        SpValue::SingleQuotedString(s) => Some(mongreldb_core::Value::Bytes(s.as_bytes().to_vec())),
1339        SpValue::Boolean(b) => Some(mongreldb_core::Value::Bool(*b)),
1340        _ => None,
1341    }
1342}
1343
1344fn is_int_ty(ty: mongreldb_core::schema::TypeId) -> bool {
1345    use mongreldb_core::schema::TypeId::*;
1346    matches!(
1347        ty,
1348        Int8 | Int16 | Int32 | Int64 | UInt8 | UInt16 | UInt32 | UInt64 | TimestampNanos | Date32
1349    )
1350}
1351
1352fn is_float_ty(ty: mongreldb_core::schema::TypeId) -> bool {
1353    matches!(
1354        ty,
1355        mongreldb_core::schema::TypeId::Float32 | mongreldb_core::schema::TypeId::Float64
1356    )
1357}
1358
1359/// Translate ONE sqlparser predicate `Expr` into one exact `Condition`.
1360/// Returns `None` for anything inexact or unsupported (→ caller falls through).
1361fn translate_sqlparser_predicate(
1362    expr: &sqlparser::ast::Expr,
1363    schema: &mongreldb_core::Schema,
1364) -> Option<mongreldb_core::Condition> {
1365    use mongreldb_core::{schema::ColumnFlags, Condition, IndexKind, Value};
1366    use sqlparser::ast::{BinaryOperator, Expr};
1367
1368    let col_def = |name: &str| schema.columns.iter().find(|c| c.name == name);
1369    let has_bitmap = |cid: u16| {
1370        schema
1371            .indexes
1372            .iter()
1373            .any(|i| i.column_id == cid && i.kind == IndexKind::Bitmap)
1374    };
1375
1376    match expr {
1377        // `a = b OR a = c …` (one column, all literals) → BitmapIn.
1378        Expr::BinaryOp {
1379            left,
1380            op: BinaryOperator::Or,
1381            right,
1382        } => {
1383            let mut values: Vec<Vec<u8>> = Vec::new();
1384            let mut target: Option<u16> = None;
1385            let mut stack: Vec<&Expr> = vec![left.as_ref(), right.as_ref()];
1386            while let Some(e) = stack.pop() {
1387                match e {
1388                    Expr::BinaryOp {
1389                        left,
1390                        op: BinaryOperator::Or,
1391                        right,
1392                    } => {
1393                        stack.push(left.as_ref());
1394                        stack.push(right.as_ref());
1395                    }
1396                    Expr::BinaryOp {
1397                        left,
1398                        op: BinaryOperator::Eq,
1399                        right,
1400                    } => {
1401                        let (name, lit) = match (left.as_ref(), right.as_ref()) {
1402                            (l, r) if sp_ident_name(l).is_some() && sp_literal(r).is_some() => {
1403                                (l, r)
1404                            }
1405                            (l, r) if sp_ident_name(r).is_some() && sp_literal(l).is_some() => {
1406                                (r, l)
1407                            }
1408                            _ => return None,
1409                        };
1410                        let cdef = col_def(sp_ident_name(name)?)?;
1411                        if !has_bitmap(cdef.id) {
1412                            return None;
1413                        }
1414                        match target {
1415                            None => target = Some(cdef.id),
1416                            Some(id) if id != cdef.id => return None,
1417                            _ => {}
1418                        }
1419                        values.push(sp_literal(lit)?.encode_key());
1420                    }
1421                    _ => return None,
1422                }
1423            }
1424            let cid = target?;
1425            (!values.is_empty()).then_some(Condition::BitmapIn {
1426                column_id: cid,
1427                values,
1428            })
1429        }
1430        // Comparison `col OP literal` (or mirrored).
1431        Expr::BinaryOp { left, op, right } => {
1432            let flipped;
1433            let (col_expr, lit_expr) = match (
1434                sp_ident_name(left),
1435                sp_literal(right),
1436                sp_ident_name(right),
1437                sp_literal(left),
1438            ) {
1439                (Some(_), Some(_), _, _) => {
1440                    flipped = false;
1441                    (left.as_ref(), right.as_ref())
1442                }
1443                (_, _, Some(_), Some(_)) => {
1444                    flipped = true;
1445                    (right.as_ref(), left.as_ref())
1446                }
1447                _ => return None,
1448            };
1449            let name = sp_ident_name(col_expr)?;
1450            let cdef = col_def(name)?;
1451            let v = sp_literal(lit_expr)?;
1452            use sqlparser::ast::BinaryOperator::*;
1453            // Inline the comparison→Range/RangeF64 bounds, fusing the flip
1454            // (BinaryOperator is not Copy, so we match the &op directly).
1455            match op {
1456                Eq => {
1457                    if has_bitmap(cdef.id) {
1458                        Some(Condition::BitmapEq {
1459                            column_id: cdef.id,
1460                            value: v.encode_key(),
1461                        })
1462                    } else if cdef.flags.contains(ColumnFlags::PRIMARY_KEY) {
1463                        Some(Condition::Pk(v.encode_key()))
1464                    } else {
1465                        None
1466                    }
1467                }
1468                Lt | LtEq | Gt | GtEq if is_int_ty(cdef.ty.clone()) => {
1469                    let n = match v {
1470                        Value::Int64(n) => n,
1471                        _ => return None,
1472                    };
1473                    // `col OP v`, or the mirrored `v OP col` with the flipped op.
1474                    let (lo, hi) = match (flipped, op) {
1475                        (false, Lt) | (true, Gt) => (i64::MIN, n.saturating_sub(1)),
1476                        (false, Gt) | (true, Lt) => (n.saturating_add(1), i64::MAX),
1477                        (false, LtEq) | (true, GtEq) => (i64::MIN, n),
1478                        (false, GtEq) | (true, LtEq) => (n, i64::MAX),
1479                        _ => (i64::MIN, i64::MAX),
1480                    };
1481                    Some(Condition::Range {
1482                        column_id: cdef.id,
1483                        lo,
1484                        hi,
1485                    })
1486                }
1487                Lt | LtEq | Gt | GtEq if is_float_ty(cdef.ty.clone()) => {
1488                    let f = match v {
1489                        Value::Float64(f) => f,
1490                        _ => return None,
1491                    };
1492                    let (lo, li, hi, hi_i) = match (flipped, op) {
1493                        (false, Lt) | (true, Gt) => (f64::NEG_INFINITY, true, f, false),
1494                        (false, Gt) | (true, Lt) => (f, false, f64::INFINITY, true),
1495                        (false, LtEq) | (true, GtEq) => (f64::NEG_INFINITY, true, f, true),
1496                        (false, GtEq) | (true, LtEq) => (f, true, f64::INFINITY, true),
1497                        _ => (f64::NEG_INFINITY, true, f64::INFINITY, true),
1498                    };
1499                    Some(Condition::RangeF64 {
1500                        column_id: cdef.id,
1501                        lo,
1502                        lo_inclusive: li,
1503                        hi,
1504                        hi_inclusive: hi_i,
1505                    })
1506                }
1507                _ => None,
1508            }
1509        }
1510        Expr::Between {
1511            expr,
1512            negated,
1513            low,
1514            high,
1515        } if !negated => {
1516            let name = sp_ident_name(expr)?;
1517            let cdef = col_def(name)?;
1518            if is_int_ty(cdef.ty.clone()) {
1519                let lo = match sp_literal(low)? {
1520                    Value::Int64(n) => n,
1521                    _ => return None,
1522                };
1523                let hi = match sp_literal(high)? {
1524                    Value::Int64(n) => n,
1525                    _ => return None,
1526                };
1527                Some(Condition::Range {
1528                    column_id: cdef.id,
1529                    lo,
1530                    hi,
1531                })
1532            } else if is_float_ty(cdef.ty.clone()) {
1533                let lo = match sp_literal(low)? {
1534                    Value::Float64(f) => f,
1535                    _ => return None,
1536                };
1537                let hi = match sp_literal(high)? {
1538                    Value::Float64(f) => f,
1539                    _ => return None,
1540                };
1541                Some(Condition::RangeF64 {
1542                    column_id: cdef.id,
1543                    lo,
1544                    lo_inclusive: true,
1545                    hi,
1546                    hi_inclusive: true,
1547                })
1548            } else {
1549                None
1550            }
1551        }
1552        Expr::InList {
1553            expr,
1554            list,
1555            negated,
1556        } if !negated => {
1557            let name = sp_ident_name(expr)?;
1558            let cdef = col_def(name)?;
1559            if !has_bitmap(cdef.id) {
1560                return None;
1561            }
1562            let values: Vec<Vec<u8>> = list
1563                .iter()
1564                .map(|e| sp_literal(e).map(|v| v.encode_key()))
1565                .collect::<Option<_>>()?;
1566            (!values.is_empty()).then_some(Condition::BitmapIn {
1567                column_id: cdef.id,
1568                values,
1569            })
1570        }
1571        // `col IS NULL` / `col IS NOT NULL`. sqlparser 0.62 represents these as
1572        // `IsNull(expr)` / `IsNotNull(expr)` (and, defensively, `IsBoolean`).
1573        Expr::IsNull(inner) => {
1574            let cid = col_def(sp_ident_name(inner)?)?.id;
1575            Some(Condition::IsNull { column_id: cid })
1576        }
1577        Expr::IsNotNull(inner) => {
1578            let cid = col_def(sp_ident_name(inner)?)?.id;
1579            Some(Condition::IsNotNull { column_id: cid })
1580        }
1581        _ => None,
1582    }
1583}
1584
1585/// Split a top-level AND tree into conjuncts, translating each to an exact
1586/// Condition. Returns `None` if any conjunct is inexact/unsupported.
1587fn translate_sqlparser_filter(
1588    expr: &sqlparser::ast::Expr,
1589    schema: &mongreldb_core::Schema,
1590) -> Option<Vec<mongreldb_core::Condition>> {
1591    use sqlparser::ast::{BinaryOperator, Expr};
1592    let mut out = Vec::new();
1593    let mut stack = vec![expr];
1594    while let Some(e) = stack.pop() {
1595        match e {
1596            Expr::BinaryOp {
1597                left,
1598                op: BinaryOperator::And,
1599                right,
1600            } => {
1601                stack.push(left.as_ref());
1602                stack.push(right.as_ref());
1603            }
1604            other => out.push(translate_sqlparser_predicate(other, schema)?),
1605        }
1606    }
1607    Some(out)
1608}
1609
1610/// Convenience wrapper: a DataFusion `SessionContext` bound to a live MongrelDB,
1611/// with a result cache keyed by `(sql, snapshot_epoch)` that auto-invalidates
1612/// when a commit advances the epoch.
1613pub struct MongrelSession {
1614    ctx: SessionContext,
1615    db: Option<TableHandle>,
1616    /// P4.1: the multi-table `Database` when opened via `open()`. When `Some`,
1617    /// the cache epoch is driven by `Database::visible_epoch()` instead of the
1618    /// legacy `combined_epoch()` fold.
1619    database: Option<Arc<Database>>,
1620    principal: Option<mongreldb_core::Principal>,
1621    principal_catalog_bound: bool,
1622    cache: ResultCache,
1623    /// Phase 16.5: logical-plan cache keyed by SQL string.
1624    plan_cache: parking_lot::Mutex<BoundedLru<String, datafusion::logical_expr::LogicalPlan>>,
1625    /// `table name → owning Table handle` for every registered table.
1626    tables: scored_sql::TableMap,
1627    /// Phase 17.3: named materialized views — `view name → defining SQL`.
1628    /// On `run("SELECT * FROM <view>")`, the defining SQL is executed (or the
1629    /// result-cache is hit). Invalidated automatically on commit (epoch bump).
1630    views: parking_lot::Mutex<HashMap<String, ViewDef>>,
1631    /// Databases attached via `ATTACH 'path' AS alias`, kept alive for the
1632    /// session's lifetime so their tables remain registered on the DataFusion
1633    /// context. Keyed by alias.
1634    attached_databases: parking_lot::Mutex<HashMap<String, Arc<Database>>>,
1635    /// SQL `BEGIN`/`COMMIT` staging for DML statements. Reads remain
1636    /// snapshot-at-scan; this batches SQL writes atomically when a client sends
1637    /// an explicit transaction block.
1638    sql_txn: parking_lot::Mutex<Option<Vec<commands::PendingSqlOp>>>,
1639    /// Failed/cancelled explicit transactions accept only a full `ROLLBACK`.
1640    sql_txn_aborted: parking_lot::Mutex<bool>,
1641    /// SAVEPOINT stack: `(name, staged-ops-length-at-savepoint)`. Truncated on
1642    /// `ROLLBACK TO name` and removed on `RELEASE name`.
1643    savepoints: parking_lot::Mutex<Vec<(String, usize)>>,
1644    /// Per-session state for SQL compatibility functions such as changes().
1645    sql_fn_state: Arc<extended_sql_functions::ExtendedSqlState>,
1646    /// Built-in plus app-provided external table modules available to this
1647    /// session.
1648    external_modules: Arc<ExternalModuleRegistry>,
1649    scored_runtime: Arc<scored_sql::ScoredRuntime>,
1650    /// Names of `PREPARE`d statements tracked so they can be `DEALLOCATE`d when
1651    /// DDL invalidates the tables they reference (DataFusion's prepared-plan
1652    /// store is not cleared by the session's own result/plan caches).
1653    prepared_names: parking_lot::Mutex<std::collections::HashSet<String>>,
1654    query_registry: Arc<SqlQueryRegistry>,
1655    execution_gate: Arc<tokio::sync::Semaphore>,
1656    statement_timeout: parking_lot::RwLock<Option<std::time::Duration>>,
1657    test_hook: parking_lot::Mutex<Option<SqlTestHook>>,
1658}
1659
1660/// `(sql, snapshot_epoch) → cached result batches`.
1661type CacheKey = (String, u64);
1662type ResultCache = parking_lot::Mutex<BoundedLru<CacheKey, Arc<Vec<RecordBatch>>>>;
1663
1664const SESSION_CACHE_MAX_ENTRIES: usize = 1024;
1665
1666struct BoundedLru<K, V> {
1667    entries: HashMap<K, (V, u64)>,
1668    clock: u64,
1669    capacity: usize,
1670}
1671
1672impl<K: Clone + Eq + Hash, V> BoundedLru<K, V> {
1673    fn new(capacity: usize) -> Self {
1674        Self {
1675            entries: HashMap::new(),
1676            clock: 0,
1677            capacity: capacity.max(1),
1678        }
1679    }
1680
1681    fn get<Q>(&mut self, key: &Q) -> Option<&V>
1682    where
1683        K: Borrow<Q>,
1684        Q: Eq + Hash + ?Sized,
1685    {
1686        self.clock = self.clock.wrapping_add(1);
1687        let (value, last_used) = self.entries.get_mut(key)?;
1688        *last_used = self.clock;
1689        Some(value)
1690    }
1691
1692    fn insert(&mut self, key: K, value: V) {
1693        self.clock = self.clock.wrapping_add(1);
1694        if let Some(entry) = self.entries.get_mut(&key) {
1695            *entry = (value, self.clock);
1696            return;
1697        }
1698        if self.entries.len() >= self.capacity {
1699            // ponytail: scan 1024 entries on eviction; use a linked map if misses get hot.
1700            if let Some(oldest) = self
1701                .entries
1702                .iter()
1703                .min_by_key(|(_, (_, last_used))| *last_used)
1704                .map(|(key, _)| key.clone())
1705            {
1706                self.entries.remove(&oldest);
1707            }
1708        }
1709        self.entries.insert(key, (value, self.clock));
1710    }
1711
1712    fn clear(&mut self) {
1713        self.entries.clear();
1714        self.clock = 0;
1715    }
1716}
1717
1718fn buffered_stream(batches: Vec<RecordBatch>) -> MongrelRecordBatchStream {
1719    let schema = batches
1720        .first()
1721        .map(RecordBatch::schema)
1722        .unwrap_or_else(|| Arc::new(arrow::datatypes::Schema::empty()));
1723    let stream = futures::stream::iter(batches.into_iter().map(Ok::<RecordBatch, DataFusionError>));
1724    Box::pin(RecordBatchStreamAdapter::new(schema, stream))
1725}
1726
1727/// Buffered SQL output whose registry entry and session permit remain live
1728/// until response serialization explicitly succeeds or fails.
1729pub struct ManagedQueryBatches {
1730    batches: Vec<RecordBatch>,
1731    query: RegisteredSqlQuery,
1732    guard: Option<RegisteredQueryGuard>,
1733    permit: Option<tokio::sync::OwnedSemaphorePermit>,
1734}
1735
1736impl ManagedQueryBatches {
1737    pub fn batches(&self) -> &[RecordBatch] {
1738        &self.batches
1739    }
1740
1741    pub fn query(&self) -> &RegisteredSqlQuery {
1742        &self.query
1743    }
1744
1745    pub fn complete(mut self) {
1746        if let Some(guard) = self.guard.take() {
1747            guard.complete();
1748        }
1749        self.permit.take();
1750    }
1751
1752    pub fn fail(mut self) {
1753        if let Some(guard) = self.guard.take() {
1754            guard.fail();
1755        }
1756        self.permit.take();
1757    }
1758}
1759
1760impl Drop for ManagedQueryBatches {
1761    fn drop(&mut self) {
1762        if self.guard.is_some() {
1763            let _ = self
1764                .query
1765                .request_cancel(mongreldb_core::CancellationReason::ClientDisconnected);
1766            if let Some(guard) = self.guard.take() {
1767                guard.fail();
1768            }
1769        }
1770    }
1771}
1772
1773struct ManagedQueryStream {
1774    inner: MongrelRecordBatchStream,
1775    schema: SchemaRef,
1776    query: RegisteredSqlQuery,
1777    guard: Option<RegisteredQueryGuard>,
1778    permit: Option<tokio::sync::OwnedSemaphorePermit>,
1779    deferred_completion: Option<Arc<std::sync::atomic::AtomicBool>>,
1780}
1781
1782#[derive(Clone)]
1783pub struct SqlStreamCompletion {
1784    completed: Arc<std::sync::atomic::AtomicBool>,
1785}
1786
1787impl SqlStreamCompletion {
1788    pub fn complete(&self) {
1789        self.completed
1790            .store(true, std::sync::atomic::Ordering::Release);
1791    }
1792}
1793
1794struct TransactionStatementGuard<'a> {
1795    session: &'a MongrelSession,
1796    savepoint: Option<usize>,
1797    completed: bool,
1798}
1799
1800impl<'a> TransactionStatementGuard<'a> {
1801    fn new(session: &'a MongrelSession, enabled: bool) -> Self {
1802        let savepoint = enabled
1803            .then(|| session.sql_txn.lock().as_ref().map(Vec::len))
1804            .flatten();
1805        Self {
1806            session,
1807            savepoint,
1808            completed: false,
1809        }
1810    }
1811
1812    fn complete(mut self) {
1813        self.completed = true;
1814    }
1815}
1816
1817impl Drop for TransactionStatementGuard<'_> {
1818    fn drop(&mut self) {
1819        if self.completed {
1820            return;
1821        }
1822        if let Some(savepoint) = self.savepoint {
1823            if let Some(ops) = self.session.sql_txn.lock().as_mut() {
1824                ops.truncate(savepoint);
1825                *self.session.sql_txn_aborted.lock() = true;
1826            }
1827        }
1828    }
1829}
1830
1831impl futures::Stream for ManagedQueryStream {
1832    type Item = datafusion::common::Result<RecordBatch>;
1833
1834    fn poll_next(
1835        mut self: std::pin::Pin<&mut Self>,
1836        context: &mut std::task::Context<'_>,
1837    ) -> std::task::Poll<Option<Self::Item>> {
1838        if let Err(error) = self.query.checkpoint() {
1839            if let Some(guard) = self.guard.take() {
1840                guard.fail();
1841            }
1842            self.permit.take();
1843            return std::task::Poll::Ready(Some(Err(DataFusionError::External(Box::new(error)))));
1844        }
1845        match self.inner.as_mut().poll_next(context) {
1846            std::task::Poll::Ready(None) => {
1847                self.query.complete_current_statement();
1848                if self.deferred_completion.is_none() {
1849                    if let Some(guard) = self.guard.take() {
1850                        guard.complete();
1851                    }
1852                    self.permit.take();
1853                }
1854                std::task::Poll::Ready(None)
1855            }
1856            std::task::Poll::Ready(Some(Err(error))) => {
1857                if let Some(guard) = self.guard.take() {
1858                    guard.fail();
1859                }
1860                self.permit.take();
1861                std::task::Poll::Ready(Some(Err(error)))
1862            }
1863            item => item,
1864        }
1865    }
1866}
1867
1868impl datafusion::physical_plan::RecordBatchStream for ManagedQueryStream {
1869    fn schema(&self) -> SchemaRef {
1870        Arc::clone(&self.schema)
1871    }
1872}
1873
1874impl Drop for ManagedQueryStream {
1875    fn drop(&mut self) {
1876        if self.guard.is_some() {
1877            let completed = self
1878                .deferred_completion
1879                .as_ref()
1880                .is_some_and(|completion| completion.load(std::sync::atomic::Ordering::Acquire));
1881            if completed {
1882                if let Some(guard) = self.guard.take() {
1883                    guard.complete();
1884                }
1885            } else {
1886                let _ = self
1887                    .query
1888                    .request_cancel(mongreldb_core::CancellationReason::ClientDisconnected);
1889                if let Some(guard) = self.guard.take() {
1890                    guard.fail();
1891                }
1892            }
1893        }
1894    }
1895}
1896
1897impl MongrelSession {
1898    /// Create a session over a live `Table`. Takes ownership; wrap in `Arc` if you
1899    /// need to keep a handle for writes after registering the provider. Registers
1900    /// the `ann_search` UDF so SQL semantic-search predicates parse.
1901    pub fn new(db: Table) -> Self {
1902        let db = Arc::new(Mutex::new(db));
1903        let handle = TableHandle::from(Arc::clone(&db));
1904        let ctx = SessionContext::new();
1905        let sql_fn_state = Arc::new(extended_sql_functions::ExtendedSqlState::default());
1906        register_mongrel_functions(&ctx, Arc::clone(&sql_fn_state));
1907        let tables = Arc::new(parking_lot::Mutex::new(HashMap::new()));
1908        let scored_runtime = scored_sql::ScoredRuntime::from_env();
1909        scored_sql::register(
1910            &ctx,
1911            Arc::clone(&tables),
1912            None,
1913            None,
1914            false,
1915            Arc::clone(&scored_runtime),
1916        );
1917        let external_modules = Arc::new(ExternalModuleRegistry::default());
1918        Self {
1919            ctx,
1920            db: Some(handle),
1921            database: None,
1922            principal: None,
1923            principal_catalog_bound: false,
1924            cache: parking_lot::Mutex::new(BoundedLru::new(SESSION_CACHE_MAX_ENTRIES)),
1925            plan_cache: parking_lot::Mutex::new(BoundedLru::new(SESSION_CACHE_MAX_ENTRIES)),
1926            tables,
1927            views: parking_lot::Mutex::new(HashMap::new()),
1928            attached_databases: parking_lot::Mutex::new(HashMap::new()),
1929            savepoints: parking_lot::Mutex::new(Vec::new()),
1930            sql_txn: parking_lot::Mutex::new(None),
1931            sql_txn_aborted: parking_lot::Mutex::new(false),
1932            sql_fn_state,
1933            external_modules,
1934            scored_runtime,
1935            prepared_names: parking_lot::Mutex::new(std::collections::HashSet::new()),
1936            query_registry: Arc::new(SqlQueryRegistry::default()),
1937            execution_gate: Arc::new(tokio::sync::Semaphore::new(1)),
1938            statement_timeout: parking_lot::RwLock::new(None),
1939            test_hook: parking_lot::Mutex::new(None),
1940        }
1941    }
1942
1943    pub fn new_with_external_modules(
1944        db: Table,
1945        modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
1946    ) -> Result<Self> {
1947        let session = Self::new(db);
1948        for module in modules {
1949            session.register_external_module(module)?;
1950        }
1951        Ok(session)
1952    }
1953
1954    /// Open a session over a multi-table [`Database`] (spec §12). Auto-registers
1955    /// every live table as a `MongrelProvider`; the cache epoch is driven by
1956    /// `Database::visible_epoch()` so any table's commit invalidates cached
1957    /// results.
1958    pub fn open(database: Arc<Database>) -> Result<Self> {
1959        Self::open_with_external_modules(database, std::iter::empty())
1960    }
1961
1962    pub fn open_as(database: Arc<Database>, principal: mongreldb_core::Principal) -> Result<Self> {
1963        Self::open_with_external_modules_as(database, std::iter::empty(), Some(principal))
1964    }
1965
1966    pub fn open_with_external_modules(
1967        database: Arc<Database>,
1968        modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
1969    ) -> Result<Self> {
1970        Self::open_with_external_modules_as(database, modules, None)
1971    }
1972
1973    pub fn open_with_external_modules_as(
1974        database: Arc<Database>,
1975        modules: impl IntoIterator<Item = Arc<dyn ExternalTableModule>>,
1976        principal: Option<mongreldb_core::Principal>,
1977    ) -> Result<Self> {
1978        let ctx = SessionContext::new();
1979        let sql_fn_state = Arc::new(extended_sql_functions::ExtendedSqlState::default());
1980        register_mongrel_functions(&ctx, Arc::clone(&sql_fn_state));
1981        let external_modules = Arc::new(ExternalModuleRegistry::default());
1982        let principal_catalog_bound = principal
1983            .as_ref()
1984            .is_some_and(|principal| database.resolve_principal(&principal.username).is_some());
1985        for module in modules {
1986            external_modules.register(module)?;
1987        }
1988
1989        let mut tables: HashMap<String, TableHandle> = HashMap::new();
1990        for name in database.table_names() {
1991            let handle = database.table(&name)?;
1992            let provider = MongrelProvider::new_secured(
1993                handle.clone(),
1994                Arc::clone(&database),
1995                name.clone(),
1996                principal.clone(),
1997            )?;
1998            ctx.register_table(&name, Arc::new(provider))
1999                .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
2000            tables.insert(name, handle);
2001        }
2002        for entry in database.external_tables() {
2003            let provider = external_modules.external_table_provider(&database, &entry)?;
2004            ctx.register_table(&entry.name, provider)
2005                .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
2006        }
2007
2008        // Pick a stable "primary" (lexicographically smallest name) for legacy
2009        // `db()` accessors. If the database is empty, `db()` returns `None`.
2010        let primary = {
2011            let mut names: Vec<&String> = tables.keys().collect();
2012            names.sort();
2013            names.first().and_then(|n| tables.get(*n).cloned())
2014        };
2015        let tables = Arc::new(parking_lot::Mutex::new(tables));
2016        let scored_runtime = scored_sql::ScoredRuntime::from_env();
2017        scored_sql::register(
2018            &ctx,
2019            Arc::clone(&tables),
2020            Some(Arc::clone(&database)),
2021            principal.clone(),
2022            principal_catalog_bound,
2023            Arc::clone(&scored_runtime),
2024        );
2025
2026        Ok(Self {
2027            ctx,
2028            db: primary,
2029            database: Some(database),
2030            principal,
2031            principal_catalog_bound,
2032            cache: parking_lot::Mutex::new(BoundedLru::new(SESSION_CACHE_MAX_ENTRIES)),
2033            plan_cache: parking_lot::Mutex::new(BoundedLru::new(SESSION_CACHE_MAX_ENTRIES)),
2034            tables,
2035            views: parking_lot::Mutex::new(HashMap::new()),
2036            attached_databases: parking_lot::Mutex::new(HashMap::new()),
2037            savepoints: parking_lot::Mutex::new(Vec::new()),
2038            sql_txn: parking_lot::Mutex::new(None),
2039            sql_txn_aborted: parking_lot::Mutex::new(false),
2040            sql_fn_state,
2041            external_modules,
2042            scored_runtime,
2043            prepared_names: parking_lot::Mutex::new(std::collections::HashSet::new()),
2044            query_registry: Arc::new(SqlQueryRegistry::default()),
2045            execution_gate: Arc::new(tokio::sync::Semaphore::new(1)),
2046            statement_timeout: parking_lot::RwLock::new(None),
2047            test_hook: parking_lot::Mutex::new(None),
2048        })
2049    }
2050
2051    pub fn query_registry(&self) -> &Arc<SqlQueryRegistry> {
2052        &self.query_registry
2053    }
2054
2055    /// Replace the session-local query registry with a process-wide registry.
2056    /// Call this before sharing the session. Servers use one registry so query
2057    /// status and cancellation never need the per-session execution lock.
2058    pub fn with_query_registry(mut self, registry: Arc<SqlQueryRegistry>) -> Self {
2059        self.query_registry = registry;
2060        self
2061    }
2062
2063    pub fn set_statement_timeout(&self, timeout: Option<std::time::Duration>) {
2064        *self.statement_timeout.write() = timeout;
2065    }
2066
2067    pub fn register_query(&self, mut options: SqlQueryOptions) -> Result<RegisteredSqlQuery> {
2068        if let Some(statement_timeout) = *self.statement_timeout.read() {
2069            options.timeout = Some(
2070                options
2071                    .timeout
2072                    .map_or(statement_timeout, |timeout| timeout.min(statement_timeout)),
2073            );
2074        }
2075        let query = self.query_registry.register(options)?;
2076        self.fire_test_hook(SqlTestHookPoint::AfterRegistration);
2077        Ok(query)
2078    }
2079
2080    pub fn cancel_query(&self, query_id: QueryId) -> CancelOutcome {
2081        self.query_registry.cancel(query_id)
2082    }
2083
2084    #[doc(hidden)]
2085    pub fn set_test_hook(&self, hook: Option<SqlTestHook>) {
2086        *self.test_hook.lock() = hook;
2087    }
2088
2089    #[doc(hidden)]
2090    pub fn sql_test_hook(&self) -> Option<SqlTestHook> {
2091        self.test_hook.lock().clone()
2092    }
2093
2094    #[doc(hidden)]
2095    pub fn staged_sql_operation_count(&self) -> Option<usize> {
2096        self.sql_txn.lock().as_ref().map(Vec::len)
2097    }
2098
2099    pub fn fire_test_hook(&self, point: SqlTestHookPoint) {
2100        let hook = self.test_hook.lock().clone();
2101        if let Some(hook) = hook {
2102            hook(point);
2103        }
2104    }
2105
2106    pub(crate) fn current_query(&self) -> Result<RegisteredSqlQuery> {
2107        CURRENT_SQL_QUERY.try_with(Clone::clone).map_err(|_| {
2108            MongrelQueryError::InvalidQueryState("SQL command has no registered query".into())
2109        })
2110    }
2111
2112    pub fn principal(&self) -> Option<mongreldb_core::Principal> {
2113        self.principal.as_ref().and_then(|principal| {
2114            if self.principal_catalog_bound {
2115                self.database
2116                    .as_ref()
2117                    .and_then(|database| database.resolve_principal(&principal.username))
2118            } else {
2119                Some(principal.clone())
2120            }
2121        })
2122    }
2123
2124    /// Set timeout, work, and fused-union limits for scored SQL functions.
2125    pub fn set_scored_execution_limits(
2126        &self,
2127        statement_timeout: std::time::Duration,
2128        max_work: usize,
2129        max_fused_candidates: usize,
2130    ) -> Result<()> {
2131        self.scored_runtime
2132            .configure(statement_timeout, max_work, max_fused_candidates)
2133            .map_err(Into::into)
2134    }
2135
2136    fn security_context_active(&self) -> bool {
2137        self.principal.is_some()
2138            || self.database.as_ref().is_some_and(|database| {
2139                database.principal_snapshot().is_some() || {
2140                    let security = database.security_catalog();
2141                    !security.rls_tables.is_empty()
2142                        || !security.policies.is_empty()
2143                        || !security.masks.is_empty()
2144                }
2145            })
2146    }
2147
2148    pub fn register_external_module(&self, module: Arc<dyn ExternalTableModule>) -> Result<()> {
2149        self.external_modules.register(module)?;
2150        self.clear_cache();
2151        Ok(())
2152    }
2153
2154    /// The underlying Table handle (Phase 19.3: used by the daemon for direct
2155    /// put/delete/commit/count access). Returns `None` when the session was
2156    /// opened over an empty `Database`.
2157    pub fn db(&self) -> Option<&TableHandle> {
2158        self.db.as_ref()
2159    }
2160
2161    /// Phase 17.3: create a named materialized view backed by a SQL query.
2162    /// `SELECT * FROM <name>` resolves to the view's defining SQL, which is
2163    /// executed (or served from the result cache) transparently. The view is
2164    /// automatically invalidated on commit (via the epoch-keyed result cache).
2165    pub fn create_view(&self, name: &str, sql: &str) {
2166        self.create_view_with_schema(name, sql, CoreSchema::default(), HashMap::new());
2167    }
2168
2169    pub(crate) fn create_view_with_schema(
2170        &self,
2171        name: &str,
2172        sql: &str,
2173        schema: CoreSchema,
2174        input_types: HashMap<u16, Option<TypeId>>,
2175    ) {
2176        self.views.lock().insert(
2177            name.to_string(),
2178            ViewDef {
2179                sql: sql.to_string(),
2180                schema,
2181                input_types,
2182            },
2183        );
2184    }
2185
2186    /// Drop a named materialized view.
2187    pub fn drop_view(&self, name: &str) {
2188        self.views.lock().remove(name);
2189    }
2190
2191    pub(crate) fn view_schema(&self, name: &str) -> Option<CoreSchema> {
2192        self.views.lock().get(name).map(|view| view.schema.clone())
2193    }
2194
2195    pub(crate) fn view_definition(&self, name: &str) -> Option<ViewDef> {
2196        self.views.lock().get(name).cloned()
2197    }
2198
2199    /// Register the table under `name` so `select * from <name>` resolves.
2200    pub async fn register(&self, name: &str) -> Result<()> {
2201        let db = self.db.clone().ok_or(MongrelQueryError::Core(
2202            mongreldb_core::MongrelError::NotFound("no primary table".into()),
2203        ))?;
2204        let provider = MongrelProvider::new_handle(db.clone())?;
2205        self.tables.lock().insert(name.to_string(), db);
2206        self.ctx
2207            .register_table(name, Arc::new(provider))
2208            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
2209        Ok(())
2210    }
2211
2212    /// Register a second (or further) live `Table` as another table on the same
2213    /// session, enabling cross-table SQL joins. The first `Table` (passed to
2214    /// [`Self::new`]) still owns the result-cache epoch: cached results are
2215    /// invalidated on its commits, so mutate the primary table last or call
2216    /// [`Self::clear_cache`] after writing a secondary table.
2217    pub async fn register_db(&self, name: &str, db: Table) -> Result<()> {
2218        let db_arc = Arc::new(Mutex::new(db));
2219        let provider = MongrelProvider::new(db_arc.clone())?;
2220        self.tables.lock().insert(name.to_string(), db_arc.into());
2221        self.ctx
2222            .register_table(name, Arc::new(provider))
2223            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
2224        Ok(())
2225    }
2226
2227    fn refresh_registered_table(&self, db: &Arc<Database>, name: &str) -> Result<()> {
2228        self.ctx
2229            .deregister_table(name)
2230            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
2231        let handle = db.table(name)?;
2232        let provider = MongrelProvider::new_secured(
2233            handle.clone(),
2234            Arc::clone(db),
2235            name.to_string(),
2236            self.principal.clone(),
2237        )?;
2238        self.ctx
2239            .register_table(name, Arc::new(provider))
2240            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
2241        self.tables.lock().insert(name.to_string(), handle);
2242        Ok(())
2243    }
2244
2245    /// Run a SQL statement and return the result batches. Repeated identical SQL
2246    /// against the same snapshot returns the cached batches without re-executing.
2247    /// Run a SQL statement and return the result batches. DDL statements
2248    /// (`CREATE TABLE`, `DROP TABLE`, `ALTER TABLE`) are intercepted when a
2249    /// Intercept `SELECT ... FROM information_schema.tables` and return
2250    /// a synthesized batch listing tables, views, and triggers. Returns
2251    /// `None` if the SQL doesn't reference that name.
2252    fn try_catalog_introspection(&self, sql: &str) -> Result<Option<Vec<RecordBatch>>> {
2253        let lower = sql.to_ascii_lowercase();
2254        if !lower.contains("information_schema.tables") {
2255            return Ok(None);
2256        }
2257        use arrow::array::{ArrayRef, Int64Array, StringArray};
2258        use arrow::datatypes::{DataType, Field, Schema};
2259        use arrow::record_batch::RecordBatch;
2260
2261        let mut types: Vec<String> = Vec::new();
2262        let mut names: Vec<String> = Vec::new();
2263        let mut tbl_names: Vec<String> = Vec::new();
2264
2265        // Tables.
2266        let principal = self.principal();
2267        let can_see = |name: &str| match &self.database {
2268            Some(database) => database
2269                .select_column_ids_for(name, principal.as_ref())
2270                .is_ok(),
2271            None => true,
2272        };
2273        for name in self.tables.lock().keys().filter(|name| can_see(name)) {
2274            types.push("table".into());
2275            names.push(name.clone());
2276            tbl_names.push(name.clone());
2277        }
2278        // Views (session-scoped).
2279        for name in self.views.lock().keys() {
2280            types.push("view".into());
2281            names.push(name.clone());
2282            tbl_names.push(name.clone());
2283        }
2284        // Triggers (engine-side, if a Database is attached).
2285        if let Some(db) = &self.database {
2286            for t in db.triggers() {
2287                let target_name = match &t.target {
2288                    mongreldb_core::trigger::TriggerTarget::Table(n)
2289                    | mongreldb_core::trigger::TriggerTarget::View(n) => n.clone(),
2290                };
2291                if !can_see(&target_name) {
2292                    continue;
2293                }
2294                types.push("trigger".into());
2295                names.push(t.name.clone());
2296                tbl_names.push(target_name);
2297            }
2298        }
2299
2300        let schema = Arc::new(Schema::new(vec![
2301            Field::new("type", DataType::Utf8, false),
2302            Field::new("name", DataType::Utf8, false),
2303            Field::new("tbl_name", DataType::Utf8, false),
2304            Field::new("rootpage", DataType::Int64, false),
2305            Field::new("sql", DataType::Utf8, true),
2306        ]));
2307        let n = names.len();
2308        let rootpages: Vec<i64> = vec![0; n];
2309        let sqls: Vec<Option<&str>> = vec![None; n];
2310        let batch = RecordBatch::try_new(
2311            schema,
2312            vec![
2313                Arc::new(StringArray::from(types)) as ArrayRef,
2314                Arc::new(StringArray::from(names)) as ArrayRef,
2315                Arc::new(StringArray::from(tbl_names)) as ArrayRef,
2316                Arc::new(Int64Array::from(rootpages)) as ArrayRef,
2317                Arc::new(StringArray::from(sqls)) as ArrayRef,
2318            ],
2319        )
2320        .map_err(|e| MongrelQueryError::Arrow(e.to_string()))?;
2321        Ok(Some(vec![batch]))
2322    }
2323
2324    /// §5.3 direct SQL dispatch: recognize a simple single-table `SELECT` from
2325    /// the raw SQL via the vendored `sqlparser` AST and serve it straight from
2326    /// the native column cursor, **bypassing DataFusion parse+plan+optimize**.
2327    /// Returns `Ok(None)` (→ fall through to `ctx.sql()`) for any shape it
2328    /// cannot serve *exactly*, or on any parse error.
2329    fn try_direct_dispatch(
2330        &self,
2331        sql: &str,
2332        query: &RegisteredSqlQuery,
2333    ) -> Result<Option<Vec<RecordBatch>>> {
2334        query.checkpoint()?;
2335        if self.security_context_active() {
2336            return Ok(None);
2337        }
2338
2339        use arrow::array::ArrayRef;
2340        use mongreldb_core::Condition;
2341        use sqlparser::ast::{Expr, Query, SelectItem, SetExpr, Statement, TableFactor};
2342        use sqlparser::dialect::PostgreSqlDialect;
2343        use sqlparser::parser::Parser;
2344
2345        // Any parse error, or more than one statement → fall through.
2346        let Ok(stmts) = Parser::parse_sql(&PostgreSqlDialect {}, sql) else {
2347            return Ok(None);
2348        };
2349        query.checkpoint()?;
2350        if stmts.len() != 1 {
2351            return Ok(None);
2352        }
2353        let Statement::Query(ast_query) = stmts.into_iter().next().unwrap() else {
2354            return Ok(None);
2355        };
2356        let Query { body, .. } = *ast_query;
2357        let select = match *body {
2358            SetExpr::Select(s) => *s,
2359            _ => return Ok(None),
2360        };
2361        // v1: fall through if LIMIT/OFFSET is present (can't read the fields
2362        // portably; a conservative token check keeps correctness safe).
2363        let lower_sql = sql.to_lowercase();
2364        if lower_sql.contains(" limit ") || lower_sql.contains(" offset ") {
2365            return Ok(None);
2366        }
2367        // Reject shapes we don't handle: DISTINCT / GROUP BY / HAVING / multi-FROM / joins.
2368        use sqlparser::ast::GroupByExpr;
2369        if select.distinct.is_some()
2370            || !matches!(&select.group_by, GroupByExpr::Expressions(e, _) if e.is_empty())
2371            || select.having.is_some()
2372            || select.from.len() != 1
2373            || !select.from[0].joins.is_empty()
2374        {
2375            return Ok(None);
2376        }
2377        let table_name = match &select.from[0].relation {
2378            TableFactor::Table { name, .. } => Some(name.to_string()),
2379            _ => return Ok(None),
2380        };
2381        let Some(table_name) = table_name else {
2382            return Ok(None);
2383        };
2384
2385        // v1 only dispatches FILTERED single-table SELECTs. An unfiltered `SELECT
2386        // *`/`SELECT cols` already streams efficiently through the scan path
2387        // (with ≤65 536-row batch chunking + Arrow shadow writes), which the
2388        // direct path's single-shot column decode can't preserve — so leave it
2389        // to DataFusion. The win here is the cold filtered-SELECT planning cost.
2390        if select.selection.is_none() {
2391            return Ok(None);
2392        }
2393
2394        // Projection: only `*` or a list of bare column identifiers.
2395        let mut proj_names: Option<Vec<String>> = None;
2396        for item in &select.projection {
2397            match item {
2398                SelectItem::Wildcard(_) => {}
2399                SelectItem::UnnamedExpr(Expr::Identifier(ident)) => {
2400                    proj_names
2401                        .get_or_insert_with(Vec::new)
2402                        .push(ident.value.clone());
2403                }
2404                SelectItem::UnnamedExpr(Expr::CompoundIdentifier(idents)) => {
2405                    if let Some(last) = idents.last() {
2406                        proj_names
2407                            .get_or_insert_with(Vec::new)
2408                            .push(last.value.clone());
2409                    }
2410                }
2411                _ => return Ok(None),
2412            }
2413        }
2414
2415        // Resolve the table handle.
2416        let handle = match self.tables.lock().get(&table_name).cloned() {
2417            Some(h) => h,
2418            None => return Ok(None),
2419        };
2420
2421        // SQL SELECT → require Select permission on the target table.
2422        if let Some(db) = &self.database {
2423            db.require_table(
2424                &table_name,
2425                mongreldb_core::auth_state::RequiredPermission::Select,
2426            )?;
2427        }
2428
2429        let mut db = handle.lock();
2430        let schema = db.schema().clone();
2431        // Translate WHERE against the live schema; an inexact/unsupported
2432        // predicate → fall through to DataFusion (which re-applies residuals).
2433        let conditions: Vec<Condition> = match &select.selection {
2434            Some(expr) => match translate_sqlparser_filter(expr, &schema) {
2435                Some(c) => c,
2436                None => return Ok(None),
2437            },
2438            None => Vec::new(),
2439        };
2440        if !conditions.is_empty() && db.ensure_indexes_complete().is_err() {
2441            return Ok(None);
2442        }
2443        let snap = db.snapshot();
2444
2445        // Resolve projected column ids + Arrow field list (in projection order).
2446        let mut col_ids: Vec<u16> = Vec::new();
2447        let mut fields: Vec<arrow::datatypes::Field> = Vec::new();
2448        let resolve_col = |name: &str| -> Option<&mongreldb_core::schema::ColumnDef> {
2449            schema.columns.iter().find(|c| c.name == name)
2450        };
2451        match &proj_names {
2452            None => {
2453                for c in &schema.columns {
2454                    col_ids.push(c.id);
2455                    fields.push(arrow::datatypes::Field::new(
2456                        &c.name,
2457                        arrow_conv::arrow_data_type(&c.ty)?,
2458                        c.flags.contains(mongreldb_core::ColumnFlags::NULLABLE),
2459                    ));
2460                }
2461            }
2462            Some(names) => {
2463                for n in names {
2464                    let cdef = match resolve_col(n) {
2465                        Some(c) => c,
2466                        None => return Ok(None), // unknown column → let DataFusion error
2467                    };
2468                    col_ids.push(cdef.id);
2469                    fields.push(arrow::datatypes::Field::new(
2470                        &cdef.name,
2471                        arrow_conv::arrow_data_type(&cdef.ty)?,
2472                        cdef.flags.contains(mongreldb_core::ColumnFlags::NULLABLE),
2473                    ));
2474                }
2475            }
2476        }
2477
2478        // Execute via the same native column path MongrelProvider::scan uses.
2479        let cols = if !conditions.is_empty() {
2480            match db.query_columns_native_cached_with_control(
2481                &conditions,
2482                Some(&col_ids),
2483                snap,
2484                query.control(),
2485            ) {
2486                Ok(Some(c)) => c,
2487                Ok(None) => db
2488                    .visible_columns_native_with_control(snap, Some(&col_ids), query.control())
2489                    .map_err(MongrelQueryError::Core)?,
2490                Err(_) => return Ok(None),
2491            }
2492        } else {
2493            db.visible_columns_native_with_control(snap, Some(&col_ids), query.control())
2494                .map_err(MongrelQueryError::Core)?
2495        };
2496        query.checkpoint()?;
2497        drop(db);
2498
2499        // Order decoded columns into projection order, then build one batch.
2500        let mut arrays: Vec<ArrayRef> = Vec::with_capacity(col_ids.len());
2501        for cid in &col_ids {
2502            query.checkpoint()?;
2503            let col = cols
2504                .iter()
2505                .find(|(id, _)| id == cid)
2506                .map(|(_, c)| c.clone());
2507            let Some(col) = col else { return Ok(None) };
2508            let ty = schema
2509                .columns
2510                .iter()
2511                .find(|c| c.id == *cid)
2512                .map(|c| c.ty.clone())
2513                .unwrap_or(mongreldb_core::schema::TypeId::Int64);
2514            arrays.push(arrow_conv::native_to_array_with_query(
2515                ty,
2516                &col,
2517                Some(query),
2518            )?);
2519        }
2520        let batch_schema = Arc::new(arrow::datatypes::Schema::new(fields));
2521        let batch = RecordBatch::try_new(batch_schema, arrays)
2522            .map_err(|e| MongrelQueryError::Arrow(format!("direct dispatch batch build: {e}")))?;
2523
2524        mongreldb_core::trace::QueryTrace::record(|t| {
2525            t.scan_mode = mongreldb_core::trace::ScanMode::DirectDispatch;
2526            t.planning_nanos = 0; // we bypassed DataFusion planning
2527        });
2528        Ok(Some(vec![batch]))
2529    }
2530
2531    async fn dataframe(&self, sql: &str) -> Result<datafusion::dataframe::DataFrame> {
2532        // Prepared commands have their own DataFusion store. Caching EXECUTE
2533        // would create one entry per parameter set.
2534        let use_plan_cache = !is_prepared_stmt_sql(sql);
2535        if let Some(plan) = use_plan_cache
2536            .then(|| self.plan_cache.lock().get(sql).cloned())
2537            .flatten()
2538        {
2539            return Ok(datafusion::dataframe::DataFrame::new(
2540                self.ctx.state(),
2541                plan,
2542            ));
2543        }
2544
2545        let df = self
2546            .ctx
2547            .sql(sql)
2548            .await
2549            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
2550        if use_plan_cache {
2551            self.plan_cache
2552                .lock()
2553                .insert(sql.to_string(), df.logical_plan().clone());
2554        }
2555        Ok(df)
2556    }
2557
2558    fn prepare_as_of_query(&self, sql: &str) -> Result<Option<AsOfQuery>> {
2559        static AS_OF_RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
2560        static NEXT_TEMP: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
2561        let re = AS_OF_RE.get_or_init(|| {
2562            regex::Regex::new(
2563                r#"(?i)\b(FROM|JOIN)\s+("[^"]+"|[A-Za-z_][A-Za-z0-9_]*)\s+AS\s+OF\s+EPOCH\s+([0-9]+)\b"#,
2564            )
2565            .expect("valid AS OF regex")
2566        });
2567        let mut captures = re.captures_iter(sql);
2568        let Some(capture) = captures.next() else {
2569            return Ok(None);
2570        };
2571        if captures.next().is_some() {
2572            return Err(MongrelQueryError::Schema(
2573                "AS OF EPOCH currently supports one historical table per query".into(),
2574            ));
2575        }
2576        let database = self.database.as_ref().ok_or_else(|| {
2577            MongrelQueryError::Schema("AS OF EPOCH requires a Database-backed session".into())
2578        })?;
2579        let whole = capture.get(0).expect("whole AS OF match");
2580        let keyword = capture.get(1).expect("FROM/JOIN capture").as_str();
2581        let table_token = capture.get(2).expect("table capture").as_str();
2582        let table_name = table_token.trim_matches('"');
2583        let epoch = capture
2584            .get(3)
2585            .expect("epoch capture")
2586            .as_str()
2587            .parse::<u64>()
2588            .map_err(|error| MongrelQueryError::Schema(format!("AS OF epoch: {error}")))?;
2589        let temp_id = NEXT_TEMP.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2590        let temp_name = format!("__mongrel_as_of_{temp_id}");
2591
2592        // Preserve an explicit alias after the epoch. Without one, alias the
2593        // temporary provider back to the original table name so qualifiers
2594        // such as `events.id` keep working.
2595        let mut replace_end = whole.end();
2596        let mut alias = table_token.to_string();
2597        let tail = &sql[replace_end..];
2598        let whitespace = tail.len() - tail.trim_start().len();
2599        let trimmed_tail = tail.trim_start();
2600        if trimmed_tail
2601            .get(..3)
2602            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("as "))
2603        {
2604            let alias_text = &trimmed_tail[3..];
2605            let alias_len = alias_text
2606                .find(|character: char| {
2607                    character.is_ascii_whitespace() || character == ',' || character == ';'
2608                })
2609                .unwrap_or(alias_text.len());
2610            if alias_len == 0 {
2611                return Err(MongrelQueryError::Schema(
2612                    "AS OF EPOCH AS requires an alias".into(),
2613                ));
2614            }
2615            alias = alias_text[..alias_len].to_string();
2616            replace_end += whitespace + 3 + alias_len;
2617        } else {
2618            let alias_len = trimmed_tail
2619                .find(|character: char| {
2620                    character.is_ascii_whitespace() || character == ',' || character == ';'
2621                })
2622                .unwrap_or(trimmed_tail.len());
2623            let candidate = &trimmed_tail[..alias_len];
2624            let keyword = candidate.to_ascii_lowercase();
2625            let clause_keyword = matches!(
2626                keyword.as_str(),
2627                "where"
2628                    | "join"
2629                    | "left"
2630                    | "right"
2631                    | "inner"
2632                    | "outer"
2633                    | "cross"
2634                    | "full"
2635                    | "on"
2636                    | "group"
2637                    | "order"
2638                    | "having"
2639                    | "limit"
2640                    | "offset"
2641                    | "union"
2642                    | "intersect"
2643                    | "except"
2644            );
2645            let valid_alias = candidate
2646                .as_bytes()
2647                .first()
2648                .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_' || *byte == b'"');
2649            if alias_len > 0 && valid_alias && !clause_keyword {
2650                alias = candidate.to_string();
2651                replace_end += whitespace + alias_len;
2652            }
2653        }
2654        let replacement = format!("{keyword} {temp_name} AS {alias}");
2655        let mut rewritten = String::with_capacity(sql.len() + replacement.len());
2656        rewritten.push_str(&sql[..whole.start()]);
2657        rewritten.push_str(&replacement);
2658        rewritten.push_str(&sql[replace_end..]);
2659
2660        let (snapshot, retention) = database.snapshot_at_owned(mongreldb_core::Epoch(epoch))?;
2661        let handle = database.table(table_name)?;
2662        let provider = MongrelProvider::new_historical(
2663            handle,
2664            snapshot,
2665            retention,
2666            Some(ProviderSecurity {
2667                database: Arc::clone(database),
2668                table: table_name.to_string(),
2669                principal: self.principal.clone(),
2670                principal_catalog_bound: self.principal_catalog_bound,
2671            }),
2672        )?;
2673        self.ctx
2674            .register_table(&temp_name, Arc::new(provider))
2675            .map_err(|error| MongrelQueryError::DataFusion(error.to_string()))?;
2676        Ok(Some(AsOfQuery {
2677            sql: rewritten,
2678            registration: AsOfRegistration {
2679                ctx: self.ctx.clone(),
2680                table_name: temp_name,
2681            },
2682        }))
2683    }
2684
2685    async fn execute_dataframe_stream(
2686        &self,
2687        dataframe: datafusion::dataframe::DataFrame,
2688        query: &RegisteredSqlQuery,
2689    ) -> Result<MongrelRecordBatchStream> {
2690        query.checkpoint()?;
2691        let task_context = dataframe.task_ctx();
2692        let session_config = task_context
2693            .session_config()
2694            .clone()
2695            .with_extension(Arc::new(SqlTaskContext::new(
2696                query.clone(),
2697                self.sql_test_hook(),
2698            )));
2699        let task_context = Arc::new(task_context.with_session_config(session_config));
2700        let physical_plan = tokio::select! {
2701            biased;
2702            plan = dataframe.create_physical_plan() => plan
2703                .map_err(|error| MongrelQueryError::DataFusion(error.to_string()))?,
2704            _ = query.control().cancelled() => return Err(query.checkpoint().unwrap_err()),
2705        };
2706        query.checkpoint()?;
2707        execute_stream(physical_plan, task_context)
2708            .map_err(|error| MongrelQueryError::DataFusion(error.to_string()))
2709    }
2710
2711    async fn collect_dataframe(
2712        &self,
2713        dataframe: datafusion::dataframe::DataFrame,
2714        query: &RegisteredSqlQuery,
2715    ) -> Result<Vec<RecordBatch>> {
2716        use futures::StreamExt;
2717
2718        let mut stream = self.execute_dataframe_stream(dataframe, query).await?;
2719        let mut batches = Vec::new();
2720        loop {
2721            let item = tokio::select! {
2722                biased;
2723                _ = query.control().cancelled() => return Err(query.checkpoint().unwrap_err()),
2724                item = stream.next() => item,
2725            };
2726            let Some(batch) = item else {
2727                break;
2728            };
2729            batches.push(batch.map_err(|error| MongrelQueryError::DataFusion(error.to_string()))?);
2730            query.checkpoint()?;
2731        }
2732        Ok(batches)
2733    }
2734
2735    async fn run_as_of(
2736        &self,
2737        query: AsOfQuery,
2738        sql_query: &RegisteredSqlQuery,
2739    ) -> Result<Vec<RecordBatch>> {
2740        let AsOfQuery { sql, registration } = query;
2741        let _registration = registration;
2742        let plan_start = std::time::Instant::now();
2743        let dataframe = self
2744            .ctx
2745            .sql(&sql)
2746            .await
2747            .map_err(|error| MongrelQueryError::DataFusion(error.to_string()))?;
2748        mongreldb_core::trace::QueryTrace::record(|trace| {
2749            trace.planning_nanos = plan_start.elapsed().as_nanos() as u64;
2750        });
2751        self.collect_dataframe(dataframe, sql_query).await
2752    }
2753
2754    async fn run_as_of_stream(
2755        &self,
2756        query: AsOfQuery,
2757        sql_query: &RegisteredSqlQuery,
2758    ) -> Result<MongrelRecordBatchStream> {
2759        use futures::StreamExt;
2760
2761        let AsOfQuery { sql, registration } = query;
2762        let dataframe = self
2763            .ctx
2764            .sql(&sql)
2765            .await
2766            .map_err(|error| MongrelQueryError::DataFusion(error.to_string()))?;
2767        let stream = self.execute_dataframe_stream(dataframe, sql_query).await?;
2768        let schema = stream.schema();
2769        let guarded = futures::stream::unfold(
2770            (stream, registration),
2771            |(mut stream, registration)| async move {
2772                stream
2773                    .next()
2774                    .await
2775                    .map(|item| (item, (stream, registration)))
2776            },
2777        );
2778        Ok(Box::pin(RecordBatchStreamAdapter::new(schema, guarded)))
2779    }
2780
2781    pub async fn run_stream_with_options(
2782        &self,
2783        sql: &str,
2784        options: SqlQueryOptions,
2785    ) -> Result<MongrelRecordBatchStream> {
2786        let query = self.register_query(options)?;
2787        self.run_stream_with_query(sql, query).await
2788    }
2789
2790    pub async fn run_stream_with_query(
2791        &self,
2792        sql: &str,
2793        query: RegisteredSqlQuery,
2794    ) -> Result<MongrelRecordBatchStream> {
2795        self.run_stream_with_query_mode(sql, query, false)
2796            .await
2797            .map(|(stream, _)| stream)
2798    }
2799
2800    pub async fn run_stream_with_query_for_serialization(
2801        &self,
2802        sql: &str,
2803        query: RegisteredSqlQuery,
2804    ) -> Result<(MongrelRecordBatchStream, SqlStreamCompletion)> {
2805        let (stream, completion) = self.run_stream_with_query_mode(sql, query, true).await?;
2806        Ok((
2807            stream,
2808            completion.expect("deferred stream completion is present"),
2809        ))
2810    }
2811
2812    async fn run_stream_with_query_mode(
2813        &self,
2814        sql: &str,
2815        query: RegisteredSqlQuery,
2816        defer_completion: bool,
2817    ) -> Result<(MongrelRecordBatchStream, Option<SqlStreamCompletion>)> {
2818        query.set_sql_metadata(sql);
2819        let guard = RegisteredQueryGuard::new(query.clone());
2820        self.fire_test_hook(SqlTestHookPoint::WaitingForSessionPermit);
2821        let permit = tokio::select! {
2822            permit = Arc::clone(&self.execution_gate).acquire_owned() => permit.map_err(|_| {
2823                MongrelQueryError::InvalidQueryState("session execution gate closed".into())
2824            })?,
2825            _ = query.control().cancelled() => {
2826                let error = query.checkpoint().unwrap_err();
2827                guard.fail();
2828                return Err(error);
2829            }
2830        };
2831        query.transition(SqlQueryPhase::Queued, SqlQueryPhase::Planning)?;
2832        self.fire_test_hook(SqlTestHookPoint::Planning);
2833        query.transition(SqlQueryPhase::Planning, SqlQueryPhase::Executing)?;
2834        query.begin_statement(0);
2835        let stream = CURRENT_SQL_QUERY
2836            .scope(query.clone(), async {
2837                tokio::select! {
2838                    biased;
2839                    result = self.run_stream_internal(sql, &query) => result,
2840                    _ = query.control().cancelled() => Err(query.checkpoint().unwrap_err()),
2841                }
2842            })
2843            .await;
2844        let stream = match stream {
2845            Ok(stream) => stream,
2846            Err(error) => {
2847                guard.fail();
2848                return Err(error);
2849            }
2850        };
2851        query.transition(SqlQueryPhase::Executing, SqlQueryPhase::Streaming)?;
2852        let deferred_completion =
2853            defer_completion.then(|| Arc::new(std::sync::atomic::AtomicBool::new(false)));
2854        if defer_completion {
2855            query.begin_serialization()?;
2856        }
2857        let schema = stream.schema();
2858        let completion = deferred_completion
2859            .as_ref()
2860            .map(|completed| SqlStreamCompletion {
2861                completed: Arc::clone(completed),
2862            });
2863        let stream = Box::pin(ManagedQueryStream {
2864            inner: stream,
2865            schema,
2866            query,
2867            guard: Some(guard),
2868            permit: Some(permit),
2869            deferred_completion,
2870        });
2871        Ok((stream, completion))
2872    }
2873
2874    pub async fn run_stream(&self, sql: &str) -> Result<MongrelRecordBatchStream> {
2875        if let Ok(query) = CURRENT_SQL_QUERY.try_with(Clone::clone) {
2876            return Box::pin(self.run_stream_internal(sql, &query)).await;
2877        }
2878        Box::pin(self.run_stream_with_options(sql, SqlQueryOptions::default())).await
2879    }
2880
2881    /// Execute SQL as a DataFusion record-batch stream. Query results bypass
2882    /// the result cache and native batch-producing fast paths. Commands and
2883    /// MongrelDB's recursive-CTE compatibility path remain buffered because
2884    /// they do not produce a DataFusion stream.
2885    async fn run_stream_internal(
2886        &self,
2887        sql: &str,
2888        query: &RegisteredSqlQuery,
2889    ) -> Result<MongrelRecordBatchStream> {
2890        query.checkpoint()?;
2891        if strip_explain_query_plan(sql).is_some() {
2892            return self.run_internal(sql, query).await.map(buffered_stream);
2893        }
2894
2895        let trimmed = sql.trim();
2896        if trimmed.contains(';') && !is_trigger_body(trimmed) {
2897            let stmts = split_sql_statements(trimmed);
2898            let non_empty: Vec<&str> = stmts
2899                .iter()
2900                .map(String::as_str)
2901                .filter(|stmt| !stmt.trim().is_empty() && stmt.trim() != ";")
2902                .collect();
2903            if non_empty.is_empty() {
2904                return Ok(buffered_stream(Vec::new()));
2905            }
2906            if stmts.len() > 1 {
2907                for (index, stmt) in non_empty[..non_empty.len() - 1].iter().enumerate() {
2908                    query.begin_statement(index);
2909                    query.checkpoint()?;
2910                    Box::pin(self.run_internal(stmt, query)).await?;
2911                    query.complete_statement(index);
2912                }
2913                query.begin_statement(non_empty.len() - 1);
2914                return Box::pin(self.run_stream_internal(non_empty[non_empty.len() - 1], query))
2915                    .await;
2916            }
2917        }
2918
2919        query.checkpoint()?;
2920        if let Some(batches) = commands::try_run_command(self, sql, query).await? {
2921            return Ok(buffered_stream(batches));
2922        }
2923
2924        if let Some(as_of_query) = self.prepare_as_of_query(sql)? {
2925            return self.run_as_of_stream(as_of_query, query).await;
2926        }
2927
2928        let resolved = self.resolve_view_sql(sql);
2929        let resolved = self.rewrite_external_module_compat_sql(&resolved);
2930        let resolved = rewrite_compat_function_calls(&resolved);
2931        let effective_sql = normalize_sql(&resolved);
2932        let sql = effective_sql.as_str();
2933
2934        if let Some(batches) = self.try_catalog_introspection(sql)? {
2935            return Ok(buffered_stream(batches));
2936        }
2937
2938        let plan_start = std::time::Instant::now();
2939        let external_module_scan = self.query_references_external_module(sql);
2940        let df = self.dataframe(sql).await?;
2941        self.track_prepared_name(sql);
2942        mongreldb_core::trace::QueryTrace::record(|trace| {
2943            trace.planning_nanos = plan_start.elapsed().as_nanos() as u64;
2944        });
2945        let stream = self.execute_dataframe_stream(df, query).await?;
2946        if external_module_scan {
2947            mongreldb_core::trace::QueryTrace::record(|trace| {
2948                trace.scan_mode = mongreldb_core::trace::ScanMode::ExternalModule;
2949            });
2950        }
2951        Ok(stream)
2952    }
2953
2954    pub async fn run_with_options(
2955        &self,
2956        sql: &str,
2957        options: SqlQueryOptions,
2958    ) -> Result<Vec<RecordBatch>> {
2959        let query = self.register_query(options)?;
2960        self.run_with_query(sql, query).await
2961    }
2962
2963    pub async fn run_with_query(
2964        &self,
2965        sql: &str,
2966        query: RegisteredSqlQuery,
2967    ) -> Result<Vec<RecordBatch>> {
2968        query.set_sql_metadata(sql);
2969        let guard = RegisteredQueryGuard::new(query.clone());
2970        self.fire_test_hook(SqlTestHookPoint::WaitingForSessionPermit);
2971        let _permit = tokio::select! {
2972            permit = Arc::clone(&self.execution_gate).acquire_owned() => permit.map_err(|_| {
2973                MongrelQueryError::InvalidQueryState("session execution gate closed".into())
2974            })?,
2975            _ = query.control().cancelled() => {
2976                let error = query.checkpoint().unwrap_err();
2977                guard.fail();
2978                return Err(error);
2979            }
2980        };
2981        query.transition(SqlQueryPhase::Queued, SqlQueryPhase::Planning)?;
2982        self.fire_test_hook(SqlTestHookPoint::Planning);
2983        query.transition(SqlQueryPhase::Planning, SqlQueryPhase::Executing)?;
2984        query.begin_statement(0);
2985        let result = CURRENT_SQL_QUERY
2986            .scope(query.clone(), async {
2987                tokio::select! {
2988                    biased;
2989                    result = self.run_internal(sql, &query) => result,
2990                    _ = query.control().cancelled() => Err(query.checkpoint().unwrap_err()),
2991                }
2992            })
2993            .await;
2994        match result {
2995            Ok(batches) => {
2996                query.complete_current_statement();
2997                guard.complete();
2998                Ok(batches)
2999            }
3000            Err(error) => {
3001                guard.fail();
3002                Err(error)
3003            }
3004        }
3005    }
3006
3007    /// Execute and collect SQL while deferring registry completion until a
3008    /// caller finishes response serialization.
3009    pub async fn run_with_query_for_serialization(
3010        &self,
3011        sql: &str,
3012        query: RegisteredSqlQuery,
3013    ) -> Result<ManagedQueryBatches> {
3014        query.set_sql_metadata(sql);
3015        let guard = RegisteredQueryGuard::new(query.clone());
3016        self.fire_test_hook(SqlTestHookPoint::WaitingForSessionPermit);
3017        let permit = tokio::select! {
3018            permit = Arc::clone(&self.execution_gate).acquire_owned() => permit.map_err(|_| {
3019                MongrelQueryError::InvalidQueryState("session execution gate closed".into())
3020            })?,
3021            _ = query.control().cancelled() => {
3022                let error = query.checkpoint().unwrap_err();
3023                guard.fail();
3024                return Err(error);
3025            }
3026        };
3027        query.transition(SqlQueryPhase::Queued, SqlQueryPhase::Planning)?;
3028        self.fire_test_hook(SqlTestHookPoint::Planning);
3029        query.transition(SqlQueryPhase::Planning, SqlQueryPhase::Executing)?;
3030        query.begin_statement(0);
3031        let result = CURRENT_SQL_QUERY
3032            .scope(query.clone(), async {
3033                tokio::select! {
3034                    biased;
3035                    result = self.run_internal(sql, &query) => result,
3036                    _ = query.control().cancelled() => Err(query.checkpoint().unwrap_err()),
3037                }
3038            })
3039            .await;
3040        match result {
3041            Ok(batches) => {
3042                query.complete_current_statement();
3043                query.begin_serialization()?;
3044                Ok(ManagedQueryBatches {
3045                    batches,
3046                    query,
3047                    guard: Some(guard),
3048                    permit: Some(permit),
3049                })
3050            }
3051            Err(error) => {
3052                guard.fail();
3053                Err(error)
3054            }
3055        }
3056    }
3057
3058    pub async fn run(&self, sql: &str) -> Result<Vec<RecordBatch>> {
3059        if let Ok(query) = CURRENT_SQL_QUERY.try_with(Clone::clone) {
3060            return Box::pin(self.run_internal(sql, &query)).await;
3061        }
3062        Box::pin(self.run_with_options(sql, SqlQueryOptions::default())).await
3063    }
3064
3065    /// Run a SQL statement: DDL/commands are intercepted; otherwise a result
3066    /// cache keyed by `(normalized SQL, snapshot epoch)` memoizes batches.
3067    /// §5.3: simple single-table SELECTs are served by [`try_direct_dispatch`]
3068    /// (no DataFusion planning) before falling back to the full DataFusion path.
3069    async fn run_internal(
3070        &self,
3071        sql: &str,
3072        query: &RegisteredSqlQuery,
3073    ) -> Result<Vec<RecordBatch>> {
3074        let normalized = sql.trim().trim_end_matches(';').trim().to_ascii_lowercase();
3075        let full_rollback = matches!(normalized.as_str(), "rollback" | "rollback transaction");
3076        let transaction_open = self.sql_txn.lock().is_some();
3077        if transaction_open && *self.sql_txn_aborted.lock() && !full_rollback {
3078            return Err(MongrelQueryError::TransactionAborted);
3079        }
3080        let statement = TransactionStatementGuard::new(self, transaction_open && !full_rollback);
3081        let result = Box::pin(self.run_internal_body(sql, query)).await;
3082        if result.is_ok() {
3083            statement.complete();
3084        }
3085        result
3086    }
3087
3088    async fn run_internal_body(
3089        &self,
3090        sql: &str,
3091        query: &RegisteredSqlQuery,
3092    ) -> Result<Vec<RecordBatch>> {
3093        query.checkpoint()?;
3094        if let Some(inner) = strip_explain_query_plan(sql) {
3095            return self.explain_query_plan(inner).await;
3096        }
3097
3098        // Multi-statement support: if the SQL contains semicolons, first try
3099        // to split into individual statements. This must happen BEFORE command
3100        // dispatch because `try_run_command` would fail on multi-statement SQL.
3101        // But CREATE TRIGGER bodies contain semicolons inside BEGIN...END, so
3102        // we only split if the first semicolon is NOT inside a BEGIN...END block.
3103        let trimmed = sql.trim();
3104        if trimmed.contains(';') && !is_trigger_body(trimmed) {
3105            let stmts = split_sql_statements(trimmed);
3106            // If all statements are empty (e.g. ";;;"), return empty result.
3107            let non_empty: Vec<&String> = stmts
3108                .iter()
3109                .filter(|s| !s.trim().is_empty() && s.trim() != ";")
3110                .collect();
3111            if non_empty.is_empty() {
3112                return Ok(Vec::new());
3113            }
3114            if stmts.len() > 1 {
3115                let mut last = Vec::new();
3116                let mut statement_index = 0;
3117                for stmt in &stmts {
3118                    let stmt = stmt.trim();
3119                    if stmt.is_empty() {
3120                        continue;
3121                    }
3122                    query.begin_statement(statement_index);
3123                    query.checkpoint()?;
3124                    last = Box::pin(self.run_internal(stmt, query)).await?;
3125                    query.complete_statement(statement_index);
3126                    statement_index += 1;
3127                }
3128                return Ok(last);
3129            }
3130        }
3131
3132        // Try command dispatch (handles DDL/DML/triggers/views/etc.).
3133        query.checkpoint()?;
3134        if let Some(batches) = commands::try_run_command(self, sql, query).await? {
3135            return Ok(batches);
3136        }
3137
3138        // Multi-statement support: if the SQL wasn't handled as a single
3139        // command and contains semicolons, split into individual statements
3140        // and execute each sequentially. This catches `SELECT 1; SELECT 2`
3141        // style multi-statement queries. Commands with semicolons in their
3142        // bodies (CREATE TRIGGER ... BEGIN ... END;) are handled above.
3143        let trimmed = sql.trim();
3144        if trimmed.contains(';') {
3145            let stmts = split_sql_statements(trimmed);
3146            if stmts.len() > 1 {
3147                let mut last = Vec::new();
3148                let mut statement_index = 0;
3149                for stmt in &stmts {
3150                    let stmt = stmt.trim();
3151                    if stmt.is_empty() {
3152                        continue;
3153                    }
3154                    query.begin_statement(statement_index);
3155                    query.checkpoint()?;
3156                    last = Box::pin(self.run_internal(stmt, query)).await?;
3157                    query.complete_statement(statement_index);
3158                    statement_index += 1;
3159                }
3160                return Ok(last);
3161            }
3162        }
3163        // P4.2: intercept DDL when a Database is attached.
3164        let lower = sql.trim_start().to_lowercase();
3165        if lower.starts_with("create table") {
3166            if let Some(db) = &self.database {
3167                db.require_for(self.principal().as_ref(), &mongreldb_core::Permission::Ddl)?;
3168                let (name, schema) = parse_create_table(sql)?;
3169                db.create_table(&name, schema)?;
3170                let handle = db.table(&name)?;
3171                let provider = MongrelProvider::new_secured(
3172                    handle.clone(),
3173                    Arc::clone(db),
3174                    name.clone(),
3175                    self.principal.clone(),
3176                )?;
3177                self.ctx
3178                    .register_table(&name, Arc::new(provider))
3179                    .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
3180                self.tables.lock().insert(name, handle);
3181                self.invalidate_prepared_statements().await;
3182                self.clear_cache();
3183                return Ok(Vec::new());
3184            }
3185        }
3186        if lower.starts_with("drop table") {
3187            if let Some(db) = &self.database {
3188                db.require_for(self.principal().as_ref(), &mongreldb_core::Permission::Ddl)?;
3189                let (name, if_exists) = parse_drop_table(sql)?;
3190                let drop_result = db.drop_table(&name);
3191                if let Err(e) = drop_result {
3192                    // IF EXISTS tolerates NotFound.
3193                    let is_not_found = matches!(e, mongreldb_core::MongrelError::NotFound(_));
3194                    if !(if_exists && is_not_found) {
3195                        return Err(e.into());
3196                    }
3197                } else {
3198                    self.ctx
3199                        .deregister_table(&name)
3200                        .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
3201                    self.tables.lock().remove(&name);
3202                }
3203                self.invalidate_prepared_statements().await;
3204                self.clear_cache();
3205                return Ok(Vec::new());
3206            }
3207        }
3208        if lower.starts_with("alter table") {
3209            if let Some(db) = &self.database {
3210                db.require_for(self.principal().as_ref(), &mongreldb_core::Permission::Ddl)?;
3211                match parse_alter_table(sql)? {
3212                    ParsedAlterTable::RenameTable { old_name, new_name } => {
3213                        db.rename_table(&old_name, &new_name)?;
3214                        // Re-key DataFusion + the session's handle cache under the new
3215                        // name. The table_id and underlying table object are unchanged
3216                        // by a rename, so a fresh handle resolves to the same table.
3217                        self.ctx
3218                            .deregister_table(&old_name)
3219                            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
3220                        self.tables.lock().remove(&old_name);
3221                        let handle = db.table(&new_name)?;
3222                        let provider = MongrelProvider::new_secured(
3223                            handle.clone(),
3224                            Arc::clone(db),
3225                            new_name.clone(),
3226                            self.principal.clone(),
3227                        )?;
3228                        self.ctx
3229                            .register_table(&new_name, Arc::new(provider))
3230                            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
3231                        self.tables.lock().insert(new_name, handle);
3232                    }
3233                    ParsedAlterTable::RenameColumn {
3234                        table_name,
3235                        column_name,
3236                        new_name,
3237                    } => {
3238                        db.alter_column(&table_name, &column_name, AlterColumn::rename(new_name))?;
3239                        self.refresh_registered_table(db, &table_name)?;
3240                    }
3241                    ParsedAlterTable::AlterColumnType {
3242                        table_name,
3243                        column_name,
3244                        ty,
3245                    } => {
3246                        db.alter_column(&table_name, &column_name, AlterColumn::set_type(ty))?;
3247                        self.refresh_registered_table(db, &table_name)?;
3248                    }
3249                    ParsedAlterTable::SetNotNull {
3250                        table_name,
3251                        column_name,
3252                    } => {
3253                        let flags = current_column_flags(db, &table_name, &column_name)?
3254                            .without(ColumnFlags::NULLABLE);
3255                        db.alter_column(&table_name, &column_name, AlterColumn::set_flags(flags))?;
3256                        self.refresh_registered_table(db, &table_name)?;
3257                    }
3258                    ParsedAlterTable::DropNotNull {
3259                        table_name,
3260                        column_name,
3261                    } => {
3262                        let flags = current_column_flags(db, &table_name, &column_name)?
3263                            .with(ColumnFlags::NULLABLE);
3264                        db.alter_column(&table_name, &column_name, AlterColumn::set_flags(flags))?;
3265                        self.refresh_registered_table(db, &table_name)?;
3266                    }
3267                }
3268                self.invalidate_prepared_statements().await;
3269                self.clear_cache();
3270                return Ok(Vec::new());
3271            }
3272        }
3273
3274        if let Some(as_of_query) = self.prepare_as_of_query(sql)? {
3275            return self.run_as_of(as_of_query, query).await;
3276        }
3277
3278        // Phase 17.3: intercept `SELECT ... FROM <view_name>` and rewrite to
3279        // the view's defining SQL.
3280        let resolved = self.resolve_view_sql(sql);
3281        let resolved = self.rewrite_external_module_compat_sql(&resolved);
3282        let resolved = rewrite_compat_function_calls(&resolved);
3283        // Canonicalize whitespace outside literals/comments so queries that
3284        // differ only in spacing share a cache key (and parse identically — SQL
3285        // is whitespace-insensitive between tokens).
3286        let effective_sql = normalize_sql(&resolved);
3287        let sql = effective_sql.as_str();
3288        // The cache key uses the Database's visible epoch (P4.1) when opened
3289        // via `open()`, or the legacy `combined_epoch()` fold for multi-table
3290        // sessions created via `new()` + `register_db()`.
3291        let epoch = self.cache_epoch();
3292        let key = (sql.to_string(), epoch);
3293        let has_ttl_table = self
3294            .tables
3295            .lock()
3296            .values()
3297            .any(|table| table.lock().ttl().is_some());
3298        let result_cacheable = !extended_sql_functions::contains_volatile_extended_function(sql)
3299            && !is_explain_analyze(sql)
3300            && !is_prepared_stmt_sql(sql)
3301            && !has_ttl_table;
3302        if result_cacheable {
3303            if let Some(hit) = self.cache.lock().get(&key) {
3304                return Ok((**hit).clone());
3305            }
3306        }
3307        // information_schema.tables: intercept catalog-introspection SELECTs
3308        // and synthesize a result batch.
3309        if let Some(batches) = self.try_catalog_introspection(sql)? {
3310            if result_cacheable {
3311                query.checkpoint()?;
3312                self.cache.lock().insert(key, Arc::new(batches.clone()));
3313            }
3314            return Ok(batches);
3315        }
3316        // §5.3: direct SQL dispatch for simple single-table SELECTs — bypasses
3317        // DataFusion parse+plan+optimize. Served batches are memoized into the
3318        // result cache like the normal path. Returns None (→ fall through) for
3319        // any shape it cannot serve exactly.
3320        if let Some(batches) = self.try_direct_dispatch(sql, query)? {
3321            if result_cacheable {
3322                query.checkpoint()?;
3323                self.cache.lock().insert(key, Arc::new(batches.clone()));
3324            }
3325            return Ok(batches);
3326        }
3327        // Phase 16.5: check the logical-plan cache before re-parsing.
3328        let plan_start = std::time::Instant::now();
3329        let external_module_scan = self.query_references_external_module(sql);
3330        let df = self.dataframe(sql).await?;
3331        // Track prepared-statement names so DDL can DEALLOCATE them (prevents a
3332        // prepared plan from querying a detached/old table after DROP+recreate).
3333        self.track_prepared_name(sql);
3334        // Priority 8: record logical-planning time (parse + plan; ~0 on a
3335        // plan-cache hit), separate from execution.
3336        let planning_nanos = plan_start.elapsed().as_nanos() as u64;
3337        mongreldb_core::trace::QueryTrace::record(|t| t.planning_nanos = planning_nanos);
3338
3339        // Phase 7.2/8.3 fast path: serve a simple single aggregate (SUM/MIN/MAX/
3340        // AVG/COUNT) over the primary table from the incremental aggregate
3341        // cache — warm cache ⇒ delta merge on commit; cold ⇒ vectorized scan.
3342        // Falls through to DataFusion for everything it cannot serve exactly.
3343        let agg_key = sql_cache_key(sql);
3344        let batches = match self.try_native_aggregate(df.logical_plan(), agg_key, query) {
3345            Ok(Some(batch)) => vec![batch],
3346            _ => {
3347                // Phase 8.1 fast path: serve a PK↔FK equi-join over two
3348                // registered tables via roaring-bitmap intersection, with no
3349                // hash-join materialization. Falls through otherwise.
3350                match self.try_fk_join(df.logical_plan(), query) {
3351                    Ok(Some(b)) => {
3352                        // Priority 13: the native FK-bitmap path served the join.
3353                        mongreldb_core::trace::QueryTrace::record(|t| {
3354                            t.join_mode = mongreldb_core::trace::JoinMode::FkBitmap;
3355                        });
3356                        b
3357                    }
3358                    _ => self.collect_dataframe(df, query).await?,
3359                }
3360            }
3361        };
3362        if external_module_scan {
3363            mongreldb_core::trace::QueryTrace::record(|t| {
3364                t.scan_mode = mongreldb_core::trace::ScanMode::ExternalModule;
3365            });
3366        }
3367        if result_cacheable {
3368            query.checkpoint()?;
3369            self.cache.lock().insert(key, Arc::new(batches.clone()));
3370        }
3371        Ok(batches)
3372    }
3373
3374    /// [`Self::run`] with a captured [`mongreldb_core::trace::QueryTrace`].
3375    ///
3376    /// Runs the SQL query inside a trace-capture scope so that path-decision
3377    /// recordings from both the SQL scan layer (`MongrelProvider::scan`) and
3378    /// the core engine (`Table::native_page_cursor`, `query_columns_native`,
3379    /// `count_conditions`, etc.) are collected into a single returned trace.
3380    ///
3381    /// The session-level result cache returns before `scan()` runs on a hit, so
3382    /// a session-cache hit yields `scan_mode = Unknown`. For scan-level
3383    /// result-cache tracing, use
3384    /// [`mongreldb_core::Table::query_columns_native_cached_traced`].
3385    pub async fn run_sql_traced(
3386        &self,
3387        sql: &str,
3388    ) -> Result<(Vec<RecordBatch>, mongreldb_core::trace::QueryTrace)> {
3389        mongreldb_core::trace::QueryTrace::push_scope();
3390        let result = self.run(sql).await;
3391        let trace = mongreldb_core::trace::QueryTrace::pop_scope();
3392        Ok((result?, trace))
3393    }
3394
3395    /// Drop all cached results (e.g. after a manual data change you want
3396    /// reflected immediately).
3397    pub fn clear_cache(&self) {
3398        self.cache.lock().clear();
3399        self.plan_cache.lock().clear();
3400    }
3401
3402    /// Record/remove a prepared-statement name from the tracking set. Called
3403    /// after the DataFusion execution path runs a `PREPARE`/`DEALLOCATE` so the
3404    /// session knows which names to invalidate when DDL changes the schema.
3405    fn track_prepared_name(&self, sql: &str) {
3406        let lower = sql.trim_start().to_ascii_lowercase();
3407        if let Some(rest) = lower.strip_prefix("prepare ") {
3408            if let Some(name) = parse_stmt_ident(rest) {
3409                self.prepared_names.lock().insert(name);
3410            }
3411        } else if let Some(rest) = lower.strip_prefix("deallocate ") {
3412            if let Some(name) = parse_stmt_ident(rest) {
3413                self.prepared_names.lock().remove(&name);
3414            }
3415        }
3416    }
3417
3418    /// `DEALLOCATE` every tracked prepared statement. Called on table DDL so a
3419    /// prepared plan cannot keep querying a dropped/altered/recreated table
3420    /// (DataFusion's prepared-plan store is otherwise untouched by `clear_cache`
3421    /// and would retain a plan bound to the old table provider). Errors from an
3422    /// unknown name are ignored (the plan was never stored, e.g. a failed
3423    /// PREPARE whose name was optimistically tracked).
3424    pub async fn invalidate_prepared_statements(&self) {
3425        let names: Vec<String> = self.prepared_names.lock().drain().collect();
3426        for name in names {
3427            // `DEALLOCATE <name>` — no params, safe (name was validated at
3428            // PREPARE time on the server path; here it came from our own
3429            // tracking so it is a bare identifier).
3430            let sql = format!("DEALLOCATE {name}");
3431            if self.ctx.sql(&sql).await.is_err() {
3432                // Unknown/stale name — ignore; the goal is just to drop it.
3433            }
3434        }
3435    }
3436
3437    async fn explain_query_plan(&self, sql: &str) -> Result<Vec<RecordBatch>> {
3438        let explain_sql = format!("EXPLAIN {}", sql.trim().trim_end_matches(';'));
3439        let batches = self
3440            .ctx
3441            .sql(&explain_sql)
3442            .await
3443            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?
3444            .collect()
3445            .await
3446            .map_err(|e| MongrelQueryError::DataFusion(e.to_string()))?;
3447        let mut detail = self.mongrel_query_plan_details(sql);
3448        for batch in &batches {
3449            if batch.num_columns() < 2 {
3450                continue;
3451            }
3452            let Some(plan_type) = batch.column(0).as_any().downcast_ref::<StringArray>() else {
3453                continue;
3454            };
3455            let Some(plan) = batch.column(1).as_any().downcast_ref::<StringArray>() else {
3456                continue;
3457            };
3458            for row in 0..batch.num_rows() {
3459                let prefix = plan_type.value(row);
3460                for line in plan.value(row).lines() {
3461                    let line = line.trim();
3462                    if !line.is_empty() {
3463                        detail.push(format!("DATAFUSION {prefix}: {line}"));
3464                    }
3465                }
3466            }
3467        }
3468        if detail.is_empty() {
3469            detail.push("plan unavailable".to_string());
3470        }
3471        let ids = (0..detail.len()).map(|i| i as i64).collect::<Vec<_>>();
3472        let parents = vec![0_i64; detail.len()];
3473        let notused = vec![0_i64; detail.len()];
3474        let schema = Arc::new(arrow::datatypes::Schema::new(vec![
3475            arrow::datatypes::Field::new("id", arrow::datatypes::DataType::Int64, false),
3476            arrow::datatypes::Field::new("parent", arrow::datatypes::DataType::Int64, false),
3477            arrow::datatypes::Field::new("notused", arrow::datatypes::DataType::Int64, false),
3478            arrow::datatypes::Field::new("detail", arrow::datatypes::DataType::Utf8, false),
3479        ]));
3480        let batch = RecordBatch::try_new(
3481            schema,
3482            vec![
3483                Arc::new(Int64Array::from(ids)) as ArrayRef,
3484                Arc::new(Int64Array::from(parents)),
3485                Arc::new(Int64Array::from(notused)),
3486                Arc::new(StringArray::from(detail)),
3487            ],
3488        )
3489        .map_err(|e| MongrelQueryError::Arrow(e.to_string()))?;
3490        Ok(vec![batch])
3491    }
3492
3493    fn mongrel_query_plan_details(&self, sql: &str) -> Vec<String> {
3494        use sqlparser::ast::{GroupByExpr, OrderByKind, SetExpr, Statement};
3495        use sqlparser::dialect::PostgreSqlDialect;
3496        use sqlparser::parser::Parser;
3497
3498        let Ok(stmts) = Parser::parse_sql(&PostgreSqlDialect {}, sql) else {
3499            return Vec::new();
3500        };
3501        let Some(Statement::Query(query)) = stmts.first() else {
3502            return Vec::new();
3503        };
3504
3505        fn collect(session: &MongrelSession, query: &sqlparser::ast::Query, out: &mut Vec<String>) {
3506            use sqlparser::ast::{SetOperator, TableWithJoins};
3507            match query.body.as_ref() {
3508                SetExpr::Select(select) => {
3509                    for TableWithJoins { relation, joins } in &select.from {
3510                        session.push_table_plan(relation, select.selection.as_ref(), out);
3511                        for join in joins {
3512                            session.push_table_plan(&join.relation, None, out);
3513                        }
3514                    }
3515                    if select.distinct.is_some() {
3516                        out.push("USE TEMP B-TREE FOR DISTINCT".to_string());
3517                    }
3518                    let grouped = match &select.group_by {
3519                        GroupByExpr::All(_) => true,
3520                        GroupByExpr::Expressions(exprs, _) => !exprs.is_empty(),
3521                    };
3522                    if grouped {
3523                        out.push("USE TEMP B-TREE FOR GROUP BY".to_string());
3524                    }
3525                    let ordered = query.order_by.as_ref().is_some_and(|order_by| {
3526                        matches!(order_by.kind, OrderByKind::All(_))
3527                            || matches!(&order_by.kind, OrderByKind::Expressions(exprs) if !exprs.is_empty())
3528                    });
3529                    if ordered {
3530                        out.push("USE TEMP B-TREE FOR ORDER BY".to_string());
3531                    }
3532                }
3533                SetExpr::Query(query) => collect(session, query, out),
3534                SetExpr::SetOperation {
3535                    left, op, right, ..
3536                } => {
3537                    let label = match op {
3538                        SetOperator::Union => "COMPOUND QUERY UNION",
3539                        SetOperator::Except => "COMPOUND QUERY EXCEPT",
3540                        SetOperator::Intersect => "COMPOUND QUERY INTERSECT",
3541                        _ => "COMPOUND QUERY",
3542                    };
3543                    out.push(label.to_string());
3544                    collect_set_expr(session, left, out);
3545                    collect_set_expr(session, right, out);
3546                }
3547                _ => {}
3548            }
3549        }
3550
3551        fn collect_set_expr(session: &MongrelSession, expr: &SetExpr, out: &mut Vec<String>) {
3552            match expr {
3553                SetExpr::Select(select) => {
3554                    for table in &select.from {
3555                        session.push_table_plan(&table.relation, select.selection.as_ref(), out);
3556                    }
3557                }
3558                SetExpr::Query(query) => collect(session, query, out),
3559                SetExpr::SetOperation { left, right, .. } => {
3560                    collect_set_expr(session, left, out);
3561                    collect_set_expr(session, right, out);
3562                }
3563                _ => {}
3564            }
3565        }
3566
3567        let mut out = Vec::new();
3568        collect(self, query, &mut out);
3569        out
3570    }
3571
3572    fn push_table_plan(
3573        &self,
3574        relation: &sqlparser::ast::TableFactor,
3575        selection: Option<&sqlparser::ast::Expr>,
3576        out: &mut Vec<String>,
3577    ) {
3578        let sqlparser::ast::TableFactor::Table { name, alias, .. } = relation else {
3579            out.push("SCAN SUBQUERY".to_string());
3580            return;
3581        };
3582        let table_name = name.to_string();
3583        let display_name = alias
3584            .as_ref()
3585            .map(|alias| alias.name.value.clone())
3586            .unwrap_or_else(|| table_name.clone());
3587        let Some(handle) = self.tables.lock().get(&table_name).cloned() else {
3588            out.push(format!("SCAN {display_name}"));
3589            return;
3590        };
3591        let schema = handle.lock().schema().clone();
3592        let searchable = selection
3593            .and_then(|expr| translate_sqlparser_filter(expr, &schema))
3594            .is_some_and(|conditions| !conditions.is_empty());
3595        if searchable {
3596            out.push(format!("SEARCH {display_name} USING MONGREL INDEX"));
3597        } else {
3598            out.push(format!("SCAN {display_name}"));
3599        }
3600    }
3601
3602    /// A cache key epoch combining the primary table's epoch with every
3603    /// secondary table's, so any registered table's commit invalidates cached
3604    /// results (correctness for multi-table joins).
3605    /// Phase 17.3: rewrite `FROM <view_name>` to `FROM (<view_sql>) AS <view_name>`.
3606    fn resolve_view_sql(&self, sql: &str) -> String {
3607        let views = self.views.lock();
3608        if views.is_empty() {
3609            return sql.to_string();
3610        }
3611        let mut result = sql.to_string();
3612        for (name, view) in views.iter() {
3613            result = replace_from_view(&result, name, &view.sql);
3614        }
3615        result
3616    }
3617
3618    fn rewrite_external_module_compat_sql(&self, sql: &str) -> String {
3619        let Some(db) = &self.database else {
3620            return sql.to_string();
3621        };
3622        rewrite_fts_match_compat_sql(sql, db)
3623    }
3624
3625    fn query_references_external_module(&self, sql: &str) -> bool {
3626        use sqlparser::ast::Statement;
3627        use sqlparser::dialect::PostgreSqlDialect;
3628        use sqlparser::parser::Parser;
3629
3630        Parser::parse_sql(&PostgreSqlDialect {}, sql)
3631            .ok()
3632            .is_some_and(|statements| {
3633                statements.iter().any(|statement| match statement {
3634                    Statement::Query(query) => self.query_uses_external_module(query),
3635                    _ => false,
3636                })
3637            })
3638    }
3639
3640    fn query_uses_external_module(&self, query: &sqlparser::ast::Query) -> bool {
3641        self.set_expr_uses_external_module(query.body.as_ref())
3642    }
3643
3644    fn set_expr_uses_external_module(&self, expr: &sqlparser::ast::SetExpr) -> bool {
3645        use sqlparser::ast::SetExpr;
3646
3647        match expr {
3648            SetExpr::Select(select) => select
3649                .from
3650                .iter()
3651                .any(|table| self.table_with_joins_uses_external_module(table)),
3652            SetExpr::Query(query) => self.query_uses_external_module(query),
3653            SetExpr::SetOperation { left, right, .. } => {
3654                self.set_expr_uses_external_module(left)
3655                    || self.set_expr_uses_external_module(right)
3656            }
3657            _ => false,
3658        }
3659    }
3660
3661    fn table_with_joins_uses_external_module(
3662        &self,
3663        table: &sqlparser::ast::TableWithJoins,
3664    ) -> bool {
3665        self.table_factor_uses_external_module(&table.relation)
3666            || table
3667                .joins
3668                .iter()
3669                .any(|join| self.table_factor_uses_external_module(&join.relation))
3670    }
3671
3672    fn table_factor_uses_external_module(&self, relation: &sqlparser::ast::TableFactor) -> bool {
3673        use sqlparser::ast::{Expr, TableFactor};
3674
3675        match relation {
3676            TableFactor::Table { name, args, .. } => {
3677                let table_name = name.to_string();
3678                self.database
3679                    .as_ref()
3680                    .is_some_and(|db| db.external_table(&table_name).is_some())
3681                    || (args.is_some() && self.external_modules.contains(&table_name))
3682            }
3683            TableFactor::Function { name, .. } => self.external_modules.contains(&name.to_string()),
3684            TableFactor::TableFunction {
3685                expr: Expr::Function(func),
3686                ..
3687            } => self.external_modules.contains(&func.name.to_string()),
3688            TableFactor::Derived { subquery, .. } => self.query_uses_external_module(subquery),
3689            _ => false,
3690        }
3691    }
3692
3693    /// Cache epoch: uses `Database::visible_epoch()` when a Database is
3694    /// attached (P4.1), otherwise falls back to the legacy `combined_epoch()`.
3695    fn cache_epoch(&self) -> u64 {
3696        if let Some(db) = &self.database {
3697            db.visible_epoch().0
3698        } else {
3699            self.combined_epoch()
3700        }
3701    }
3702
3703    fn combined_epoch(&self) -> u64 {
3704        let primary = self.db.as_ref().expect("no primary table");
3705        let mut combined = primary.lock().snapshot().epoch.0;
3706        let tables = self.tables.lock();
3707        for arc in tables.values() {
3708            if !arc.ptr_eq(primary) {
3709                let e = arc.lock().snapshot().epoch.0;
3710                combined = combined.wrapping_mul(31).wrapping_add(e);
3711            }
3712        }
3713        combined
3714    }
3715
3716    /// Attempt the Phase 7.2/8.3 native aggregate fast path against `plan`.
3717    /// Returns `Ok(Some(batch))` when served natively, `Ok(None)` to fall
3718    /// through. `cache_key` ties the result to the incremental cache (Phase 8.3).
3719    fn try_native_aggregate(
3720        &self,
3721        plan: &datafusion::logical_expr::LogicalPlan,
3722        cache_key: u64,
3723        query: &RegisteredSqlQuery,
3724    ) -> Result<Option<RecordBatch>> {
3725        if self.security_context_active() {
3726            return Ok(None);
3727        }
3728        let Some(primary) = self.db.as_ref() else {
3729            return Ok(None);
3730        };
3731        let mut db = primary.lock();
3732        let schema = db.schema().clone();
3733        let snap = db.snapshot();
3734        native_agg::try_native_aggregate(&mut db, &schema, snap, plan, cache_key, Some(query))
3735    }
3736
3737    /// Attempt the Phase 8.1 FK-join (bitmap-intersection) fast path against
3738    /// `plan`. Returns `Ok(Some(batches))` when served natively, `Ok(None)` to
3739    /// fall through to DataFusion.
3740    fn try_fk_join(
3741        &self,
3742        plan: &datafusion::logical_expr::LogicalPlan,
3743        query: &RegisteredSqlQuery,
3744    ) -> Result<Option<Vec<RecordBatch>>> {
3745        if self.security_context_active() {
3746            return Ok(None);
3747        }
3748        let tables = self.tables.lock();
3749        fk_join::try_fk_join(&tables, plan, Some(query))
3750    }
3751
3752    pub fn context(&self) -> &SessionContext {
3753        &self.ctx
3754    }
3755
3756    /// Register a custom scalar SQL function on this session.
3757    ///
3758    /// This is the Rust escape hatch for application-defined SQL functions. The
3759    /// session's plan and result caches are cleared because function resolution
3760    /// can change query output without advancing the storage epoch.
3761    pub fn register_scalar_udf(&self, f: ScalarUDF) {
3762        self.ctx.register_udf(f);
3763        self.clear_cache();
3764    }
3765
3766    /// Register a custom aggregate SQL function on this session.
3767    pub fn register_aggregate_udf(&self, f: AggregateUDF) {
3768        self.ctx.register_udaf(f);
3769        self.clear_cache();
3770    }
3771
3772    /// Register a custom window SQL function on this session.
3773    pub fn register_window_udf(&self, f: WindowUDF) {
3774        self.ctx.register_udwf(f);
3775        self.clear_cache();
3776    }
3777}
3778
3779fn register_mongrel_functions(
3780    ctx: &SessionContext,
3781    sql_fn_state: Arc<extended_sql_functions::ExtendedSqlState>,
3782) {
3783    ctx.register_udf(ScalarUDF::from(udf::AnnSearchUdf::new()));
3784    ctx.register_udf(ScalarUDF::from(udf::SparseMatchUdf::new()));
3785    ctx.register_udf(ScalarUDF::from(udf::RTreeIntersectsUdf::new()));
3786    ctx.register_udf(ScalarUDF::from(udf::FtsRankUdf::new()));
3787    for udaf in percentile::percentile_udafs() {
3788        ctx.register_udaf(udaf);
3789    }
3790    extended_sql_functions::register_extended_sql_functions_with_state(ctx, sql_fn_state);
3791}
3792
3793/// Check if the SQL is a CREATE TRIGGER statement with a BEGIN...END body.
3794/// These contain semicolons inside the body that must NOT be split.
3795fn is_trigger_body(sql: &str) -> bool {
3796    let lower = sql.to_ascii_lowercase();
3797    if !lower.contains("create trigger") && !lower.contains("create or replace trigger") {
3798        return false;
3799    }
3800    lower.contains("begin") && lower.contains("end")
3801}
3802
3803/// Split a SQL string into individual statements on semicolons, respecting
3804/// single-quoted strings (`'...'`), double-quoted identifiers (`"..."`),
3805/// dollar-quoting (`$$...$$` or `$tag$...$tag$`), and line/block comments.
3806/// A trailing semicolon is not an empty statement.
3807fn split_sql_statements(sql: &str) -> Vec<String> {
3808    let b = sql.as_bytes();
3809    let n = b.len();
3810    let mut stmts = Vec::new();
3811    let mut current = String::new();
3812    let mut i = 0;
3813    while i < n {
3814        let c = b[i];
3815        // Line comment: skip to end of line.
3816        if c == b'-' && i + 1 < n && b[i + 1] == b'-' {
3817            while i < n && b[i] != b'\n' {
3818                current.push(c as char);
3819                i += 1;
3820            }
3821            continue;
3822        }
3823        // Block comment: skip to matching close.
3824        if c == b'/' && i + 1 < n && b[i + 1] == b'*' {
3825            current.push_str("/*");
3826            i += 2;
3827            let mut depth = 1;
3828            while i + 1 < n && depth > 0 {
3829                if b[i] == b'/' && b[i + 1] == b'*' {
3830                    depth += 1;
3831                    current.push_str("/*");
3832                    i += 2;
3833                } else if b[i] == b'*' && b[i + 1] == b'/' {
3834                    depth -= 1;
3835                    current.push_str("*/");
3836                    i += 2;
3837                } else {
3838                    current.push(b[i] as char);
3839                    i += 1;
3840                }
3841            }
3842            continue;
3843        }
3844        // Single-quoted string.
3845        if c == b'\'' {
3846            current.push('\'');
3847            i += 1;
3848            while i < n {
3849                current.push(b[i] as char);
3850                if b[i] == b'\'' {
3851                    // Check for doubled quote escape.
3852                    if i + 1 < n && b[i + 1] == b'\'' {
3853                        current.push('\'');
3854                        i += 2;
3855                        continue;
3856                    }
3857                    i += 1;
3858                    break;
3859                }
3860                i += 1;
3861            }
3862            continue;
3863        }
3864        // Double-quoted identifier.
3865        if c == b'"' {
3866            current.push('"');
3867            i += 1;
3868            while i < n {
3869                current.push(b[i] as char);
3870                if b[i] == b'"' {
3871                    if i + 1 < n && b[i + 1] == b'"' {
3872                        current.push('"');
3873                        i += 2;
3874                        continue;
3875                    }
3876                    i += 1;
3877                    break;
3878                }
3879                i += 1;
3880            }
3881            continue;
3882        }
3883        // Dollar-quoting: $tag$ ... $tag$
3884        if c == b'$' {
3885            // Try to read a dollar-quote opener.
3886            let start = i;
3887            let mut tag_end = i + 1;
3888            while tag_end < n && b[tag_end] != b'$' && b[tag_end].is_ascii_alphanumeric() {
3889                tag_end += 1;
3890            }
3891            if tag_end < n && b[tag_end] == b'$' {
3892                let tag = &sql[start..=tag_end];
3893                current.push_str(tag);
3894                i = tag_end + 1;
3895                // Find the closing tag.
3896                while i < n {
3897                    if b[i] == b'$' && sql[i..].starts_with(tag) {
3898                        current.push_str(tag);
3899                        i += tag.len();
3900                        break;
3901                    }
3902                    current.push(b[i] as char);
3903                    i += 1;
3904                }
3905                continue;
3906            }
3907        }
3908        // Semicolon — statement boundary.
3909        if c == b';' {
3910            current.push(';');
3911            let trimmed = current.trim();
3912            if !trimmed.is_empty() && trimmed != ";" {
3913                stmts.push(current.clone());
3914            }
3915            current.clear();
3916            i += 1;
3917            continue;
3918        }
3919        current.push(c as char);
3920        i += 1;
3921    }
3922    // Trailing content without a semicolon.
3923    let trimmed = current.trim();
3924    if !trimmed.is_empty() {
3925        stmts.push(current);
3926    }
3927    stmts
3928}
3929
3930fn strip_explain_query_plan(sql: &str) -> Option<&str> {
3931    let trimmed = sql.trim_start();
3932    let lower = trimmed.to_ascii_lowercase();
3933    if !lower.starts_with("explain") {
3934        return None;
3935    }
3936    let after_explain = trimmed.get(7..)?.trim_start();
3937    let after_explain_lower = after_explain.to_ascii_lowercase();
3938    if !after_explain_lower.starts_with("query") {
3939        return None;
3940    }
3941    let after_query = after_explain.get(5..)?.trim_start();
3942    let after_query_lower = after_query.to_ascii_lowercase();
3943    if !after_query_lower.starts_with("plan") {
3944        return None;
3945    }
3946    Some(after_query.get(4..)?.trim_start())
3947}
3948
3949/// Whether `sql` is a `PREPARE`/`EXECUTE`/`DEALLOCATE` statement. These must
3950/// bypass the session result + logical-plan caches: `EXECUTE name($p)` with
3951/// varying params would otherwise create one cache entry per parameter set
3952/// (unbounded growth), and the prepared plan itself is already held by the
3953/// DataFusion context.
3954fn is_prepared_stmt_sql(sql: &str) -> bool {
3955    let lower = sql.trim_start().to_ascii_lowercase();
3956    lower.starts_with("prepare ")
3957        || lower.starts_with("execute ")
3958        || lower.starts_with("deallocate ")
3959}
3960
3961/// Parse the first identifier from the text following a `PREPARE`/`DEALLOCATE`
3962/// keyword (e.g. `"gt AS SELECT ..."` → `"gt"`, `"p"` → `"p"`), stripping
3963/// surrounding quotes. Used to track prepared-statement names.
3964fn parse_stmt_ident(s: &str) -> Option<String> {
3965    let tok = s
3966        .trim_start()
3967        .split(|c: char| c.is_whitespace() || c == '(')
3968        .next()?;
3969    let t = tok.trim_matches(|c| c == '"' || c == '`' || c == '\'');
3970    (!t.is_empty()).then(|| t.to_string())
3971}
3972
3973/// Whether `sql` is an `EXPLAIN ANALYZE ...` statement. EXPLAIN ANALYZE falls
3974/// through to DataFusion 54 (which executes the plan and reports per-operator
3975/// timing), but its output must never be result-cached: the timing metrics are
3976/// request-specific and would be misleading on a cache hit.
3977fn is_explain_analyze(sql: &str) -> bool {
3978    let trimmed = sql.trim_start();
3979    let lower = trimmed.to_ascii_lowercase();
3980    if !lower.starts_with("explain") {
3981        return false;
3982    }
3983    let after = trimmed[7..].trim_start();
3984    after.to_ascii_lowercase().starts_with("analyze")
3985}
3986
3987#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3988enum SqlCompatTokenKind {
3989    Ident,
3990    String,
3991    Dot,
3992    LParen,
3993    RParen,
3994    Comma,
3995}
3996
3997#[derive(Debug, Clone)]
3998struct SqlCompatToken {
3999    kind: SqlCompatTokenKind,
4000    raw: String,
4001    normalized: String,
4002    start: usize,
4003    end: usize,
4004}
4005
4006#[derive(Debug, Clone)]
4007struct FtsMatchBinding {
4008    query_ref: String,
4009}
4010
4011#[derive(Debug, Clone)]
4012struct SqlReplacement {
4013    start: usize,
4014    end: usize,
4015    replacement: String,
4016}
4017
4018fn rewrite_fts_match_compat_sql(sql: &str, db: &Database) -> String {
4019    let tokens = sql_compat_tokens(sql);
4020    if tokens.is_empty() {
4021        return sql.to_string();
4022    }
4023    let bindings = fts_match_bindings(sql, db, &tokens);
4024    if bindings.is_empty() {
4025        return sql.to_string();
4026    }
4027    let unique_refs = bindings
4028        .values()
4029        .map(|binding| binding.query_ref.as_str())
4030        .collect::<HashSet<_>>();
4031    let unique_binding = if unique_refs.len() == 1 {
4032        bindings.values().next().cloned()
4033    } else {
4034        None
4035    };
4036    let mut replacements = Vec::new();
4037    for (idx, token) in tokens.iter().enumerate() {
4038        if token.kind != SqlCompatTokenKind::Ident || token.normalized != "match" {
4039            continue;
4040        }
4041        let Some(rhs) = tokens.get(idx + 1) else {
4042            continue;
4043        };
4044        if rhs.kind != SqlCompatTokenKind::String {
4045            continue;
4046        }
4047        let Some((lhs_start, _lhs_end, query_ref)) =
4048            fts_match_lhs_query_ref(&tokens, idx, &bindings, unique_binding.as_ref())
4049        else {
4050            continue;
4051        };
4052        replacements.push(SqlReplacement {
4053            start: lhs_start,
4054            end: rhs.end,
4055            replacement: format!("{query_ref}.query = {}", rhs.raw),
4056        });
4057    }
4058    apply_sql_replacements(sql, &replacements)
4059}
4060
4061fn fts_match_lhs_query_ref(
4062    tokens: &[SqlCompatToken],
4063    match_idx: usize,
4064    bindings: &HashMap<String, FtsMatchBinding>,
4065    unique_binding: Option<&FtsMatchBinding>,
4066) -> Option<(usize, usize, String)> {
4067    if match_idx == 0 {
4068        return None;
4069    }
4070    let lhs = tokens.get(match_idx - 1)?;
4071    if lhs.kind != SqlCompatTokenKind::Ident {
4072        return None;
4073    }
4074
4075    if match_idx >= 3
4076        && tokens.get(match_idx - 2)?.kind == SqlCompatTokenKind::Dot
4077        && tokens.get(match_idx - 3)?.kind == SqlCompatTokenKind::Ident
4078    {
4079        let owner = tokens.get(match_idx - 3)?;
4080        let binding = bindings.get(&owner.normalized)?;
4081        if lhs.normalized == "query" || lhs.normalized == "text" {
4082            return Some((owner.start, lhs.end, binding.query_ref.clone()));
4083        }
4084        return None;
4085    }
4086
4087    if let Some(binding) = bindings.get(&lhs.normalized) {
4088        return Some((lhs.start, lhs.end, binding.query_ref.clone()));
4089    }
4090    if lhs.normalized == "text" {
4091        let binding = unique_binding?;
4092        return Some((lhs.start, lhs.end, binding.query_ref.clone()));
4093    }
4094    None
4095}
4096
4097fn fts_match_bindings(
4098    sql: &str,
4099    db: &Database,
4100    tokens: &[SqlCompatToken],
4101) -> HashMap<String, FtsMatchBinding> {
4102    let mut out = HashMap::new();
4103    let mut i = 0;
4104    while i < tokens.len() {
4105        let token = &tokens[i];
4106        let starts_table_ref = token.kind == SqlCompatTokenKind::Ident
4107            && matches!(token.normalized.as_str(), "from" | "join");
4108        if !starts_table_ref {
4109            i += 1;
4110            continue;
4111        }
4112        let mut table_idx = i + 1;
4113        if tokens
4114            .get(table_idx)
4115            .is_some_and(|token| token.kind == SqlCompatTokenKind::LParen)
4116        {
4117            i += 1;
4118            continue;
4119        }
4120        let Some(table) = tokens.get(table_idx) else {
4121            break;
4122        };
4123        if table.kind != SqlCompatTokenKind::Ident {
4124            i += 1;
4125            continue;
4126        }
4127        let mut table_name = table.normalized.clone();
4128        let mut table_ref = table.raw.clone();
4129        if tokens
4130            .get(table_idx + 1)
4131            .is_some_and(|token| token.kind == SqlCompatTokenKind::Dot)
4132            && tokens
4133                .get(table_idx + 2)
4134                .is_some_and(|token| token.kind == SqlCompatTokenKind::Ident)
4135        {
4136            let qualified = tokens.get(table_idx + 2).unwrap();
4137            table_name = qualified.normalized.clone();
4138            table_ref = sql[table.start..qualified.end].to_string();
4139            table_idx += 2;
4140        }
4141        if !is_fts_docs_table(db, &table_name) {
4142            i = table_idx + 1;
4143            continue;
4144        }
4145        let mut query_ref = table_ref.clone();
4146        let mut alias_key = None;
4147        let mut next = table_idx + 1;
4148        if tokens.get(next).is_some_and(|token| {
4149            token.kind == SqlCompatTokenKind::Ident && token.normalized == "as"
4150        }) {
4151            next += 1;
4152        }
4153        if let Some(alias) = tokens.get(next) {
4154            if alias.kind == SqlCompatTokenKind::Ident && !is_table_ref_boundary(&alias.normalized)
4155            {
4156                alias_key = Some(alias.normalized.clone());
4157                query_ref = alias.raw.clone();
4158                next += 1;
4159            }
4160        }
4161        out.insert(
4162            table_name,
4163            FtsMatchBinding {
4164                query_ref: query_ref.clone(),
4165            },
4166        );
4167        if let Some(alias_key) = alias_key {
4168            out.insert(alias_key, FtsMatchBinding { query_ref });
4169        }
4170        i = next;
4171    }
4172    out
4173}
4174
4175fn is_fts_docs_table(db: &Database, name: &str) -> bool {
4176    db.external_table(name)
4177        .is_some_and(|entry| entry.module == "fts_docs")
4178}
4179
4180fn is_table_ref_boundary(normalized: &str) -> bool {
4181    matches!(
4182        normalized,
4183        "where"
4184            | "join"
4185            | "left"
4186            | "right"
4187            | "inner"
4188            | "outer"
4189            | "full"
4190            | "cross"
4191            | "on"
4192            | "using"
4193            | "group"
4194            | "order"
4195            | "having"
4196            | "limit"
4197            | "offset"
4198            | "union"
4199            | "except"
4200            | "intersect"
4201    )
4202}
4203
4204fn sql_compat_tokens(sql: &str) -> Vec<SqlCompatToken> {
4205    let bytes = sql.as_bytes();
4206    let mut tokens = Vec::new();
4207    let mut i = 0;
4208    while i < bytes.len() {
4209        match bytes[i] {
4210            b if b.is_ascii_whitespace() => i += 1,
4211            b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
4212                i += 2;
4213                while i < bytes.len() && bytes[i] != b'\n' {
4214                    i += 1;
4215                }
4216            }
4217            b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4218                i = skip_block_comment(bytes, i);
4219            }
4220            b'\'' => {
4221                let end = skip_quoted(bytes, i, b'\'');
4222                tokens.push(sql_token(sql, SqlCompatTokenKind::String, i, end));
4223                i = end;
4224            }
4225            b'E' | b'e' if i + 1 < bytes.len() && bytes[i + 1] == b'\'' => {
4226                let end = skip_quoted(bytes, i + 1, b'\'');
4227                tokens.push(sql_token(sql, SqlCompatTokenKind::String, i, end));
4228                i = end;
4229            }
4230            b'$' => {
4231                let (end, matched) = skip_dollar_quoted(bytes, i);
4232                if matched {
4233                    tokens.push(sql_token(sql, SqlCompatTokenKind::String, i, end));
4234                    i = end;
4235                } else {
4236                    i += 1;
4237                }
4238            }
4239            b'"' => {
4240                let end = skip_quoted(bytes, i, b'"');
4241                let raw = sql[i..end].to_string();
4242                let normalized = unquote_sql_ident(&raw).to_ascii_lowercase();
4243                tokens.push(SqlCompatToken {
4244                    kind: SqlCompatTokenKind::Ident,
4245                    raw,
4246                    normalized,
4247                    start: i,
4248                    end,
4249                });
4250                i = end;
4251            }
4252            b'.' => {
4253                tokens.push(sql_token(sql, SqlCompatTokenKind::Dot, i, i + 1));
4254                i += 1;
4255            }
4256            b'(' => {
4257                tokens.push(sql_token(sql, SqlCompatTokenKind::LParen, i, i + 1));
4258                i += 1;
4259            }
4260            b')' => {
4261                tokens.push(sql_token(sql, SqlCompatTokenKind::RParen, i, i + 1));
4262                i += 1;
4263            }
4264            b',' => {
4265                tokens.push(sql_token(sql, SqlCompatTokenKind::Comma, i, i + 1));
4266                i += 1;
4267            }
4268            b if is_sql_ident_byte(b) => {
4269                let start = i;
4270                i += 1;
4271                while i < bytes.len() && is_sql_ident_byte(bytes[i]) {
4272                    i += 1;
4273                }
4274                tokens.push(sql_token(sql, SqlCompatTokenKind::Ident, start, i));
4275            }
4276            _ => i += 1,
4277        }
4278    }
4279    tokens
4280}
4281
4282fn sql_token(sql: &str, kind: SqlCompatTokenKind, start: usize, end: usize) -> SqlCompatToken {
4283    let raw = sql[start..end].to_string();
4284    SqlCompatToken {
4285        kind,
4286        normalized: raw.to_ascii_lowercase(),
4287        raw,
4288        start,
4289        end,
4290    }
4291}
4292
4293fn unquote_sql_ident(raw: &str) -> String {
4294    if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
4295        raw[1..raw.len() - 1].replace("\"\"", "\"")
4296    } else {
4297        raw.to_string()
4298    }
4299}
4300
4301fn apply_sql_replacements(sql: &str, replacements: &[SqlReplacement]) -> String {
4302    if replacements.is_empty() {
4303        return sql.to_string();
4304    }
4305    let mut ordered = replacements.to_vec();
4306    ordered.sort_by_key(|replacement| replacement.start);
4307    let mut out = String::with_capacity(sql.len());
4308    let mut cursor = 0;
4309    for replacement in ordered {
4310        if replacement.start < cursor || replacement.end > sql.len() {
4311            continue;
4312        }
4313        out.push_str(&sql[cursor..replacement.start]);
4314        out.push_str(&replacement.replacement);
4315        cursor = replacement.end;
4316    }
4317    out.push_str(&sql[cursor..]);
4318    out
4319}
4320
4321fn rewrite_compat_function_calls(sql: &str) -> String {
4322    let bytes = sql.as_bytes();
4323    let mut out = String::with_capacity(sql.len());
4324    let mut i = 0;
4325    while i < bytes.len() {
4326        match bytes[i] {
4327            b'\'' => i = copy_quoted_to_string(&mut out, bytes, i, b'\''),
4328            b'"' => i = copy_quoted_to_string(&mut out, bytes, i, b'"'),
4329            b'E' | b'e' if i + 1 < bytes.len() && bytes[i + 1] == b'\'' => {
4330                out.push(bytes[i] as char);
4331                i += 1;
4332                i = copy_quoted_to_string(&mut out, bytes, i, b'\'');
4333            }
4334            b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
4335                out.push('-');
4336                out.push('-');
4337                i += 2;
4338                while i < bytes.len() {
4339                    let ch = bytes[i] as char;
4340                    out.push(ch);
4341                    i += 1;
4342                    if ch == '\n' {
4343                        break;
4344                    }
4345                }
4346            }
4347            b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4348                let start = i;
4349                i = skip_block_comment(bytes, i);
4350                out.push_str(&sql[start..i.min(bytes.len())]);
4351            }
4352            b'$' => {
4353                let start_len = out.len();
4354                let (next, matched) = copy_dollar_quoted_to_string(&mut out, bytes, i);
4355                if matched {
4356                    i = next;
4357                } else {
4358                    out.truncate(start_len);
4359                    out.push('$');
4360                    i += 1;
4361                }
4362            }
4363            b'g' | b'G' | b'm' | b'M' | b't' | b'T' => {
4364                if let Some((replacement, next)) = compat_function_rewrite_at(sql, i) {
4365                    out.push_str(&replacement);
4366                    i = next;
4367                } else {
4368                    out.push(bytes[i] as char);
4369                    i += 1;
4370                }
4371            }
4372            _ => {
4373                out.push(bytes[i] as char);
4374                i += 1;
4375            }
4376        }
4377    }
4378    out
4379}
4380
4381fn compat_function_rewrite_at(sql: &str, start: usize) -> Option<(String, usize)> {
4382    let bytes = sql.as_bytes();
4383    let (name, kind) = if ident_eq_at(bytes, start, b"max") {
4384        ("max", CompatRewriteKind::ScalarMax)
4385    } else if ident_eq_at(bytes, start, b"min") {
4386        ("min", CompatRewriteKind::ScalarMin)
4387    } else if ident_eq_at(bytes, start, b"group_concat") {
4388        ("group_concat", CompatRewriteKind::GroupConcat)
4389    } else if ident_eq_at(bytes, start, b"total") {
4390        ("total", CompatRewriteKind::Total)
4391    } else {
4392        return None;
4393    };
4394    let before_ok = start == 0 || !is_sql_ident_byte(bytes[start - 1]);
4395    let after_name = start + name.len();
4396    let after_ok = bytes
4397        .get(after_name)
4398        .is_some_and(|b| !is_sql_ident_byte(*b));
4399    if !before_ok || !after_ok {
4400        return None;
4401    }
4402    let mut open = after_name;
4403    while open < bytes.len() && bytes[open].is_ascii_whitespace() {
4404        open += 1;
4405    }
4406    if bytes.get(open) != Some(&b'(') {
4407        return None;
4408    }
4409    let summary = call_arg_summary(sql, open)?;
4410    match kind {
4411        CompatRewriteKind::ScalarMax if summary.top_level_commas > 0 => {
4412            Some(("__mongreldb_scalar_max(".to_string(), open + 1))
4413        }
4414        CompatRewriteKind::ScalarMin if summary.top_level_commas > 0 => {
4415            Some(("__mongreldb_scalar_min(".to_string(), open + 1))
4416        }
4417        CompatRewriteKind::GroupConcat => {
4418            let args = &sql[open + 1..summary.close];
4419            let rewritten = if summary.top_level_commas == 0 {
4420                format!("string_agg({args}, ',')")
4421            } else {
4422                format!("string_agg({args})")
4423            };
4424            Some((rewritten, summary.close + 1))
4425        }
4426        CompatRewriteKind::Total if summary.top_level_commas == 0 => {
4427            let args = &sql[open + 1..summary.close];
4428            let suffix_end = aggregate_suffix_end(sql, summary.close + 1);
4429            let suffix = &sql[summary.close + 1..suffix_end];
4430            Some((
4431                format!("coalesce(cast(sum({args}){suffix} as double), 0.0)"),
4432                suffix_end,
4433            ))
4434        }
4435        _ => None,
4436    }
4437}
4438
4439#[derive(Clone, Copy)]
4440enum CompatRewriteKind {
4441    ScalarMax,
4442    ScalarMin,
4443    GroupConcat,
4444    Total,
4445}
4446
4447fn ident_eq_at(bytes: &[u8], start: usize, ident: &[u8]) -> bool {
4448    bytes
4449        .get(start..start + ident.len())
4450        .is_some_and(|slice| slice.eq_ignore_ascii_case(ident))
4451}
4452
4453fn is_sql_ident_byte(b: u8) -> bool {
4454    b.is_ascii_alphanumeric() || b == b'_' || b == b'$'
4455}
4456
4457fn keyword_at(bytes: &[u8], start: usize, keyword: &[u8]) -> bool {
4458    if !ident_eq_at(bytes, start, keyword) {
4459        return false;
4460    }
4461    let before_ok = start == 0 || !is_sql_ident_byte(bytes[start - 1]);
4462    let after = start + keyword.len();
4463    let after_ok = after >= bytes.len() || !is_sql_ident_byte(bytes[after]);
4464    before_ok && after_ok
4465}
4466
4467fn skip_sql_whitespace(bytes: &[u8], mut i: usize) -> usize {
4468    while i < bytes.len() && bytes[i].is_ascii_whitespace() {
4469        i += 1;
4470    }
4471    i
4472}
4473
4474fn aggregate_suffix_end(sql: &str, start: usize) -> usize {
4475    let bytes = sql.as_bytes();
4476    let mut suffix_end = start;
4477    let mut i = skip_sql_whitespace(bytes, start);
4478
4479    if keyword_at(bytes, i, b"filter") {
4480        let open = skip_sql_whitespace(bytes, i + b"filter".len());
4481        if bytes.get(open) != Some(&b'(') {
4482            return start;
4483        }
4484        let Some(summary) = call_arg_summary(sql, open) else {
4485            return start;
4486        };
4487        suffix_end = summary.close + 1;
4488        i = skip_sql_whitespace(bytes, suffix_end);
4489    }
4490
4491    if keyword_at(bytes, i, b"over") {
4492        let after_over = skip_sql_whitespace(bytes, i + b"over".len());
4493        if bytes.get(after_over) == Some(&b'(') {
4494            let Some(summary) = call_arg_summary(sql, after_over) else {
4495                return suffix_end;
4496            };
4497            suffix_end = summary.close + 1;
4498        } else {
4499            let mut end = after_over;
4500            while end < bytes.len() && is_sql_ident_byte(bytes[end]) {
4501                end += 1;
4502            }
4503            if end > after_over {
4504                suffix_end = end;
4505            }
4506        }
4507    }
4508
4509    suffix_end
4510}
4511
4512struct CallArgSummary {
4513    close: usize,
4514    top_level_commas: usize,
4515}
4516
4517fn call_arg_summary(sql: &str, open: usize) -> Option<CallArgSummary> {
4518    let bytes = sql.as_bytes();
4519    let mut depth = 1;
4520    let mut i = open + 1;
4521    let mut top_level_commas = 0;
4522    while i < bytes.len() {
4523        match bytes[i] {
4524            b'\'' => i = skip_quoted(bytes, i, b'\''),
4525            b'"' => i = skip_quoted(bytes, i, b'"'),
4526            b'E' | b'e' if i + 1 < bytes.len() && bytes[i + 1] == b'\'' => {
4527                i = skip_quoted(bytes, i + 1, b'\'')
4528            }
4529            b'$' => {
4530                let (next, matched) = skip_dollar_quoted(bytes, i);
4531                i = if matched { next } else { i + 1 };
4532            }
4533            b'-' if i + 1 < bytes.len() && bytes[i + 1] == b'-' => {
4534                i += 2;
4535                while i < bytes.len() && bytes[i] != b'\n' {
4536                    i += 1;
4537                }
4538            }
4539            b'/' if i + 1 < bytes.len() && bytes[i + 1] == b'*' => {
4540                i = skip_block_comment(bytes, i);
4541            }
4542            b'(' => {
4543                depth += 1;
4544                i += 1;
4545            }
4546            b')' => {
4547                depth -= 1;
4548                if depth == 0 {
4549                    return Some(CallArgSummary {
4550                        close: i,
4551                        top_level_commas,
4552                    });
4553                }
4554                i += 1;
4555            }
4556            b',' if depth == 1 => {
4557                top_level_commas += 1;
4558                i += 1;
4559            }
4560            _ => i += 1,
4561        }
4562    }
4563    None
4564}
4565
4566fn copy_quoted_to_string(out: &mut String, bytes: &[u8], start: usize, delim: u8) -> usize {
4567    let end = skip_quoted(bytes, start, delim);
4568    out.push_str(std::str::from_utf8(&bytes[start..end]).unwrap_or_default());
4569    end
4570}
4571
4572fn skip_quoted(bytes: &[u8], start: usize, delim: u8) -> usize {
4573    let mut i = start;
4574    if i < bytes.len() {
4575        i += 1;
4576    }
4577    while i < bytes.len() {
4578        if bytes[i] == delim {
4579            i += 1;
4580            if i < bytes.len() && bytes[i] == delim {
4581                i += 1;
4582                continue;
4583            }
4584            break;
4585        }
4586        i += 1;
4587    }
4588    i
4589}
4590
4591fn copy_dollar_quoted_to_string(out: &mut String, bytes: &[u8], start: usize) -> (usize, bool) {
4592    let (end, matched) = skip_dollar_quoted(bytes, start);
4593    if matched {
4594        out.push_str(std::str::from_utf8(&bytes[start..end]).unwrap_or_default());
4595    }
4596    (end, matched)
4597}
4598
4599fn skip_dollar_quoted(bytes: &[u8], start: usize) -> (usize, bool) {
4600    if bytes.get(start) != Some(&b'$') {
4601        return (start, false);
4602    }
4603    let mut j = start + 1;
4604    while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') {
4605        j += 1;
4606    }
4607    if bytes.get(j) != Some(&b'$') {
4608        return (start, false);
4609    }
4610    let tag = &bytes[start..=j];
4611    let mut i = j + 1;
4612    while i + tag.len() <= bytes.len() {
4613        if &bytes[i..i + tag.len()] == tag {
4614            return (i + tag.len(), true);
4615        }
4616        i += 1;
4617    }
4618    (start, false)
4619}
4620
4621fn skip_block_comment(bytes: &[u8], start: usize) -> usize {
4622    let mut i = start + 2;
4623    let mut depth = 1;
4624    while i + 1 < bytes.len() && depth > 0 {
4625        if bytes[i] == b'/' && bytes[i + 1] == b'*' {
4626            depth += 1;
4627            i += 2;
4628        } else if bytes[i] == b'*' && bytes[i + 1] == b'/' {
4629            depth -= 1;
4630            i += 2;
4631        } else {
4632            i += 1;
4633        }
4634    }
4635    i
4636}
4637
4638/// Stable 64-bit cache key for a SQL string (Phase 8.3 incremental cache).
4639fn sql_cache_key(sql: &str) -> u64 {
4640    use std::hash::{Hash, Hasher};
4641    let mut h = std::collections::hash_map::DefaultHasher::new();
4642    sql.hash(&mut h);
4643    h.finish()
4644}
4645
4646/// Replace the first whole-word `FROM <name>` reference (case-insensitive) in
4647/// `sql` with `FROM (<view_sql>) AS <name>`. Unlike a raw substring search this
4648/// requires a word boundary on both sides, so a view named `log` will **not**
4649/// rewrite `FROM logs` (the prior behavior matched the `from log` prefix and
4650/// left a dangling `s`). Original (non-lowercased) casing is preserved outside
4651/// the rewritten span.
4652fn replace_from_view(sql: &str, name: &str, view_sql: &str) -> String {
4653    let lower = sql.to_ascii_lowercase();
4654    let bytes = lower.as_bytes();
4655    let name_b = name.as_bytes();
4656    let mut i = 0usize;
4657    while let Some(rel) = lower[i..].find("from") {
4658        let from_start = i + rel;
4659        let after_from = from_start + 4;
4660        i = after_from;
4661        // Left boundary: "from" must not be a suffix of a longer identifier.
4662        if from_start > 0 && is_ident_byte(bytes[from_start - 1]) {
4663            continue;
4664        }
4665        // Must be followed by whitespace then the name.
4666        let mut j = after_from;
4667        while j < bytes.len() && bytes[j].is_ascii_whitespace() {
4668            j += 1;
4669        }
4670        if j == after_from || !bytes[j..].starts_with(name_b) {
4671            continue;
4672        }
4673        let after_name = j + name_b.len();
4674        // Right boundary: the name must not be a prefix of a longer identifier.
4675        if after_name < bytes.len() && is_ident_byte(bytes[after_name]) {
4676            continue;
4677        }
4678        // Preserve the original `FROM ` casing/whitespace (sql[from_start..j]),
4679        // then wrap the view body as a subquery aliased back to the view name.
4680        let mut out = String::with_capacity(sql.len() + view_sql.len() + name.len() + 8);
4681        out.push_str(&sql[..from_start]);
4682        out.push_str(&sql[from_start..j]);
4683        out.push('(');
4684        out.push_str(view_sql);
4685        out.push_str(") AS ");
4686        out.push_str(name);
4687        out.push_str(&sql[after_name..]);
4688        return out;
4689    }
4690    sql.to_string()
4691}
4692
4693fn is_ident_byte(b: u8) -> bool {
4694    b.is_ascii_alphanumeric() || b == b'_'
4695}
4696
4697/// Canonicalize a SQL string for caching/parsing: collapse runs of ASCII
4698/// whitespace outside of literals/comments to a single space and trim. String
4699/// literals (`'...'`, with `''` escapes), quoted identifiers (`"..."`), escape
4700/// strings (`E'...'`), line comments (`--`), block comments (`/* */`), and
4701/// dollar-quoting (`$tag$...$tag$`) are passed through verbatim so their
4702/// internal whitespace (which IS semantically significant) is never altered.
4703/// SQL parsing is whitespace-insensitive outside literals, so the normalized
4704/// form parses identically while making `SELECT  *  FROM t`, `SELECT * FROM t`,
4705/// and `\n  SELECT * FROM t  \n` share one cache key.
4706fn normalize_sql(sql: &str) -> String {
4707    let b = sql.as_bytes();
4708    let n = b.len();
4709    let mut out: Vec<u8> = Vec::with_capacity(n);
4710    // Whether a single separating space should precede the next emitted token
4711    // (i.e. we're between tokens, not at the very start of the output).
4712    let mut want_space = false;
4713    let mut i = 0usize;
4714    while i < n {
4715        let c = b[i];
4716        // Whitespace and comments both act only as token separators — they set
4717        // the pending-space flag but never emit a byte themselves, so a run of
4718        // "1  -- c\nFROM" collapses to a single separating space.
4719        if c.is_ascii_whitespace() {
4720            want_space = true;
4721            i += 1;
4722            continue;
4723        }
4724        if c == b'-' && i + 1 < n && b[i + 1] == b'-' {
4725            // Line comment: skip to end of line.
4726            i += 2;
4727            while i < n && b[i] != b'\n' {
4728                i += 1;
4729            }
4730            want_space = !out.is_empty();
4731            continue;
4732        }
4733        if c == b'/' && i + 1 < n && b[i + 1] == b'*' {
4734            // Block comment: skip to the matching close `*/`, honoring nesting
4735            // (Postgres/DataFusion allow `/* /* */ */`).
4736            i += 2;
4737            let mut depth = 1usize;
4738            while i + 1 < n && depth > 0 {
4739                if b[i] == b'/' && b[i + 1] == b'*' {
4740                    depth += 1;
4741                    i += 2;
4742                } else if b[i] == b'*' && b[i + 1] == b'/' {
4743                    depth -= 1;
4744                    i += 2;
4745                } else {
4746                    i += 1;
4747                }
4748            }
4749            want_space = !out.is_empty();
4750            continue;
4751        }
4752        // A real token byte (or a literal/quote opener) — emit the separator.
4753        if want_space && !out.is_empty() {
4754            out.push(b' ');
4755        }
4756        want_space = false;
4757        match c {
4758            // Escape string E'...' (backslash escapes; '' is still an escape).
4759            b'E' | b'e' if i + 1 < n && b[i + 1] == b'\'' => {
4760                out.push(c);
4761                i += 1;
4762                i = copy_quoted(&mut out, b, i, n, b'\'');
4763                continue;
4764            }
4765            // Single-quoted string literal ('...' with '' escape).
4766            b'\'' => {
4767                i = copy_quoted(&mut out, b, i, n, b'\'');
4768                continue;
4769            }
4770            // Double-quoted identifier ("..." with "" escape).
4771            b'"' => {
4772                i = copy_quoted(&mut out, b, i, n, b'"');
4773                continue;
4774            }
4775            // Dollar-quoting: $tag$ ... $tag$ (tag optional/empty).
4776            b'$' => {
4777                let (consumed, matched) = copy_dollar_quoted(&mut out, b, i, n);
4778                if matched {
4779                    i = consumed;
4780                    continue;
4781                }
4782                out.push(c);
4783                i += 1;
4784                continue;
4785            }
4786            _ => {
4787                out.push(c);
4788                i += 1;
4789            }
4790        }
4791    }
4792    String::from_utf8(out).unwrap_or_else(|_| sql.to_string())
4793}
4794
4795/// Copy a quote-delimited span starting at `start` (the opening quote byte is
4796/// `delim`), including the opening and closing delimiters and any doubled
4797/// escapes, verbatim into `out`. Returns the index past the closing quote.
4798fn copy_quoted(out: &mut Vec<u8>, b: &[u8], start: usize, n: usize, delim: u8) -> usize {
4799    out.push(b[start]);
4800    let mut i = start + 1;
4801    while i < n {
4802        let c = b[i];
4803        out.push(c);
4804        if c == delim {
4805            // Doubled delimiter (e.g. '' or "") is an escape, not the end.
4806            if i + 1 < n && b[i + 1] == delim {
4807                out.push(b[i + 1]);
4808                i += 2;
4809                continue;
4810            }
4811            return i + 1;
4812        }
4813        i += 1;
4814    }
4815    i
4816}
4817
4818/// Copy a dollar-quoted span starting at the opening `$`. Returns
4819/// `(index_past_close, true)` if a matching close delimiter was found, or
4820/// `(start + 1, false)` if this `$` does not open a dollar-quote.
4821fn copy_dollar_quoted(out: &mut Vec<u8>, b: &[u8], start: usize, n: usize) -> (usize, bool) {
4822    // Parse the opening delimiter: '$' [tag] '$'. An empty tag ($$..$$) is
4823    // allowed; a non-empty tag must be identifier bytes starting with a
4824    // letter/underscore.
4825    let mut j = start + 1;
4826    let tag_start = j;
4827    while j < n && b[j] != b'$' && is_dollar_tag_byte(b[j]) {
4828        j += 1;
4829    }
4830    if j >= n || b[j] != b'$' {
4831        return (start + 1, false);
4832    }
4833    if tag_start < j && !(b[tag_start].is_ascii_alphabetic() || b[tag_start] == b'_') {
4834        return (start + 1, false);
4835    }
4836    let close_end = j + 1; // index just past the opening '$'
4837    let delim = &b[start..close_end];
4838    // Copy the opening delimiter verbatim.
4839    out.extend_from_slice(delim);
4840    // Find the matching close delimiter.
4841    let mut k = close_end;
4842    while k + delim.len() <= n {
4843        if &b[k..k + delim.len()] == delim {
4844            out.extend_from_slice(delim);
4845            return (k + delim.len(), true);
4846        }
4847        out.push(b[k]);
4848        k += 1;
4849    }
4850    // Unterminated: copy the remainder verbatim (don't corrupt).
4851    out.extend_from_slice(&b[close_end..n]);
4852    (n, true)
4853}
4854
4855fn is_dollar_tag_byte(b: u8) -> bool {
4856    b.is_ascii_alphanumeric() || b == b'_'
4857}
4858
4859/// Strip an ASCII case-insensitive prefix from `s`, returning the remainder.
4860fn strip_prefix_ci<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
4861    let bytes = s.as_bytes();
4862    let pb = prefix.as_bytes();
4863    if bytes.len() >= pb.len() && bytes[..pb.len()].eq_ignore_ascii_case(pb) {
4864        Some(&s[pb.len()..])
4865    } else {
4866        None
4867    }
4868}
4869
4870/// Recognized column constraints in `CREATE TABLE` column definitions. Each
4871/// entry maps a SQL phrase (matched case-insensitively as a substring of the
4872/// whitespace-normalized constraint clause) to the [`ColumnFlags`] bit it sets.
4873///
4874/// Multi-word phrases such as `"primary key"` match regardless of internal
4875/// spacing because the clause is normalized to single spaces before matching.
4876///
4877/// **Adding a new column constraint is a one-line change:** append `(phrase,
4878/// flag)` here. This keeps the DDL shim's grammar in one place rather than
4879/// scattering `contains(...)` checks across the parser. (A full SQL grammar is
4880/// deliberately out of scope — only the DDL shapes handled below are
4881/// intercepted here; all query parsing is delegated to DataFusion.)
4882const COLUMN_CONSTRAINTS: &[(&str, u32)] = &[
4883    ("primary key", ColumnFlags::PRIMARY_KEY),
4884    // Both spellings are accepted: `AUTOINCREMENT` (SQLite) and `AUTO_INCREMENT`
4885    // (MySQL). The engine enforces that the flag is valid only on a single
4886    // non-nullable `Int64` primary key (see `Schema::validate_auto_increment`),
4887    // so recognizing the keyword on any column here is safe — invalid
4888    // placements are rejected at table-creation time, before the schema is
4889    // durably logged.
4890    ("autoincrement", ColumnFlags::AUTO_INCREMENT),
4891    ("auto_increment", ColumnFlags::AUTO_INCREMENT),
4892];
4893
4894/// Translate a column's constraint clause (the text following `<name> <type>`
4895/// in a `CREATE TABLE` column definition) into [`ColumnFlags`]. The clause is
4896/// lowercased and its internal whitespace collapsed to single spaces so
4897/// multi-word phrases match regardless of formatting. See
4898/// [`COLUMN_CONSTRAINTS`] for the recognized phrases; add new ones there.
4899fn parse_column_constraints(constraint_text: &str) -> ColumnFlags {
4900    let normalized = constraint_text.to_lowercase();
4901    let mut flags = ColumnFlags::empty();
4902    for (phrase, bit) in COLUMN_CONSTRAINTS {
4903        if normalized.contains(phrase) {
4904            flags = flags.with(*bit);
4905        }
4906    }
4907    flags
4908}
4909
4910fn parse_sql_type(ty_str: &str) -> Result<mongreldb_core::schema::TypeId> {
4911    use mongreldb_core::schema::TypeId;
4912
4913    match ty_str.trim().trim_end_matches(';').to_lowercase().as_str() {
4914        "bigint" | "int8" | "int64" | "integer" | "int" => Ok(TypeId::Int64),
4915        "double" | "float8" | "float64" | "real" | "float" => Ok(TypeId::Float64),
4916        "varchar" | "text" | "string" | "bytes" => Ok(TypeId::Bytes),
4917        "boolean" | "bool" => Ok(TypeId::Bool),
4918        other => Err(MongrelQueryError::Schema(format!(
4919            "unsupported column type: {other}"
4920        ))),
4921    }
4922}
4923
4924/// Parse `CREATE TABLE [IF NOT EXISTS] <name> (<col> <type> <constraints>, ...)`
4925/// into a MongrelDB table name + schema. Supports BIGINT/INTEGER/INT, DOUBLE,
4926/// VARCHAR/TEXT, BOOLEAN. Recognized column constraints (`PRIMARY KEY`,
4927/// `AUTOINCREMENT` / `AUTO_INCREMENT`) are listed in [`COLUMN_CONSTRAINTS`].
4928/// Table name may be double-quoted.
4929fn parse_create_table(sql: &str) -> Result<(String, mongreldb_core::schema::Schema)> {
4930    use mongreldb_core::schema::*;
4931
4932    let open = sql
4933        .find('(')
4934        .ok_or(MongrelQueryError::Schema("CREATE TABLE missing '('".into()))?;
4935    let close = sql
4936        .rfind(')')
4937        .ok_or(MongrelQueryError::Schema("CREATE TABLE missing ')'".into()))?;
4938    let head = sql[..open].trim();
4939    let after_kw = strip_prefix_ci(head, "CREATE TABLE")
4940        .or_else(|| strip_prefix_ci(head, "create table"))
4941        .unwrap_or("")
4942        .trim();
4943    // Skip optional `IF NOT EXISTS`.
4944    let after_kw = after_kw
4945        .strip_prefix("IF NOT EXISTS")
4946        .or_else(|| after_kw.strip_prefix("if not exists"))
4947        .map(str::trim)
4948        .unwrap_or(after_kw);
4949    let name = after_kw.trim_matches('"').to_string();
4950    if name.is_empty() {
4951        return Err(MongrelQueryError::Schema(
4952            "CREATE TABLE missing table name".into(),
4953        ));
4954    }
4955
4956    let body = &sql[open + 1..close];
4957    let mut columns = Vec::new();
4958    let schema_id: u64 = 0; // Database::create_table overrides with the table_id.
4959    for (i, raw) in body.split(',').enumerate() {
4960        let part = raw.trim();
4961        if part.is_empty() {
4962            continue;
4963        }
4964        let mut tokens = part.split_whitespace();
4965        let col_name = tokens
4966            .next()
4967            .ok_or(MongrelQueryError::Schema("missing column name".into()))?
4968            .trim_matches('"');
4969        let ty_str = tokens
4970            .next()
4971            .ok_or(MongrelQueryError::Schema("missing column type".into()))?
4972            .to_lowercase();
4973        let ty = parse_sql_type(&ty_str)?;
4974        // Everything after `<name> <type>` is the column's constraint clause
4975        // (e.g. `PRIMARY KEY`, `PRIMARY KEY AUTOINCREMENT`). The remaining
4976        // tokens are matched against `COLUMN_CONSTRAINTS`.
4977        let constraint_clause: String = tokens.collect::<Vec<_>>().join(" ");
4978        let flags = parse_column_constraints(&constraint_clause);
4979        columns.push(ColumnDef {
4980            id: (i + 1) as u16,
4981            name: col_name.to_string(),
4982            ty,
4983            flags,
4984            default_value: None,
4985        });
4986    }
4987
4988    Ok((
4989        name,
4990        Schema {
4991            schema_id,
4992            columns,
4993            indexes: vec![],
4994            colocation: vec![],
4995            constraints: Default::default(),
4996            clustered: false,
4997        },
4998    ))
4999}
5000
5001/// Parse `DROP TABLE [IF EXISTS] <name>`. Returns `(name, if_exists)`.
5002fn parse_drop_table(sql: &str) -> Result<(String, bool)> {
5003    let head = sql.trim();
5004    let after_kw = strip_prefix_ci(head, "DROP TABLE")
5005        .or_else(|| strip_prefix_ci(head, "drop table"))
5006        .unwrap_or("")
5007        .trim();
5008    // Detect optional `IF EXISTS`.
5009    let (rest, if_exists) = if let Some(r) = after_kw
5010        .strip_prefix("IF EXISTS")
5011        .or_else(|| after_kw.strip_prefix("if exists"))
5012        .map(str::trim)
5013    {
5014        (r, true)
5015    } else {
5016        (after_kw, false)
5017    };
5018    let name = rest.trim_matches(';').trim_matches('"').trim();
5019    if name.is_empty() {
5020        return Err(MongrelQueryError::Schema(
5021            "DROP TABLE missing table name".into(),
5022        ));
5023    }
5024    Ok((name.to_string(), if_exists))
5025}
5026
5027enum ParsedAlterTable {
5028    RenameTable {
5029        old_name: String,
5030        new_name: String,
5031    },
5032    RenameColumn {
5033        table_name: String,
5034        column_name: String,
5035        new_name: String,
5036    },
5037    AlterColumnType {
5038        table_name: String,
5039        column_name: String,
5040        ty: mongreldb_core::schema::TypeId,
5041    },
5042    SetNotNull {
5043        table_name: String,
5044        column_name: String,
5045    },
5046    DropNotNull {
5047        table_name: String,
5048        column_name: String,
5049    },
5050}
5051
5052fn current_column_flags(db: &Arc<Database>, table: &str, column: &str) -> Result<ColumnFlags> {
5053    let handle = db.table(table)?;
5054    let table = handle.lock();
5055    table
5056        .schema()
5057        .column(column)
5058        .map(|c| c.flags)
5059        .ok_or_else(|| MongrelQueryError::Schema(format!("unknown column {column}")))
5060}
5061
5062fn parse_alter_table(sql: &str) -> Result<ParsedAlterTable> {
5063    let trimmed = strip_statement_semicolon(sql.trim());
5064    let after_kw = strip_prefix_ci(trimmed, "ALTER TABLE")
5065        .ok_or_else(|| MongrelQueryError::Schema("not an ALTER TABLE statement".into()))?
5066        .trim();
5067    let (table_name, rest) = take_sql_ident(after_kw, "ALTER TABLE missing table name")?;
5068    let rest = rest.trim();
5069
5070    if let Some(after) = strip_prefix_ci(rest, "RENAME TO") {
5071        let new_name = parse_trailing_identifier(after, "ALTER TABLE missing new table name")?;
5072        return Ok(ParsedAlterTable::RenameTable {
5073            old_name: table_name,
5074            new_name,
5075        });
5076    }
5077
5078    if let Some(after) = strip_prefix_ci(rest, "RENAME COLUMN") {
5079        let (column_name, after_col) =
5080            take_sql_ident(after, "ALTER TABLE RENAME COLUMN missing column name")?;
5081        let after_to = strip_prefix_ci(after_col.trim(), "TO").ok_or_else(|| {
5082            MongrelQueryError::Schema("ALTER TABLE RENAME COLUMN missing TO".into())
5083        })?;
5084        let new_name = parse_trailing_identifier(
5085            after_to,
5086            "ALTER TABLE RENAME COLUMN missing new column name",
5087        )?;
5088        return Ok(ParsedAlterTable::RenameColumn {
5089            table_name,
5090            column_name,
5091            new_name,
5092        });
5093    }
5094
5095    let after_alter = strip_prefix_ci(rest, "ALTER COLUMN")
5096        .or_else(|| strip_prefix_ci(rest, "ALTER"))
5097        .ok_or_else(|| {
5098            MongrelQueryError::Schema(
5099                "ALTER TABLE must be RENAME TO, RENAME COLUMN, or ALTER COLUMN".into(),
5100            )
5101        })?;
5102    let (column_name, action) =
5103        take_sql_ident(after_alter, "ALTER TABLE ALTER COLUMN missing column name")?;
5104    let action = action.trim();
5105
5106    if let Some(after_type) =
5107        strip_prefix_ci(action, "TYPE").or_else(|| strip_prefix_ci(action, "SET DATA TYPE"))
5108    {
5109        let ty = parse_type_tail(after_type)?;
5110        return Ok(ParsedAlterTable::AlterColumnType {
5111            table_name,
5112            column_name,
5113            ty,
5114        });
5115    }
5116    if strip_prefix_ci(action, "SET NOT NULL").is_some() {
5117        return Ok(ParsedAlterTable::SetNotNull {
5118            table_name,
5119            column_name,
5120        });
5121    }
5122    if strip_prefix_ci(action, "DROP NOT NULL").is_some() {
5123        return Ok(ParsedAlterTable::DropNotNull {
5124            table_name,
5125            column_name,
5126        });
5127    }
5128
5129    Err(MongrelQueryError::Schema(
5130        "unsupported ALTER COLUMN action".into(),
5131    ))
5132}
5133
5134fn strip_statement_semicolon(s: &str) -> &str {
5135    s.trim().trim_end_matches(';').trim()
5136}
5137
5138fn take_sql_ident<'a>(s: &'a str, missing: &str) -> Result<(String, &'a str)> {
5139    let s = s.trim();
5140    if s.is_empty() {
5141        return Err(MongrelQueryError::Schema(missing.into()));
5142    }
5143    if let Some(rest) = s.strip_prefix('"') {
5144        let Some(end) = rest.find('"') else {
5145            return Err(MongrelQueryError::Schema(
5146                "unterminated quoted identifier".into(),
5147            ));
5148        };
5149        let ident = rest[..end].to_string();
5150        if ident.is_empty() {
5151            return Err(MongrelQueryError::Schema(missing.into()));
5152        }
5153        return Ok((ident, &rest[end + 1..]));
5154    }
5155    let end = s.find(|c: char| c.is_ascii_whitespace()).unwrap_or(s.len());
5156    let ident = s[..end].trim_matches('"').to_string();
5157    if ident.is_empty() {
5158        return Err(MongrelQueryError::Schema(missing.into()));
5159    }
5160    Ok((ident, &s[end..]))
5161}
5162
5163fn parse_trailing_identifier(s: &str, missing: &str) -> Result<String> {
5164    let (ident, rest) = take_sql_ident(s, missing)?;
5165    if !strip_statement_semicolon(rest).is_empty() {
5166        return Err(MongrelQueryError::Schema(
5167            "unexpected tokens after identifier".into(),
5168        ));
5169    }
5170    Ok(ident)
5171}
5172
5173fn parse_type_tail(s: &str) -> Result<mongreldb_core::schema::TypeId> {
5174    let tail = strip_statement_semicolon(s);
5175    let ty = tail
5176        .split_whitespace()
5177        .next()
5178        .ok_or_else(|| MongrelQueryError::Schema("ALTER COLUMN TYPE missing type".into()))?;
5179    parse_sql_type(ty)
5180}
5181
5182#[cfg(test)]
5183mod tests {
5184    use super::*;
5185
5186    #[test]
5187    fn bounded_lru_evicts_least_recently_used() {
5188        let mut cache = BoundedLru::new(2);
5189        cache.insert("a", 1);
5190        cache.insert("b", 2);
5191        assert_eq!(cache.get("a"), Some(&1));
5192        cache.insert("c", 3);
5193        assert_eq!(cache.entries.len(), 2);
5194        assert_eq!(cache.get("b"), None);
5195        assert_eq!(cache.get("a"), Some(&1));
5196        assert_eq!(cache.get("c"), Some(&3));
5197    }
5198
5199    #[tokio::test]
5200    async fn streaming_query_bypasses_result_cache() {
5201        use futures::StreamExt;
5202
5203        let dir = tempfile::tempdir().unwrap();
5204        let database = Arc::new(Database::create(dir.path()).unwrap());
5205        let session = MongrelSession::open(database).unwrap();
5206        let mut stream = session.run_stream("SELECT 1 AS value").await.unwrap();
5207        let batch = stream.next().await.unwrap().unwrap();
5208        assert_eq!(batch.num_rows(), 1);
5209        assert!(stream.next().await.is_none());
5210        assert!(session.cache.lock().entries.is_empty());
5211    }
5212
5213    #[tokio::test]
5214    async fn ttl_tables_bypass_epoch_keyed_result_cache() {
5215        let dir = tempfile::tempdir().unwrap();
5216        let database = Arc::new(Database::create(dir.path()).unwrap());
5217        let session = MongrelSession::open(database).unwrap();
5218        session
5219            .run(
5220                "CREATE TABLE events (id BIGINT PRIMARY KEY, ts TIMESTAMP) \
5221                 TTL_COLUMN ts TTL '1 day'",
5222            )
5223            .await
5224            .unwrap();
5225        session.run("SELECT * FROM events").await.unwrap();
5226        assert!(session.cache.lock().entries.is_empty());
5227    }
5228
5229    #[test]
5230    fn normalize_collapses_and_trims_whitespace() {
5231        assert_eq!(normalize_sql("SELECT * FROM t"), "SELECT * FROM t");
5232        assert_eq!(normalize_sql("  SELECT  *   FROM   t  "), "SELECT * FROM t");
5233        assert_eq!(
5234            normalize_sql("\n\tSELECT\n*\nFROM\n\tt\n"),
5235            "SELECT * FROM t"
5236        );
5237        assert_eq!(
5238            normalize_sql("SELECT   a,   b   FROM   t"),
5239            normalize_sql("SELECT a, b FROM t")
5240        );
5241    }
5242
5243    #[test]
5244    fn boolean_ai_predicate_detection_uses_sql_tokens() {
5245        assert!(contains_boolean_ai_predicate(
5246            "SELECT * FROM docs WHERE ANN_SEARCH (embedding, '[1]', 1)"
5247        ));
5248        assert!(contains_boolean_ai_predicate(
5249            "PREPARE q AS SELECT * FROM docs WHERE sparse_match(sparse, '[]', 1)"
5250        ));
5251        assert!(!contains_boolean_ai_predicate(
5252            "SELECT 'ann_search(x)', sparse_search_scored('docs', 'sparse', '[]', 1) /* ann_search(x) */"
5253        ));
5254    }
5255
5256    #[test]
5257    fn normalize_preserves_string_literal_whitespace() {
5258        assert_eq!(
5259            normalize_sql("SELECT 'hello   world' FROM t"),
5260            "SELECT 'hello   world' FROM t"
5261        );
5262        assert_eq!(
5263            normalize_sql("SELECT 'it''s   ok' FROM t"),
5264            "SELECT 'it''s   ok' FROM t"
5265        );
5266        assert_eq!(
5267            normalize_sql("  SELECT  'a  b'  FROM  t  "),
5268            "SELECT 'a  b' FROM t"
5269        );
5270    }
5271
5272    #[test]
5273    fn normalize_preserves_quoted_identifier_and_dollar_quote() {
5274        assert_eq!(
5275            normalize_sql("  SELECT  \"my col\"  FROM  t  "),
5276            "SELECT \"my col\" FROM t"
5277        );
5278        assert_eq!(
5279            normalize_sql("  SELECT  $$a   b$$  FROM  t  "),
5280            "SELECT $$a   b$$ FROM t"
5281        );
5282        assert_eq!(
5283            normalize_sql("SELECT $tag$body   with spaces$tag$ FROM t"),
5284            "SELECT $tag$body   with spaces$tag$ FROM t"
5285        );
5286    }
5287
5288    #[test]
5289    fn normalize_strips_comments() {
5290        assert_eq!(
5291            normalize_sql("SELECT 1 -- trailing comment\nFROM t"),
5292            "SELECT 1 FROM t"
5293        );
5294        assert_eq!(
5295            normalize_sql("SELECT /* block */ 1 FROM t"),
5296            "SELECT 1 FROM t"
5297        );
5298        // Comment with a quote-like body must not confuse the scanner.
5299        assert_eq!(
5300            normalize_sql("SELECT /* 'not a string' */ 1 FROM t"),
5301            "SELECT 1 FROM t"
5302        );
5303        // Nested block comments are honored (Postgres/DataFusion allow nesting).
5304        assert_eq!(
5305            normalize_sql("SELECT /* outer /* inner */ still outer */ 1 FROM t"),
5306            "SELECT 1 FROM t"
5307        );
5308    }
5309
5310    #[test]
5311    fn normalize_escape_string_preserved() {
5312        assert_eq!(
5313            normalize_sql("SELECT E'line\\nbreak' FROM t"),
5314            "SELECT E'line\\nbreak' FROM t"
5315        );
5316    }
5317
5318    #[test]
5319    fn replace_from_view_matches_whole_word_only() {
5320        let out = replace_from_view("SELECT * FROM logs", "log", "SELECT 1");
5321        assert_eq!(out, "SELECT * FROM logs");
5322
5323        let out = replace_from_view("SELECT * FROM log", "log", "SELECT 1");
5324        assert_eq!(out, "SELECT * FROM (SELECT 1) AS log");
5325
5326        let out = replace_from_view("select * from log where x", "log", "SELECT 1");
5327        assert_eq!(out, "select * from (SELECT 1) AS log where x");
5328
5329        let out = replace_from_view("SELECT * FROM log)", "log", "SELECT 1");
5330        assert_eq!(out, "SELECT * FROM (SELECT 1) AS log)");
5331
5332        let out = replace_from_view("SELECT * xfrom log", "log", "SELECT 1");
5333        assert_eq!(out, "SELECT * xfrom log");
5334    }
5335
5336    #[test]
5337    fn compat_function_rewrite_handles_sqlite_compatibility_calls() {
5338        assert_eq!(
5339            rewrite_compat_function_calls("select max(id), min(id) from t"),
5340            "select max(id), min(id) from t"
5341        );
5342        assert_eq!(
5343            rewrite_compat_function_calls("select max(1, min(2, 3), 'max(4,5)')"),
5344            "select __mongreldb_scalar_max(1, __mongreldb_scalar_min(2, 3), 'max(4,5)')"
5345        );
5346        assert_eq!(
5347            rewrite_compat_function_calls("select /* max(1,2) */ min(1, (2 + 3))"),
5348            "select /* max(1,2) */ __mongreldb_scalar_min(1, (2 + 3))"
5349        );
5350        assert_eq!(
5351            rewrite_compat_function_calls("select max_value, min_value from t"),
5352            "select max_value, min_value from t"
5353        );
5354        assert_eq!(
5355            rewrite_compat_function_calls(
5356                "select group_concat(label), group_concat(label, '|') from t"
5357            ),
5358            "select string_agg(label, ','), string_agg(label, '|') from t"
5359        );
5360        assert_eq!(
5361            rewrite_compat_function_calls("select total(val), total(val) filter (where grp = 2) from t"),
5362            "select coalesce(cast(sum(val) as double), 0.0), coalesce(cast(sum(val) filter (where grp = 2) as double), 0.0) from t"
5363        );
5364        assert_eq!(
5365            rewrite_compat_function_calls(
5366                "select total(val) over (partition by grp order by id) from t"
5367            ),
5368            "select coalesce(cast(sum(val) over (partition by grp order by id) as double), 0.0) from t"
5369        );
5370    }
5371}