1use std::collections::HashSet;
5use std::path::PathBuf;
6use std::sync::{Arc, RwLock};
7
8use arrow::datatypes::SchemaRef;
9use async_trait::async_trait;
10use datafusion::catalog::TableProvider;
11use datafusion::catalog::TableProviderFactory;
12use datafusion::catalog::streaming::StreamingTable;
13use datafusion::error::{DataFusionError, Result as DataFusionResult};
14use datafusion::logical_expr::CreateExternalTable;
15use datafusion::physical_plan::ExecutionPlan;
16use krishiv_connectors::{ConnectorConfig, ConnectorError, ConnectorRegistry, default_registry};
17
18use crate::kafka_table::{KafkaPartitionStream, kafka_auto_commit_interval_ms, project_batch};
19
20fn is_object_store_url(location: &str) -> bool {
25 let l = location.trim_start();
32 let scheme = match l.split_once("://") {
33 Some((scheme, _)) => scheme,
34 None => return false,
35 };
36 [
37 "s3", "s3a", "gs", "gcs", "az", "azure", "abfs", "abfss",
38 ]
39 .iter()
40 .any(|known| scheme.eq_ignore_ascii_case(known))
41}
42
43fn validate_path_under_warehouse(location: &str) -> DataFusionResult<()> {
45 let warehouse = std::env::var("KRISHIV_WAREHOUSE_ROOT").unwrap_or_else(|_| ".".to_string());
46 let base = PathBuf::from(&warehouse).canonicalize().map_err(|e| {
47 DataFusionError::External(Box::new(ConnectorError::Unsupported {
48 message: format!("warehouse root '{warehouse}' not accessible: {e}"),
49 }))
50 })?;
51 let candidate = PathBuf::from(location);
52 let resolved = if candidate.is_relative() {
53 base.join(&candidate)
54 } else {
55 candidate
56 };
57 let canonical = resolved.canonicalize().map_err(|e| {
58 DataFusionError::External(Box::new(ConnectorError::Unsupported {
59 message: format!("path '{location}' not accessible: {e}"),
60 }))
61 })?;
62 if !canonical.starts_with(&base) {
63 return Err(DataFusionError::External(Box::new(
64 ConnectorError::Unsupported {
65 message: format!("path '{location}' escapes warehouse root '{warehouse}'"),
66 },
67 )));
68 }
69 Ok(())
70}
71
72pub fn shared_connector_registry() -> Arc<ConnectorRegistry> {
74 Arc::new(default_registry())
75}
76
77pub fn register_connector_table_factories(
79 table_factories: &mut std::collections::HashMap<String, Arc<dyn TableProviderFactory>>,
80 streaming_sources: Arc<RwLock<HashSet<String>>>,
81) {
82 let registry = shared_connector_registry();
83 table_factories.insert(
84 "PARQUET".to_string(),
85 Arc::new(ConnectorTableFactory::bounded(
86 "parquet",
87 Arc::clone(®istry),
88 )),
89 );
90 table_factories.insert(
91 "S3".to_string(),
92 Arc::new(ConnectorTableFactory::bounded("s3", registry)),
93 );
94 table_factories.insert(
95 "KAFKA".to_string(),
96 Arc::new(ConnectorTableFactory::streaming(streaming_sources)),
97 );
98 #[cfg(feature = "jdbc")]
99 table_factories.insert(
100 "JDBC".to_string(),
101 Arc::new(ConnectorTableFactory::bounded(
102 "jdbc",
103 shared_connector_registry(),
104 )),
105 );
106}
107
108pub fn connector_config_from_ddl(
110 kind: &str,
111 cmd: &CreateExternalTable,
112) -> DataFusionResult<ConnectorConfig> {
113 let name = cmd.name.table().to_string();
114 Ok(match kind {
115 "parquet" => {
116 if !cmd.location.is_empty() {
117 validate_path_under_warehouse(&cmd.location)?;
118 }
119 ConnectorConfig::new(name, kind).with_property("path", cmd.location.clone())
120 }
121 "s3" => {
122 let mut cfg = ConnectorConfig::new(cmd.name.table(), kind)
123 .with_property("object_path", cmd.location.clone());
124 for (key, value) in &cmd.options {
125 if key == "base_path" {
126 cfg = cfg.with_property("base_path", value.clone());
127 }
128 }
129 cfg
130 }
131 "kafka" => {
132 let mut cfg = ConnectorConfig::new(cmd.name.table(), kind)
133 .with_property("topic", cmd.location.clone())
134 .with_property("bootstrap.servers", "127.0.0.1:9092".to_string())
135 .with_property("group.id", "krishiv-sql".to_string());
136 for (key, value) in &cmd.options {
137 match key.as_str() {
138 "bootstrap.servers" => {
139 cfg = cfg.with_property("bootstrap.servers", value.clone());
140 }
141 "group.id" => {
142 cfg = cfg.with_property("group.id", value.clone());
143 }
144 other => {
145 cfg = cfg.with_property(other, value.clone());
146 }
147 }
148 }
149 if let Some(ms) = kafka_auto_commit_interval_ms() {
150 cfg = cfg.with_property("auto.commit.interval.ms", ms.to_string());
151 }
152 cfg
153 }
154 "jdbc" => {
160 let mut cfg =
161 ConnectorConfig::new(name, kind).with_property("url", cmd.location.clone());
162 for (key, value) in &cmd.options {
163 let key = key.strip_prefix("format.").unwrap_or(key);
166 match key {
167 "table" | "cursor.column" | "cursor.after" | "batch_size" => {
168 cfg = cfg.with_property(key, value.clone());
169 }
170 other => {
171 return Err(DataFusionError::External(Box::new(
172 ConnectorError::Unsupported {
173 message: format!(
174 "unknown JDBC option '{other}' (expected table, \
175 cursor.column, cursor.after, batch_size)"
176 ),
177 },
178 )));
179 }
180 }
181 }
182 cfg
183 }
184 _ => ConnectorConfig::new(name, kind).with_property("path", cmd.location.clone()),
185 })
186}
187
188fn connector_error(err: ConnectorError) -> DataFusionError {
189 DataFusionError::External(Box::new(err))
190}
191
192pub struct ConnectorTableFactory {
194 connector_kind: &'static str,
195 registry: Arc<ConnectorRegistry>,
196 streaming_sources: Option<Arc<RwLock<HashSet<String>>>>,
197}
198
199impl std::fmt::Debug for ConnectorTableFactory {
200 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
201 f.debug_struct("ConnectorTableFactory")
202 .field("connector_kind", &self.connector_kind)
203 .finish_non_exhaustive()
204 }
205}
206
207impl ConnectorTableFactory {
208 pub fn bounded(connector_kind: &'static str, registry: Arc<ConnectorRegistry>) -> Self {
209 Self {
210 connector_kind,
211 registry,
212 streaming_sources: None,
213 }
214 }
215
216 pub fn streaming(streaming_sources: Arc<RwLock<HashSet<String>>>) -> Self {
217 Self {
218 connector_kind: "kafka",
219 registry: shared_connector_registry(),
220 streaming_sources: Some(streaming_sources),
221 }
222 }
223}
224
225#[async_trait]
226impl TableProviderFactory for ConnectorTableFactory {
227 async fn create(
228 &self,
229 state: &dyn datafusion::catalog::Session,
230 cmd: &CreateExternalTable,
231 ) -> DataFusionResult<Arc<dyn TableProvider>> {
232 if self.connector_kind == "parquet" && is_object_store_url(&cmd.location) {
244 return datafusion::datasource::listing_table_factory::ListingTableFactory::new()
245 .create(state, cmd)
246 .await;
247 }
248
249 let kind = self.connector_kind;
254 let cmd_owned = cmd.clone();
255 let config =
256 tokio::task::spawn_blocking(move || connector_config_from_ddl(kind, &cmd_owned))
257 .await
258 .map_err(|e| {
259 DataFusionError::External(Box::new(ConnectorError::Unsupported {
260 message: format!("connector config validation task panicked: {e}"),
261 }))
262 })??;
263 self.registry
264 .validate_source(&config)
265 .map_err(connector_error)?;
266
267 if self.connector_kind == "kafka" {
268 return create_kafka_table_provider(cmd, &config, self.streaming_sources.as_ref())
269 .await;
270 }
271
272 let schema: SchemaRef = cmd.schema.as_ref().inner().clone();
273 Ok(Arc::new(BoundedConnectorProvider {
274 registry: Arc::clone(&self.registry),
275 config,
276 schema,
277 }))
278 }
279}
280
281async fn create_kafka_table_provider(
282 cmd: &CreateExternalTable,
283 config: &ConnectorConfig,
284 streaming_sources: Option<&Arc<RwLock<HashSet<String>>>>,
285) -> DataFusionResult<Arc<dyn TableProvider>> {
286 use krishiv_connectors::kafka::{KafkaConfig, KafkaSource};
287
288 let kafka_config = KafkaConfig::from_config(config).map_err(connector_error)?;
289 let schema: SchemaRef = cmd.schema.as_ref().inner().clone();
290 let source = KafkaSource::new(kafka_config).map_err(connector_error)?;
291 let partition = Arc::new(KafkaPartitionStream::new(schema.clone(), source));
292 let table = StreamingTable::try_new(schema, vec![partition])?;
293
294 if let Some(streaming_sources) = streaming_sources {
295 let table_name = cmd.name.table().to_string();
296 streaming_sources
297 .write()
298 .unwrap_or_else(|e| e.into_inner())
299 .insert(table_name);
300 }
301
302 Ok(Arc::new(table))
303}
304
305struct BoundedConnectorProvider {
307 registry: Arc<ConnectorRegistry>,
308 config: ConnectorConfig,
309 schema: SchemaRef,
310}
311
312impl std::fmt::Debug for BoundedConnectorProvider {
313 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
314 f.debug_struct("BoundedConnectorProvider")
315 .field("config", &self.config)
316 .finish_non_exhaustive()
317 }
318}
319
320#[async_trait]
321impl TableProvider for BoundedConnectorProvider {
322 fn schema(&self) -> SchemaRef {
323 Arc::clone(&self.schema)
324 }
325
326 fn table_type(&self) -> datafusion::logical_expr::TableType {
327 datafusion::logical_expr::TableType::Base
328 }
329
330 fn statistics(&self) -> Option<datafusion::physical_plan::Statistics> {
331 use datafusion::common::stats::Precision;
332 use datafusion::physical_plan::Statistics;
333 let row_count = self.registry.estimated_row_count(&self.config)?;
334 Some(Statistics {
335 num_rows: Precision::Inexact(row_count as usize),
336 ..Statistics::new_unknown(&self.schema)
337 })
338 }
339
340 async fn scan(
341 &self,
342 state: &dyn datafusion::catalog::Session,
343 projection: Option<&Vec<usize>>,
344 filters: &[datafusion::logical_expr::Expr],
345 limit: Option<usize>,
346 ) -> DataFusionResult<Arc<dyn ExecutionPlan>> {
347 let partition = Arc::new(BoundedConnectorPartitionStream {
357 registry: Arc::clone(&self.registry),
358 config: self.config.clone(),
359 schema: Arc::clone(&self.schema),
360 });
361 let table = StreamingTable::try_new(Arc::clone(&self.schema), vec![partition])?;
362 table.scan(state, projection, filters, limit).await
363 }
364}
365
366struct BoundedConnectorPartitionStream {
373 registry: Arc<ConnectorRegistry>,
374 config: ConnectorConfig,
375 schema: SchemaRef,
376}
377
378impl std::fmt::Debug for BoundedConnectorPartitionStream {
379 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
380 f.debug_struct("BoundedConnectorPartitionStream")
381 .field("config", &self.config)
382 .finish_non_exhaustive()
383 }
384}
385
386impl datafusion::physical_plan::streaming::PartitionStream for BoundedConnectorPartitionStream {
387 fn schema(&self) -> &SchemaRef {
388 &self.schema
389 }
390
391 fn execute(
392 &self,
393 _ctx: Arc<datafusion::execution::TaskContext>,
394 ) -> datafusion::physical_plan::SendableRecordBatchStream {
395 use futures::{StreamExt as _, TryStreamExt as _};
396
397 let registry = Arc::clone(&self.registry);
398 let config = self.config.clone();
399 let schema = Arc::clone(&self.schema);
400 let batch_schema = Arc::clone(&self.schema);
401 let stream = futures::stream::once(async move {
402 let source = registry
403 .open_source(&config)
404 .await
405 .map_err(connector_error)?;
406 Ok::<_, DataFusionError>(futures::stream::try_unfold(source, move |mut source| {
407 let schema = Arc::clone(&batch_schema);
408 async move {
409 loop {
410 match source.read_batch_dyn().await.map_err(connector_error)? {
411 Some(batch) => {
412 let batch = project_batch(&batch, &schema)
413 .map_err(|e| DataFusionError::ArrowError(Box::new(e), None))?;
414 if batch.num_rows() == 0 {
415 continue;
416 }
417 return Ok(Some((batch, source)));
418 }
419 None => return Ok(None),
420 }
421 }
422 }
423 }))
424 })
425 .try_flatten()
426 .boxed();
427 Box::pin(datafusion::physical_plan::stream::RecordBatchStreamAdapter::new(schema, stream))
428 }
429}
430
431#[cfg(test)]
432mod tests {
433 use std::sync::Arc;
434
435 use arrow::datatypes::{DataType, Field, Schema};
436
437 use super::*;
438
439 #[cfg(feature = "jdbc")]
444 #[tokio::test]
445 async fn jdbc_ddl_validates_options_without_connecting() {
446 let engine = crate::SqlEngine::new();
447 engine
448 .sql(
449 "CREATE EXTERNAL TABLE pg_orders (id BIGINT, amount DOUBLE) \
450 STORED AS JDBC LOCATION 'postgres://u:p@127.0.0.1:1/db' \
451 OPTIONS ('table' 'public.orders', 'cursor.column' 'id', \
452 'cursor.after' '42', 'batch_size' '500')",
453 )
454 .await
455 .expect("jdbc DDL must succeed without a live database");
456
457 let unknown = engine
458 .sql(
459 "CREATE EXTERNAL TABLE pg_bad (id BIGINT) STORED AS JDBC \
460 LOCATION 'postgres://u:p@127.0.0.1:1/db' \
461 OPTIONS ('table' 't', 'bogus' 'x')",
462 )
463 .await
464 .expect_err("unknown option must be rejected");
465 assert!(
466 unknown.to_string().contains("unknown JDBC option"),
467 "{unknown}"
468 );
469
470 let dangling_cursor = engine
471 .sql(
472 "CREATE EXTERNAL TABLE pg_bad2 (id BIGINT) STORED AS JDBC \
473 LOCATION 'postgres://u:p@127.0.0.1:1/db' \
474 OPTIONS ('table' 't', 'cursor.after' '7')",
475 )
476 .await
477 .expect_err("cursor.after without cursor.column must be rejected");
478 assert!(
479 dangling_cursor
480 .to_string()
481 .contains("cursor.after requires cursor.column"),
482 "{dangling_cursor}"
483 );
484 }
485
486 #[test]
487 fn bounded_connector_provider_statistics_returns_none_for_unknown_table() {
488 let registry = Arc::new(krishiv_connectors::ConnectorRegistry::new());
489 let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int64, false)]));
490 let config = krishiv_connectors::ConnectorConfig::new("unknown", "parquet");
491 let provider = BoundedConnectorProvider {
492 registry,
493 config,
494 schema,
495 };
496 assert!(
497 provider.statistics().is_none(),
498 "no path in config → estimated_row_count returns None → statistics returns None"
499 );
500 }
501
502 #[test]
503 fn extract_create_external_table_name_parses_table_name() {
504 assert_eq!(
505 super::super::extract_create_external_table_name(
506 "CREATE EXTERNAL TABLE my_table STORED AS PARQUET LOCATION 'data.parquet'"
507 ),
508 Some("my_table".to_string())
509 );
510 assert_eq!(
511 super::super::extract_create_external_table_name("SELECT * FROM foo"),
512 None
513 );
514 assert_eq!(
515 super::super::extract_create_external_table_name(
516 "CREATE OR REPLACE EXTERNAL TABLE orders STORED AS PARQUET LOCATION 'orders.parquet'"
517 ),
518 Some("orders".to_string())
519 );
520 }
521
522 #[test]
529 fn object_store_schemes_are_recognised_regardless_of_case() {
530 for uri in [
531 "s3://bucket/k", "S3://bucket/k", "S3A://bucket/k", "Gs://b/k",
532 "GCS://b/k", "AZ://b/k", "Azure://b/k", "ABFS://b/k", "AbFsS://b/k",
533 ] {
534 assert!(is_object_store_url(uri), "{uri} must be object storage");
535 }
536 }
537
538 #[test]
541 fn leading_whitespace_does_not_hide_the_scheme() {
542 assert!(is_object_store_url(" s3://bucket/k"));
543 }
544
545 #[test]
548 fn non_object_store_locations_are_not_claimed() {
549 for uri in [
550 "/var/data/orders",
551 "orders/",
552 "file:///var/data",
553 "s3",
554 "s3:/bucket/k",
555 "s3x://bucket/k",
556 "https://example.com/x",
557 "",
558 ] {
559 assert!(!is_object_store_url(uri), "{uri} must not be object storage");
560 }
561 }
562}