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