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, on_collision: Default::default() })],
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    /// Rows in / out of the chain, for the run's row accounting (#879).
50    row_accounts: Option<std::sync::Arc<crate::accounting::RowAccounts>>,
51}
52
53impl TransformingSource {
54    /// Compile `stages` and wrap `inner`. Returns
55    /// [`FaucetError::Transform`] if any stage's compilation fails (e.g.
56    /// invalid regex in `RenameKeys`). The chain stays on the `Value` path
57    /// (no columnar batch forms).
58    pub fn new(
59        inner: Box<dyn Source>,
60        stages: Vec<TransformStage>,
61        labels: Labels,
62    ) -> Result<Self, FaucetError> {
63        let compiled = stages
64            .iter()
65            .map(compile_stage)
66            .collect::<Result<Vec<_>, _>>()?;
67        // Auto-derive the Arrow batch form for each stage (#636): a `Map` over a
68        // vectorizable record transform (select/drop/rename_field/set/redact)
69        // gets a columnar kernel, so a chain of only those over a columnar
70        // source+sink keeps the fast path. Any stage without one (a
71        // value-inspecting transform, filter/explode/cdc-unwrap, a custom or
72        // page fn) leaves a `None`, which makes `supports_columnar` false and
73        // holds the whole chain on the `Value` path.
74        #[cfg(feature = "arrow")]
75        let batch_fns: Vec<Option<crate::stage::PageFnBatchBox>> = stages
76            .iter()
77            .map(|s| match s {
78                TransformStage::Map(t) => crate::columnar_transform::batch_form(t),
79                _ => None,
80            })
81            .collect();
82        Ok(Self {
83            inner,
84            stages: compiled,
85            labels,
86            #[cfg(feature = "arrow")]
87            batch_fns,
88            row_accounts: None,
89        })
90    }
91
92    /// Like [`new`](Self::new), but each stage may carry an Arrow `RecordBatch`
93    /// form (`batch_fns[i]` parallels `stages[i]`), so the chain can run on the
94    /// columnar fast path (#375) when the inner source and sink are Arrow-native
95    /// and **every** stage supplies one. Used by the CLI for `sql` transforms.
96    #[cfg(feature = "arrow")]
97    pub fn new_with_batches(
98        inner: Box<dyn Source>,
99        stages: Vec<TransformStage>,
100        batch_fns: Vec<Option<crate::stage::PageFnBatchBox>>,
101        labels: Labels,
102    ) -> Result<Self, FaucetError> {
103        if batch_fns.len() != stages.len() {
104            return Err(FaucetError::Transform(format!(
105                "TransformingSource::new_with_batches: {} batch fns for {} stages",
106                batch_fns.len(),
107                stages.len()
108            )));
109        }
110        let compiled = stages
111            .iter()
112            .map(compile_stage)
113            .collect::<Result<Vec<_>, _>>()?;
114        Ok(Self {
115            inner,
116            stages: compiled,
117            labels,
118            batch_fns,
119            row_accounts: None,
120        })
121    }
122
123    /// Count the rows entering and leaving the chain into `accounts` (#879),
124    /// so a run summary can say how many a filter removed.
125    pub fn with_row_accounts(
126        mut self,
127        accounts: std::sync::Arc<crate::accounting::RowAccounts>,
128    ) -> Self {
129        self.row_accounts = Some(accounts);
130        self
131    }
132}
133
134#[async_trait]
135impl Source for TransformingSource {
136    async fn fetch_with_context(
137        &self,
138        ctx: &HashMap<String, Value>,
139    ) -> Result<Vec<Value>, FaucetError> {
140        let records = self.inner.fetch_with_context(ctx).await?;
141        instrumented_apply_stages(records, &self.stages, &self.labels)
142    }
143
144    async fn fetch_with_context_incremental(
145        &self,
146        ctx: &HashMap<String, Value>,
147    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
148        let (records, bookmark) = self.inner.fetch_with_context_incremental(ctx).await?;
149        let transformed = instrumented_apply_stages(records, &self.stages, &self.labels)?;
150        Ok((transformed, bookmark))
151    }
152
153    fn stream_pages<'a>(
154        &'a self,
155        ctx: &'a HashMap<String, Value>,
156        batch_size: usize,
157    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
158        Box::pin(async_stream::try_stream! {
159            let mut pages = self.inner.stream_pages(ctx, batch_size);
160            while let Some(page) = pages.next().await {
161                let page = page?;
162                // The inner source already sized this page per its own config
163                // `batch_size` (the authoritative knob — the pipeline-supplied
164                // hint is only informational). Re-chunking the transformed
165                // output *below* that inner page size would silently defeat an
166                // explicit source `batch_size` (e.g. a 200k-row page shrunk to
167                // the 1k default hint → 200 tiny sink writes / load jobs). So
168                // never chunk smaller than the inner page; only bound *growth*
169                // from a 1→N stage (explode) at that inner size.
170                let page_len = page.records.len();
171                let records = page.records;
172                let out = instrumented_apply_stages(records, &self.stages, &self.labels)?;
173                if let Some(a) = &self.row_accounts {
174                    a.add_transform(page_len, out.len());
175                }
176                if out.is_empty() {
177                    yield StreamPage { records: vec![], bookmark: page.bookmark };
178                    continue;
179                }
180                // A bookmark-carrying page is the unit a commit token covers, so
181                // splitting it would write the earlier chunks without one and an
182                // exactly-once resume would replay them. Sinks re-chunk internally.
183                if batch_size == 0 || page.bookmark.is_some() {
184                    yield StreamPage { records: out, bookmark: page.bookmark };
185                    continue;
186                }
187                let effective = std::cmp::max(batch_size, page_len);
188                if out.len() <= effective {
189                    yield StreamPage { records: out, bookmark: page.bookmark };
190                    continue;
191                }
192                // Move the records into the chunks rather than cloning them.
193                let mut rest = out.into_iter();
194                while rest.len() > 0 {
195                    let chunk: Vec<Value> = rest.by_ref().take(effective).collect();
196                    let is_last = rest.len() == 0;
197                    yield StreamPage {
198                        records: chunk,
199                        bookmark: if is_last { page.bookmark.clone() } else { None },
200                    };
201                }
202            }
203        })
204    }
205
206    /// Columnar only when the inner source is columnar **and** every stage has
207    /// an Arrow batch form (today: the SQL transform). Any `Value`-only stage
208    /// (`Map` / `Filter` / `Explode` / `CdcUnwrap` / `Custom` / plain `PageFn`)
209    /// keeps the whole chain on the `Value` path (#375).
210    #[cfg(feature = "arrow")]
211    fn supports_columnar(&self) -> bool {
212        self.inner.supports_columnar()
213            && !self.batch_fns.is_empty()
214            && self.batch_fns.iter().all(Option::is_some)
215    }
216
217    /// Stream the inner source's Arrow batches with every stage's batch form
218    /// applied in declared order — so `parquet → sql → parquet` runs Arrow
219    /// end-to-end. Only reached when [`supports_columnar`](Self::supports_columnar)
220    /// is `true`, i.e. every stage has a batch form.
221    #[cfg(feature = "arrow")]
222    fn stream_batches<'a>(
223        &'a self,
224        ctx: &'a HashMap<String, Value>,
225        batch_size: usize,
226    ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'a>>
227    {
228        Box::pin(async_stream::try_stream! {
229            use metrics::{Label, SharedString, counter};
230            let metric_labels = vec![
231                Label::new("pipeline", SharedString::from(self.labels.pipeline.to_string())),
232                Label::new("row", SharedString::from(self.labels.row.to_string())),
233            ];
234            let mut pages = self.inner.stream_batches(ctx, batch_size);
235            while let Some(page) = pages.next().await {
236                let crate::columnar::ColumnarPage { mut batch, bookmark } = page?;
237                // Metric parity with the Value path (#636): the same
238                // `faucet_transform_records_{in,out}_total` counters, per page.
239                // A pure-projection kernel is 1→1, so in == out, but emitting
240                // both keeps a dashboard identical across the two paths.
241                let n_in = batch.num_rows();
242                for bf in self.batch_fns.iter().flatten() {
243                    batch = crate::stage::run_blocking(true, || bf(batch)).inspect_err(|_| {
244                        counter!("faucet_transform_errors_total", metric_labels.clone())
245                            .increment(1);
246                    })?;
247                }
248                counter!("faucet_transform_records_in_total", metric_labels.clone())
249                    .increment(n_in as u64);
250                counter!("faucet_transform_records_out_total", metric_labels.clone())
251                    .increment(batch.num_rows() as u64);
252                yield crate::columnar::ColumnarPage { batch, bookmark };
253            }
254        })
255    }
256
257    // Everything else reaches the wrapped connector. The convenience fetches
258    // take the trait default so they run through the transforms above; the
259    // native byte path and server-side range digests stay at the default
260    // (off) because both would bypass the transforms.
261    crate::forward_source!(inner; except fetch_with_context, fetch_with_context_incremental,
262        stream_pages, supports_columnar, stream_batches, fetch_all, fetch_all_incremental,
263        native_output_formats, stream_native, range_digest);
264}
265
266#[cfg(test)]
267#[allow(clippy::disallowed_methods)] // tests drive time and raw clients directly
268mod tests {
269    use super::*;
270
271    struct OnePage;
272
273    #[async_trait::async_trait]
274    impl crate::Source for OnePage {
275        async fn fetch_with_context(
276            &self,
277            _ctx: &HashMap<String, Value>,
278        ) -> Result<Vec<Value>, FaucetError> {
279            Ok(vec![serde_json::json!({"a": 1})])
280        }
281    }
282
283    fn slow_page_source() -> TransformingSource {
284        let slow: crate::stage::PageFnBox = std::sync::Arc::new(|records: Vec<Value>| {
285            std::thread::sleep(std::time::Duration::from_millis(400));
286            Ok(records)
287        });
288        TransformingSource::new(
289            Box::new(OnePage),
290            vec![TransformStage::PageFn(slow)],
291            Labels::for_named("slow"),
292        )
293        .unwrap()
294    }
295
296    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
297    async fn a_slow_page_transform_does_not_pin_the_worker() {
298        use std::sync::atomic::{AtomicUsize, Ordering};
299        let ticks = std::sync::Arc::new(AtomicUsize::new(0));
300        let t = ticks.clone();
301        let ticker = tokio::spawn(async move {
302            loop {
303                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
304                t.fetch_add(1, Ordering::SeqCst);
305            }
306        });
307        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
308        let src = slow_page_source();
309        let during = tokio::spawn(async move {
310            let ctx = HashMap::new();
311            let mut pages = src.stream_pages(&ctx, 10);
312            let before = ticks.load(Ordering::SeqCst);
313            let page = pages.next().await.unwrap().unwrap();
314            assert_eq!(page.records.len(), 1);
315            let (records, _) = src.fetch_with_context_incremental(&ctx).await.unwrap();
316            assert_eq!(records.len(), 1);
317            ticks.load(Ordering::SeqCst) - before
318        })
319        .await
320        .unwrap();
321        ticker.abort();
322        assert!(during >= 20, "the ticker starved: {during} ticks");
323    }
324
325    #[tokio::test]
326    async fn a_slow_page_transform_runs_inline_on_a_current_thread_runtime() {
327        let src = slow_page_source();
328        assert_eq!(
329            src.fetch_with_context(&HashMap::new()).await.unwrap().len(),
330            1
331        );
332        assert_eq!(crate::stage::run_blocking(true, || 7), 7);
333    }
334
335    /// A change-stream double with its own multi-table hooks (#731).
336    #[tokio::test]
337    async fn routed_double_fetches_nothing() {
338        use crate::Source as _;
339        assert!(
340            RoutedSource
341                .fetch_with_context(&Default::default())
342                .await
343                .unwrap()
344                .is_empty()
345        );
346    }
347
348    struct RoutedSource;
349
350    #[async_trait::async_trait]
351    impl crate::Source for RoutedSource {
352        async fn fetch_with_context(
353            &self,
354            _ctx: &std::collections::HashMap<String, serde_json::Value>,
355        ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
356            Ok(Vec::new())
357        }
358
359        fn record_table(&self, record: &serde_json::Value) -> Option<String> {
360            record.get("t")?.as_str().map(str::to_string)
361        }
362
363        fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
364            Some(a.as_u64()? <= b.as_u64()?)
365        }
366
367        fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
368            Some(serde_json::json!("inner-min"))
369        }
370
371        fn record_covered(
372            &self,
373            record: &serde_json::Value,
374            position: &serde_json::Value,
375        ) -> Option<bool> {
376            Some(record.get("p")?.as_u64()? < position.as_u64()?)
377        }
378    }
379
380    fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
381        assert_eq!(
382            s.record_table(&serde_json::json!({"t": "public.a"}))
383                .as_deref(),
384            Some("public.a")
385        );
386        assert_eq!(
387            s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
388            Some(true)
389        );
390        assert_eq!(
391            s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
392            Some(false)
393        );
394        assert_eq!(
395            s.record_covered(&serde_json::json!({"p": 1}), &serde_json::json!(2)),
396            Some(true)
397        );
398        assert_eq!(
399            s.record_covered(&serde_json::json!({"p": 2}), &serde_json::json!(2)),
400            Some(false)
401        );
402        assert_eq!(
403            s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
404            Some(serde_json::json!("inner-min"))
405        );
406    }
407
408    #[test]
409    fn multi_table_hooks_are_forwarded_to_the_inner_source() {
410        let wrapped =
411            TransformingSource::new(Box::new(RoutedSource), vec![], Labels::for_named("test"))
412                .unwrap();
413        assert_forwards_multi_table_hooks(&wrapped);
414    }
415
416    #[tokio::test]
417    async fn forwards_every_hook_the_transforms_do_not_change() {
418        let probe = crate::forwarding::HookSource::default();
419        let wrapped =
420            TransformingSource::new(Box::new(probe.clone()), vec![], Labels::for_named("test"))
421                .unwrap();
422        crate::forwarding::assert_source_forwards_every_hook(
423            &wrapped,
424            &probe,
425            &[
426                "fetch_all",
427                "fetch_all_incremental",
428                "native_output_formats",
429                "stream_native",
430                "range_digest",
431            ],
432        )
433        .await;
434        assert!(wrapped.native_output_formats().is_empty());
435    }
436
437    #[derive(Default)]
438    struct ShardedSource {
439        applied: std::sync::Mutex<Vec<String>>,
440    }
441
442    #[async_trait]
443    impl Source for ShardedSource {
444        async fn fetch_with_context(
445            &self,
446            _ctx: &HashMap<String, serde_json::Value>,
447        ) -> Result<Vec<serde_json::Value>, FaucetError> {
448            Ok(vec![])
449        }
450        fn is_shardable(&self) -> bool {
451            true
452        }
453        async fn enumerate_shards(
454            &self,
455            target: usize,
456        ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
457            Ok((0..target)
458                .map(|i| crate::shard::ShardSpec::new(format!("s{i}"), serde_json::json!(i)))
459                .collect())
460        }
461        async fn apply_shard(&self, shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
462            self.applied.lock().unwrap().push(shard.id.clone());
463            Ok(())
464        }
465        async fn range_digest(
466            &self,
467            _range: &crate::diff::KeyRange,
468            _key: &str,
469            _columns: &[String],
470        ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
471            Err(FaucetError::Source(
472                "a raw-data digest must not be consulted".into(),
473            ))
474        }
475        fn supports_discover(&self) -> bool {
476            true
477        }
478        async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
479            Ok(vec![crate::discover::DatasetDescriptor::new(
480                "orders",
481                "table",
482                serde_json::json!({}),
483            )])
484        }
485    }
486
487    #[tokio::test]
488    async fn shard_and_discover_hooks_reach_the_inner_source() {
489        let inner = Arc::new(ShardedSource::default());
490        struct Shared(Arc<ShardedSource>);
491        #[async_trait]
492        impl Source for Shared {
493            async fn fetch_with_context(
494                &self,
495                ctx: &HashMap<String, serde_json::Value>,
496            ) -> Result<Vec<serde_json::Value>, FaucetError> {
497                self.0.fetch_with_context(ctx).await
498            }
499            fn is_shardable(&self) -> bool {
500                self.0.is_shardable()
501            }
502            async fn enumerate_shards(
503                &self,
504                target: usize,
505            ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
506                self.0.enumerate_shards(target).await
507            }
508            async fn apply_shard(
509                &self,
510                shard: &crate::shard::ShardSpec,
511            ) -> Result<(), FaucetError> {
512                self.0.apply_shard(shard).await
513            }
514            async fn range_digest(
515                &self,
516                range: &crate::diff::KeyRange,
517                key: &str,
518                columns: &[String],
519            ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
520                self.0.range_digest(range, key, columns).await
521            }
522            fn supports_discover(&self) -> bool {
523                self.0.supports_discover()
524            }
525            async fn discover(
526                &self,
527            ) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
528                self.0.discover().await
529            }
530        }
531        let wrapped = TransformingSource::new(
532            Box::new(Shared(inner.clone())),
533            vec![],
534            Labels::for_named("t"),
535        )
536        .unwrap();
537        assert!(wrapped.is_shardable());
538        let shards = wrapped.enumerate_shards(3).await.unwrap();
539        assert_eq!(shards.len(), 3);
540        wrapped.apply_shard(&shards[1]).await.unwrap();
541        assert_eq!(*inner.applied.lock().unwrap(), vec!["s1".to_string()]);
542        assert!(wrapped.supports_discover());
543        assert_eq!(wrapped.discover().await.unwrap()[0].name, "orders");
544        let digest = wrapped
545            .range_digest(&crate::diff::KeyRange::ALL, "id", &[])
546            .await
547            .unwrap();
548        assert!(
549            digest.is_none(),
550            "transformed rows must be compared client-side, never by a raw-data digest"
551        );
552    }
553    use crate::stage::TransformStage;
554    use crate::transform::{KeyCaseMode, RecordTransform};
555    use serde_json::json;
556    use std::sync::Arc;
557    use std::sync::atomic::{AtomicBool, Ordering};
558
559    struct MockSource(Vec<Value>);
560
561    #[async_trait]
562    impl Source for MockSource {
563        async fn fetch_with_context(
564            &self,
565            _ctx: &HashMap<String, Value>,
566        ) -> Result<Vec<Value>, FaucetError> {
567            Ok(self.0.clone())
568        }
569    }
570
571    #[tokio::test]
572    async fn fetch_with_context_transforms_records() {
573        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
574        let wrapped = TransformingSource::new(
575            inner,
576            vec![TransformStage::Map(RecordTransform::KeysCase {
577                mode: KeyCaseMode::Snake,
578                on_collision: Default::default(),
579            })],
580            Labels::for_named("test"),
581        )
582        .expect("compile succeeds");
583        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
584        assert_eq!(out, vec![json!({"foo_bar": 1})]);
585    }
586
587    struct VersionedSource;
588
589    #[async_trait]
590    impl Source for VersionedSource {
591        async fn fetch_with_context(
592            &self,
593            _ctx: &HashMap<String, Value>,
594        ) -> Result<Vec<Value>, FaucetError> {
595            Ok(Vec::new())
596        }
597        fn state_schema(&self) -> u32 {
598            2
599        }
600        fn migrate_state(&self, from: u32, data: Value) -> Result<Value, FaucetError> {
601            Ok(json!({"from": from, "data": data}))
602        }
603    }
604
605    #[test]
606    fn state_versioning_is_forwarded_to_the_inner_source() {
607        let wrapped =
608            TransformingSource::new(Box::new(VersionedSource), vec![], Labels::for_named("test"))
609                .expect("compile succeeds");
610        assert_eq!(wrapped.state_schema(), 2);
611        assert_eq!(
612            wrapped.migrate_state(1, json!("x")).unwrap(),
613            json!({"from": 1, "data": "x"})
614        );
615    }
616
617    struct IncrementalSource {
618        records: Vec<Value>,
619        bookmark: Value,
620    }
621
622    #[async_trait]
623    impl Source for IncrementalSource {
624        async fn fetch_with_context(
625            &self,
626            _ctx: &HashMap<String, Value>,
627        ) -> Result<Vec<Value>, FaucetError> {
628            Ok(self.records.clone())
629        }
630
631        async fn fetch_with_context_incremental(
632            &self,
633            _ctx: &HashMap<String, Value>,
634        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
635            Ok((self.records.clone(), Some(self.bookmark.clone())))
636        }
637    }
638
639    #[tokio::test]
640    async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
641        let inner: Box<dyn Source> = Box::new(IncrementalSource {
642            records: vec![json!({"FooBar": 1})],
643            bookmark: json!("2026-05-28T00:00:00Z"),
644        });
645        let wrapped = TransformingSource::new(
646            inner,
647            vec![TransformStage::Map(RecordTransform::KeysCase {
648                mode: KeyCaseMode::Snake,
649                on_collision: Default::default(),
650            })],
651            Labels::for_named("test"),
652        )
653        .unwrap();
654        let (records, bookmark) = wrapped
655            .fetch_with_context_incremental(&HashMap::new())
656            .await
657            .unwrap();
658        assert_eq!(records, vec![json!({"foo_bar": 1})]);
659        assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
660    }
661
662    /// Emits records as N predetermined pages with the bookmark only on the last.
663    /// Overrides `stream_pages` directly so the test catches whether the wrapper
664    /// delegates to the native streaming path (correct) or falls back to the
665    /// chunk-the-buffer default (wrong — the bug we're fixing).
666    struct PagedSource {
667        pages: Vec<Vec<Value>>,
668        final_bookmark: Value,
669    }
670
671    #[async_trait]
672    impl Source for PagedSource {
673        async fn fetch_with_context(
674            &self,
675            _ctx: &HashMap<String, Value>,
676        ) -> Result<Vec<Value>, FaucetError> {
677            Ok(self.pages.iter().flatten().cloned().collect())
678        }
679
680        fn stream_pages<'a>(
681            &'a self,
682            _ctx: &'a HashMap<String, Value>,
683            _batch_size: usize,
684        ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
685        {
686            let pages = self.pages.clone();
687            let bookmark = self.final_bookmark.clone();
688            Box::pin(async_stream::try_stream! {
689                let n = pages.len();
690                for (i, records) in pages.into_iter().enumerate() {
691                    let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
692                    yield StreamPage { records, bookmark: bm };
693                }
694            })
695        }
696    }
697
698    #[tokio::test]
699    async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
700        let inner: Box<dyn Source> = Box::new(PagedSource {
701            pages: vec![
702                vec![json!({"FooBar": 1})],
703                vec![json!({"FooBar": 2})],
704                vec![json!({"FooBar": 3})],
705            ],
706            final_bookmark: json!("v1"),
707        });
708        let wrapped = TransformingSource::new(
709            inner,
710            vec![TransformStage::Map(RecordTransform::KeysCase {
711                mode: KeyCaseMode::Snake,
712                on_collision: Default::default(),
713            })],
714            Labels::for_named("test"),
715        )
716        .unwrap();
717
718        let ctx = HashMap::new();
719        let mut stream = wrapped.stream_pages(&ctx, 1000);
720        let mut collected: Vec<StreamPage> = Vec::new();
721        while let Some(page) = stream.next().await {
722            collected.push(page.unwrap());
723        }
724
725        assert_eq!(collected.len(), 3);
726        assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
727        assert!(collected[0].bookmark.is_none());
728        assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
729        assert!(collected[1].bookmark.is_none());
730        assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
731        assert_eq!(collected[2].bookmark, Some(json!("v1")));
732    }
733
734    /// Regression: a 1→1 transform must NOT re-chunk the inner page below the
735    /// size the inner source already chose. A source that yields one large page
736    /// (its config `batch_size` honored) followed by a `keys_case` stage must
737    /// still emit that page as ONE `StreamPage`, not many hint-sized sub-pages —
738    /// otherwise an explicit source `batch_size` is silently defeated whenever a
739    /// transform is present (the cause of 60 tiny BigQuery load jobs instead of
740    /// one).
741    #[tokio::test]
742    async fn stream_pages_does_not_rechunk_large_page_below_inner_size() {
743        let big: Vec<Value> = (0..2500).map(|i| json!({"FooBar": i})).collect();
744        let inner: Box<dyn Source> = Box::new(PagedSource {
745            pages: vec![big],
746            final_bookmark: json!("v1"),
747        });
748        let wrapped = TransformingSource::new(
749            inner,
750            vec![TransformStage::Map(RecordTransform::KeysCase {
751                mode: KeyCaseMode::Snake,
752                on_collision: Default::default(),
753            })],
754            Labels::for_named("t"),
755        )
756        .unwrap();
757        let ctx = HashMap::new();
758        // Pipeline hint is the 1000-row default; it must NOT shrink the page.
759        let mut stream = wrapped.stream_pages(&ctx, 1000);
760        let mut pages: Vec<StreamPage> = Vec::new();
761        while let Some(p) = stream.next().await {
762            pages.push(p.unwrap());
763        }
764        assert_eq!(pages.len(), 1, "one inner page must stay one page");
765        assert_eq!(pages[0].records.len(), 2500);
766        assert_eq!(pages[0].records[0], json!({"foo_bar": 0}));
767        assert_eq!(pages[0].bookmark, Some(json!("v1")));
768    }
769
770    #[tokio::test]
771    async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
772        struct EmptyWithBookmark;
773        #[async_trait]
774        impl Source for EmptyWithBookmark {
775            async fn fetch_with_context(
776                &self,
777                _ctx: &HashMap<String, Value>,
778            ) -> Result<Vec<Value>, FaucetError> {
779                Ok(Vec::new())
780            }
781            fn stream_pages<'a>(
782                &'a self,
783                _ctx: &'a HashMap<String, Value>,
784                _batch_size: usize,
785            ) -> Pin<
786                Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
787            > {
788                Box::pin(async_stream::try_stream! {
789                    yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
790                })
791            }
792        }
793        let wrapped = TransformingSource::new(
794            Box::new(EmptyWithBookmark),
795            vec![TransformStage::Map(RecordTransform::KeysCase {
796                mode: KeyCaseMode::Snake,
797                on_collision: Default::default(),
798            })],
799            Labels::for_named("test"),
800        )
801        .unwrap();
802        let ctx = HashMap::new();
803        let mut stream = wrapped.stream_pages(&ctx, 1000);
804        let page = stream.next().await.unwrap().unwrap();
805        assert!(page.records.is_empty());
806        assert_eq!(page.bookmark, Some(json!("v1")));
807        assert!(stream.next().await.is_none());
808    }
809
810    struct InstrumentedSource {
811        started: Arc<AtomicBool>,
812    }
813
814    #[async_trait]
815    impl Source for InstrumentedSource {
816        async fn fetch_with_context(
817            &self,
818            _ctx: &HashMap<String, Value>,
819        ) -> Result<Vec<Value>, FaucetError> {
820            Ok(vec![])
821        }
822        fn connector_name(&self) -> &'static str {
823            "instrumented"
824        }
825        fn state_key(&self) -> Option<String> {
826            Some("instrumented::key".to_string())
827        }
828        async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
829            self.started.store(true, Ordering::Relaxed);
830            Ok(())
831        }
832        fn supports_exactly_once(&self) -> bool {
833            true
834        }
835        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
836            Ok(Some(json!("captured")))
837        }
838    }
839
840    struct RecorderProbe(Arc<AtomicBool>);
841
842    #[async_trait]
843    impl Source for RecorderProbe {
844        async fn fetch_with_context(
845            &self,
846            _ctx: &HashMap<String, Value>,
847        ) -> Result<Vec<Value>, FaucetError> {
848            Ok(vec![])
849        }
850        fn set_roundtrip_recorder(&self, _recorder: Arc<crate::observability::RoundtripRecorder>) {
851            self.0.store(true, Ordering::Relaxed);
852        }
853        fn set_run_clock(&self, _now: chrono::DateTime<chrono::Utc>) {
854            self.0.store(true, Ordering::Relaxed);
855        }
856        fn set_cancel(&self, _cancel: tokio_util::sync::CancellationToken) -> bool {
857            self.0.store(true, Ordering::Relaxed);
858            true
859        }
860    }
861
862    #[test]
863    fn cancel_reaches_the_wrapped_source_and_its_answer_comes_back() {
864        let got = Arc::new(AtomicBool::new(false));
865        let wrapped = TransformingSource::new(
866            Box::new(RecorderProbe(got.clone())),
867            vec![],
868            Labels::for_named("test"),
869        )
870        .unwrap();
871        assert!(wrapped.set_cancel(tokio_util::sync::CancellationToken::new()));
872        assert!(got.load(Ordering::Relaxed));
873    }
874
875    #[test]
876    fn run_clock_reaches_the_wrapped_source() {
877        let got = Arc::new(AtomicBool::new(false));
878        let wrapped = TransformingSource::new(
879            Box::new(RecorderProbe(got.clone())),
880            vec![],
881            Labels::for_named("test"),
882        )
883        .unwrap();
884        wrapped.set_run_clock(chrono::Utc::now());
885        assert!(got.load(Ordering::Relaxed));
886    }
887
888    #[test]
889    fn roundtrip_recorder_reaches_the_wrapped_source() {
890        let got = Arc::new(AtomicBool::new(false));
891        let wrapped = TransformingSource::new(
892            Box::new(RecorderProbe(got.clone())),
893            vec![],
894            Labels::for_named("test"),
895        )
896        .unwrap();
897        wrapped.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
898            crate::observability::RoundtripSide::Source,
899            "p",
900            "r",
901            "probe",
902        )));
903        assert!(got.load(Ordering::Relaxed));
904    }
905
906    #[tokio::test]
907    async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
908        let started = Arc::new(AtomicBool::new(false));
909        let inner = InstrumentedSource {
910            started: started.clone(),
911        };
912        let wrapped = TransformingSource::new(
913            Box::new(inner),
914            vec![TransformStage::Map(RecordTransform::KeysCase {
915                mode: KeyCaseMode::Snake,
916                on_collision: Default::default(),
917            })],
918            Labels::for_named("test"),
919        )
920        .unwrap();
921        assert_eq!(wrapped.connector_name(), "instrumented");
922        assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
923        wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
924        assert!(started.load(Ordering::Relaxed));
925        // Exactly-once capabilities must survive the transform wrap — the
926        // pipeline's mechanism selection reads them through this layer.
927        assert!(wrapped.supports_exactly_once());
928        assert!(!wrapped.consumes_destructively());
929        assert_eq!(
930            wrapped.replay_guarantee(),
931            crate::idempotency::ReplayGuarantee::Deterministic
932        );
933        assert_eq!(
934            wrapped.capture_resume_position().await.unwrap(),
935            Some(json!("captured"))
936        );
937        assert_eq!(wrapped.lag().await.unwrap(), None);
938    }
939
940    #[tokio::test]
941    async fn new_fails_fast_on_invalid_regex() {
942        let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
943        let result = TransformingSource::new(
944            inner,
945            vec![TransformStage::Map(RecordTransform::RenameKeys {
946                pattern: "[invalid".to_string(),
947                replacement: "x".to_string(),
948            })],
949            Labels::for_named("test"),
950        );
951        let err = match result {
952            Ok(_) => panic!("invalid regex must fail at new()"),
953            Err(e) => e,
954        };
955        assert!(matches!(err, FaucetError::Transform(_)));
956    }
957
958    #[tokio::test]
959    async fn custom_closure_transform_runs() {
960        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
961        let wrapped = TransformingSource::new(
962            inner,
963            vec![TransformStage::Map(RecordTransform::custom(
964                |mut record| {
965                    if let Some(obj) = record.as_object_mut() {
966                        obj.insert("added".to_string(), json!(true));
967                    }
968                    record
969                },
970            ))],
971            Labels::for_named("test"),
972        )
973        .unwrap();
974        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
975        assert_eq!(out, vec![json!({"x": 1, "added": true})]);
976    }
977
978    #[tokio::test]
979    async fn usable_as_boxed_dyn_source() {
980        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
981        let wrapped: Box<dyn Source> = Box::new(
982            TransformingSource::new(
983                inner,
984                vec![TransformStage::Map(RecordTransform::KeysCase {
985                    mode: KeyCaseMode::Snake,
986                    on_collision: Default::default(),
987                })],
988                Labels::for_named("test"),
989            )
990            .unwrap(),
991        );
992        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
993        assert_eq!(out, vec![json!({"foo_bar": 1})]);
994    }
995
996    /// A source that emits a single page with the given records and bookmark.
997    ///
998    /// Only the bookmark-forwarding tests construct it, and those are gated on
999    /// a transform feature, so a build without one compiles it unused.
1000    #[allow(dead_code)]
1001    struct OnePageSource {
1002        records: Vec<Value>,
1003        bookmark: Option<Value>,
1004    }
1005
1006    #[async_trait]
1007    impl Source for OnePageSource {
1008        async fn fetch_with_context(
1009            &self,
1010            _ctx: &HashMap<String, Value>,
1011        ) -> Result<Vec<Value>, FaucetError> {
1012            Ok(self.records.clone())
1013        }
1014        async fn fetch_with_context_incremental(
1015            &self,
1016            _ctx: &HashMap<String, Value>,
1017        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1018            Ok((self.records.clone(), self.bookmark.clone()))
1019        }
1020        fn stream_pages<'a>(
1021            &'a self,
1022            _ctx: &'a HashMap<String, Value>,
1023            _batch_size: usize,
1024        ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
1025            let page = StreamPage {
1026                records: self.records.clone(),
1027                bookmark: self.bookmark.clone(),
1028            };
1029            Box::pin(async_stream::stream! { yield Ok(page); })
1030        }
1031    }
1032
1033    #[cfg(feature = "transform-explode")]
1034    fn explode_stage() -> TransformStage {
1035        TransformStage::Explode(crate::stage::ExplodeSpec {
1036            path: "items".to_owned(),
1037            prefix: None,
1038            separator: "_".to_owned(),
1039            on_missing: crate::stage::OnMissing::Drop,
1040        })
1041    }
1042
1043    /// Build N records each with a 10-element `items` array, so explode 10×s them.
1044    #[cfg(feature = "transform-explode")]
1045    fn explode_10x_records(n: usize) -> Vec<Value> {
1046        (0..n)
1047            .map(|i| {
1048                json!({
1049                    "id": i,
1050                    "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
1051                })
1052            })
1053            .collect()
1054    }
1055
1056    #[cfg(feature = "transform-explode")]
1057    #[tokio::test]
1058    async fn stream_pages_never_splits_a_bookmark_carrying_page() {
1059        let inner: Box<dyn Source> = Box::new(OnePageSource {
1060            records: explode_10x_records(100), // 100 → 1000 after explode
1061            bookmark: Some(json!("bm")),
1062        });
1063        let wrapped =
1064            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1065        let ctx = HashMap::new();
1066        let mut stream = wrapped.stream_pages(&ctx, 200);
1067        let mut sub_pages: Vec<StreamPage> = Vec::new();
1068        while let Some(p) = stream.next().await {
1069            sub_pages.push(p.unwrap());
1070        }
1071        assert_eq!(
1072            sub_pages.len(),
1073            1,
1074            "a bookmarked page stays one commit unit"
1075        );
1076        assert_eq!(sub_pages[0].records.len(), 1000);
1077        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1078    }
1079
1080    #[cfg(feature = "transform-explode")]
1081    #[tokio::test]
1082    async fn stream_pages_rechunks_a_grown_page_without_a_bookmark() {
1083        let inner: Box<dyn Source> = Box::new(OnePageSource {
1084            records: explode_10x_records(100),
1085            bookmark: None,
1086        });
1087        let wrapped =
1088            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1089        let ctx = HashMap::new();
1090        let mut stream = wrapped.stream_pages(&ctx, 200);
1091        let mut sub_pages: Vec<StreamPage> = Vec::new();
1092        while let Some(p) = stream.next().await {
1093            sub_pages.push(p.unwrap());
1094        }
1095        assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
1096        assert!(sub_pages.iter().all(|p| p.records.len() == 200));
1097        assert!(sub_pages.iter().all(|p| p.bookmark.is_none()));
1098    }
1099
1100    #[cfg(feature = "transform-explode")]
1101    #[tokio::test]
1102    async fn stream_pages_batch_size_zero_emits_one_page() {
1103        let inner: Box<dyn Source> = Box::new(OnePageSource {
1104            records: explode_10x_records(10), // 10 → 100 after explode
1105            bookmark: Some(json!("bm")),
1106        });
1107        let wrapped =
1108            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1109        let ctx = HashMap::new();
1110        let mut stream = wrapped.stream_pages(&ctx, 0);
1111        let mut sub_pages: Vec<StreamPage> = Vec::new();
1112        while let Some(p) = stream.next().await {
1113            sub_pages.push(p.unwrap());
1114        }
1115        assert_eq!(sub_pages.len(), 1, "batch_size=0 means one sub-page");
1116        assert_eq!(sub_pages[0].records.len(), 100);
1117        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1118    }
1119
1120    #[cfg(feature = "transform-filter")]
1121    #[tokio::test]
1122    async fn stream_pages_filter_drops_all_still_yields_bookmark() {
1123        let inner: Box<dyn Source> = Box::new(OnePageSource {
1124            records: vec![json!({"deleted": true}), json!({"deleted": true})],
1125            bookmark: Some(json!("bm")),
1126        });
1127        let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
1128            path: "deleted".to_owned(),
1129            op: crate::stage::FilterOp::Ne,
1130            value: Some(json!(true)),
1131        });
1132        let wrapped =
1133            TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
1134        let ctx = HashMap::new();
1135        let mut stream = wrapped.stream_pages(&ctx, 100);
1136        let mut sub_pages: Vec<StreamPage> = Vec::new();
1137        while let Some(p) = stream.next().await {
1138            sub_pages.push(p.unwrap());
1139        }
1140        assert_eq!(sub_pages.len(), 1);
1141        assert!(sub_pages[0].records.is_empty());
1142        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1143    }
1144}
1145
1146#[cfg(all(test, feature = "arrow"))]
1147mod columnar_tests {
1148    use super::*;
1149    use crate::columnar::{ColumnarPage, record_batch_to_values, values_to_record_batch_inferred};
1150    use crate::stage::TransformStage;
1151    use serde_json::json;
1152    use std::sync::Arc;
1153
1154    /// A source that emits one Arrow batch (columnar-capable).
1155    struct ColumnarMock(Vec<Value>);
1156    #[async_trait]
1157    impl Source for ColumnarMock {
1158        async fn fetch_with_context(
1159            &self,
1160            _ctx: &HashMap<String, Value>,
1161        ) -> Result<Vec<Value>, FaucetError> {
1162            Ok(self.0.clone())
1163        }
1164        fn supports_columnar(&self) -> bool {
1165            true
1166        }
1167        fn stream_batches<'a>(
1168            &'a self,
1169            _ctx: &'a HashMap<String, Value>,
1170            _bs: usize,
1171        ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'a>> {
1172            let batch = values_to_record_batch_inferred(&self.0).unwrap();
1173            Box::pin(async_stream::stream! {
1174                yield Ok(ColumnarPage { batch, bookmark: Some(json!("bm")) });
1175            })
1176        }
1177    }
1178
1179    /// A source with no columnar support (default `supports_columnar` = false).
1180    struct RowOnlyMock(Vec<Value>);
1181    #[async_trait]
1182    impl Source for RowOnlyMock {
1183        async fn fetch_with_context(
1184            &self,
1185            _ctx: &HashMap<String, Value>,
1186        ) -> Result<Vec<Value>, FaucetError> {
1187            Ok(self.0.clone())
1188        }
1189    }
1190
1191    /// An identity page stage + its identity batch form — enough to exercise
1192    /// the columnar wiring.
1193    fn identity_stage() -> (TransformStage, Option<crate::stage::PageFnBatchBox>) {
1194        let rows: crate::stage::PageFnBox = Arc::new(Ok);
1195        let batch: crate::stage::PageFnBatchBox = Arc::new(Ok);
1196        (TransformStage::PageFn(rows), Some(batch))
1197    }
1198
1199    #[tokio::test]
1200    async fn columnar_inner_plus_columnar_stage_is_supported_and_streams() {
1201        let inner: Box<dyn Source> =
1202            Box::new(ColumnarMock(vec![json!({"id": 1}), json!({"id": 2})]));
1203        let (stage, batch) = identity_stage();
1204        let wrapped = TransformingSource::new_with_batches(
1205            inner,
1206            vec![stage],
1207            vec![batch],
1208            Labels::for_named("t"),
1209        )
1210        .unwrap();
1211        assert!(wrapped.supports_columnar());
1212        let ctx = HashMap::new();
1213        let mut s = wrapped.stream_batches(&ctx, 0);
1214        let page = s.next().await.unwrap().unwrap();
1215        let rows = record_batch_to_values(&page.batch).unwrap();
1216        assert_eq!(rows.len(), 2);
1217        assert_eq!(page.bookmark, Some(json!("bm")));
1218    }
1219
1220    #[tokio::test]
1221    async fn value_only_stage_disables_columnar() {
1222        // A `Map` stage has no Arrow batch form (batch_fn None) → the whole
1223        // chain drops off the columnar path even though the inner is columnar.
1224        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"FooBar": 1})]));
1225        let wrapped = TransformingSource::new(
1226            inner,
1227            vec![TransformStage::Map(
1228                crate::transform::RecordTransform::KeysCase {
1229                    mode: crate::transform::KeyCaseMode::Snake,
1230                    on_collision: crate::transform::KeyCollision::Error,
1231                },
1232            )],
1233            Labels::for_named("t"),
1234        )
1235        .unwrap();
1236        assert!(!wrapped.supports_columnar());
1237    }
1238
1239    /// #636: a chain of *built-in vectorizable* transforms (no hand-supplied
1240    /// batch fns) over a columnar source keeps the fast path automatically —
1241    /// `new` now derives their Arrow kernels. This is the whole point: a
1242    /// `parquet → select/drop/set → parquet` run no longer drops to `Value`.
1243    #[cfg(all(feature = "transform-drop", feature = "transform-set"))]
1244    #[tokio::test]
1245    async fn built_in_vectorizable_transforms_keep_the_columnar_path() {
1246        use crate::transform::RecordTransform;
1247        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![
1248            json!({"id": 1, "name": "ada", "secret": "x"}),
1249            json!({"id": 2, "name": "grace", "secret": "y"}),
1250        ]));
1251        let mut set_vals = serde_json::Map::new();
1252        set_vals.insert("stage".into(), json!("prod"));
1253        let wrapped = TransformingSource::new(
1254            inner,
1255            vec![
1256                TransformStage::Map(RecordTransform::Drop {
1257                    fields: vec!["secret".into()],
1258                }),
1259                TransformStage::Map(RecordTransform::Set { values: set_vals }),
1260            ],
1261            Labels::for_named("t"),
1262        )
1263        .unwrap();
1264        assert!(
1265            wrapped.supports_columnar(),
1266            "a chain of vectorizable built-ins must stay columnar"
1267        );
1268        let ctx = HashMap::new();
1269        let mut s = wrapped.stream_batches(&ctx, 0);
1270        let page = s.next().await.unwrap().unwrap();
1271        let rows = record_batch_to_values(&page.batch).unwrap();
1272        assert_eq!(rows.len(), 2);
1273        // `drop` removed `secret`, `set` added `stage` — the transforms really
1274        // ran on the columnar path, not just passed through.
1275        assert!(rows[0].get("secret").is_none(), "drop ran: {:?}", rows[0]);
1276        assert_eq!(rows[0]["stage"], json!("prod"), "set ran: {:?}", rows[0]);
1277    }
1278
1279    /// One non-vectorizable transform anywhere in the chain holds the whole
1280    /// chain on the `Value` path — the vectorizable ones must not silently run
1281    /// columnar while the opaque one is skipped.
1282    #[cfg(all(feature = "transform-select", feature = "transform-flatten"))]
1283    #[tokio::test]
1284    async fn a_mixed_chain_with_one_opaque_transform_stays_on_value() {
1285        use crate::transform::RecordTransform;
1286        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"id": 1})]));
1287        let wrapped = TransformingSource::new(
1288            inner,
1289            vec![
1290                TransformStage::Map(RecordTransform::Select {
1291                    fields: vec!["id".into()],
1292                }),
1293                // `flatten` has no kernel → None → whole chain off columnar.
1294                TransformStage::Map(RecordTransform::Flatten {
1295                    separator: "_".into(),
1296                }),
1297            ],
1298            Labels::for_named("t"),
1299        )
1300        .unwrap();
1301        assert!(!wrapped.supports_columnar());
1302    }
1303
1304    #[tokio::test]
1305    async fn columnar_stage_over_row_only_inner_is_disabled() {
1306        let inner: Box<dyn Source> = Box::new(RowOnlyMock(vec![json!({"id": 1})]));
1307        let (stage, batch) = identity_stage();
1308        let wrapped = TransformingSource::new_with_batches(
1309            inner,
1310            vec![stage],
1311            vec![batch],
1312            Labels::for_named("t"),
1313        )
1314        .unwrap();
1315        assert!(!wrapped.supports_columnar(), "inner is not columnar");
1316    }
1317}