1#![forbid(unsafe_code)]
2
3use 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
28pub(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 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;
77pub mod spark_sql_ext;
79pub mod sqlstate;
80pub mod subquery;
81pub mod unnest_sql;
82
83pub mod streaming;
84pub mod streaming_tvf;
85pub mod streaming_window_plan;
86mod udf;
87mod window_functions;
88
89pub use cep_sql::{
90 MatchRecognizeStatement, execute_streaming_match_recognize, parse_match_recognize,
91};
92pub use lakehouse::{AsOfTableRef, MergeResult, MergeTargetUnsupportedError, preprocess_as_of_sql};
93
94pub use grammar::{
95 FeatureEntry, FeatureStatus, feature_matrix, features_by_status, features_for_category,
96};
97pub use sqlstate::{SqlStateError, sqlstate_for};
98pub use streaming::{ContinuousInputError, ContinuousTableInput};
99
100pub type SqlResult<T> = Result<T, SqlError>;
102
103pub type SqlStream =
109 std::pin::Pin<Box<dyn futures::stream::Stream<Item = Result<RecordBatch, SqlError>> + Send>>;
110
111static EPHEMERAL_TABLE_COUNTER: AtomicU64 = AtomicU64::new(0);
114
115fn next_ephemeral_name(prefix: &str) -> String {
116 let id = EPHEMERAL_TABLE_COUNTER.fetch_add(1, Ordering::Relaxed);
117 format!("__{prefix}_{id}")
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125enum WindowFnRegistration {
126 Register,
128 Skip,
132}
133
134struct PlanCache {
140 map: HashMap<String, datafusion::logical_expr::LogicalPlan>,
141 order: VecDeque<String>,
142 max: usize,
143}
144
145impl PlanCache {
146 fn new(max: usize) -> Self {
147 Self {
148 map: HashMap::new(),
149 order: VecDeque::new(),
150 max,
151 }
152 }
153
154 fn get(&self, key: &str) -> Option<&datafusion::logical_expr::LogicalPlan> {
155 self.map.get(key)
156 }
157
158 fn insert(&mut self, key: String, plan: datafusion::logical_expr::LogicalPlan) {
159 if self.map.contains_key(&key) {
160 self.order.retain(|k| k != &key);
163 } else if self.map.len() >= self.max
164 && let Some(oldest) = self.order.pop_front()
165 {
166 self.map.remove(&oldest);
167 }
168 self.order.push_back(key.clone());
169 self.map.insert(key, plan);
170 }
171
172 fn clear(&mut self) {
173 self.map.clear();
174 self.order.clear();
175 }
176
177 #[cfg(test)]
178 fn is_empty(&self) -> bool {
179 self.map.is_empty()
180 }
181}
182
183#[derive(Debug, Clone, Default)]
185pub struct ParquetReaderOptions {
186 pub batch_size: Option<usize>,
188}
189
190#[derive(Debug, Clone, Default)]
192pub struct CsvReaderOptions {
193 pub delimiter: Option<char>,
195 pub has_header: Option<bool>,
197}
198
199#[derive(Debug, Clone, Default)]
201pub struct ParquetWriterOptions {
202 pub compression: Option<String>,
204 pub max_row_group_size: Option<usize>,
206}
207
208#[derive(Debug, Clone, Default)]
210pub struct CsvWriterOptions {
211 pub delimiter: Option<char>,
213 pub has_header: Option<bool>,
215}
216
217#[non_exhaustive]
219#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
220pub enum SqlError {
221 #[error("SQL query is empty")]
223 EmptyQuery,
224 #[error("table name is empty")]
226 EmptyTableName,
227 #[error("unsupported SQL feature: {feature}")]
229 Unsupported { feature: String },
230 #[error("invalid table function: {message}")]
232 InvalidTableFunction { message: String },
233 #[error("DataFusion error: {message}")]
235 DataFusion { message: String },
236 #[error(transparent)]
238 Optimizer(#[from] krishiv_plan::optimizer::OptimizerError),
239 #[error("access denied: {reason}")]
241 AccessDenied { reason: String },
242 #[error("operation {operation_id} was cancelled")]
244 OperationCancelled { operation_id: u64 },
245 #[error("query timed out after {timeout_ms} ms")]
247 Timeout { timeout_ms: u64 },
248}
249
250impl From<datafusion::error::DataFusionError> for SqlError {
251 fn from(value: datafusion::error::DataFusionError) -> Self {
252 Self::DataFusion {
253 message: value.to_string(),
254 }
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub struct SqlPlan {
261 query: String,
262 logical_plan: LogicalPlan,
263}
264
265impl SqlPlan {
266 pub fn query(&self) -> &str {
268 &self.query
269 }
270
271 pub fn logical_plan(&self) -> &LogicalPlan {
273 &self.logical_plan
274 }
275}
276
277const PLAN_CACHE_MAX_ENTRIES: usize = 256;
289
290fn resolve_plan_cache_max_entries() -> usize {
291 std::env::var("KRISHIV_PLAN_CACHE_MAX_ENTRIES")
292 .ok()
293 .and_then(|v| v.parse().ok())
294 .filter(|&n| n > 0)
295 .unwrap_or(PLAN_CACHE_MAX_ENTRIES)
296}
297const STREAMING_CEP_MAX_ROWS_DEFAULT: usize = 100_000;
298
299pub fn resolve_streaming_match_recognize_limit(raw: Option<&str>) -> usize {
303 raw.and_then(|s| s.parse::<usize>().ok())
304 .filter(|n| *n > 0)
305 .unwrap_or(STREAMING_CEP_MAX_ROWS_DEFAULT)
306}
307
308pub fn streaming_match_recognize_limit_from_env() -> usize {
311 resolve_streaming_match_recognize_limit(
312 std::env::var("KRISHIV_MATCH_RECOGNIZE_STREAMING_LIMIT")
313 .ok()
314 .as_deref(),
315 )
316}
317
318pub fn resolve_query_memory_limit_bytes(raw: Option<&str>) -> Option<usize> {
322 raw.and_then(|s| s.trim().parse::<usize>().ok())
323 .filter(|n| *n > 0)
324}
325
326pub fn query_memory_limit_from_env() -> Option<usize> {
337 match std::env::var("KRISHIV_QUERY_MEMORY_LIMIT_BYTES").ok() {
338 Some(raw) => resolve_query_memory_limit_bytes(Some(&raw)),
341 None => cgroup_memory_limit_bytes()
342 .map(|limit| (limit / 4) as usize)
343 .filter(|&n| n > 0),
344 }
345}
346
347pub use krishiv_common::cgroup_memory_limit_bytes;
348
349static RUNTIME_FILTERS_OVERRIDE: std::sync::atomic::AtomicU8 =
354 std::sync::atomic::AtomicU8::new(u8::MAX);
355
356#[doc(hidden)]
359pub fn set_runtime_filters_for_tests(enabled: bool) {
360 RUNTIME_FILTERS_OVERRIDE.store(u8::from(enabled), std::sync::atomic::Ordering::Relaxed);
361}
362
363pub fn runtime_filters_enabled_from_env() -> bool {
368 match RUNTIME_FILTERS_OVERRIDE.load(std::sync::atomic::Ordering::Relaxed) {
369 0 => return false,
370 1 => return true,
371 _ => {}
372 }
373 !matches!(
374 std::env::var("KRISHIV_RUNTIME_FILTERS")
375 .unwrap_or_default()
376 .trim()
377 .to_ascii_lowercase()
378 .as_str(),
379 "off" | "0" | "false" | "disabled"
380 )
381}
382
383pub fn batch_size_from_env() -> usize {
387 std::env::var("KRISHIV_BATCH_SIZE")
388 .ok()
389 .and_then(|v| v.parse::<usize>().ok())
390 .filter(|n| *n > 0)
391 .unwrap_or(8192)
392}
393
394pub fn default_parallelism_from_env() -> NonZeroUsize {
398 std::env::var("KRISHIV_TARGET_PARALLELISM")
399 .ok()
400 .and_then(|v| v.parse::<usize>().ok())
401 .and_then(NonZeroUsize::new)
402 .unwrap_or_else(|| std::thread::available_parallelism().unwrap_or(NonZeroUsize::MIN))
403}
404
405const DEFAULT_SORT_SPILL_RESERVATION_BYTES: usize = 10 * 1024 * 1024;
411
412const MIN_SORT_SPILL_RESERVATION_BYTES: usize = 64 * 1024;
415
416fn build_single_node_session_config(
430 target_partitions: NonZeroUsize,
431 memory_limit_bytes: Option<usize>,
432) -> datafusion::prelude::SessionConfig {
433 let tp = target_partitions.get();
434 let batch_size = batch_size_from_env();
435 let mut config = datafusion::prelude::SessionConfig::new()
436 .with_target_partitions(tp)
437 .with_batch_size(batch_size)
438 .with_information_schema(true)
439 .set_bool(
440 "datafusion.optimizer.enable_round_robin_repartition",
441 tp > 1,
442 )
443 .set_bool(
448 "datafusion.optimizer.enable_dynamic_filter_pushdown",
449 runtime_filters_enabled_from_env(),
450 )
451 .set_bool(
452 "datafusion.optimizer.enable_join_dynamic_filter_pushdown",
453 runtime_filters_enabled_from_env(),
454 )
455 .set_bool(
456 "datafusion.optimizer.enable_topk_dynamic_filter_pushdown",
457 runtime_filters_enabled_from_env(),
458 )
459 .set_bool(
460 "datafusion.optimizer.enable_aggregate_dynamic_filter_pushdown",
461 runtime_filters_enabled_from_env(),
462 );
463 if let Some(limit) = memory_limit_bytes {
470 let scaled = (limit / 4).clamp(
471 MIN_SORT_SPILL_RESERVATION_BYTES,
472 DEFAULT_SORT_SPILL_RESERVATION_BYTES,
473 );
474 config = config.with_sort_spill_reservation_bytes(scaled);
475 }
476 config
477}
478
479#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
483type IcebergCatalogRegistry =
484 Arc<std::sync::RwLock<Vec<(Arc<catalog::unified::KrishivCatalog>, String)>>>;
485
486#[derive(Clone)]
487pub struct SqlEngine {
488 context: SessionContext,
489 target_parallelism: NonZeroUsize,
490 krishiv_catalog: Option<Arc<RwLock<InMemoryCatalog>>>,
491 udf_registry: Option<std::sync::Arc<std::sync::RwLock<krishiv_plan::udf::UdfRegistry>>>,
492 streaming_sources: Arc<RwLock<std::collections::HashSet<String>>>,
495 streaming_registration: Arc<Mutex<()>>,
497 has_streaming_sources: Arc<AtomicBool>,
502 udf_limits: Option<krishiv_plan::udf::ResourceLimits>,
505 udf_registry_version: Arc<AtomicU64>,
509 udf_last_synced_version: Arc<AtomicU64>,
512 plan_cache: Arc<Mutex<PlanCache>>,
518 shuffle_partitions: Arc<std::sync::RwLock<Option<u32>>>,
521 table_row_counts: Arc<std::sync::RwLock<HashMap<String, u64>>>,
526 memory_limit_bytes: Option<usize>,
531 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
535 iceberg_catalogs: IcebergCatalogRegistry,
536 live_table_registry: Arc<live_table::LiveTableRegistry>,
538 incremental_view_registry: Arc<incremental_view::IncrementalViewRegistry>,
540 pipeline_registry: Arc<pipeline_ddl::PipelineRegistry>,
542 operation_registry: Arc<OperationRegistry>,
544}
545
546impl fmt::Debug for SqlEngine {
547 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
548 f.debug_struct("SqlEngine")
549 .field("backend", &"datafusion")
550 .finish_non_exhaustive()
551 }
552}
553
554impl Default for SqlEngine {
555 fn default() -> Self {
556 Self::new()
557 }
558}
559
560impl SqlEngine {
561 pub fn new() -> Self {
575 Self::new_with_memory_limit(query_memory_limit_from_env())
576 }
577
578 pub fn new_with_memory_limit(memory_limit_bytes: Option<usize>) -> Self {
590 let parallelism = default_parallelism_from_env();
591 match Self::build_local(
592 None,
593 WindowFnRegistration::Register,
594 parallelism,
595 memory_limit_bytes,
596 ) {
597 Ok(engine) => engine,
598 Err(err) => {
599 tracing::warn!(
600 error = %err,
601 "SqlEngine::new: window helper UDF registration failed; \
602 window SQL functions will be unavailable, other queries are unaffected"
603 );
604 Self::build_local(
605 None,
606 WindowFnRegistration::Skip,
607 parallelism,
608 memory_limit_bytes,
609 )
610 .unwrap_or_else(|err| {
611 tracing::error!(
612 error = %err,
613 "memory-limited DataFusion runtime construction failed; \
614 falling back to an unbounded engine"
615 );
616 Self::build_local(None, WindowFnRegistration::Skip, parallelism, None)
617 .unwrap_or_else(|_| Self::build_absolute_minimal(parallelism))
618 })
619 }
620 }
621 }
622
623 pub fn try_new() -> SqlResult<Self> {
628 Self::build_local(
629 None,
630 WindowFnRegistration::Register,
631 default_parallelism_from_env(),
632 query_memory_limit_from_env(),
633 )
634 }
635
636 pub fn with_in_memory_catalog(catalog: Arc<RwLock<InMemoryCatalog>>) -> SqlResult<Self> {
638 if krishiv_common::profile_requires_fail_closed_metadata(
639 krishiv_common::resolve_durability_profile(),
640 ) {
641 return Err(SqlError::DataFusion {
642 message: String::from(
643 "InMemoryCatalog is dev-only; configure a durable REST or file-backed \
644 catalog for production deployments",
645 ),
646 });
647 }
648 Self::build_local(
649 Some(catalog),
650 WindowFnRegistration::Register,
651 default_parallelism_from_env(),
652 query_memory_limit_from_env(),
653 )
654 }
655
656 #[must_use]
667 pub fn with_target_parallelism(mut self, n: NonZeroUsize) -> Self {
668 self.target_parallelism = n;
669 self.apply_target_partitions(n);
670 self
671 }
672
673 fn apply_target_partitions(&self, n: NonZeroUsize) {
682 let state_ref = self.context.state_ref();
683 let mut state = state_ref.write();
684 let options = state.config_mut().options_mut();
685 options.execution.target_partitions = n.get();
686 options.optimizer.enable_round_robin_repartition = n.get() > 1;
687 }
688
689 pub fn target_parallelism(&self) -> NonZeroUsize {
691 self.target_parallelism
692 }
693
694 pub fn memory_limit_bytes(&self) -> Option<usize> {
696 self.memory_limit_bytes
697 }
698
699 pub fn session_context(&self) -> &SessionContext {
705 &self.context
706 }
707
708 pub fn shuffle_partitions(&self) -> Option<u32> {
710 *self
711 .shuffle_partitions
712 .read()
713 .unwrap_or_else(|e| e.into_inner())
714 }
715
716 pub fn table_row_counts(&self) -> Arc<std::sync::RwLock<HashMap<String, u64>>> {
722 Arc::clone(&self.table_row_counts)
723 }
724
725 pub fn registered_table_names(&self) -> Vec<String> {
731 let mut names = Vec::new();
732 for catalog_name in self.context.catalog_names() {
733 let Some(catalog) = self.context.catalog(&catalog_name) else {
734 continue;
735 };
736 for schema_name in catalog.schema_names() {
737 let Some(schema) = catalog.schema(&schema_name) else {
738 continue;
739 };
740 names.extend(schema.table_names());
741 }
742 }
743 names.sort();
744 names.dedup();
745 names
746 }
747
748 fn make_sql_df(&self, name: &str, dataframe: DataFusionDataFrame) -> SqlDataFrame {
751 SqlDataFrame::new(name, dataframe, self.table_row_counts())
752 .with_context(self.context.clone())
753 }
754
755 fn attach_query_metadata(&self, df: SqlDataFrame, query: &str) -> SqlDataFrame {
757 let kind = if self.is_streaming_query(query).unwrap_or(false) {
758 ExecutionKind::Streaming
759 } else {
760 ExecutionKind::Batch
761 };
762 df.with_query(query).with_execution_kind(kind)
763 }
764
765 #[must_use]
770 pub fn with_shuffle_partitions(self, n: Option<u32>) -> Self {
771 if let Ok(mut guard) = self.shuffle_partitions.write() {
772 *guard = n;
773 }
774 self
775 }
776
777 fn build_local(
787 krishiv_catalog: Option<Arc<RwLock<InMemoryCatalog>>>,
788 window_fn_registration: WindowFnRegistration,
789 target_partitions: NonZeroUsize,
790 memory_limit_bytes: Option<usize>,
791 ) -> SqlResult<Self> {
792 let streaming_sources: Arc<RwLock<std::collections::HashSet<String>>> =
796 Arc::new(RwLock::new(std::collections::HashSet::new()));
797
798 let mut state_builder = datafusion::execution::session_state::SessionStateBuilder::new()
799 .with_default_features()
800 .with_config(build_single_node_session_config(
801 target_partitions,
802 memory_limit_bytes,
803 ));
804 if let Some(limit) = memory_limit_bytes {
805 let runtime_env = datafusion::execution::runtime_env::RuntimeEnvBuilder::new()
810 .with_memory_pool(Arc::new(
811 datafusion::execution::memory_pool::FairSpillPool::new(limit),
812 ))
813 .build_arc()
814 .map_err(|e| SqlError::DataFusion {
815 message: format!(
816 "failed to build memory-limited DataFusion runtime \
817 (limit {limit} bytes): {e}"
818 ),
819 })?;
820 state_builder = state_builder.with_runtime_env(runtime_env);
821 }
822 let mut state = state_builder.build();
823 crate::connector_table::register_connector_table_factories(
827 state.table_factories_mut(),
828 streaming_sources.clone(),
829 );
830 let context = SessionContext::new_with_state(state);
831 if let Some(catalog) = &krishiv_catalog {
832 context.register_catalog(
833 "krishiv",
834 Arc::new(DataFusionCatalogBridge::new(catalog.clone())),
835 );
836 }
837 if matches!(window_fn_registration, WindowFnRegistration::Register) {
838 window_functions::register_window_functions(&context).map_err(|e| {
839 SqlError::DataFusion {
840 message: format!("failed to register window helper UDFs: {e}"),
841 }
842 })?;
843 }
844 Ok(Self {
845 context,
846 target_parallelism: target_partitions,
847 krishiv_catalog,
848 udf_registry: None,
849 streaming_sources,
850 streaming_registration: Arc::new(Mutex::new(())),
851 has_streaming_sources: Arc::new(AtomicBool::new(false)),
852 udf_limits: None,
853 udf_registry_version: Arc::new(AtomicU64::new(0)),
854 udf_last_synced_version: Arc::new(AtomicU64::new(u64::MAX)),
855 plan_cache: Arc::new(Mutex::new(PlanCache::new(resolve_plan_cache_max_entries()))),
856 shuffle_partitions: Arc::new(std::sync::RwLock::new(None)),
857 table_row_counts: Arc::new(std::sync::RwLock::new(HashMap::new())),
858 memory_limit_bytes,
859 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
860 iceberg_catalogs: Arc::new(std::sync::RwLock::new(Vec::new())),
861 live_table_registry: Arc::new(live_table::LiveTableRegistry::new()),
862 incremental_view_registry: Arc::new(incremental_view::IncrementalViewRegistry::new()),
863 pipeline_registry: Arc::new(pipeline_ddl::PipelineRegistry::new()),
864 operation_registry: Arc::new(OperationRegistry::new()),
865 })
866 }
867
868 fn build_absolute_minimal(target_partitions: NonZeroUsize) -> Self {
872 let streaming_sources: Arc<RwLock<std::collections::HashSet<String>>> =
873 Arc::new(RwLock::new(std::collections::HashSet::new()));
874 let mut state = datafusion::execution::session_state::SessionStateBuilder::new()
875 .with_default_features()
876 .with_config(build_single_node_session_config(target_partitions, None))
877 .build();
878 crate::connector_table::register_connector_table_factories(
879 state.table_factories_mut(),
880 streaming_sources.clone(),
881 );
882 let context = SessionContext::new_with_state(state);
883 Self {
884 context,
885 target_parallelism: target_partitions,
886 krishiv_catalog: None,
887 udf_registry: None,
888 streaming_sources,
889 streaming_registration: Arc::new(Mutex::new(())),
890 has_streaming_sources: Arc::new(AtomicBool::new(false)),
891 udf_limits: None,
892 udf_registry_version: Arc::new(AtomicU64::new(0)),
893 udf_last_synced_version: Arc::new(AtomicU64::new(u64::MAX)),
894 plan_cache: Arc::new(Mutex::new(PlanCache::new(resolve_plan_cache_max_entries()))),
895 shuffle_partitions: Arc::new(std::sync::RwLock::new(None)),
896 table_row_counts: Arc::new(std::sync::RwLock::new(HashMap::new())),
897 memory_limit_bytes: None,
898 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
899 iceberg_catalogs: Arc::new(std::sync::RwLock::new(Vec::new())),
900 live_table_registry: Arc::new(live_table::LiveTableRegistry::new()),
901 incremental_view_registry: Arc::new(incremental_view::IncrementalViewRegistry::new()),
902 pipeline_registry: Arc::new(pipeline_ddl::PipelineRegistry::new()),
903 operation_registry: Arc::new(OperationRegistry::new()),
904 }
905 }
906
907 pub fn register_streaming_table(
918 &self,
919 name: &str,
920 schema: arrow::datatypes::SchemaRef,
921 ) -> SqlResult<Arc<ContinuousTableInput>> {
922 let _registration = self.lock_streaming_registration()?;
923 self.validate_new_streaming_table(name, &schema)?;
924 let (table, input) = crate::streaming::create_continuous_table(schema).map_err(|e| {
925 SqlError::DataFusion {
926 message: e.to_string(),
927 }
928 })?;
929 self.register_new_streaming_provider(name, table)?;
930 self.streaming_sources
931 .write()
932 .unwrap_or_else(|e| e.into_inner())
933 .insert(name.to_string());
934 self.has_streaming_sources.store(true, Ordering::Release);
935 self.invalidate_plan_cache();
936 Ok(input)
937 }
938
939 pub fn register_streaming_table_with_capacity(
944 &self,
945 name: &str,
946 schema: arrow::datatypes::SchemaRef,
947 capacity: usize,
948 ) -> SqlResult<Arc<ContinuousTableInput>> {
949 let _registration = self.lock_streaming_registration()?;
950 self.validate_new_streaming_table(name, &schema)?;
951 let (table, input) = crate::streaming::create_continuous_table_with_capacity(
952 schema, capacity,
953 )
954 .map_err(|e| SqlError::DataFusion {
955 message: e.to_string(),
956 })?;
957 self.register_new_streaming_provider(name, table)?;
958 self.streaming_sources
959 .write()
960 .unwrap_or_else(|e| e.into_inner())
961 .insert(name.to_string());
962 self.has_streaming_sources.store(true, Ordering::Release);
963 self.invalidate_plan_cache();
964 Ok(input)
965 }
966
967 fn lock_streaming_registration(&self) -> SqlResult<std::sync::MutexGuard<'_, ()>> {
968 self.streaming_registration
969 .lock()
970 .map_err(|error| SqlError::DataFusion {
971 message: format!("streaming table registration lock poisoned: {error}"),
972 })
973 }
974
975 fn validate_new_streaming_table(
976 &self,
977 name: &str,
978 schema: &arrow::datatypes::SchemaRef,
979 ) -> SqlResult<()> {
980 if name.trim().is_empty() {
981 return Err(SqlError::EmptyTableName);
982 }
983 if schema.fields().is_empty() {
984 return Err(SqlError::DataFusion {
985 message: "streaming table schema must contain at least one field".into(),
986 });
987 }
988 if self
989 .context
990 .table_exist(name)
991 .map_err(|error| SqlError::DataFusion {
992 message: error.to_string(),
993 })?
994 {
995 return Err(SqlError::DataFusion {
996 message: format!("table '{name}' is already registered"),
997 });
998 }
999 Ok(())
1000 }
1001
1002 fn register_new_streaming_provider(
1003 &self,
1004 name: &str,
1005 table: Arc<dyn datafusion::catalog::TableProvider>,
1006 ) -> SqlResult<()> {
1007 let previous =
1008 self.context
1009 .register_table(name, table)
1010 .map_err(|error| SqlError::DataFusion {
1011 message: error.to_string(),
1012 })?;
1013 if let Some(previous) = previous {
1014 self.context
1015 .register_table(name, previous)
1016 .map_err(|error| SqlError::DataFusion {
1017 message: format!(
1018 "table '{name}' was concurrently registered and could not be restored: \
1019 {error}"
1020 ),
1021 })?;
1022 return Err(SqlError::DataFusion {
1023 message: format!("table '{name}' was concurrently registered"),
1024 });
1025 }
1026 Ok(())
1027 }
1028
1029 pub fn register_kafka_source(
1043 &self,
1044 table_name: impl AsRef<str>,
1045 schema: arrow::datatypes::SchemaRef,
1046 bootstrap_servers: impl Into<String>,
1047 topic: impl Into<String>,
1048 group_id: impl Into<String>,
1049 ) -> SqlResult<()> {
1050 let table_name = table_name.as_ref();
1051 if table_name.trim().is_empty() {
1052 return Err(SqlError::EmptyTableName);
1053 }
1054 let config = krishiv_connectors::kafka::KafkaConfig {
1055 bootstrap_servers: bootstrap_servers.into(),
1056 topic: topic.into(),
1057 group_id: group_id.into(),
1058 auto_commit_interval_ms: {
1059 let profile = krishiv_common::resolve_durability_profile();
1060 if krishiv_common::requires_manual_kafka_commit(profile) {
1061 None
1062 } else {
1063 Some(1_000)
1064 }
1065 },
1066 security_protocol: None,
1067 ssl_ca_location: None,
1068 ssl_certificate_location: None,
1069 ssl_key_location: None,
1070 ssl_key_password: None,
1071 sasl_username: None,
1072 sasl_password: None,
1073 sasl_mechanisms: None,
1074 enable_idempotence: None,
1075 transactional_id: None,
1076 };
1077 let table =
1078 crate::kafka_table::create_kafka_streaming_table(schema, config).map_err(|e| {
1079 SqlError::DataFusion {
1080 message: e.to_string(),
1081 }
1082 })?;
1083 if self
1084 .context
1085 .table_exist(table_name)
1086 .map_err(SqlError::from)?
1087 {
1088 let _ = self
1089 .context
1090 .deregister_table(table_name)
1091 .map_err(SqlError::from)?;
1092 }
1093 self.context
1094 .register_table(table_name, table)
1095 .map_err(|e| SqlError::DataFusion {
1096 message: e.to_string(),
1097 })?;
1098 self.streaming_sources
1099 .write()
1100 .unwrap_or_else(|e| e.into_inner())
1101 .insert(table_name.to_string());
1102 self.has_streaming_sources.store(true, Ordering::Release);
1103 self.invalidate_plan_cache();
1104 Ok(())
1105 }
1106
1107 pub async fn sql_to_kafka(
1117 &self,
1118 sql: impl AsRef<str>,
1119 bootstrap_servers: impl Into<String>,
1120 topic: impl Into<String>,
1121 ) -> SqlResult<u64> {
1122 use futures::StreamExt;
1123 use krishiv_connectors::Sink as _;
1124 use krishiv_connectors::kafka::{KafkaConfig, KafkaSink};
1125
1126 let config = KafkaConfig {
1127 bootstrap_servers: bootstrap_servers.into(),
1128 topic: topic.into(),
1129 group_id: "krishiv-sql-writer".into(),
1130 auto_commit_interval_ms: None,
1131 security_protocol: None,
1132 ssl_ca_location: None,
1133 ssl_certificate_location: None,
1134 ssl_key_location: None,
1135 ssl_key_password: None,
1136 sasl_username: None,
1137 sasl_password: None,
1138 sasl_mechanisms: None,
1139 enable_idempotence: None,
1140 transactional_id: None,
1141 };
1142 let mut sink = KafkaSink::new(config).map_err(|e| SqlError::DataFusion {
1143 message: e.to_string(),
1144 })?;
1145
1146 let df = self.sql(sql.as_ref()).await?;
1147 let mut stream = df.execute_stream().await?;
1148 let mut total_rows = 0u64;
1149
1150 while let Some(result) = stream.next().await {
1151 let batch = result.map_err(|e| SqlError::DataFusion {
1152 message: e.to_string(),
1153 })?;
1154 if batch.num_rows() > 0 {
1155 total_rows += batch.num_rows() as u64;
1156 sink.write_batch(batch)
1157 .await
1158 .map_err(|e| SqlError::DataFusion {
1159 message: e.to_string(),
1160 })?;
1161 }
1162 }
1163 sink.flush().await.map_err(|e| SqlError::DataFusion {
1164 message: e.to_string(),
1165 })?;
1166 Ok(total_rows)
1167 }
1168
1169 pub fn with_udf_limits(mut self, limits: krishiv_plan::udf::ResourceLimits) -> Self {
1173 self.udf_limits = Some(limits);
1174 self
1175 }
1176
1177 pub fn is_streaming_source(&self, table_name: &str) -> bool {
1179 self.streaming_sources
1180 .read()
1181 .unwrap_or_else(|e| e.into_inner())
1182 .contains(table_name)
1183 }
1184
1185 pub fn register_streaming_source_name(&self, table_name: impl Into<String>) -> SqlResult<()> {
1194 let name: String = table_name.into();
1195 if name.trim().is_empty() {
1196 return Err(SqlError::EmptyTableName);
1197 }
1198 self.streaming_sources
1199 .write()
1200 .unwrap_or_else(|e| e.into_inner())
1201 .insert(name);
1202 self.has_streaming_sources.store(true, Ordering::Release);
1203 self.invalidate_plan_cache();
1204 Ok(())
1205 }
1206
1207 pub fn deregister_streaming_source(&self, name: &str) -> SqlResult<()> {
1213 if name.trim().is_empty() {
1214 return Err(SqlError::EmptyTableName);
1215 }
1216 let _ = self
1218 .context
1219 .deregister_table(name)
1220 .map_err(SqlError::from)?;
1221 {
1222 let mut sources = self
1223 .streaming_sources
1224 .write()
1225 .unwrap_or_else(|e| e.into_inner());
1226 sources.remove(name);
1227 if sources.is_empty() {
1228 self.has_streaming_sources.store(false, Ordering::Release);
1229 }
1230 self.invalidate_plan_cache();
1234 }
1235 Ok(())
1236 }
1237
1238 pub fn live_table_registry(&self) -> &Arc<live_table::LiveTableRegistry> {
1240 &self.live_table_registry
1241 }
1242
1243 pub fn incremental_view_registry(&self) -> &Arc<incremental_view::IncrementalViewRegistry> {
1245 &self.incremental_view_registry
1246 }
1247
1248 pub fn pipeline_registry(&self) -> &Arc<pipeline_ddl::PipelineRegistry> {
1250 &self.pipeline_registry
1251 }
1252
1253 pub fn operation_registry(&self) -> &Arc<OperationRegistry> {
1255 &self.operation_registry
1256 }
1257
1258 pub fn deregister_table(&self, name: &str) -> SqlResult<()> {
1262 if name.trim().is_empty() {
1263 return Err(SqlError::EmptyTableName);
1264 }
1265 let _ = self
1266 .context
1267 .deregister_table(name)
1268 .map_err(SqlError::from)?;
1269 self.invalidate_plan_cache();
1270 Ok(())
1271 }
1272
1273 pub fn register_table_udf_fn(
1297 &self,
1298 name: impl Into<String>,
1299 schema: arrow::datatypes::Schema,
1300 f: impl Fn(
1301 &[krishiv_plan::udf::ScalarValue],
1302 ) -> Result<arrow::record_batch::RecordBatch, krishiv_plan::udf::UdfError>
1303 + Send
1304 + Sync
1305 + 'static,
1306 ) -> SqlResult<()> {
1307 let udf =
1308 create_function_ddl::ClosureTableUdf::try_new(name, schema, std::sync::Arc::new(f))
1309 .map_err(|error| SqlError::InvalidTableFunction {
1310 message: error.to_string(),
1311 })?;
1312 if let Some(registry) = &self.udf_registry {
1313 let mut guard = registry.write().map_err(|e| SqlError::DataFusion {
1314 message: e.to_string(),
1315 })?;
1316 guard.register_table(std::sync::Arc::new(udf.clone()));
1317 }
1318 udf::register_single_table_udf(&self.context, std::sync::Arc::new(udf))
1319 .map_err(SqlError::from)
1320 }
1321
1322 pub fn is_streaming_query(&self, sql: &str) -> SqlResult<bool> {
1324 if !self.has_streaming_sources.load(Ordering::Acquire) {
1327 return Ok(false);
1328 }
1329 let sources = self
1330 .streaming_sources
1331 .read()
1332 .unwrap_or_else(|e| e.into_inner());
1333 if sources.is_empty() {
1334 return Ok(false);
1335 }
1336 let dialect = GenericDialect {};
1337 let statements = Parser::parse_sql(&dialect, sql).map_err(|e| SqlError::DataFusion {
1338 message: e.to_string(),
1339 })?;
1340 for stmt in &statements {
1341 let mut is_streaming = false;
1342 let _ = visit_relations(stmt, |relation| {
1343 let full = relation.to_string();
1346 let table_name = full.split('.').next_back().unwrap_or(&full);
1347 if sources.contains(table_name) {
1348 is_streaming = true;
1349 return ControlFlow::Break(());
1350 }
1351 ControlFlow::Continue(())
1352 });
1353 if is_streaming {
1354 return Ok(true);
1355 }
1356 }
1357 Ok(false)
1358 }
1359
1360 pub fn krishiv_catalog(&self) -> Option<&Arc<RwLock<InMemoryCatalog>>> {
1362 self.krishiv_catalog.as_ref()
1363 }
1364
1365 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
1374 #[must_use]
1375 pub fn with_iceberg_catalog(
1376 self,
1377 catalog: std::sync::Arc<catalog::unified::KrishivCatalog>,
1378 catalog_name: impl Into<String>,
1379 ) -> Self {
1380 self.register_iceberg_catalog(catalog, catalog_name);
1381 self
1382 }
1383
1384 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
1391 pub fn register_iceberg_catalog(
1392 &self,
1393 catalog: std::sync::Arc<catalog::unified::KrishivCatalog>,
1394 catalog_name: impl Into<String>,
1395 ) {
1396 let catalog_name = catalog_name.into();
1397 let bridge = catalog::iceberg_catalog_bridge::IcebergCatalogBridge::new(
1398 Arc::clone(&catalog),
1399 catalog_name.clone(),
1400 );
1401 self.context
1402 .register_catalog(catalog_name.clone(), Arc::new(bridge));
1403 self.iceberg_catalogs
1404 .write()
1405 .unwrap_or_else(|e| e.into_inner())
1406 .push((catalog, catalog_name));
1407 self.invalidate_plan_cache();
1408 }
1409
1410 pub async fn register_iceberg_rest_catalog_from_env(&self) -> Result<bool, String> {
1422 #[cfg(feature = "rest-catalog")]
1423 {
1424 let uri = match std::env::var("KRISHIV_ICEBERG_REST_URI") {
1425 Ok(uri) => uri,
1426 Err(_) => return Ok(false),
1427 };
1428 let warehouse = std::env::var("KRISHIV_ICEBERG_REST_WAREHOUSE").unwrap_or_default();
1429 let token = std::env::var("KRISHIV_ICEBERG_REST_TOKEN").ok();
1430 let name =
1435 std::env::var("KRISHIV_ICEBERG_REST_NAME").unwrap_or_else(|_| String::from("main"));
1436 self.register_s3_object_store_for_warehouse(&warehouse)?;
1442 let catalog = std::sync::Arc::new(
1443 catalog::unified::KrishivCatalog::rest(&uri, &warehouse, token.as_deref())
1444 .await
1445 .map_err(|e| format!("iceberg REST catalog at {uri}: {e}"))?,
1446 );
1447 self.register_iceberg_catalog(std::sync::Arc::clone(&catalog), &name);
1448 if name != "krishiv" {
1454 self.register_iceberg_catalog(catalog, "krishiv");
1455 }
1456 Ok(true)
1457 }
1458 #[cfg(not(feature = "rest-catalog"))]
1459 {
1460 Ok(false)
1461 }
1462 }
1463
1464 #[must_use]
1466 pub fn with_udf_registry(
1467 mut self,
1468 registry: std::sync::Arc<std::sync::RwLock<krishiv_plan::udf::UdfRegistry>>,
1469 ) -> Self {
1470 self.udf_registry = Some(registry);
1471 self.bump_udf_version();
1473 self
1474 }
1475
1476 pub(crate) fn bump_udf_version(&self) {
1479 self.udf_registry_version.fetch_add(1, Ordering::Release);
1480 }
1481
1482 fn invalidate_plan_cache(&self) {
1487 match self.plan_cache.lock() {
1488 Ok(mut cache) => cache.clear(),
1489 Err(poisoned) => poisoned.into_inner().clear(),
1490 }
1491 }
1492
1493 pub fn clear_plan_cache(&self) {
1496 self.invalidate_plan_cache();
1497 }
1498
1499 pub async fn sync_scalar_udfs(&self) -> SqlResult<()> {
1502 let Some(registry) = &self.udf_registry else {
1503 return Ok(());
1504 };
1505 let guard = registry.read().map_err(|e| SqlError::DataFusion {
1506 message: e.to_string(),
1507 })?;
1508 let limits = self.udf_limits.clone().unwrap_or_default();
1509 udf::sync_scalar_udfs_with_limits(&self.context, &guard, limits).map_err(|e| {
1510 SqlError::DataFusion {
1511 message: e.to_string(),
1512 }
1513 })
1514 }
1515
1516 pub async fn sync_scalar_udfs_with_limits(
1521 &self,
1522 limits: krishiv_plan::udf::ResourceLimits,
1523 ) -> SqlResult<()> {
1524 self.sync_scalar_udfs_with_limits_for_profile(
1525 limits,
1526 krishiv_common::resolve_durability_profile(),
1527 )
1528 .await
1529 }
1530
1531 pub async fn sync_scalar_udfs_with_limits_for_profile(
1533 &self,
1534 limits: krishiv_plan::udf::ResourceLimits,
1535 profile: krishiv_common::DurabilityProfile,
1536 ) -> SqlResult<()> {
1537 self.sync_scalar_udfs_with_limits_for_policy(
1538 limits,
1539 krishiv_common::NativeScalarUdfPolicy::resolve(profile),
1540 )
1541 .await
1542 }
1543
1544 pub async fn sync_scalar_udfs_with_limits_for_policy(
1546 &self,
1547 limits: krishiv_plan::udf::ResourceLimits,
1548 policy: krishiv_common::NativeScalarUdfPolicy,
1549 ) -> SqlResult<()> {
1550 let Some(registry) = &self.udf_registry else {
1551 return Ok(());
1552 };
1553 let guard = registry.read().map_err(|e| SqlError::DataFusion {
1554 message: e.to_string(),
1555 })?;
1556 udf::sync_scalar_udfs_with_limits_for_policy(&self.context, &guard, limits, policy).map_err(
1557 |e| SqlError::DataFusion {
1558 message: e.to_string(),
1559 },
1560 )
1561 }
1562
1563 pub async fn sync_aggregate_udfs(&self) -> SqlResult<()> {
1565 let Some(registry) = &self.udf_registry else {
1566 return Ok(());
1567 };
1568 let guard = registry.read().map_err(|e| SqlError::DataFusion {
1569 message: e.to_string(),
1570 })?;
1571 udf::sync_aggregate_udfs(&self.context, &guard).map_err(|e| SqlError::DataFusion {
1572 message: e.to_string(),
1573 })
1574 }
1575
1576 pub async fn sync_table_udfs(&self) -> SqlResult<()> {
1578 let Some(registry) = &self.udf_registry else {
1579 return Ok(());
1580 };
1581 let guard = registry.read().map_err(|e| SqlError::DataFusion {
1582 message: e.to_string(),
1583 })?;
1584 udf::sync_table_udfs(&self.context, &guard).map_err(|e| SqlError::DataFusion {
1585 message: e.to_string(),
1586 })
1587 }
1588
1589 pub async fn sync_all_udfs(&self) -> SqlResult<()> {
1591 self.sync_scalar_udfs().await?;
1592 self.sync_aggregate_udfs().await?;
1593 self.sync_table_udfs().await?;
1594 Ok(())
1595 }
1596
1597 pub(crate) fn register_s3_object_store_for_warehouse(&self, path: &str) -> Result<(), String> {
1604 if !(path.starts_with("s3://") || path.starts_with("s3a://")) {
1605 return Ok(());
1606 }
1607 let url = url::Url::parse(path).map_err(|e| format!("invalid s3 url {path}: {e}"))?;
1608 let bucket = url.host_str().unwrap_or_default();
1609 let store_url = url::Url::parse(&format!("s3://{bucket}"))
1611 .map_err(|e| format!("invalid s3 bucket url: {e}"))?;
1612 let store = build_s3_object_store(bucket).map_err(|e| format!("s3 store init: {e}"))?;
1613 self.context.register_object_store(&store_url, store);
1614 Ok(())
1615 }
1616
1617 pub async fn register_parquet(
1619 &self,
1620 table_name: impl AsRef<str>,
1621 path: impl AsRef<Path>,
1622 ) -> SqlResult<()> {
1623 let table_name = table_name.as_ref();
1624 if table_name.trim().is_empty() {
1625 return Err(SqlError::EmptyTableName);
1626 }
1627
1628 let path = path.as_ref().to_string_lossy().into_owned();
1629
1630 self.register_s3_object_store_for_warehouse(&path)
1633 .map_err(|message| SqlError::DataFusion { message })?;
1634
1635 if self
1636 .context
1637 .table_exist(table_name)
1638 .map_err(SqlError::from)?
1639 {
1640 let _ = self
1641 .context
1642 .deregister_table(table_name)
1643 .map_err(SqlError::from)?;
1644 }
1645 self.context
1646 .register_parquet(table_name, path, ParquetReadOptions::default())
1647 .await?;
1648 if let Ok(provider) = self.context.table_provider(table_name).await
1650 && let Some(stats) = provider.statistics()
1651 && let Some(n) = stats.num_rows.get_value()
1652 && let Ok(mut counts) = self.table_row_counts.write()
1653 {
1654 counts.insert(table_name.to_string(), *n as u64);
1655 }
1656 self.invalidate_plan_cache();
1657 Ok(())
1658 }
1659
1660 pub async fn read_parquet(&self, path: impl AsRef<Path>) -> SqlResult<SqlDataFrame> {
1662 let path = path.as_ref().to_string_lossy().into_owned();
1663 let dataframe = self
1664 .context
1665 .read_parquet(path, ParquetReadOptions::default())
1666 .await?;
1667 Ok(self.make_sql_df("parquet-read", dataframe))
1668 }
1669
1670 pub async fn register_record_batches(
1676 &self,
1677 table_name: impl AsRef<str>,
1678 batches: Vec<RecordBatch>,
1679 ) -> SqlResult<()> {
1680 use std::sync::Arc;
1681 let table_name = table_name.as_ref();
1682 if table_name.trim().is_empty() {
1683 return Err(SqlError::EmptyTableName);
1684 }
1685 if batches.is_empty() {
1686 return Ok(());
1687 }
1688 let total_rows: usize = batches.iter().map(|b| b.num_rows()).sum();
1689 let schema = batches
1690 .first()
1691 .ok_or_else(|| SqlError::DataFusion {
1692 message: "empty batch list".into(),
1693 })?
1694 .schema();
1695 let mem_table =
1696 datafusion::datasource::MemTable::try_new(schema, vec![batches]).map_err(|e| {
1697 SqlError::DataFusion {
1698 message: e.to_string(),
1699 }
1700 })?;
1701 if self
1702 .context
1703 .table_exist(table_name)
1704 .map_err(SqlError::from)?
1705 {
1706 let _ = self
1707 .context
1708 .deregister_table(table_name)
1709 .map_err(SqlError::from)?;
1710 }
1711 self.context
1712 .register_table(table_name, Arc::new(mem_table))
1713 .map_err(|e| SqlError::DataFusion {
1714 message: e.to_string(),
1715 })?;
1716 if total_rows > 0
1717 && let Ok(mut counts) = self.table_row_counts.write()
1718 {
1719 counts.insert(table_name.to_string(), total_rows as u64);
1720 }
1721 self.invalidate_plan_cache();
1722 Ok(())
1723 }
1724
1725 pub async fn read_parquet_with_options(
1727 &self,
1728 path: impl AsRef<Path>,
1729 opts: &ParquetReaderOptions,
1730 ) -> SqlResult<SqlDataFrame> {
1731 let path = path.as_ref().to_string_lossy().into_owned();
1732 let mut options = datafusion::prelude::ParquetReadOptions::default();
1733 if opts.batch_size.is_some() {
1734 options = options.parquet_pruning(true);
1735 }
1736 let dataframe = self.context.read_parquet(path, options).await?;
1742 Ok(self.make_sql_df("parquet-read", dataframe))
1743 }
1744
1745 pub async fn read_csv(&self, path: impl AsRef<Path>) -> SqlResult<SqlDataFrame> {
1747 self.read_csv_with_options(path, &CsvReaderOptions::default())
1748 .await
1749 }
1750
1751 pub async fn read_csv_with_options(
1753 &self,
1754 path: impl AsRef<Path>,
1755 opts: &CsvReaderOptions,
1756 ) -> SqlResult<SqlDataFrame> {
1757 let path = path.as_ref().to_string_lossy().into_owned();
1758 let mut options = datafusion::prelude::CsvReadOptions::new();
1759 if let Some(delim) = opts.delimiter {
1760 options = options.delimiter(delim as u8);
1761 }
1762 if let Some(has_header) = opts.has_header {
1763 options = options.has_header(has_header);
1764 }
1765 let dataframe = self.context.read_csv(path, options).await?;
1766 Ok(self.make_sql_df("csv-read", dataframe))
1767 }
1768
1769 pub async fn read_json(&self, path: impl AsRef<Path>) -> SqlResult<SqlDataFrame> {
1771 let path = path.as_ref().to_string_lossy().into_owned();
1772 let dataframe = self
1773 .context
1774 .read_json(path, datafusion::prelude::JsonReadOptions::default())
1775 .await?;
1776 Ok(self.make_sql_df("json-read", dataframe))
1777 }
1778
1779 pub async fn read_delta(
1781 &self,
1782 path: impl AsRef<str>,
1783 version: Option<i64>,
1784 ) -> SqlResult<SqlDataFrame> {
1785 let path = path.as_ref();
1786 let base = path.replace(['/', '.', '-'], "_");
1787 let table = match version {
1788 Some(v) => format!("delta_{base}_v{v}"),
1789 None => format!("delta_{base}"),
1790 };
1791 lakehouse::register_delta_uri(&self.context, &table, path, version).await?;
1792 self.sql(format!("SELECT * FROM {table}")).await
1793 }
1794
1795 pub async fn read_hudi(
1797 &self,
1798 path: impl AsRef<str>,
1799 query_type: krishiv_connectors::lakehouse::HudiQueryType,
1800 begin_instant: Option<&str>,
1801 ) -> SqlResult<SqlDataFrame> {
1802 let path = path.as_ref();
1803 let table = format!("hudi_{}", path.replace(['/', '.', '-'], "_"));
1804 lakehouse::register_hudi_uri(&self.context, &table, path, query_type, begin_instant)
1805 .await?;
1806 self.sql(format!("SELECT * FROM {table}")).await
1807 }
1808
1809 pub async fn sql(&self, query: impl AsRef<str>) -> SqlResult<SqlDataFrame> {
1811 let query = query.as_ref();
1812 if query.trim().is_empty() {
1813 return Err(SqlError::EmptyQuery);
1814 }
1815
1816 {
1820 let current = self.udf_registry_version.load(Ordering::Acquire);
1821 let last = self.udf_last_synced_version.load(Ordering::Relaxed);
1822 if current != last {
1823 self.sync_all_udfs().await?;
1824 self.udf_last_synced_version
1825 .store(current, Ordering::Release);
1826 }
1827 }
1828
1829 if let Some(stmt) = introspection_sql::parse_introspection_statement(query)? {
1831 return match stmt {
1832 introspection_sql::IntrospectionStatement::Describe { table } => {
1833 let batch = introspection_sql::describe_table(&self.context, &table).await?;
1834 let describe_table_name = next_ephemeral_name("describe_result");
1835 lakehouse::register_scan_batches(
1836 &self.context,
1837 &describe_table_name,
1838 vec![batch],
1839 )
1840 .await?;
1841 let dataframe = self
1842 .context
1843 .sql(&format!("SELECT * FROM {describe_table_name}"))
1844 .await?;
1845 Ok(self.attach_query_metadata(self.make_sql_df("describe", dataframe), query))
1846 }
1847 introspection_sql::IntrospectionStatement::Explain { mode, query: inner } => {
1848 let text = introspection_sql::explain_query(&inner, mode)?;
1849 let batch = introspection_sql::explain_result_batch(&text)?;
1850 let explain_table = next_ephemeral_name("explain_result");
1851 lakehouse::register_scan_batches(&self.context, &explain_table, vec![batch])
1852 .await?;
1853 let dataframe = self
1854 .context
1855 .sql(&format!("SELECT * FROM {explain_table}"))
1856 .await?;
1857 Ok(self.attach_query_metadata(self.make_sql_df("explain", dataframe), query))
1858 }
1859 };
1860 }
1861
1862 if live_table::execute_live_table_ddl(&self.live_table_registry, query)?.is_some() {
1864 let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
1865 return Ok(self.attach_query_metadata(self.make_sql_df("live-table-ddl", empty), query));
1866 }
1867
1868 match incremental_view::execute_incremental_view_ddl(
1870 &self.incremental_view_registry,
1871 query,
1872 )? {
1873 Some(incremental_view::IncrementalViewResult::Refresh(_name)) => {
1874 let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
1877 return Ok(self.attach_query_metadata(
1878 self.make_sql_df("incremental-view-refresh", empty),
1879 query,
1880 ));
1881 }
1882 Some(_) => {
1883 let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
1884 return Ok(self.attach_query_metadata(
1885 self.make_sql_df("incremental-view-ddl", empty),
1886 query,
1887 ));
1888 }
1889 None => {}
1890 }
1891
1892 if pipeline_ddl::execute_pipeline_ddl(&self.pipeline_registry, query)?.is_some() {
1896 let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
1897 return Ok(self.attach_query_metadata(self.make_sql_df("pipeline-ddl", empty), query));
1898 }
1899
1900 let trimmed = query.trim();
1903 if trimmed
1904 .to_ascii_uppercase()
1905 .starts_with("SET SHUFFLE.PARTITIONS")
1906 {
1907 let value = trimmed.split('=').nth(1).map(|s| s.trim()).unwrap_or("");
1908 match value.parse::<u32>() {
1909 Ok(n) if n > 0 => {
1910 {
1911 let mut guard =
1912 self.shuffle_partitions
1913 .write()
1914 .map_err(|e| SqlError::DataFusion {
1915 message: e.to_string(),
1916 })?;
1917 *guard = Some(n);
1918 }
1919 let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
1920 return Ok(self.make_sql_df("set-shuffle-partitions", empty));
1921 }
1922 Ok(_) => {
1923 {
1924 let mut guard =
1925 self.shuffle_partitions
1926 .write()
1927 .map_err(|e| SqlError::DataFusion {
1928 message: e.to_string(),
1929 })?;
1930 *guard = None;
1931 }
1932 let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
1933 return Ok(self.make_sql_df("set-shuffle-partitions", empty));
1934 }
1935 Err(_) => {
1936 return Err(SqlError::DataFusion {
1937 message: format!(
1938 "invalid shuffle.partitions value '{value}'; expected a positive integer"
1939 ),
1940 });
1941 }
1942 }
1943 }
1944
1945 if create_function_ddl::is_create_function_returns_table(query) {
1950 let ddl = create_function_ddl::parse_create_function(query)
1951 .map_err(|message| SqlError::InvalidTableFunction { message })?;
1952 if ddl.language.as_deref() != Some("sql") {
1953 return Err(SqlError::Unsupported {
1954 feature: format!(
1955 "CREATE FUNCTION '{}' uses language {:?}; only LANGUAGE SQL AS '...' \
1956 table functions are executable",
1957 ddl.function_name, ddl.language
1958 ),
1959 });
1960 }
1961 let body = ddl
1962 .body
1963 .as_deref()
1964 .filter(|body| !body.trim().is_empty())
1965 .ok_or_else(|| SqlError::InvalidTableFunction {
1966 message: format!(
1967 "SQL table function '{}' requires a non-empty AS body",
1968 ddl.function_name
1969 ),
1970 })?;
1971 let fields: Vec<_> = ddl
1972 .return_columns
1973 .iter()
1974 .map(|column| {
1975 arrow::datatypes::Field::new(&column.name, column.data_type.clone(), true)
1976 })
1977 .collect();
1978 let schema = arrow::datatypes::Schema::new(fields);
1979 let udf: std::sync::Arc<dyn krishiv_plan::udf::TableUdf> = std::sync::Arc::new(
1980 create_function_ddl::SqlBodyTableUdf::try_new(
1981 &ddl.function_name,
1982 schema,
1983 body,
1984 ddl.arguments.len(),
1985 std::sync::Arc::new(self.context.clone()),
1986 )
1987 .map_err(|error| SqlError::InvalidTableFunction {
1988 message: error.to_string(),
1989 })?,
1990 );
1991 if let Some(registry) = &self.udf_registry {
1992 let mut guard = registry.write().map_err(|e| SqlError::DataFusion {
1993 message: e.to_string(),
1994 })?;
1995 guard.register_table(std::sync::Arc::clone(&udf));
1996 }
1997 udf::register_single_table_udf(&self.context, std::sync::Arc::clone(&udf))
1998 .map_err(SqlError::from)?;
1999 let empty = self.context.sql("SELECT 1 WHERE FALSE").await?;
2000 return Ok(
2001 self.attach_query_metadata(self.make_sql_df("create-function", empty), query)
2002 );
2003 }
2004
2005 if query
2006 .trim_start()
2007 .to_ascii_uppercase()
2008 .starts_with("MERGE INTO")
2009 {
2010 let batches = lakehouse::execute_merge_sql(&self.context, query).await?;
2011 let merge_table = next_ephemeral_name("merge_result");
2012 lakehouse::register_scan_batches(&self.context, &merge_table, batches).await?;
2013 let dataframe = self
2014 .context
2015 .sql(&format!("SELECT * FROM {merge_table}"))
2016 .await?;
2017 return Ok(self.attach_query_metadata(self.make_sql_df("merge", dataframe), query));
2018 }
2019
2020 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2029 if trimmed.to_ascii_uppercase().starts_with("CREATE ")
2030 && let Some(parsed_ctas) = parse_ctas(trimmed)
2031 {
2032 let resolved = self.resolve_iceberg_table(&parsed_ctas.table_ref);
2033 if resolved.is_none() && !parsed_ctas.partition_by.is_empty() {
2036 return Err(SqlError::DataFusion {
2037 message: format!(
2038 "PARTITIONED BY requires an Iceberg catalog table; `{}` does not \
2039 resolve to a registered Iceberg catalog",
2040 parsed_ctas.table_ref
2041 ),
2042 });
2043 }
2044 if let Some((iceberg_catalog, table_ident)) = resolved {
2045 return self
2046 .execute_iceberg_ctas(iceberg_catalog, table_ident, parsed_ctas, query)
2047 .await;
2048 }
2049 }
2050
2051 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2054 if trimmed.to_ascii_uppercase().starts_with("CALL SYSTEM.") {
2055 let result = self.dispatch_call_system(trimmed).await?;
2056 let call_table = next_ephemeral_name("call_result");
2057 lakehouse::register_scan_batches(&self.context, &call_table, vec![result]).await?;
2058 let dataframe = self
2059 .context
2060 .sql(&format!("SELECT * FROM {call_table}"))
2061 .await?;
2062 return Ok(self.attach_query_metadata(self.make_sql_df("call", dataframe), query));
2063 }
2064
2065 if trimmed
2071 .get(..14)
2072 .is_some_and(|p| p.eq_ignore_ascii_case("ANALYZE TABLE "))
2073 {
2074 let result = self.dispatch_analyze_table(trimmed).await?;
2075 let res_table = next_ephemeral_name("analyze_result");
2076 lakehouse::register_scan_batches(&self.context, &res_table, vec![result]).await?;
2077 let dataframe = self
2078 .context
2079 .sql(&format!("SELECT * FROM {res_table}"))
2080 .await?;
2081 return Ok(self.attach_query_metadata(self.make_sql_df("analyze", dataframe), query));
2082 }
2083
2084 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2088 if trimmed.to_ascii_uppercase().starts_with("DELETE FROM ")
2089 && let Some((table_ref, predicate)) = parse_dml_delete(trimmed)
2090 && let Some((iceberg_catalog, table_ident)) = self.resolve_iceberg_table(&table_ref)
2091 {
2092 use arrow::array::{ArrayRef, Int64Array};
2093 use arrow::datatypes::{DataType, Field, Schema};
2094 let (deleted, _) = krishiv_connectors::lakehouse::dml::iceberg_delete_where(
2095 iceberg_catalog,
2096 &table_ident,
2097 &predicate,
2098 &self.context,
2099 )
2100 .await
2101 .map_err(|e| SqlError::DataFusion {
2102 message: e.to_string(),
2103 })?;
2104 self.adjust_table_row_count_stat(&table_ref, -(deleted as i64));
2106 let schema = Arc::new(Schema::new(vec![Field::new(
2107 "deleted_rows",
2108 DataType::Int64,
2109 false,
2110 )]));
2111 let array: ArrayRef = Arc::new(Int64Array::from(vec![deleted as i64]));
2112 let batch =
2113 RecordBatch::try_new(schema, vec![array]).map_err(|e| SqlError::DataFusion {
2114 message: e.to_string(),
2115 })?;
2116 let res_table = next_ephemeral_name("delete_result");
2117 lakehouse::register_scan_batches(&self.context, &res_table, vec![batch]).await?;
2118 let dataframe = self
2119 .context
2120 .sql(&format!("SELECT * FROM {res_table}"))
2121 .await?;
2122 return Ok(self.attach_query_metadata(self.make_sql_df("delete", dataframe), query));
2123 }
2124
2125 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2127 if trimmed.to_ascii_uppercase().starts_with("UPDATE ")
2128 && let Some(parsed) = parse_dml_update(trimmed)
2129 && let Some((iceberg_catalog, table_ident)) =
2130 self.resolve_iceberg_table(&parsed.table_ref)
2131 {
2132 use arrow::array::{ArrayRef, Int64Array};
2133 use arrow::datatypes::{DataType, Field, Schema};
2134 let borrowed: Vec<(&str, &str)> = parsed
2135 .assignments
2136 .iter()
2137 .map(|(c, e)| (c.as_str(), e.as_str()))
2138 .collect();
2139 let pred = parsed.predicate.as_deref();
2140 let (updated, _) = krishiv_connectors::lakehouse::dml::iceberg_update_where(
2141 iceberg_catalog,
2142 &table_ident,
2143 &borrowed,
2144 pred,
2145 &self.context,
2146 )
2147 .await
2148 .map_err(|e| SqlError::DataFusion {
2149 message: e.to_string(),
2150 })?;
2151 let schema = Arc::new(Schema::new(vec![Field::new(
2152 "updated_rows",
2153 DataType::Int64,
2154 false,
2155 )]));
2156 let array: ArrayRef = Arc::new(Int64Array::from(vec![updated as i64]));
2157 let batch =
2158 RecordBatch::try_new(schema, vec![array]).map_err(|e| SqlError::DataFusion {
2159 message: e.to_string(),
2160 })?;
2161 let res_table = next_ephemeral_name("update_result");
2162 lakehouse::register_scan_batches(&self.context, &res_table, vec![batch]).await?;
2163 let dataframe = self
2164 .context
2165 .sql(&format!("SELECT * FROM {res_table}"))
2166 .await?;
2167 return Ok(self.attach_query_metadata(self.make_sql_df("update", dataframe), query));
2168 }
2169
2170 if query.to_ascii_uppercase().contains(" MATCH_RECOGNIZE ")
2174 && let Some(stmt) = cep_sql::parse_match_recognize(query)?
2175 {
2176 let is_streaming = self.is_streaming_source(&stmt.source_table);
2177 let streaming_limit = streaming_match_recognize_limit_from_env();
2185 let source_sql = if is_streaming {
2186 format!(
2187 "SELECT * FROM {} LIMIT {}",
2188 stmt.source_table, streaming_limit
2189 )
2190 } else {
2191 format!("SELECT * FROM {}", stmt.source_table)
2192 };
2193 let source_df = self.context.sql(&source_sql).await?;
2194 let source_batches = source_df.collect().await?;
2195 if is_streaming {
2196 tracing::warn!(
2197 source = %stmt.source_table,
2198 limit = streaming_limit,
2199 collected_rows = source_batches.iter().map(|b| b.num_rows()).sum::<usize>(),
2200 "MATCH_RECOGNIZE executed against a streaming source under \
2201 bounded materialisation; results only cover the first {0} rows \
2202 of the source. Set KRISHIV_MATCH_RECOGNIZE_STREAMING_LIMIT to a \
2203 larger value if your executor has the memory budget.",
2204 streaming_limit
2205 );
2206 }
2207 let results = cep_sql::execute_match_recognize(stmt, &source_batches)?;
2208 let cep_table = next_ephemeral_name("cep_result");
2209 lakehouse::register_scan_batches(&self.context, &cep_table, results).await?;
2210 let dataframe = self
2211 .context
2212 .sql(&format!("SELECT * FROM {cep_table}"))
2213 .await?;
2214 return Ok(self.attach_query_metadata(self.make_sql_df("cep", dataframe), query));
2215 }
2216
2217 let query = &pivot_sql::rewrite_pivot_unpivot(query)?;
2220
2221 let query = &streaming_tvf::rewrite_window_tvfs(query);
2223
2224 let (rewritten, as_ofs) =
2225 lakehouse::preprocess_as_of_sql(query).unwrap_or_else(|_| (query.to_string(), vec![]));
2226 lakehouse::apply_as_of_refs(&self.context, &as_ofs).await?;
2227
2228 let can_cache = as_ofs.is_empty();
2235 let shuffle_override = self
2236 .shuffle_partitions
2237 .read()
2238 .map(|g| *g)
2239 .unwrap_or_else(|e| *e.into_inner());
2240 if can_cache {
2241 let cached_plan: Option<datafusion::logical_expr::LogicalPlan> = self
2243 .plan_cache
2244 .lock()
2245 .unwrap_or_else(|e| e.into_inner())
2246 .get(&rewritten)
2247 .cloned();
2248 if let Some(plan) = cached_plan {
2249 let dataframe = self.context.execute_logical_plan(plan).await?;
2250 return Ok(self.attach_query_metadata(
2251 self.make_sql_df("sql-query", dataframe)
2252 .with_shuffle_partitions(shuffle_override),
2253 &rewritten,
2254 ));
2255 }
2256 }
2257
2258 let dataframe = self.context.sql(&rewritten).await?;
2259
2260 if let Some(table_name) = extract_create_external_table_name(&rewritten)
2264 && !table_name.is_empty()
2265 && let Ok(provider) = self.context.table_provider(&table_name).await
2266 {
2267 let maybe_rows = provider
2268 .statistics()
2269 .and_then(|s| s.num_rows.get_value().copied());
2270 if let Some(n) = maybe_rows
2271 && let Ok(mut counts) = self.table_row_counts.write()
2272 {
2273 counts.entry(table_name).or_insert(n as u64);
2274 }
2275 }
2276
2277 if can_cache {
2279 let plan = dataframe.logical_plan().clone();
2280 match self.plan_cache.lock() {
2281 Ok(mut cache) => cache.insert(rewritten.clone(), plan),
2282 Err(poisoned) => poisoned.into_inner().insert(rewritten.clone(), plan),
2283 }
2284 }
2285
2286 Ok(self.attach_query_metadata(
2287 self.make_sql_df("sql-query", dataframe)
2288 .with_shuffle_partitions(shuffle_override),
2289 &rewritten,
2290 ))
2291 }
2292
2293 pub async fn execute_with_timeout(
2300 &self,
2301 query: impl AsRef<str> + Send,
2302 timeout_ms: u64,
2303 ) -> SqlResult<SqlDataFrame> {
2304 let timeout = std::time::Duration::from_millis(timeout_ms);
2305 tokio::time::timeout(timeout, self.sql(query))
2306 .await
2307 .map_err(|_| SqlError::Timeout { timeout_ms })?
2308 }
2309
2310 pub async fn execute_with_operation_id(
2317 &self,
2318 operation_id: u64,
2319 query: impl AsRef<str> + Send,
2320 cancelled_ids: &OperationRegistry,
2321 ) -> SqlResult<TaggedQueryResult> {
2322 if cancelled_ids.is_cancelled(operation_id) {
2323 return Err(SqlError::OperationCancelled { operation_id });
2324 }
2325 let df = self.sql(query).await?;
2326 Ok(TaggedQueryResult {
2327 operation_id,
2328 inner: df,
2329 })
2330 }
2331
2332 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2338 fn resolve_iceberg_table(
2339 &self,
2340 table_ref: &str,
2341 ) -> Option<(Arc<dyn iceberg::Catalog + Send + Sync>, iceberg::TableIdent)> {
2342 let parts: Vec<&str> = table_ref.splitn(3, '.').collect();
2343 let (catalog_arc, ns_str, table_str) = {
2344 let guard = self
2345 .iceberg_catalogs
2346 .read()
2347 .unwrap_or_else(|e| e.into_inner());
2348 if guard.is_empty() {
2349 return None;
2350 }
2351 match parts.len() {
2352 2 => {
2353 let (cat, _) = guard.first()?;
2354 (Arc::clone(cat), *parts.first()?, *parts.get(1)?)
2355 }
2356 3 => {
2357 let cat_name = parts.first().copied()?;
2358 let (cat, _) = guard.iter().find(|(_, n)| n == cat_name)?;
2359 (Arc::clone(cat), *parts.get(1)?, *parts.get(2)?)
2360 }
2361 _ => return None,
2362 }
2363 };
2364 let ns = iceberg::NamespaceIdent::from_vec(vec![ns_str.to_string()]).ok()?;
2365 let ident = iceberg::TableIdent::new(ns, table_str.to_string());
2366 Some((catalog_arc.as_iceberg(), ident))
2367 }
2368
2369 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2375 async fn execute_iceberg_ctas(
2376 &self,
2377 iceberg_catalog: Arc<dyn iceberg::Catalog + Send + Sync>,
2378 table_ident: iceberg::TableIdent,
2379 parsed_ctas: ParsedCtas,
2380 query: &str,
2381 ) -> SqlResult<SqlDataFrame> {
2382 use arrow::array::{ArrayRef, Int64Array};
2383 use arrow::datatypes::{DataType, Field, Schema};
2384 use krishiv_connectors::lakehouse::partitioned_write::parse_partition_transform;
2385
2386 let partition_by = parsed_ctas
2387 .partition_by
2388 .iter()
2389 .map(|item| parse_partition_transform(item))
2390 .collect::<Result<Vec<_>, _>>()
2391 .map_err(|e| SqlError::DataFusion {
2392 message: e.to_string(),
2393 })?;
2394
2395 let dataframe = self.context.sql(&parsed_ctas.inner_query).await?;
2396 let stream = dataframe
2397 .execute_stream()
2398 .await
2399 .map_err(|e| SqlError::DataFusion {
2400 message: e.to_string(),
2401 })?;
2402 let report = krishiv_connectors::lakehouse::dml::land_ctas(
2403 iceberg_catalog,
2404 &table_ident,
2405 parsed_ctas.or_replace,
2406 &partition_by,
2407 stream,
2408 )
2409 .await
2410 .map_err(|e| SqlError::DataFusion {
2411 message: e.to_string(),
2412 })?;
2413 self.invalidate_plan_cache();
2415 self.record_table_row_count_stat(&parsed_ctas.table_ref, report.rows as u64);
2417
2418 let schema = Arc::new(Schema::new(vec![
2419 Field::new("rows_written", DataType::Int64, false),
2420 Field::new("bytes_written", DataType::Int64, false),
2421 Field::new("data_files", DataType::Int64, false),
2422 Field::new("snapshot_id", DataType::Int64, false),
2423 ]));
2424 let columns: Vec<ArrayRef> = vec![
2425 Arc::new(Int64Array::from(vec![report.rows as i64])),
2426 Arc::new(Int64Array::from(vec![report.bytes as i64])),
2427 Arc::new(Int64Array::from(vec![report.data_files as i64])),
2428 Arc::new(Int64Array::from(vec![report.snapshot_id])),
2429 ];
2430 let batch = RecordBatch::try_new(schema, columns).map_err(|e| SqlError::DataFusion {
2431 message: e.to_string(),
2432 })?;
2433 let res_table = next_ephemeral_name("ctas_result");
2434 lakehouse::register_scan_batches(&self.context, &res_table, vec![batch]).await?;
2435 let dataframe = self
2436 .context
2437 .sql(&format!("SELECT * FROM {res_table}"))
2438 .await?;
2439 Ok(self.attach_query_metadata(self.make_sql_df("ctas", dataframe), query))
2440 }
2441
2442 fn record_table_row_count_stat(&self, table_ref: &str, row_count: u64) {
2447 let registry = krishiv_plan::optimizer::global_table_stats();
2448 let mut names = vec![table_ref];
2449 let bare = table_ref.rsplit('.').next().unwrap_or(table_ref);
2450 if bare != table_ref {
2451 names.push(bare);
2452 }
2453 for name in &names {
2454 let mut stats = registry
2455 .get(name)
2456 .unwrap_or_else(|| krishiv_plan::optimizer::TableCboStats::new(*name));
2457 stats.row_count = Some(row_count);
2458 registry.put(stats);
2459 }
2460 if let Ok(mut counts) = self.table_row_counts.write() {
2461 for name in &names {
2462 counts.insert((*name).to_owned(), row_count);
2463 }
2464 }
2465 }
2466
2467 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2472 fn adjust_table_row_count_stat(&self, table_ref: &str, delta: i64) {
2473 let registry = krishiv_plan::optimizer::global_table_stats();
2474 let mut names = vec![table_ref];
2475 let bare = table_ref.rsplit('.').next().unwrap_or(table_ref);
2476 if bare != table_ref {
2477 names.push(bare);
2478 }
2479 for name in &names {
2480 if let Some(mut stats) = registry.get(name)
2481 && let Some(current) = stats.row_count
2482 {
2483 stats.row_count = Some(current.saturating_add_signed(delta));
2484 registry.put(stats);
2485 }
2486 }
2487 if let Ok(mut counts) = self.table_row_counts.write() {
2488 for name in &names {
2489 if let Some(current) = counts.get(*name).copied() {
2490 counts.insert((*name).to_owned(), current.saturating_add_signed(delta));
2491 }
2492 }
2493 }
2494 }
2495
2496 async fn dispatch_analyze_table(&self, stmt: &str) -> SqlResult<RecordBatch> {
2507 use arrow::array::{ArrayRef, Int64Array, StringArray};
2508 use arrow::datatypes::{DataType, Field, Schema};
2509
2510 let rest = stmt
2511 .get(14..)
2512 .unwrap_or("")
2513 .trim()
2514 .trim_end_matches(';')
2515 .trim();
2516 let (table_ref, tail) = match rest.split_once(char::is_whitespace) {
2517 Some((t, tail)) => (t.trim(), tail.trim()),
2518 None => (rest, ""),
2519 };
2520 if table_ref.is_empty() {
2521 return Err(SqlError::DataFusion {
2522 message: String::from("ANALYZE TABLE: table reference is required"),
2523 });
2524 }
2525 let mut tail = tail;
2527 if tail
2528 .get(..18)
2529 .is_some_and(|p| p.eq_ignore_ascii_case("COMPUTE STATISTICS"))
2530 {
2531 tail = tail.get(18..).unwrap_or("").trim();
2532 }
2533 let columns: Vec<String> = if tail
2534 .get(..11)
2535 .is_some_and(|p| p.eq_ignore_ascii_case("FOR COLUMNS"))
2536 {
2537 tail.get(11..)
2538 .unwrap_or("")
2539 .trim()
2540 .trim_start_matches('(')
2541 .trim_end_matches(')')
2542 .split(',')
2543 .map(|c| c.trim().trim_matches('"').to_owned())
2544 .filter(|c| !c.is_empty())
2545 .collect()
2546 } else if tail.is_empty() {
2547 Vec::new()
2548 } else {
2549 return Err(SqlError::DataFusion {
2550 message: format!("ANALYZE TABLE: unexpected trailing clause: {tail}"),
2551 });
2552 };
2553
2554 let mut projections = vec![String::from("count(*)")];
2556 for c in &columns {
2557 projections.push(format!("approx_distinct(\"{c}\")"));
2558 projections.push(format!("min(\"{c}\")"));
2559 projections.push(format!("max(\"{c}\")"));
2560 projections.push(format!("count(\"{c}\")"));
2561 }
2562 let scan_sql = format!("SELECT {} FROM {table_ref}", projections.join(", "));
2563 let batches = self.context.sql(&scan_sql).await?.collect().await?;
2564 let row = batches
2565 .iter()
2566 .find(|b| b.num_rows() > 0)
2567 .ok_or_else(|| SqlError::DataFusion {
2568 message: format!("ANALYZE TABLE {table_ref}: aggregation returned no rows"),
2569 })?;
2570 let cell_string = |col: usize| -> Option<String> {
2571 let column = row.columns().get(col)?;
2572 if column.is_null(0) {
2573 return None;
2574 }
2575 arrow::util::display::array_value_to_string(column, 0).ok()
2576 };
2577 let cell_u64 = |col: usize| -> Option<u64> { cell_string(col)?.parse().ok() };
2578 let row_count = cell_u64(0).ok_or_else(|| SqlError::DataFusion {
2579 message: format!("ANALYZE TABLE {table_ref}: COUNT(*) unreadable"),
2580 })?;
2581
2582 let mut column_stats = Vec::with_capacity(columns.len());
2583 for (i, name) in columns.iter().enumerate() {
2584 let base = 1 + i * 4;
2585 let non_null = cell_u64(base + 3);
2586 column_stats.push(krishiv_plan::optimizer::ColumnCboStats {
2587 name: name.clone(),
2588 ndv: cell_u64(base),
2589 min: cell_string(base + 1),
2590 max: cell_string(base + 2),
2591 null_count: non_null.map(|n| row_count.saturating_sub(n)),
2592 });
2593 }
2594
2595 let avg_row_bytes = match self.context.table_provider(table_ref).await {
2597 Ok(provider) => provider.statistics().and_then(|s| {
2598 let rows = s.num_rows.get_value().copied()?;
2599 let bytes = s.total_byte_size.get_value().copied()?;
2600 (rows > 0).then(|| (bytes / rows) as u64)
2601 }),
2602 Err(_) => None,
2603 };
2604
2605 let mut stats = krishiv_plan::optimizer::TableCboStats::new(table_ref)
2606 .with_row_count(row_count);
2607 if let Some(bytes) = avg_row_bytes {
2608 stats = stats.with_avg_row_bytes(bytes);
2609 }
2610 if let Some(max_ndv) = column_stats.iter().filter_map(|c| c.ndv).max() {
2611 stats = stats.with_ndv(max_ndv);
2613 }
2614 stats.columns = column_stats;
2615 let registry = krishiv_plan::optimizer::global_table_stats();
2616 let bare = table_ref.rsplit('.').next().unwrap_or(table_ref);
2619 if bare != table_ref {
2620 let mut bare_stats = stats.clone();
2621 bare_stats.table = bare.to_owned();
2622 registry.put(bare_stats);
2623 }
2624 let analyzed_columns = stats.columns.len();
2625 registry.put(stats);
2626 if let Ok(mut counts) = self.table_row_counts.write() {
2627 counts.insert(table_ref.to_owned(), row_count);
2628 if bare != table_ref {
2629 counts.insert(bare.to_owned(), row_count);
2630 }
2631 }
2632 self.invalidate_plan_cache();
2633
2634 let schema = Arc::new(Schema::new(vec![
2635 Field::new("table_name", DataType::Utf8, false),
2636 Field::new("row_count", DataType::Int64, false),
2637 Field::new("avg_row_bytes", DataType::Int64, true),
2638 Field::new("columns_analyzed", DataType::Int64, false),
2639 ]));
2640 let columns_out: Vec<ArrayRef> = vec![
2641 Arc::new(StringArray::from(vec![table_ref.to_owned()])),
2642 Arc::new(Int64Array::from(vec![row_count as i64])),
2643 Arc::new(Int64Array::from(vec![avg_row_bytes.map(|b| b as i64)])),
2644 Arc::new(Int64Array::from(vec![analyzed_columns as i64])),
2645 ];
2646 RecordBatch::try_new(schema, columns_out).map_err(|e| SqlError::DataFusion {
2647 message: e.to_string(),
2648 })
2649 }
2650
2651 #[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
2654 async fn dispatch_call_system(&self, stmt: &str) -> SqlResult<RecordBatch> {
2655 use arrow::array::{ArrayRef, Int64Array};
2656 use arrow::datatypes::{DataType, Field, Schema};
2657
2658 let upper = stmt.to_ascii_uppercase();
2659 const PREFIX: &str = "CALL SYSTEM.";
2660 let upper_after = &upper[PREFIX.len()..];
2661 let orig_after = &stmt[PREFIX.len()..];
2662
2663 let paren = upper_after.find('(').ok_or_else(|| SqlError::DataFusion {
2664 message: format!("CALL: missing '(' in: {stmt}"),
2665 })?;
2666 let proc_name = upper_after[..paren].trim();
2667
2668 let args_raw = orig_after[paren + 1..]
2669 .trim_end_matches(';')
2670 .trim()
2671 .trim_end_matches(')')
2672 .trim();
2673 let args = call_args_from_str(args_raw);
2674
2675 let iceberg_catalog = {
2676 let guard = self
2677 .iceberg_catalogs
2678 .read()
2679 .unwrap_or_else(|e| e.into_inner());
2680 guard
2681 .first()
2682 .ok_or_else(|| SqlError::DataFusion {
2683 message: "CALL system: no Iceberg catalog registered".to_string(),
2684 })?
2685 .0
2686 .as_iceberg()
2687 };
2688
2689 let table_ref = args.first().ok_or_else(|| SqlError::DataFusion {
2690 message: format!("CALL {proc_name}: table reference argument is required"),
2691 })?;
2692 let table_ident = iceberg_table_ident(table_ref)?;
2693
2694 if proc_name == "MAINTAIN_TABLE" {
2698 let older_than = parse_call_duration(args.get(1).map_or("7 days", |s| s.as_str()))?;
2699 let target_bytes = args
2700 .get(2)
2701 .and_then(|s| s.parse::<u64>().ok())
2702 .unwrap_or(128 * 1024 * 1024);
2703 let retain_last = args
2704 .get(3)
2705 .and_then(|s| s.parse::<usize>().ok())
2706 .unwrap_or(1);
2707 let report = krishiv_connectors::lakehouse::maintenance::maintain_table(
2708 iceberg_catalog,
2709 &table_ident,
2710 target_bytes,
2711 older_than,
2712 retain_last,
2713 )
2714 .await
2715 .map_err(|e| SqlError::DataFusion {
2716 message: e.to_string(),
2717 })?;
2718 let schema = Arc::new(Schema::new(vec![
2719 Field::new("compacted_files", DataType::Int64, false),
2720 Field::new("expired_snapshots", DataType::Int64, false),
2721 Field::new("removed_orphans", DataType::Int64, false),
2722 ]));
2723 let columns: Vec<ArrayRef> = vec![
2724 Arc::new(Int64Array::from(vec![report.compacted_files as i64])),
2725 Arc::new(Int64Array::from(vec![report.expired_snapshots as i64])),
2726 Arc::new(Int64Array::from(vec![report.removed_orphans as i64])),
2727 ];
2728 return RecordBatch::try_new(schema, columns).map_err(|e| SqlError::DataFusion {
2729 message: e.to_string(),
2730 });
2731 }
2732
2733 let count: i64 = match proc_name {
2734 "EXPIRE_SNAPSHOTS" => {
2735 let dur_s = args.get(1).ok_or_else(|| SqlError::DataFusion {
2736 message: "CALL expire_snapshots: duration argument is required".to_string(),
2737 })?;
2738 let older_than = parse_call_duration(dur_s)?;
2739 let retain_last = args
2740 .get(2)
2741 .and_then(|s| s.parse::<usize>().ok())
2742 .unwrap_or(1);
2743 krishiv_connectors::lakehouse::maintenance::expire_snapshots(
2744 iceberg_catalog,
2745 &table_ident,
2746 older_than,
2747 retain_last,
2748 )
2749 .await
2750 .map_err(|e| SqlError::DataFusion {
2751 message: e.to_string(),
2752 })? as i64
2753 }
2754 "REMOVE_ORPHAN_FILES" => {
2755 let dur_s = args.get(1).ok_or_else(|| SqlError::DataFusion {
2756 message: "CALL remove_orphan_files: duration argument is required".to_string(),
2757 })?;
2758 let older_than = parse_call_duration(dur_s)?;
2759 krishiv_connectors::lakehouse::maintenance::remove_orphan_files(
2760 iceberg_catalog,
2761 &table_ident,
2762 older_than,
2763 )
2764 .await
2765 .map_err(|e| SqlError::DataFusion {
2766 message: e.to_string(),
2767 })? as i64
2768 }
2769 "COMPACT_DATA_FILES" => {
2770 let target_bytes = args
2771 .get(1)
2772 .and_then(|s| s.parse::<u64>().ok())
2773 .unwrap_or(128 * 1024 * 1024);
2774 krishiv_connectors::lakehouse::maintenance::compact_data_files(
2775 iceberg_catalog,
2776 &table_ident,
2777 target_bytes,
2778 )
2779 .await
2780 .map_err(|e| SqlError::DataFusion {
2781 message: e.to_string(),
2782 })? as i64
2783 }
2784 other => {
2785 return Err(SqlError::Unsupported {
2786 feature: format!("CALL system.{other}: unknown procedure"),
2787 });
2788 }
2789 };
2790
2791 let col = match proc_name {
2792 "EXPIRE_SNAPSHOTS" => "expired_snapshots",
2793 "REMOVE_ORPHAN_FILES" => "removed_files",
2794 "COMPACT_DATA_FILES" => "rewritten_files",
2795 _ => "result",
2796 };
2797 let schema = Arc::new(Schema::new(vec![Field::new(col, DataType::Int64, false)]));
2798 let array: ArrayRef = Arc::new(Int64Array::from(vec![count]));
2799 RecordBatch::try_new(schema, vec![array]).map_err(|e| SqlError::DataFusion {
2800 message: e.to_string(),
2801 })
2802 }
2803}
2804
2805pub struct TaggedQueryResult {
2807 pub operation_id: u64,
2809 pub inner: SqlDataFrame,
2811}
2812
2813#[derive(Clone, Default)]
2819pub struct OperationRegistry {
2820 cancelled: Arc<std::sync::RwLock<std::collections::HashSet<u64>>>,
2821 progress: Arc<std::sync::RwLock<std::collections::HashMap<u64, (u64, u64)>>>,
2822}
2823
2824impl OperationRegistry {
2825 pub fn new() -> Self {
2827 Self::default()
2828 }
2829
2830 pub fn cancel(&self, operation_id: u64) {
2834 if let Ok(mut ids) = self.cancelled.write() {
2835 ids.insert(operation_id);
2836 }
2837 }
2838
2839 pub fn is_cancelled(&self, operation_id: u64) -> bool {
2841 self.cancelled
2842 .read()
2843 .map(|ids| ids.contains(&operation_id))
2844 .unwrap_or(false)
2845 }
2846
2847 pub fn remove(&self, operation_id: u64) {
2849 if let Ok(mut ids) = self.cancelled.write() {
2850 ids.remove(&operation_id);
2851 }
2852 if let Ok(mut progress) = self.progress.write() {
2853 progress.remove(&operation_id);
2854 }
2855 }
2856
2857 pub fn update_progress(&self, operation_id: u64, rows_scanned: u64, rows_emitted: u64) {
2859 if let Ok(mut progress) = self.progress.write() {
2860 progress.insert(operation_id, (rows_scanned, rows_emitted));
2861 }
2862 }
2863
2864 pub fn progress(&self, operation_id: u64) -> Option<(u64, u64)> {
2866 self.progress
2867 .read()
2868 .ok()
2869 .and_then(|progress| progress.get(&operation_id).copied())
2870 }
2871
2872 pub fn cancelled_ids(&self) -> Vec<u64> {
2874 self.cancelled
2875 .read()
2876 .map(|ids| ids.iter().copied().collect())
2877 .unwrap_or_default()
2878 }
2879}
2880
2881pub(crate) fn extract_create_external_table_name(query: &str) -> Option<String> {
2886 use datafusion::sql::parser::{DFParser, Statement as DFStatement};
2887 let mut stmts = DFParser::parse_sql(query).ok()?;
2888 match stmts.pop_front()? {
2889 DFStatement::CreateExternalTable(create) => Some(create.name.to_string()),
2890 _ => None,
2891 }
2892}
2893
2894pub enum GroupingMode<'a> {
2902 Sets(Vec<Vec<&'a krishiv_plan::expression::Expr>>),
2903 Cube(Vec<&'a krishiv_plan::expression::Expr>),
2904 Rollup(Vec<&'a krishiv_plan::expression::Expr>),
2905}
2906
2907#[async_trait::async_trait]
2908pub trait KrishivDataFrameOps: Send + Sync {
2909 async fn collect(&self) -> SqlResult<Vec<RecordBatch>>;
2911 async fn collect_with_stats(&self) -> SqlResult<(Vec<RecordBatch>, SqlExecutionStats)>;
2913 async fn explain(&self) -> SqlResult<String>;
2915 fn explain_logical(&self) -> String;
2917 fn krishiv_logical_plan(&self) -> LogicalPlan;
2919 fn query(&self) -> Option<&str>;
2921 async fn execute_stream(&self) -> SqlResult<SqlStream>;
2923
2924 fn schema(&self) -> SchemaRef;
2928
2929 async fn select(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2931
2932 async fn select_exprs(
2934 &self,
2935 expressions: &[&krishiv_plan::expression::Expr],
2936 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2937
2938 async fn aggregate(
2940 &self,
2941 group_exprs: &[&krishiv_plan::expression::Expr],
2942 aggregate_exprs: &[&krishiv_plan::expression::Expr],
2943 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2944
2945 async fn aggregate_grouping(
2947 &self,
2948 grouping: GroupingMode<'_>,
2949 aggregate_exprs: &[&krishiv_plan::expression::Expr],
2950 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2951
2952 async fn pivot(
2954 &self,
2955 group_exprs: &[&krishiv_plan::expression::Expr],
2956 pivot_column: &krishiv_plan::expression::Expr,
2957 aggregate_expr: &krishiv_plan::expression::Expr,
2958 values: &[(krishiv_plan::expression::ScalarValue, String)],
2959 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2960
2961 async fn unpivot(
2963 &self,
2964 columns: &[&str],
2965 name_column: &str,
2966 value_column: &str,
2967 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2968
2969 async fn filter(&self, predicate: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2971
2972 async fn filter_expr(
2974 &self,
2975 predicate: &krishiv_plan::expression::Expr,
2976 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2977
2978 async fn limit(&self, n: usize) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2980
2981 async fn distinct(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2983
2984 async fn drop_nulls(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2986
2987 async fn sample(&self, fraction: f64) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2989
2990 async fn sort(
2992 &self,
2993 columns: &[&str],
2994 descending: &[bool],
2995 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2996
2997 async fn alias(&self, alias: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
2999
3000 async fn drop_columns(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3002
3003 async fn rename_column(&self, old: &str, new: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3005
3006 async fn with_column(&self, name: &str, expr: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3008
3009 fn as_any(&self) -> &dyn std::any::Any;
3011
3012 async fn describe(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3014
3015 async fn fill_null(&self, column: &str, value: &str)
3017 -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3018
3019 async fn join(
3021 &self,
3022 right: &dyn KrishivDataFrameOps,
3023 how: &str,
3024 left_on: &[&str],
3025 right_on: &[&str],
3026 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3027
3028 async fn union(
3030 &self,
3031 right: &dyn KrishivDataFrameOps,
3032 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3033
3034 async fn union_distinct(
3035 &self,
3036 right: &dyn KrishivDataFrameOps,
3037 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3038
3039 async fn intersect(
3040 &self,
3041 right: &dyn KrishivDataFrameOps,
3042 distinct: bool,
3043 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3044
3045 async fn except(
3046 &self,
3047 right: &dyn KrishivDataFrameOps,
3048 distinct: bool,
3049 ) -> SqlResult<Box<dyn KrishivDataFrameOps>>;
3050
3051 async fn register_batches(&self, name: &str, batches: Vec<RecordBatch>) -> SqlResult<()>;
3054
3055 async fn deregister_table(&self, name: &str) -> SqlResult<()>;
3057
3058 async fn create_view(&self, name: &str, replace: bool) -> SqlResult<()>;
3061}
3062
3063fn df_plan_to_krishiv_nodes(
3071 plan: &datafusion::logical_expr::LogicalPlan,
3072 table_row_counts: &std::collections::HashMap<String, u64>,
3073 counter: &mut usize,
3074) -> (Vec<krishiv_plan::PlanNode>, String) {
3075 use datafusion::logical_expr::LogicalPlan as DfPlan;
3076 use krishiv_plan::{ExecutionKind, NodeOp, PlanNode};
3077
3078 *counter += 1;
3079 let idx = *counter;
3080
3081 match plan {
3082 DfPlan::TableScan(ts) => {
3083 let table_name = ts.table_name.table().to_string();
3084 let row_count = table_row_counts.get(&table_name).copied();
3085 let filters: Vec<String> = ts.filters.iter().map(|e| e.to_string()).collect();
3086 let id = format!("scan-{idx}");
3087 let node = PlanNode::new(&id, format!("Scan {table_name}"), ExecutionKind::Batch)
3088 .with_op(NodeOp::Scan {
3089 table: table_name,
3090 filters,
3091 })
3092 .with_estimated_rows(row_count);
3093 (vec![node], id)
3094 }
3095
3096 DfPlan::Projection(proj) => {
3097 let (mut nodes, input_id) =
3098 df_plan_to_krishiv_nodes(&proj.input, table_row_counts, counter);
3099 let id = format!("proj-{idx}");
3100 let columns: Vec<String> = proj.expr.iter().map(|e| e.to_string()).collect();
3101 nodes.push(
3102 PlanNode::new(&id, "Projection", ExecutionKind::Batch)
3103 .with_op(NodeOp::Project { columns })
3104 .with_inputs([input_id]),
3105 );
3106 (nodes, id)
3107 }
3108
3109 DfPlan::Filter(filter) => {
3110 let (mut nodes, input_id) =
3111 df_plan_to_krishiv_nodes(&filter.input, table_row_counts, counter);
3112 let id = format!("filter-{idx}");
3113 let predicate = filter.predicate.to_string();
3114 nodes.push(
3115 PlanNode::new(&id, "Filter", ExecutionKind::Batch)
3116 .with_op(NodeOp::Filter { predicate })
3117 .with_inputs([input_id]),
3118 );
3119 (nodes, id)
3120 }
3121
3122 DfPlan::Aggregate(agg) => {
3123 let (mut nodes, input_id) =
3124 df_plan_to_krishiv_nodes(&agg.input, table_row_counts, counter);
3125 let id = format!("agg-{idx}");
3126 let group_keys: Vec<String> = agg.group_expr.iter().map(|e| e.to_string()).collect();
3127 nodes.push(
3128 PlanNode::new(&id, "Aggregate", ExecutionKind::Batch)
3129 .with_op(NodeOp::Aggregate { group_keys })
3130 .with_inputs([input_id]),
3131 );
3132 (nodes, id)
3133 }
3134
3135 DfPlan::Join(join) => {
3136 let (mut nodes, left_id) =
3137 df_plan_to_krishiv_nodes(&join.left, table_row_counts, counter);
3138 let (right_nodes, right_id) =
3139 df_plan_to_krishiv_nodes(&join.right, table_row_counts, counter);
3140 nodes.extend(right_nodes);
3141 let id = format!("join-{idx}");
3142 let krishiv_join_type = match join.join_type {
3147 datafusion::common::JoinType::Inner => krishiv_plan::JoinType::Inner,
3148 datafusion::common::JoinType::Left => krishiv_plan::JoinType::Left,
3149 datafusion::common::JoinType::Right => krishiv_plan::JoinType::Right,
3150 datafusion::common::JoinType::Full => krishiv_plan::JoinType::Full,
3151 datafusion::common::JoinType::LeftSemi => krishiv_plan::JoinType::LeftSemi,
3152 datafusion::common::JoinType::RightSemi => krishiv_plan::JoinType::RightSemi,
3153 datafusion::common::JoinType::LeftAnti => krishiv_plan::JoinType::LeftAnti,
3154 datafusion::common::JoinType::RightAnti => krishiv_plan::JoinType::RightAnti,
3155 datafusion::common::JoinType::LeftMark => krishiv_plan::JoinType::LeftSemi,
3159 datafusion::common::JoinType::RightMark => krishiv_plan::JoinType::RightSemi,
3160 };
3161 nodes.push(
3162 PlanNode::new(&id, "Join", ExecutionKind::Batch)
3163 .with_op(NodeOp::Join {
3164 join_type: krishiv_join_type,
3165 })
3166 .with_inputs([left_id, right_id]),
3167 );
3168 (nodes, id)
3169 }
3170
3171 DfPlan::Sort(sort) => {
3172 let (mut nodes, input_id) =
3173 df_plan_to_krishiv_nodes(&sort.input, table_row_counts, counter);
3174 let id = format!("sort-{idx}");
3175 nodes.push(
3176 PlanNode::new(&id, "Sort", ExecutionKind::Batch)
3177 .with_op(NodeOp::Other {
3178 description: format!(
3179 "Sort({})",
3180 sort.expr
3181 .iter()
3182 .map(|e| e.to_string())
3183 .collect::<Vec<_>>()
3184 .join(", ")
3185 ),
3186 })
3187 .with_inputs([input_id]),
3188 );
3189 (nodes, id)
3190 }
3191
3192 DfPlan::Repartition(repart) => {
3193 let (mut nodes, input_id) =
3194 df_plan_to_krishiv_nodes(&repart.input, table_row_counts, counter);
3195 let id = format!("exchange-{idx}");
3196 let partitioning = krishiv_plan::Partitioning::Unpartitioned;
3197 nodes.push(
3198 PlanNode::new(&id, "Exchange", ExecutionKind::Batch)
3199 .with_op(NodeOp::Exchange { partitioning })
3200 .with_inputs([input_id]),
3201 );
3202 (nodes, id)
3203 }
3204
3205 DfPlan::Limit(limit) => {
3206 let (mut nodes, input_id) =
3207 df_plan_to_krishiv_nodes(&limit.input, table_row_counts, counter);
3208 let id = format!("limit-{idx}");
3209 nodes.push(
3210 PlanNode::new(&id, "Limit", ExecutionKind::Batch)
3211 .with_op(NodeOp::Other {
3212 description: format!(
3213 "Limit(skip={:?}, fetch={:?})",
3214 limit.skip.as_ref().map(|e| e.to_string()),
3215 limit.fetch.as_ref().map(|e| e.to_string()),
3216 ),
3217 })
3218 .with_inputs([input_id]),
3219 );
3220 (nodes, id)
3221 }
3222
3223 DfPlan::Union(union) if union.inputs.len() == 1 => {
3224 if let Some(input) = union.inputs.first() {
3225 df_plan_to_krishiv_nodes(input, table_row_counts, counter)
3226 } else {
3227 (Vec::new(), String::new())
3228 }
3229 }
3230 DfPlan::Union(union) => {
3231 let mut all_nodes = Vec::new();
3232 let mut input_ids = Vec::new();
3233 for input in &union.inputs {
3234 let (sub_nodes, sub_id) =
3235 df_plan_to_krishiv_nodes(input, table_row_counts, counter);
3236 all_nodes.extend(sub_nodes);
3237 input_ids.push(sub_id);
3238 }
3239 let id = format!("union-{idx}");
3240 all_nodes.push(
3241 PlanNode::new(&id, "Union", ExecutionKind::Batch)
3242 .with_op(NodeOp::Other {
3243 description: "Union".to_string(),
3244 })
3245 .with_inputs(input_ids),
3246 );
3247 (all_nodes, id)
3248 }
3249
3250 DfPlan::SubqueryAlias(alias) => {
3251 df_plan_to_krishiv_nodes(&alias.input, table_row_counts, counter)
3253 }
3254
3255 DfPlan::Values(_) => {
3256 let id = format!("values-{idx}");
3257 let node = PlanNode::new(&id, "Values", ExecutionKind::Batch).with_op(NodeOp::Other {
3258 description: "Values".to_string(),
3259 });
3260 (vec![node], id)
3261 }
3262
3263 DfPlan::Extension(_) => {
3264 let id = format!("ext-{idx}");
3265 let label = plan.to_string();
3266 let node = PlanNode::new(&id, label.clone(), ExecutionKind::Batch)
3267 .with_op(NodeOp::Other { description: label });
3268 (vec![node], id)
3269 }
3270
3271 DfPlan::EmptyRelation(_) => {
3272 let id = format!("empty-{idx}");
3273 let node =
3274 PlanNode::new(&id, "EmptyRelation", ExecutionKind::Batch).with_op(NodeOp::Other {
3275 description: "EmptyRelation".to_string(),
3276 });
3277 (vec![node], id)
3278 }
3279
3280 _ => {
3282 let id = format!("df-{idx}");
3283 let label = plan.to_string();
3284 let node = PlanNode::new(&id, label.clone(), ExecutionKind::Batch)
3285 .with_op(NodeOp::Other { description: label });
3286 (vec![node], id)
3287 }
3288 }
3289}
3290
3291#[derive(Clone)]
3293pub struct SqlDataFrame {
3294 name: String,
3295 query: Option<String>,
3296 query_text: Option<String>,
3298 execution_kind: ExecutionKind,
3299 dataframe: DataFusionDataFrame,
3300 shuffle_partitions: Option<u32>,
3301 context: SessionContext,
3303 table_row_counts: Arc<std::sync::RwLock<HashMap<String, u64>>>,
3307}
3308
3309impl fmt::Debug for SqlDataFrame {
3310 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3311 f.debug_struct("SqlDataFrame")
3312 .field("name", &self.name)
3313 .field("query", &self.query)
3314 .field("shuffle_partitions", &self.shuffle_partitions)
3315 .finish_non_exhaustive()
3316 }
3317}
3318
3319impl SqlDataFrame {
3320 fn new(
3321 name: impl Into<String>,
3322 dataframe: DataFusionDataFrame,
3323 table_row_counts: Arc<std::sync::RwLock<HashMap<String, u64>>>,
3324 ) -> Self {
3325 Self {
3326 name: name.into(),
3327 query: None,
3328 query_text: None,
3329 execution_kind: ExecutionKind::Batch,
3330 dataframe,
3331 shuffle_partitions: None,
3332 context: SessionContext::default(),
3333 table_row_counts,
3334 }
3335 }
3336
3337 pub(crate) fn with_context(mut self, context: SessionContext) -> Self {
3339 self.context = context;
3340 self
3341 }
3342
3343 fn with_query(mut self, query: impl Into<String>) -> Self {
3344 let q = query.into();
3345 self.query_text = Some(q.clone());
3346 self.query = Some(q);
3347 self
3348 }
3349
3350 fn with_execution_kind(mut self, kind: ExecutionKind) -> Self {
3351 self.execution_kind = kind;
3352 self
3353 }
3354
3355 fn with_shuffle_partitions(mut self, n: Option<u32>) -> Self {
3356 self.shuffle_partitions = n;
3357 self
3358 }
3359
3360 pub fn query(&self) -> Option<&str> {
3362 self.query.as_deref()
3363 }
3364
3365 pub fn arrow_schema(&self) -> arrow::datatypes::SchemaRef {
3371 std::sync::Arc::new(self.dataframe.schema().as_arrow().clone())
3372 }
3373
3374 fn with_new_dataframe(&self, df: DataFusionDataFrame, tag: &str) -> Self {
3378 Self {
3379 name: format!("{}-{}", self.name, tag),
3380 query: None,
3381 query_text: None,
3382 execution_kind: self.execution_kind,
3383 dataframe: df,
3384 shuffle_partitions: self.shuffle_partitions,
3385 context: self.context.clone(),
3386 table_row_counts: self.table_row_counts.clone(),
3387 }
3388 }
3389
3390 pub fn krishiv_logical_plan(&self) -> LogicalPlan {
3399 let df_plan = self.dataframe.logical_plan();
3400 let counts = self
3401 .table_row_counts
3402 .read()
3403 .unwrap_or_else(|e| e.into_inner());
3404 let mut counter = 0usize;
3405 let (nodes, _root_id) = df_plan_to_krishiv_nodes(df_plan, &counts, &mut counter);
3406
3407 let mut plan = LogicalPlan::new(self.name.clone(), self.execution_kind);
3408 for node in nodes {
3409 plan = plan.with_node(node);
3410 }
3411
3412 let optimizer = krishiv_plan::optimizer::default_logical_optimizer();
3417 let fallback = plan.clone();
3418 match optimizer.optimize(plan) {
3419 Ok(result) => result.plan,
3420 Err(error) => {
3421 tracing::warn!(
3422 plan = %self.name,
3423 %error,
3424 "logical optimizer failed; using unoptimized plan"
3425 );
3426 fallback
3427 }
3428 }
3429 }
3430
3431 pub fn explain_logical(&self) -> String {
3433 self.dataframe.logical_plan().to_string()
3434 }
3435
3436 pub async fn explain(&self) -> SqlResult<String> {
3438 let batches = self
3439 .dataframe
3440 .clone()
3441 .explain(false, false)?
3442 .collect()
3443 .await?;
3444 pretty_batches(&batches)
3445 }
3446
3447 pub async fn collect(&self) -> SqlResult<Vec<RecordBatch>> {
3449 Ok(self.dataframe.clone().collect().await?)
3450 }
3451
3452 pub async fn execute_stream(&self) -> SqlResult<SqlStream> {
3454 let df_stream = self.dataframe.clone().execute_stream().await?;
3455 use futures::StreamExt;
3456 let mapped = df_stream.map(|res| {
3457 res.map_err(|e| SqlError::DataFusion {
3458 message: e.to_string(),
3459 })
3460 });
3461 Ok(Box::pin(mapped))
3462 }
3463
3464 pub async fn collect_with_stats(&self) -> SqlResult<(Vec<RecordBatch>, SqlExecutionStats)> {
3472 use datafusion::physical_plan::collect as df_collect;
3473
3474 let df = self.dataframe.clone();
3475 let task_ctx = df.task_ctx();
3476 let physical_plan = df.create_physical_plan().await?;
3477
3478 let batches = df_collect(physical_plan.clone(), task_ctx.into()).await?;
3479
3480 let mut output_rows: u64 = batches.iter().map(|b| b.num_rows() as u64).sum();
3481 let mut cpu_nanos: u64 = 0;
3482
3483 if let Some(metrics) = physical_plan.metrics() {
3484 if let Some(v) = metrics.output_rows() {
3485 output_rows = v as u64;
3486 }
3487 if let Some(t) = metrics.elapsed_compute() {
3488 cpu_nanos = t as u64;
3489 }
3490 }
3491
3492 let (spill_bytes, spill_count) = aggregate_spill_metrics(physical_plan.as_ref());
3493
3494 Ok((
3495 batches,
3496 SqlExecutionStats {
3497 output_rows,
3498 cpu_nanos,
3499 spill_bytes,
3500 spill_count,
3501 },
3502 ))
3503 }
3504
3505 pub async fn execute_stream_with_stats(&self) -> SqlResult<(SqlStream, SqlStatsHandle)> {
3515 use futures::StreamExt;
3516
3517 let df = self.dataframe.clone();
3518 let task_ctx = df.task_ctx();
3519 let physical_plan = df.create_physical_plan().await?;
3520 let df_stream = datafusion::physical_plan::execute_stream(
3521 physical_plan.clone(),
3522 std::sync::Arc::new(task_ctx),
3523 )?;
3524 let mapped = df_stream.map(|res| {
3525 res.map_err(|e| SqlError::DataFusion {
3526 message: e.to_string(),
3527 })
3528 });
3529 Ok((
3530 Box::pin(mapped),
3531 SqlStatsHandle {
3532 plan: physical_plan,
3533 },
3534 ))
3535 }
3536}
3537
3538pub struct SqlStatsHandle {
3541 plan: std::sync::Arc<dyn datafusion::physical_plan::ExecutionPlan>,
3542}
3543
3544impl SqlStatsHandle {
3545 pub fn stats(&self) -> SqlExecutionStats {
3550 let mut output_rows: u64 = 0;
3551 let mut cpu_nanos: u64 = 0;
3552 if let Some(metrics) = self.plan.metrics() {
3553 if let Some(v) = metrics.output_rows() {
3554 output_rows = v as u64;
3555 }
3556 if let Some(t) = metrics.elapsed_compute() {
3557 cpu_nanos = t as u64;
3558 }
3559 }
3560 let (spill_bytes, spill_count) = aggregate_spill_metrics(self.plan.as_ref());
3561 SqlExecutionStats {
3562 output_rows,
3563 cpu_nanos,
3564 spill_bytes,
3565 spill_count,
3566 }
3567 }
3568}
3569
3570fn aggregate_spill_metrics(plan: &dyn datafusion::physical_plan::ExecutionPlan) -> (u64, u64) {
3577 let mut spill_bytes: u64 = 0;
3578 let mut spill_count: u64 = 0;
3579 if let Some(metrics) = plan.metrics() {
3580 if let Some(bytes) = metrics.spilled_bytes() {
3581 spill_bytes = spill_bytes.saturating_add(bytes as u64);
3582 }
3583 if let Some(count) = metrics.spill_count() {
3584 spill_count = spill_count.saturating_add(count as u64);
3585 }
3586 }
3587 for child in plan.children() {
3588 let (child_bytes, child_count) = aggregate_spill_metrics(child.as_ref());
3589 spill_bytes = spill_bytes.saturating_add(child_bytes);
3590 spill_count = spill_count.saturating_add(child_count);
3591 }
3592 (spill_bytes, spill_count)
3593}
3594
3595#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
3597pub struct SqlExecutionStats {
3598 pub output_rows: u64,
3599 pub cpu_nanos: u64,
3600 pub spill_bytes: u64,
3602 pub spill_count: u64,
3604}
3605
3606fn top_level_alias_index(expression: &str) -> Option<usize> {
3607 let bytes = expression.as_bytes();
3608 let mut depth = 0usize;
3609 let mut single_quoted = false;
3610 let mut double_quoted = false;
3611 let mut candidate = None;
3612 let mut index = 0usize;
3613 while index < bytes.len() {
3614 let Some(&byte) = bytes.get(index) else {
3615 break;
3616 };
3617 match byte {
3618 b'\'' if !double_quoted => {
3619 if single_quoted && bytes.get(index + 1) == Some(&b'\'') {
3620 index += 2;
3621 continue;
3622 }
3623 single_quoted = !single_quoted;
3624 }
3625 b'"' if !single_quoted => {
3626 if double_quoted && bytes.get(index + 1) == Some(&b'"') {
3627 index += 2;
3628 continue;
3629 }
3630 double_quoted = !double_quoted;
3631 }
3632 b'(' if !single_quoted && !double_quoted => depth += 1,
3633 b')' if !single_quoted && !double_quoted => depth = depth.saturating_sub(1),
3634 b' ' if depth == 0
3635 && !single_quoted
3636 && !double_quoted
3637 && bytes
3638 .get(index..index + 4)
3639 .is_some_and(|slice| slice.eq_ignore_ascii_case(b" AS ")) =>
3640 {
3641 candidate = Some(index);
3642 index += 3;
3643 }
3644 _ => {}
3645 }
3646 index += 1;
3647 }
3648 candidate
3649}
3650
3651fn parse_dataframe_expression(
3652 dataframe: &datafusion::dataframe::DataFrame,
3653 expression: &str,
3654) -> SqlResult<datafusion::logical_expr::Expr> {
3655 if let Some(index) = top_level_alias_index(expression) {
3656 let (body, alias) = expression.split_at(index);
3657 let alias = alias[4..].trim();
3658 if !alias.is_empty() {
3659 let alias = alias
3660 .strip_prefix('"')
3661 .and_then(|value| value.strip_suffix('"'))
3662 .unwrap_or(alias)
3663 .replace("\"\"", "\"");
3664 return Ok(dataframe.parse_sql_expr(body.trim())?.alias(alias));
3665 }
3666 }
3667 dataframe.parse_sql_expr(expression).map_err(Into::into)
3668}
3669
3670pub fn parse_public_expression(sql: &str) -> SqlResult<krishiv_plan::expression::Expr> {
3672 let dialect = GenericDialect {};
3673 let mut parser =
3674 Parser::new(&dialect)
3675 .try_with_sql(sql)
3676 .map_err(|error| SqlError::Unsupported {
3677 feature: format!("public expression parse: {error}"),
3678 })?;
3679 let expression = parser.parse_expr().map_err(|error| SqlError::Unsupported {
3680 feature: format!("public expression parse: {error}"),
3681 })?;
3682 sqlparser_expression_to_public(&expression)
3683}
3684
3685fn sqlparser_expression_to_public(
3686 expression: &datafusion::sql::sqlparser::ast::Expr,
3687) -> SqlResult<krishiv_plan::expression::Expr> {
3688 use datafusion::sql::sqlparser::ast::{BinaryOperator as SqlOperator, Expr as SqlExpr, Value};
3689 use krishiv_plan::expression::{BinaryOperator, Expr, ScalarValue};
3690
3691 Ok(match expression {
3692 SqlExpr::Identifier(identifier) => Expr::Column {
3693 path: vec![identifier.value.clone()],
3694 },
3695 SqlExpr::CompoundIdentifier(identifiers) => Expr::Column {
3696 path: identifiers
3697 .iter()
3698 .map(|identifier| identifier.value.clone())
3699 .collect(),
3700 },
3701 SqlExpr::Nested(expression) => sqlparser_expression_to_public(expression)?,
3702 SqlExpr::IsNull(expression) => Expr::IsNull {
3703 expression: Box::new(sqlparser_expression_to_public(expression)?),
3704 negated: false,
3705 },
3706 SqlExpr::IsNotNull(expression) => Expr::IsNull {
3707 expression: Box::new(sqlparser_expression_to_public(expression)?),
3708 negated: true,
3709 },
3710 SqlExpr::BinaryOp { left, op, right } => Expr::Binary {
3711 left: Box::new(sqlparser_expression_to_public(left)?),
3712 op: match op {
3713 SqlOperator::Eq => BinaryOperator::Eq,
3714 SqlOperator::NotEq => BinaryOperator::NotEq,
3715 SqlOperator::Gt => BinaryOperator::Gt,
3716 SqlOperator::GtEq => BinaryOperator::GtEq,
3717 SqlOperator::Lt => BinaryOperator::Lt,
3718 SqlOperator::LtEq => BinaryOperator::LtEq,
3719 SqlOperator::And => BinaryOperator::And,
3720 SqlOperator::Or => BinaryOperator::Or,
3721 SqlOperator::Plus => BinaryOperator::Plus,
3722 SqlOperator::Minus => BinaryOperator::Minus,
3723 SqlOperator::Multiply => BinaryOperator::Multiply,
3724 SqlOperator::Divide => BinaryOperator::Divide,
3725 other => {
3726 return Err(SqlError::Unsupported {
3727 feature: format!("public expression operator {other}"),
3728 });
3729 }
3730 },
3731 right: Box::new(sqlparser_expression_to_public(right)?),
3732 },
3733 SqlExpr::Value(value) => Expr::Literal {
3734 value: match &value.value {
3735 Value::Null => ScalarValue::Null,
3736 Value::Boolean(value) => ScalarValue::Boolean(*value),
3737 Value::SingleQuotedString(value) => ScalarValue::Utf8(value.clone()),
3738 Value::Number(value, _)
3739 if value.contains('.') || value.contains('e') || value.contains('E') =>
3740 {
3741 ScalarValue::float64(value.parse::<f64>().map_err(|error| {
3742 SqlError::Unsupported {
3743 feature: format!("numeric expression literal: {error}"),
3744 }
3745 })?)
3746 }
3747 Value::Number(value, _) => {
3748 ScalarValue::Int64(value.parse::<i64>().map_err(|error| {
3749 SqlError::Unsupported {
3750 feature: format!("integer expression literal: {error}"),
3751 }
3752 })?)
3753 }
3754 other => {
3755 return Err(SqlError::Unsupported {
3756 feature: format!("public expression literal {other}"),
3757 });
3758 }
3759 },
3760 },
3761 other => {
3762 return Err(SqlError::Unsupported {
3763 feature: format!("public expression node {other}"),
3764 });
3765 }
3766 })
3767}
3768
3769fn public_data_type_to_arrow(
3770 data_type: &krishiv_plan::expression::ExprDataType,
3771) -> arrow::datatypes::DataType {
3772 use arrow::datatypes::{DataType, Field, IntervalUnit, TimeUnit};
3773 use krishiv_plan::expression::{ExprDataType, IntervalUnit as PublicIntervalUnit};
3774
3775 match data_type {
3776 ExprDataType::Null => DataType::Null,
3777 ExprDataType::Boolean => DataType::Boolean,
3778 ExprDataType::Int64 => DataType::Int64,
3779 ExprDataType::UInt64 => DataType::UInt64,
3780 ExprDataType::Float64 => DataType::Float64,
3781 ExprDataType::Utf8 => DataType::Utf8,
3782 ExprDataType::Binary => DataType::Binary,
3783 ExprDataType::Decimal128 { precision, scale } => DataType::Decimal128(*precision, *scale),
3784 ExprDataType::Date32 => DataType::Date32,
3785 ExprDataType::Timestamp { unit, timezone } => DataType::Timestamp(
3786 match unit {
3787 krishiv_plan::expression::TimeUnit::Second => TimeUnit::Second,
3788 krishiv_plan::expression::TimeUnit::Millisecond => TimeUnit::Millisecond,
3789 krishiv_plan::expression::TimeUnit::Microsecond => TimeUnit::Microsecond,
3790 krishiv_plan::expression::TimeUnit::Nanosecond => TimeUnit::Nanosecond,
3791 },
3792 timezone.clone().map(Into::into),
3793 ),
3794 ExprDataType::Interval { unit } => DataType::Interval(match unit {
3795 PublicIntervalUnit::YearMonth => IntervalUnit::YearMonth,
3796 PublicIntervalUnit::DayTime => IntervalUnit::DayTime,
3797 PublicIntervalUnit::MonthDayNano => IntervalUnit::MonthDayNano,
3798 }),
3799 ExprDataType::List(element) => DataType::List(Arc::new(Field::new(
3800 "item",
3801 public_data_type_to_arrow(element),
3802 true,
3803 ))),
3804 ExprDataType::Map { key, value } => DataType::Map(
3805 Arc::new(Field::new(
3806 "entries",
3807 DataType::Struct(
3808 vec![
3809 Arc::new(Field::new("key", public_data_type_to_arrow(key), false)),
3810 Arc::new(Field::new("value", public_data_type_to_arrow(value), true)),
3811 ]
3812 .into(),
3813 ),
3814 false,
3815 )),
3816 false,
3817 ),
3818 ExprDataType::Struct(fields) => DataType::Struct(
3819 fields
3820 .iter()
3821 .map(|field| {
3822 Arc::new(Field::new(
3823 &field.name,
3824 public_data_type_to_arrow(&field.data_type),
3825 field.nullable,
3826 ))
3827 })
3828 .collect::<Vec<_>>()
3829 .into(),
3830 ),
3831 ExprDataType::Variant => DataType::Utf8,
3836 }
3837}
3838
3839fn public_scalar_to_datafusion(
3840 value: &krishiv_plan::expression::ScalarValue,
3841) -> Option<datafusion::common::ScalarValue> {
3842 use datafusion::common::ScalarValue;
3843 use krishiv_plan::expression::{ScalarValue as PublicScalar, TimeUnit};
3844
3845 Some(match value {
3846 PublicScalar::Null => ScalarValue::Null,
3847 PublicScalar::Boolean(value) => ScalarValue::Boolean(Some(*value)),
3848 PublicScalar::Int64(value) => ScalarValue::Int64(Some(*value)),
3849 PublicScalar::UInt64(value) => ScalarValue::UInt64(Some(*value)),
3850 PublicScalar::Float64(bits) => ScalarValue::Float64(Some(f64::from_bits(*bits))),
3851 PublicScalar::Utf8(value) => ScalarValue::Utf8(Some(value.clone())),
3852 PublicScalar::Binary(value) => ScalarValue::Binary(Some(value.clone())),
3853 PublicScalar::Decimal128 {
3854 value,
3855 precision,
3856 scale,
3857 } => ScalarValue::Decimal128(Some(*value), *precision, *scale),
3858 PublicScalar::Date32(value) => ScalarValue::Date32(Some(*value)),
3859 PublicScalar::Timestamp {
3860 value,
3861 unit,
3862 timezone,
3863 } => {
3864 let timezone = timezone.clone().map(Into::into);
3865 match unit {
3866 TimeUnit::Second => ScalarValue::TimestampSecond(Some(*value), timezone),
3867 TimeUnit::Millisecond => ScalarValue::TimestampMillisecond(Some(*value), timezone),
3868 TimeUnit::Microsecond => ScalarValue::TimestampMicrosecond(Some(*value), timezone),
3869 TimeUnit::Nanosecond => ScalarValue::TimestampNanosecond(Some(*value), timezone),
3870 }
3871 }
3872 PublicScalar::Interval { .. } => return None,
3873 })
3874}
3875
3876fn lower_public_expression(
3882 dataframe: &datafusion::dataframe::DataFrame,
3883 expression: &krishiv_plan::expression::Expr,
3884) -> SqlResult<datafusion::logical_expr::Expr> {
3885 expression
3886 .validate()
3887 .map_err(|error| SqlError::Unsupported {
3888 feature: format!("invalid public expression: {error}"),
3889 })?;
3890 use datafusion::logical_expr::{Expr as DataFusionExpr, Operator, binary_expr, cast, try_cast};
3891 use krishiv_plan::expression::{BinaryOperator, Expr};
3892
3893 Ok(match expression {
3894 Expr::Column { path } if path.len() == 1 => {
3895 datafusion::prelude::col(path.first().map(String::as_str).unwrap_or(""))
3896 }
3897 Expr::Column { .. } => parse_dataframe_expression(dataframe, &expression.to_sql())?,
3898 Expr::Literal { value } => match public_scalar_to_datafusion(value) {
3899 Some(value) => DataFusionExpr::Literal(value, None),
3900 None => parse_dataframe_expression(dataframe, &expression.to_sql())?,
3901 },
3902 Expr::Alias { expression, name } => {
3903 lower_public_expression(dataframe, expression)?.alias(name)
3904 }
3905 Expr::Binary { left, op, right } => binary_expr(
3906 lower_public_expression(dataframe, left)?,
3907 match op {
3908 BinaryOperator::Eq => Operator::Eq,
3909 BinaryOperator::NotEq => Operator::NotEq,
3910 BinaryOperator::Gt => Operator::Gt,
3911 BinaryOperator::GtEq => Operator::GtEq,
3912 BinaryOperator::Lt => Operator::Lt,
3913 BinaryOperator::LtEq => Operator::LtEq,
3914 BinaryOperator::And => Operator::And,
3915 BinaryOperator::Or => Operator::Or,
3916 BinaryOperator::Plus => Operator::Plus,
3917 BinaryOperator::Minus => Operator::Minus,
3918 BinaryOperator::Multiply => Operator::Multiply,
3919 BinaryOperator::Divide => Operator::Divide,
3920 },
3921 lower_public_expression(dataframe, right)?,
3922 ),
3923 Expr::IsNull {
3924 expression,
3925 negated,
3926 } => {
3927 let expression = lower_public_expression(dataframe, expression)?;
3928 if *negated {
3929 expression.is_not_null()
3930 } else {
3931 expression.is_null()
3932 }
3933 }
3934 Expr::Cast {
3935 expression,
3936 data_type,
3937 safe,
3938 } => {
3939 let expression = lower_public_expression(dataframe, expression)?;
3940 let data_type = public_data_type_to_arrow(data_type);
3941 if *safe {
3942 try_cast(expression, data_type)
3943 } else {
3944 cast(expression, data_type)
3945 }
3946 }
3947 Expr::Sort { .. } => {
3948 return Err(SqlError::Unsupported {
3949 feature: "standalone sort expressions are only valid inside windows or order_by"
3950 .into(),
3951 });
3952 }
3953 Expr::Aggregate { .. }
3954 | Expr::Function { .. }
3955 | Expr::Window { .. }
3956 | Expr::RawSql { .. } => parse_dataframe_expression(dataframe, &expression.to_sql())?,
3957 })
3958}
3959
3960fn sql_dataframe<'a>(
3961 dataframe: &'a dyn KrishivDataFrameOps,
3962 operation: &str,
3963) -> SqlResult<&'a SqlDataFrame> {
3964 dataframe
3965 .as_any()
3966 .downcast_ref::<SqlDataFrame>()
3967 .ok_or_else(|| SqlError::DataFusion {
3968 message: format!("right DataFrame must be SqlDataFrame for {operation}"),
3969 })
3970}
3971
3972#[async_trait::async_trait]
3973impl KrishivDataFrameOps for SqlDataFrame {
3974 async fn collect(&self) -> SqlResult<Vec<RecordBatch>> {
3975 SqlDataFrame::collect(self).await
3976 }
3977 async fn collect_with_stats(&self) -> SqlResult<(Vec<RecordBatch>, SqlExecutionStats)> {
3978 SqlDataFrame::collect_with_stats(self).await
3979 }
3980 async fn explain(&self) -> SqlResult<String> {
3981 SqlDataFrame::explain(self).await
3982 }
3983 fn explain_logical(&self) -> String {
3984 SqlDataFrame::explain_logical(self)
3985 }
3986 fn krishiv_logical_plan(&self) -> LogicalPlan {
3987 let label = self.dataframe.logical_plan().to_string();
3988 let mut plan = LogicalPlan::new(self.name.clone(), ExecutionKind::Batch).with_node(
3989 PlanNode::new("datafusion-logical", label, ExecutionKind::Batch),
3990 );
3991 if let Some(n) = self.shuffle_partitions {
3992 plan = plan.with_shuffle_partitions(Some(n));
3993 }
3994 plan
3995 }
3996 fn query(&self) -> Option<&str> {
3997 SqlDataFrame::query(self)
3998 }
3999 async fn execute_stream(&self) -> SqlResult<SqlStream> {
4000 SqlDataFrame::execute_stream(self).await
4001 }
4002
4003 fn schema(&self) -> SchemaRef {
4006 SchemaRef::from(self.dataframe.schema().clone())
4007 }
4008
4009 async fn select(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4010 let df = self.dataframe.clone().select_columns(columns)?;
4011 Ok(Box::new(self.with_new_dataframe(df, "select")))
4012 }
4013
4014 async fn select_exprs(
4015 &self,
4016 expressions: &[&krishiv_plan::expression::Expr],
4017 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4018 let expressions = expressions
4019 .iter()
4020 .map(|expression| lower_public_expression(&self.dataframe, expression))
4021 .collect::<Result<Vec<_>, _>>()?;
4022 let df = self.dataframe.clone().select(expressions)?;
4023 Ok(Box::new(self.with_new_dataframe(df, "select_exprs")))
4024 }
4025
4026 async fn aggregate(
4027 &self,
4028 group_exprs: &[&krishiv_plan::expression::Expr],
4029 aggregate_exprs: &[&krishiv_plan::expression::Expr],
4030 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4031 if aggregate_exprs.is_empty() {
4032 return Err(SqlError::Unsupported {
4033 feature: "aggregate requires at least one aggregate expression".into(),
4034 });
4035 }
4036 let group_exprs = group_exprs
4037 .iter()
4038 .map(|expression| lower_public_expression(&self.dataframe, expression))
4039 .collect::<Result<Vec<_>, _>>()?;
4040 let aggregate_exprs = aggregate_exprs
4041 .iter()
4042 .map(|expression| lower_public_expression(&self.dataframe, expression))
4043 .collect::<Result<Vec<_>, _>>()?;
4044 let df = self
4045 .dataframe
4046 .clone()
4047 .aggregate(group_exprs, aggregate_exprs)?;
4048 Ok(Box::new(self.with_new_dataframe(df, "aggregate")))
4049 }
4050
4051 async fn aggregate_grouping(
4052 &self,
4053 grouping: GroupingMode<'_>,
4054 aggregate_exprs: &[&krishiv_plan::expression::Expr],
4055 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4056 if aggregate_exprs.is_empty() {
4057 return Err(SqlError::Unsupported {
4058 feature: "grouping aggregation requires at least one aggregate expression".into(),
4059 });
4060 }
4061 let lower = |expression: &&krishiv_plan::expression::Expr| {
4062 lower_public_expression(&self.dataframe, expression)
4063 };
4064 let group = match grouping {
4065 GroupingMode::Sets(sets) => datafusion::logical_expr::grouping_set(
4066 sets.into_iter()
4067 .map(|set| set.iter().map(lower).collect::<Result<Vec<_>, _>>())
4068 .collect::<Result<Vec<_>, _>>()?,
4069 ),
4070 GroupingMode::Cube(expressions) => datafusion::logical_expr::cube(
4071 expressions
4072 .iter()
4073 .map(lower)
4074 .collect::<Result<Vec<_>, _>>()?,
4075 ),
4076 GroupingMode::Rollup(expressions) => datafusion::logical_expr::rollup(
4077 expressions
4078 .iter()
4079 .map(lower)
4080 .collect::<Result<Vec<_>, _>>()?,
4081 ),
4082 };
4083 let aggregates = aggregate_exprs
4084 .iter()
4085 .map(lower)
4086 .collect::<Result<Vec<_>, _>>()?;
4087 let df = self.dataframe.clone().aggregate(vec![group], aggregates)?;
4088 Ok(Box::new(self.with_new_dataframe(df, "aggregate_grouping")))
4089 }
4090
4091 async fn pivot(
4092 &self,
4093 group_exprs: &[&krishiv_plan::expression::Expr],
4094 pivot_column: &krishiv_plan::expression::Expr,
4095 aggregate_expr: &krishiv_plan::expression::Expr,
4096 values: &[(krishiv_plan::expression::ScalarValue, String)],
4097 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4098 use krishiv_plan::expression::Expr as PublicExpr;
4099 let (function, input, distinct) = match aggregate_expr {
4100 PublicExpr::Aggregate {
4101 function,
4102 expression: Some(input),
4103 distinct,
4104 } => (*function, input.as_ref(), *distinct),
4105 _ => {
4106 return Err(SqlError::Unsupported {
4107 feature: "pivot requires an aggregate expression with one input".into(),
4108 });
4109 }
4110 };
4111 if values.is_empty() {
4112 return Err(SqlError::Unsupported {
4113 feature: "pivot requires at least one value".into(),
4114 });
4115 }
4116 let group_exprs = group_exprs
4117 .iter()
4118 .map(|expression| lower_public_expression(&self.dataframe, expression))
4119 .collect::<Result<Vec<_>, _>>()?;
4120 let aggregates = values
4121 .iter()
4122 .map(|(value, alias)| {
4123 let conditional = PublicExpr::raw(format!(
4124 "CASE WHEN {} = {} THEN {} END",
4125 pivot_column.to_sql(),
4126 value.to_sql_literal(),
4127 input.to_sql()
4128 ));
4129 let aggregate = PublicExpr::Aggregate {
4130 function,
4131 expression: Some(Box::new(conditional)),
4132 distinct,
4133 }
4134 .alias(alias);
4135 lower_public_expression(&self.dataframe, &aggregate)
4136 })
4137 .collect::<Result<Vec<_>, _>>()?;
4138 let dataframe = self.dataframe.clone().aggregate(group_exprs, aggregates)?;
4139 Ok(Box::new(self.with_new_dataframe(dataframe, "pivot")))
4140 }
4141
4142 async fn unpivot(
4143 &self,
4144 columns: &[&str],
4145 name_column: &str,
4146 value_column: &str,
4147 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4148 if columns.is_empty() {
4149 return Err(SqlError::Unsupported {
4150 feature: "unpivot requires at least one column".into(),
4151 });
4152 }
4153 let retained = self
4154 .dataframe
4155 .schema()
4156 .fields()
4157 .iter()
4158 .map(|field| field.name().as_str())
4159 .filter(|name| !columns.contains(name))
4160 .collect::<Vec<_>>();
4161 let mut branches = Vec::with_capacity(columns.len());
4162 for column in columns {
4163 let mut expressions = retained
4164 .iter()
4165 .map(|name| datafusion::logical_expr::col(*name))
4166 .collect::<Vec<_>>();
4167 expressions
4168 .push(datafusion::logical_expr::lit((*column).to_owned()).alias(name_column));
4169 expressions.push(datafusion::logical_expr::col(*column).alias(value_column));
4170 branches.push(self.dataframe.clone().select(expressions)?);
4171 }
4172 let mut branches = branches.into_iter();
4173 let Some(mut dataframe) = branches.next() else {
4174 return Err(SqlError::Unsupported {
4175 feature: "unpivot requires at least one branch".into(),
4176 });
4177 };
4178 for branch in branches {
4179 dataframe = dataframe.union(branch)?;
4180 }
4181 Ok(Box::new(self.with_new_dataframe(dataframe, "unpivot")))
4182 }
4183
4184 async fn filter(&self, predicate: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4185 let expr = self.dataframe.parse_sql_expr(predicate)?;
4186 let df = self.dataframe.clone().filter(expr)?;
4187 Ok(Box::new(self.with_new_dataframe(df, "filter")))
4188 }
4189
4190 async fn filter_expr(
4191 &self,
4192 predicate: &krishiv_plan::expression::Expr,
4193 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4194 let expr = lower_public_expression(&self.dataframe, predicate)?;
4195 let df = self.dataframe.clone().filter(expr)?;
4196 Ok(Box::new(self.with_new_dataframe(df, "filter_expr")))
4197 }
4198
4199 async fn limit(&self, n: usize) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4200 let df = self.dataframe.clone().limit(0, Some(n))?;
4201 Ok(Box::new(self.with_new_dataframe(df, "limit")))
4202 }
4203
4204 async fn distinct(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4205 let df = self.dataframe.clone().distinct()?;
4206 Ok(Box::new(self.with_new_dataframe(df, "distinct")))
4207 }
4208
4209 async fn drop_nulls(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4210 let columns = if columns.is_empty() {
4211 self.dataframe
4212 .schema()
4213 .fields()
4214 .iter()
4215 .map(|field| field.name().as_str())
4216 .collect::<Vec<_>>()
4217 } else {
4218 columns.to_vec()
4219 };
4220 let mut predicate: Option<datafusion::logical_expr::Expr> = None;
4221 for column in columns {
4222 let next = datafusion::logical_expr::col(column).is_not_null();
4223 predicate = Some(match predicate {
4224 Some(current) => current.and(next),
4225 None => next,
4226 });
4227 }
4228 let df = match predicate {
4229 Some(predicate) => self.dataframe.clone().filter(predicate)?,
4230 None => self.dataframe.clone(),
4231 };
4232 Ok(Box::new(self.with_new_dataframe(df, "drop_nulls")))
4233 }
4234
4235 async fn sample(&self, fraction: f64) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4236 if !(0.0..=1.0).contains(&fraction) {
4237 return Err(SqlError::Unsupported {
4238 feature: "sample fraction must be between 0 and 1".into(),
4239 });
4240 }
4241 let predicate = self
4242 .dataframe
4243 .parse_sql_expr(&format!("random() < {fraction}"))?;
4244 let df = self.dataframe.clone().filter(predicate)?;
4245 Ok(Box::new(self.with_new_dataframe(df, "sample")))
4246 }
4247
4248 async fn sort(
4249 &self,
4250 columns: &[&str],
4251 descending: &[bool],
4252 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4253 use datafusion::logical_expr::SortExpr;
4254 let exprs: Vec<SortExpr> = columns
4255 .iter()
4256 .zip(descending.iter())
4257 .map(|(col_name, desc)| datafusion::logical_expr::col(*col_name).sort(!desc, *desc))
4258 .collect();
4259 let df = self.dataframe.clone().sort(exprs)?;
4260 Ok(Box::new(self.with_new_dataframe(df, "sort")))
4261 }
4262
4263 async fn alias(&self, alias: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4264 let df = self.dataframe.clone().alias(alias)?;
4265 Ok(Box::new(self.with_new_dataframe(df, "alias")))
4266 }
4267
4268 async fn drop_columns(&self, columns: &[&str]) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4269 let df = self.dataframe.clone().drop_columns(columns)?;
4270 Ok(Box::new(self.with_new_dataframe(df, "drop")))
4271 }
4272
4273 async fn rename_column(&self, old: &str, new: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4274 let df = self.dataframe.clone().with_column_renamed(old, new)?;
4275 Ok(Box::new(self.with_new_dataframe(df, "rename")))
4276 }
4277
4278 async fn with_column(&self, name: &str, expr: &str) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4279 let parsed = self.dataframe.parse_sql_expr(expr)?;
4280 let df = self.dataframe.clone().with_column(name, parsed)?;
4281 Ok(Box::new(self.with_new_dataframe(df, "with_column")))
4282 }
4283
4284 fn as_any(&self) -> &dyn std::any::Any {
4285 self
4286 }
4287
4288 async fn describe(&self) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4289 let df = self.dataframe.clone().describe().await?;
4290 Ok(Box::new(self.with_new_dataframe(df, "describe")))
4291 }
4292
4293 async fn fill_null(
4294 &self,
4295 column: &str,
4296 value: &str,
4297 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4298 let expr = format!("COALESCE({column}, {value})");
4299 let parsed = self.dataframe.parse_sql_expr(&expr)?;
4300 let df = self.dataframe.clone().with_column(column, parsed)?;
4301 Ok(Box::new(self.with_new_dataframe(df, "fill_null")))
4302 }
4303
4304 async fn join(
4305 &self,
4306 right: &dyn KrishivDataFrameOps,
4307 how: &str,
4308 left_on: &[&str],
4309 right_on: &[&str],
4310 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4311 let right_sql = right
4312 .as_any()
4313 .downcast_ref::<SqlDataFrame>()
4314 .ok_or_else(|| SqlError::DataFusion {
4315 message: "right DataFrame must be SqlDataFrame for join".into(),
4316 })?;
4317 use datafusion::common::JoinType;
4318 let join_type = match how.to_lowercase().as_str() {
4319 "inner" => JoinType::Inner,
4320 "left" => JoinType::Left,
4321 "right" => JoinType::Right,
4322 "full" | "outer" => JoinType::Full,
4323 "leftsemi" | "left_semi" => JoinType::LeftSemi,
4324 "rightsemi" | "right_semi" => JoinType::RightSemi,
4325 "leftanti" | "left_anti" => JoinType::LeftAnti,
4326 "rightanti" | "right_anti" => JoinType::RightAnti,
4327 _ => {
4328 return Err(SqlError::DataFusion {
4329 message: format!("unsupported join type: {how}"),
4330 });
4331 }
4332 };
4333 let df = self.dataframe.clone().join(
4334 right_sql.dataframe.clone(),
4335 join_type,
4336 left_on,
4337 right_on,
4338 None,
4339 )?;
4340 Ok(Box::new(self.with_new_dataframe(df, "join")))
4341 }
4342
4343 async fn union(
4344 &self,
4345 right: &dyn KrishivDataFrameOps,
4346 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4347 let right_sql = right
4348 .as_any()
4349 .downcast_ref::<SqlDataFrame>()
4350 .ok_or_else(|| SqlError::DataFusion {
4351 message: "right DataFrame must be SqlDataFrame for union".into(),
4352 })?;
4353 let df = self.dataframe.clone().union(right_sql.dataframe.clone())?;
4354 Ok(Box::new(self.with_new_dataframe(df, "union")))
4355 }
4356
4357 async fn union_distinct(
4358 &self,
4359 right: &dyn KrishivDataFrameOps,
4360 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4361 let right = sql_dataframe(right, "union_distinct")?;
4362 let df = self
4363 .dataframe
4364 .clone()
4365 .union_distinct(right.dataframe.clone())?;
4366 Ok(Box::new(self.with_new_dataframe(df, "union_distinct")))
4367 }
4368
4369 async fn intersect(
4370 &self,
4371 right: &dyn KrishivDataFrameOps,
4372 distinct: bool,
4373 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4374 let right = sql_dataframe(right, "intersect")?;
4375 let df = if distinct {
4376 self.dataframe
4377 .clone()
4378 .intersect_distinct(right.dataframe.clone())?
4379 } else {
4380 self.dataframe.clone().intersect(right.dataframe.clone())?
4381 };
4382 Ok(Box::new(self.with_new_dataframe(df, "intersect")))
4383 }
4384
4385 async fn except(
4386 &self,
4387 right: &dyn KrishivDataFrameOps,
4388 distinct: bool,
4389 ) -> SqlResult<Box<dyn KrishivDataFrameOps>> {
4390 let right = sql_dataframe(right, "except")?;
4391 let df = if distinct {
4392 self.dataframe
4393 .clone()
4394 .except_distinct(right.dataframe.clone())?
4395 } else {
4396 self.dataframe.clone().except(right.dataframe.clone())?
4397 };
4398 Ok(Box::new(self.with_new_dataframe(df, "except")))
4399 }
4400
4401 async fn register_batches(&self, name: &str, batches: Vec<RecordBatch>) -> SqlResult<()> {
4402 let schema = batches
4403 .first()
4404 .map(|b| b.schema())
4405 .unwrap_or_else(|| Arc::new(arrow::datatypes::Schema::empty()));
4406 let mem_table =
4407 datafusion::datasource::MemTable::try_new(schema, vec![batches]).map_err(|e| {
4408 SqlError::DataFusion {
4409 message: e.to_string(),
4410 }
4411 })?;
4412 self.context
4413 .register_table(name, Arc::new(mem_table))
4414 .map_err(SqlError::from)?;
4415 Ok(())
4416 }
4417
4418 async fn deregister_table(&self, name: &str) -> SqlResult<()> {
4419 let _ = self
4420 .context
4421 .deregister_table(name)
4422 .map_err(SqlError::from)?;
4423 Ok(())
4424 }
4425
4426 async fn create_view(&self, name: &str, replace: bool) -> SqlResult<()> {
4427 let query = self
4428 .query_text
4429 .as_deref()
4430 .ok_or_else(|| SqlError::DataFusion {
4431 message: "create_view requires an SQL query string on the DataFrame".into(),
4432 })?;
4433 let or_replace = if replace { "OR REPLACE " } else { "" };
4434 let safe_name = quote_identifier(name);
4435 let view_sql = format!("CREATE {or_replace}VIEW {safe_name} AS {query}");
4436 self.context.sql(&view_sql).await?;
4437 Ok(())
4438 }
4439}
4440
4441use krishiv_common::sql_util::quote_identifier;
4442
4443#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4450fn call_args_from_str(s: &str) -> Vec<String> {
4451 let mut args: Vec<String> = Vec::new();
4452 let mut cur = String::new();
4453 let mut in_str = false;
4454 let mut after_str = false;
4455 for ch in s.chars() {
4456 if after_str {
4457 if ch == ',' {
4458 after_str = false;
4459 }
4460 continue;
4461 }
4462 if in_str {
4463 if ch == '\'' {
4464 in_str = false;
4465 after_str = true;
4466 args.push(std::mem::take(&mut cur));
4467 } else {
4468 cur.push(ch);
4469 }
4470 } else if ch == '\'' {
4471 in_str = true;
4472 } else if ch == ',' {
4473 let t = cur.trim().to_string();
4474 if !t.is_empty() {
4475 args.push(t);
4476 }
4477 cur.clear();
4478 } else {
4479 cur.push(ch);
4480 }
4481 }
4482 let t = cur.trim().to_string();
4483 if !t.is_empty() {
4484 args.push(t);
4485 }
4486 args
4487}
4488
4489#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4496fn iceberg_table_ident(table_ref: &str) -> SqlResult<iceberg::TableIdent> {
4497 let parts: Vec<&str> = table_ref.splitn(3, '.').collect();
4498 match parts.len() {
4499 2 => {
4500 let ns = iceberg::NamespaceIdent::from_vec(vec![
4501 parts.first().copied().unwrap_or("").to_string(),
4502 ])
4503 .map_err(|e| SqlError::DataFusion {
4504 message: e.to_string(),
4505 })?;
4506 Ok(iceberg::TableIdent::new(
4507 ns,
4508 parts.get(1).copied().unwrap_or("").to_string(),
4509 ))
4510 }
4511 3 => {
4512 let ns = iceberg::NamespaceIdent::from_vec(vec![
4513 parts.get(1).copied().unwrap_or("").to_string(),
4514 ])
4515 .map_err(|e| SqlError::DataFusion {
4516 message: e.to_string(),
4517 })?;
4518 Ok(iceberg::TableIdent::new(
4519 ns,
4520 parts.get(2).copied().unwrap_or("").to_string(),
4521 ))
4522 }
4523 _ => Err(SqlError::DataFusion {
4524 message: format!(
4525 "invalid table reference '{table_ref}': expected 'ns.table' or 'cat.ns.table'"
4526 ),
4527 }),
4528 }
4529}
4530
4531#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4536fn parse_call_duration(s: &str) -> SqlResult<chrono::Duration> {
4537 let s = s.trim();
4538 let mut it = s.splitn(2, ' ');
4539 let n: i64 = it
4540 .next()
4541 .and_then(|v| v.parse().ok())
4542 .ok_or_else(|| SqlError::DataFusion {
4543 message: format!("invalid duration value in '{s}'"),
4544 })?;
4545 let unit = it.next().unwrap_or("").trim().to_ascii_lowercase();
4546 match unit.trim_end_matches('s') {
4547 "day" => Ok(chrono::Duration::days(n)),
4548 "hour" => Ok(chrono::Duration::hours(n)),
4549 "week" => Ok(chrono::Duration::weeks(n)),
4550 "minute" | "min" => Ok(chrono::Duration::minutes(n)),
4551 _ => Err(SqlError::DataFusion {
4552 message: format!("unknown duration unit '{unit}' in '{s}'"),
4553 }),
4554 }
4555}
4556
4557#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4565fn parse_dml_delete(stmt: &str) -> Option<(String, String)> {
4566 use datafusion::sql::sqlparser::ast::{FromTable, Statement, TableFactor};
4567 use datafusion::sql::sqlparser::dialect::GenericDialect;
4568 use datafusion::sql::sqlparser::parser::Parser;
4569
4570 let mut stmts = Parser::parse_sql(&GenericDialect {}, stmt).ok()?;
4571 if stmts.len() != 1 {
4572 return None;
4573 }
4574 let Statement::Delete(delete) = stmts.remove(0) else {
4575 return None;
4576 };
4577 let tables = match delete.from {
4580 FromTable::WithFromKeyword(tables) | FromTable::WithoutKeyword(tables) => tables,
4581 };
4582 let first_from = tables.into_iter().next()?;
4583 let table_name = match first_from.relation {
4584 TableFactor::Table { name, .. } => name.to_string(),
4585 _ => return None,
4586 };
4587 let predicate = delete
4588 .selection
4589 .map(|e| e.to_string())
4590 .unwrap_or_else(|| "TRUE".to_string());
4591 Some((table_name, predicate))
4592}
4593
4594#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4596struct ParsedCtas {
4597 table_ref: String,
4599 or_replace: bool,
4600 inner_query: String,
4602 partition_by: Vec<String>,
4605}
4606
4607#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4617fn extract_partitioned_by(stmt: &str) -> Option<(String, Vec<String>)> {
4618 let bytes = stmt.as_bytes();
4619 let upper = stmt.to_ascii_uppercase();
4620 let upper_bytes = upper.as_bytes();
4621 const NEEDLE: &[u8] = b"PARTITIONED";
4622
4623 fn is_ident_byte(b: u8) -> bool {
4624 b.is_ascii_alphanumeric() || b == b'_'
4625 }
4626 fn skip_quoted(bytes: &[u8], mut i: usize, quote: u8) -> usize {
4629 i += 1;
4630 while let Some(&b) = bytes.get(i) {
4631 if b == quote {
4632 if bytes.get(i + 1) == Some("e) {
4633 i += 2;
4634 continue;
4635 }
4636 return i + 1;
4637 }
4638 i += 1;
4639 }
4640 i
4641 }
4642
4643 let mut i = 0;
4644 while let Some(&b) = bytes.get(i) {
4645 match b {
4646 b'\'' | b'"' => i = skip_quoted(bytes, i, b),
4647 _ => {
4648 let at_needle = upper_bytes
4649 .get(i..)
4650 .is_some_and(|rest| rest.starts_with(NEEDLE))
4651 && (i == 0
4652 || !i
4653 .checked_sub(1)
4654 .and_then(|p| upper_bytes.get(p))
4655 .copied()
4656 .is_some_and(is_ident_byte));
4657 if at_needle {
4658 let mut j = i + NEEDLE.len();
4659 while bytes.get(j).is_some_and(u8::is_ascii_whitespace) {
4660 j += 1;
4661 }
4662 if j > i + NEEDLE.len()
4665 && upper_bytes
4666 .get(j..)
4667 .is_some_and(|rest| rest.starts_with(b"BY"))
4668 && !upper_bytes.get(j + 2).copied().is_some_and(is_ident_byte)
4669 {
4670 let mut k = j + 2;
4671 while bytes.get(k).is_some_and(u8::is_ascii_whitespace) {
4672 k += 1;
4673 }
4674 if bytes.get(k) == Some(&b'(') {
4675 let mut depth = 0i32;
4677 let mut c = k;
4678 let close = loop {
4679 match bytes.get(c) {
4680 None => return None,
4682 Some(b'(') => depth += 1,
4683 Some(b')') => {
4684 depth -= 1;
4685 if depth == 0 {
4686 break c;
4687 }
4688 }
4689 Some(&(q @ b'\'' | q @ b'"')) => {
4690 c = skip_quoted(bytes, c, q);
4691 continue;
4692 }
4693 Some(_) => {}
4694 }
4695 c += 1;
4696 };
4697 let body = stmt.get(k + 1..close)?;
4698 let head = stmt.get(..i)?.trim_end();
4699 let tail = stmt.get(close + 1..)?.trim_start();
4700 let items = split_top_level_commas(body);
4701 let mut remainder = String::with_capacity(stmt.len());
4702 remainder.push_str(head);
4703 remainder.push(' ');
4704 remainder.push_str(tail);
4705 return Some((remainder, items));
4706 }
4707 }
4708 }
4709 i += 1;
4710 }
4711 }
4712 }
4713 None
4714}
4715
4716#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4719fn split_top_level_commas(s: &str) -> Vec<String> {
4720 let bytes = s.as_bytes();
4721 let mut items = Vec::new();
4722 let mut depth = 0i32;
4723 let mut start = 0usize;
4724 let mut i = 0;
4725 while let Some(&b) = bytes.get(i) {
4726 match b {
4727 b'(' => depth += 1,
4728 b')' => depth -= 1,
4729 b'\'' | b'"' => {
4730 i += 1;
4731 while bytes.get(i).is_some_and(|&c| c != b) {
4732 i += 1;
4733 }
4734 }
4735 b',' if depth == 0 => {
4736 if let Some(item) = s.get(start..i).map(str::trim)
4737 && !item.is_empty()
4738 {
4739 items.push(item.to_string());
4740 }
4741 start = i + 1;
4742 }
4743 _ => {}
4744 }
4745 i += 1;
4746 }
4747 if let Some(last) = s.get(start..).map(str::trim)
4748 && !last.is_empty()
4749 {
4750 items.push(last.to_string());
4751 }
4752 items
4753}
4754
4755#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4763fn parse_ctas(stmt: &str) -> Option<ParsedCtas> {
4764 use datafusion::sql::sqlparser::ast::Statement;
4765 use datafusion::sql::sqlparser::dialect::GenericDialect;
4766 use datafusion::sql::sqlparser::parser::Parser;
4767
4768 let (stripped, partition_by) = match extract_partitioned_by(stmt) {
4769 Some((remainder, items)) => (remainder, items),
4770 None => (stmt.to_string(), Vec::new()),
4771 };
4772 let mut stmts = Parser::parse_sql(&GenericDialect {}, &stripped).ok()?;
4773 if stmts.len() != 1 {
4774 return None;
4775 }
4776 let Statement::CreateTable(create) = stmts.remove(0) else {
4777 return None;
4778 };
4779 if create.external || create.temporary {
4780 return None;
4781 }
4782 let inner_query = create.query?.to_string();
4783 Some(ParsedCtas {
4784 table_ref: create.name.to_string(),
4785 or_replace: create.or_replace,
4786 inner_query,
4787 partition_by,
4788 })
4789}
4790
4791#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4793struct ParsedUpdate {
4794 table_ref: String,
4795 assignments: Vec<(String, String)>,
4797 predicate: Option<String>,
4798}
4799
4800#[cfg(all(feature = "iceberg-datafusion", feature = "local-catalog"))]
4806fn parse_dml_update(stmt: &str) -> Option<ParsedUpdate> {
4807 use datafusion::sql::sqlparser::ast::{Statement, TableFactor};
4808 use datafusion::sql::sqlparser::dialect::GenericDialect;
4809 use datafusion::sql::sqlparser::parser::Parser;
4810
4811 let mut stmts = Parser::parse_sql(&GenericDialect {}, stmt).ok()?;
4812 if stmts.len() != 1 {
4813 return None;
4814 }
4815 let Statement::Update(update) = stmts.remove(0) else {
4817 return None;
4818 };
4819 let table_name = match update.table.relation {
4820 TableFactor::Table { name, .. } => name.to_string(),
4821 _ => return None,
4822 };
4823 let parsed_assignments: Vec<(String, String)> = update
4825 .assignments
4826 .into_iter()
4827 .map(|a| {
4828 let col = a.target.to_string();
4830 let val = a.value.to_string();
4831 (col, val)
4832 })
4833 .collect();
4834 if parsed_assignments.is_empty() {
4835 return None;
4836 }
4837 Some(ParsedUpdate {
4838 table_ref: table_name,
4839 assignments: parsed_assignments,
4840 predicate: update.selection.map(|e| e.to_string()),
4841 })
4842}
4843
4844pub fn plan_sql(query: impl Into<String>) -> SqlResult<SqlPlan> {
4846 let query = query.into();
4847 if query.trim().is_empty() {
4848 return Err(SqlError::EmptyQuery);
4849 }
4850
4851 if let Some(stmt) = cep_sql::parse_match_recognize(&query)? {
4852 let logical_plan = cep_sql::plan_match_recognize(stmt, &query);
4853 let optimized = Optimizer::default().optimize(logical_plan)?;
4854 return Ok(SqlPlan {
4855 query,
4856 logical_plan: optimized.plan,
4857 });
4858 }
4859
4860 let logical_plan =
4861 LogicalPlan::new("sql-query", ExecutionKind::Batch).with_node(PlanNode::new(
4862 "sql",
4863 format!("sql: {}", query.trim()),
4864 ExecutionKind::Batch,
4865 ));
4866
4867 let optimized = Optimizer::default().optimize(logical_plan)?;
4868 Ok(SqlPlan {
4869 query,
4870 logical_plan: optimized.plan,
4871 })
4872}
4873
4874pub fn explain_sql(query: impl Into<String>) -> SqlResult<String> {
4876 let plan = plan_sql(query)?;
4877 Ok(plan.logical_plan().describe())
4878}
4879
4880pub fn explain_sql_optimized(query: impl Into<String>, optimizer: &Optimizer) -> SqlResult<String> {
4885 let plan = plan_sql(query)?;
4886 let result = optimizer.optimize(plan.logical_plan().clone())?;
4887 let mut output = result.plan.describe();
4888 let optimizer_line = result.describe();
4889 output.push('\n');
4890 output.push_str(&optimizer_line);
4891 Ok(output)
4892}
4893
4894pub fn explain_sql_with_cost(
4896 query: impl Into<String>,
4897 cost_model: &dyn CostModel,
4898) -> SqlResult<String> {
4899 let plan = plan_sql(query)?;
4900 let cost = cost_model.estimate(plan.logical_plan());
4901 let mut output = plan.logical_plan().describe();
4902 output.push_str(&format!(
4903 "\ncost: cpu_nanos={}, memory_bytes={}, network_bytes={}",
4904 cost.cpu_nanos, cost.memory_bytes, cost.network_bytes
4905 ));
4906 Ok(output)
4907}
4908
4909pub fn referenced_table_names(query: impl AsRef<str>) -> SqlResult<Vec<String>> {
4915 let query = query.as_ref();
4916 if query.trim().is_empty() {
4917 return Err(SqlError::EmptyQuery);
4918 }
4919
4920 let statements =
4921 Parser::parse_sql(&GenericDialect {}, query).map_err(|e| SqlError::DataFusion {
4922 message: format!("SQL parse error: {e}"),
4923 })?;
4924 let mut names = BTreeSet::new();
4925 let _ = visit_relations(&statements, |relation| {
4926 names.insert(relation.to_string());
4927 ControlFlow::<()>::Continue(())
4928 });
4929 Ok(names.into_iter().collect())
4930}
4931
4932pub fn pretty_batches(batches: &[RecordBatch]) -> SqlResult<String> {
4934 Ok(pretty_format_batches(batches)
4935 .map_err(|error| SqlError::DataFusion {
4936 message: error.to_string(),
4937 })?
4938 .to_string())
4939}
4940
4941#[cfg(test)]
4942mod sql_tests;