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