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