Skip to main content

uni_plugin/adapters/
catalog_from_storage.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2024-2026 Dragonscale Team
3
4//! Bridge a [`Storage`] plugin into the [`CatalogTable`] surface so the
5//! host's graph planner can route a virtual label through plugin
6//! storage (M5h).
7//!
8//! Background: plugin `Storage::read_batch` is async; `CatalogTable::scan`
9//! is sync but returns a `SendableRecordBatchStream`. The adapter
10//! builds a stream that *lazily* awaits `read_batch` when first polled,
11//! so the sync `scan()` signature is honored without blocking inside
12//! the planner.
13//!
14//! Filter handling: `CatalogTable::scan` passes a slice of
15//! [`datafusion::logical_expr::Expr`] filters. `Storage::read_batch`
16//! accepts a single optional filter. We AND-combine the slice into one
17//! conjunction before forwarding; backends that can't encode the
18//! conjunction signal that via [`FnError::code`] `0x711` per the
19//! Storage contract, and the adapter falls back to an unfiltered scan
20//! plus a DataFusion-side filter applied by the planner (the planner
21//! already wraps `CatalogTable::scan` in a `Filter` node when the
22//! source declines pushdown — see `CatalogVertexScanExec`).
23//!
24//! Projection / limit: applied client-side on the resulting stream,
25//! after `Storage` returns its batches. Backends that can push these
26//! down should advertise [`SupportsProjectionPushdown`] /
27//! [`SupportsLimitPushdown`] markers and route through
28//! `PushdownAwareTable`; this adapter is the minimum-viable bridge and
29//! does not negotiate pushdown.
30//!
31//! # Examples
32//!
33//! ```no_run
34//! use std::sync::Arc;
35//! use arrow_schema::{DataType, Field, Schema};
36//! use uni_plugin::adapters::StorageCatalogTable;
37//! use uni_plugin::traits::storage::Storage;
38//! use uni_plugin::traits::catalog::CatalogTable;
39//!
40//! fn wrap(storage: Arc<dyn Storage>) -> Arc<dyn CatalogTable> {
41//!     let schema = Arc::new(Schema::new(vec![
42//!         Field::new("id", DataType::Int64, false),
43//!         Field::new("name", DataType::Utf8, true),
44//!     ]));
45//!     Arc::new(StorageCatalogTable::new(storage, "people".to_owned(), schema))
46//! }
47//! ```
48//!
49//! [`Storage`]: crate::traits::storage::Storage
50//! [`CatalogTable`]: crate::traits::catalog::CatalogTable
51//! [`SupportsProjectionPushdown`]: crate::traits::pushdown::SupportsProjectionPushdown
52//! [`SupportsLimitPushdown`]: crate::traits::pushdown::SupportsLimitPushdown
53
54use std::pin::Pin;
55use std::sync::Arc;
56use std::task::{Context, Poll};
57
58use arrow_array::RecordBatch;
59use arrow_schema::SchemaRef;
60use datafusion::error::DataFusionError;
61use datafusion::execution::SendableRecordBatchStream;
62use datafusion::logical_expr::{BinaryExpr, Expr, Operator};
63use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
64use futures::stream::{self, Stream, StreamExt, TryStreamExt};
65
66/// Trait object alias for the plain (no schema) stream carried by the
67/// projection/limit layer. `SendableRecordBatchStream` requires a
68/// schema accessor — `RecordBatchStreamAdapter` provides it externally
69/// — so the inner stream type is a plain `Stream<Item = Result<…>>`.
70type BatchStream = Pin<Box<dyn Stream<Item = Result<RecordBatch, DataFusionError>> + Send>>;
71
72use crate::errors::FnError;
73use crate::traits::catalog::CatalogTable;
74use crate::traits::storage::Storage;
75
76// Rust guideline compliant
77
78/// `FnError` code reserved by the `Storage` contract for "predicate
79/// cannot be encoded by this backend". When the wrapped storage
80/// returns this, the adapter retries with an unfiltered scan and
81/// trusts the planner to wrap a `Filter` node on top.
82pub const STORAGE_FILTER_UNENCODABLE: u32 = 0x711;
83
84/// Adapter that exposes a [`Storage`] plugin as a [`CatalogTable`].
85///
86/// Construction is cheap — the schema is supplied by the caller so the
87/// adapter doesn't need an async I/O to satisfy `CatalogTable::schema()`.
88/// If the wrapped storage's [`Storage::schema`] yields a `SchemaRef`,
89/// callers can await it during plugin registration and feed it here.
90pub struct StorageCatalogTable {
91    storage: Arc<dyn Storage>,
92    table: String,
93    schema: SchemaRef,
94}
95
96impl std::fmt::Debug for StorageCatalogTable {
97    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
98        f.debug_struct("StorageCatalogTable")
99            .field("table", &self.table)
100            .field("schema", &self.schema)
101            .field("storage", &"<dyn Storage>")
102            .finish()
103    }
104}
105
106impl StorageCatalogTable {
107    /// Build a new adapter over `storage` exposing rows from `table`
108    /// with the supplied `schema`.
109    #[must_use]
110    pub fn new(storage: Arc<dyn Storage>, table: String, schema: SchemaRef) -> Self {
111        Self {
112            storage,
113            table,
114            schema,
115        }
116    }
117
118    /// Reference to the wrapped storage (useful in tests).
119    #[must_use]
120    pub fn storage(&self) -> &Arc<dyn Storage> {
121        &self.storage
122    }
123
124    /// Name of the underlying table.
125    #[must_use]
126    pub fn table(&self) -> &str {
127        &self.table
128    }
129}
130
131impl CatalogTable for StorageCatalogTable {
132    fn schema(&self) -> SchemaRef {
133        Arc::clone(&self.schema)
134    }
135
136    fn scan(
137        &self,
138        projection: Option<&[usize]>,
139        filters: &[Expr],
140        limit: Option<usize>,
141    ) -> Result<SendableRecordBatchStream, FnError> {
142        let storage = Arc::clone(&self.storage);
143        let table = self.table.clone();
144        let predicate = and_combine(filters);
145        let projection_owned: Option<Vec<usize>> = projection.map(<[usize]>::to_vec);
146
147        // Output schema reflects the projection so downstream nodes see
148        // a stable column shape even before the first batch arrives.
149        let output_schema: SchemaRef = match projection_owned.as_deref() {
150            Some(p) => project_schema(&self.schema, p),
151            None => Arc::clone(&self.schema),
152        };
153
154        let inner = stream::once(async move {
155            match storage.read_batch(&table, predicate.as_ref()).await {
156                Ok(s) => Ok(s),
157                // Backend rejected the encoded predicate — retry
158                // unfiltered. Planner-side `Filter` re-applies it.
159                Err(e) if e.code == STORAGE_FILTER_UNENCODABLE => {
160                    storage.read_batch(&table, None).await.map_err(fn_err_to_df)
161                }
162                Err(e) => Err(fn_err_to_df(e)),
163            }
164        })
165        .try_flatten();
166
167        let projected = ProjectionAndLimitStream::new(inner.boxed(), projection_owned, limit);
168
169        Ok(Box::pin(RecordBatchStreamAdapter::new(
170            output_schema,
171            projected,
172        )))
173    }
174}
175
176/// AND-combine a slice of filter expressions into a single conjunction.
177/// Returns `None` for an empty slice (full scan) and the lone element
178/// unchanged when only one filter is supplied.
179fn and_combine(filters: &[Expr]) -> Option<Expr> {
180    let mut iter = filters.iter().cloned();
181    let first = iter.next()?;
182    Some(iter.fold(first, |acc, next| {
183        Expr::BinaryExpr(BinaryExpr::new(
184            Box::new(acc),
185            Operator::And,
186            Box::new(next),
187        ))
188    }))
189}
190
191/// Project an Arrow schema down to the indices in `projection`.
192fn project_schema(schema: &SchemaRef, projection: &[usize]) -> SchemaRef {
193    let fields: Vec<arrow_schema::Field> = projection
194        .iter()
195        .filter_map(|i| schema.fields().get(*i).map(|f| f.as_ref().clone()))
196        .collect();
197    Arc::new(arrow_schema::Schema::new(fields))
198}
199
200/// Translate `FnError` into a `DataFusionError` for stream emission.
201fn fn_err_to_df(e: FnError) -> DataFusionError {
202    DataFusionError::Execution(format!(
203        "plugin Storage::read_batch failed (code 0x{:x}): {}",
204        e.code, e.message
205    ))
206}
207
208/// Wrap a batch stream with client-side projection and limit. Avoids
209/// pulling the DataFusion `ProjectionExec` / `LimitExec` for the
210/// trivial cases this adapter handles, and keeps the bridge a
211/// single-allocation stream layer.
212struct ProjectionAndLimitStream {
213    inner: BatchStream,
214    projection: Option<Vec<usize>>,
215    remaining: Option<usize>,
216    done: bool,
217}
218
219impl ProjectionAndLimitStream {
220    fn new(inner: BatchStream, projection: Option<Vec<usize>>, limit: Option<usize>) -> Self {
221        Self {
222            inner,
223            projection,
224            remaining: limit,
225            done: false,
226        }
227    }
228
229    fn apply(&self, batch: RecordBatch) -> Result<RecordBatch, DataFusionError> {
230        match self.projection.as_deref() {
231            Some(p) => batch
232                .project(p)
233                .map_err(|e| DataFusionError::ArrowError(Box::new(e), None)),
234            None => Ok(batch),
235        }
236    }
237}
238
239impl Stream for ProjectionAndLimitStream {
240    type Item = Result<RecordBatch, DataFusionError>;
241
242    fn poll_next(
243        mut self: std::pin::Pin<&mut Self>,
244        cx: &mut Context<'_>,
245    ) -> Poll<Option<Self::Item>> {
246        if self.done {
247            return Poll::Ready(None);
248        }
249        match self.inner.poll_next_unpin(cx) {
250            Poll::Pending => Poll::Pending,
251            Poll::Ready(None) => {
252                self.done = true;
253                Poll::Ready(None)
254            }
255            Poll::Ready(Some(Err(e))) => {
256                self.done = true;
257                Poll::Ready(Some(Err(e)))
258            }
259            Poll::Ready(Some(Ok(batch))) => {
260                let projected = match self.apply(batch) {
261                    Ok(b) => b,
262                    Err(e) => {
263                        self.done = true;
264                        return Poll::Ready(Some(Err(e)));
265                    }
266                };
267                let take = match self.remaining {
268                    Some(n) if n <= projected.num_rows() => {
269                        self.done = true;
270                        n
271                    }
272                    Some(n) => {
273                        self.remaining = Some(n - projected.num_rows());
274                        projected.num_rows()
275                    }
276                    None => projected.num_rows(),
277                };
278                if take == projected.num_rows() {
279                    Poll::Ready(Some(Ok(projected)))
280                } else {
281                    // Slice the batch down to the limit.
282                    Poll::Ready(Some(Ok(projected.slice(0, take))))
283                }
284            }
285        }
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use arrow_array::{Int64Array, StringArray};
293    use arrow_schema::{DataType, Field, Schema};
294    use async_trait::async_trait;
295    use datafusion::execution::SendableRecordBatchStream;
296    use datafusion::physical_plan::stream::RecordBatchStreamAdapter;
297    use futures::stream::{self, StreamExt};
298    use std::sync::Mutex;
299
300    use crate::traits::storage::WriteHandle;
301
302    struct StaticStorage {
303        batches: Mutex<Vec<RecordBatch>>,
304        schema: SchemaRef,
305        last_predicate: Mutex<Option<Expr>>,
306        fail_on_filter: bool,
307    }
308
309    #[async_trait]
310    impl Storage for StaticStorage {
311        async fn read_batch(
312            &self,
313            _table: &str,
314            predicate: Option<&Expr>,
315        ) -> Result<SendableRecordBatchStream, FnError> {
316            if self.fail_on_filter && predicate.is_some() {
317                return Err(FnError::new(STORAGE_FILTER_UNENCODABLE, "unencodable"));
318            }
319            *self.last_predicate.lock().expect("predicate mutex") = predicate.cloned();
320            let batches = self.batches.lock().expect("batches mutex").clone();
321            let schema = Arc::clone(&self.schema);
322            let s = stream::iter(batches.into_iter().map(Ok));
323            Ok(Box::pin(RecordBatchStreamAdapter::new(schema, s)))
324        }
325
326        async fn write_batch(
327            &self,
328            _table: &str,
329            _batch: &RecordBatch,
330        ) -> Result<WriteHandle, FnError> {
331            Err(FnError::new(1, "read-only fixture"))
332        }
333
334        async fn list_tables(&self) -> Result<Vec<String>, FnError> {
335            Ok(vec!["t".to_owned()])
336        }
337
338        async fn delete(&self, _table: &str, _predicate: &Expr) -> Result<u64, FnError> {
339            Err(FnError::new(1, "read-only fixture"))
340        }
341    }
342
343    fn fixture_schema() -> SchemaRef {
344        Arc::new(Schema::new(vec![
345            Field::new("id", DataType::Int64, false),
346            Field::new("name", DataType::Utf8, true),
347        ]))
348    }
349
350    fn fixture_batch(schema: &SchemaRef, ids: &[i64], names: &[&str]) -> RecordBatch {
351        let id_arr = Arc::new(Int64Array::from(ids.to_vec()));
352        let name_arr = Arc::new(StringArray::from_iter(names.iter().map(|s| Some(*s))));
353        RecordBatch::try_new(Arc::clone(schema), vec![id_arr, name_arr]).expect("fixture batch")
354    }
355
356    #[tokio::test]
357    async fn full_scan_streams_all_rows() {
358        let schema = fixture_schema();
359        let storage = Arc::new(StaticStorage {
360            batches: Mutex::new(vec![fixture_batch(&schema, &[1, 2, 3], &["a", "b", "c"])]),
361            schema: Arc::clone(&schema),
362            last_predicate: Mutex::new(None),
363            fail_on_filter: false,
364        });
365        let storage: Arc<dyn Storage> = storage;
366        let table = StorageCatalogTable::new(storage, "people".to_owned(), schema);
367
368        let mut stream = table.scan(None, &[], None).expect("scan starts");
369        let mut total = 0usize;
370        while let Some(b) = stream.next().await {
371            total += b.expect("batch").num_rows();
372        }
373        assert_eq!(total, 3);
374    }
375
376    #[tokio::test]
377    async fn limit_is_applied_client_side() {
378        let schema = fixture_schema();
379        let storage = Arc::new(StaticStorage {
380            batches: Mutex::new(vec![fixture_batch(&schema, &[1, 2, 3], &["a", "b", "c"])]),
381            schema: Arc::clone(&schema),
382            last_predicate: Mutex::new(None),
383            fail_on_filter: false,
384        });
385        let storage: Arc<dyn Storage> = storage;
386        let table = StorageCatalogTable::new(storage, "people".to_owned(), schema);
387
388        let mut stream = table.scan(None, &[], Some(2)).expect("scan starts");
389        let mut total = 0usize;
390        while let Some(b) = stream.next().await {
391            total += b.expect("batch").num_rows();
392        }
393        assert_eq!(total, 2);
394    }
395
396    #[tokio::test]
397    async fn projection_drops_columns() {
398        let schema = fixture_schema();
399        let storage = Arc::new(StaticStorage {
400            batches: Mutex::new(vec![fixture_batch(&schema, &[1, 2], &["a", "b"])]),
401            schema: Arc::clone(&schema),
402            last_predicate: Mutex::new(None),
403            fail_on_filter: false,
404        });
405        let table = StorageCatalogTable::new(storage, "people".to_owned(), Arc::clone(&schema));
406
407        let mut stream = table.scan(Some(&[0]), &[], None).expect("scan starts");
408        let mut total_cols = 0usize;
409        while let Some(b) = stream.next().await {
410            let b = b.expect("batch");
411            total_cols = b.num_columns();
412        }
413        assert_eq!(total_cols, 1, "projection should drop name column");
414    }
415
416    #[tokio::test]
417    async fn unencodable_filter_falls_back_to_unfiltered() {
418        use datafusion::logical_expr::{col, lit};
419        let schema = fixture_schema();
420        let storage = Arc::new(StaticStorage {
421            batches: Mutex::new(vec![fixture_batch(&schema, &[1, 2, 3], &["a", "b", "c"])]),
422            schema: Arc::clone(&schema),
423            last_predicate: Mutex::new(None),
424            fail_on_filter: true,
425        });
426        let storage: Arc<dyn Storage> = storage;
427        let table = StorageCatalogTable::new(storage, "people".to_owned(), schema);
428
429        let filter = col("id").eq(lit(2_i64));
430        let mut stream = table.scan(None, &[filter], None).expect("scan starts");
431        let mut total = 0usize;
432        while let Some(b) = stream.next().await {
433            total += b.expect("batch").num_rows();
434        }
435        // Backend rejected the filter — adapter retried unfiltered, so
436        // all 3 rows come back. Planner-side `Filter` would re-apply
437        // the predicate in a real query path.
438        assert_eq!(total, 3);
439    }
440}