Skip to main content

krishiv_sql/
lib.rs

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