Skip to main content

faucet_core/
transforming_source.rs

1//! Wrap any [`Source`] with a fixed list of [`TransformStage`]s applied to
2//! every emitted record. The canonical way for library callers to attach
3//! stages (transforms wrapped via [`TransformStage::Map`], plus `Filter` /
4//! `Explode` / `Custom`); the CLI uses this same type internally.
5
6use crate::error::FaucetError;
7use crate::observability::{Labels, instrumented_apply_stages};
8use crate::pipeline::StreamPage;
9use crate::stage::{CompiledStage, TransformStage, compile_stage};
10use crate::traits::Source;
11use async_trait::async_trait;
12use futures::StreamExt;
13use futures_core::Stream;
14use serde_json::Value;
15use std::collections::HashMap;
16use std::pin::Pin;
17
18/// Source decorator that applies a fixed list of compiled stages to every
19/// record. Emits `faucet_transform_*` metrics per page via
20/// [`instrumented_apply_stages`].
21///
22/// # Example
23///
24/// ```no_run
25/// use faucet_core::{RecordTransform, Source, TransformingSource};
26/// use faucet_core::observability::Labels;
27/// use faucet_core::stage::TransformStage;
28/// use faucet_core::transform::KeyCaseMode;
29///
30/// # fn build_inner() -> Box<dyn Source> { unimplemented!() }
31/// let inner: Box<dyn Source> = build_inner();
32/// let wrapped = TransformingSource::new(
33///     inner,
34///     vec![TransformStage::Map(RecordTransform::KeysCase { mode: KeyCaseMode::Snake })],
35///     Labels::for_named("rest"),
36/// ).unwrap();
37/// ```
38pub struct TransformingSource {
39    inner: Box<dyn Source>,
40    stages: Vec<CompiledStage>,
41    labels: Labels,
42    /// Optional Arrow `RecordBatch → RecordBatch` form for each stage, parallel
43    /// to `stages` (#375). `Some` only for columnar-capable stages (today the
44    /// SQL transform, supplied via [`new_with_batches`](Self::new_with_batches));
45    /// `None` for `Value`-only stages. When every entry is `Some` and the inner
46    /// source is columnar, the whole chain runs on the columnar fast path.
47    #[cfg(feature = "arrow")]
48    batch_fns: Vec<Option<crate::stage::PageFnBatchBox>>,
49}
50
51impl TransformingSource {
52    /// Compile `stages` and wrap `inner`. Returns
53    /// [`FaucetError::Transform`] if any stage's compilation fails (e.g.
54    /// invalid regex in `RenameKeys`). The chain stays on the `Value` path
55    /// (no columnar batch forms).
56    pub fn new(
57        inner: Box<dyn Source>,
58        stages: Vec<TransformStage>,
59        labels: Labels,
60    ) -> Result<Self, FaucetError> {
61        let compiled = stages
62            .iter()
63            .map(compile_stage)
64            .collect::<Result<Vec<_>, _>>()?;
65        #[cfg(feature = "arrow")]
66        let n = compiled.len();
67        Ok(Self {
68            inner,
69            stages: compiled,
70            labels,
71            #[cfg(feature = "arrow")]
72            batch_fns: vec![None; n],
73        })
74    }
75
76    /// Like [`new`](Self::new), but each stage may carry an Arrow `RecordBatch`
77    /// form (`batch_fns[i]` parallels `stages[i]`), so the chain can run on the
78    /// columnar fast path (#375) when the inner source and sink are Arrow-native
79    /// and **every** stage supplies one. Used by the CLI for `sql` transforms.
80    #[cfg(feature = "arrow")]
81    pub fn new_with_batches(
82        inner: Box<dyn Source>,
83        stages: Vec<TransformStage>,
84        batch_fns: Vec<Option<crate::stage::PageFnBatchBox>>,
85        labels: Labels,
86    ) -> Result<Self, FaucetError> {
87        if batch_fns.len() != stages.len() {
88            return Err(FaucetError::Transform(format!(
89                "TransformingSource::new_with_batches: {} batch fns for {} stages",
90                batch_fns.len(),
91                stages.len()
92            )));
93        }
94        let compiled = stages
95            .iter()
96            .map(compile_stage)
97            .collect::<Result<Vec<_>, _>>()?;
98        Ok(Self {
99            inner,
100            stages: compiled,
101            labels,
102            batch_fns,
103        })
104    }
105}
106
107#[async_trait]
108impl Source for TransformingSource {
109    async fn fetch_with_context(
110        &self,
111        ctx: &HashMap<String, Value>,
112    ) -> Result<Vec<Value>, FaucetError> {
113        let records = self.inner.fetch_with_context(ctx).await?;
114        instrumented_apply_stages(records, &self.stages, &self.labels)
115    }
116
117    async fn fetch_with_context_incremental(
118        &self,
119        ctx: &HashMap<String, Value>,
120    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
121        let (records, bookmark) = self.inner.fetch_with_context_incremental(ctx).await?;
122        let transformed = instrumented_apply_stages(records, &self.stages, &self.labels)?;
123        Ok((transformed, bookmark))
124    }
125
126    fn stream_pages<'a>(
127        &'a self,
128        ctx: &'a HashMap<String, Value>,
129        batch_size: usize,
130    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
131        Box::pin(async_stream::try_stream! {
132            let mut pages = self.inner.stream_pages(ctx, batch_size);
133            while let Some(page) = pages.next().await {
134                let page = page?;
135                let out = instrumented_apply_stages(
136                    page.records, &self.stages, &self.labels,
137                )?;
138                if out.is_empty() {
139                    yield StreamPage { records: vec![], bookmark: page.bookmark };
140                    continue;
141                }
142                if batch_size == 0 {
143                    yield StreamPage { records: out, bookmark: page.bookmark };
144                    continue;
145                }
146                let total = out.len();
147                let mut start = 0usize;
148                while start < total {
149                    let end = std::cmp::min(start + batch_size, total);
150                    let is_last = end == total;
151                    let chunk: Vec<Value> = out[start..end].to_vec();
152                    yield StreamPage {
153                        records: chunk,
154                        bookmark: if is_last { page.bookmark.clone() } else { None },
155                    };
156                    start = end;
157                }
158            }
159        })
160    }
161
162    /// Columnar only when the inner source is columnar **and** every stage has
163    /// an Arrow batch form (today: the SQL transform). Any `Value`-only stage
164    /// (`Map` / `Filter` / `Explode` / `CdcUnwrap` / `Custom` / plain `PageFn`)
165    /// keeps the whole chain on the `Value` path (#375).
166    #[cfg(feature = "arrow")]
167    fn supports_columnar(&self) -> bool {
168        self.inner.supports_columnar()
169            && !self.batch_fns.is_empty()
170            && self.batch_fns.iter().all(Option::is_some)
171    }
172
173    /// Stream the inner source's Arrow batches with every stage's batch form
174    /// applied in declared order — so `parquet → sql → parquet` runs Arrow
175    /// end-to-end. Only reached when [`supports_columnar`](Self::supports_columnar)
176    /// is `true`, i.e. every stage has a batch form.
177    #[cfg(feature = "arrow")]
178    fn stream_batches<'a>(
179        &'a self,
180        ctx: &'a HashMap<String, Value>,
181        batch_size: usize,
182    ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'a>>
183    {
184        Box::pin(async_stream::try_stream! {
185            let mut pages = self.inner.stream_batches(ctx, batch_size);
186            while let Some(page) = pages.next().await {
187                let crate::columnar::ColumnarPage { mut batch, bookmark } = page?;
188                for bf in self.batch_fns.iter().flatten() {
189                    batch = bf(batch)?;
190                }
191                yield crate::columnar::ColumnarPage { batch, bookmark };
192            }
193        })
194    }
195
196    fn state_key(&self) -> Option<String> {
197        self.inner.state_key()
198    }
199
200    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
201        self.inner.apply_start_bookmark(bookmark).await
202    }
203
204    fn supports_exactly_once(&self) -> bool {
205        self.inner.supports_exactly_once()
206    }
207
208    fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
209        self.inner.replay_guarantee()
210    }
211
212    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
213        self.inner.capture_resume_position().await
214    }
215
216    fn connector_name(&self) -> &'static str {
217        self.inner.connector_name()
218    }
219
220    fn dataset_uri(&self) -> String {
221        // Forward the wrapped connector's identity — without this, lineage and
222        // the Data Movement Catalog would see the default
223        // `<connector>://unknown` whenever transforms are attached.
224        self.inner.dataset_uri()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use crate::stage::TransformStage;
232    use crate::transform::{KeyCaseMode, RecordTransform};
233    use serde_json::json;
234    use std::sync::Arc;
235    use std::sync::atomic::{AtomicBool, Ordering};
236
237    struct MockSource(Vec<Value>);
238
239    #[async_trait]
240    impl Source for MockSource {
241        async fn fetch_with_context(
242            &self,
243            _ctx: &HashMap<String, Value>,
244        ) -> Result<Vec<Value>, FaucetError> {
245            Ok(self.0.clone())
246        }
247    }
248
249    #[tokio::test]
250    async fn fetch_with_context_transforms_records() {
251        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
252        let wrapped = TransformingSource::new(
253            inner,
254            vec![TransformStage::Map(RecordTransform::KeysCase {
255                mode: KeyCaseMode::Snake,
256            })],
257            Labels::for_named("test"),
258        )
259        .expect("compile succeeds");
260        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
261        assert_eq!(out, vec![json!({"foo_bar": 1})]);
262    }
263
264    struct IncrementalSource {
265        records: Vec<Value>,
266        bookmark: Value,
267    }
268
269    #[async_trait]
270    impl Source for IncrementalSource {
271        async fn fetch_with_context(
272            &self,
273            _ctx: &HashMap<String, Value>,
274        ) -> Result<Vec<Value>, FaucetError> {
275            Ok(self.records.clone())
276        }
277
278        async fn fetch_with_context_incremental(
279            &self,
280            _ctx: &HashMap<String, Value>,
281        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
282            Ok((self.records.clone(), Some(self.bookmark.clone())))
283        }
284    }
285
286    #[tokio::test]
287    async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
288        let inner: Box<dyn Source> = Box::new(IncrementalSource {
289            records: vec![json!({"FooBar": 1})],
290            bookmark: json!("2026-05-28T00:00:00Z"),
291        });
292        let wrapped = TransformingSource::new(
293            inner,
294            vec![TransformStage::Map(RecordTransform::KeysCase {
295                mode: KeyCaseMode::Snake,
296            })],
297            Labels::for_named("test"),
298        )
299        .unwrap();
300        let (records, bookmark) = wrapped
301            .fetch_with_context_incremental(&HashMap::new())
302            .await
303            .unwrap();
304        assert_eq!(records, vec![json!({"foo_bar": 1})]);
305        assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
306    }
307
308    /// Emits records as N predetermined pages with the bookmark only on the last.
309    /// Overrides `stream_pages` directly so the test catches whether the wrapper
310    /// delegates to the native streaming path (correct) or falls back to the
311    /// chunk-the-buffer default (wrong — the bug we're fixing).
312    struct PagedSource {
313        pages: Vec<Vec<Value>>,
314        final_bookmark: Value,
315    }
316
317    #[async_trait]
318    impl Source for PagedSource {
319        async fn fetch_with_context(
320            &self,
321            _ctx: &HashMap<String, Value>,
322        ) -> Result<Vec<Value>, FaucetError> {
323            Ok(self.pages.iter().flatten().cloned().collect())
324        }
325
326        fn stream_pages<'a>(
327            &'a self,
328            _ctx: &'a HashMap<String, Value>,
329            _batch_size: usize,
330        ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
331        {
332            let pages = self.pages.clone();
333            let bookmark = self.final_bookmark.clone();
334            Box::pin(async_stream::try_stream! {
335                let n = pages.len();
336                for (i, records) in pages.into_iter().enumerate() {
337                    let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
338                    yield StreamPage { records, bookmark: bm };
339                }
340            })
341        }
342    }
343
344    #[tokio::test]
345    async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
346        let inner: Box<dyn Source> = Box::new(PagedSource {
347            pages: vec![
348                vec![json!({"FooBar": 1})],
349                vec![json!({"FooBar": 2})],
350                vec![json!({"FooBar": 3})],
351            ],
352            final_bookmark: json!("v1"),
353        });
354        let wrapped = TransformingSource::new(
355            inner,
356            vec![TransformStage::Map(RecordTransform::KeysCase {
357                mode: KeyCaseMode::Snake,
358            })],
359            Labels::for_named("test"),
360        )
361        .unwrap();
362
363        let ctx = HashMap::new();
364        let mut stream = wrapped.stream_pages(&ctx, 1000);
365        let mut collected: Vec<StreamPage> = Vec::new();
366        while let Some(page) = stream.next().await {
367            collected.push(page.unwrap());
368        }
369
370        assert_eq!(collected.len(), 3);
371        assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
372        assert!(collected[0].bookmark.is_none());
373        assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
374        assert!(collected[1].bookmark.is_none());
375        assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
376        assert_eq!(collected[2].bookmark, Some(json!("v1")));
377    }
378
379    #[tokio::test]
380    async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
381        struct EmptyWithBookmark;
382        #[async_trait]
383        impl Source for EmptyWithBookmark {
384            async fn fetch_with_context(
385                &self,
386                _ctx: &HashMap<String, Value>,
387            ) -> Result<Vec<Value>, FaucetError> {
388                Ok(Vec::new())
389            }
390            fn stream_pages<'a>(
391                &'a self,
392                _ctx: &'a HashMap<String, Value>,
393                _batch_size: usize,
394            ) -> Pin<
395                Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
396            > {
397                Box::pin(async_stream::try_stream! {
398                    yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
399                })
400            }
401        }
402        let wrapped = TransformingSource::new(
403            Box::new(EmptyWithBookmark),
404            vec![TransformStage::Map(RecordTransform::KeysCase {
405                mode: KeyCaseMode::Snake,
406            })],
407            Labels::for_named("test"),
408        )
409        .unwrap();
410        let ctx = HashMap::new();
411        let mut stream = wrapped.stream_pages(&ctx, 1000);
412        let page = stream.next().await.unwrap().unwrap();
413        assert!(page.records.is_empty());
414        assert_eq!(page.bookmark, Some(json!("v1")));
415        assert!(stream.next().await.is_none());
416    }
417
418    struct InstrumentedSource {
419        started: Arc<AtomicBool>,
420    }
421
422    #[async_trait]
423    impl Source for InstrumentedSource {
424        async fn fetch_with_context(
425            &self,
426            _ctx: &HashMap<String, Value>,
427        ) -> Result<Vec<Value>, FaucetError> {
428            Ok(vec![])
429        }
430        fn connector_name(&self) -> &'static str {
431            "instrumented"
432        }
433        fn state_key(&self) -> Option<String> {
434            Some("instrumented::key".to_string())
435        }
436        async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
437            self.started.store(true, Ordering::Relaxed);
438            Ok(())
439        }
440        fn supports_exactly_once(&self) -> bool {
441            true
442        }
443        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
444            Ok(Some(json!("captured")))
445        }
446    }
447
448    #[tokio::test]
449    async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
450        let started = Arc::new(AtomicBool::new(false));
451        let inner = InstrumentedSource {
452            started: started.clone(),
453        };
454        let wrapped = TransformingSource::new(
455            Box::new(inner),
456            vec![TransformStage::Map(RecordTransform::KeysCase {
457                mode: KeyCaseMode::Snake,
458            })],
459            Labels::for_named("test"),
460        )
461        .unwrap();
462        assert_eq!(wrapped.connector_name(), "instrumented");
463        assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
464        wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
465        assert!(started.load(Ordering::Relaxed));
466        // Exactly-once capabilities must survive the transform wrap — the
467        // pipeline's mechanism selection reads them through this layer.
468        assert!(wrapped.supports_exactly_once());
469        assert_eq!(
470            wrapped.replay_guarantee(),
471            crate::idempotency::ReplayGuarantee::Deterministic
472        );
473        assert_eq!(
474            wrapped.capture_resume_position().await.unwrap(),
475            Some(json!("captured"))
476        );
477    }
478
479    #[tokio::test]
480    async fn new_fails_fast_on_invalid_regex() {
481        let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
482        let result = TransformingSource::new(
483            inner,
484            vec![TransformStage::Map(RecordTransform::RenameKeys {
485                pattern: "[invalid".to_string(),
486                replacement: "x".to_string(),
487            })],
488            Labels::for_named("test"),
489        );
490        let err = match result {
491            Ok(_) => panic!("invalid regex must fail at new()"),
492            Err(e) => e,
493        };
494        assert!(matches!(err, FaucetError::Transform(_)));
495    }
496
497    #[tokio::test]
498    async fn custom_closure_transform_runs() {
499        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
500        let wrapped = TransformingSource::new(
501            inner,
502            vec![TransformStage::Map(RecordTransform::custom(
503                |mut record| {
504                    if let Some(obj) = record.as_object_mut() {
505                        obj.insert("added".to_string(), json!(true));
506                    }
507                    record
508                },
509            ))],
510            Labels::for_named("test"),
511        )
512        .unwrap();
513        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
514        assert_eq!(out, vec![json!({"x": 1, "added": true})]);
515    }
516
517    #[tokio::test]
518    async fn usable_as_boxed_dyn_source() {
519        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
520        let wrapped: Box<dyn Source> = Box::new(
521            TransformingSource::new(
522                inner,
523                vec![TransformStage::Map(RecordTransform::KeysCase {
524                    mode: KeyCaseMode::Snake,
525                })],
526                Labels::for_named("test"),
527            )
528            .unwrap(),
529        );
530        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
531        assert_eq!(out, vec![json!({"foo_bar": 1})]);
532    }
533
534    /// A source that emits a single page with the given records and bookmark.
535    struct OnePageSource {
536        records: Vec<Value>,
537        bookmark: Option<Value>,
538    }
539
540    #[async_trait]
541    impl Source for OnePageSource {
542        async fn fetch_with_context(
543            &self,
544            _ctx: &HashMap<String, Value>,
545        ) -> Result<Vec<Value>, FaucetError> {
546            Ok(self.records.clone())
547        }
548        async fn fetch_with_context_incremental(
549            &self,
550            _ctx: &HashMap<String, Value>,
551        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
552            Ok((self.records.clone(), self.bookmark.clone()))
553        }
554        fn stream_pages<'a>(
555            &'a self,
556            _ctx: &'a HashMap<String, Value>,
557            _batch_size: usize,
558        ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
559            let page = StreamPage {
560                records: self.records.clone(),
561                bookmark: self.bookmark.clone(),
562            };
563            Box::pin(async_stream::stream! { yield Ok(page); })
564        }
565    }
566
567    #[cfg(feature = "transform-explode")]
568    fn explode_stage() -> TransformStage {
569        TransformStage::Explode(crate::stage::ExplodeSpec {
570            path: "items".to_owned(),
571            prefix: None,
572            separator: "_".to_owned(),
573            on_missing: crate::stage::OnMissing::Drop,
574        })
575    }
576
577    /// Build N records each with a 10-element `items` array, so explode 10×s them.
578    #[cfg(feature = "transform-explode")]
579    fn explode_10x_records(n: usize) -> Vec<Value> {
580        (0..n)
581            .map(|i| {
582                json!({
583                    "id": i,
584                    "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
585                })
586            })
587            .collect()
588    }
589
590    #[cfg(feature = "transform-explode")]
591    #[tokio::test]
592    async fn stream_pages_rechunks_explosion_with_bookmark_on_last() {
593        let inner: Box<dyn Source> = Box::new(OnePageSource {
594            records: explode_10x_records(100), // 100 → 1000 after explode
595            bookmark: Some(json!("bm")),
596        });
597        let wrapped =
598            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
599        let ctx = HashMap::new();
600        let mut stream = wrapped.stream_pages(&ctx, 200);
601        let mut sub_pages: Vec<StreamPage> = Vec::new();
602        while let Some(p) = stream.next().await {
603            sub_pages.push(p.unwrap());
604        }
605        assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
606        for (i, p) in sub_pages.iter().enumerate() {
607            assert_eq!(p.records.len(), 200, "sub-page {i} should be size 200");
608            if i < 4 {
609                assert!(
610                    p.bookmark.is_none(),
611                    "non-final sub-page {i} carries no bookmark"
612                );
613            } else {
614                assert_eq!(p.bookmark, Some(json!("bm")), "final sub-page has bookmark");
615            }
616        }
617    }
618
619    #[cfg(feature = "transform-explode")]
620    #[tokio::test]
621    async fn stream_pages_batch_size_zero_emits_one_page() {
622        let inner: Box<dyn Source> = Box::new(OnePageSource {
623            records: explode_10x_records(10), // 10 → 100 after explode
624            bookmark: Some(json!("bm")),
625        });
626        let wrapped =
627            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
628        let ctx = HashMap::new();
629        let mut stream = wrapped.stream_pages(&ctx, 0);
630        let mut sub_pages: Vec<StreamPage> = Vec::new();
631        while let Some(p) = stream.next().await {
632            sub_pages.push(p.unwrap());
633        }
634        assert_eq!(sub_pages.len(), 1, "batch_size=0 means one sub-page");
635        assert_eq!(sub_pages[0].records.len(), 100);
636        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
637    }
638
639    #[cfg(feature = "transform-filter")]
640    #[tokio::test]
641    async fn stream_pages_filter_drops_all_still_yields_bookmark() {
642        let inner: Box<dyn Source> = Box::new(OnePageSource {
643            records: vec![json!({"deleted": true}), json!({"deleted": true})],
644            bookmark: Some(json!("bm")),
645        });
646        let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
647            path: "deleted".to_owned(),
648            op: crate::stage::FilterOp::Ne,
649            value: Some(json!(true)),
650        });
651        let wrapped =
652            TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
653        let ctx = HashMap::new();
654        let mut stream = wrapped.stream_pages(&ctx, 100);
655        let mut sub_pages: Vec<StreamPage> = Vec::new();
656        while let Some(p) = stream.next().await {
657            sub_pages.push(p.unwrap());
658        }
659        assert_eq!(sub_pages.len(), 1);
660        assert!(sub_pages[0].records.is_empty());
661        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
662    }
663}
664
665#[cfg(all(test, feature = "arrow"))]
666mod columnar_tests {
667    use super::*;
668    use crate::columnar::{ColumnarPage, record_batch_to_values, values_to_record_batch_inferred};
669    use crate::stage::TransformStage;
670    use serde_json::json;
671    use std::sync::Arc;
672
673    /// A source that emits one Arrow batch (columnar-capable).
674    struct ColumnarMock(Vec<Value>);
675    #[async_trait]
676    impl Source for ColumnarMock {
677        async fn fetch_with_context(
678            &self,
679            _ctx: &HashMap<String, Value>,
680        ) -> Result<Vec<Value>, FaucetError> {
681            Ok(self.0.clone())
682        }
683        fn supports_columnar(&self) -> bool {
684            true
685        }
686        fn stream_batches<'a>(
687            &'a self,
688            _ctx: &'a HashMap<String, Value>,
689            _bs: usize,
690        ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'a>> {
691            let batch = values_to_record_batch_inferred(&self.0).unwrap();
692            Box::pin(async_stream::stream! {
693                yield Ok(ColumnarPage { batch, bookmark: Some(json!("bm")) });
694            })
695        }
696    }
697
698    /// A source with no columnar support (default `supports_columnar` = false).
699    struct RowOnlyMock(Vec<Value>);
700    #[async_trait]
701    impl Source for RowOnlyMock {
702        async fn fetch_with_context(
703            &self,
704            _ctx: &HashMap<String, Value>,
705        ) -> Result<Vec<Value>, FaucetError> {
706            Ok(self.0.clone())
707        }
708    }
709
710    /// An identity page stage + its identity batch form — enough to exercise
711    /// the columnar wiring.
712    fn identity_stage() -> (TransformStage, Option<crate::stage::PageFnBatchBox>) {
713        let rows: crate::stage::PageFnBox = Arc::new(Ok);
714        let batch: crate::stage::PageFnBatchBox = Arc::new(Ok);
715        (TransformStage::PageFn(rows), Some(batch))
716    }
717
718    #[tokio::test]
719    async fn columnar_inner_plus_columnar_stage_is_supported_and_streams() {
720        let inner: Box<dyn Source> =
721            Box::new(ColumnarMock(vec![json!({"id": 1}), json!({"id": 2})]));
722        let (stage, batch) = identity_stage();
723        let wrapped = TransformingSource::new_with_batches(
724            inner,
725            vec![stage],
726            vec![batch],
727            Labels::for_named("t"),
728        )
729        .unwrap();
730        assert!(wrapped.supports_columnar());
731        let ctx = HashMap::new();
732        let mut s = wrapped.stream_batches(&ctx, 0);
733        let page = s.next().await.unwrap().unwrap();
734        let rows = record_batch_to_values(&page.batch).unwrap();
735        assert_eq!(rows.len(), 2);
736        assert_eq!(page.bookmark, Some(json!("bm")));
737    }
738
739    #[tokio::test]
740    async fn value_only_stage_disables_columnar() {
741        // A `Map` stage has no Arrow batch form (batch_fn None) → the whole
742        // chain drops off the columnar path even though the inner is columnar.
743        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"FooBar": 1})]));
744        let wrapped = TransformingSource::new(
745            inner,
746            vec![TransformStage::Map(
747                crate::transform::RecordTransform::KeysCase {
748                    mode: crate::transform::KeyCaseMode::Snake,
749                },
750            )],
751            Labels::for_named("t"),
752        )
753        .unwrap();
754        assert!(!wrapped.supports_columnar());
755    }
756
757    #[tokio::test]
758    async fn columnar_stage_over_row_only_inner_is_disabled() {
759        let inner: Box<dyn Source> = Box::new(RowOnlyMock(vec![json!({"id": 1})]));
760        let (stage, batch) = identity_stage();
761        let wrapped = TransformingSource::new_with_batches(
762            inner,
763            vec![stage],
764            vec![batch],
765            Labels::for_named("t"),
766        )
767        .unwrap();
768        assert!(!wrapped.supports_columnar(), "inner is not columnar");
769    }
770}