Skip to main content

krishiv_sql/
connector_table.rs

1//! DataFusion `TableProviderFactory` implementations backed by
2//! [`krishiv_connectors::ConnectorRegistry`].
3
4use 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
20/// Whether a `CREATE EXTERNAL TABLE` LOCATION is an object-store URL (S3/GCS/
21/// Azure) rather than a local filesystem path. Object-store URLs must not be
22/// run through local-filesystem canonicalization, and `STORED AS PARQUET`
23/// against one is a native DataFusion ListingTable read, not a connector source.
24fn is_object_store_url(location: &str) -> bool {
25    // URI schemes are case-insensitive (RFC 3986 §3.1), and this check decides
26    // whether a `LOCATION` is object storage or a path under the warehouse
27    // root. A case-sensitive match sent `LOCATION 'S3://bucket/x'` down the
28    // *filesystem* branch, where `validate_path_under_warehouse` tried to
29    // canonicalise it as a relative path and failed with "path not accessible"
30    // — an error about the local disk for a bucket that was perfectly fine.
31    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
43/// Reject paths that escape the warehouse root via traversal or absolutes.
44fn 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
72/// Shared registry instance for SQL DDL table factories.
73pub fn shared_connector_registry() -> Arc<ConnectorRegistry> {
74    Arc::new(default_registry())
75}
76
77/// Register PARQUET, S3, and KAFKA DDL factories on a DataFusion table-factory map.
78pub 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(&registry),
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
108/// Build a [`ConnectorConfig`] from a `CREATE EXTERNAL TABLE` command.
109pub 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 pull source (Phase 31 ingest breadth): LOCATION is the bare
155        // Postgres connection URL (no warehouse-path validation — it is not a
156        // filesystem path). Options: `table` (required, validated by the
157        // registry driver), `cursor.column`/`cursor.after` for incremental
158        // keyset pull, `batch_size` for page sizing.
159        "jdbc" => {
160            let mut cfg =
161                ConnectorConfig::new(name, kind).with_property("url", cmd.location.clone());
162            for (key, value) in &cmd.options {
163                // DataFusion namespaces un-dotted OPTIONS keys under
164                // `format.` — accept both spellings of the same option.
165                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
192/// Factory for bounded connector sources opened through the registry.
193pub 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        // `STORED AS PARQUET LOCATION 's3://…'` is a native DataFusion
233        // ListingTable read of object storage, not a connector source. The
234        // SqlEngine has already registered the backing S3 object store on the
235        // runtime env (register_s3_object_store_for_warehouse, invoked before
236        // this DDL executes), so delegate to DataFusion's own
237        // ListingTableFactory: it looks up the Parquet FileFormat, lists the
238        // location to infer the schema, and builds the ListingTable. This
239        // bypasses the connector path's local-filesystem `canonicalize`, which
240        // cannot resolve an s3:// URL and previously failed the DDL with
241        // "path 's3://…' not accessible: No such file or directory"
242        // (engine-s3-ddl-gap).
243        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        // `connector_config_from_ddl` calls `validate_path_under_warehouse`,
250        // which does blocking `Path::canonicalize` syscalls. Run it on the
251        // blocking pool so this async `create` never stalls the DataFusion/
252        // Flight SQL async worker thread on filesystem I/O.
253        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
305/// Bounded scan provider that streams connector batches at execution time.
306struct 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        // Zero-materialization scan (Phase 52 #194): the source is opened
348        // lazily at execution time and its batches flow straight into the
349        // query pipeline. The previous implementation drained the entire
350        // source into a `MemTable` at scan time — projection and limit are
351        // now applied per batch by `StreamingTableExec` and DataFusion's
352        // limit operator, which also cancels the source early by dropping
353        // the stream. Filter pushdown to the connector remains a follow-up
354        // (the `Source` trait does not accept filter expressions); filters
355        // run in DataFusion's downstream `FilterExec` exactly as before.
356        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
366/// Lazily streams a bounded connector source, one `read_batch` at a time.
367///
368/// Each execution opens a fresh source from the registry (sources are
369/// single-pass); raw connector batches are normalized to the declared table
370/// schema per batch. Zero-row batches are dropped, matching the drained
371/// implementation this replaces.
372struct 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    /// Phase 31 ingest breadth: `STORED AS JDBC` DDL creates the provider
440    /// without touching the database (connection is deferred to scan), and
441    /// the option surface is closed — unknown keys and cursor misuse fail at
442    /// DDL time, not at first pull.
443    #[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    /// URI schemes are case-insensitive (RFC 3986 §3.1).
523    ///
524    /// A case-sensitive match sent `LOCATION 'S3://bucket/x'` down the
525    /// *filesystem* branch, where the warehouse-root validator tried to
526    /// canonicalise it as a relative path and reported "path not accessible" —
527    /// an error about the local disk for a bucket that was fine.
528    #[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    /// Leading whitespace is tolerated because `LOCATION` values arrive
539    /// straight from SQL.
540    #[test]
541    fn leading_whitespace_does_not_hide_the_scheme() {
542        assert!(is_object_store_url("   s3://bucket/k"));
543    }
544
545    /// Everything else is a warehouse path and must stay on the local branch —
546    /// including schemes that merely *start* like a known one.
547    #[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}