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