Skip to main content

krishiv_sql/
lib.rs

1#![forbid(unsafe_code)]
2
3//! SQL planning and local execution seam for Krishiv.
4//!
5//! This crate owns the DataFusion integration for R1 while keeping DataFusion
6//! out of the long-term public API exposed by `krishiv-api`.
7
8use std::collections::{BTreeSet, HashMap, VecDeque};
9use std::fmt;
10use std::num::NonZeroUsize;
11use std::ops::ControlFlow;
12use std::path::Path;
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::{Arc, Mutex, RwLock};
15
16use arrow::datatypes::SchemaRef;
17use arrow::record_batch::RecordBatch;
18use arrow::util::pretty::pretty_format_batches;
19use catalog::{InMemoryCatalog, datafusion_bridge::DataFusionCatalogBridge};
20use datafusion::dataframe::DataFrame as DataFusionDataFrame;
21use datafusion::prelude::{ParquetReadOptions, SessionContext};
22use datafusion::sql::sqlparser::{ast::visit_relations, dialect::GenericDialect, parser::Parser};
23use object_store::aws::AmazonS3Builder;
24
25use krishiv_plan::optimizer::{CostModel, Optimizer};
26use krishiv_plan::{ExecutionKind, LogicalPlan, PlanNode};
27
28/// Build an `object_store` S3 client for `bucket` from the ambient AWS
29/// environment (`AWS_ENDPOINT_URL` for MinIO, credentials, region).
30///
31/// Shared by the Iceberg FileIO [`catalog::object_store_io::KrishivStorage`]
32/// (metadata reads/writes) *and* DataFusion's object-store registry (Parquet
33/// data scans via `ListingTable`) so both hit the *same* S3/MinIO backend.
34/// Reading `AWS_ENDPOINT_URL` here — the AWS-SDK convention prod sets — is what
35/// makes MinIO reachable; `AmazonS3Builder::from_env` alone honours only
36/// `AWS_ENDPOINT` and would silently target real AWS.
37/// Map an Arrow type name (as shipped in a Python-UDF directive) to a DataType.
38/// Unknown names fall back to Utf8 (the safest catch-all for a scalar result).
39fn python_udf_arrow_type(name: &str) -> arrow::datatypes::DataType {
40    use arrow::datatypes::DataType;
41    match name.trim().to_ascii_lowercase().as_str() {
42        "double" | "float64" | "float" => DataType::Float64,
43        "float32" | "real" => DataType::Float32,
44        "int" | "int64" | "bigint" | "long" => DataType::Int64,
45        "int32" | "integer" => DataType::Int32,
46        "bool" | "boolean" => DataType::Boolean,
47        "utf8" | "string" | "varchar" | "text" => DataType::Utf8,
48        _ => DataType::Utf8,
49    }
50}
51
52pub(crate) fn build_s3_object_store(
53    bucket: &str,
54) -> object_store::Result<std::sync::Arc<dyn object_store::ObjectStore>> {
55    let mut builder = AmazonS3Builder::from_env().with_bucket_name(bucket);
56    let mut has_endpoint = false;
57    if let Ok(endpoint) = std::env::var("AWS_ENDPOINT_URL")
58        && !endpoint.is_empty()
59    {
60        // MinIO / S3-compatible: path-style access over plain HTTP.
61        builder = builder.with_endpoint(endpoint).with_allow_http(true);
62        has_endpoint = true;
63    }
64    let has_key = std::env::var("AWS_ACCESS_KEY_ID")
65        .map(|k| !k.is_empty())
66        .unwrap_or(false);
67    if let Ok(key) = std::env::var("AWS_ACCESS_KEY_ID")
68        && !key.is_empty()
69    {
70        builder = builder.with_access_key_id(key);
71    }
72    if let Ok(secret) = std::env::var("AWS_SECRET_ACCESS_KEY")
73        && !secret.is_empty()
74    {
75        builder = builder.with_secret_access_key(secret);
76    }
77    // A custom endpoint with no credentials means MinIO / an S3-compatible store
78    // reached anonymously — NOT EC2. Skip request signing so the client reads
79    // public objects directly instead of blocking for ~180s trying to fetch
80    // instance credentials from the IMDS endpoint (169.254.169.254), which is
81    // unreachable off-EC2. Real-AWS deployments (no endpoint) keep the default
82    // credential chain, so EC2 instance-role auth is unaffected.
83    if has_endpoint && !has_key {
84        builder = builder.with_skip_signature(true);
85    }
86    let region = std::env::var("AWS_REGION")
87        .or_else(|_| std::env::var("AWS_DEFAULT_REGION"))
88        .unwrap_or_else(|_| "us-east-1".to_string());
89    builder = builder.with_region(region);
90    Ok(std::sync::Arc::new(builder.build()?))
91}
92
93pub mod analyze;
94pub mod catalog;
95pub mod cep_sql;
96
97pub mod connector_table;
98pub mod coop_amplifiers;
99pub mod create_function_ddl;
100pub mod distributed_plan;
101pub mod grace_hash_join;
102pub mod grammar;
103pub mod incremental_view;
104pub mod introspection_sql;
105
106pub mod kafka_table;
107pub mod lakehouse;
108pub mod late_materialize;
109pub mod live_table;
110/// One reading of a join build side's size estimate, shared by the broadcast
111/// override and the spillable-join choice so the two cannot disagree.
112pub(crate) mod join_estimates;
113pub mod object_store_registry;
114pub mod pipeline_ddl;
115pub mod pivot_sql;
116pub mod python_udf;
117pub mod scalar_udf;
118pub mod semi_join_reduction;
119/// Spark SQL extensions: LATERAL VIEW, TABLESAMPLE, TRANSFORM, DESCRIBE EXTENDED, etc.
120pub mod spark_sql_ext;
121pub mod runtime_filter_exec;
122pub mod spillable_join;
123pub mod sqlstate;
124pub mod subquery;
125pub mod unnest_sql;
126pub mod unspillable_headroom;
127
128pub mod coverage;
129mod higher_order_functions;
130mod json_functions;
131mod spark_functions;
132pub mod statement_completion;
133pub mod streaming;
134pub mod streaming_table_ddl;
135pub mod streaming_tvf;
136pub mod streaming_window_plan;
137mod udf;
138mod window_functions;
139
140pub use cep_sql::{
141    MatchRecognizeStatement, execute_streaming_match_recognize, parse_match_recognize,
142};
143pub use lakehouse::{AsOfTableRef, MergeResult, MergeTargetUnsupportedError, preprocess_as_of_sql};
144
145pub use grammar::{
146    FeatureEntry, FeatureStatus, feature_matrix, features_by_status, features_for_category,
147};
148pub use sqlstate::{SqlStateError, sqlstate_for};
149pub use streaming::{ContinuousInputError, ContinuousTableInput};
150
151/// SQL result alias.
152pub type SqlResult<T> = Result<T, SqlError>;
153
154/// Pinned stream of record batches with typed [`SqlError`] items.
155///
156/// Previously this used `String` as the error type, which lost diagnostic
157/// information at the stream boundary. Callers that need a `String` error can
158/// map with `|e| e.to_string()`.
159pub type SqlStream =
160    std::pin::Pin<Box<dyn futures::stream::Stream<Item = Result<RecordBatch, SqlError>> + Send>>;
161
162/// Global counter for unique ephemeral table names, preventing concurrent
163/// MERGE/CEP queries from overwriting each other's result tables.
164static EPHEMERAL_TABLE_COUNTER: AtomicU64 = AtomicU64::new(0);
165
166fn next_ephemeral_name(prefix: &str) -> String {
167    let id = EPHEMERAL_TABLE_COUNTER.fetch_add(1, Ordering::Relaxed);
168    format!("__{prefix}_{id}")
169}
170
171// ── Plan cache (single-lock, race-free) ──────────────────────────────────────
172
173/// Whether the [`SqlEngine`] internal builder should attempt to register the
174/// helper window UDFs (`tumble_start` / `tumble_end` / `hop_start` / `hop_end`).
175#[derive(Debug, Clone, Copy, PartialEq, Eq)]
176enum WindowFnRegistration {
177    /// Call `window_functions::register_window_functions`; propagate any error.
178    Register,
179    /// Skip registration entirely; infallible. Used as a fallback by
180    /// [`SqlEngine::new`] when `Register` fails so the engine is still usable
181    /// for non-window queries.
182    Skip,
183}
184
185/// Bounded query-plan cache keyed by query text.
186///
187/// A single `Mutex<PlanCache>` replaces the previous two-structure approach
188/// (`DashMap` + `Mutex<VecDeque>`) which had a TOCTOU race: two threads could
189/// both see `len() < MAX` and both insert, growing the cache past the limit.
190struct PlanCache {
191    map: HashMap<String, (datafusion::logical_expr::LogicalPlan, std::time::Instant)>,
192    order: VecDeque<String>,
193    max: usize,
194}
195
196/// How long a cached logical plan stays valid.
197///
198/// A cached plan pins the table providers resolved at planning time —
199/// including the exact data-file listing of Iceberg catalog tables. Local
200/// writes clear the cache, but a table replaced by ANOTHER engine (a
201/// coordinator-mode executor landing a pipeline pull, a second daemon) is
202/// invisible here, and without a TTL a repeated query text served stale
203/// results forever (found live 2026-07-19: the jdbc-pull e2e's COUNT read
204/// 3 rows from a snapshot the executor had already replaced with 5).
205/// 30s matches CATALOG_CACHE_TTL — the staleness bound the catalog layer
206/// already accepts.
207const PLAN_CACHE_TTL: std::time::Duration = std::time::Duration::from_secs(30);
208
209impl PlanCache {
210    fn new(max: usize) -> Self {
211        Self {
212            map: HashMap::new(),
213            order: VecDeque::new(),
214            max,
215        }
216    }
217
218    fn get(&self, key: &str) -> Option<&datafusion::logical_expr::LogicalPlan> {
219        self.map
220            .get(key)
221            .filter(|(_, at)| at.elapsed() < PLAN_CACHE_TTL)
222            .map(|(plan, _)| plan)
223    }
224
225    fn insert(&mut self, key: String, plan: datafusion::logical_expr::LogicalPlan) {
226        if self.map.contains_key(&key) {
227            // Remove the stale order entry so a repeated insert doesn't accumulate
228            // duplicate references and corrupt LRU eviction order.
229            self.order.retain(|k| k != &key);
230        } else if self.map.len() >= self.max
231            && let Some(oldest) = self.order.pop_front()
232        {
233            self.map.remove(&oldest);
234        }
235        self.order.push_back(key.clone());
236        self.map.insert(key, (plan, std::time::Instant::now()));
237    }
238
239    fn clear(&mut self) {
240        self.map.clear();
241        self.order.clear();
242    }
243
244    #[cfg(test)]
245    fn is_empty(&self) -> bool {
246        self.map.is_empty()
247    }
248}
249
250/// Typed options for Parquet reads (propagated into DataFusion).
251#[derive(Debug, Clone, Default)]
252pub struct ParquetReaderOptions {
253    /// Maximum number of rows per output batch (None = DataFusion default 8192).
254    pub batch_size: Option<usize>,
255}
256
257/// Typed options for CSV reads (propagated into DataFusion).
258#[derive(Debug, Clone, Default)]
259pub struct CsvReaderOptions {
260    /// Field delimiter character (None = `,`).
261    pub delimiter: Option<char>,
262    /// Whether the first row is a header (None = true).
263    pub has_header: Option<bool>,
264}
265
266/// Typed options for Parquet writes (propagated into the `ArrowWriter`).
267#[derive(Debug, Clone, Default)]
268pub struct ParquetWriterOptions {
269    /// Compression codec: "snappy" | "zstd" | "gzip" | "lz4" | "brotli" | "uncompressed".
270    pub compression: Option<String>,
271    /// Maximum number of rows per row-group (None = `ArrowWriter` default 1 048 576).
272    pub max_row_group_size: Option<usize>,
273}
274
275/// Typed options for CSV writes.
276#[derive(Debug, Clone, Default)]
277pub struct CsvWriterOptions {
278    /// Field delimiter character (None = `,`).
279    pub delimiter: Option<char>,
280    /// Whether to emit a header row (None = true).
281    pub has_header: Option<bool>,
282}
283
284/// SQL-layer errors.
285#[non_exhaustive]
286#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
287pub enum SqlError {
288    /// Query was empty or whitespace only.
289    #[error("SQL query is empty")]
290    EmptyQuery,
291    /// A table name was empty.
292    #[error("table name is empty")]
293    EmptyTableName,
294    /// The requested SQL feature is not available in R1.
295    #[error("unsupported SQL feature: {feature}")]
296    Unsupported { feature: String },
297    /// A table-function declaration or runtime registration was invalid.
298    #[error("invalid table function: {message}")]
299    InvalidTableFunction { message: String },
300    /// DataFusion returned an error.
301    #[error("DataFusion error: {message}")]
302    DataFusion { message: String },
303    /// Krishiv logical-plan optimization failed.
304    #[error(transparent)]
305    Optimizer(#[from] krishiv_plan::optimizer::OptimizerError),
306    /// Access denied by auth or policy check.
307    #[error("access denied: {reason}")]
308    AccessDenied { reason: String },
309    /// A running operation was cancelled by the caller.
310    #[error("operation {operation_id} was cancelled")]
311    OperationCancelled { operation_id: u64 },
312    /// A query exceeded its configured execution timeout.
313    #[error("query timed out after {timeout_ms} ms")]
314    Timeout { timeout_ms: u64 },
315}
316
317impl From<datafusion::error::DataFusionError> for SqlError {
318    fn from(value: datafusion::error::DataFusionError) -> Self {
319        Self::DataFusion {
320            message: value.to_string(),
321        }
322    }
323}
324
325/// SQL planning output.
326#[derive(Debug, Clone, PartialEq, Eq)]
327pub struct SqlPlan {
328    query: String,
329    logical_plan: LogicalPlan,
330}
331
332impl SqlPlan {
333    /// Original query.
334    pub fn query(&self) -> &str {
335        &self.query
336    }
337
338    /// Krishiv logical plan wrapper.
339    pub fn logical_plan(&self) -> &LogicalPlan {
340        &self.logical_plan
341    }
342}
343
344/// Local SQL engine backed by DataFusion.
345///
346/// **Local-only**: All SQL execution is in-process via DataFusion. No distributed SQL
347/// execution path is available in this crate.
348/// This crate is scoped to R1 — DataFusion will be abstracted behind
349/// the `KrishivDataFrameOps` trait in future releases.
350///
351/// Methods like `register_parquet`, `read_delta`, and `read_hudi` treat
352/// path arguments as local filesystem paths. S3/GCS paths require the
353/// object-store connector layer.
354/// Maximum number of query plans stored in the plan cache before random eviction.
355const PLAN_CACHE_MAX_ENTRIES: usize = 256;
356
357fn resolve_plan_cache_max_entries() -> usize {
358    std::env::var("KRISHIV_PLAN_CACHE_MAX_ENTRIES")
359        .ok()
360        .and_then(|v| v.parse().ok())
361        .filter(|&n| n > 0)
362        .unwrap_or(PLAN_CACHE_MAX_ENTRIES)
363}
364const STREAMING_CEP_MAX_ROWS_DEFAULT: usize = 100_000;
365
366/// Resolve the streaming MATCH_RECOGNIZE row cap from a raw env var value.
367/// `None` and unparseable values fall back to the documented default of
368/// 100_000. Zero is rejected because it would mean "scan zero rows".
369pub fn resolve_streaming_match_recognize_limit(raw: Option<&str>) -> usize {
370    raw.and_then(|s| s.parse::<usize>().ok())
371        .filter(|n| *n > 0)
372        .unwrap_or(STREAMING_CEP_MAX_ROWS_DEFAULT)
373}
374
375/// Resolve the streaming MATCH_RECOGNIZE row cap from the
376/// `KRISHIV_MATCH_RECOGNIZE_STREAMING_LIMIT` environment variable.
377pub fn streaming_match_recognize_limit_from_env() -> usize {
378    resolve_streaming_match_recognize_limit(
379        std::env::var("KRISHIV_MATCH_RECOGNIZE_STREAMING_LIMIT")
380            .ok()
381            .as_deref(),
382    )
383}
384
385/// Resolve a per-engine DataFusion memory limit from a raw env var value.
386/// `None`, unparseable, and zero values all mean "no limit" (the engine runs
387/// with DataFusion's default unbounded pool).
388pub fn resolve_query_memory_limit_bytes(raw: Option<&str>) -> Option<usize> {
389    raw.and_then(|s| s.trim().parse::<usize>().ok())
390        .filter(|n| *n > 0)
391}
392
393/// Resolve the default per-engine memory limit from the
394/// `KRISHIV_QUERY_MEMORY_LIMIT_BYTES` environment variable, falling back to
395/// a cgroup-derived default when the variable is unset.
396///
397/// Fallback: 25% of the container's cgroup memory limit (v2 `memory.max`,
398/// v1 `memory.limit_in_bytes`). Several engines can be live in one process
399/// (executor task slots, the Flight SQL host, IVM ticks), so a single
400/// engine's pool must not claim the whole container. Explicit `0` disables
401/// the limit entirely (DataFusion's default unbounded pool); an unlimited
402/// cgroup (no limit / `max`) also yields `None`.
403pub fn query_memory_limit_from_env() -> Option<usize> {
404    match std::env::var("KRISHIV_QUERY_MEMORY_LIMIT_BYTES").ok() {
405        // Set (including "0" and garbage): explicit-config semantics — no
406        // cgroup fallback, unparseable/zero means unlimited.
407        Some(raw) => resolve_query_memory_limit_bytes(Some(&raw)),
408        None => cgroup_memory_limit_bytes()
409            .map(|limit| (limit / 4) as usize)
410            .filter(|&n| n > 0),
411    }
412}
413
414pub use krishiv_common::cgroup_memory_limit_bytes;
415
416/// DataFusion's memory-pool trait, re-exported so crates that only build
417/// engines (the executor) can hold a shared pool without depending on
418/// DataFusion directly.
419pub use datafusion::execution::memory_pool::MemoryPool;
420
421/// The consumer/reservation half of the same pool API.
422///
423/// Re-exported for the same reason as [`MemoryPool`]: the executor's map-side
424/// shuffle-write buffer has to reserve its bytes through the task engine's
425/// pool — a buffer the pool cannot see is a buffer the container limit cannot
426/// govern — and it should not take a DataFusion dependency to do it.
427pub use datafusion::execution::memory_pool::{MemoryConsumer, MemoryReservation};
428
429/// The record-batch stream trait, re-exported for the same reason as
430/// [`MemoryPool`].
431///
432/// A fragment's real output schema is the one its *stream* declares, not the
433/// one its `DataFrame` does — physical planning re-types expressions, and
434/// TPC-H q17 shipped batches of `Decimal128(30, 15)` under a logical schema
435/// that said `Decimal128(15, 2)`. Reading it needs this trait in scope, and
436/// the executor should not take a DataFusion dependency to do that.
437pub use datafusion::physical_plan::RecordBatchStream;
438
439/// The one query memory pool for this process, sized by
440/// [`krishiv_common::ExecutorCapacity`] from the cgroup limit.
441///
442/// `None` when execution memory is unbounded: no cgroup limit (bare metal or
443/// an unconstrained container), or an explicit
444/// `KRISHIV_QUERY_MEMORY_LIMIT_BYTES=0`.
445///
446/// Built once. Every engine that does not carry an explicit per-job limit
447/// draws on it, so the total execution memory of the process is this number
448/// regardless of how many engines exist.
449pub fn process_query_pool() -> Option<&'static Arc<dyn MemoryPool>> {
450    static POOL: std::sync::LazyLock<Option<Arc<dyn MemoryPool>>> =
451        std::sync::LazyLock::new(|| {
452            let capacity = krishiv_common::ExecutorCapacity::detect();
453            let bytes = capacity.query_pool_bytes?;
454            tracing::info!(
455                capacity = %capacity.summary(),
456                "query memory: one shared FairSpillPool for this process"
457            );
458            Some(EngineMemory::shared_pool(
459                usize::try_from(bytes).unwrap_or(usize::MAX),
460            ))
461        });
462    POOL.as_ref()
463}
464
465/// A `FairSpillPool` of `bytes`, wrapped so spillable consumers cannot occupy
466/// the whole of it.
467///
468/// The single constructor for every bounded pool in this crate. Bare
469/// `FairSpillPool` lets N spillable consumers — each politely inside its own
470/// fair share — take the entire budget between them, after which an operator
471/// that *cannot* spill is refused even a few hundred bytes and nothing can
472/// reclaim anything. TPC-H q10 and q11 died that way at SF100 (877 B refused by
473/// a 2.3 GB pool). See [`crate::unspillable_headroom`].
474///
475/// Both `Private` and `Shared` route through here deliberately: the first
476/// version guarded only the shared pool, and every executor task engine is
477/// `Private`, so the protection was absent from the one deployment that needed
478/// it. One constructor is what keeps that from being possible.
479fn guarded_fair_pool(bytes: usize) -> Arc<dyn MemoryPool> {
480    let inner: Arc<dyn MemoryPool> =
481        Arc::new(datafusion::execution::memory_pool::FairSpillPool::new(bytes));
482    Arc::new(crate::unspillable_headroom::UnspillableHeadroomPool::new(
483        inner,
484        bytes,
485        crate::unspillable_headroom::headroom_bytes(bytes),
486    ))
487}
488
489/// One engine's expected share of [`process_query_pool`] when every slot is
490/// busy. Sizes spill reservations only; the pool itself is shared.
491///
492fn process_query_pool_fair_share_bytes() -> usize {
493    krishiv_common::ExecutorCapacity::detect()
494        .min_task_memory_share_bytes()
495        .map_or(usize::MAX, |bytes| {
496            usize::try_from(bytes).unwrap_or(usize::MAX)
497        })
498}
499
500/// Where an engine's DataFusion execution memory comes from.
501///
502/// The distinction that matters is [`Private`](EngineMemory::Private) versus
503/// [`Shared`](EngineMemory::Shared). An executor runs `slots` task fragments
504/// at once; giving each its own pool means the process claims
505/// `slots × pool_size`, which is how a container gets OOM-killed while every
506/// individual pool still reports headroom. One pool shared by every task makes
507/// the executor's total execution memory a single hard number, and
508/// `FairSpillPool` divides it live across whoever is actually running — a task
509/// alone gets all of it, four tasks get a quarter each, and nobody has to
510/// predict the concurrency in advance.
511#[derive(Clone)]
512pub enum EngineMemory {
513    /// DataFusion's default unbounded pool: no accounting, no spill.
514    Unbounded,
515    /// A `FairSpillPool` of this size belonging to this engine alone.
516    /// Correct for one-engine-per-process deployments (embedded, gateway).
517    Private(usize),
518    /// A pool shared with every other engine in this process.
519    ///
520    /// `fair_share_bytes` is what this engine can expect when all slots are
521    /// busy. It sizes spill *reservations* only — the pool itself is the
522    /// shared object and is not bounded by this number.
523    Shared {
524        /// The process-wide pool.
525        pool: Arc<dyn datafusion::execution::memory_pool::MemoryPool>,
526        /// This engine's expected share, for reservation sizing.
527        fair_share_bytes: usize,
528    },
529}
530
531impl EngineMemory {
532    /// A private pool of `bytes`, or [`Unbounded`](EngineMemory::Unbounded)
533    /// when there is no limit.
534    #[must_use]
535    pub fn from_limit(bytes: Option<usize>) -> Self {
536        bytes.map_or(Self::Unbounded, Self::Private)
537    }
538
539    /// Build a pool of `bytes` intended to be shared by several engines.
540    ///
541    /// The caller keeps the returned handle and passes clones of it in
542    /// [`EngineMemory::Shared`], which is what bounds their combined execution
543    /// memory rather than each engine's individually.
544    #[must_use]
545    pub fn shared_pool(bytes: usize) -> Arc<dyn MemoryPool> {
546        guarded_fair_pool(bytes)
547    }
548
549    /// The default memory source for an engine built in this process: a share
550    /// of [`process_query_pool`].
551    ///
552    /// This is what makes the bound hold no matter how many engines a process
553    /// ends up with. A process can host the Flight SQL engine, several
554    /// executor task slots, and IVM tick engines at once; when each built its
555    /// own pool at a fraction of the container, the fractions summed past the
556    /// container and the container was OOM-killed while every pool still
557    /// reported headroom. One shared pool cannot oversubscribe, and
558    /// `FairSpillPool` divides it live — an engine running alone gets all of
559    /// it, so the bound costs nothing when there is no contention.
560    #[must_use]
561    pub fn for_this_process() -> Self {
562        match process_query_pool() {
563            Some(pool) => Self::Shared {
564                pool: Arc::clone(pool),
565                fair_share_bytes: process_query_pool_fair_share_bytes(),
566            },
567            None => Self::Unbounded,
568        }
569    }
570
571    /// The byte figure that should size spill reservations and session config,
572    /// or `None` when execution memory is unbounded.
573    #[must_use]
574    pub fn sizing_bytes(&self) -> Option<usize> {
575        match self {
576            Self::Unbounded => None,
577            Self::Private(bytes) => Some(*bytes),
578            Self::Shared {
579                fair_share_bytes, ..
580            } => Some(*fair_share_bytes),
581        }
582    }
583
584    /// The pool to install on the runtime, or `None` for DataFusion's default.
585    fn pool(&self) -> Option<Arc<dyn datafusion::execution::memory_pool::MemoryPool>> {
586        match self {
587            Self::Unbounded => None,
588            // Guarded, like `shared_pool` — and this is the path that matters
589            // on the cluster. Every executor task engine is built with
590            // `EngineMemory::Private` (krishiv-executor `task_sql_engine`), so
591            // wrapping only the shared pool would have left the headroom
592            // switched off in exactly the deployment q10/q11 failed in.
593            Self::Private(bytes) => Some(guarded_fair_pool(*bytes)),
594            Self::Shared { pool, .. } => Some(Arc::clone(pool)),
595        }
596    }
597}
598
599impl fmt::Debug for EngineMemory {
600    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
601        match self {
602            Self::Unbounded => f.write_str("EngineMemory::Unbounded"),
603            Self::Private(bytes) => write!(f, "EngineMemory::Private({bytes})"),
604            Self::Shared {
605                fair_share_bytes, ..
606            } => write!(f, "EngineMemory::Shared(share={fair_share_bytes})"),
607        }
608    }
609}
610
611/// Programmatic override for [`runtime_filters_enabled_from_env`]
612/// (`u8::MAX` = unset). Exists because `std::env::set_var` is `unsafe`
613/// under edition 2024 and the workspace forbids unsafe code, so the
614/// corpus dual-run (AQE/runtime-filters off) cannot toggle the env var.
615static RUNTIME_FILTERS_OVERRIDE: std::sync::atomic::AtomicU8 =
616    std::sync::atomic::AtomicU8::new(u8::MAX);
617
618/// Test/diagnostic hook: force runtime filters on (`true`) or off (`false`)
619/// for every engine built in this process afterwards.
620#[doc(hidden)]
621pub fn set_runtime_filters_for_tests(enabled: bool) {
622    RUNTIME_FILTERS_OVERRIDE.store(u8::from(enabled), std::sync::atomic::Ordering::Relaxed);
623}
624
625/// Phase 54: DataFusion's native dynamic ("runtime") filters — TopK, join,
626/// and aggregate predicates pushed sideways into probe-side file scans at
627/// execution time. On by default; `KRISHIV_RUNTIME_FILTERS=off` disables
628/// them all (the AQE dual-run switch for result-identity verification).
629pub fn runtime_filters_enabled_from_env() -> bool {
630    match RUNTIME_FILTERS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed) {
631        0 => return false,
632        1 => return true,
633        _ => {}
634    }
635    !matches!(
636        std::env::var("KRISHIV_RUNTIME_FILTERS")
637            .unwrap_or_default()
638            .trim()
639            .to_ascii_lowercase()
640            .as_str(),
641        "off" | "0" | "false" | "disabled"
642    )
643}
644
645/// Resolve the batch size from `KRISHIV_BATCH_SIZE` env var.
646///
647/// Falls back to DataFusion's default (8192) if unset or invalid.
648pub fn batch_size_from_env() -> usize {
649    std::env::var("KRISHIV_BATCH_SIZE")
650        .ok()
651        .and_then(|v| v.parse::<usize>().ok())
652        .filter(|n| *n > 0)
653        .unwrap_or(8192)
654}
655
656/// Resolve the default parallelism from `KRISHIV_TARGET_PARALLELISM` env var.
657///
658/// Falls back to available parallelism if unset.
659pub fn default_parallelism_from_env() -> NonZeroUsize {
660    std::env::var("KRISHIV_TARGET_PARALLELISM")
661        .ok()
662        .and_then(|v| v.parse::<usize>().ok())
663        .and_then(NonZeroUsize::new)
664        .unwrap_or_else(|| std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN))
665}
666
667/// DataFusion's own default `sort_spill_reservation_bytes` (the merge-phase
668/// buffer an external sort reserves up front). Deployments that set a
669/// `memory_limit_bytes` smaller than this would otherwise have every sort
670/// fail immediately with "Not enough memory to continue external sort"
671/// before a single byte spills — the reservation alone doesn't fit the pool.
672const DEFAULT_SORT_SPILL_RESERVATION_BYTES: usize = 10 * 1024 * 1024;
673
674/// Floor for the scaled-down reservation below which DataFusion's merge step
675/// has too little room to make forward progress.
676const MIN_SORT_SPILL_RESERVATION_BYTES: usize = 64 * 1024;
677
678/// Build the DataFusion session config with a configurable parallelism level.
679///
680/// When `target_partitions > 1`, round-robin repartitioning is enabled so
681/// DataFusion can balance work across threads for hash-join build,
682/// aggregation spill, and parquet scan parallelism.
683///
684/// `execution.batch_size` is set from `KRISHIV_BATCH_SIZE` (default: 8192).
685///
686/// `memory_limit_bytes`, when `Some`, scales `sort_spill_reservation_bytes`
687/// down proportionally so a tight memory pool can still spill instead of
688/// failing outright because the reservation itself doesn't fit. Pools at or
689/// above `4 * DEFAULT_SORT_SPILL_RESERVATION_BYTES` (40MB) are unaffected —
690/// this only kicks in for genuinely memory-constrained deployments.
691/// Install Krishiv's optimizer rules on a session-state builder.
692///
693/// **A6 (review 2026-07-27).** These rules used to be written out at each
694/// construction site, with a comment at one of them warning that "a rule
695/// installed on only one of them is indistinguishable from a rule that works
696/// until you hit the other path". That warning was correct and the drift
697/// happened anyway: there is a *third* site — `planning_session_context`, the
698/// context the coordinator plans **every distributed query** on — and it
699/// carried none of these. The consequence was that two shipped performance
700/// fixes did not apply to the path being benchmarked:
701///
702/// - `SpillableJoinSelection` is what lets q18's oversized hash-join build
703///   side become a spillable sort-merge join. Unregistered, q18 fails with
704///   `Resources exhausted: HashJoinInput` on every distributed run.
705/// - `SemiJoinReductionThroughAggregate` is 88 % of q17's runtime.
706/// - `CooperativeAmplifiers` is what lets distributed cancellation preempt an
707///   amplifying operator at all.
708///
709/// Registering them in one place is the actual fix: a new construction site
710/// now has to *opt out* to be wrong, and `SessionStateBuilder` is consumed and
711/// returned so this composes into an existing chain.
712///
713/// Note on `SpillableJoinSelection::from_capacity()`: it reads the *calling
714/// process's* cgroup. On an executor that is right; on the coordinator it
715/// describes the coordinator, not the executors the plan will run on. The
716/// threshold is overridable via `KRISHIV_SPILL_JOIN_BUILD_BYTES`, which is the
717/// supported way to make coordinator-side planning use the executor's real
718/// per-task share until the capacity is plumbed through the stage builder.
719#[must_use]
720pub fn with_krishiv_optimizer_rules(
721    builder: datafusion::execution::session_state::SessionStateBuilder,
722) -> datafusion::execution::session_state::SessionStateBuilder {
723    with_krishiv_optimizer_rules_with_join_threshold(builder, None)
724}
725
726/// As [`with_krishiv_optimizer_rules`], with the spillable-join build-side
727/// threshold given explicitly; `None` derives it from this process's capacity.
728#[must_use]
729pub fn with_krishiv_optimizer_rules_with_join_threshold(
730    builder: datafusion::execution::session_state::SessionStateBuilder,
731    spill_join_build_bytes: Option<u64>,
732) -> datafusion::execution::session_state::SessionStateBuilder {
733    let spillable_join = match spill_join_build_bytes {
734        // Deliberately NOT grace-aware: this builder plans the stages the
735        // coordinator has to *encode*, and `GraceHashJoinExec` is a Krishiv
736        // node that `datafusion-proto` cannot serialize. A grace join here made
737        // the whole fragment fail to encode, and the scheduler's response to an
738        // unencodable stage plan is to run the query as a SINGLE TASK — so
739        // enabling the flag silently un-distributed q10 and q21 while looking
740        // like a memory fix.
741        //
742        // Grace is applied on the executor instead, after decode, by
743        // `distributed_plan::apply_local_spill_strategy`. Which algorithm an
744        // operator uses to spill is a local execution decision; it has no
745        // business on the wire.
746        Some(bytes) => crate::spillable_join::SpillableJoinSelection::with_threshold(Some(bytes)),
747        None => crate::spillable_join::SpillableJoinSelection::from_capacity(),
748    }
749    // ...except in a process that never encodes a stage plan. The paragraph
750    // above is about the *coordinator*, and this builder serves the one-shot
751    // CLI too, where the encodability argument simply does not apply — nothing
752    // it plans goes on the wire. Gated on `is_single_query_process()`, the same
753    // predicate that enables the degenerate-broadcast rescue, so the rescue and
754    // the algorithm it wants stop being enabled in different processes. Still
755    // off unless `KRISHIV_GRACE_HASH_JOIN` says otherwise.
756    .with_grace_where_plans_are_never_encoded();
757    builder
758        .with_physical_optimizer_rule(std::sync::Arc::new(
759            crate::coop_amplifiers::CooperativeAmplifiers::new(),
760        ))
761        // q18: a hash-join build side that exceeds the per-task memory share
762        // fails under a cgroup cap because hash join cannot spill. This rule
763        // converts exactly those joins — known-large build sides only — to
764        // sort-merge, which can. See `spillable_join` for why this is per-join
765        // and not a session config bit.
766        .with_physical_optimizer_rule(std::sync::Arc::new(spillable_join))
767        // Aggregates joined on their own grouping key only need the groups the
768        // join keeps; see `semi_join_reduction`.
769        .with_optimizer_rule(std::sync::Arc::new(
770            crate::semi_join_reduction::SemiJoinReductionThroughAggregate,
771        ))
772        // q18: a decorrelated IN-subquery semi-join lands at the top of the
773        // plan, so the most selective predicate runs after the joins it should
774        // have shrunk. Push it into the join input instead.
775        //
776        // On by default: with it off, q18 at SF100 does not merely slow down,
777        // it FAILS — the sort cannot get its first 2.1 MB because the joins
778        // hold the whole 2.6 GB pool. With it on, 424 s. See
779        // `SEMI_JOIN_PUSHDOWN_ENV`.
780        .with_optimizer_rule(std::sync::Arc::new(
781            crate::semi_join_reduction::SemiJoinPushdownThroughInnerJoin::default(),
782        ))
783        // q7: the two 25-row `nation` tables are last in the FROM clause, so a
784        // left-deep plan applies `n_name IN (FRANCE, GERMANY)` above every big
785        // join — after `s_nationkey` has ridden two 9.48 GB shuffles that were
786        // measured at 81% of the query. Reduce the fact stream by the filtered
787        // dimension first; the pushdown rule above then carries that reducer
788        // down onto the `supplier` scan.
789        //
790        // Registered *after* the pushdown rule so the reducer it introduces is
791        // picked up on the following pass rather than sitting at the top.
792        .with_optimizer_rule(std::sync::Arc::new(
793            crate::semi_join_reduction::SemiJoinReductionFromSelectiveDimension::default(),
794        ))
795        // q10: a `GROUP BY` that lists a key *and the columns that key
796        // determines* drags those columns through every join and shuffle to
797        // display twenty of them. Group on the key, take the top N, then
798        // re-fetch. Measured at 14.8x on q10 at SF100 — and measured *not* to
799        // be obtainable any cheaper; see `late_materialize` for the two local
800        // rewrites that were tried first and lost.
801        //
802        // Registered last on purpose: it rewrites what the projection and
803        // limit rules have already settled, and DataFusion's optimizer loops
804        // (`max_passes`), so `optimize_projections` runs again afterwards and
805        // is what actually prunes the deferred columns out of the scans.
806        .with_optimizer_rule(std::sync::Arc::new(
807            crate::late_materialize::LateMaterializeTopKAggregate::default(),
808        ))
809}
810
811pub(crate) fn build_single_node_session_config(
812    target_partitions: NonZeroUsize,
813    memory_limit_bytes: Option<usize>,
814) -> datafusion::prelude::SessionConfig {
815    let tp = target_partitions.get();
816    let batch_size = batch_size_from_env();
817    let mut config = datafusion::prelude::SessionConfig::new()
818        .with_target_partitions(tp)
819        .with_batch_size(batch_size)
820        .with_information_schema(true)
821        .set_bool(
822            "datafusion.optimizer.enable_round_robin_repartition",
823            tp > 1,
824        )
825        // Phase 54 runtime filters: DataFusion's master switch does NOT
826        // suppress the per-operator options when set to false (it only
827        // force-enables them when true), so `KRISHIV_RUNTIME_FILTERS=off`
828        // must clear all four together.
829        .set_bool(
830            "datafusion.optimizer.enable_dynamic_filter_pushdown",
831            runtime_filters_enabled_from_env(),
832        )
833        .set_bool(
834            "datafusion.optimizer.enable_join_dynamic_filter_pushdown",
835            runtime_filters_enabled_from_env(),
836        )
837        .set_bool(
838            "datafusion.optimizer.enable_topk_dynamic_filter_pushdown",
839            runtime_filters_enabled_from_env(),
840        )
841        .set_bool(
842            "datafusion.optimizer.enable_aggregate_dynamic_filter_pushdown",
843            runtime_filters_enabled_from_env(),
844        );
845    // Phase 60: use a lambda-capable parser dialect so Spark-style higher-order
846    // functions (`transform(arr, x -> …)`, `filter`, `forall`) parse — the
847    // default `Generic` dialect treats `->` as the JSON-arrow operator, so the
848    // lambda variable is mis-planned as a column reference. DuckDB is the most
849    // standards-compatible dialect that supports BOTH `x -> body` lambdas and
850    // `[...]` array literals; it changes only parse syntax, not semantics, and
851    // is validated against the full krishiv-sql suite.
852    config.options_mut().sql_parser.dialect = datafusion::common::config::Dialect::DuckDB;
853    // Parquet scan options stay at DataFusion's defaults (`pushdown_filters`
854    // off, `enable_page_index` on). Forcing `pushdown_filters = true` here
855    // cost ~2.2× on scan-heavy queries (Phase 52 #194 attribution probe,
856    // TPC-H Q6 SF1: 268 ms → 121 ms); workloads that benefit from row-level
857    // late materialization can opt in per session via
858    // `SET datafusion.execution.parquet.pushdown_filters = true`.
859    if let Some(limit) = memory_limit_bytes {
860        let scaled = (limit / 4).clamp(
861            MIN_SORT_SPILL_RESERVATION_BYTES,
862            DEFAULT_SORT_SPILL_RESERVATION_BYTES,
863        );
864        config = config.with_sort_spill_reservation_bytes(scaled);
865    }
866    config
867}
868
869/// Iceberg catalogs registered via `with_iceberg_catalog`, paired with their
870/// DataFusion catalog name, behind a shared lock for `CALL system.<proc>`
871/// dispatch.
872#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
873type IcebergCatalogRegistry =
874    Arc<std::sync::RwLock<Vec<(Arc<catalog::unified::KrishivCatalog>, String)>>>;
875
876#[derive(Clone)]
877pub struct SqlEngine {
878    context: SessionContext,
879    target_parallelism: NonZeroUsize,
880    krishiv_catalog: Option<Arc<RwLock<InMemoryCatalog>>>,
881    udf_registry: Option<std::sync::Arc<std::sync::RwLock<krishiv_plan::udf::UdfRegistry>>>,
882    /// Table names registered as unbounded streaming sources.
883    /// Wrapped in `Arc<RwLock<>>` so that Session clones share the same set.
884    streaming_sources: Arc<RwLock<std::collections::HashSet<String>>>,
885    /// Serializes streaming table name validation and catalog registration.
886    streaming_registration: Arc<Mutex<()>>,
887    /// `true` once any streaming source has been registered.  Checked with a
888    /// relaxed atomic load before acquiring `streaming_sources` so that the
889    /// common case (no streaming sources, pure batch workload) avoids both the
890    /// lock and the SQL parse inside `is_streaming_query`.
891    has_streaming_sources: Arc<AtomicBool>,
892    /// Optional UDF resource limits to apply when syncing UDFs for this engine.
893    /// Set for job-specific engines so sandbox enforcement uses the job's budgets.
894    udf_limits: Option<krishiv_plan::udf::ResourceLimits>,
895    /// Monotonically increasing version counter; incremented on every UDF
896    /// registration or removal. Used to skip `sync_all_udfs()` when nothing
897    /// has changed since the last sync.
898    udf_registry_version: Arc<AtomicU64>,
899    /// The version at which the last `sync_all_udfs()` was performed.
900    /// Compared against `udf_registry_version` to detect staleness.
901    udf_last_synced_version: Arc<AtomicU64>,
902    /// Bounded query plan cache: query text → DataFusion LogicalPlan.
903    /// Skips re-parsing and re-optimising identical repeated queries.
904    /// Max `PLAN_CACHE_MAX_ENTRIES` entries; oldest entry evicted when full.
905    /// Single-lock design prevents the TOCTOU race of the previous two-structure
906    /// (`DashMap` + `VecDeque`) implementation.
907    plan_cache: Arc<Mutex<PlanCache>>,
908    /// Override for shuffle partition count (`SET shuffle.partitions = N`).
909    /// When `Some`, exchange nodes use this bucket count instead of auto-sizing.
910    shuffle_partitions: Arc<std::sync::RwLock<Option<u32>>>,
911    /// Estimated row counts for registered tables, keyed by table name.
912    /// Populated by `register_parquet` and `register_record_batches`.
913    /// Used by `krishiv_logical_plan` to annotate scan nodes for the
914    /// `BroadcastAutoRule` optimizer.
915    table_row_counts: Arc<std::sync::RwLock<HashMap<String, u64>>>,
916    /// DataFusion memory pool limit in bytes for this engine, when bounded.
917    /// `None` means the default unbounded pool. When `Some`, the engine runs
918    /// with a `FairSpillPool` so sorts, hash joins, and aggregations spill to
919    /// disk under memory pressure instead of growing without bound.
920    memory_limit_bytes: Option<usize>,
921    /// Iceberg catalogs registered via `with_iceberg_catalog`, keyed by their
922    /// DataFusion catalog name. Stored so that `CALL system.<proc>` statements
923    /// can dispatch maintenance operations to the right catalog.
924    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
925    iceberg_catalogs: IcebergCatalogRegistry,
926    /// Live-table DDL registry shared across SQL and session APIs.
927    live_table_registry: Arc<live_table::LiveTableRegistry>,
928    /// Incremental-view DDL registry shared across SQL and session APIs.
929    incremental_view_registry: Arc<incremental_view::IncrementalViewRegistry>,
930    /// Pipeline DDL registry (CREATE SOURCE / CREATE SINK metadata).
931    pipeline_registry: Arc<pipeline_ddl::PipelineRegistry>,
932    /// Cancelled operation IDs and progress snapshots for query lifecycle control.
933    operation_registry: Arc<OperationRegistry>,
934}
935
936impl fmt::Debug for SqlEngine {
937    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
938        f.debug_struct("SqlEngine")
939            .field("backend", &"datafusion")
940            .finish_non_exhaustive()
941    }
942}
943
944impl Default for SqlEngine {
945    fn default() -> Self {
946        Self::new()
947    }
948}
949
950impl SqlEngine {
951    /// Create a local SQL engine.
952    ///
953    /// Window helper UDFs (`tumble_start`, `tumble_end`, `hop_start`, `hop_end`)
954    /// are registered as part of construction. If registration fails the
955    /// engine is still returned — non-window queries work — and a
956    /// `tracing::warn!` is emitted. Use [`SqlEngine::try_new`] when callers
957    /// need to surface the registration error.
958    ///
959    /// DataFusion `target_partitions` defaults to the machine's available CPU
960    /// parallelism (matching a bare `datafusion::SessionContext`), overridable
961    /// via `KRISHIV_TARGET_PARALLELISM` or [`SqlEngine::with_target_parallelism`].
962    /// Executor task engines deliberately scale this down to their per-slot
963    /// share so concurrent tasks don't oversubscribe the machine.
964    pub fn new() -> Self {
965        Self::new_with_engine_memory(EngineMemory::for_this_process())
966    }
967
968    /// Create a local SQL engine whose DataFusion execution memory is capped
969    /// at `memory_limit_bytes`.
970    ///
971    /// When `Some`, the engine runs with a `FairSpillPool` of that size plus
972    /// the default disk manager, so memory-intensive operators (sort, hash
973    /// join, aggregation) spill to disk under pressure and queries that cannot
974    /// spill fail with a resources-exhausted error instead of exhausting
975    /// process memory. `None` keeps DataFusion's default unbounded pool.
976    ///
977    /// Shares [`SqlEngine::new`]'s fallback behavior for window helper UDF
978    /// registration failures.
979    pub fn new_with_memory_limit(memory_limit_bytes: Option<usize>) -> Self {
980        Self::new_with_engine_memory(EngineMemory::from_limit(memory_limit_bytes))
981    }
982
983    /// Create a local SQL engine over an explicit [`EngineMemory`] source.
984    ///
985    /// Prefer this over [`new_with_memory_limit`](Self::new_with_memory_limit)
986    /// when several engines live in one process: passing each of them
987    /// [`EngineMemory::Shared`] with the same pool bounds their *combined*
988    /// execution memory, which per-engine limits cannot do.
989    ///
990    /// Shares [`SqlEngine::new`]'s fallback behavior for window helper UDF
991    /// registration failures.
992    pub fn new_with_engine_memory(engine_memory: EngineMemory) -> Self {
993        let parallelism = default_parallelism_from_env();
994        match Self::build_local(
995            None,
996            WindowFnRegistration::Register,
997            parallelism,
998            engine_memory.clone(),
999        ) {
1000            Ok(engine) => engine,
1001            Err(err) => {
1002                tracing::warn!(
1003                    error = %err,
1004                    "SqlEngine::new: window helper UDF registration failed; \
1005                     window SQL functions will be unavailable, other queries are unaffected"
1006                );
1007                Self::build_local(
1008                    None,
1009                    WindowFnRegistration::Skip,
1010                    parallelism,
1011                    engine_memory.clone(),
1012                )
1013                .unwrap_or_else(|err| {
1014                    tracing::error!(
1015                        error = %err,
1016                        "memory-limited DataFusion runtime construction failed; \
1017                         falling back to an unbounded engine"
1018                    );
1019                    Self::build_local(
1020                        None,
1021                        WindowFnRegistration::Skip,
1022                        parallelism,
1023                        EngineMemory::Unbounded,
1024                    )
1025                    .unwrap_or_else(|_| Self::build_absolute_minimal(parallelism))
1026                })
1027            }
1028        }
1029    }
1030
1031    /// Create a local SQL engine, propagating window helper registration errors.
1032    ///
1033    /// Callers that need to abort startup when window functions cannot be
1034    /// registered should use this constructor.
1035    pub fn try_new() -> SqlResult<Self> {
1036        Self::build_local(
1037            None,
1038            WindowFnRegistration::Register,
1039            default_parallelism_from_env(),
1040            EngineMemory::for_this_process(),
1041        )
1042    }
1043
1044    /// Create an engine whose `krishiv` catalog resolves tables registered in `InMemoryCatalog` (P0-10).
1045    pub fn with_in_memory_catalog(catalog: Arc<RwLock<InMemoryCatalog>>) -> SqlResult<Self> {
1046        if krishiv_common::profile_requires_fail_closed_metadata(
1047            krishiv_common::resolve_durability_profile(),
1048        ) {
1049            return Err(SqlError::DataFusion {
1050                message: String::from(
1051                    "InMemoryCatalog is dev-only; configure a durable REST or file-backed \
1052                     catalog for production deployments",
1053                ),
1054            });
1055        }
1056        Self::build_local(
1057            Some(catalog),
1058            WindowFnRegistration::Register,
1059            default_parallelism_from_env(),
1060            EngineMemory::for_this_process(),
1061        )
1062    }
1063
1064    /// Set the DataFusion `target_partitions` parallelism level for this engine.
1065    ///
1066    /// Higher values allow DataFusion to parallelise hash-join build,
1067    /// aggregation spilling, and parquet scans across more threads.
1068    /// Default: [`default_parallelism_from_env`] (available CPU parallelism,
1069    /// `KRISHIV_TARGET_PARALLELISM` override).
1070    ///
1071    /// The new level applies to queries planned after this call; plans already
1072    /// produced (or cached logical plans re-planned physically) pick it up at
1073    /// physical planning time.
1074    #[must_use]
1075    pub fn with_target_parallelism(mut self, n: NonZeroUsize) -> Self {
1076        self.target_parallelism = n;
1077        self.apply_target_partitions(n);
1078        self
1079    }
1080
1081    /// Write `n` back into the live session state so DataFusion actually
1082    /// plans with it.
1083    ///
1084    /// `SessionConfig` is baked into the `SessionState` at construction;
1085    /// setting only the `target_parallelism` field would leave physical
1086    /// planning at the construction-time partition count (the Phase 51
1087    /// 4.5–8.9× embedded-overhead finding was exactly this: every
1088    /// `with_target_parallelism` caller silently kept running single-threaded).
1089    fn apply_target_partitions(&self, n: NonZeroUsize) {
1090        let state_ref = self.context.state_ref();
1091        let mut state = state_ref.write();
1092        let options = state.config_mut().options_mut();
1093        options.execution.target_partitions = n.get();
1094        options.optimizer.enable_round_robin_repartition = n.get() > 1;
1095    }
1096
1097    /// Return the configured `target_partitions` parallelism level.
1098    pub fn target_parallelism(&self) -> NonZeroUsize {
1099        self.target_parallelism
1100    }
1101
1102    /// Return the DataFusion memory pool limit for this engine, if bounded.
1103    pub fn memory_limit_bytes(&self) -> Option<usize> {
1104        self.memory_limit_bytes
1105    }
1106
1107    /// Direct access to the underlying DataFusion session context.
1108    ///
1109    /// Used by the distributed stage builder (ADR-0003) to create and
1110    /// round-trip physical plans; general query execution should go through
1111    /// [`SqlEngine::sql`] so engine-level rewrites and governance apply.
1112    pub fn session_context(&self) -> &SessionContext {
1113        &self.context
1114    }
1115
1116    /// Return the current `shuffle.partitions` override, if set via `SET shuffle.partitions = N`.
1117    pub fn shuffle_partitions(&self) -> Option<u32> {
1118        *self
1119            .shuffle_partitions
1120            .read()
1121            .unwrap_or_else(|e| e.into_inner())
1122    }
1123
1124    /// Return access to the table row-count registry.
1125    ///
1126    /// Populated by `register_parquet` and `register_record_batches` with
1127    /// estimated row counts extracted from table-provider statistics. Used
1128    /// by `SqlDataFrame::krishiv_logical_plan` to annotate scan nodes.
1129    pub fn table_row_counts(&self) -> Arc<std::sync::RwLock<HashMap<String, u64>>> {
1130        Arc::clone(&self.table_row_counts)
1131    }
1132
1133    /// Return table/view names registered in the live DataFusion catalog.
1134    ///
1135    /// Uses DataFusion's catalog provider API directly instead of routing
1136    /// through `SHOW TABLES`, which requires optional information-schema
1137    /// support in some DataFusion configurations.
1138    pub fn registered_table_names(&self) -> Vec<String> {
1139        let mut names = Vec::new();
1140        for catalog_name in self.context.catalog_names() {
1141            let Some(catalog) = self.context.catalog(&catalog_name) else {
1142                continue;
1143            };
1144            for schema_name in catalog.schema_names() {
1145                let Some(schema) = catalog.schema(&schema_name) else {
1146                    continue;
1147                };
1148                names.extend(schema.table_names());
1149            }
1150        }
1151        names.sort();
1152        names.dedup();
1153        names
1154    }
1155
1156    /// Build a `SqlDataFrame` with this engine's shared session context attached
1157    /// so that `cache()` / `create_or_replace_temp_view()` work on the live session.
1158    fn make_sql_df(&self, name: &str, dataframe: DataFusionDataFrame) -> SqlDataFrame {
1159        SqlDataFrame::new(name, dataframe, self.table_row_counts())
1160            .with_context(self.context.clone())
1161    }
1162
1163    /// Attach SQL text and execution kind derived from registered streaming sources.
1164    fn attach_query_metadata(&self, df: SqlDataFrame, query: &str) -> SqlDataFrame {
1165        let kind = if self.is_streaming_query(query).unwrap_or(false) {
1166            ExecutionKind::Streaming
1167        } else {
1168            ExecutionKind::Batch
1169        };
1170        df.with_query(query).with_execution_kind(kind)
1171    }
1172
1173    /// Set an override for the shuffle partition count.
1174    ///
1175    /// When `n` is `Some`, exchange and shuffle-write operations use `n` buckets
1176    /// instead of auto-sizing. Pass `None` to restore auto-sizing.
1177    #[must_use]
1178    pub fn with_shuffle_partitions(self, n: Option<u32>) -> Self {
1179        if let Ok(mut guard) = self.shuffle_partitions.write() {
1180            *guard = n;
1181        }
1182        self
1183    }
1184
1185    /// Internal builder shared by the public constructors.
1186    ///
1187    /// `krishiv_catalog` is `Some(...)` when the engine should bridge to an
1188    /// `InMemoryCatalog`; `None` for a default engine.
1189    ///
1190    /// `window_fn_registration` controls whether the helper UDFs
1191    /// (`tumble_start` / `tumble_end` / `hop_start` / `hop_end`) are
1192    /// registered. `Skip` is used as a fallback by [`SqlEngine::new`] when
1193    /// `Register` fails; it is infallible.
1194    fn build_local(
1195        krishiv_catalog: Option<Arc<RwLock<InMemoryCatalog>>>,
1196        window_fn_registration: WindowFnRegistration,
1197        target_partitions: NonZeroUsize,
1198        engine_memory: EngineMemory,
1199    ) -> SqlResult<Self> {
1200        let memory_limit_bytes = engine_memory.sizing_bytes();
1201        // Create streaming_sources first so it can be shared with KafkaTableFactory.
1202        // DDL-created Kafka tables (CREATE EXTERNAL TABLE … STORED AS KAFKA) then
1203        // correctly register in is_streaming_query.
1204        let streaming_sources: Arc<RwLock<std::collections::HashSet<String>>> =
1205            Arc::new(RwLock::new(std::collections::HashSet::new()));
1206
1207        let mut state_builder = with_krishiv_optimizer_rules(
1208            datafusion::execution::session_state::SessionStateBuilder::new()
1209                .with_default_features(),
1210        )
1211        .with_config(build_single_node_session_config(
1212            target_partitions,
1213            memory_limit_bytes,
1214        ));
1215        {
1216            // The lazy registry is installed unconditionally, not only when a
1217            // memory limit is configured. An executor decoding a `dfplan:`
1218            // fragment discovers which buckets the plan touches only by
1219            // decoding it, and the decode is what needs the store — so there
1220            // is no earlier point at which the bucket could be registered.
1221            let mut runtime_builder = datafusion::execution::runtime_env::RuntimeEnvBuilder::new()
1222                .with_object_store_registry(Arc::new(
1223                    crate::object_store_registry::LazyCloudObjectStoreRegistry::new(),
1224                ));
1225            if let Some(pool) = engine_memory.pool() {
1226                // A FairSpillPool divides its capacity across concurrently
1227                // running consumers and lets spill-capable operators (sort,
1228                // hash join, aggregation) write to the default disk manager's
1229                // temp files instead of failing outright when the pool is
1230                // exhausted. Under `EngineMemory::Shared` that pool is the
1231                // executor's single process-wide pool, so the consumers being
1232                // divided across are every operator of every concurrent task.
1233                runtime_builder = runtime_builder.with_memory_pool(pool);
1234            }
1235            let runtime_env = runtime_builder
1236                .build_arc()
1237                .map_err(|e| SqlError::DataFusion {
1238                    message: format!(
1239                        "failed to build DataFusion runtime \
1240                     (memory limit {memory_limit_bytes:?} bytes): {e}"
1241                    ),
1242                })?;
1243            state_builder = state_builder.with_runtime_env(runtime_env);
1244        }
1245        let mut state = state_builder.build();
1246        // Connector factories layer on top of the defaults the builder already
1247        // installed; mutating in place avoids building a second throwaway
1248        // SessionState just to harvest the default factory map.
1249        crate::connector_table::register_connector_table_factories(
1250            state.table_factories_mut(),
1251            streaming_sources.clone(),
1252        );
1253        let context = SessionContext::new_with_state(state);
1254        if let Some(catalog) = &krishiv_catalog {
1255            context.register_catalog(
1256                "krishiv",
1257                Arc::new(DataFusionCatalogBridge::new(catalog.clone())),
1258            );
1259        }
1260        if matches!(window_fn_registration, WindowFnRegistration::Register) {
1261            window_functions::register_window_functions(&context).map_err(|e| {
1262                SqlError::DataFusion {
1263                    message: format!("failed to register window helper UDFs: {e}"),
1264                }
1265            })?;
1266        }
1267        // Phase 60: Spark-reference JSON scalar functions are always available on
1268        // the batch SQL front door (get_json_object, json_array_length).
1269        json_functions::register_json_functions(&context).map_err(|e| SqlError::DataFusion {
1270            message: format!("failed to register JSON UDFs: {e}"),
1271        })?;
1272        // Phase 60: Spark-parity higher-order array functions
1273        // (transform/filter/exists/forall) on the batch SQL front door.
1274        higher_order_functions::register_higher_order_spark_functions(&context).map_err(|e| {
1275            SqlError::DataFusion {
1276                message: format!("failed to register higher-order UDFs: {e}"),
1277            }
1278        })?;
1279        // Phase 60: Spark-parity scalar functions (Spark-pattern date_format, crc32).
1280        spark_functions::register_spark_scalar_functions(&context).map_err(|e| {
1281            SqlError::DataFusion {
1282                message: format!("failed to register Spark scalar UDFs: {e}"),
1283            }
1284        })?;
1285        Ok(Self {
1286            context,
1287            target_parallelism: target_partitions,
1288            krishiv_catalog,
1289            udf_registry: None,
1290            streaming_sources,
1291            streaming_registration: Arc::new(Mutex::new(())),
1292            has_streaming_sources: Arc::new(AtomicBool::new(false)),
1293            udf_limits: None,
1294            udf_registry_version: Arc::new(AtomicU64::new(0)),
1295            udf_last_synced_version: Arc::new(AtomicU64::new(u64::MAX)),
1296            plan_cache: Arc::new(Mutex::new(PlanCache::new(resolve_plan_cache_max_entries()))),
1297            shuffle_partitions: Arc::new(std::sync::RwLock::new(None)),
1298            table_row_counts: Arc::new(std::sync::RwLock::new(HashMap::new())),
1299            memory_limit_bytes,
1300            #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
1301            iceberg_catalogs: Arc::new(std::sync::RwLock::new(Vec::new())),
1302            live_table_registry: Arc::new(live_table::LiveTableRegistry::new()),
1303            incremental_view_registry: Arc::new(incremental_view::IncrementalViewRegistry::new()),
1304            pipeline_registry: Arc::new(pipeline_ddl::PipelineRegistry::new()),
1305            operation_registry: Arc::new(OperationRegistry::new()),
1306        })
1307    }
1308
1309    /// Build the absolute minimal engine: no catalog, no window UDFs, no memory
1310    /// limit. Every step is infallible, so the return type is `Self`. Used as
1311    /// the last-resort fallback in `new_with_memory_limit`.
1312    fn build_absolute_minimal(target_partitions: NonZeroUsize) -> Self {
1313        let streaming_sources: Arc<RwLock<std::collections::HashSet<String>>> =
1314            Arc::new(RwLock::new(std::collections::HashSet::new()));
1315        let mut state = with_krishiv_optimizer_rules(
1316            datafusion::execution::session_state::SessionStateBuilder::new()
1317                .with_default_features(),
1318        )
1319        .with_config(build_single_node_session_config(target_partitions, None))
1320        .build();
1321        crate::connector_table::register_connector_table_factories(
1322            state.table_factories_mut(),
1323            streaming_sources.clone(),
1324        );
1325        let context = SessionContext::new_with_state(state);
1326        Self {
1327            context,
1328            target_parallelism: target_partitions,
1329            krishiv_catalog: None,
1330            udf_registry: None,
1331            streaming_sources,
1332            streaming_registration: Arc::new(Mutex::new(())),
1333            has_streaming_sources: Arc::new(AtomicBool::new(false)),
1334            udf_limits: None,
1335            udf_registry_version: Arc::new(AtomicU64::new(0)),
1336            udf_last_synced_version: Arc::new(AtomicU64::new(u64::MAX)),
1337            plan_cache: Arc::new(Mutex::new(PlanCache::new(resolve_plan_cache_max_entries()))),
1338            shuffle_partitions: Arc::new(std::sync::RwLock::new(None)),
1339            table_row_counts: Arc::new(std::sync::RwLock::new(HashMap::new())),
1340            memory_limit_bytes: None,
1341            #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
1342            iceberg_catalogs: Arc::new(std::sync::RwLock::new(Vec::new())),
1343            live_table_registry: Arc::new(live_table::LiveTableRegistry::new()),
1344            incremental_view_registry: Arc::new(incremental_view::IncrementalViewRegistry::new()),
1345            pipeline_registry: Arc::new(pipeline_ddl::PipelineRegistry::new()),
1346            operation_registry: Arc::new(OperationRegistry::new()),
1347        }
1348    }
1349
1350    /// Register an unbounded continuous table, returning its typed input.
1351    ///
1352    /// The returned input uses a bounded channel with capacity
1353    /// [`crate::streaming::CONTINUOUS_TABLE_CHANNEL_CAPACITY`]. When the
1354    /// consumer (the DataFusion query plan) is slower than the producer,
1355    /// `ContinuousTableInput::send(...).await` backpressures the producer,
1356    /// and `ContinuousTableInput::try_send(...)` returns a resource error
1357    /// rather than growing memory without limit. Use
1358    /// [`register_streaming_table_with_capacity`] for a non-default
1359    /// capacity.
1360    pub fn register_streaming_table(
1361        &self,
1362        name: &str,
1363        schema: arrow::datatypes::SchemaRef,
1364    ) -> SqlResult<Arc<ContinuousTableInput>> {
1365        let _registration = self.lock_streaming_registration()?;
1366        self.validate_new_streaming_table(name, &schema)?;
1367        let (table, input) = crate::streaming::create_continuous_table(schema).map_err(|e| {
1368            SqlError::DataFusion {
1369                message: e.to_string(),
1370            }
1371        })?;
1372        self.register_new_streaming_provider(name, table)?;
1373        self.streaming_sources
1374            .write()
1375            .unwrap_or_else(|e| e.into_inner())
1376            .insert(name.to_string());
1377        self.has_streaming_sources.store(true, Ordering::Release);
1378        self.invalidate_plan_cache();
1379        Ok(input)
1380    }
1381
1382    /// Same as [`Self::register_streaming_table`] but with a caller-supplied
1383    /// channel capacity. Useful for tests that want to exercise the
1384    /// full/empty channel boundary without pushing
1385    /// `CONTINUOUS_TABLE_CHANNEL_CAPACITY` (64) batches.
1386    pub fn register_streaming_table_with_capacity(
1387        &self,
1388        name: &str,
1389        schema: arrow::datatypes::SchemaRef,
1390        capacity: usize,
1391    ) -> SqlResult<Arc<ContinuousTableInput>> {
1392        let _registration = self.lock_streaming_registration()?;
1393        self.validate_new_streaming_table(name, &schema)?;
1394        let (table, input) = crate::streaming::create_continuous_table_with_capacity(
1395            schema, capacity,
1396        )
1397        .map_err(|e| SqlError::DataFusion {
1398            message: e.to_string(),
1399        })?;
1400        self.register_new_streaming_provider(name, table)?;
1401        self.streaming_sources
1402            .write()
1403            .unwrap_or_else(|e| e.into_inner())
1404            .insert(name.to_string());
1405        self.has_streaming_sources.store(true, Ordering::Release);
1406        self.invalidate_plan_cache();
1407        Ok(input)
1408    }
1409
1410    fn lock_streaming_registration(&self) -> SqlResult<std::sync::MutexGuard<'_, ()>> {
1411        self.streaming_registration
1412            .lock()
1413            .map_err(|error| SqlError::DataFusion {
1414                message: format!("streaming table registration lock poisoned: {error}"),
1415            })
1416    }
1417
1418    fn validate_new_streaming_table(
1419        &self,
1420        name: &str,
1421        schema: &arrow::datatypes::SchemaRef,
1422    ) -> SqlResult<()> {
1423        if name.trim().is_empty() {
1424            return Err(SqlError::EmptyTableName);
1425        }
1426        if schema.fields().is_empty() {
1427            return Err(SqlError::DataFusion {
1428                message: "streaming table schema must contain at least one field".into(),
1429            });
1430        }
1431        if self
1432            .context
1433            .table_exist(name)
1434            .map_err(|error| SqlError::DataFusion {
1435                message: error.to_string(),
1436            })?
1437        {
1438            return Err(SqlError::DataFusion {
1439                message: format!("table '{name}' is already registered"),
1440            });
1441        }
1442        Ok(())
1443    }
1444
1445    fn register_new_streaming_provider(
1446        &self,
1447        name: &str,
1448        table: Arc<dyn datafusion::catalog::TableProvider>,
1449    ) -> SqlResult<()> {
1450        let previous =
1451            self.context
1452                .register_table(name, table)
1453                .map_err(|error| SqlError::DataFusion {
1454                    message: error.to_string(),
1455                })?;
1456        if let Some(previous) = previous {
1457            self.context
1458                .register_table(name, previous)
1459                .map_err(|error| SqlError::DataFusion {
1460                    message: format!(
1461                        "table '{name}' was concurrently registered and could not be restored: \
1462                         {error}"
1463                    ),
1464                })?;
1465            return Err(SqlError::DataFusion {
1466                message: format!("table '{name}' was concurrently registered"),
1467            });
1468        }
1469        Ok(())
1470    }
1471
1472    /// Register a live Kafka/Redpanda topic as an unbounded streaming table.
1473    ///
1474    /// This is the native Rust path — no Python bridge or external process
1475    /// required.  Under the hood it creates an `rdkafka` consumer and wraps it
1476    /// in a DataFusion `StreamingTable` so normal SQL queries (`SELECT`,
1477    /// `GROUP BY`, windowed aggregations) work against the live topic.
1478    ///
1479    /// Equivalent SQL DDL:
1480    /// ```sql
1481    /// CREATE EXTERNAL TABLE <name> (<cols>) STORED AS KAFKA
1482    ///   LOCATION '<topic>'
1483    ///   OPTIONS ('bootstrap.servers' = '…', 'group.id' = '…');
1484    /// ```
1485    pub fn register_kafka_source(
1486        &self,
1487        table_name: impl AsRef<str>,
1488        schema: arrow::datatypes::SchemaRef,
1489        bootstrap_servers: impl Into<String>,
1490        topic: impl Into<String>,
1491        group_id: impl Into<String>,
1492    ) -> SqlResult<()> {
1493        let table_name = table_name.as_ref();
1494        if table_name.trim().is_empty() {
1495            return Err(SqlError::EmptyTableName);
1496        }
1497        let config = krishiv_connectors::kafka::KafkaConfig {
1498            bootstrap_servers: bootstrap_servers.into(),
1499            topic: topic.into(),
1500            group_id: group_id.into(),
1501            auto_commit_interval_ms: {
1502                let profile = krishiv_common::resolve_durability_profile();
1503                if krishiv_common::requires_manual_kafka_commit(profile) {
1504                    None
1505                } else {
1506                    Some(1_000)
1507                }
1508            },
1509            security_protocol: None,
1510            ssl_ca_location: None,
1511            ssl_certificate_location: None,
1512            ssl_key_location: None,
1513            ssl_key_password: None,
1514            sasl_username: None,
1515            sasl_password: None,
1516            sasl_mechanisms: None,
1517            enable_idempotence: None,
1518            transactional_id: None,
1519        };
1520        let table =
1521            crate::kafka_table::create_kafka_streaming_table(schema, config).map_err(|e| {
1522                SqlError::DataFusion {
1523                    message: e.to_string(),
1524                }
1525            })?;
1526        if self
1527            .context
1528            .table_exist(table_name)
1529            .map_err(SqlError::from)?
1530        {
1531            let _ = self
1532                .context
1533                .deregister_table(table_name)
1534                .map_err(SqlError::from)?;
1535        }
1536        self.context
1537            .register_table(table_name, table)
1538            .map_err(|e| SqlError::DataFusion {
1539                message: e.to_string(),
1540            })?;
1541        self.streaming_sources
1542            .write()
1543            .unwrap_or_else(|e| e.into_inner())
1544            .insert(table_name.to_string());
1545        self.has_streaming_sources.store(true, Ordering::Release);
1546        self.invalidate_plan_cache();
1547        Ok(())
1548    }
1549
1550    /// Execute a SQL query and write every result row to a Kafka/Redpanda topic.
1551    ///
1552    /// Each row is serialised as a JSON object using the same format as
1553    /// [`KafkaSink`].  The method blocks until the query stream ends and the
1554    /// producer queue is flushed, then returns the total number of rows written.
1555    ///
1556    /// **Note**: If `sql` targets an unbounded streaming table (e.g. one
1557    /// registered via [`register_kafka_source`]) this call will never return.
1558    /// Use it with batch sources or add a `LIMIT` clause.
1559    pub async fn sql_to_kafka(
1560        &self,
1561        sql: impl AsRef<str>,
1562        bootstrap_servers: impl Into<String>,
1563        topic: impl Into<String>,
1564    ) -> SqlResult<u64> {
1565        use futures::StreamExt;
1566        use krishiv_connectors::Sink as _;
1567        use krishiv_connectors::kafka::{KafkaConfig, KafkaSink};
1568
1569        let config = KafkaConfig {
1570            bootstrap_servers: bootstrap_servers.into(),
1571            topic: topic.into(),
1572            group_id: "krishiv-sql-writer".into(),
1573            auto_commit_interval_ms: None,
1574            security_protocol: None,
1575            ssl_ca_location: None,
1576            ssl_certificate_location: None,
1577            ssl_key_location: None,
1578            ssl_key_password: None,
1579            sasl_username: None,
1580            sasl_password: None,
1581            sasl_mechanisms: None,
1582            enable_idempotence: None,
1583            transactional_id: None,
1584        };
1585        let mut sink = KafkaSink::new(config).map_err(|e| SqlError::DataFusion {
1586            message: e.to_string(),
1587        })?;
1588
1589        let df = self.sql(sql.as_ref()).await?;
1590        let mut stream = df.execute_stream().await?;
1591        let mut total_rows = 0u64;
1592
1593        while let Some(result) = stream.next().await {
1594            let batch = result.map_err(|e| SqlError::DataFusion {
1595                message: e.to_string(),
1596            })?;
1597            if batch.num_rows() > 0 {
1598                total_rows += batch.num_rows() as u64;
1599                sink.write_batch(batch)
1600                    .await
1601                    .map_err(|e| SqlError::DataFusion {
1602                        message: e.to_string(),
1603                    })?;
1604            }
1605        }
1606        sink.flush().await.map_err(|e| SqlError::DataFusion {
1607            message: e.to_string(),
1608        })?;
1609        Ok(total_rows)
1610    }
1611
1612    /// Configure this engine with explicit UDF resource limits (Track E).
1613    /// When set, calls to `sql()` and direct UDF syncs will use these budgets
1614    /// instead of unlimited defaults. Intended for job-specific engines.
1615    pub fn with_udf_limits(mut self, limits: krishiv_plan::udf::ResourceLimits) -> Self {
1616        self.udf_limits = Some(limits);
1617        self
1618    }
1619
1620    /// Returns `true` if `table_name` is registered as an unbounded streaming source.
1621    pub fn is_streaming_source(&self, table_name: &str) -> bool {
1622        self.streaming_sources
1623            .read()
1624            .unwrap_or_else(|e| e.into_inner())
1625            .contains(table_name)
1626    }
1627
1628    /// Register a table name as a streaming source without creating a live connector.
1629    ///
1630    /// This is the test-safe alternative to [`register_kafka_source`]: it marks
1631    /// `table_name` in the `streaming_sources` set so that `is_streaming_query`
1632    /// returns `true` for queries that reference it, without constructing any
1633    /// broker connection. Useful for unit tests where a live Kafka broker is not
1634    /// available and rdkafka's log subsystem is not initialised.
1635    /// Returns [`SqlError::EmptyTableName`] if `table_name` is blank.
1636    pub fn register_streaming_source_name(&self, table_name: impl Into<String>) -> SqlResult<()> {
1637        let name: String = table_name.into();
1638        if name.trim().is_empty() {
1639            return Err(SqlError::EmptyTableName);
1640        }
1641        self.streaming_sources
1642            .write()
1643            .unwrap_or_else(|e| e.into_inner())
1644            .insert(name);
1645        self.has_streaming_sources.store(true, Ordering::Release);
1646        self.invalidate_plan_cache();
1647        Ok(())
1648    }
1649
1650    /// Remove a streaming source registration.
1651    ///
1652    /// Deregisters the table from DataFusion and removes it from the streaming-
1653    /// sources set. Invalidates the plan cache. Idempotent — deregistering a
1654    /// name that was never registered is not an error.
1655    pub fn deregister_streaming_source(&self, name: &str) -> SqlResult<()> {
1656        if name.trim().is_empty() {
1657            return Err(SqlError::EmptyTableName);
1658        }
1659        // Idempotent: ignore the Option return (None when table wasn't registered).
1660        let _ = self
1661            .context
1662            .deregister_table(name)
1663            .map_err(SqlError::from)?;
1664        {
1665            let mut sources = self
1666                .streaming_sources
1667                .write()
1668                .unwrap_or_else(|e| e.into_inner());
1669            sources.remove(name);
1670            if sources.is_empty() {
1671                self.has_streaming_sources.store(false, Ordering::Release);
1672            }
1673            // Invalidate while still holding the write lock so there is no window
1674            // between source removal and cache invalidation where a concurrent
1675            // is_streaming_query returns false but serves a stale cached plan (N5).
1676            self.invalidate_plan_cache();
1677        }
1678        Ok(())
1679    }
1680
1681    /// Shared live-table registry for `CREATE LIVE TABLE` DDL.
1682    pub fn live_table_registry(&self) -> &Arc<live_table::LiveTableRegistry> {
1683        &self.live_table_registry
1684    }
1685
1686    /// Shared incremental-view registry for `CREATE INCREMENTAL VIEW` DDL.
1687    pub fn incremental_view_registry(&self) -> &Arc<incremental_view::IncrementalViewRegistry> {
1688        &self.incremental_view_registry
1689    }
1690
1691    /// Shared pipeline registry for `CREATE SOURCE` / `CREATE SINK` DDL.
1692    pub fn pipeline_registry(&self) -> &Arc<pipeline_ddl::PipelineRegistry> {
1693        &self.pipeline_registry
1694    }
1695
1696    /// Shared operation registry for cancellation and progress reporting.
1697    pub fn operation_registry(&self) -> &Arc<OperationRegistry> {
1698        &self.operation_registry
1699    }
1700
1701    /// Drop a named table from the session context.
1702    ///
1703    /// Idempotent — dropping a name that was never registered is not an error.
1704    ///
1705    /// Also clears any streaming-source registration for `name`. This function
1706    /// used to drop the table from DataFusion and leave the name in
1707    /// `streaming_sources` forever, with `has_streaming_sources` latched true.
1708    /// Two consequences, and the second is the bad one:
1709    ///
1710    /// * the set grew without bound for the life of the engine, and
1711    /// * a name **re-registered as an ordinary batch table** stayed classified
1712    ///   as streaming. `DROP TABLE events; CREATE TABLE events AS SELECT …`
1713    ///   followed by a plain `SELECT` over `events` would be routed down the
1714    ///   streaming path by [`is_streaming_query`], which decides execution mode
1715    ///   purely from this set.
1716    ///
1717    /// `deregister_streaming_source` always did this cleanup; the two paths
1718    /// simply disagreed, and the generic one is the one most callers reach for.
1719    pub fn deregister_table(&self, name: &str) -> SqlResult<()> {
1720        if name.trim().is_empty() {
1721            return Err(SqlError::EmptyTableName);
1722        }
1723        let _ = self
1724            .context
1725            .deregister_table(name)
1726            .map_err(SqlError::from)?;
1727        {
1728            let mut sources = self
1729                .streaming_sources
1730                .write()
1731                .unwrap_or_else(|e| e.into_inner());
1732            sources.remove(name);
1733            if sources.is_empty() {
1734                self.has_streaming_sources.store(false, Ordering::Release);
1735            }
1736            // Invalidated under the write lock, as in
1737            // `deregister_streaming_source`: otherwise there is a window where
1738            // a concurrent `is_streaming_query` sees the name gone but still
1739            // serves a stale cached plan built for the streaming shape (N5).
1740            self.invalidate_plan_cache();
1741        }
1742        Ok(())
1743    }
1744
1745    /// Register a table UDF backed by a Rust closure.
1746    ///
1747    /// The closure receives literal arguments passed by the SQL caller as
1748    /// `ScalarValue` values and returns an Arrow `RecordBatch`. Non-literal
1749    /// arguments are rejected because they cannot be evaluated safely at the
1750    /// synchronous DataFusion table-function boundary. `schema` describes the
1751    /// output columns.
1752    ///
1753    /// # Example
1754    /// ```ignore
1755    /// engine.register_table_udf_fn(
1756    ///     "generate_ints",
1757    ///     Schema::new(vec![Field::new("n", DataType::Int64, false)]),
1758    ///     |args| {
1759    ///         let count = match args.first() {
1760    ///             Some(ScalarValue::Int64(n)) => *n,
1761    ///             _ => 10,
1762    ///         };
1763    ///         let arr = Int64Array::from((0..count).collect::<Vec<_>>());
1764    ///         Ok(RecordBatch::try_from_iter([("n", Arc::new(arr) as _)])?)
1765    ///     },
1766    /// )?;
1767    /// ```
1768    pub fn register_table_udf_fn(
1769        &self,
1770        name: impl Into<String>,
1771        schema: arrow::datatypes::Schema,
1772        f: impl Fn(
1773            &[krishiv_plan::udf::ScalarValue],
1774        ) -> Result<arrow::record_batch::RecordBatch, krishiv_plan::udf::UdfError>
1775        + Send
1776        + Sync
1777        + 'static,
1778    ) -> SqlResult<()> {
1779        let udf =
1780            create_function_ddl::ClosureTableUdf::try_new(name, schema, std::sync::Arc::new(f))
1781                .map_err(|error| SqlError::InvalidTableFunction {
1782                    message: error.to_string(),
1783                })?;
1784        if let Some(registry) = &self.udf_registry {
1785            let mut guard = registry.write().map_err(|e| SqlError::DataFusion {
1786                message: e.to_string(),
1787            })?;
1788            guard.register_table(std::sync::Arc::new(udf.clone()));
1789        }
1790        udf::register_single_table_udf(&self.context, std::sync::Arc::new(udf))
1791            .map_err(SqlError::from)
1792    }
1793
1794    /// Returns `true` if any table referenced in `sql` is a registered streaming source.
1795    pub fn is_streaming_query(&self, sql: &str) -> SqlResult<bool> {
1796        // Fast-path: avoid the RwLock acquire and SQL parse for the common case
1797        // where no streaming sources have ever been registered (pure batch engines).
1798        if !self.has_streaming_sources.load(Ordering::Acquire) {
1799            return Ok(false);
1800        }
1801        let sources = self
1802            .streaming_sources
1803            .read()
1804            .unwrap_or_else(|e| e.into_inner());
1805        if sources.is_empty() {
1806            return Ok(false);
1807        }
1808        let dialect = GenericDialect {};
1809        let statements = Parser::parse_sql(&dialect, sql).map_err(|e| SqlError::DataFusion {
1810            message: e.to_string(),
1811        })?;
1812        for stmt in &statements {
1813            let mut is_streaming = false;
1814            let _ = visit_relations(stmt, |relation| {
1815                // relation.to_string() yields the fully-qualified name (e.g. "schema.table").
1816                // Extract the unqualified table name (last segment after dot).
1817                let full = relation.to_string();
1818                let table_name = full.split('.').next_back().unwrap_or(&full);
1819                if sources.contains(table_name) {
1820                    is_streaming = true;
1821                    return ControlFlow::Break(());
1822                }
1823                ControlFlow::Continue(())
1824            });
1825            if is_streaming {
1826                return Ok(true);
1827            }
1828        }
1829        Ok(false)
1830    }
1831
1832    /// Shared Krishiv catalog backing this engine, if configured.
1833    pub fn krishiv_catalog(&self) -> Option<&Arc<RwLock<InMemoryCatalog>>> {
1834        self.krishiv_catalog.as_ref()
1835    }
1836
1837    /// Register an Iceberg [`KrishivCatalog`] as a DataFusion catalog provider.
1838    ///
1839    /// Tables in the catalog are resolved automatically by DataFusion when SQL
1840    /// queries reference `<catalog_name>.<namespace>.<table>`. The bridge uses
1841    /// `plan_files()` to enumerate Parquet files and wraps them in a
1842    /// `ListingTable`, giving DataFusion native projection/filter pushdown.
1843    ///
1844    /// Multiple catalogs can be registered under different names.
1845    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
1846    #[must_use]
1847    pub fn with_iceberg_catalog(
1848        self,
1849        catalog: std::sync::Arc<catalog::unified::KrishivCatalog>,
1850        catalog_name: impl Into<String>,
1851    ) -> Self {
1852        self.register_iceberg_catalog(catalog, catalog_name);
1853        self
1854    }
1855
1856    /// Register an Iceberg [`KrishivCatalog`] on an already-built engine.
1857    ///
1858    /// Non-consuming twin of [`Self::with_iceberg_catalog`] for callers that
1859    /// only hold a shared reference (e.g. the Flight SQL daemon attaching a
1860    /// platform REST catalog at startup). Invalidates the plan cache so
1861    /// statements planned before registration cannot pin the old schema view.
1862    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
1863    pub fn register_iceberg_catalog(
1864        &self,
1865        catalog: std::sync::Arc<catalog::unified::KrishivCatalog>,
1866        catalog_name: impl Into<String>,
1867    ) {
1868        let catalog_name = catalog_name.into();
1869        let bridge = catalog::iceberg_catalog_bridge::IcebergCatalogBridge::new(
1870            Arc::clone(&catalog),
1871            catalog_name.clone(),
1872        );
1873        self.context
1874            .register_catalog(catalog_name.clone(), Arc::new(bridge));
1875        self.iceberg_catalogs
1876            .write()
1877            .unwrap_or_else(|e| e.into_inner())
1878            .push((catalog, catalog_name));
1879        self.invalidate_plan_cache();
1880    }
1881
1882    /// Register a platform Iceberg REST catalog from the `KRISHIV_ICEBERG_REST_*`
1883    /// environment (URI / WAREHOUSE / TOKEN / NAME) when `KRISHIV_ICEBERG_REST_URI`
1884    /// is set, so governed `catalog.namespace.table` references resolve on *this*
1885    /// engine. Returns whether a catalog was registered.
1886    ///
1887    /// Unlike the Flight host's registration (which only fires in InProcess mode),
1888    /// this is callable from the per-task engines the executor builds for
1889    /// coordinated batch SQL — closing the coordinator-mode catalog gap so
1890    /// `SELECT … FROM krishiv.<ns>.<table>` works on the split (coordinator +
1891    /// platformd) topology. `s3://` warehouses are read through `KrishivStorage`.
1892    /// No-op returning `Ok(false)` when built without the `rest-catalog` feature.
1893    pub async fn register_iceberg_rest_catalog_from_env(&self) -> Result<bool, String> {
1894        #[cfg(feature = "rest-catalog")]
1895        {
1896            let uri = match std::env::var("KRISHIV_ICEBERG_REST_URI") {
1897                Ok(uri) => uri,
1898                Err(_) => return Ok(false),
1899            };
1900            let warehouse = std::env::var("KRISHIV_ICEBERG_REST_WAREHOUSE").unwrap_or_default();
1901            let token = std::env::var("KRISHIV_ICEBERG_REST_TOKEN").ok();
1902            // The platform's canonical governed catalog is `main` (every pipeline
1903            // SQL, guard, and permission references `main.<ns>.<table>`). Default
1904            // to that so coordinator-mode `SELECT … FROM main.…` resolves without
1905            // per-deploy env; `KRISHIV_ICEBERG_REST_NAME` still overrides.
1906            let name =
1907                std::env::var("KRISHIV_ICEBERG_REST_NAME").unwrap_or_else(|_| String::from("main"));
1908            // When the warehouse is object-store-backed, register an S3 store on
1909            // this engine's DataFusion runtime so the `ListingTable` the catalog
1910            // bridge builds can *scan* the Parquet data files (iceberg FileIO
1911            // only covers metadata reads). Without this a correctly-named S3
1912            // table resolves but fails at scan with "no object store for s3://".
1913            self.register_s3_object_store_for_warehouse(&warehouse)?;
1914            let catalog = std::sync::Arc::new(
1915                catalog::unified::KrishivCatalog::rest(&uri, &warehouse, token.as_deref())
1916                    .await
1917                    .map_err(|e| format!("iceberg REST catalog at {uri}: {e}"))?,
1918            );
1919            self.register_iceberg_catalog(std::sync::Arc::clone(&catalog), &name);
1920            // Back-compat alias: parts of the surface (console sample queries,
1921            // some example jobs, older docs) still qualify governed tables as
1922            // `krishiv.<ns>.<table>` from a half-finished `krishiv`→`main` rename.
1923            // Register the same catalog under `krishiv` too so both resolve.
1924            // Harmless when the primary name already is `krishiv`.
1925            if name != "krishiv" {
1926                self.register_iceberg_catalog(catalog, "krishiv");
1927            }
1928            Ok(true)
1929        }
1930        #[cfg(not(feature = "rest-catalog"))]
1931        {
1932            Ok(false)
1933        }
1934    }
1935
1936    /// Share a session UDF registry so scalar UDFs are visible in SQL.
1937    #[must_use]
1938    pub fn with_udf_registry(
1939        mut self,
1940        registry: std::sync::Arc<std::sync::RwLock<krishiv_plan::udf::UdfRegistry>>,
1941    ) -> Self {
1942        self.udf_registry = Some(registry);
1943        // Mark UDFs as dirty so the first sql() call syncs them.
1944        self.bump_udf_version();
1945        self
1946    }
1947
1948    /// Increment the UDF version counter to signal that `sync_all_udfs()` is
1949    /// needed on the next `sql()` call.
1950    pub(crate) fn bump_udf_version(&self) {
1951        self.udf_registry_version.fetch_add(1, Ordering::Release);
1952    }
1953
1954    /// Invalidate the plan cache after any schema change. Call this whenever a
1955    /// table is registered, replaced, or deregistered. Full invalidation is
1956    /// simpler and safer than per-table tracking: the cache refills quickly on
1957    /// the next few queries.
1958    fn invalidate_plan_cache(&self) {
1959        match self.plan_cache.lock() {
1960            Ok(mut cache) => cache.clear(),
1961            Err(poisoned) => poisoned.into_inner().clear(),
1962        }
1963    }
1964
1965    /// Expose cache invalidation for tests and external callers that register
1966    /// tables through a different path.
1967    pub fn clear_plan_cache(&self) {
1968        self.invalidate_plan_cache();
1969    }
1970
1971    /// Register all scalar UDFs from the attached registry with DataFusion.
1972    /// Uses unlimited defaults (backward compat).
1973    /// Extract Python UDF/UDAF directive comments from `sql`, register each on
1974    /// this engine, and return `sql` with the directives removed. Handles both
1975    /// `/* krishiv-register-python-udf:name:in,…:out:pickle_b64 */` (scalar, via
1976    /// [`Self::register_python_udf`]) and
1977    /// `/* krishiv-register-python-udaf:… */` (aggregate, via
1978    /// [`Self::register_python_udaf`]). This is how a Python UDF shipped inside a
1979    /// fragment's SQL reaches the executor's fresh per-task engine. No-op for SQL
1980    /// that carries none.
1981    pub async fn register_python_udfs_from_sql(&self, sql: &str) -> SqlResult<String> {
1982        const SCALAR_PREFIX: &str = "/* krishiv-register-python-udf:";
1983        const AGG_PREFIX: &str = "/* krishiv-register-python-udaf:";
1984        if !sql.contains(SCALAR_PREFIX) && !sql.contains(AGG_PREFIX) {
1985            return Ok(sql.to_string());
1986        }
1987        let mut out = String::with_capacity(sql.len());
1988        let mut rest = sql;
1989        loop {
1990            // Find whichever directive appears first (aggregate and scalar
1991            // prefixes are disjoint: "udaf:" never contains "udf:").
1992            let agg = rest.find(AGG_PREFIX).map(|i| (i, true, AGG_PREFIX.len()));
1993            let scalar = rest
1994                .find(SCALAR_PREFIX)
1995                .map(|i| (i, false, SCALAR_PREFIX.len()));
1996            let next = match (agg, scalar) {
1997                (Some(a), Some(s)) => Some(if a.0 <= s.0 { a } else { s }),
1998                (Some(a), None) => Some(a),
1999                (None, Some(s)) => Some(s),
2000                (None, None) => None,
2001            };
2002            let Some((start, is_aggregate, prefix_len)) = next else {
2003                break;
2004            };
2005            out.push_str(&rest[..start]);
2006            let after = &rest[start + prefix_len..];
2007            let Some(end) = after.find(" */") else {
2008                // Unterminated — leave the remainder verbatim and stop.
2009                out.push_str(&rest[start..]);
2010                return Ok(out);
2011            };
2012            self.register_python_udf_directive(&after[..end], is_aggregate)
2013                .await?;
2014            rest = &after[end + " */".len()..];
2015        }
2016        out.push_str(rest);
2017        Ok(out)
2018    }
2019
2020    /// Parse and register one `name:in1,in2:out:pickle_b64` directive body as a
2021    /// scalar or aggregate Python UDF.
2022    async fn register_python_udf_directive(&self, body: &str, is_aggregate: bool) -> SqlResult<()> {
2023        use base64::Engine as _;
2024        let mut parts = body.splitn(4, ':');
2025        let (name, in_types, out_type, pickle_b64) =
2026            match (parts.next(), parts.next(), parts.next(), parts.next()) {
2027                (Some(n), Some(i), Some(o), Some(p)) => (n, i, o, p),
2028                _ => {
2029                    return Err(SqlError::DataFusion {
2030                        message: "malformed python-udf directive".into(),
2031                    });
2032                }
2033            };
2034        let input_types: Vec<String> = if in_types.is_empty() {
2035            Vec::new()
2036        } else {
2037            in_types.split(',').map(str::to_string).collect()
2038        };
2039        let pickle = base64::engine::general_purpose::STANDARD
2040            .decode(pickle_b64)
2041            .map_err(|e| SqlError::DataFusion {
2042                message: format!("invalid python-udf pickle base64: {e}"),
2043            })?;
2044        if is_aggregate {
2045            self.register_python_udaf(name, &pickle, &input_types, out_type)
2046                .await
2047        } else {
2048            self.register_python_udf(name, &pickle, &input_types, out_type)
2049                .await
2050        }
2051    }
2052
2053    /// Register a distributed Python UDF from cloudpickled bytes. The callable
2054    /// runs in a shared `python3` worker subprocess ([`crate::python_udf`]) — so
2055    /// it works on the Rust executors with no embedded interpreter. `input_types`
2056    /// / `output_type` are Arrow type names. Requires a UDF registry on this
2057    /// engine (executors' task engines set one).
2058    pub async fn register_python_udf(
2059        &self,
2060        name: &str,
2061        pickle: &[u8],
2062        input_types: &[String],
2063        output_type: &str,
2064    ) -> SqlResult<()> {
2065        use arrow::datatypes::{Field, Schema};
2066        let Some(registry) = &self.udf_registry else {
2067            return Err(SqlError::DataFusion {
2068                message: "cannot register a python UDF: engine has no UDF registry".into(),
2069            });
2070        };
2071        let input_fields: Vec<Field> = input_types
2072            .iter()
2073            .enumerate()
2074            .map(|(i, t)| Field::new(format!("a{i}"), python_udf_arrow_type(t), true))
2075            .collect();
2076        let input_schema = Schema::new(input_fields);
2077        let output_field = Field::new("out", python_udf_arrow_type(output_type), true);
2078        let pool = crate::python_udf::global_pool().map_err(|e| SqlError::DataFusion {
2079            message: format!("python UDF worker unavailable: {e:?}"),
2080        })?;
2081        let udf = std::sync::Arc::new(crate::python_udf::PythonWorkerUdf::new(
2082            name,
2083            pickle.to_vec(),
2084            input_schema,
2085            output_field,
2086            pool,
2087        ));
2088        registry
2089            .write()
2090            .map_err(|e| SqlError::DataFusion {
2091                message: e.to_string(),
2092            })?
2093            .register_scalar(udf);
2094        self.udf_registry_version
2095            .fetch_add(1, std::sync::atomic::Ordering::Release);
2096        self.sync_scalar_udfs().await
2097    }
2098
2099    /// Register a distributed Python **aggregate** UDF (GROUPED_AGG) from
2100    /// cloudpickled bytes. The callable buffers a group's rows and runs once at
2101    /// finalize inside the shared `python3` worker, so it works on the Rust
2102    /// executors and merges correctly across partitions/executors (two-phase
2103    /// aggregation). `input_types`/`output_type` are Arrow type names. Requires
2104    /// a UDF registry on this engine (executors' task engines set one).
2105    pub async fn register_python_udaf(
2106        &self,
2107        name: &str,
2108        pickle: &[u8],
2109        input_types: &[String],
2110        output_type: &str,
2111    ) -> SqlResult<()> {
2112        use arrow::datatypes::{Field, Schema};
2113        let Some(registry) = &self.udf_registry else {
2114            return Err(SqlError::DataFusion {
2115                message: "cannot register a python UDAF: engine has no UDF registry".into(),
2116            });
2117        };
2118        let input_fields: Vec<Field> = input_types
2119            .iter()
2120            .enumerate()
2121            .map(|(i, t)| Field::new(format!("a{i}"), python_udf_arrow_type(t), true))
2122            .collect();
2123        let input_schema = Schema::new(input_fields);
2124        let output_field = Field::new("out", python_udf_arrow_type(output_type), true);
2125        let pool = crate::python_udf::global_pool().map_err(|e| SqlError::DataFusion {
2126            message: format!("python UDF worker unavailable: {e:?}"),
2127        })?;
2128        let udf = std::sync::Arc::new(crate::python_udf::PythonWorkerAggregateUdf::new(
2129            name,
2130            pickle.to_vec(),
2131            input_schema,
2132            output_field,
2133            pool,
2134        ));
2135        registry
2136            .write()
2137            .map_err(|e| SqlError::DataFusion {
2138                message: e.to_string(),
2139            })?
2140            .register_aggregate(udf);
2141        self.udf_registry_version
2142            .fetch_add(1, std::sync::atomic::Ordering::Release);
2143        self.sync_aggregate_udfs().await
2144    }
2145
2146    pub async fn sync_scalar_udfs(&self) -> SqlResult<()> {
2147        let Some(registry) = &self.udf_registry else {
2148            return Ok(());
2149        };
2150        let guard = registry.read().map_err(|e| SqlError::DataFusion {
2151            message: e.to_string(),
2152        })?;
2153        let limits = self.udf_limits.clone().unwrap_or_default();
2154        udf::sync_scalar_udfs_with_limits(&self.context, &guard, limits).map_err(|e| {
2155            SqlError::DataFusion {
2156                message: e.to_string(),
2157            }
2158        })
2159    }
2160
2161    /// Register scalar UDFs with explicit ResourceLimits for sandbox enforcement.
2162    /// Callers that have a job context (scheduler, runner, api session for a job)
2163    /// should use this and pass limits derived from the JobSpec (memory + time cap).
2164    /// This is the concrete Track E seam from job limits to UDF execution.
2165    pub async fn sync_scalar_udfs_with_limits(
2166        &self,
2167        limits: krishiv_plan::udf::ResourceLimits,
2168    ) -> SqlResult<()> {
2169        self.sync_scalar_udfs_with_limits_for_profile(
2170            limits,
2171            krishiv_common::resolve_durability_profile(),
2172        )
2173        .await
2174    }
2175
2176    /// Register scalar UDFs using a caller-resolved durability profile.
2177    pub async fn sync_scalar_udfs_with_limits_for_profile(
2178        &self,
2179        limits: krishiv_plan::udf::ResourceLimits,
2180        profile: krishiv_common::DurabilityProfile,
2181    ) -> SqlResult<()> {
2182        self.sync_scalar_udfs_with_limits_for_policy(
2183            limits,
2184            krishiv_common::NativeScalarUdfPolicy::resolve(profile),
2185        )
2186        .await
2187    }
2188
2189    /// Register scalar UDFs using a caller-snapshotted policy decision.
2190    pub async fn sync_scalar_udfs_with_limits_for_policy(
2191        &self,
2192        limits: krishiv_plan::udf::ResourceLimits,
2193        policy: krishiv_common::NativeScalarUdfPolicy,
2194    ) -> SqlResult<()> {
2195        let Some(registry) = &self.udf_registry else {
2196            return Ok(());
2197        };
2198        let guard = registry.read().map_err(|e| SqlError::DataFusion {
2199            message: e.to_string(),
2200        })?;
2201        udf::sync_scalar_udfs_with_limits_for_policy(&self.context, &guard, limits, policy).map_err(
2202            |e| SqlError::DataFusion {
2203                message: e.to_string(),
2204            },
2205        )
2206    }
2207
2208    /// Register aggregate UDFs from the attached registry (P1-21).
2209    pub async fn sync_aggregate_udfs(&self) -> SqlResult<()> {
2210        let Some(registry) = &self.udf_registry else {
2211            return Ok(());
2212        };
2213        let guard = registry.read().map_err(|e| SqlError::DataFusion {
2214            message: e.to_string(),
2215        })?;
2216        udf::sync_aggregate_udfs(&self.context, &guard).map_err(|e| SqlError::DataFusion {
2217            message: e.to_string(),
2218        })
2219    }
2220
2221    /// Register table UDFs from the attached registry (P1-21).
2222    pub async fn sync_table_udfs(&self) -> SqlResult<()> {
2223        let Some(registry) = &self.udf_registry else {
2224            return Ok(());
2225        };
2226        let guard = registry.read().map_err(|e| SqlError::DataFusion {
2227            message: e.to_string(),
2228        })?;
2229        udf::sync_table_udfs(&self.context, &guard).map_err(|e| SqlError::DataFusion {
2230            message: e.to_string(),
2231        })
2232    }
2233
2234    /// Sync all UDF categories, respecting any limits configured on this engine (Track E).
2235    pub async fn sync_all_udfs(&self) -> SqlResult<()> {
2236        self.sync_scalar_udfs().await?;
2237        self.sync_aggregate_udfs().await?;
2238        self.sync_table_udfs().await?;
2239        Ok(())
2240    }
2241
2242    /// Register an S3/MinIO object store on this engine's DataFusion runtime for
2243    /// an `s3://`/`s3a://`-scheme `path` (a warehouse root or a data-file URI), so
2244    /// `ListingTable` scans — including those the Iceberg catalog bridge builds
2245    /// for governed tables — can read Parquet from object storage. No-op for
2246    /// non-object-store paths. Idempotent: re-registering a bucket replaces the
2247    /// prior store.
2248    pub(crate) fn register_s3_object_store_for_warehouse(&self, path: &str) -> Result<(), String> {
2249        if !(path.starts_with("s3://") || path.starts_with("s3a://")) {
2250            return Ok(());
2251        }
2252        let url = url::Url::parse(path).map_err(|e| format!("invalid s3 url {path}: {e}"))?;
2253        let bucket = url.host_str().unwrap_or_default();
2254        // DataFusion keys object stores by scheme+authority (`s3://bucket`).
2255        let store_url = url::Url::parse(&format!("s3://{bucket}"))
2256            .map_err(|e| format!("invalid s3 bucket url: {e}"))?;
2257        let store = build_s3_object_store(bucket).map_err(|e| format!("s3 store init: {e}"))?;
2258        self.context.register_object_store(&store_url, store);
2259        Ok(())
2260    }
2261
2262    /// Register a local Parquet path as a table.
2263    pub async fn register_parquet(
2264        &self,
2265        table_name: impl AsRef<str>,
2266        path: impl AsRef<Path>,
2267    ) -> SqlResult<()> {
2268        self.register_parquet_with_primary_key(table_name, path, &[] as &[String])
2269            .await
2270    }
2271
2272    /// As [`Self::register_parquet`], declaring a primary key for the table.
2273    ///
2274    /// Parquet carries no key, so this declaration is the only way the optimizer
2275    /// can learn that one column determines the others — which is what unlocks
2276    /// functional-dependency group-by pruning and the late-materialisation
2277    /// join-back (`crate::late_materialize`, measured at 14.8x on TPC-H q10 at
2278    /// SF100).
2279    ///
2280    /// **Informational and unverified**, exactly as Spark/Databricks `RELY`:
2281    /// nothing reads the data to check it, and a key that is not unique or not
2282    /// non-null produces *wrong answers*, not slow ones.
2283    ///
2284    /// This exists so the embedded engine can be told the same thing the
2285    /// distributed one is told through `BatchSqlTable::primary_key`. A
2286    /// declaration that reached only one of the two would mean the two
2287    /// topologies silently plan different queries — the hardest kind of
2288    /// difference to notice, because both still return correct results.
2289    pub async fn register_parquet_with_primary_key<S: AsRef<str>>(
2290        &self,
2291        table_name: impl AsRef<str>,
2292        path: impl AsRef<Path>,
2293        primary_key: &[S],
2294    ) -> SqlResult<()> {
2295        let table_name = table_name.as_ref();
2296        if table_name.trim().is_empty() {
2297            return Err(SqlError::EmptyTableName);
2298        }
2299
2300        let path = path.as_ref().to_string_lossy().into_owned();
2301
2302        // Register an S3 ObjectStore when the path is an s3:// URL so DataFusion
2303        // can read remote Parquet files transparently.
2304        self.register_s3_object_store_for_warehouse(&path)
2305            .map_err(|message| SqlError::DataFusion { message })?;
2306
2307        if self
2308            .context
2309            .table_exist(table_name)
2310            .map_err(SqlError::from)?
2311        {
2312            let _ = self
2313                .context
2314                .deregister_table(table_name)
2315                .map_err(SqlError::from)?;
2316        }
2317        // One registration path, not two. `register_parquet_table` is what the
2318        // coordinator uses for staged planning; routing the embedded engine
2319        // through the same function is what keeps "declared key" from meaning
2320        // two different things depending on where the query runs — including
2321        // its error for a key column that is not in the file's schema, which
2322        // would otherwise be a silent no-op here.
2323        let spec = crate::distributed_plan::ParquetTableSpec::new(table_name, path)
2324            .with_primary_key(primary_key.iter().map(|c| c.as_ref().to_owned()));
2325        crate::distributed_plan::register_parquet_table(&self.context, &spec).await?;
2326        // Extract estimated row count from table provider statistics.
2327        if let Ok(provider) = self.context.table_provider(table_name).await
2328            && let Some(stats) = provider.statistics()
2329            && let Some(n) = stats.num_rows.get_value()
2330            && let Ok(mut counts) = self.table_row_counts.write()
2331        {
2332            counts.insert(table_name.to_string(), *n as u64);
2333        }
2334        self.invalidate_plan_cache();
2335        Ok(())
2336    }
2337
2338    /// Create a DataFrame by reading a local Parquet path directly.
2339    pub async fn read_parquet(&self, path: impl AsRef<Path>) -> SqlResult<SqlDataFrame> {
2340        let path = path.as_ref().to_string_lossy().into_owned();
2341        let dataframe = self
2342            .context
2343            .read_parquet(path, ParquetReadOptions::default())
2344            .await?;
2345        Ok(self.make_sql_df("parquet-read", dataframe))
2346    }
2347
2348    /// Register an in-memory table from Arrow record batches.
2349    ///
2350    /// The schema is inferred from the first batch. An empty `batches` slice
2351    /// registers a table with no rows using the provided schema if the batches
2352    /// are non-empty, or is a no-op if empty.
2353    pub async fn register_record_batches(
2354        &self,
2355        table_name: impl AsRef<str>,
2356        batches: Vec<RecordBatch>,
2357    ) -> SqlResult<()> {
2358        use std::sync::Arc;
2359        let table_name = table_name.as_ref();
2360        if table_name.trim().is_empty() {
2361            return Err(SqlError::EmptyTableName);
2362        }
2363        if batches.is_empty() {
2364            return Ok(());
2365        }
2366        let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
2367        let schema = batches
2368            .first()
2369            .ok_or_else(|| SqlError::DataFusion {
2370                message: "empty batch list".into(),
2371            })?
2372            .schema();
2373        let mem_table =
2374            datafusion::datasource::MemTable::try_new(schema, vec![batches]).map_err(|e| {
2375                SqlError::DataFusion {
2376                    message: e.to_string(),
2377                }
2378            })?;
2379        if self
2380            .context
2381            .table_exist(table_name)
2382            .map_err(SqlError::from)?
2383        {
2384            let _ = self
2385                .context
2386                .deregister_table(table_name)
2387                .map_err(SqlError::from)?;
2388        }
2389        self.context
2390            .register_table(table_name, Arc::new(mem_table))
2391            .map_err(|e| SqlError::DataFusion {
2392                message: e.to_string(),
2393            })?;
2394        if total_rows > 0
2395            && let Ok(mut counts) = self.table_row_counts.write()
2396        {
2397            counts.insert(table_name.to_string(), total_rows as u64);
2398        }
2399        self.invalidate_plan_cache();
2400        Ok(())
2401    }
2402
2403    /// Create a DataFrame by reading a local Parquet path with typed options.
2404    pub async fn read_parquet_with_options(
2405        &self,
2406        path: impl AsRef<Path>,
2407        opts: &ParquetReaderOptions,
2408    ) -> SqlResult<SqlDataFrame> {
2409        let path = path.as_ref().to_string_lossy().into_owned();
2410        let mut options = datafusion::prelude::ParquetReadOptions::default();
2411        if opts.batch_size.is_some() {
2412            options = options.parquet_pruning(true);
2413        }
2414        // NOTE: `batch_size` is not yet propagated here because DataFusion's
2415        // ParquetReadOptions has no batch_size field — it lives on SessionConfig.
2416        // Callers should set batch_size on the SqlEngine's session config before
2417        // calling this method (via `SessionContext::new_with_state` with a config
2418        // that has `execution.batch_size` set).
2419        let dataframe = self.context.read_parquet(path, options).await?;
2420        Ok(self.make_sql_df("parquet-read", dataframe))
2421    }
2422
2423    /// Create a DataFrame by reading a local CSV path directly.
2424    pub async fn read_csv(&self, path: impl AsRef<Path>) -> SqlResult<SqlDataFrame> {
2425        self.read_csv_with_options(path, &CsvReaderOptions::default())
2426            .await
2427    }
2428
2429    /// Create a DataFrame by reading a local CSV path with typed options.
2430    pub async fn read_csv_with_options(
2431        &self,
2432        path: impl AsRef<Path>,
2433        opts: &CsvReaderOptions,
2434    ) -> SqlResult<SqlDataFrame> {
2435        let path = path.as_ref().to_string_lossy().into_owned();
2436        let mut options = datafusion::prelude::CsvReadOptions::new();
2437        if let Some(delim) = opts.delimiter {
2438            options = options.delimiter(delim as u8);
2439        }
2440        if let Some(has_header) = opts.has_header {
2441            options = options.has_header(has_header);
2442        }
2443        let dataframe = self.context.read_csv(path, options).await?;
2444        Ok(self.make_sql_df("csv-read", dataframe))
2445    }
2446
2447    /// Create a DataFrame by reading a local JSON/NDJSON path directly.
2448    pub async fn read_json(&self, path: impl AsRef<Path>) -> SqlResult<SqlDataFrame> {
2449        let path = path.as_ref().to_string_lossy().into_owned();
2450        let dataframe = self
2451            .context
2452            .read_json(path, datafusion::prelude::JsonReadOptions::default())
2453            .await?;
2454        Ok(self.make_sql_df("json-read", dataframe))
2455    }
2456
2457    /// Read a local Delta table directory into a DataFrame.
2458    pub async fn read_delta(
2459        &self,
2460        path: impl AsRef<str>,
2461        version: Option<i64>,
2462    ) -> SqlResult<SqlDataFrame> {
2463        let path = path.as_ref();
2464        let base = path.replace(['/', '.', '-'], "_");
2465        let table = match version {
2466            Some(v) => format!("delta_{base}_v{v}"),
2467            None => format!("delta_{base}"),
2468        };
2469        lakehouse::register_delta_uri(&self.context, &table, path, version).await?;
2470        self.sql(format!("SELECT * FROM {table}")).await
2471    }
2472
2473    /// Read a Hudi table directory.
2474    pub async fn read_hudi(
2475        &self,
2476        path: impl AsRef<str>,
2477        query_type: krishiv_connectors::lakehouse::HudiQueryType,
2478        begin_instant: Option<&str>,
2479    ) -> SqlResult<SqlDataFrame> {
2480        let path = path.as_ref();
2481        let table = format!("hudi_{}", path.replace(['/', '.', '-'], "_"));
2482        lakehouse::register_hudi_uri(&self.context, &table, path, query_type, begin_instant)
2483            .await?;
2484        self.sql(format!("SELECT * FROM {table}")).await
2485    }
2486
2487    /// Plan a SQL query with DataFusion.
2488    ///
2489    /// Returns a boxed future ON PURPOSE (do not revert to `async fn`): this
2490    /// body is one of the largest async state machines in the workspace, and
2491    /// as an opaque `impl Future` its `Send` proof leaked into every CONSUMER
2492    /// crate — rustc's old trait solver re-derived it per call site, which
2493    /// drove krishiv-executor's front-end to 20+ hour compiles (95% of CPU in
2494    /// `ObligationForest::process_obligations`, bisected 2026-07-19). Boxing
2495    /// at the definition pays that proof once, here.
2496    pub fn sql<'a>(
2497        &'a self,
2498        query: impl AsRef<str> + Send + 'a,
2499    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = SqlResult<SqlDataFrame>> + Send + 'a>>
2500    {
2501        let query: String = query.as_ref().to_owned();
2502        Box::pin(self.sql_boxed_body(query))
2503    }
2504
2505    /// The real `sql` body — see the boxing note on [`Self::sql`].
2506    async fn sql_boxed_body(&self, query: String) -> SqlResult<SqlDataFrame> {
2507        let query = query.as_str();
2508        if query.trim().is_empty() {
2509            return Err(SqlError::EmptyQuery);
2510        }
2511
2512        // ── Multi-statement scripts ──────────────────────────────────────────
2513        // Distributed batch fragments are re-planned on a fresh engine per
2514        // assignment, so session state registered by an earlier Flight SQL
2515        // call (e.g. a `STORED AS JDBC` pull table) does not exist there. A
2516        // caller that needs setup DDL plus the statement that reads it must
2517        // ship both in one body; statements run sequentially in this engine
2518        // and the last statement's result is returned. A failing statement
2519        // aborts the script (statements already executed are not rolled back).
2520        let script = split_sql_statements(query);
2521        if script.len() > 1
2522            && let [setup @ .., last_stmt] = script.as_slice()
2523        {
2524            for stmt in setup {
2525                // DDL executes eagerly inside `sql()`, but DML comes back as a
2526                // lazy frame that only runs on collect — force each setup
2527                // statement to completion before anything that depends on it.
2528                Box::pin(self.sql(stmt.as_str())).await?.collect().await?;
2529            }
2530            return Box::pin(self.sql(last_stmt.as_str())).await;
2531        }
2532
2533        // Lazy UDF sync: only re-sync when the registry has changed since the
2534        // last sync. Avoids 3 RwLock reads per query when no UDFs are registered
2535        // or when the UDF set hasn't changed.
2536        {
2537            let current = self.udf_registry_version.load(Ordering::Acquire);
2538            let last = self.udf_last_synced_version.load(Ordering::Relaxed);
2539            if current != last {
2540                self.sync_all_udfs().await?;
2541                self.udf_last_synced_version
2542                    .store(current, Ordering::Release);
2543            }
2544        }
2545
2546        // ── Intercept DESCRIBE / SHOW COLUMNS / EXPLAIN ──────────────────────
2547        if let Some(stmt) = introspection_sql::parse_introspection_statement(query)? {
2548            return match stmt {
2549                introspection_sql::IntrospectionStatement::Describe { table } => {
2550                    let batch = introspection_sql::describe_table(&self.context, &table).await?;
2551                    let describe_table_name = next_ephemeral_name("describe_result");
2552                    lakehouse::register_scan_batches(
2553                        &self.context,
2554                        &describe_table_name,
2555                        vec![batch],
2556                    )
2557                    .await?;
2558                    let dataframe = self
2559                        .context
2560                        .sql(&format!("SELECT * FROM {describe_table_name}"))
2561                        .await?;
2562                    Ok(self.attach_query_metadata(self.make_sql_df("describe", dataframe), query))
2563                }
2564                introspection_sql::IntrospectionStatement::Explain { mode, query: inner } => {
2565                    let text = introspection_sql::explain_query(&inner, mode)?;
2566                    let batch = introspection_sql::explain_result_batch(&text)?;
2567                    let explain_table = next_ephemeral_name("explain_result");
2568                    lakehouse::register_scan_batches(&self.context, &explain_table, vec![batch])
2569                        .await?;
2570                    let dataframe = self
2571                        .context
2572                        .sql(&format!("SELECT * FROM {explain_table}"))
2573                        .await?;
2574                    Ok(self.attach_query_metadata(self.make_sql_df("explain", dataframe), query))
2575                }
2576            };
2577        }
2578
2579        // ── Intercept CREATE / REFRESH / DROP LIVE TABLE ─────────────────────
2580        if live_table::execute_live_table_ddl(&self.live_table_registry, query)?.is_some() {
2581            let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2582            return Ok(self.attach_query_metadata(self.make_sql_df("live-table-ddl", empty), query));
2583        }
2584
2585        // ── Intercept CREATE/DECLARE/REFRESH/DROP INCREMENTAL VIEW ───────────
2586        match incremental_view::execute_incremental_view_ddl(
2587            &self.incremental_view_registry,
2588            query,
2589        )? {
2590            Some(incremental_view::IncrementalViewResult::Refresh(_name)) => {
2591                // REFRESH requires the caller (Session) to re-run the pipeline.
2592                // Return a sentinel empty result so the caller knows to refresh.
2593                let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2594                return Ok(self.attach_query_metadata(
2595                    self.make_sql_df("incremental-view-refresh", empty),
2596                    query,
2597                ));
2598            }
2599            Some(_) => {
2600                let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2601                return Ok(self.attach_query_metadata(
2602                    self.make_sql_df("incremental-view-ddl", empty),
2603                    query,
2604                ));
2605            }
2606            None => {}
2607        }
2608
2609        // ── Intercept CREATE STREAMING TABLE … AS <streaming SELECT> ─────────
2610        // The body is validated through the shared streaming front door, so an
2611        // unsupported query fails at the planner. Running the job needs the
2612        // streaming coordinator, which the pure SQL engine does not have, so we
2613        // surface a clear, actionable error rather than a cryptic parse failure;
2614        // a cluster-attached session submits the validated plan via the
2615        // continuous-stream registration API.
2616        if let Some(ddl) = streaming_table_ddl::parse_create_streaming_table(query) {
2617            let _plan = streaming_window_plan::compile_streaming_window_sql(&ddl.query)?;
2618            return Err(SqlError::Unsupported {
2619                feature: format!(
2620                    "CREATE STREAMING TABLE '{}' compiled to a continuous plan, but this session \
2621                     has no streaming coordinator to run it; submit it via the continuous-stream \
2622                     registration API or a cluster-attached session",
2623                    ddl.name
2624                ),
2625            });
2626        }
2627
2628        // ── Intercept CREATE/DROP SOURCE / SINK (pipeline DDL) ───────────────
2629        // `START PIPELINE` is NOT handled here — it is executed by the
2630        // `krishiv-api` session, which can reach `Session::pipeline()`.
2631        if pipeline_ddl::execute_pipeline_ddl(&self.pipeline_registry, query)?.is_some() {
2632            let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2633            return Ok(self.attach_query_metadata(self.make_sql_df("pipeline-ddl", empty), query));
2634        }
2635
2636        // ── Intercept SET shuffle.partitions = N ─────────────────────────────
2637        // Krishiv-specific session config; DataFusion does not know about it.
2638        let trimmed = query.trim();
2639        if trimmed
2640            .to_ascii_uppercase()
2641            .starts_with("SET SHUFFLE.PARTITIONS")
2642        {
2643            let value = trimmed.split('=').nth(1).map(|s| s.trim()).unwrap_or("");
2644            match value.parse::<u32>() {
2645                Ok(n) if n > 0 => {
2646                    {
2647                        let mut guard =
2648                            self.shuffle_partitions
2649                                .write()
2650                                .map_err(|e| SqlError::DataFusion {
2651                                    message: e.to_string(),
2652                                })?;
2653                        *guard = Some(n);
2654                    }
2655                    let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2656                    return Ok(self.make_sql_df("set-shuffle-partitions", empty));
2657                }
2658                Ok(_) => {
2659                    {
2660                        let mut guard =
2661                            self.shuffle_partitions
2662                                .write()
2663                                .map_err(|e| SqlError::DataFusion {
2664                                    message: e.to_string(),
2665                                })?;
2666                        *guard = None;
2667                    }
2668                    let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2669                    return Ok(self.make_sql_df("set-shuffle-partitions", empty));
2670                }
2671                Err(_) => {
2672                    return Err(SqlError::DataFusion {
2673                        message: format!(
2674                            "invalid shuffle.partitions value '{value}'; expected a positive integer"
2675                        ),
2676                    });
2677                }
2678            }
2679        }
2680
2681        // ── Intercept USE / SHOW DATABASES (Phase 60 statement completion) ───
2682        // DataFusion does not plan `USE` (session catalog/schema navigation) or
2683        // `SHOW DATABASES`/`SHOW SCHEMAS`; handle both before the fallthrough.
2684        if let Some(result) = statement_completion::apply_use(&self.context, query) {
2685            result.map_err(|message| SqlError::DataFusion { message })?;
2686            let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2687            return Ok(self.attach_query_metadata(self.make_sql_df("use", empty), query));
2688        }
2689        if let Some(rewrite) = statement_completion::rewrite_show_databases(query) {
2690            let dataframe = self.context.sql(&rewrite).await?;
2691            return Ok(
2692                self.attach_query_metadata(self.make_sql_df("show-databases", dataframe), query)
2693            );
2694        }
2695
2696        // ── Intercept CREATE FUNCTION … RETURNS TABLE ────────────────────────
2697        // DataFusion does not understand this extended DDL syntax. Parse and
2698        // register only executable LANGUAGE SQL definitions; unsupported
2699        // languages fail before any registry mutation.
2700        if create_function_ddl::is_create_function_returns_table(query) {
2701            let ddl = create_function_ddl::parse_create_function(query)
2702                .map_err(|message| SqlError::InvalidTableFunction { message })?;
2703            if ddl.language.as_deref() != Some("sql") {
2704                return Err(SqlError::Unsupported {
2705                    feature: format!(
2706                        "CREATE FUNCTION '{}' uses language {:?}; only LANGUAGE SQL AS '...' \
2707                         table functions are executable",
2708                        ddl.function_name, ddl.language
2709                    ),
2710                });
2711            }
2712            let body = ddl
2713                .body
2714                .as_deref()
2715                .filter(|body| !body.trim().is_empty())
2716                .ok_or_else(|| SqlError::InvalidTableFunction {
2717                    message: format!(
2718                        "SQL table function '{}' requires a non-empty AS body",
2719                        ddl.function_name
2720                    ),
2721                })?;
2722            let fields: Vec<_> = ddl
2723                .return_columns
2724                .iter()
2725                .map(|column| {
2726                    arrow::datatypes::Field::new(&column.name, column.data_type.clone(), true)
2727                })
2728                .collect();
2729            let schema = arrow::datatypes::Schema::new(fields);
2730            let udf: std::sync::Arc<dyn krishiv_plan::udf::TableUdf> = std::sync::Arc::new(
2731                create_function_ddl::SqlBodyTableUdf::try_new(
2732                    &ddl.function_name,
2733                    schema,
2734                    body,
2735                    ddl.arguments.len(),
2736                    std::sync::Arc::new(self.context.clone()),
2737                )
2738                .map_err(|error| SqlError::InvalidTableFunction {
2739                    message: error.to_string(),
2740                })?,
2741            );
2742            if let Some(registry) = &self.udf_registry {
2743                let mut guard = registry.write().map_err(|e| SqlError::DataFusion {
2744                    message: e.to_string(),
2745                })?;
2746                guard.register_table(std::sync::Arc::clone(&udf));
2747            }
2748            udf::register_single_table_udf(&self.context, std::sync::Arc::clone(&udf))
2749                .map_err(SqlError::from)?;
2750            let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2751            return Ok(
2752                self.attach_query_metadata(self.make_sql_df("create-function", empty), query)
2753            );
2754        }
2755
2756        if query
2757            .trim_start()
2758            .to_ascii_uppercase()
2759            .starts_with("MERGE INTO")
2760        {
2761            let batches = lakehouse::execute_merge_sql(&self.context, query).await?;
2762            let merge_table = next_ephemeral_name("merge_result");
2763            lakehouse::register_scan_batches(&self.context, &merge_table, batches).await?;
2764            let dataframe = self
2765                .context
2766                .sql(&format!("SELECT * FROM {merge_table}"))
2767                .await?;
2768            return Ok(self.attach_query_metadata(self.make_sql_df("merge", dataframe), query));
2769        }
2770
2771        // ── Intercept CREATE [OR REPLACE] TABLE <iceberg-table> AS <query> ───
2772        // Durable CTAS (gap G17): when the target resolves to a registered
2773        // Iceberg catalog, execute the inner query on this engine and land
2774        // the result stream directly in Iceberg (rolling Parquet parts +
2775        // snapshot commit) instead of materializing it as a session table.
2776        // The result is a single row of landing counts — the full result set
2777        // never crosses a wire. Targets that do not resolve to an Iceberg
2778        // catalog fall through to DataFusion's session-local CTAS.
2779        #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2780        if trimmed.to_ascii_uppercase().starts_with("CREATE ")
2781            && let Some(parsed_ctas) = parse_ctas(trimmed)
2782        {
2783            let resolved = self.resolve_iceberg_table(&parsed_ctas.table_ref);
2784            // PARTITIONED BY only has meaning for Iceberg targets; erroring
2785            // beats silently creating an unpartitioned session table.
2786            if resolved.is_none() && !parsed_ctas.partition_by.is_empty() {
2787                return Err(SqlError::DataFusion {
2788                    message: format!(
2789                        "PARTITIONED BY requires an Iceberg catalog table; `{}` does not \
2790                         resolve to a registered Iceberg catalog",
2791                        parsed_ctas.table_ref
2792                    ),
2793                });
2794            }
2795            if let Some((iceberg_catalog, table_ident)) = resolved {
2796                return self
2797                    .execute_iceberg_ctas(iceberg_catalog, table_ident, parsed_ctas, query)
2798                    .await;
2799            }
2800        }
2801
2802        // ── Intercept CALL system.<proc> ──────────────────────────────────────
2803        // Route Iceberg maintenance procedures to registered KrishivCatalogs.
2804        #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2805        if trimmed.to_ascii_uppercase().starts_with("CALL SYSTEM.") {
2806            let result = self.dispatch_call_system(trimmed).await?;
2807            let call_table = next_ephemeral_name("call_result");
2808            lakehouse::register_scan_batches(&self.context, &call_table, vec![result]).await?;
2809            let dataframe = self
2810                .context
2811                .sql(&format!("SELECT * FROM {call_table}"))
2812                .await?;
2813            return Ok(self.attach_query_metadata(self.make_sql_df("call", dataframe), query));
2814        }
2815
2816        // ── Intercept ANALYZE TABLE <ref> [FOR COLUMNS (c1, …)] ──────────────
2817        // Phase 54 statistics collection: one scan (COUNT(*) plus optional
2818        // per-column approx_distinct/min/max/null-count) feeding the engine's
2819        // row-count registry (BroadcastAutoRule) and the process-global
2820        // TableStatsRegistry (CBO / AQE cost model).
2821        if trimmed
2822            .get(..14)
2823            .is_some_and(|p| p.eq_ignore_ascii_case("ANALYZE TABLE "))
2824        {
2825            let result = self.dispatch_analyze_table(trimmed).await?;
2826            let res_table = next_ephemeral_name("analyze_result");
2827            lakehouse::register_scan_batches(&self.context, &res_table, vec![result]).await?;
2828            let dataframe = self
2829                .context
2830                .sql(&format!("SELECT * FROM {res_table}"))
2831                .await?;
2832            return Ok(self.attach_query_metadata(self.make_sql_df("analyze", dataframe), query));
2833        }
2834
2835        // ── Intercept DELETE FROM <iceberg-table> [WHERE …] ──────────────────
2836        // Route to copy-on-write iceberg_delete_where when the table is tracked
2837        // by a registered KrishivCatalog. Falls through to DataFusion otherwise.
2838        #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2839        if trimmed.to_ascii_uppercase().starts_with("DELETE FROM ")
2840            && let Some((table_ref, predicate)) = parse_dml_delete(trimmed)
2841            && let Some((iceberg_catalog, table_ident)) = self.resolve_iceberg_table(&table_ref)
2842        {
2843            use arrow::array::{ArrayRef, Int64Array};
2844            use arrow::datatypes::{DataType, Field, Schema};
2845            let (deleted, _) = krishiv_connectors::lakehouse::dml::iceberg_delete_where(
2846                iceberg_catalog,
2847                &table_ident,
2848                &predicate,
2849                &self.context,
2850            )
2851            .await
2852            .map_err(|e| SqlError::DataFusion {
2853                message: e.to_string(),
2854            })?;
2855            // Phase 54 auto-stats: keep any known row count in step.
2856            self.adjust_table_row_count_stat(&table_ref, -(deleted as i64));
2857            let schema = Arc::new(Schema::new(vec![Field::new(
2858                "deleted_rows",
2859                DataType::Int64,
2860                false,
2861            )]));
2862            let array: ArrayRef = Arc::new(Int64Array::from(vec![deleted as i64]));
2863            let batch =
2864                RecordBatch::try_new(schema, vec![array]).map_err(|e| SqlError::DataFusion {
2865                    message: e.to_string(),
2866                })?;
2867            let res_table = next_ephemeral_name("delete_result");
2868            lakehouse::register_scan_batches(&self.context, &res_table, vec![batch]).await?;
2869            let dataframe = self
2870                .context
2871                .sql(&format!("SELECT * FROM {res_table}"))
2872                .await?;
2873            return Ok(self.attach_query_metadata(self.make_sql_df("delete", dataframe), query));
2874        }
2875
2876        // ── Intercept UPDATE <iceberg-table> SET … [WHERE …] ─────────────────
2877        #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2878        if trimmed.to_ascii_uppercase().starts_with("UPDATE ")
2879            && let Some(parsed) = parse_dml_update(trimmed)
2880            && let Some((iceberg_catalog, table_ident)) =
2881                self.resolve_iceberg_table(&parsed.table_ref)
2882        {
2883            use arrow::array::{ArrayRef, Int64Array};
2884            use arrow::datatypes::{DataType, Field, Schema};
2885            let borrowed: Vec<(&str, &str)> = parsed
2886                .assignments
2887                .iter()
2888                .map(|(c, e)| (c.as_str(), e.as_str()))
2889                .collect();
2890            let pred = parsed.predicate.as_deref();
2891            let (updated, _) = krishiv_connectors::lakehouse::dml::iceberg_update_where(
2892                iceberg_catalog,
2893                &table_ident,
2894                &borrowed,
2895                pred,
2896                &self.context,
2897            )
2898            .await
2899            .map_err(|e| SqlError::DataFusion {
2900                message: e.to_string(),
2901            })?;
2902            let schema = Arc::new(Schema::new(vec![Field::new(
2903                "updated_rows",
2904                DataType::Int64,
2905                false,
2906            )]));
2907            let array: ArrayRef = Arc::new(Int64Array::from(vec![updated as i64]));
2908            let batch =
2909                RecordBatch::try_new(schema, vec![array]).map_err(|e| SqlError::DataFusion {
2910                    message: e.to_string(),
2911                })?;
2912            let res_table = next_ephemeral_name("update_result");
2913            lakehouse::register_scan_batches(&self.context, &res_table, vec![batch]).await?;
2914            let dataframe = self
2915                .context
2916                .sql(&format!("SELECT * FROM {res_table}"))
2917                .await?;
2918            return Ok(self.attach_query_metadata(self.make_sql_df("update", dataframe), query));
2919        }
2920
2921        // ── Intercept INSERT INTO <iceberg-table> [SELECT|VALUES ...] ────────
2922        // #219: DataFusion's own ListingTable::insert_into rejects every
2923        // catalog table deterministically — each data file parses to an
2924        // exact object path, never a URL ending in `/`, so its "backed by a
2925        // single file" collection check always fails, table-exists-or-not.
2926        // Route the common form (no explicit column list, i.e. all columns
2927        // in table order) to a durable append landing instead. An explicit
2928        // column list falls through to DataFusion's existing (rejecting)
2929        // path — narrower form, not yet supported here (residual).
2930        #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2931        if trimmed.to_ascii_uppercase().starts_with("INSERT ")
2932            && let Some(parsed) = parse_dml_insert(trimmed)
2933            && parsed.columns.is_empty()
2934            && let Some((iceberg_catalog, table_ident)) =
2935                self.resolve_iceberg_table(&parsed.table_ref)
2936        {
2937            use arrow::array::{ArrayRef, Int64Array};
2938            use arrow::datatypes::{DataType, Field, Schema};
2939            let source_df = self.context.sql(&parsed.inner_query).await?;
2940            let stream = source_df
2941                .execute_stream()
2942                .await
2943                .map_err(|e| SqlError::DataFusion {
2944                    message: e.to_string(),
2945                })?;
2946            let report = krishiv_connectors::lakehouse::dml::iceberg_append_into(
2947                iceberg_catalog,
2948                &table_ident,
2949                stream,
2950            )
2951            .await
2952            .map_err(|e| SqlError::DataFusion {
2953                message: e.to_string(),
2954            })?;
2955            // Phase 54 auto-stats: keep any known row count in step.
2956            self.adjust_table_row_count_stat(&parsed.table_ref, report.rows as i64);
2957            let schema = Arc::new(Schema::new(vec![Field::new(
2958                "inserted_rows",
2959                DataType::Int64,
2960                false,
2961            )]));
2962            let array: ArrayRef = Arc::new(Int64Array::from(vec![report.rows as i64]));
2963            let batch =
2964                RecordBatch::try_new(schema, vec![array]).map_err(|e| SqlError::DataFusion {
2965                    message: e.to_string(),
2966                })?;
2967            let res_table = next_ephemeral_name("insert_result");
2968            lakehouse::register_scan_batches(&self.context, &res_table, vec![batch]).await?;
2969            let dataframe = self
2970                .context
2971                .sql(&format!("SELECT * FROM {res_table}"))
2972                .await?;
2973            return Ok(self.attach_query_metadata(self.make_sql_df("insert", dataframe), query));
2974        }
2975
2976        // ── Intercept MATCH_RECOGNIZE ─────────────────────────────────────────
2977        // DataFusion does not parse MATCH_RECOGNIZE. Route it through the CEP
2978        // path: parse → run PatternMatcher on the source table → return results.
2979        if query.to_ascii_uppercase().contains(" MATCH_RECOGNIZE ")
2980            && let Some(stmt) = cep_sql::parse_match_recognize(query)?
2981        {
2982            let is_streaming = self.is_streaming_source(&stmt.source_table);
2983            // For streaming sources collect a bounded window of recent events
2984            // (capped at the configured limit) so the query terminates. The
2985            // cap is configurable through `KRISHIV_MATCH_RECOGNIZE_STREAMING_LIMIT`
2986            // (default 100_000) so users can raise it for high-rate streams
2987            // or lower it to bound memory on small executors. The truncation
2988            // is logged at warn level because the result is no longer a
2989            // complete match over the unbounded stream.
2990            let streaming_limit = streaming_match_recognize_limit_from_env();
2991            let source_sql = if is_streaming {
2992                format!(
2993                    "SELECT * FROM {} LIMIT {}",
2994                    stmt.source_table, streaming_limit
2995                )
2996            } else {
2997                format!("SELECT * FROM {}", stmt.source_table)
2998            };
2999            let source_df = self.context.sql(&source_sql).await?;
3000            let source_batches = source_df.collect().await?;
3001            if is_streaming {
3002                tracing::warn!(
3003                    source = %stmt.source_table,
3004                    limit = streaming_limit,
3005                    collected_rows = source_batches.iter().map(|b| b.num_rows()).sum::<usize>(),
3006                    "MATCH_RECOGNIZE executed against a streaming source under \
3007                     bounded materialisation; results only cover the first {0} rows \
3008                     of the source. Set KRISHIV_MATCH_RECOGNIZE_STREAMING_LIMIT to a \
3009                     larger value if your executor has the memory budget.",
3010                    streaming_limit
3011                );
3012            }
3013            let results = cep_sql::execute_match_recognize(stmt, &source_batches)?;
3014            let cep_table = next_ephemeral_name("cep_result");
3015            lakehouse::register_scan_batches(&self.context, &cep_table, results).await?;
3016            let dataframe = self
3017                .context
3018                .sql(&format!("SELECT * FROM {cep_table}"))
3019                .await?;
3020            return Ok(self.attach_query_metadata(self.make_sql_df("cep", dataframe), query));
3021        }
3022
3023        // Rewrite PIVOT / UNPIVOT into equivalent CASE WHEN / UNION ALL SQL —
3024        // DataFusion does not parse either construct natively.
3025        let query = &pivot_sql::rewrite_pivot_unpivot(query)?;
3026
3027        // Rewrite TUMBLE/HOP/SESSION TVFs before other preprocessing.
3028        let query = &streaming_tvf::rewrite_window_tvfs(query);
3029
3030        let (rewritten, as_ofs) =
3031            lakehouse::preprocess_as_of_sql(query).unwrap_or_else(|_| (query.to_string(), vec![]));
3032        lakehouse::apply_as_of_refs(&self.context, &as_ofs).await?;
3033
3034        // ── Plan cache ────────────────────────────────────────────────────────
3035        // Check the cache before sending the query through DataFusion's full
3036        // parse → analyse → optimise pipeline. Only cache simple queries without
3037        // DDL or AS-OF refs; DDL side effects must not be bypassed.
3038        // Single-lock design: lookup and insert share the same Mutex<PlanCache>,
3039        // eliminating the TOCTOU race of the previous DashMap + VecDeque approach.
3040        let can_cache = as_ofs.is_empty();
3041        let shuffle_override = self
3042            .shuffle_partitions
3043            .read()
3044            .map(|g| *g)
3045            .unwrap_or_else(|e| *e.into_inner());
3046        if can_cache {
3047            // Scope the guard so it is dropped before any .await point.
3048            let cached_plan: Option<datafusion::logical_expr::LogicalPlan> = self
3049                .plan_cache
3050                .lock()
3051                .unwrap_or_else(|e| e.into_inner())
3052                .get(&rewritten)
3053                .cloned();
3054            if let Some(plan) = cached_plan {
3055                let dataframe = self.context.execute_logical_plan(plan).await?;
3056                return Ok(self.attach_query_metadata(
3057                    self.make_sql_df("sql-query", dataframe)
3058                        .with_shuffle_partitions(shuffle_override),
3059                    &rewritten,
3060                ));
3061            }
3062        }
3063
3064        // Register the backing S3 object store before executing a
3065        // `CREATE EXTERNAL TABLE … LOCATION 's3://…'` DDL. DataFusion infers the
3066        // schema by listing the location during DDL execution, which needs the
3067        // object store registered on the runtime env first. The `register_parquet`
3068        // path already does this; DDL-created external tables did not
3069        // (engine-s3-ddl-gap), so an `s3://` external table previously failed at
3070        // plan time with "no suitable object store found for s3://…". No-op for
3071        // file/connector locations, so it is safe for every external-table DDL.
3072        if let Some(location) = extract_create_external_table_location(&rewritten) {
3073            self.register_s3_object_store_for_warehouse(&location)
3074                .map_err(|message| SqlError::DataFusion { message })?;
3075        }
3076
3077        let dataframe = self.context.sql(&rewritten).await?;
3078
3079        // After CREATE EXTERNAL TABLE DDL, try to extract row-count statistics
3080        // from the newly registered table provider so `BroadcastAutoRule` can
3081        // fire for small connector-backed tables (e.g. Parquet/S3 via DDL).
3082        if let Some(table_name) = extract_create_external_table_name(&rewritten)
3083            && !table_name.is_empty()
3084            && let Ok(provider) = self.context.table_provider(&table_name).await
3085        {
3086            let maybe_rows = provider
3087                .statistics()
3088                .and_then(|s| s.num_rows.get_value().copied());
3089            if let Some(n) = maybe_rows
3090                && let Ok(mut counts) = self.table_row_counts.write()
3091            {
3092                counts.entry(table_name).or_insert(n as u64);
3093            }
3094        }
3095
3096        // Cache the logical plan for future repeated calls.
3097        if can_cache {
3098            let plan = dataframe.logical_plan().clone();
3099            match self.plan_cache.lock() {
3100                Ok(mut cache) => cache.insert(rewritten.clone(), plan),
3101                Err(poisoned) => poisoned.into_inner().insert(rewritten.clone(), plan),
3102            }
3103        }
3104
3105        Ok(self.attach_query_metadata(
3106            self.make_sql_df("sql-query", dataframe)
3107                .with_shuffle_partitions(shuffle_override),
3108            &rewritten,
3109        ))
3110    }
3111
3112    /// Execute a SQL query with a timeout.
3113    ///
3114    /// Returns [`SqlError::Timeout`] if `timeout_ms` elapses before the query
3115    /// produces a result.  The underlying DataFusion task is abandoned (not
3116    /// cancelled at the engine level) when the timeout fires; its resources are
3117    /// released when the spawned task eventually completes.
3118    pub async fn execute_with_timeout(
3119        &self,
3120        query: impl AsRef<str> + Send,
3121        timeout_ms: u64,
3122    ) -> SqlResult<SqlDataFrame> {
3123        let timeout = std::time::Duration::from_millis(timeout_ms);
3124        tokio::time::timeout(timeout, self.sql(query))
3125            .await
3126            .map_err(|_| SqlError::Timeout { timeout_ms })?
3127    }
3128
3129    /// Execute a SQL query tagged with a caller-supplied operation ID.
3130    ///
3131    /// The operation ID is recorded in the returned [`TaggedQueryResult`] and
3132    /// can be used to correlate logs, metrics, and cancellation requests.
3133    /// If `cancelled_ids` contains `operation_id` before execution begins the
3134    /// function returns [`SqlError::OperationCancelled`] immediately.
3135    pub async fn execute_with_operation_id(
3136        &self,
3137        operation_id: u64,
3138        query: impl AsRef<str> + Send,
3139        cancelled_ids: &OperationRegistry,
3140    ) -> SqlResult<TaggedQueryResult> {
3141        if cancelled_ids.is_cancelled(operation_id) {
3142            return Err(SqlError::OperationCancelled { operation_id });
3143        }
3144        let df = self.sql(query).await?;
3145        Ok(TaggedQueryResult {
3146            operation_id,
3147            inner: df,
3148        })
3149    }
3150
3151    /// Resolve a SQL table reference to an `(Arc<dyn Catalog>, TableIdent)` pair
3152    /// from the registered Iceberg catalogs.
3153    ///
3154    /// Accepts 2-part (`ns.tbl`) and 3-part (`cat.ns.tbl`) references.
3155    /// Returns `None` when no catalog is registered or the reference is ambiguous.
3156    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
3157    fn resolve_iceberg_table(
3158        &self,
3159        table_ref: &str,
3160    ) -> Option<(Arc<dyn iceberg::Catalog + Send + Sync>, iceberg::TableIdent)> {
3161        let parts: Vec<&str> = table_ref.splitn(3, '.').collect();
3162        let (catalog_arc, ns_str, table_str) = {
3163            let guard = self
3164                .iceberg_catalogs
3165                .read()
3166                .unwrap_or_else(|e| e.into_inner());
3167            if guard.is_empty() {
3168                return None;
3169            }
3170            match parts.len() {
3171                2 => {
3172                    let (cat, _) = guard.first()?;
3173                    (Arc::clone(cat), *parts.first()?, *parts.get(1)?)
3174                }
3175                3 => {
3176                    let cat_name = parts.first().copied()?;
3177                    let (cat, _) = guard.iter().find(|(_, n)| n == cat_name)?;
3178                    (Arc::clone(cat), *parts.get(1)?, *parts.get(2)?)
3179                }
3180                _ => return None,
3181            }
3182        };
3183        let ns = iceberg::NamespaceIdent::from_vec(vec![ns_str.to_string()]).ok()?;
3184        let ident = iceberg::TableIdent::new(ns, table_str.to_string());
3185        Some((catalog_arc.as_iceberg(), ident))
3186    }
3187
3188    /// Execute a durable Iceberg CTAS: run the inner query on this engine
3189    /// and land the result stream directly in the Iceberg table (rolling
3190    /// Parquet parts fanned out per `PARTITIONED BY` value + one snapshot
3191    /// commit). The result is a single row of landing counts — the full
3192    /// result set never crosses a wire (gap G17).
3193    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
3194    async fn execute_iceberg_ctas(
3195        &self,
3196        iceberg_catalog: Arc<dyn iceberg::Catalog + Send + Sync>,
3197        table_ident: iceberg::TableIdent,
3198        parsed_ctas: ParsedCtas,
3199        query: &str,
3200    ) -> SqlResult<SqlDataFrame> {
3201        use arrow::array::{ArrayRef, Int64Array};
3202        use arrow::datatypes::{DataType, Field, Schema};
3203        use krishiv_connectors::lakehouse::partitioned_write::parse_partition_transform;
3204
3205        let partition_by = parsed_ctas
3206            .partition_by
3207            .iter()
3208            .map(|item| parse_partition_transform(item))
3209            .collect::<Result<Vec<_>, _>>()
3210            .map_err(|e| SqlError::DataFusion {
3211                message: e.to_string(),
3212            })?;
3213
3214        let dataframe = self.context.sql(&parsed_ctas.inner_query).await?;
3215        let stream = dataframe
3216            .execute_stream()
3217            .await
3218            .map_err(|e| SqlError::DataFusion {
3219                message: e.to_string(),
3220            })?;
3221        let report = krishiv_connectors::lakehouse::dml::land_ctas(
3222            iceberg_catalog,
3223            &table_ident,
3224            parsed_ctas.or_replace,
3225            &partition_by,
3226            stream,
3227        )
3228        .await
3229        .map_err(|e| SqlError::DataFusion {
3230            message: e.to_string(),
3231        })?;
3232        // The target table (or its schema) changed under any cached plan.
3233        self.invalidate_plan_cache();
3234        // Phase 54 auto-stats: the landing report gives an exact row count.
3235        self.record_table_row_count_stat(&parsed_ctas.table_ref, report.rows as u64);
3236
3237        let schema = Arc::new(Schema::new(vec![
3238            Field::new("rows_written", DataType::Int64, false),
3239            Field::new("bytes_written", DataType::Int64, false),
3240            Field::new("data_files", DataType::Int64, false),
3241            Field::new("snapshot_id", DataType::Int64, false),
3242        ]));
3243        let columns: Vec<ArrayRef> = vec![
3244            Arc::new(Int64Array::from(vec![report.rows as i64])),
3245            Arc::new(Int64Array::from(vec![report.bytes as i64])),
3246            Arc::new(Int64Array::from(vec![report.data_files as i64])),
3247            Arc::new(Int64Array::from(vec![report.snapshot_id])),
3248        ];
3249        let batch = RecordBatch::try_new(schema, columns).map_err(|e| SqlError::DataFusion {
3250            message: e.to_string(),
3251        })?;
3252        let res_table = next_ephemeral_name("ctas_result");
3253        lakehouse::register_scan_batches(&self.context, &res_table, vec![batch]).await?;
3254        let dataframe = self
3255            .context
3256            .sql(&format!("SELECT * FROM {res_table}"))
3257            .await?;
3258        Ok(self.attach_query_metadata(self.make_sql_df("ctas", dataframe), query))
3259    }
3260
3261    /// Phase 54 auto-stats: record an absolute `row_count` for `table_ref`
3262    /// in both the engine row-count registry and the process-global stats
3263    /// registry. Called from write paths (Iceberg CTAS) so planner
3264    /// statistics stay warm without an explicit `ANALYZE TABLE` run.
3265    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
3266    fn record_table_row_count_stat(&self, table_ref: &str, row_count: u64) {
3267        let registry = krishiv_plan::optimizer::global_table_stats();
3268        let mut names = vec![table_ref];
3269        let bare = table_ref.rsplit('.').next().unwrap_or(table_ref);
3270        if bare != table_ref {
3271            names.push(bare);
3272        }
3273        for name in &names {
3274            let mut stats = registry
3275                .get(name)
3276                .unwrap_or_else(|| krishiv_plan::optimizer::TableCboStats::new(*name));
3277            stats.row_count = Some(row_count);
3278            registry.put(stats);
3279        }
3280        if let Ok(mut counts) = self.table_row_counts.write() {
3281            for name in &names {
3282                counts.insert((*name).to_owned(), row_count);
3283            }
3284        }
3285    }
3286
3287    /// Phase 54 auto-stats: apply a signed row-count delta (Iceberg DELETE)
3288    /// to any existing statistic for `table_ref`. Tables never analyzed or
3289    /// written through this engine are left alone — a delta without a base
3290    /// count would fabricate data.
3291    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
3292    fn adjust_table_row_count_stat(&self, table_ref: &str, delta: i64) {
3293        let registry = krishiv_plan::optimizer::global_table_stats();
3294        let mut names = vec![table_ref];
3295        let bare = table_ref.rsplit('.').next().unwrap_or(table_ref);
3296        if bare != table_ref {
3297            names.push(bare);
3298        }
3299        for name in &names {
3300            if let Some(mut stats) = registry.get(name)
3301                && let Some(current) = stats.row_count
3302            {
3303                stats.row_count = Some(current.saturating_add_signed(delta));
3304                registry.put(stats);
3305            }
3306        }
3307        if let Ok(mut counts) = self.table_row_counts.write() {
3308            for name in &names {
3309                if let Some(current) = counts.get(*name).copied() {
3310                    counts.insert((*name).to_owned(), current.saturating_add_signed(delta));
3311                }
3312            }
3313        }
3314    }
3315
3316    /// Execute `ANALYZE TABLE <ref> [COMPUTE STATISTICS] [FOR COLUMNS (c1, …)]`
3317    /// (Phase 54).
3318    ///
3319    /// Runs one aggregation scan over the table: `COUNT(*)` always, plus
3320    /// `approx_distinct` / `min` / `max` / non-null count per requested
3321    /// column. Results land in the engine's `table_row_counts` registry
3322    /// (the `BroadcastAutoRule` feed) and the process-global
3323    /// [`krishiv_plan::optimizer::TableStatsRegistry`]; the returned batch
3324    /// summarizes what was collected. `avg_row_bytes` is taken from the
3325    /// provider's own statistics when it reports byte sizes (Parquet does).
3326    async fn dispatch_analyze_table(&self, stmt: &str) -> SqlResult<RecordBatch> {
3327        use arrow::array::{ArrayRef, Int64Array, StringArray};
3328        use arrow::datatypes::{DataType, Field, Schema};
3329
3330        let rest = stmt
3331            .get(14..)
3332            .unwrap_or("")
3333            .trim()
3334            .trim_end_matches(';')
3335            .trim();
3336        let (table_ref, tail) = match rest.split_once(char::is_whitespace) {
3337            Some((t, tail)) => (t.trim(), tail.trim()),
3338            None => (rest, ""),
3339        };
3340        if table_ref.is_empty() {
3341            return Err(SqlError::DataFusion {
3342                message: String::from("ANALYZE TABLE: table reference is required"),
3343            });
3344        }
3345        // Optional noise word (Spark compatibility), then optional column list.
3346        let mut tail = tail;
3347        if tail
3348            .get(..18)
3349            .is_some_and(|p| p.eq_ignore_ascii_case("COMPUTE STATISTICS"))
3350        {
3351            tail = tail.get(18..).unwrap_or("").trim();
3352        }
3353        let columns: Vec<String> = if tail
3354            .get(..11)
3355            .is_some_and(|p| p.eq_ignore_ascii_case("FOR COLUMNS"))
3356        {
3357            tail.get(11..)
3358                .unwrap_or("")
3359                .trim()
3360                .trim_start_matches('(')
3361                .trim_end_matches(')')
3362                .split(',')
3363                .map(|c| c.trim().trim_matches('"').to_owned())
3364                .filter(|c| !c.is_empty())
3365                .collect()
3366        } else if tail.is_empty() {
3367            Vec::new()
3368        } else {
3369            return Err(SqlError::DataFusion {
3370                message: format!("ANALYZE TABLE: unexpected trailing clause: {tail}"),
3371            });
3372        };
3373
3374        // One scan: COUNT(*) plus four aggregates per analyzed column.
3375        let mut projections = vec![String::from("count(*)")];
3376        for c in &columns {
3377            projections.push(format!("approx_distinct(\"{c}\")"));
3378            projections.push(format!("min(\"{c}\")"));
3379            projections.push(format!("max(\"{c}\")"));
3380            projections.push(format!("count(\"{c}\")"));
3381        }
3382        let scan_sql = format!("SELECT {} FROM {table_ref}", projections.join(", "));
3383        let batches = self.context.sql(&scan_sql).await?.collect().await?;
3384        let row =
3385            batches
3386                .iter()
3387                .find(|b| b.num_rows() > 0)
3388                .ok_or_else(|| SqlError::DataFusion {
3389                    message: format!("ANALYZE TABLE {table_ref}: aggregation returned no rows"),
3390                })?;
3391        let cell_string = |col: usize| -> Option<String> {
3392            let column = row.columns().get(col)?;
3393            if column.is_null(0) {
3394                return None;
3395            }
3396            arrow::util::display::array_value_to_string(column, 0).ok()
3397        };
3398        let cell_u64 = |col: usize| -> Option<u64> { cell_string(col)?.parse().ok() };
3399        let row_count = cell_u64(0).ok_or_else(|| SqlError::DataFusion {
3400            message: format!("ANALYZE TABLE {table_ref}: COUNT(*) unreadable"),
3401        })?;
3402
3403        let mut column_stats = Vec::with_capacity(columns.len());
3404        for (i, name) in columns.iter().enumerate() {
3405            let base = 1 + i * 4;
3406            let non_null = cell_u64(base + 3);
3407            column_stats.push(krishiv_plan::optimizer::ColumnCboStats {
3408                name: name.clone(),
3409                ndv: cell_u64(base),
3410                min: cell_string(base + 1),
3411                max: cell_string(base + 2),
3412                null_count: non_null.map(|n| row_count.saturating_sub(n)),
3413            });
3414        }
3415
3416        // Provider-reported byte sizes give avg_row_bytes when available.
3417        let avg_row_bytes = match self.context.table_provider(table_ref).await {
3418            Ok(provider) => provider.statistics().and_then(|s| {
3419                let rows = s.num_rows.get_value().copied()?;
3420                let bytes = s.total_byte_size.get_value().copied()?;
3421                (rows > 0).then(|| (bytes / rows) as u64)
3422            }),
3423            Err(_) => None,
3424        };
3425
3426        let mut stats =
3427            krishiv_plan::optimizer::TableCboStats::new(table_ref).with_row_count(row_count);
3428        if let Some(bytes) = avg_row_bytes {
3429            stats = stats.with_avg_row_bytes(bytes);
3430        }
3431        if let Some(max_ndv) = column_stats.iter().filter_map(|c| c.ndv).max() {
3432            // Table-level NDV proxy: the widest column NDV (join-key upper bound).
3433            stats = stats.with_ndv(max_ndv);
3434        }
3435        stats.columns = column_stats;
3436        let registry = krishiv_plan::optimizer::global_table_stats();
3437        // Register under the full reference AND the bare table name — scan
3438        // nodes may carry either depending on how the table was registered.
3439        let bare = table_ref.rsplit('.').next().unwrap_or(table_ref);
3440        if bare != table_ref {
3441            let mut bare_stats = stats.clone();
3442            bare_stats.table = bare.to_owned();
3443            registry.put(bare_stats);
3444        }
3445        let analyzed_columns = stats.columns.len();
3446        registry.put(stats);
3447        if let Ok(mut counts) = self.table_row_counts.write() {
3448            counts.insert(table_ref.to_owned(), row_count);
3449            if bare != table_ref {
3450                counts.insert(bare.to_owned(), row_count);
3451            }
3452        }
3453        self.invalidate_plan_cache();
3454
3455        let schema = Arc::new(Schema::new(vec![
3456            Field::new("table_name", DataType::Utf8, false),
3457            Field::new("row_count", DataType::Int64, false),
3458            Field::new("avg_row_bytes", DataType::Int64, true),
3459            Field::new("columns_analyzed", DataType::Int64, false),
3460        ]));
3461        let columns_out: Vec<ArrayRef> = vec![
3462            Arc::new(StringArray::from(vec![table_ref.to_owned()])),
3463            Arc::new(Int64Array::from(vec![row_count as i64])),
3464            Arc::new(Int64Array::from(vec![avg_row_bytes.map(|b| b as i64)])),
3465            Arc::new(Int64Array::from(vec![analyzed_columns as i64])),
3466        ];
3467        RecordBatch::try_new(schema, columns_out).map_err(|e| SqlError::DataFusion {
3468            message: e.to_string(),
3469        })
3470    }
3471
3472    /// Dispatch a `CALL system.<proc>(...)` statement to the appropriate
3473    /// Iceberg maintenance function on the first registered KrishivCatalog.
3474    #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
3475    async fn dispatch_call_system(&self, stmt: &str) -> SqlResult<RecordBatch> {
3476        use arrow::array::{ArrayRef, Int64Array};
3477        use arrow::datatypes::{DataType, Field, Schema};
3478
3479        let upper = stmt.to_ascii_uppercase();
3480        const PREFIX: &str = "CALL SYSTEM.";
3481        let upper_after = &upper[PREFIX.len()..];
3482        let orig_after = &stmt[PREFIX.len()..];
3483
3484        let paren = upper_after.find('(').ok_or_else(|| SqlError::DataFusion {
3485            message: format!("CALL: missing '(' in: {stmt}"),
3486        })?;
3487        let proc_name = upper_after[..paren].trim();
3488
3489        let args_raw = orig_after[paren + 1..]
3490            .trim_end_matches(';')
3491            .trim()
3492            .trim_end_matches(')')
3493            .trim();
3494        let args = call_args_from_str(args_raw);
3495
3496        let iceberg_catalog = {
3497            let guard = self
3498                .iceberg_catalogs
3499                .read()
3500                .unwrap_or_else(|e| e.into_inner());
3501            guard
3502                .first()
3503                .ok_or_else(|| SqlError::DataFusion {
3504                    message: "CALL system: no Iceberg catalog registered".to_string(),
3505                })?
3506                .0
3507                .as_iceberg()
3508        };
3509
3510        let table_ref = args.first().ok_or_else(|| SqlError::DataFusion {
3511            message: format!("CALL {proc_name}: table reference argument is required"),
3512        })?;
3513        let table_ident = iceberg_table_ident(table_ref)?;
3514
3515        // maintain_table returns a three-column report, unlike the single
3516        // counters below: CALL system.maintain_table('ns.tbl'[, '7 days'
3517        // [, target_file_bytes [, retain_last]]]).
3518        if proc_name == "MAINTAIN_TABLE" {
3519            let older_than = parse_call_duration(args.get(1).map_or("7 days", |s| s.as_str()))?;
3520            let target_bytes = args
3521                .get(2)
3522                .and_then(|s| s.parse::<u64>().ok())
3523                .unwrap_or(128 * 1024 * 1024);
3524            let retain_last = args
3525                .get(3)
3526                .and_then(|s| s.parse::<usize>().ok())
3527                .unwrap_or(1);
3528            let report = krishiv_connectors::lakehouse::maintenance::maintain_table(
3529                iceberg_catalog,
3530                &table_ident,
3531                target_bytes,
3532                older_than,
3533                retain_last,
3534            )
3535            .await
3536            .map_err(|e| SqlError::DataFusion {
3537                message: e.to_string(),
3538            })?;
3539            let schema = Arc::new(Schema::new(vec![
3540                Field::new("compacted_files", DataType::Int64, false),
3541                Field::new("expired_snapshots", DataType::Int64, false),
3542                Field::new("removed_orphans", DataType::Int64, false),
3543            ]));
3544            let columns: Vec<ArrayRef> = vec![
3545                Arc::new(Int64Array::from(vec![report.compacted_files as i64])),
3546                Arc::new(Int64Array::from(vec![report.expired_snapshots as i64])),
3547                Arc::new(Int64Array::from(vec![report.removed_orphans as i64])),
3548            ];
3549            return RecordBatch::try_new(schema, columns).map_err(|e| SqlError::DataFusion {
3550                message: e.to_string(),
3551            });
3552        }
3553
3554        let count: i64 = match proc_name {
3555            "EXPIRE_SNAPSHOTS" => {
3556                let dur_s = args.get(1).ok_or_else(|| SqlError::DataFusion {
3557                    message: "CALL expire_snapshots: duration argument is required".to_string(),
3558                })?;
3559                let older_than = parse_call_duration(dur_s)?;
3560                let retain_last = args
3561                    .get(2)
3562                    .and_then(|s| s.parse::<usize>().ok())
3563                    .unwrap_or(1);
3564                krishiv_connectors::lakehouse::maintenance::expire_snapshots(
3565                    iceberg_catalog,
3566                    &table_ident,
3567                    older_than,
3568                    retain_last,
3569                )
3570                .await
3571                .map_err(|e| SqlError::DataFusion {
3572                    message: e.to_string(),
3573                })? as i64
3574            }
3575            "REMOVE_ORPHAN_FILES" => {
3576                let dur_s = args.get(1).ok_or_else(|| SqlError::DataFusion {
3577                    message: "CALL remove_orphan_files: duration argument is required".to_string(),
3578                })?;
3579                let older_than = parse_call_duration(dur_s)?;
3580                krishiv_connectors::lakehouse::maintenance::remove_orphan_files(
3581                    iceberg_catalog,
3582                    &table_ident,
3583                    older_than,
3584                )
3585                .await
3586                .map_err(|e| SqlError::DataFusion {
3587                    message: e.to_string(),
3588                })? as i64
3589            }
3590            "COMPACT_DATA_FILES" => {
3591                let target_bytes = args
3592                    .get(1)
3593                    .and_then(|s| s.parse::<u64>().ok())
3594                    .unwrap_or(128 * 1024 * 1024);
3595                krishiv_connectors::lakehouse::maintenance::compact_data_files(
3596                    iceberg_catalog,
3597                    &table_ident,
3598                    target_bytes,
3599                )
3600                .await
3601                .map_err(|e| SqlError::DataFusion {
3602                    message: e.to_string(),
3603                })? as i64
3604            }
3605            other => {
3606                return Err(SqlError::Unsupported {
3607                    feature: format!("CALL system.{other}: unknown procedure"),
3608                });
3609            }
3610        };
3611
3612        let col = match proc_name {
3613            "EXPIRE_SNAPSHOTS" => "expired_snapshots",
3614            "REMOVE_ORPHAN_FILES" => "removed_files",
3615            "COMPACT_DATA_FILES" => "rewritten_files",
3616            _ => "result",
3617        };
3618        let schema = Arc::new(Schema::new(vec![Field::new(col, DataType::Int64, false)]));
3619        let array: ArrayRef = Arc::new(Int64Array::from(vec![count]));
3620        RecordBatch::try_new(schema, vec![array]).map_err(|e| SqlError::DataFusion {
3621            message: e.to_string(),
3622        })
3623    }
3624}
3625
3626/// A query result annotated with the operation ID that produced it.
3627pub struct TaggedQueryResult {
3628    /// The caller-supplied operation ID.
3629    pub operation_id: u64,
3630    /// The underlying SQL DataFrame.
3631    pub inner: SqlDataFrame,
3632}
3633
3634/// Registry of cancelled operation IDs and optional progress snapshots.
3635///
3636/// Callers can cancel an in-flight operation by registering its ID here before
3637/// or during execution.  [`SqlEngine::execute_with_operation_id`] checks this
3638/// registry at the start of execution.
3639#[derive(Clone, Default)]
3640pub struct OperationRegistry {
3641    cancelled: Arc<std::sync::RwLock<std::collections::HashSet<u64>>>,
3642    progress: Arc<std::sync::RwLock<std::collections::HashMap<u64, (u64, u64)>>>,
3643}
3644
3645impl OperationRegistry {
3646    /// Create a new, empty operation registry.
3647    pub fn new() -> Self {
3648        Self::default()
3649    }
3650
3651    /// Cancel an operation by ID.  Subsequent
3652    /// [`execute_with_operation_id`][SqlEngine::execute_with_operation_id] calls
3653    /// with this ID will return [`SqlError::OperationCancelled`].
3654    pub fn cancel(&self, operation_id: u64) {
3655        if let Ok(mut ids) = self.cancelled.write() {
3656            ids.insert(operation_id);
3657        }
3658    }
3659
3660    /// Return `true` if `operation_id` has been cancelled.
3661    pub fn is_cancelled(&self, operation_id: u64) -> bool {
3662        self.cancelled
3663            .read()
3664            .map(|ids| ids.contains(&operation_id))
3665            .unwrap_or(false)
3666    }
3667
3668    /// Remove a cancelled ID (e.g. once the operation has been cleaned up).
3669    pub fn remove(&self, operation_id: u64) {
3670        if let Ok(mut ids) = self.cancelled.write() {
3671            ids.remove(&operation_id);
3672        }
3673        if let Ok(mut progress) = self.progress.write() {
3674            progress.remove(&operation_id);
3675        }
3676    }
3677
3678    /// Record row-level progress for an operation.
3679    pub fn update_progress(&self, operation_id: u64, rows_scanned: u64, rows_emitted: u64) {
3680        if let Ok(mut progress) = self.progress.write() {
3681            progress.insert(operation_id, (rows_scanned, rows_emitted));
3682        }
3683    }
3684
3685    /// Return the latest `(rows_scanned, rows_emitted)` snapshot, if any.
3686    pub fn progress(&self, operation_id: u64) -> Option<(u64, u64)> {
3687        self.progress
3688            .read()
3689            .ok()
3690            .and_then(|progress| progress.get(&operation_id).copied())
3691    }
3692
3693    /// Return all currently cancelled operation IDs.
3694    pub fn cancelled_ids(&self) -> Vec<u64> {
3695        self.cancelled
3696            .read()
3697            .map(|ids| ids.iter().copied().collect())
3698            .unwrap_or_default()
3699    }
3700}
3701
3702/// Extract the table name from a `CREATE EXTERNAL TABLE <name> ...` DDL statement.
3703///
3704/// Returns `None` for any other SQL statement. Used to populate `table_row_counts`
3705/// after DDL so that `BroadcastAutoRule` can fire for connector-backed tables.
3706pub(crate) fn extract_create_external_table_name(query: &str) -> Option<String> {
3707    use datafusion::sql::parser::{DFParser, Statement as DFStatement};
3708    let mut stmts = DFParser::parse_sql(query).ok()?;
3709    match stmts.pop_front()? {
3710        DFStatement::CreateExternalTable(create) => Some(create.name.to_string()),
3711        _ => None,
3712    }
3713}
3714
3715/// Extract the `LOCATION` URI of a `CREATE EXTERNAL TABLE … LOCATION '<uri>'`
3716/// statement, or `None` for any other SQL.
3717///
3718/// Used to register the backing S3 object store before the DDL executes, so an
3719/// `s3://`/`s3a://` location can be schema-inferred and scanned. Mirrors
3720/// [`extract_create_external_table_name`] (same single-parse, first-statement
3721/// contract).
3722pub(crate) fn extract_create_external_table_location(query: &str) -> Option<String> {
3723    use datafusion::sql::parser::{DFParser, Statement as DFStatement};
3724    let mut stmts = DFParser::parse_sql(query).ok()?;
3725    match stmts.pop_front()? {
3726        DFStatement::CreateExternalTable(create) => Some(create.location),
3727        _ => None,
3728    }
3729}
3730
3731/// Engine-agnostic interface over a prepared query result.
3732///
3733/// Hides the concrete [`SqlDataFrame`] (which holds a DataFusion `DataFrame`)
3734/// behind a stable trait so that `krishiv-api` and other callers are not
3735/// forced to depend on DataFusion types.  `datafusion` stays an implementation
3736/// detail inside `krishiv-sql`; a future engine swap only requires a new impl.
3737/// Engine-neutral grouping-set mode for canonical DataFrame aggregation.
3738pub enum GroupingMode<'a> {
3739    Sets(Vec<Vec<&'a krishiv_plan::expression::Expr>>),
3740    Cube(Vec<&'a krishiv_plan::expression::Expr>),
3741    Rollup(Vec<&'a krishiv_plan::expression::Expr>),
3742}
3743
3744#[async_trait::async_trait]
3745pub trait KrishivDataFrameOps: Send + Sync {
3746    /// Execute and collect all result batches.
3747    async fn collect(&self) -> SqlResult<Vec<RecordBatch>>;
3748    /// Execute, collect results, and return lightweight runtime statistics.
3749    async fn collect_with_stats(&self) -> SqlResult<(Vec<RecordBatch>, SqlExecutionStats)>;
3750    /// Explain the physical and logical plan text (does not execute).
3751    async fn explain(&self) -> SqlResult<String>;
3752
3753    /// Execute and report per-operator runtime metrics.
3754    ///
3755    /// Defaults to an error rather than silently falling back to a plain
3756    /// EXPLAIN: a caller asking for measurements must not be handed a plan
3757    /// that looks like one.
3758    async fn explain_analyze(&self) -> SqlResult<String> {
3759        Err(SqlError::DataFusion {
3760            message: String::from("EXPLAIN ANALYZE is not supported for this dataframe backend"),
3761        })
3762    }
3763    /// Explain the logical plan text without executing.
3764    fn explain_logical(&self) -> String;
3765    /// Build a Krishiv [`LogicalPlan`] wrapper for this DataFrame.
3766    fn krishiv_logical_plan(&self) -> LogicalPlan;
3767    /// The original SQL query string, if any.
3768    fn query(&self) -> Option<&str>;
3769    /// SQL text for the **current** logical plan (reflecting every applied
3770    /// transform), suitable for defining an incremental view. Implemented via the
3771    /// DataFusion unparser; the default errors for ops that cannot be unparsed.
3772    fn to_sql(&self) -> SqlResult<String> {
3773        Err(SqlError::Unsupported {
3774            feature: "to_sql (plan unparsing) is not supported for this DataFrame".into(),
3775        })
3776    }
3777    /// Execute and return a record batch stream.
3778    async fn execute_stream(&self) -> SqlResult<SqlStream>;
3779
3780    // ── DataFrame transforms (lazy) ─────────────────────────────────────────
3781
3782    /// Return the Arrow schema of this DataFrame.
3783    fn schema(&self) -> SchemaRef;
3784
3785    /// Select columns by name.
3786    async fn select(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3787
3788    /// Select arbitrary SQL expressions.
3789    async fn select_exprs(
3790        &self,
3791        expressions: &[&krishiv_plan::expression::Expr],
3792    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3793
3794    /// Unnest (explode) one or more array/list columns, producing one output
3795    /// row per element. Multiple equal-length columns are zipped element-wise
3796    /// (DataFusion `DataFrame::unnest_columns`).
3797    async fn unnest_columns(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3798
3799    /// Group by expressions and compute aggregate expressions.
3800    async fn aggregate(
3801        &self,
3802        group_exprs: &[&krishiv_plan::expression::Expr],
3803        aggregate_exprs: &[&krishiv_plan::expression::Expr],
3804    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3805
3806    /// Aggregate using GROUPING SETS, CUBE, or ROLLUP.
3807    async fn aggregate_grouping(
3808        &self,
3809        grouping: GroupingMode<'_>,
3810        aggregate_exprs: &[&krishiv_plan::expression::Expr],
3811    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3812
3813    /// Pivot known values into aggregate columns.
3814    async fn pivot(
3815        &self,
3816        group_exprs: &[&krishiv_plan::expression::Expr],
3817        pivot_column: &krishiv_plan::expression::Expr,
3818        aggregate_expr: &krishiv_plan::expression::Expr,
3819        values: &[(krishiv_plan::expression::ScalarValue, String)],
3820    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3821
3822    /// Unpivot columns into name/value rows while preserving other columns.
3823    async fn unpivot(
3824        &self,
3825        columns: &[&str],
3826        name_column: &str,
3827        value_column: &str,
3828    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3829
3830    /// Filter rows by a SQL predicate expression.
3831    async fn filter(&self, predicate: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3832
3833    /// Filter rows using the engine-owned typed expression AST.
3834    async fn filter_expr(
3835        &self,
3836        predicate: &krishiv_plan::expression::Expr,
3837    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3838
3839    /// Limit the number of rows.
3840    async fn limit(&self, n: usize) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3841
3842    /// Remove duplicate rows.
3843    async fn distinct(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3844
3845    /// Drop rows with nulls in selected columns; an empty list checks all columns.
3846    async fn drop_nulls(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3847
3848    /// Bernoulli-sample rows.
3849    async fn sample(&self, fraction: f64) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3850
3851    /// Sort by columns with optional descending flags.
3852    async fn sort(
3853        &self,
3854        columns: &[&str],
3855        descending: &[bool],
3856    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3857
3858    /// Assign an alias (table name) to this DataFrame.
3859    async fn alias(&self, alias: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3860
3861    /// Drop columns by name.
3862    async fn drop_columns(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3863
3864    /// Rename a column from `old` to `new`.
3865    async fn rename_column(&self, old: &str, new: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3866
3867    /// Add or replace a column with a computed expression.
3868    async fn with_column(&self, name: &str, expr: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3869
3870    /// Return the underlying concrete type for downcasting.
3871    fn as_any(&self) -> &dyn std::any::Any;
3872
3873    /// Compute summary statistics (delegates to DataFusion's `describe`).
3874    async fn describe(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3875
3876    /// Fill null values in `column` with the literal SQL `value`.
3877    async fn fill_null(&self, column: &str, value: &str)
3878    -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3879
3880    /// Join with another DataFrame using a join type and equi-join keys.
3881    async fn join(
3882        &self,
3883        right: &dyn KrishivDataFrameOps,
3884        how: &str,
3885        left_on: &[&str],
3886        right_on: &[&str],
3887    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3888
3889    /// Union this DataFrame with another (UNION ALL semantics).
3890    async fn union(
3891        &self,
3892        right: &dyn KrishivDataFrameOps,
3893    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3894
3895    async fn union_distinct(
3896        &self,
3897        right: &dyn KrishivDataFrameOps,
3898    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3899
3900    async fn intersect(
3901        &self,
3902        right: &dyn KrishivDataFrameOps,
3903        distinct: bool,
3904    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3905
3906    async fn except(
3907        &self,
3908        right: &dyn KrishivDataFrameOps,
3909        distinct: bool,
3910    ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3911
3912    /// Register a list of record batches as a named in-memory table in the
3913    /// same session context that backs this DataFrame.  Used by `cache()`.
3914    async fn register_batches(&self, name: &str, batches: Vec<RecordBatch>) -> SqlResult<()>;
3915
3916    /// Deregister a named table from the session context.  Used by `unpersist()`.
3917    async fn deregister_table(&self, name: &str) -> SqlResult<()>;
3918
3919    /// Create (or replace) a SQL view named `name` backed by this DataFrame's
3920    /// query.  Used by `create_or_replace_temp_view()`.
3921    async fn create_view(&self, name: &str, replace: bool) -> SqlResult<()>;
3922}
3923
3924/// Recursively walk a DataFusion `LogicalPlan` and produce Krishiv `PlanNode`
3925/// entries.  Returns `(nodes, root_id)` where `root_id` is the ID of the
3926/// top-level Krishiv node representing `plan`.
3927///
3928/// Table-scan nodes carry `estimated_rows` when the table name is found in
3929/// `table_row_counts`.  Unhandled node types fall back to a single opaque
3930/// `NodeOp::Other` node.
3931fn df_plan_to_krishiv_nodes(
3932    plan: &datafusion::logical_expr::LogicalPlan,
3933    table_row_counts: &std::collections::HashMap<String, u64>,
3934    counter: &mut usize,
3935) -> (Vec<krishiv_plan::PlanNode>, String) {
3936    use datafusion::logical_expr::LogicalPlan as DfPlan;
3937    use krishiv_plan::{ExecutionKind, NodeOp, PlanNode};
3938
3939    *counter += 1;
3940    let idx = *counter;
3941
3942    match plan {
3943        DfPlan::TableScan(ts) => {
3944            let table_name = ts.table_name.table().to_string();
3945            let row_count = table_row_counts.get(&table_name).copied();
3946            let filters: Vec<String> = ts.filters.iter().map(|e| e.to_string()).collect();
3947            let id = format!("scan-{idx}");
3948            let node = PlanNode::new(&id, format!("Scan {table_name}"), ExecutionKind::Batch)
3949                .with_op(NodeOp::Scan {
3950                    table: table_name,
3951                    filters,
3952                })
3953                .with_estimated_rows(row_count);
3954            (vec![node], id)
3955        }
3956
3957        DfPlan::Projection(proj) => {
3958            let (mut nodes, input_id) =
3959                df_plan_to_krishiv_nodes(&proj.input, table_row_counts, counter);
3960            let id = format!("proj-{idx}");
3961            let columns: Vec<String> = proj.expr.iter().map(|e| e.to_string()).collect();
3962            nodes.push(
3963                PlanNode::new(&id, "Projection", ExecutionKind::Batch)
3964                    .with_op(NodeOp::Project { columns })
3965                    .with_inputs([input_id]),
3966            );
3967            (nodes, id)
3968        }
3969
3970        DfPlan::Filter(filter) => {
3971            let (mut nodes, input_id) =
3972                df_plan_to_krishiv_nodes(&filter.input, table_row_counts, counter);
3973            let id = format!("filter-{idx}");
3974            let predicate = filter.predicate.to_string();
3975            nodes.push(
3976                PlanNode::new(&id, "Filter", ExecutionKind::Batch)
3977                    .with_op(NodeOp::Filter { predicate })
3978                    .with_inputs([input_id]),
3979            );
3980            (nodes, id)
3981        }
3982
3983        DfPlan::Aggregate(agg) => {
3984            let (mut nodes, input_id) =
3985                df_plan_to_krishiv_nodes(&agg.input, table_row_counts, counter);
3986            let id = format!("agg-{idx}");
3987            let group_keys: Vec<String> = agg.group_expr.iter().map(|e| e.to_string()).collect();
3988            nodes.push(
3989                PlanNode::new(&id, "Aggregate", ExecutionKind::Batch)
3990                    .with_op(NodeOp::Aggregate { group_keys })
3991                    .with_inputs([input_id]),
3992            );
3993            (nodes, id)
3994        }
3995
3996        DfPlan::Join(join) => {
3997            let (mut nodes, left_id) =
3998                df_plan_to_krishiv_nodes(&join.left, table_row_counts, counter);
3999            let (right_nodes, right_id) =
4000                df_plan_to_krishiv_nodes(&join.right, table_row_counts, counter);
4001            nodes.extend(right_nodes);
4002            let id = format!("join-{idx}");
4003            // T2: map every DataFusion join variant to its first-class plan
4004            // counterpart instead of silently downgrading unknowns to `Inner`.
4005            // `LeftSemi`/`RightSemi`/`LeftAnti`/`RightAnti` are the variants
4006            // that were previously collapsed.
4007            let krishiv_join_type = match join.join_type {
4008                datafusion::common::JoinType::Inner => krishiv_plan::JoinType::Inner,
4009                datafusion::common::JoinType::Left => krishiv_plan::JoinType::Left,
4010                datafusion::common::JoinType::Right => krishiv_plan::JoinType::Right,
4011                datafusion::common::JoinType::Full => krishiv_plan::JoinType::Full,
4012                datafusion::common::JoinType::LeftSemi => krishiv_plan::JoinType::LeftSemi,
4013                datafusion::common::JoinType::RightSemi => krishiv_plan::JoinType::RightSemi,
4014                datafusion::common::JoinType::LeftAnti => krishiv_plan::JoinType::LeftAnti,
4015                datafusion::common::JoinType::RightAnti => krishiv_plan::JoinType::RightAnti,
4016                // DataFusion also exposes `LeftMark`/`RightMark` for some
4017                // subquery-rewritten plans; treat them as Semi for now to
4018                // preserve the prior behaviour. Future work can split them.
4019                datafusion::common::JoinType::LeftMark => krishiv_plan::JoinType::LeftSemi,
4020                datafusion::common::JoinType::RightMark => krishiv_plan::JoinType::RightSemi,
4021            };
4022            nodes.push(
4023                PlanNode::new(&id, "Join", ExecutionKind::Batch)
4024                    .with_op(NodeOp::Join {
4025                        join_type: krishiv_join_type,
4026                    })
4027                    .with_inputs([left_id, right_id]),
4028            );
4029            (nodes, id)
4030        }
4031
4032        DfPlan::Sort(sort) => {
4033            let (mut nodes, input_id) =
4034                df_plan_to_krishiv_nodes(&sort.input, table_row_counts, counter);
4035            let id = format!("sort-{idx}");
4036            nodes.push(
4037                PlanNode::new(&id, "Sort", ExecutionKind::Batch)
4038                    .with_op(NodeOp::Other {
4039                        description: format!(
4040                            "Sort({})",
4041                            sort.expr
4042                                .iter()
4043                                .map(|e| e.to_string())
4044                                .collect::<Vec<_>>()
4045                                .join(", ")
4046                        ),
4047                    })
4048                    .with_inputs([input_id]),
4049            );
4050            (nodes, id)
4051        }
4052
4053        DfPlan::Repartition(repart) => {
4054            let (mut nodes, input_id) =
4055                df_plan_to_krishiv_nodes(&repart.input, table_row_counts, counter);
4056            let id = format!("exchange-{idx}");
4057            let partitioning = krishiv_plan::Partitioning::Unpartitioned;
4058            nodes.push(
4059                PlanNode::new(&id, "Exchange", ExecutionKind::Batch)
4060                    .with_op(NodeOp::Exchange { partitioning })
4061                    .with_inputs([input_id]),
4062            );
4063            (nodes, id)
4064        }
4065
4066        DfPlan::Limit(limit) => {
4067            let (mut nodes, input_id) =
4068                df_plan_to_krishiv_nodes(&limit.input, table_row_counts, counter);
4069            let id = format!("limit-{idx}");
4070            nodes.push(
4071                PlanNode::new(&id, "Limit", ExecutionKind::Batch)
4072                    .with_op(NodeOp::Other {
4073                        description: format!(
4074                            "Limit(skip={:?}, fetch={:?})",
4075                            limit.skip.as_ref().map(|e| e.to_string()),
4076                            limit.fetch.as_ref().map(|e| e.to_string()),
4077                        ),
4078                    })
4079                    .with_inputs([input_id]),
4080            );
4081            (nodes, id)
4082        }
4083
4084        DfPlan::Union(union) if union.inputs.len() == 1 => {
4085            if let Some(input) = union.inputs.first() {
4086                df_plan_to_krishiv_nodes(input, table_row_counts, counter)
4087            } else {
4088                (Vec::new(), String::new())
4089            }
4090        }
4091        DfPlan::Union(union) => {
4092            let mut all_nodes = Vec::new();
4093            let mut input_ids = Vec::new();
4094            for input in &union.inputs {
4095                let (sub_nodes, sub_id) =
4096                    df_plan_to_krishiv_nodes(input, table_row_counts, counter);
4097                all_nodes.extend(sub_nodes);
4098                input_ids.push(sub_id);
4099            }
4100            let id = format!("union-{idx}");
4101            all_nodes.push(
4102                PlanNode::new(&id, "Union", ExecutionKind::Batch)
4103                    .with_op(NodeOp::Other {
4104                        description: "Union".to_string(),
4105                    })
4106                    .with_inputs(input_ids),
4107            );
4108            (all_nodes, id)
4109        }
4110
4111        DfPlan::SubqueryAlias(alias) => {
4112            // SubqueryAlias is transparent; peel it and continue.
4113            df_plan_to_krishiv_nodes(&alias.input, table_row_counts, counter)
4114        }
4115
4116        DfPlan::Values(_) => {
4117            let id = format!("values-{idx}");
4118            let node = PlanNode::new(&id, "Values", ExecutionKind::Batch).with_op(NodeOp::Other {
4119                description: "Values".to_string(),
4120            });
4121            (vec![node], id)
4122        }
4123
4124        DfPlan::Extension(_) => {
4125            let id = format!("ext-{idx}");
4126            let label = plan.to_string();
4127            let node = PlanNode::new(&id, label.clone(), ExecutionKind::Batch)
4128                .with_op(NodeOp::Other { description: label });
4129            (vec![node], id)
4130        }
4131
4132        DfPlan::EmptyRelation(_) => {
4133            let id = format!("empty-{idx}");
4134            let node =
4135                PlanNode::new(&id, "EmptyRelation", ExecutionKind::Batch).with_op(NodeOp::Other {
4136                    description: "EmptyRelation".to_string(),
4137                });
4138            (vec![node], id)
4139        }
4140
4141        // Fallback: wrap the entire subplan as an opaque node.
4142        _ => {
4143            let id = format!("df-{idx}");
4144            let label = plan.to_string();
4145            let node = PlanNode::new(&id, label.clone(), ExecutionKind::Batch)
4146                .with_op(NodeOp::Other { description: label });
4147            (vec![node], id)
4148        }
4149    }
4150}
4151
4152/// Krishiv-owned wrapper around a DataFusion DataFrame.
4153#[derive(Clone)]
4154pub struct SqlDataFrame {
4155    name: String,
4156    query: Option<String>,
4157    /// Alias for `query` used by `create_view` — same value.
4158    query_text: Option<String>,
4159    execution_kind: ExecutionKind,
4160    dataframe: DataFusionDataFrame,
4161    shuffle_partitions: Option<u32>,
4162    /// Shared session context for table registration (cache/view operations).
4163    context: SessionContext,
4164    /// Estimated row counts for registered tables, keyed by table name.
4165    /// Used by `krishiv_logical_plan` to annotate scan nodes with
4166    /// `estimated_rows` so `BroadcastAutoRule` can fire.
4167    table_row_counts: Arc<std::sync::RwLock<HashMap<String, u64>>>,
4168}
4169
4170impl fmt::Debug for SqlDataFrame {
4171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4172        f.debug_struct("SqlDataFrame")
4173            .field("name", &self.name)
4174            .field("query", &self.query)
4175            .field("shuffle_partitions", &self.shuffle_partitions)
4176            .finish_non_exhaustive()
4177    }
4178}
4179
4180impl SqlDataFrame {
4181    fn new(
4182        name: impl Into<String>,
4183        dataframe: DataFusionDataFrame,
4184        table_row_counts: Arc<std::sync::RwLock<HashMap<String, u64>>>,
4185    ) -> Self {
4186        Self {
4187            name: name.into(),
4188            query: None,
4189            query_text: None,
4190            execution_kind: ExecutionKind::Batch,
4191            dataframe,
4192            shuffle_partitions: None,
4193            context: SessionContext::default(),
4194            table_row_counts,
4195        }
4196    }
4197
4198    /// Attach the session context so cache/view operations share the live session.
4199    pub(crate) fn with_context(mut self, context: SessionContext) -> Self {
4200        self.context = context;
4201        self
4202    }
4203
4204    fn with_query(mut self, query: impl Into<String>) -> Self {
4205        let q = query.into();
4206        self.query_text = Some(q.clone());
4207        self.query = Some(q);
4208        self
4209    }
4210
4211    fn with_execution_kind(mut self, kind: ExecutionKind) -> Self {
4212        self.execution_kind = kind;
4213        self
4214    }
4215
4216    fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
4217        self.shuffle_partitions = n;
4218        self
4219    }
4220
4221    /// Original SQL query when created from [`SqlEngine::sql`].
4222    pub fn query(&self) -> Option<&str> {
4223        self.query.as_deref()
4224    }
4225
4226    /// The Arrow schema of this DataFrame's output.
4227    ///
4228    /// Available immediately after planning — no execution happens. Used by
4229    /// the Flight SQL server to populate `dataset_schema` on prepared
4230    /// statements so JDBC clients can route query-vs-update correctly.
4231    pub fn arrow_schema(&self) -> arrow::datatypes::SchemaRef {
4232        std::sync::Arc::new(self.dataframe.schema().as_arrow().clone())
4233    }
4234
4235    /// Return a new `SqlDataFrame` with the given DataFusion DataFrame,
4236    /// preserving the rest of this instance's state.  The new name suffix
4237    /// helps distinguish transform steps in logical-plan descriptions.
4238    fn with_new_dataframe(&self, df: DataFusionDataFrame, tag: &str) -> Self {
4239        Self {
4240            name: format!("{}-{}", self.name, tag),
4241            query: None,
4242            query_text: None,
4243            execution_kind: self.execution_kind,
4244            dataframe: df,
4245            shuffle_partitions: self.shuffle_partitions,
4246            context: self.context.clone(),
4247            table_row_counts: self.table_row_counts.clone(),
4248        }
4249    }
4250
4251    /// Create a Krishiv logical plan wrapper for this DataFrame.
4252    ///
4253    /// Walks the DataFusion logical plan tree, creating Krishiv `PlanNode`
4254    /// entries for each operator. Table-scan nodes are annotated with
4255    /// `estimated_rows` from the engine's table-row-count registry, allowing
4256    /// `BroadcastAutoRule` to identify small tables for broadcast join
4257    /// promotion. The plan is then run through the logical optimizer before
4258    /// being returned.
4259    pub fn krishiv_logical_plan(&self) -> LogicalPlan {
4260        let df_plan = self.dataframe.logical_plan();
4261        let counts = self
4262            .table_row_counts
4263            .read()
4264            .unwrap_or_else(|e| e.into_inner());
4265        let mut counter = 0usize;
4266        let (nodes, _root_id) = df_plan_to_krishiv_nodes(df_plan, &counts, &mut counter);
4267
4268        let mut plan = LogicalPlan::new(self.name.clone(), self.execution_kind);
4269        for node in nodes {
4270            plan = plan.with_node(node);
4271        }
4272
4273        // Run the logical optimizer so BroadcastAutoRule fires on eligible scans.
4274        // An optimizer failure falls back to the unoptimized (still valid) plan;
4275        // execution correctness does not depend on optimization, but the failure
4276        // must be observable rather than silent.
4277        let optimizer = krishiv_plan::optimizer::default_logical_optimizer();
4278        let fallback = plan.clone();
4279        match optimizer.optimize(plan) {
4280            Ok(result) => result.plan,
4281            Err(error) => {
4282                tracing::warn!(
4283                    plan = %self.name,
4284                    %error,
4285                    "logical optimizer failed; using unoptimized plan"
4286                );
4287                fallback
4288            }
4289        }
4290    }
4291
4292    /// Explain the logical plan without executing it.
4293    pub fn explain_logical(&self) -> String {
4294        self.dataframe.logical_plan().to_string()
4295    }
4296
4297    /// Explain logical and physical plan details through DataFusion.
4298    pub async fn explain(&self) -> SqlResult<String> {
4299        let batches = self
4300            .dataframe
4301            .clone()
4302            .explain(false, false)?
4303            .collect()
4304            .await?;
4305        pretty_batches(&batches)
4306    }
4307
4308    /// Execute the query and report per-operator runtime metrics.
4309    ///
4310    /// `explain` alone shows the plan the optimizer produced, which is enough
4311    /// to confirm a query is *planned* well and not enough to explain why it
4312    /// is slow. TPC-H q17 plans correctly here — decorrelated into a grouped
4313    /// aggregate plus two partitioned hash joins, with projection and
4314    /// predicate pushdown both active — and still runs ~6x DuckDB. The plan
4315    /// shows `DynamicFilter [ empty ]` on the lineitem scans because dynamic
4316    /// filters are populated at run time, so a static EXPLAIN can never say
4317    /// whether the join's build side actually pruned the probe scan.
4318    ///
4319    /// Answering that needs the counters: rows emitted per operator, row
4320    /// groups pruned, bytes scanned, time per partition. Those only exist
4321    /// after execution, which is what `analyze` turns on.
4322    pub async fn explain_analyze(&self) -> SqlResult<String> {
4323        let batches = self
4324            .dataframe
4325            .clone()
4326            .explain(false, true)?
4327            .collect()
4328            .await?;
4329        pretty_batches(&batches)
4330    }
4331
4332    /// Execute and collect this DataFrame.
4333    ///
4334    /// Boxed at the definition — see the compile-time note on
4335    /// [`SqlEngine::sql`].
4336    pub fn collect(
4337        &self,
4338    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = SqlResult<Vec<RecordBatch>>> + Send + '_>>
4339    {
4340        Box::pin(async move { Ok(self.dataframe.clone().collect().await?) })
4341    }
4342
4343    /// Execute and return a record batch stream.
4344    ///
4345    /// Boxed at the definition (like [`SqlEngine::sql`]) so the DataFusion
4346    /// planning future inside never leaks as an opaque type to consumer
4347    /// crates — see the compile-time note on `sql`.
4348    pub fn execute_stream(
4349        &self,
4350    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = SqlResult<SqlStream>> + Send + '_>>
4351    {
4352        Box::pin(self.execute_stream_boxed_body())
4353    }
4354
4355    async fn execute_stream_boxed_body(&self) -> SqlResult<SqlStream> {
4356        Ok(self.execute_stream_with_schema_boxed_body().await?.1)
4357    }
4358
4359    /// Execute, returning the stream **and the schema its batches actually
4360    /// carry**.
4361    ///
4362    /// [`SqlStream`] is a bare `Pin<Box<dyn Stream>>`, so wrapping
4363    /// DataFusion's stream throws away the one thing only it knows: the
4364    /// physical output schema. `KrishivDataFrameOps::schema` is not a
4365    /// substitute — that is the *logical* schema, and physical planning
4366    /// re-types expressions. TPC-H q17's `avg(l_quantity)` is
4367    /// `Decimal128(15, 2)` logically and `Decimal128(30, 15)` in the batches,
4368    /// and a caller that labelled those batches with the logical schema
4369    /// produced shuffle partitions whose rows violated their own schema.
4370    ///
4371    /// Callers that need to declare a schema before the first batch arrives —
4372    /// or when no batch ever arrives — want this, not `schema()`.
4373    pub fn execute_stream_with_schema(
4374        &self,
4375    ) -> futures::future::BoxFuture<'_, SqlResult<(SchemaRef, SqlStream)>> {
4376        Box::pin(self.execute_stream_with_schema_boxed_body())
4377    }
4378
4379    async fn execute_stream_with_schema_boxed_body(&self) -> SqlResult<(SchemaRef, SqlStream)> {
4380        let df_stream = self.dataframe.clone().execute_stream().await?;
4381        let schema = df_stream.schema();
4382        use futures::StreamExt;
4383        let mapped = df_stream.map(|res| {
4384            res.map_err(|e| SqlError::DataFusion {
4385                message: e.to_string(),
4386            })
4387        });
4388        Ok((schema, Box::pin(mapped)))
4389    }
4390
4391    /// Execute and collect this DataFrame, also returning lightweight runtime statistics.
4392    ///
4393    /// Collects `output_rows` from DataFusion's execution metrics. `cpu_nanos`
4394    /// is approximated from `elapsed_compute` when available. `spill_bytes`
4395    /// and `spill_count` are aggregated across every operator in the physical
4396    /// plan tree (sorts, hash joins, and aggregations report spills when the
4397    /// memory pool forces them to disk); other fields default to 0.
4398    pub fn collect_with_stats(
4399        &self,
4400    ) -> futures::future::BoxFuture<'_, SqlResult<(Vec<RecordBatch>, SqlExecutionStats)>> {
4401        Box::pin(self.collect_with_stats_boxed_body())
4402    }
4403
4404    async fn collect_with_stats_boxed_body(
4405        &self,
4406    ) -> SqlResult<(Vec<RecordBatch>, SqlExecutionStats)> {
4407        use datafusion::physical_plan::collect as df_collect;
4408
4409        let df = self.dataframe.clone();
4410        let task_ctx = df.task_ctx();
4411        let physical_plan = df.create_physical_plan().await?;
4412
4413        let batches = df_collect(physical_plan.clone(), task_ctx.into()).await?;
4414
4415        let mut output_rows: u64 = batches.iter().map(|b| b.num_rows() as u64).sum();
4416        let mut cpu_nanos: u64 = 0;
4417
4418        if let Some(metrics) = physical_plan.metrics() {
4419            if let Some(v) = metrics.output_rows() {
4420                output_rows = v as u64;
4421            }
4422            if let Some(t) = metrics.elapsed_compute() {
4423                cpu_nanos = t as u64;
4424            }
4425        }
4426
4427        let (spill_bytes, spill_count) = aggregate_spill_metrics(physical_plan.as_ref());
4428
4429        Ok((
4430            batches,
4431            SqlExecutionStats {
4432                output_rows,
4433                cpu_nanos,
4434                spill_bytes,
4435                spill_count,
4436            },
4437        ))
4438    }
4439
4440    /// Execute this DataFrame as a record-batch stream, returning a stats
4441    /// handle that reads the same runtime metrics as [`collect_with_stats`]
4442    /// once the stream has been fully drained.
4443    ///
4444    /// Unlike `collect_with_stats`, the caller never holds more than one
4445    /// batch in memory — the intended path for large results that are
4446    /// spooled to disk or written straight into a sink.
4447    ///
4448    /// [`collect_with_stats`]: Self::collect_with_stats
4449    pub fn execute_stream_with_stats(
4450        &self,
4451    ) -> futures::future::BoxFuture<'_, SqlResult<(SqlStream, SqlStatsHandle)>> {
4452        Box::pin(self.execute_stream_with_stats_boxed_body())
4453    }
4454
4455    async fn execute_stream_with_stats_boxed_body(&self) -> SqlResult<(SqlStream, SqlStatsHandle)> {
4456        use futures::StreamExt;
4457
4458        let df = self.dataframe.clone();
4459        let task_ctx = df.task_ctx();
4460        let physical_plan = df.create_physical_plan().await?;
4461        let df_stream = datafusion::physical_plan::execute_stream(
4462            physical_plan.clone(),
4463            std::sync::Arc::new(task_ctx),
4464        )?;
4465        let mapped = df_stream.map(|res| {
4466            res.map_err(|e| SqlError::DataFusion {
4467                message: e.to_string(),
4468            })
4469        });
4470        Ok((
4471            Box::pin(mapped),
4472            SqlStatsHandle {
4473                plan: physical_plan,
4474            },
4475        ))
4476    }
4477}
4478
4479/// Handle onto a streamed execution's physical plan; reads runtime metrics
4480/// (output rows, CPU time, spill totals) after the stream is drained.
4481pub struct SqlStatsHandle {
4482    plan: std::sync::Arc<dyn datafusion::physical_plan::ExecutionPlan>,
4483}
4484
4485impl SqlStatsHandle {
4486    /// Aggregate execution statistics from the plan's runtime metrics.
4487    ///
4488    /// Only meaningful once the associated stream has been fully consumed;
4489    /// calling earlier reports the metrics accumulated so far.
4490    pub fn stats(&self) -> SqlExecutionStats {
4491        let mut output_rows: u64 = 0;
4492        let mut cpu_nanos: u64 = 0;
4493        if let Some(metrics) = self.plan.metrics() {
4494            if let Some(v) = metrics.output_rows() {
4495                output_rows = v as u64;
4496            }
4497            if let Some(t) = metrics.elapsed_compute() {
4498                cpu_nanos = t as u64;
4499            }
4500        }
4501        let (spill_bytes, spill_count) = aggregate_spill_metrics(self.plan.as_ref());
4502        SqlExecutionStats {
4503            output_rows,
4504            cpu_nanos,
4505            spill_bytes,
4506            spill_count,
4507        }
4508    }
4509}
4510
4511/// Recursively sum `spilled_bytes` and `spill_count` metrics across every
4512/// operator in a physical plan tree.
4513///
4514/// The root node's `metrics()` only reflects the root operator; spilling
4515/// happens in interior sort/join/aggregate nodes, so the whole tree must be
4516/// walked to account for all disk spill activity.
4517fn aggregate_spill_metrics(plan: &dyn datafusion::physical_plan::ExecutionPlan) -> (u64, u64) {
4518    let mut spill_bytes: u64 = 0;
4519    let mut spill_count: u64 = 0;
4520    if let Some(metrics) = plan.metrics() {
4521        if let Some(bytes) = metrics.spilled_bytes() {
4522            spill_bytes = spill_bytes.saturating_add(bytes as u64);
4523        }
4524        if let Some(count) = metrics.spill_count() {
4525            spill_count = spill_count.saturating_add(count as u64);
4526        }
4527    }
4528    for child in plan.children() {
4529        let (child_bytes, child_count) = aggregate_spill_metrics(child.as_ref());
4530        spill_bytes = spill_bytes.saturating_add(child_bytes);
4531        spill_count = spill_count.saturating_add(child_count);
4532    }
4533    (spill_bytes, spill_count)
4534}
4535
4536/// Lightweight execution statistics collected from a DataFusion physical plan.
4537#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
4538pub struct SqlExecutionStats {
4539    pub output_rows: u64,
4540    pub cpu_nanos: u64,
4541    /// Total bytes spilled to disk across all operators in the plan.
4542    pub spill_bytes: u64,
4543    /// Number of spill events (roughly: spill files written) across all operators.
4544    pub spill_count: u64,
4545}
4546
4547fn top_level_alias_index(expression: &str) -> Option<usize> {
4548    let bytes = expression.as_bytes();
4549    let mut depth = 0usize;
4550    let mut single_quoted = false;
4551    let mut double_quoted = false;
4552    let mut candidate = None;
4553    let mut index = 0usize;
4554    while index < bytes.len() {
4555        let Some(&byte) = bytes.get(index) else {
4556            break;
4557        };
4558        match byte {
4559            b'\'' if !double_quoted => {
4560                if single_quoted && bytes.get(index + 1) == Some(&b'\'') {
4561                    index += 2;
4562                    continue;
4563                }
4564                single_quoted = !single_quoted;
4565            }
4566            b'"' if !single_quoted => {
4567                if double_quoted && bytes.get(index + 1) == Some(&b'"') {
4568                    index += 2;
4569                    continue;
4570                }
4571                double_quoted = !double_quoted;
4572            }
4573            b'(' if !single_quoted && !double_quoted => depth += 1,
4574            b')' if !single_quoted && !double_quoted => depth = depth.saturating_sub(1),
4575            b' ' if depth == 0
4576                && !single_quoted
4577                && !double_quoted
4578                && bytes
4579                    .get(index..index + 4)
4580                    .is_some_and(|slice| slice.eq_ignore_ascii_case(b" AS ")) =>
4581            {
4582                candidate = Some(index);
4583                index += 3;
4584            }
4585            _ => {}
4586        }
4587        index += 1;
4588    }
4589    candidate
4590}
4591
4592fn parse_dataframe_expression(
4593    dataframe: &datafusion::dataframe::DataFrame,
4594    expression: &str,
4595) -> SqlResult<datafusion::logical_expr::Expr> {
4596    if let Some(index) = top_level_alias_index(expression) {
4597        let (body, alias) = expression.split_at(index);
4598        let alias = alias[4..].trim();
4599        if !alias.is_empty() {
4600            let alias = alias
4601                .strip_prefix('"')
4602                .and_then(|value| value.strip_suffix('"'))
4603                .unwrap_or(alias)
4604                .replace("\"\"", "\"");
4605            return Ok(dataframe.parse_sql_expr(body.trim())?.alias(alias));
4606        }
4607    }
4608    dataframe.parse_sql_expr(expression).map_err(Into::into)
4609}
4610
4611/// Parse the stable SQL-expression subset into the same engine-owned AST used by Rust and Python.
4612pub fn parse_public_expression(sql: &str) -> SqlResult<krishiv_plan::expression::Expr> {
4613    let dialect = GenericDialect {};
4614    let mut parser =
4615        Parser::new(&dialect)
4616            .try_with_sql(sql)
4617            .map_err(|error| SqlError::Unsupported {
4618                feature: format!("public expression parse: {error}"),
4619            })?;
4620    let expression = parser.parse_expr().map_err(|error| SqlError::Unsupported {
4621        feature: format!("public expression parse: {error}"),
4622    })?;
4623    sqlparser_expression_to_public(&expression)
4624}
4625
4626fn sqlparser_expression_to_public(
4627    expression: &datafusion::sql::sqlparser::ast::Expr,
4628) -> SqlResult<krishiv_plan::expression::Expr> {
4629    use datafusion::sql::sqlparser::ast::{BinaryOperator as SqlOperator, Expr as SqlExpr, Value};
4630    use krishiv_plan::expression::{BinaryOperator, Expr, ScalarValue};
4631
4632    Ok(match expression {
4633        SqlExpr::Identifier(identifier) => Expr::Column {
4634            path: vec![identifier.value.clone()],
4635        },
4636        SqlExpr::CompoundIdentifier(identifiers) => Expr::Column {
4637            path: identifiers
4638                .iter()
4639                .map(|identifier| identifier.value.clone())
4640                .collect(),
4641        },
4642        SqlExpr::Nested(expression) => sqlparser_expression_to_public(expression)?,
4643        SqlExpr::IsNull(expression) => Expr::IsNull {
4644            expression: Box::new(sqlparser_expression_to_public(expression)?),
4645            negated: false,
4646        },
4647        SqlExpr::IsNotNull(expression) => Expr::IsNull {
4648            expression: Box::new(sqlparser_expression_to_public(expression)?),
4649            negated: true,
4650        },
4651        SqlExpr::BinaryOp { left, op, right } => Expr::Binary {
4652            left: Box::new(sqlparser_expression_to_public(left)?),
4653            op: match op {
4654                SqlOperator::Eq => BinaryOperator::Eq,
4655                SqlOperator::NotEq => BinaryOperator::NotEq,
4656                SqlOperator::Gt => BinaryOperator::Gt,
4657                SqlOperator::GtEq => BinaryOperator::GtEq,
4658                SqlOperator::Lt => BinaryOperator::Lt,
4659                SqlOperator::LtEq => BinaryOperator::LtEq,
4660                SqlOperator::And => BinaryOperator::And,
4661                SqlOperator::Or => BinaryOperator::Or,
4662                SqlOperator::Plus => BinaryOperator::Plus,
4663                SqlOperator::Minus => BinaryOperator::Minus,
4664                SqlOperator::Multiply => BinaryOperator::Multiply,
4665                SqlOperator::Divide => BinaryOperator::Divide,
4666                other => {
4667                    return Err(SqlError::Unsupported {
4668                        feature: format!("public expression operator {other}"),
4669                    });
4670                }
4671            },
4672            right: Box::new(sqlparser_expression_to_public(right)?),
4673        },
4674        SqlExpr::Value(value) => Expr::Literal {
4675            value: match &value.value {
4676                Value::Null => ScalarValue::Null,
4677                Value::Boolean(value) => ScalarValue::Boolean(*value),
4678                Value::SingleQuotedString(value) => ScalarValue::Utf8(value.clone()),
4679                Value::Number(value, _)
4680                    if value.contains('.') || value.contains('e') || value.contains('E') =>
4681                {
4682                    ScalarValue::float64(value.parse::<f64>().map_err(|error| {
4683                        SqlError::Unsupported {
4684                            feature: format!("numeric expression literal: {error}"),
4685                        }
4686                    })?)
4687                }
4688                Value::Number(value, _) => {
4689                    ScalarValue::Int64(value.parse::<i64>().map_err(|error| {
4690                        SqlError::Unsupported {
4691                            feature: format!("integer expression literal: {error}"),
4692                        }
4693                    })?)
4694                }
4695                other => {
4696                    return Err(SqlError::Unsupported {
4697                        feature: format!("public expression literal {other}"),
4698                    });
4699                }
4700            },
4701        },
4702        other => {
4703            return Err(SqlError::Unsupported {
4704                feature: format!("public expression node {other}"),
4705            });
4706        }
4707    })
4708}
4709
4710fn public_data_type_to_arrow(
4711    data_type: &krishiv_plan::expression::ExprDataType,
4712) -> arrow::datatypes::DataType {
4713    use arrow::datatypes::{DataType, Field, IntervalUnit, TimeUnit};
4714    use krishiv_plan::expression::{ExprDataType, IntervalUnit as PublicIntervalUnit};
4715
4716    match data_type {
4717        ExprDataType::Null => DataType::Null,
4718        ExprDataType::Boolean => DataType::Boolean,
4719        ExprDataType::Int64 => DataType::Int64,
4720        ExprDataType::UInt64 => DataType::UInt64,
4721        ExprDataType::Float64 => DataType::Float64,
4722        ExprDataType::Utf8 => DataType::Utf8,
4723        ExprDataType::Binary => DataType::Binary,
4724        ExprDataType::Decimal128 { precision, scale } => DataType::Decimal128(*precision, *scale),
4725        ExprDataType::Date32 => DataType::Date32,
4726        ExprDataType::Timestamp { unit, timezone } => DataType::Timestamp(
4727            match unit {
4728                krishiv_plan::expression::TimeUnit::Second => TimeUnit::Second,
4729                krishiv_plan::expression::TimeUnit::Millisecond => TimeUnit::Millisecond,
4730                krishiv_plan::expression::TimeUnit::Microsecond => TimeUnit::Microsecond,
4731                krishiv_plan::expression::TimeUnit::Nanosecond => TimeUnit::Nanosecond,
4732            },
4733            timezone.clone().map(Into::into),
4734        ),
4735        ExprDataType::Interval { unit } => DataType::Interval(match unit {
4736            PublicIntervalUnit::YearMonth => IntervalUnit::YearMonth,
4737            PublicIntervalUnit::DayTime => IntervalUnit::DayTime,
4738            PublicIntervalUnit::MonthDayNano => IntervalUnit::MonthDayNano,
4739        }),
4740        ExprDataType::List(element) => DataType::List(Arc::new(Field::new(
4741            "item",
4742            public_data_type_to_arrow(element),
4743            true,
4744        ))),
4745        ExprDataType::Map { key, value } => DataType::Map(
4746            Arc::new(Field::new(
4747                "entries",
4748                DataType::Struct(
4749                    vec![
4750                        Arc::new(Field::new("key", public_data_type_to_arrow(key), false)),
4751                        Arc::new(Field::new("value", public_data_type_to_arrow(value), true)),
4752                    ]
4753                    .into(),
4754                ),
4755                false,
4756            )),
4757            false,
4758        ),
4759        ExprDataType::Struct(fields) => DataType::Struct(
4760            fields
4761                .iter()
4762                .map(|field| {
4763                    Arc::new(Field::new(
4764                        &field.name,
4765                        public_data_type_to_arrow(&field.data_type),
4766                        field.nullable,
4767                    ))
4768                })
4769                .collect::<Vec<_>>()
4770                .into(),
4771        ),
4772        // Variant: stored as JSON-encoded UTF-8 until Arrow gains a
4773        // native variant logical type. Read/write paths use Utf8
4774        // columns and the datafusion engine treats the values as
4775        // opaque strings.
4776        ExprDataType::Variant => DataType::Utf8,
4777    }
4778}
4779
4780fn public_scalar_to_datafusion(
4781    value: &krishiv_plan::expression::ScalarValue,
4782) -> Option<datafusion::common::ScalarValue> {
4783    use datafusion::common::ScalarValue;
4784    use krishiv_plan::expression::{ScalarValue as PublicScalar, TimeUnit};
4785
4786    Some(match value {
4787        PublicScalar::Null => ScalarValue::Null,
4788        PublicScalar::Boolean(value) => ScalarValue::Boolean(Some(*value)),
4789        PublicScalar::Int64(value) => ScalarValue::Int64(Some(*value)),
4790        PublicScalar::UInt64(value) => ScalarValue::UInt64(Some(*value)),
4791        PublicScalar::Float64(bits) => ScalarValue::Float64(Some(f64::from_bits(*bits))),
4792        PublicScalar::Utf8(value) => ScalarValue::Utf8(Some(value.clone())),
4793        PublicScalar::Binary(value) => ScalarValue::Binary(Some(value.clone())),
4794        PublicScalar::Decimal128 {
4795            value,
4796            precision,
4797            scale,
4798        } => ScalarValue::Decimal128(Some(*value), *precision, *scale),
4799        PublicScalar::Date32(value) => ScalarValue::Date32(Some(*value)),
4800        PublicScalar::Timestamp {
4801            value,
4802            unit,
4803            timezone,
4804        } => {
4805            let timezone = timezone.clone().map(Into::into);
4806            match unit {
4807                TimeUnit::Second => ScalarValue::TimestampSecond(Some(*value), timezone),
4808                TimeUnit::Millisecond => ScalarValue::TimestampMillisecond(Some(*value), timezone),
4809                TimeUnit::Microsecond => ScalarValue::TimestampMicrosecond(Some(*value), timezone),
4810                TimeUnit::Nanosecond => ScalarValue::TimestampNanosecond(Some(*value), timezone),
4811            }
4812        }
4813        PublicScalar::Interval { .. } => return None,
4814    })
4815}
4816
4817/// Lower the versioned engine-owned expression contract into a DataFusion expression.
4818///
4819/// Ordinary nodes are lowered structurally. `RawSql`, generic function calls, aggregate
4820/// calls, and interval literals intentionally use DataFusion's SQL analyzer as the
4821/// compatibility/preview path until those families receive dedicated typed nodes.
4822fn lower_public_expression(
4823    dataframe: &datafusion::dataframe::DataFrame,
4824    expression: &krishiv_plan::expression::Expr,
4825) -> SqlResult<datafusion::logical_expr::Expr> {
4826    expression
4827        .validate()
4828        .map_err(|error| SqlError::Unsupported {
4829            feature: format!("invalid public expression: {error}"),
4830        })?;
4831    use datafusion::logical_expr::{Expr as DataFusionExpr, Operator, binary_expr, cast, try_cast};
4832    use krishiv_plan::expression::{BinaryOperator, Expr};
4833
4834    Ok(match expression {
4835        Expr::Column { path } if path.len() == 1 => {
4836            datafusion::prelude::col(path.first().map(String::as_str).unwrap_or(""))
4837        }
4838        Expr::Column { .. } => parse_dataframe_expression(dataframe, &expression.to_sql())?,
4839        Expr::Literal { value } => match public_scalar_to_datafusion(value) {
4840            Some(value) => DataFusionExpr::Literal(value, None),
4841            None => parse_dataframe_expression(dataframe, &expression.to_sql())?,
4842        },
4843        Expr::Alias { expression, name } => {
4844            lower_public_expression(dataframe, expression)?.alias(name)
4845        }
4846        Expr::Binary { left, op, right } => binary_expr(
4847            lower_public_expression(dataframe, left)?,
4848            match op {
4849                BinaryOperator::Eq => Operator::Eq,
4850                BinaryOperator::NotEq => Operator::NotEq,
4851                BinaryOperator::Gt => Operator::Gt,
4852                BinaryOperator::GtEq => Operator::GtEq,
4853                BinaryOperator::Lt => Operator::Lt,
4854                BinaryOperator::LtEq => Operator::LtEq,
4855                BinaryOperator::And => Operator::And,
4856                BinaryOperator::Or => Operator::Or,
4857                BinaryOperator::Plus => Operator::Plus,
4858                BinaryOperator::Minus => Operator::Minus,
4859                BinaryOperator::Multiply => Operator::Multiply,
4860                BinaryOperator::Divide => Operator::Divide,
4861            },
4862            lower_public_expression(dataframe, right)?,
4863        ),
4864        Expr::IsNull {
4865            expression,
4866            negated,
4867        } => {
4868            let expression = lower_public_expression(dataframe, expression)?;
4869            if *negated {
4870                expression.is_not_null()
4871            } else {
4872                expression.is_null()
4873            }
4874        }
4875        Expr::Cast {
4876            expression,
4877            data_type,
4878            safe,
4879        } => {
4880            let expression = lower_public_expression(dataframe, expression)?;
4881            let data_type = public_data_type_to_arrow(data_type);
4882            if *safe {
4883                try_cast(expression, data_type)
4884            } else {
4885                cast(expression, data_type)
4886            }
4887        }
4888        Expr::Sort { .. } => {
4889            return Err(SqlError::Unsupported {
4890                feature: "standalone sort expressions are only valid inside windows or order_by"
4891                    .into(),
4892            });
4893        }
4894        Expr::Aggregate { .. }
4895        | Expr::Function { .. }
4896        | Expr::Window { .. }
4897        | Expr::RawSql { .. } => parse_dataframe_expression(dataframe, &expression.to_sql())?,
4898    })
4899}
4900
4901fn sql_dataframe<'a>(
4902    dataframe: &'a dyn KrishivDataFrameOps,
4903    operation: &str,
4904) -> SqlResult<&'a SqlDataFrame> {
4905    dataframe
4906        .as_any()
4907        .downcast_ref::<SqlDataFrame>()
4908        .ok_or_else(|| SqlError::DataFusion {
4909            message: format!("right DataFrame must be SqlDataFrame for {operation}"),
4910        })
4911}
4912
4913#[async_trait::async_trait]
4914impl KrishivDataFrameOps for SqlDataFrame {
4915    async fn collect(&self) -> SqlResult<Vec<RecordBatch>> {
4916        SqlDataFrame::collect(self).await
4917    }
4918    async fn collect_with_stats(&self) -> SqlResult<(Vec<RecordBatch>, SqlExecutionStats)> {
4919        SqlDataFrame::collect_with_stats(self).await
4920    }
4921    async fn explain_analyze(&self) -> SqlResult<String> {
4922        SqlDataFrame::explain_analyze(self).await
4923    }
4924
4925    async fn explain(&self) -> SqlResult<String> {
4926        SqlDataFrame::explain(self).await
4927    }
4928    fn explain_logical(&self) -> String {
4929        SqlDataFrame::explain_logical(self)
4930    }
4931    fn krishiv_logical_plan(&self) -> LogicalPlan {
4932        let label = self.dataframe.logical_plan().to_string();
4933        let mut plan = LogicalPlan::new(self.name.clone(), ExecutionKind::Batch).with_node(
4934            PlanNode::new("datafusion-logical", label, ExecutionKind::Batch),
4935        );
4936        if let Some(n) = self.shuffle_partitions {
4937            plan = plan.with_shuffle_partitions(Some(n));
4938        }
4939        plan
4940    }
4941    fn query(&self) -> Option<&str> {
4942        SqlDataFrame::query(self)
4943    }
4944    fn to_sql(&self) -> SqlResult<String> {
4945        // Unparse the CURRENT DataFusion logical plan (reflects all transforms).
4946        // Fall back to the original query string if unparsing is unavailable.
4947        match datafusion::sql::unparser::plan_to_sql(self.dataframe.logical_plan()) {
4948            Ok(statement) => Ok(statement.to_string()),
4949            Err(err) => self
4950                .query()
4951                .map(str::to_string)
4952                .ok_or_else(|| SqlError::Unsupported {
4953                    feature: format!("cannot render DataFrame plan as SQL: {err}"),
4954                }),
4955        }
4956    }
4957    async fn execute_stream(&self) -> SqlResult<SqlStream> {
4958        SqlDataFrame::execute_stream(self).await
4959    }
4960
4961    // ── DataFrame transforms ────────────────────────────────────────────────
4962
4963    fn schema(&self) -> SchemaRef {
4964        SchemaRef::from(self.dataframe.schema().clone())
4965    }
4966
4967    async fn select(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4968        let df = self.dataframe.clone().select_columns(columns)?;
4969        Ok(Box::new(self.with_new_dataframe(df, "select")))
4970    }
4971
4972    async fn select_exprs(
4973        &self,
4974        expressions: &[&krishiv_plan::expression::Expr],
4975    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4976        let expressions = expressions
4977            .iter()
4978            .map(|expression| lower_public_expression(&self.dataframe, expression))
4979            .collect::<Result<Vec<_>, _>>()?;
4980        let df = self.dataframe.clone().select(expressions)?;
4981        Ok(Box::new(self.with_new_dataframe(df, "select_exprs")))
4982    }
4983
4984    async fn unnest_columns(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4985        let df = self.dataframe.clone().unnest_columns(columns)?;
4986        Ok(Box::new(self.with_new_dataframe(df, "unnest")))
4987    }
4988
4989    async fn aggregate(
4990        &self,
4991        group_exprs: &[&krishiv_plan::expression::Expr],
4992        aggregate_exprs: &[&krishiv_plan::expression::Expr],
4993    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4994        if aggregate_exprs.is_empty() {
4995            return Err(SqlError::Unsupported {
4996                feature: "aggregate requires at least one aggregate expression".into(),
4997            });
4998        }
4999        let group_exprs = group_exprs
5000            .iter()
5001            .map(|expression| lower_public_expression(&self.dataframe, expression))
5002            .collect::<Result<Vec<_>, _>>()?;
5003        let aggregate_exprs = aggregate_exprs
5004            .iter()
5005            .map(|expression| lower_public_expression(&self.dataframe, expression))
5006            .collect::<Result<Vec<_>, _>>()?;
5007        let df = self
5008            .dataframe
5009            .clone()
5010            .aggregate(group_exprs, aggregate_exprs)?;
5011        Ok(Box::new(self.with_new_dataframe(df, "aggregate")))
5012    }
5013
5014    async fn aggregate_grouping(
5015        &self,
5016        grouping: GroupingMode<'_>,
5017        aggregate_exprs: &[&krishiv_plan::expression::Expr],
5018    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5019        if aggregate_exprs.is_empty() {
5020            return Err(SqlError::Unsupported {
5021                feature: "grouping aggregation requires at least one aggregate expression".into(),
5022            });
5023        }
5024        let lower = |expression: &&krishiv_plan::expression::Expr| {
5025            lower_public_expression(&self.dataframe, expression)
5026        };
5027        let group = match grouping {
5028            GroupingMode::Sets(sets) => datafusion::logical_expr::grouping_set(
5029                sets.into_iter()
5030                    .map(|set| set.iter().map(lower).collect::<Result<Vec<_>, _>>())
5031                    .collect::<Result<Vec<_>, _>>()?,
5032            ),
5033            GroupingMode::Cube(expressions) => datafusion::logical_expr::cube(
5034                expressions
5035                    .iter()
5036                    .map(lower)
5037                    .collect::<Result<Vec<_>, _>>()?,
5038            ),
5039            GroupingMode::Rollup(expressions) => datafusion::logical_expr::rollup(
5040                expressions
5041                    .iter()
5042                    .map(lower)
5043                    .collect::<Result<Vec<_>, _>>()?,
5044            ),
5045        };
5046        let aggregates = aggregate_exprs
5047            .iter()
5048            .map(lower)
5049            .collect::<Result<Vec<_>, _>>()?;
5050        let df = self.dataframe.clone().aggregate(vec![group], aggregates)?;
5051        Ok(Box::new(self.with_new_dataframe(df, "aggregate_grouping")))
5052    }
5053
5054    async fn pivot(
5055        &self,
5056        group_exprs: &[&krishiv_plan::expression::Expr],
5057        pivot_column: &krishiv_plan::expression::Expr,
5058        aggregate_expr: &krishiv_plan::expression::Expr,
5059        values: &[(krishiv_plan::expression::ScalarValue, String)],
5060    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5061        use krishiv_plan::expression::Expr as PublicExpr;
5062        let (function, input, distinct) = match aggregate_expr {
5063            PublicExpr::Aggregate {
5064                function,
5065                expression: Some(input),
5066                distinct,
5067            } => (*function, input.as_ref(), *distinct),
5068            _ => {
5069                return Err(SqlError::Unsupported {
5070                    feature: "pivot requires an aggregate expression with one input".into(),
5071                });
5072            }
5073        };
5074        if values.is_empty() {
5075            return Err(SqlError::Unsupported {
5076                feature: "pivot requires at least one value".into(),
5077            });
5078        }
5079        let group_exprs = group_exprs
5080            .iter()
5081            .map(|expression| lower_public_expression(&self.dataframe, expression))
5082            .collect::<Result<Vec<_>, _>>()?;
5083        let aggregates = values
5084            .iter()
5085            .map(|(value, alias)| {
5086                let conditional = PublicExpr::raw(format!(
5087                    "CASE WHEN {} = {} THEN {} END",
5088                    pivot_column.to_sql(),
5089                    value.to_sql_literal(),
5090                    input.to_sql()
5091                ));
5092                let aggregate = PublicExpr::Aggregate {
5093                    function,
5094                    expression: Some(Box::new(conditional)),
5095                    distinct,
5096                }
5097                .alias(alias);
5098                lower_public_expression(&self.dataframe, &aggregate)
5099            })
5100            .collect::<Result<Vec<_>, _>>()?;
5101        let dataframe = self.dataframe.clone().aggregate(group_exprs, aggregates)?;
5102        Ok(Box::new(self.with_new_dataframe(dataframe, "pivot")))
5103    }
5104
5105    async fn unpivot(
5106        &self,
5107        columns: &[&str],
5108        name_column: &str,
5109        value_column: &str,
5110    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5111        if columns.is_empty() {
5112            return Err(SqlError::Unsupported {
5113                feature: "unpivot requires at least one column".into(),
5114            });
5115        }
5116        let retained = self
5117            .dataframe
5118            .schema()
5119            .fields()
5120            .iter()
5121            .map(|field| field.name().as_str())
5122            .filter(|name| !columns.contains(name))
5123            .collect::<Vec<_>>();
5124        let mut branches = Vec::with_capacity(columns.len());
5125        for column in columns {
5126            let mut expressions = retained
5127                .iter()
5128                .map(|name| datafusion::logical_expr::col(*name))
5129                .collect::<Vec<_>>();
5130            expressions
5131                .push(datafusion::logical_expr::lit((*column).to_owned()).alias(name_column));
5132            expressions.push(datafusion::logical_expr::col(*column).alias(value_column));
5133            branches.push(self.dataframe.clone().select(expressions)?);
5134        }
5135        let mut branches = branches.into_iter();
5136        let Some(mut dataframe) = branches.next() else {
5137            return Err(SqlError::Unsupported {
5138                feature: "unpivot requires at least one branch".into(),
5139            });
5140        };
5141        for branch in branches {
5142            dataframe = dataframe.union(branch)?;
5143        }
5144        Ok(Box::new(self.with_new_dataframe(dataframe, "unpivot")))
5145    }
5146
5147    async fn filter(&self, predicate: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5148        let expr = self.dataframe.parse_sql_expr(predicate)?;
5149        let df = self.dataframe.clone().filter(expr)?;
5150        Ok(Box::new(self.with_new_dataframe(df, "filter")))
5151    }
5152
5153    async fn filter_expr(
5154        &self,
5155        predicate: &krishiv_plan::expression::Expr,
5156    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5157        let expr = lower_public_expression(&self.dataframe, predicate)?;
5158        let df = self.dataframe.clone().filter(expr)?;
5159        Ok(Box::new(self.with_new_dataframe(df, "filter_expr")))
5160    }
5161
5162    async fn limit(&self, n: usize) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5163        let df = self.dataframe.clone().limit(0, Some(n))?;
5164        Ok(Box::new(self.with_new_dataframe(df, "limit")))
5165    }
5166
5167    async fn distinct(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5168        let df = self.dataframe.clone().distinct()?;
5169        Ok(Box::new(self.with_new_dataframe(df, "distinct")))
5170    }
5171
5172    async fn drop_nulls(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5173        let columns = if columns.is_empty() {
5174            self.dataframe
5175                .schema()
5176                .fields()
5177                .iter()
5178                .map(|field| field.name().as_str())
5179                .collect::<Vec<_>>()
5180        } else {
5181            columns.to_vec()
5182        };
5183        let mut predicate: Option<datafusion::logical_expr::Expr> = None;
5184        for column in columns {
5185            let next = datafusion::logical_expr::col(column).is_not_null();
5186            predicate = Some(match predicate {
5187                Some(current) => current.and(next),
5188                None => next,
5189            });
5190        }
5191        let df = match predicate {
5192            Some(predicate) => self.dataframe.clone().filter(predicate)?,
5193            None => self.dataframe.clone(),
5194        };
5195        Ok(Box::new(self.with_new_dataframe(df, "drop_nulls")))
5196    }
5197
5198    async fn sample(&self, fraction: f64) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5199        if !(0.0..=1.0).contains(&fraction) {
5200            return Err(SqlError::Unsupported {
5201                feature: "sample fraction must be between 0 and 1".into(),
5202            });
5203        }
5204        let predicate = self
5205            .dataframe
5206            .parse_sql_expr(&format!("random() < {fraction}"))?;
5207        let df = self.dataframe.clone().filter(predicate)?;
5208        Ok(Box::new(self.with_new_dataframe(df, "sample")))
5209    }
5210
5211    async fn sort(
5212        &self,
5213        columns: &[&str],
5214        descending: &[bool],
5215    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5216        use datafusion::logical_expr::SortExpr;
5217        let exprs: Vec<SortExpr> = columns
5218            .iter()
5219            .zip(descending.iter())
5220            .map(|(col_name, desc)| datafusion::logical_expr::col(*col_name).sort(!desc, *desc))
5221            .collect();
5222        let df = self.dataframe.clone().sort(exprs)?;
5223        Ok(Box::new(self.with_new_dataframe(df, "sort")))
5224    }
5225
5226    async fn alias(&self, alias: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5227        let df = self.dataframe.clone().alias(alias)?;
5228        Ok(Box::new(self.with_new_dataframe(df, "alias")))
5229    }
5230
5231    async fn drop_columns(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5232        let df = self.dataframe.clone().drop_columns(columns)?;
5233        Ok(Box::new(self.with_new_dataframe(df, "drop")))
5234    }
5235
5236    async fn rename_column(&self, old: &str, new: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5237        let df = self.dataframe.clone().with_column_renamed(old, new)?;
5238        Ok(Box::new(self.with_new_dataframe(df, "rename")))
5239    }
5240
5241    async fn with_column(&self, name: &str, expr: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5242        let parsed = self.dataframe.parse_sql_expr(expr)?;
5243        let df = self.dataframe.clone().with_column(name, parsed)?;
5244        Ok(Box::new(self.with_new_dataframe(df, "with_column")))
5245    }
5246
5247    fn as_any(&self) -> &dyn std::any::Any {
5248        self
5249    }
5250
5251    async fn describe(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5252        let df = self.dataframe.clone().describe().await?;
5253        Ok(Box::new(self.with_new_dataframe(df, "describe")))
5254    }
5255
5256    async fn fill_null(
5257        &self,
5258        column: &str,
5259        value: &str,
5260    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5261        let expr = format!("COALESCE({column}, {value})");
5262        let parsed = self.dataframe.parse_sql_expr(&expr)?;
5263        let df = self.dataframe.clone().with_column(column, parsed)?;
5264        Ok(Box::new(self.with_new_dataframe(df, "fill_null")))
5265    }
5266
5267    async fn join(
5268        &self,
5269        right: &dyn KrishivDataFrameOps,
5270        how: &str,
5271        left_on: &[&str],
5272        right_on: &[&str],
5273    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5274        let right_sql = right
5275            .as_any()
5276            .downcast_ref::<SqlDataFrame>()
5277            .ok_or_else(|| SqlError::DataFusion {
5278                message: "right DataFrame must be SqlDataFrame for join".into(),
5279            })?;
5280        use datafusion::common::JoinType;
5281        let join_type = match how.to_lowercase().as_str() {
5282            "inner" => JoinType::Inner,
5283            "left" => JoinType::Left,
5284            "right" => JoinType::Right,
5285            "full" | "outer" => JoinType::Full,
5286            "leftsemi" | "left_semi" => JoinType::LeftSemi,
5287            "rightsemi" | "right_semi" => JoinType::RightSemi,
5288            "leftanti" | "left_anti" => JoinType::LeftAnti,
5289            "rightanti" | "right_anti" => JoinType::RightAnti,
5290            _ => {
5291                return Err(SqlError::DataFusion {
5292                    message: format!("unsupported join type: {how}"),
5293                });
5294            }
5295        };
5296        let df = self.dataframe.clone().join(
5297            right_sql.dataframe.clone(),
5298            join_type,
5299            left_on,
5300            right_on,
5301            None,
5302        )?;
5303        Ok(Box::new(self.with_new_dataframe(df, "join")))
5304    }
5305
5306    async fn union(
5307        &self,
5308        right: &dyn KrishivDataFrameOps,
5309    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5310        let right_sql = right
5311            .as_any()
5312            .downcast_ref::<SqlDataFrame>()
5313            .ok_or_else(|| SqlError::DataFusion {
5314                message: "right DataFrame must be SqlDataFrame for union".into(),
5315            })?;
5316        let df = self.dataframe.clone().union(right_sql.dataframe.clone())?;
5317        Ok(Box::new(self.with_new_dataframe(df, "union")))
5318    }
5319
5320    async fn union_distinct(
5321        &self,
5322        right: &dyn KrishivDataFrameOps,
5323    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5324        let right = sql_dataframe(right, "union_distinct")?;
5325        let df = self
5326            .dataframe
5327            .clone()
5328            .union_distinct(right.dataframe.clone())?;
5329        Ok(Box::new(self.with_new_dataframe(df, "union_distinct")))
5330    }
5331
5332    async fn intersect(
5333        &self,
5334        right: &dyn KrishivDataFrameOps,
5335        distinct: bool,
5336    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5337        let right = sql_dataframe(right, "intersect")?;
5338        let df = if distinct {
5339            self.dataframe
5340                .clone()
5341                .intersect_distinct(right.dataframe.clone())?
5342        } else {
5343            self.dataframe.clone().intersect(right.dataframe.clone())?
5344        };
5345        Ok(Box::new(self.with_new_dataframe(df, "intersect")))
5346    }
5347
5348    async fn except(
5349        &self,
5350        right: &dyn KrishivDataFrameOps,
5351        distinct: bool,
5352    ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
5353        let right = sql_dataframe(right, "except")?;
5354        let df = if distinct {
5355            self.dataframe
5356                .clone()
5357                .except_distinct(right.dataframe.clone())?
5358        } else {
5359            self.dataframe.clone().except(right.dataframe.clone())?
5360        };
5361        Ok(Box::new(self.with_new_dataframe(df, "except")))
5362    }
5363
5364    async fn register_batches(&self, name: &str, batches: Vec<RecordBatch>) -> SqlResult<()> {
5365        let schema = batches
5366            .first()
5367            .map(|b| b.schema())
5368            .unwrap_or_else(|| Arc::new(arrow::datatypes::Schema::empty()));
5369        let mem_table =
5370            datafusion::datasource::MemTable::try_new(schema, vec![batches]).map_err(|e| {
5371                SqlError::DataFusion {
5372                    message: e.to_string(),
5373                }
5374            })?;
5375        self.context
5376            .register_table(name, Arc::new(mem_table))
5377            .map_err(SqlError::from)?;
5378        Ok(())
5379    }
5380
5381    async fn deregister_table(&self, name: &str) -> SqlResult<()> {
5382        let _ = self
5383            .context
5384            .deregister_table(name)
5385            .map_err(SqlError::from)?;
5386        Ok(())
5387    }
5388
5389    async fn create_view(&self, name: &str, replace: bool) -> SqlResult<()> {
5390        let query = self
5391            .query_text
5392            .as_deref()
5393            .ok_or_else(|| SqlError::DataFusion {
5394                message: "create_view requires an SQL query string on the DataFrame".into(),
5395            })?;
5396        let or_replace = if replace { "OR REPLACE " } else { "" };
5397        let safe_name = quote_identifier(name);
5398        let view_sql = format!("CREATE {or_replace}VIEW {safe_name} AS {query}");
5399        self.context.sql(&view_sql).await?;
5400        Ok(())
5401    }
5402}
5403
5404use krishiv_common::sql_util::quote_identifier;
5405
5406// ── CALL-system helpers ───────────────────────────────────────────────────────
5407
5408/// Extract positional arguments from the body of a `CALL` statement.
5409///
5410/// Handles single-quoted string literals and bare integers.
5411/// `'catalog.ns.table', '7 days', 5` → `["catalog.ns.table", "7 days", "5"]`
5412#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5413fn call_args_from_str(s: &str) -> Vec<String> {
5414    let mut args: Vec<String> = Vec::new();
5415    let mut cur = String::new();
5416    let mut in_str = false;
5417    let mut after_str = false;
5418    for ch in s.chars() {
5419        if after_str {
5420            if ch == ',' {
5421                after_str = false;
5422            }
5423            continue;
5424        }
5425        if in_str {
5426            if ch == '\'' {
5427                in_str = false;
5428                after_str = true;
5429                args.push(std::mem::take(&mut cur));
5430            } else {
5431                cur.push(ch);
5432            }
5433        } else if ch == '\'' {
5434            in_str = true;
5435        } else if ch == ',' {
5436            let t = cur.trim().to_string();
5437            if !t.is_empty() {
5438                args.push(t);
5439            }
5440            cur.clear();
5441        } else {
5442            cur.push(ch);
5443        }
5444    }
5445    let t = cur.trim().to_string();
5446    if !t.is_empty() {
5447        args.push(t);
5448    }
5449    args
5450}
5451
5452/// Parse an Iceberg `TableIdent` from a dotted string.
5453///
5454/// Accepts:
5455/// - `"namespace.table"` — single-level namespace
5456/// - `"catalog.namespace.table"` — catalog prefix is ignored (catalog is
5457///   selected by registration order, not by name, in the CALL dispatch)
5458#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5459fn iceberg_table_ident(table_ref: &str) -> SqlResult<iceberg::TableIdent> {
5460    let parts: Vec<&str> = table_ref.splitn(3, '.').collect();
5461    match parts.len() {
5462        2 => {
5463            let ns = iceberg::NamespaceIdent::from_vec(vec![
5464                parts.first().copied().unwrap_or("").to_string(),
5465            ])
5466            .map_err(|e| SqlError::DataFusion {
5467                message: e.to_string(),
5468            })?;
5469            Ok(iceberg::TableIdent::new(
5470                ns,
5471                parts.get(1).copied().unwrap_or("").to_string(),
5472            ))
5473        }
5474        3 => {
5475            let ns = iceberg::NamespaceIdent::from_vec(vec![
5476                parts.get(1).copied().unwrap_or("").to_string(),
5477            ])
5478            .map_err(|e| SqlError::DataFusion {
5479                message: e.to_string(),
5480            })?;
5481            Ok(iceberg::TableIdent::new(
5482                ns,
5483                parts.get(2).copied().unwrap_or("").to_string(),
5484            ))
5485        }
5486        _ => Err(SqlError::DataFusion {
5487            message: format!(
5488                "invalid table reference '{table_ref}': expected 'ns.table' or 'cat.ns.table'"
5489            ),
5490        }),
5491    }
5492}
5493
5494/// Parse a human-readable duration string into a [`chrono::Duration`].
5495///
5496/// Accepted formats: `"N days"`, `"N day"`, `"N hours"`, `"N hour"`,
5497/// `"N weeks"`, `"N week"`, `"N minutes"`, `"N minute"`.
5498#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5499fn parse_call_duration(s: &str) -> SqlResult<chrono::Duration> {
5500    let s = s.trim();
5501    let mut it = s.splitn(2, ' ');
5502    let n: i64 = it
5503        .next()
5504        .and_then(|v| v.parse().ok())
5505        .ok_or_else(|| SqlError::DataFusion {
5506            message: format!("invalid duration value in '{s}'"),
5507        })?;
5508    let unit = it.next().unwrap_or("").trim().to_ascii_lowercase();
5509    match unit.trim_end_matches('s') {
5510        "day" => Ok(chrono::Duration::days(n)),
5511        "hour" => Ok(chrono::Duration::hours(n)),
5512        "week" => Ok(chrono::Duration::weeks(n)),
5513        "minute" | "min" => Ok(chrono::Duration::minutes(n)),
5514        _ => Err(SqlError::DataFusion {
5515            message: format!("unknown duration unit '{unit}' in '{s}'"),
5516        }),
5517    }
5518}
5519
5520// ── Iceberg DML helpers ───────────────────────────────────────────────────────
5521
5522/// Parse `DELETE FROM <table> [WHERE <predicate>]` into `(table_ref, predicate)`
5523/// using the sqlparser AST, which correctly handles quoted identifiers, comments,
5524/// and subqueries in predicates.  Returns `None` for non-DELETE statements.
5525///
5526/// A missing WHERE clause is returned as `"TRUE"` (delete all rows).
5527#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5528fn parse_dml_delete(stmt: &str) -> Option<(String, String)> {
5529    use datafusion::sql::sqlparser::ast::{FromTable, Statement, TableFactor};
5530    use datafusion::sql::sqlparser::dialect::GenericDialect;
5531    use datafusion::sql::sqlparser::parser::Parser;
5532
5533    let mut stmts = Parser::parse_sql(&GenericDialect {}, stmt).ok()?;
5534    if stmts.len() != 1 {
5535        return None;
5536    }
5537    let Statement::Delete(delete) = stmts.remove(0) else {
5538        return None;
5539    };
5540    // `Delete::from` is a `FromTable` enum (sqlparser ≥0.54); both arms carry the
5541    // table list. The first FROM table is the deletion target.
5542    let tables = match delete.from {
5543        FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => tables,
5544    };
5545    let first_from = tables.into_iter().next()?;
5546    let table_name = match first_from.relation {
5547        TableFactor::Table { name, .. } => name.to_string(),
5548        _ => return None,
5549    };
5550    let predicate = delete
5551        .selection
5552        .map(|e| e.to_string())
5553        .unwrap_or_else(|| "TRUE".to_string());
5554    Some((table_name, predicate))
5555}
5556
5557/// Parsed `INSERT INTO <table> [(<columns>)] <source>` statement.
5558#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5559struct ParsedInsert {
5560    /// Dotted target reference exactly as written (`cat.ns.tbl` or `ns.tbl`).
5561    table_ref: String,
5562    /// Explicit column list, if given (`INSERT INTO t (a, b) ...`). Empty
5563    /// means "all columns, in table order" — the only form this engine's
5564    /// Iceberg append landing currently accepts (#219 residual: an explicit
5565    /// list would need positional remapping + NULL-fill for omitted
5566    /// columns, not implemented yet).
5567    columns: Vec<String>,
5568    /// The source query text (`SELECT ...` or `VALUES (...)`), rendered
5569    /// back from the AST so it can be executed on its own.
5570    inner_query: String,
5571}
5572
5573/// Parse `INSERT INTO <table> [(<columns>)] <source-query>` using the
5574/// sqlparser AST. Returns `None` for non-INSERT statements, multi-statement
5575/// input, or an INSERT with no source query (bare `DEFAULT VALUES` / MySQL
5576/// `SET`-assignment forms) — this engine only supports the `SELECT`/`VALUES`
5577/// source forms, both of which parse into `Insert::source`.
5578#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5579fn parse_dml_insert(stmt: &str) -> Option<ParsedInsert> {
5580    use datafusion::sql::sqlparser::ast::{Statement, TableObject};
5581    use datafusion::sql::sqlparser::dialect::GenericDialect;
5582    use datafusion::sql::sqlparser::parser::Parser;
5583
5584    let mut stmts = Parser::parse_sql(&GenericDialect {}, stmt).ok()?;
5585    if stmts.len() != 1 {
5586        return None;
5587    }
5588    let Statement::Insert(insert) = stmts.remove(0) else {
5589        return None;
5590    };
5591    let TableObject::TableName(name) = insert.table else {
5592        return None;
5593    };
5594    let inner_query = insert.source?.to_string();
5595    Some(ParsedInsert {
5596        table_ref: name.to_string(),
5597        columns: insert.columns.iter().map(|c| c.to_string()).collect(),
5598        inner_query,
5599    })
5600}
5601
5602/// Parsed `CREATE [OR REPLACE] TABLE … AS <query>` statement.
5603#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5604struct ParsedCtas {
5605    /// Dotted target reference exactly as written (`cat.ns.tbl` or `ns.tbl`).
5606    table_ref: String,
5607    or_replace: bool,
5608    /// The inner query text (sqlparser AST rendering of the AS body).
5609    inner_query: String,
5610    /// Raw `PARTITIONED BY` items (`region`, `bucket(4, id)`, `day(ts)`),
5611    /// empty for unpartitioned tables.
5612    partition_by: Vec<String>,
5613}
5614
5615/// Lift a `PARTITIONED BY (…)` clause out of a CREATE TABLE statement.
5616///
5617/// Iceberg partition transforms (`bucket(4, id)`, `day(ts)`) are not valid
5618/// column definitions in sqlparser's Hive-style `PARTITIONED BY` list, so
5619/// the clause is extracted textually before the statement is parsed: the
5620/// keywords are matched case-insensitively outside single-quoted strings and
5621/// double-quoted identifiers, and the balanced-paren list that follows is
5622/// split on top-level commas. Returns the statement with the clause removed
5623/// plus the raw items; `None` when no clause is present.
5624#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5625fn extract_partitioned_by(stmt: &str) -> Option<(String, Vec<String>)> {
5626    let bytes = stmt.as_bytes();
5627    let upper = stmt.to_ascii_uppercase();
5628    let upper_bytes = upper.as_bytes();
5629    const NEEDLE: &[u8] = b"PARTITIONED";
5630
5631    fn is_ident_byte(b: u8) -> bool {
5632        b.is_ascii_alphanumeric() || b == b'_'
5633    }
5634    // Advance past a quoted region starting at `i` (index of the opening
5635    // quote); `''` / `""` escapes stay inside the region.
5636    fn skip_quoted(bytes: &[u8], mut i: usize, quote: u8) -> usize {
5637        i += 1;
5638        while let Some(&b) = bytes.get(i) {
5639            if b == quote {
5640                if bytes.get(i + 1) == Some(&quote) {
5641                    i += 2;
5642                    continue;
5643                }
5644                return i + 1;
5645            }
5646            i += 1;
5647        }
5648        i
5649    }
5650
5651    let mut i = 0;
5652    while let Some(&b) = bytes.get(i) {
5653        match b {
5654            b'\'' | b'"' => i = skip_quoted(bytes, i, b),
5655            _ => {
5656                let at_needle = upper_bytes
5657                    .get(i..)
5658                    .is_some_and(|rest| rest.starts_with(NEEDLE))
5659                    && (i == 0
5660                        || !i
5661                            .checked_sub(1)
5662                            .and_then(|p| upper_bytes.get(p))
5663                            .copied()
5664                            .is_some_and(is_ident_byte));
5665                if at_needle {
5666                    let mut j = i + NEEDLE.len();
5667                    while bytes.get(j).is_some_and(u8::is_ascii_whitespace) {
5668                        j += 1;
5669                    }
5670                    // Require whitespace between the keywords and `BY` to not
5671                    // be part of a longer identifier.
5672                    if j > i + NEEDLE.len()
5673                        && upper_bytes
5674                            .get(j..)
5675                            .is_some_and(|rest| rest.starts_with(b"BY"))
5676                        && !upper_bytes.get(j + 2).copied().is_some_and(is_ident_byte)
5677                    {
5678                        let mut k = j + 2;
5679                        while bytes.get(k).is_some_and(u8::is_ascii_whitespace) {
5680                            k += 1;
5681                        }
5682                        if bytes.get(k) == Some(&b'(') {
5683                            // Find the balanced close, respecting quotes.
5684                            let mut depth = 0i32;
5685                            let mut c = k;
5686                            let close = loop {
5687                                match bytes.get(c) {
5688                                    // unbalanced: let sqlparser reject it
5689                                    None => return None,
5690                                    Some(b'(') => depth += 1,
5691                                    Some(b')') => {
5692                                        depth -= 1;
5693                                        if depth == 0 {
5694                                            break c;
5695                                        }
5696                                    }
5697                                    Some(&(q @ b'\'' | q @ b'"')) => {
5698                                        c = skip_quoted(bytes, c, q);
5699                                        continue;
5700                                    }
5701                                    Some(_) => {}
5702                                }
5703                                c += 1;
5704                            };
5705                            let body = stmt.get(k + 1..close)?;
5706                            let head = stmt.get(..i)?.trim_end();
5707                            let tail = stmt.get(close + 1..)?.trim_start();
5708                            let items = split_top_level_commas(body);
5709                            let mut remainder = String::with_capacity(stmt.len());
5710                            remainder.push_str(head);
5711                            remainder.push(' ');
5712                            remainder.push_str(tail);
5713                            return Some((remainder, items));
5714                        }
5715                    }
5716                }
5717                i += 1;
5718            }
5719        }
5720    }
5721    None
5722}
5723
5724/// Split a parenthesized list body on commas at paren depth zero, skipping
5725/// quoted regions. Empty items are dropped.
5726#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5727fn split_top_level_commas(s: &str) -> Vec<String> {
5728    let bytes = s.as_bytes();
5729    let mut items = Vec::new();
5730    let mut depth = 0i32;
5731    let mut start = 0usize;
5732    let mut i = 0;
5733    while let Some(&b) = bytes.get(i) {
5734        match b {
5735            b'(' => depth += 1,
5736            b')' => depth -= 1,
5737            b'\'' | b'"' => {
5738                i += 1;
5739                while bytes.get(i).is_some_and(|&c| c != b) {
5740                    i += 1;
5741                }
5742            }
5743            b',' if depth == 0 => {
5744                if let Some(item) = s.get(start..i).map(str::trim)
5745                    && !item.is_empty()
5746                {
5747                    items.push(item.to_string());
5748                }
5749                start = i + 1;
5750            }
5751            _ => {}
5752        }
5753        i += 1;
5754    }
5755    if let Some(last) = s.get(start..).map(str::trim)
5756        && !last.is_empty()
5757    {
5758        items.push(last.to_string());
5759    }
5760    items
5761}
5762
5763/// Split a SQL body into its top-level statements.
5764///
5765/// Splits on `;` outside single-quoted literals (`''` escapes), double-quoted
5766/// identifiers, `--` line comments, and `/* … */` block comments, preserving
5767/// each statement's original text (no re-rendering, so literal contents are
5768/// never altered). Empty pieces (trailing semicolons, blank statements) are
5769/// dropped. A body with no top-level semicolon comes back as a single item.
5770fn split_sql_statements(sql: &str) -> Vec<String> {
5771    let mut items = Vec::new();
5772    let mut start = 0usize;
5773    let mut chars = sql.char_indices().peekable();
5774    while let Some((i, c)) = chars.next() {
5775        match c {
5776            '\'' => {
5777                // Single-quoted literal; '' is an escaped quote, not a close.
5778                while let Some((_, c2)) = chars.next() {
5779                    if c2 == '\'' {
5780                        if chars.peek().is_some_and(|&(_, c3)| c3 == '\'') {
5781                            chars.next();
5782                            continue;
5783                        }
5784                        break;
5785                    }
5786                }
5787            }
5788            '"' => {
5789                for (_, c2) in chars.by_ref() {
5790                    if c2 == '"' {
5791                        break;
5792                    }
5793                }
5794            }
5795            '-' if chars.peek().is_some_and(|&(_, c2)| c2 == '-') => {
5796                for (_, c2) in chars.by_ref() {
5797                    if c2 == '\n' {
5798                        break;
5799                    }
5800                }
5801            }
5802            '/' if chars.peek().is_some_and(|&(_, c2)| c2 == '*') => {
5803                chars.next();
5804                let mut star = false;
5805                for (_, c2) in chars.by_ref() {
5806                    if star && c2 == '/' {
5807                        break;
5808                    }
5809                    star = c2 == '*';
5810                }
5811            }
5812            ';' => {
5813                if let Some(piece) = sql.get(start..i).map(str::trim)
5814                    && !piece.is_empty()
5815                {
5816                    items.push(piece.to_string());
5817                }
5818                // ';' is ASCII — one byte — so i + 1 is a char boundary.
5819                start = i + 1;
5820            }
5821            _ => {}
5822        }
5823    }
5824    if let Some(last) = sql.get(start..).map(str::trim)
5825        && !last.is_empty()
5826    {
5827        items.push(last.to_string());
5828    }
5829    items
5830}
5831
5832/// Parse `CREATE [OR REPLACE] TABLE <ref> [PARTITIONED BY (…)] AS <query>`
5833/// using the sqlparser AST (with the PARTITIONED BY clause lifted out
5834/// textually first — see [`extract_partitioned_by`]).
5835///
5836/// Returns `None` for anything else — plain column-list CREATE TABLE,
5837/// CREATE EXTERNAL/TEMPORARY TABLE (DataFusion's own DDL), multi-statement
5838/// input, or unparseable text — so callers fall through to DataFusion.
5839#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5840fn parse_ctas(stmt: &str) -> Option<ParsedCtas> {
5841    use datafusion::sql::sqlparser::ast::Statement;
5842    use datafusion::sql::sqlparser::dialect::GenericDialect;
5843    use datafusion::sql::sqlparser::parser::Parser;
5844
5845    let (stripped, partition_by) = match extract_partitioned_by(stmt) {
5846        Some((remainder, items)) => (remainder, items),
5847        None => (stmt.to_string(), Vec::new()),
5848    };
5849    let mut stmts = Parser::parse_sql(&GenericDialect {}, &stripped).ok()?;
5850    if stmts.len() != 1 {
5851        return None;
5852    }
5853    let Statement::CreateTable(create) = stmts.remove(0) else {
5854        return None;
5855    };
5856    if create.external || create.temporary {
5857        return None;
5858    }
5859    let inner_query = create.query?.to_string();
5860    Some(ParsedCtas {
5861        table_ref: create.name.to_string(),
5862        or_replace: create.or_replace,
5863        inner_query,
5864        partition_by,
5865    })
5866}
5867
5868/// Parsed UPDATE statement, decomposed into its components for Iceberg DML.
5869#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5870struct ParsedUpdate {
5871    table_ref: String,
5872    /// Ordered (column_name, value_expression) pairs from the SET clause.
5873    assignments: Vec<(String, String)>,
5874    predicate: Option<String>,
5875}
5876
5877/// Parse `UPDATE <table> SET col = expr [, …] [WHERE <predicate>]` using the
5878/// sqlparser AST.  Returns `None` for non-UPDATE statements or unsupported shapes.
5879///
5880/// Replaces the former regex implementation which could not handle quoted
5881/// identifiers, expressions with commas, or subqueries in predicates.
5882#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
5883fn parse_dml_update(stmt: &str) -> Option<ParsedUpdate> {
5884    use datafusion::sql::sqlparser::ast::{Statement, TableFactor};
5885    use datafusion::sql::sqlparser::dialect::GenericDialect;
5886    use datafusion::sql::sqlparser::parser::Parser;
5887
5888    let mut stmts = Parser::parse_sql(&GenericDialect {}, stmt).ok()?;
5889    if stmts.len() != 1 {
5890        return None;
5891    }
5892    // `Statement::Update` wraps an `Update` struct (sqlparser ≥0.55).
5893    let Statement::Update(update) = stmts.remove(0) else {
5894        return None;
5895    };
5896    let table_name = match update.table.relation {
5897        TableFactor::Table { name, .. } => name.to_string(),
5898        _ => return None,
5899    };
5900    // Convert AST assignments to (column_name, expression_string) pairs.
5901    let parsed_assignments: Vec<(String, String)> = update
5902        .assignments
5903        .into_iter()
5904        .map(|a| {
5905            // `target` is `AssignmentTarget::ColumnName(ObjectName)` in 0.61.
5906            let col = a.target.to_string();
5907            let val = a.value.to_string();
5908            (col, val)
5909        })
5910        .collect();
5911    if parsed_assignments.is_empty() {
5912        return None;
5913    }
5914    Some(ParsedUpdate {
5915        table_ref: table_name,
5916        assignments: parsed_assignments,
5917        predicate: update.selection.map(|e| e.to_string()),
5918    })
5919}
5920
5921/// Create a Krishiv logical plan wrapper for a SQL query without executing it.
5922pub fn plan_sql(query: impl Into<String>) -> SqlResult<SqlPlan> {
5923    let query = query.into();
5924    if query.trim().is_empty() {
5925        return Err(SqlError::EmptyQuery);
5926    }
5927
5928    if let Some(stmt) = cep_sql::parse_match_recognize(&query)? {
5929        let logical_plan = cep_sql::plan_match_recognize(stmt, &query);
5930        let optimized = Optimizer::default().optimize(logical_plan)?;
5931        return Ok(SqlPlan {
5932            query,
5933            logical_plan: optimized.plan,
5934        });
5935    }
5936
5937    let logical_plan =
5938        LogicalPlan::new("sql-query", ExecutionKind::Batch).with_node(PlanNode::new(
5939            "sql",
5940            format!("sql: {}", query.trim()),
5941            ExecutionKind::Batch,
5942        ));
5943
5944    let optimized = Optimizer::default().optimize(logical_plan)?;
5945    Ok(SqlPlan {
5946        query,
5947        logical_plan: optimized.plan,
5948    })
5949}
5950
5951/// Create bootstrap `EXPLAIN` text for a SQL query.
5952pub fn explain_sql(query: impl Into<String>) -> SqlResult<String> {
5953    let plan = plan_sql(query)?;
5954    Ok(plan.logical_plan().describe())
5955}
5956
5957/// Explain a SQL query including optimizer rule decisions.
5958///
5959/// Runs the logical plan through `optimizer` and appends the optimizer
5960/// summary to the plan description.
5961pub fn explain_sql_optimized(query: impl Into<String>, optimizer: &Optimizer) -> SqlResult<String> {
5962    let plan = plan_sql(query)?;
5963    let result = optimizer.optimize(plan.logical_plan().clone())?;
5964    let mut output = result.plan.describe();
5965    let optimizer_line = result.describe();
5966    output.push('\n');
5967    output.push_str(&optimizer_line);
5968    Ok(output)
5969}
5970
5971/// Explain a SQL query and append a cost estimate from the provided cost model.
5972pub fn explain_sql_with_cost(
5973    query: impl Into<String>,
5974    cost_model: &dyn CostModel,
5975) -> SqlResult<String> {
5976    let plan = plan_sql(query)?;
5977    let cost = cost_model.estimate(plan.logical_plan());
5978    let mut output = plan.logical_plan().describe();
5979    output.push_str(&format!(
5980        "\ncost: cpu_nanos={}, memory_bytes={}, network_bytes={}",
5981        cost.cpu_nanos, cost.memory_bytes, cost.network_bytes
5982    ));
5983    Ok(output)
5984}
5985
5986/// Return all base table/relation names referenced by `query`.
5987///
5988/// This uses the same SQL parser family as DataFusion, so policy checks cover
5989/// joins, subqueries, CTE bodies, and other nested relation references instead
5990/// of relying on a single best-effort `FROM` token.
5991pub fn referenced_table_names(query: impl AsRef<str>) -> SqlResult<Vec<String>> {
5992    let query = query.as_ref();
5993    if query.trim().is_empty() {
5994        return Err(SqlError::EmptyQuery);
5995    }
5996
5997    let statements =
5998        Parser::parse_sql(&GenericDialect {}, query).map_err(|e| SqlError::DataFusion {
5999            message: format!("SQL parse error: {e}"),
6000        })?;
6001    let mut names = BTreeSet::new();
6002    let _ = visit_relations(&statements, |relation| {
6003        names.insert(relation.to_string());
6004        ControlFlow::<()>::Continue(())
6005    });
6006    Ok(names.into_iter().collect())
6007}
6008
6009/// Format Arrow batches for CLI and tests.
6010pub fn pretty_batches(batches: &[RecordBatch]) -> SqlResult<String> {
6011    Ok(pretty_format_batches(batches)
6012        .map_err(|error| SqlError::DataFusion {
6013            message: error.to_string(),
6014        })?
6015        .to_string())
6016}
6017
6018#[cfg(test)]
6019mod sql_tests;