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(), suffix_keys: Vec::new() })],
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                if let Some(a) = &self.row_accounts {
253                    a.add_transform(n_in, batch.num_rows());
254                }
255                yield crate::columnar::ColumnarPage { batch, bookmark };
256            }
257        })
258    }
259
260    // Everything else reaches the wrapped connector. The convenience fetches
261    // take the trait default so they run through the transforms above; the
262    // native byte path and server-side range digests stay at the default
263    // (off) because both would bypass the transforms.
264    // A stage other than a filter may rename, add, drop or rewrite columns, so
265    // the inner declaration no longer describes the records exactly.
266    async fn declared_columns(
267        &self,
268        context: &HashMap<String, Value>,
269    ) -> Result<Option<crate::declared::DeclaredColumns>, FaucetError> {
270        let declared = self.inner.declared_columns(context).await?;
271        Ok(declared.map(|d| {
272            if self.stages.iter().all(is_filter) {
273                d
274            } else {
275                d.reshaped()
276            }
277        }))
278    }
279
280    crate::forward_source!(inner; except fetch_with_context, fetch_with_context_incremental,
281        stream_pages, supports_columnar, stream_batches, fetch_all, fetch_all_incremental,
282        native_output_formats, stream_native, range_digest, declared_columns);
283}
284
285fn is_filter(stage: &CompiledStage) -> bool {
286    #[cfg(feature = "transform-filter")]
287    if matches!(stage, CompiledStage::Filter(_)) {
288        return true;
289    }
290    let _ = stage;
291    false
292}
293
294#[cfg(test)]
295#[allow(clippy::disallowed_methods)] // tests drive time and raw clients directly
296mod tests {
297    use super::*;
298
299    pub(super) struct OnePage;
300
301    #[async_trait::async_trait]
302    impl crate::Source for OnePage {
303        async fn fetch_with_context(
304            &self,
305            _ctx: &HashMap<String, Value>,
306        ) -> Result<Vec<Value>, FaucetError> {
307            Ok(vec![serde_json::json!({"a": 1})])
308        }
309    }
310
311    fn slow_page_source() -> TransformingSource {
312        let slow: crate::stage::PageFnBox = std::sync::Arc::new(|records: Vec<Value>| {
313            std::thread::sleep(std::time::Duration::from_millis(400));
314            Ok(records)
315        });
316        TransformingSource::new(
317            Box::new(OnePage),
318            vec![TransformStage::PageFn(slow)],
319            Labels::for_named("slow"),
320        )
321        .unwrap()
322    }
323
324    #[tokio::test(flavor = "multi_thread", worker_threads = 1)]
325    async fn a_slow_page_transform_does_not_pin_the_worker() {
326        use std::sync::atomic::{AtomicUsize, Ordering};
327        let ticks = std::sync::Arc::new(AtomicUsize::new(0));
328        let t = ticks.clone();
329        let ticker = tokio::spawn(async move {
330            loop {
331                tokio::time::sleep(std::time::Duration::from_millis(10)).await;
332                t.fetch_add(1, Ordering::SeqCst);
333            }
334        });
335        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
336        let src = slow_page_source();
337        let during = tokio::spawn(async move {
338            let ctx = HashMap::new();
339            let mut pages = src.stream_pages(&ctx, 10);
340            let before = ticks.load(Ordering::SeqCst);
341            let page = pages.next().await.unwrap().unwrap();
342            assert_eq!(page.records.len(), 1);
343            let (records, _) = src.fetch_with_context_incremental(&ctx).await.unwrap();
344            assert_eq!(records.len(), 1);
345            ticks.load(Ordering::SeqCst) - before
346        })
347        .await
348        .unwrap();
349        ticker.abort();
350        assert!(during >= 20, "the ticker starved: {during} ticks");
351    }
352
353    #[tokio::test]
354    async fn a_slow_page_transform_runs_inline_on_a_current_thread_runtime() {
355        let src = slow_page_source();
356        assert_eq!(
357            src.fetch_with_context(&HashMap::new()).await.unwrap().len(),
358            1
359        );
360        assert_eq!(crate::stage::run_blocking(true, || 7), 7);
361    }
362
363    /// A change-stream double with its own multi-table hooks (#731).
364    #[tokio::test]
365    async fn routed_double_fetches_nothing() {
366        use crate::Source as _;
367        assert!(
368            RoutedSource
369                .fetch_with_context(&Default::default())
370                .await
371                .unwrap()
372                .is_empty()
373        );
374    }
375
376    struct RoutedSource;
377
378    #[async_trait::async_trait]
379    impl crate::Source for RoutedSource {
380        async fn fetch_with_context(
381            &self,
382            _ctx: &std::collections::HashMap<String, serde_json::Value>,
383        ) -> Result<Vec<serde_json::Value>, crate::FaucetError> {
384            Ok(Vec::new())
385        }
386
387        fn record_table(&self, record: &serde_json::Value) -> Option<String> {
388            record.get("t")?.as_str().map(str::to_string)
389        }
390
391        fn position_le(&self, a: &serde_json::Value, b: &serde_json::Value) -> Option<bool> {
392            Some(a.as_u64()? <= b.as_u64()?)
393        }
394
395        fn position_min(&self, _positions: &[serde_json::Value]) -> Option<serde_json::Value> {
396            Some(serde_json::json!("inner-min"))
397        }
398
399        fn record_covered(
400            &self,
401            record: &serde_json::Value,
402            position: &serde_json::Value,
403        ) -> Option<bool> {
404            Some(record.get("p")?.as_u64()? < position.as_u64()?)
405        }
406    }
407
408    fn assert_forwards_multi_table_hooks(s: &dyn crate::Source) {
409        assert_eq!(
410            s.record_table(&serde_json::json!({"t": "public.a"}))
411                .as_deref(),
412            Some("public.a")
413        );
414        assert_eq!(
415            s.position_le(&serde_json::json!(1), &serde_json::json!(2)),
416            Some(true)
417        );
418        assert_eq!(
419            s.position_le(&serde_json::json!(3), &serde_json::json!(2)),
420            Some(false)
421        );
422        assert_eq!(
423            s.record_covered(&serde_json::json!({"p": 1}), &serde_json::json!(2)),
424            Some(true)
425        );
426        assert_eq!(
427            s.record_covered(&serde_json::json!({"p": 2}), &serde_json::json!(2)),
428            Some(false)
429        );
430        assert_eq!(
431            s.position_min(&[serde_json::json!(1), serde_json::json!(2)]),
432            Some(serde_json::json!("inner-min"))
433        );
434    }
435
436    #[test]
437    fn multi_table_hooks_are_forwarded_to_the_inner_source() {
438        let wrapped =
439            TransformingSource::new(Box::new(RoutedSource), vec![], Labels::for_named("test"))
440                .unwrap();
441        assert_forwards_multi_table_hooks(&wrapped);
442    }
443
444    #[tokio::test]
445    async fn forwards_every_hook_the_transforms_do_not_change() {
446        let probe = crate::forwarding::HookSource::default();
447        let wrapped =
448            TransformingSource::new(Box::new(probe.clone()), vec![], Labels::for_named("test"))
449                .unwrap();
450        crate::forwarding::assert_source_forwards_every_hook(
451            &wrapped,
452            &probe,
453            &[
454                "fetch_all",
455                "fetch_all_incremental",
456                "native_output_formats",
457                "stream_native",
458                "range_digest",
459            ],
460        )
461        .await;
462        assert!(wrapped.native_output_formats().is_empty());
463    }
464
465    #[derive(Default)]
466    struct ShardedSource {
467        applied: std::sync::Mutex<Vec<String>>,
468    }
469
470    #[async_trait]
471    impl Source for ShardedSource {
472        async fn fetch_with_context(
473            &self,
474            _ctx: &HashMap<String, serde_json::Value>,
475        ) -> Result<Vec<serde_json::Value>, FaucetError> {
476            Ok(vec![])
477        }
478        fn is_shardable(&self) -> bool {
479            true
480        }
481        async fn enumerate_shards(
482            &self,
483            target: usize,
484        ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
485            Ok((0..target)
486                .map(|i| crate::shard::ShardSpec::new(format!("s{i}"), serde_json::json!(i)))
487                .collect())
488        }
489        async fn apply_shard(&self, shard: &crate::shard::ShardSpec) -> Result<(), FaucetError> {
490            self.applied.lock().unwrap().push(shard.id.clone());
491            Ok(())
492        }
493        async fn range_digest(
494            &self,
495            _range: &crate::diff::KeyRange,
496            _key: &str,
497            _columns: &[String],
498        ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
499            Err(FaucetError::Source(
500                "a raw-data digest must not be consulted".into(),
501            ))
502        }
503        fn supports_discover(&self) -> bool {
504            true
505        }
506        async fn discover(&self) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
507            Ok(vec![crate::discover::DatasetDescriptor::new(
508                "orders",
509                "table",
510                serde_json::json!({}),
511            )])
512        }
513    }
514
515    #[tokio::test]
516    async fn shard_and_discover_hooks_reach_the_inner_source() {
517        let inner = Arc::new(ShardedSource::default());
518        struct Shared(Arc<ShardedSource>);
519        #[async_trait]
520        impl Source for Shared {
521            async fn fetch_with_context(
522                &self,
523                ctx: &HashMap<String, serde_json::Value>,
524            ) -> Result<Vec<serde_json::Value>, FaucetError> {
525                self.0.fetch_with_context(ctx).await
526            }
527            fn is_shardable(&self) -> bool {
528                self.0.is_shardable()
529            }
530            async fn enumerate_shards(
531                &self,
532                target: usize,
533            ) -> Result<Vec<crate::shard::ShardSpec>, FaucetError> {
534                self.0.enumerate_shards(target).await
535            }
536            async fn apply_shard(
537                &self,
538                shard: &crate::shard::ShardSpec,
539            ) -> Result<(), FaucetError> {
540                self.0.apply_shard(shard).await
541            }
542            async fn range_digest(
543                &self,
544                range: &crate::diff::KeyRange,
545                key: &str,
546                columns: &[String],
547            ) -> Result<Option<crate::diff::ServerDigest>, FaucetError> {
548                self.0.range_digest(range, key, columns).await
549            }
550            fn supports_discover(&self) -> bool {
551                self.0.supports_discover()
552            }
553            async fn discover(
554                &self,
555            ) -> Result<Vec<crate::discover::DatasetDescriptor>, FaucetError> {
556                self.0.discover().await
557            }
558        }
559        let wrapped = TransformingSource::new(
560            Box::new(Shared(inner.clone())),
561            vec![],
562            Labels::for_named("t"),
563        )
564        .unwrap();
565        assert!(wrapped.is_shardable());
566        let shards = wrapped.enumerate_shards(3).await.unwrap();
567        assert_eq!(shards.len(), 3);
568        wrapped.apply_shard(&shards[1]).await.unwrap();
569        assert_eq!(*inner.applied.lock().unwrap(), vec!["s1".to_string()]);
570        assert!(wrapped.supports_discover());
571        assert_eq!(wrapped.discover().await.unwrap()[0].name, "orders");
572        let digest = wrapped
573            .range_digest(&crate::diff::KeyRange::ALL, "id", &[])
574            .await
575            .unwrap();
576        assert!(
577            digest.is_none(),
578            "transformed rows must be compared client-side, never by a raw-data digest"
579        );
580    }
581    use crate::stage::TransformStage;
582    use crate::transform::{KeyCaseMode, RecordTransform};
583    use serde_json::json;
584    use std::sync::Arc;
585    use std::sync::atomic::{AtomicBool, Ordering};
586
587    struct MockSource(Vec<Value>);
588
589    #[async_trait]
590    impl Source for MockSource {
591        async fn fetch_with_context(
592            &self,
593            _ctx: &HashMap<String, Value>,
594        ) -> Result<Vec<Value>, FaucetError> {
595            Ok(self.0.clone())
596        }
597    }
598
599    #[tokio::test]
600    async fn fetch_with_context_transforms_records() {
601        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
602        let wrapped = TransformingSource::new(
603            inner,
604            vec![TransformStage::Map(RecordTransform::KeysCase {
605                mode: KeyCaseMode::Snake,
606                on_collision: Default::default(),
607                suffix_keys: Vec::new(),
608            })],
609            Labels::for_named("test"),
610        )
611        .expect("compile succeeds");
612        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
613        assert_eq!(out, vec![json!({"foo_bar": 1})]);
614    }
615
616    struct VersionedSource;
617
618    #[async_trait]
619    impl Source for VersionedSource {
620        async fn fetch_with_context(
621            &self,
622            _ctx: &HashMap<String, Value>,
623        ) -> Result<Vec<Value>, FaucetError> {
624            Ok(Vec::new())
625        }
626        fn state_schema(&self) -> u32 {
627            2
628        }
629        fn migrate_state(&self, from: u32, data: Value) -> Result<Value, FaucetError> {
630            Ok(json!({"from": from, "data": data}))
631        }
632    }
633
634    #[test]
635    fn state_versioning_is_forwarded_to_the_inner_source() {
636        let wrapped =
637            TransformingSource::new(Box::new(VersionedSource), vec![], Labels::for_named("test"))
638                .expect("compile succeeds");
639        assert_eq!(wrapped.state_schema(), 2);
640        assert_eq!(
641            wrapped.migrate_state(1, json!("x")).unwrap(),
642            json!({"from": 1, "data": "x"})
643        );
644    }
645
646    struct IncrementalSource {
647        records: Vec<Value>,
648        bookmark: Value,
649    }
650
651    #[async_trait]
652    impl Source for IncrementalSource {
653        async fn fetch_with_context(
654            &self,
655            _ctx: &HashMap<String, Value>,
656        ) -> Result<Vec<Value>, FaucetError> {
657            Ok(self.records.clone())
658        }
659
660        async fn fetch_with_context_incremental(
661            &self,
662            _ctx: &HashMap<String, Value>,
663        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
664            Ok((self.records.clone(), Some(self.bookmark.clone())))
665        }
666    }
667
668    #[tokio::test]
669    async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
670        let inner: Box<dyn Source> = Box::new(IncrementalSource {
671            records: vec![json!({"FooBar": 1})],
672            bookmark: json!("2026-05-28T00:00:00Z"),
673        });
674        let wrapped = TransformingSource::new(
675            inner,
676            vec![TransformStage::Map(RecordTransform::KeysCase {
677                mode: KeyCaseMode::Snake,
678                on_collision: Default::default(),
679                suffix_keys: Vec::new(),
680            })],
681            Labels::for_named("test"),
682        )
683        .unwrap();
684        let (records, bookmark) = wrapped
685            .fetch_with_context_incremental(&HashMap::new())
686            .await
687            .unwrap();
688        assert_eq!(records, vec![json!({"foo_bar": 1})]);
689        assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
690    }
691
692    /// Emits records as N predetermined pages with the bookmark only on the last.
693    /// Overrides `stream_pages` directly so the test catches whether the wrapper
694    /// delegates to the native streaming path (correct) or falls back to the
695    /// chunk-the-buffer default (wrong — the bug we're fixing).
696    struct PagedSource {
697        pages: Vec<Vec<Value>>,
698        final_bookmark: Value,
699    }
700
701    #[async_trait]
702    impl Source for PagedSource {
703        async fn fetch_with_context(
704            &self,
705            _ctx: &HashMap<String, Value>,
706        ) -> Result<Vec<Value>, FaucetError> {
707            Ok(self.pages.iter().flatten().cloned().collect())
708        }
709
710        fn stream_pages<'a>(
711            &'a self,
712            _ctx: &'a HashMap<String, Value>,
713            _batch_size: usize,
714        ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
715        {
716            let pages = self.pages.clone();
717            let bookmark = self.final_bookmark.clone();
718            Box::pin(async_stream::try_stream! {
719                let n = pages.len();
720                for (i, records) in pages.into_iter().enumerate() {
721                    let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
722                    yield StreamPage { records, bookmark: bm };
723                }
724            })
725        }
726    }
727
728    #[tokio::test]
729    async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
730        let inner: Box<dyn Source> = Box::new(PagedSource {
731            pages: vec![
732                vec![json!({"FooBar": 1})],
733                vec![json!({"FooBar": 2})],
734                vec![json!({"FooBar": 3})],
735            ],
736            final_bookmark: json!("v1"),
737        });
738        let wrapped = TransformingSource::new(
739            inner,
740            vec![TransformStage::Map(RecordTransform::KeysCase {
741                mode: KeyCaseMode::Snake,
742                on_collision: Default::default(),
743                suffix_keys: Vec::new(),
744            })],
745            Labels::for_named("test"),
746        )
747        .unwrap();
748
749        let ctx = HashMap::new();
750        let mut stream = wrapped.stream_pages(&ctx, 1000);
751        let mut collected: Vec<StreamPage> = Vec::new();
752        while let Some(page) = stream.next().await {
753            collected.push(page.unwrap());
754        }
755
756        assert_eq!(collected.len(), 3);
757        assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
758        assert!(collected[0].bookmark.is_none());
759        assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
760        assert!(collected[1].bookmark.is_none());
761        assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
762        assert_eq!(collected[2].bookmark, Some(json!("v1")));
763    }
764
765    /// Regression: a 1→1 transform must NOT re-chunk the inner page below the
766    /// size the inner source already chose. A source that yields one large page
767    /// (its config `batch_size` honored) followed by a `keys_case` stage must
768    /// still emit that page as ONE `StreamPage`, not many hint-sized sub-pages —
769    /// otherwise an explicit source `batch_size` is silently defeated whenever a
770    /// transform is present (the cause of 60 tiny BigQuery load jobs instead of
771    /// one).
772    #[tokio::test]
773    async fn stream_pages_does_not_rechunk_large_page_below_inner_size() {
774        let big: Vec<Value> = (0..2500).map(|i| json!({"FooBar": i})).collect();
775        let inner: Box<dyn Source> = Box::new(PagedSource {
776            pages: vec![big],
777            final_bookmark: json!("v1"),
778        });
779        let wrapped = TransformingSource::new(
780            inner,
781            vec![TransformStage::Map(RecordTransform::KeysCase {
782                mode: KeyCaseMode::Snake,
783                on_collision: Default::default(),
784                suffix_keys: Vec::new(),
785            })],
786            Labels::for_named("t"),
787        )
788        .unwrap();
789        let ctx = HashMap::new();
790        // Pipeline hint is the 1000-row default; it must NOT shrink the page.
791        let mut stream = wrapped.stream_pages(&ctx, 1000);
792        let mut pages: Vec<StreamPage> = Vec::new();
793        while let Some(p) = stream.next().await {
794            pages.push(p.unwrap());
795        }
796        assert_eq!(pages.len(), 1, "one inner page must stay one page");
797        assert_eq!(pages[0].records.len(), 2500);
798        assert_eq!(pages[0].records[0], json!({"foo_bar": 0}));
799        assert_eq!(pages[0].bookmark, Some(json!("v1")));
800    }
801
802    #[tokio::test]
803    async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
804        struct EmptyWithBookmark;
805        #[async_trait]
806        impl Source for EmptyWithBookmark {
807            async fn fetch_with_context(
808                &self,
809                _ctx: &HashMap<String, Value>,
810            ) -> Result<Vec<Value>, FaucetError> {
811                Ok(Vec::new())
812            }
813            fn stream_pages<'a>(
814                &'a self,
815                _ctx: &'a HashMap<String, Value>,
816                _batch_size: usize,
817            ) -> Pin<
818                Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
819            > {
820                Box::pin(async_stream::try_stream! {
821                    yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
822                })
823            }
824        }
825        let wrapped = TransformingSource::new(
826            Box::new(EmptyWithBookmark),
827            vec![TransformStage::Map(RecordTransform::KeysCase {
828                mode: KeyCaseMode::Snake,
829                on_collision: Default::default(),
830                suffix_keys: Vec::new(),
831            })],
832            Labels::for_named("test"),
833        )
834        .unwrap();
835        let ctx = HashMap::new();
836        let mut stream = wrapped.stream_pages(&ctx, 1000);
837        let page = stream.next().await.unwrap().unwrap();
838        assert!(page.records.is_empty());
839        assert_eq!(page.bookmark, Some(json!("v1")));
840        assert!(stream.next().await.is_none());
841    }
842
843    struct InstrumentedSource {
844        started: Arc<AtomicBool>,
845    }
846
847    #[async_trait]
848    impl Source for InstrumentedSource {
849        async fn fetch_with_context(
850            &self,
851            _ctx: &HashMap<String, Value>,
852        ) -> Result<Vec<Value>, FaucetError> {
853            Ok(vec![])
854        }
855        fn connector_name(&self) -> &'static str {
856            "instrumented"
857        }
858        fn state_key(&self) -> Option<String> {
859            Some("instrumented::key".to_string())
860        }
861        async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
862            self.started.store(true, Ordering::Relaxed);
863            Ok(())
864        }
865        fn supports_exactly_once(&self) -> bool {
866            true
867        }
868        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
869            Ok(Some(json!("captured")))
870        }
871    }
872
873    struct RecorderProbe(Arc<AtomicBool>);
874
875    #[async_trait]
876    impl Source for RecorderProbe {
877        async fn fetch_with_context(
878            &self,
879            _ctx: &HashMap<String, Value>,
880        ) -> Result<Vec<Value>, FaucetError> {
881            Ok(vec![])
882        }
883        fn set_roundtrip_recorder(&self, _recorder: Arc<crate::observability::RoundtripRecorder>) {
884            self.0.store(true, Ordering::Relaxed);
885        }
886        fn set_run_clock(&self, _now: chrono::DateTime<chrono::Utc>) {
887            self.0.store(true, Ordering::Relaxed);
888        }
889        fn set_cancel(&self, _cancel: tokio_util::sync::CancellationToken) -> bool {
890            self.0.store(true, Ordering::Relaxed);
891            true
892        }
893    }
894
895    #[test]
896    fn cancel_reaches_the_wrapped_source_and_its_answer_comes_back() {
897        let got = Arc::new(AtomicBool::new(false));
898        let wrapped = TransformingSource::new(
899            Box::new(RecorderProbe(got.clone())),
900            vec![],
901            Labels::for_named("test"),
902        )
903        .unwrap();
904        assert!(wrapped.set_cancel(tokio_util::sync::CancellationToken::new()));
905        assert!(got.load(Ordering::Relaxed));
906    }
907
908    #[test]
909    fn run_clock_reaches_the_wrapped_source() {
910        let got = Arc::new(AtomicBool::new(false));
911        let wrapped = TransformingSource::new(
912            Box::new(RecorderProbe(got.clone())),
913            vec![],
914            Labels::for_named("test"),
915        )
916        .unwrap();
917        wrapped.set_run_clock(chrono::Utc::now());
918        assert!(got.load(Ordering::Relaxed));
919    }
920
921    #[test]
922    fn roundtrip_recorder_reaches_the_wrapped_source() {
923        let got = Arc::new(AtomicBool::new(false));
924        let wrapped = TransformingSource::new(
925            Box::new(RecorderProbe(got.clone())),
926            vec![],
927            Labels::for_named("test"),
928        )
929        .unwrap();
930        wrapped.set_roundtrip_recorder(Arc::new(crate::observability::RoundtripRecorder::new(
931            crate::observability::RoundtripSide::Source,
932            "p",
933            "r",
934            "probe",
935        )));
936        assert!(got.load(Ordering::Relaxed));
937    }
938
939    #[tokio::test]
940    async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
941        let started = Arc::new(AtomicBool::new(false));
942        let inner = InstrumentedSource {
943            started: started.clone(),
944        };
945        let wrapped = TransformingSource::new(
946            Box::new(inner),
947            vec![TransformStage::Map(RecordTransform::KeysCase {
948                mode: KeyCaseMode::Snake,
949                on_collision: Default::default(),
950                suffix_keys: Vec::new(),
951            })],
952            Labels::for_named("test"),
953        )
954        .unwrap();
955        assert_eq!(wrapped.connector_name(), "instrumented");
956        assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
957        wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
958        assert!(started.load(Ordering::Relaxed));
959        // Exactly-once capabilities must survive the transform wrap — the
960        // pipeline's mechanism selection reads them through this layer.
961        assert!(wrapped.supports_exactly_once());
962        assert!(!wrapped.consumes_destructively());
963        assert_eq!(
964            wrapped.replay_guarantee(),
965            crate::idempotency::ReplayGuarantee::Deterministic
966        );
967        assert_eq!(
968            wrapped.capture_resume_position().await.unwrap(),
969            Some(json!("captured"))
970        );
971        assert_eq!(wrapped.lag().await.unwrap(), None);
972    }
973
974    #[tokio::test]
975    async fn new_fails_fast_on_invalid_regex() {
976        let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
977        let result = TransformingSource::new(
978            inner,
979            vec![TransformStage::Map(RecordTransform::RenameKeys {
980                pattern: "[invalid".to_string(),
981                replacement: "x".to_string(),
982            })],
983            Labels::for_named("test"),
984        );
985        let err = match result {
986            Ok(_) => panic!("invalid regex must fail at new()"),
987            Err(e) => e,
988        };
989        assert!(matches!(err, FaucetError::Transform(_)));
990    }
991
992    #[tokio::test]
993    async fn custom_closure_transform_runs() {
994        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
995        let wrapped = TransformingSource::new(
996            inner,
997            vec![TransformStage::Map(RecordTransform::custom(
998                |mut record| {
999                    if let Some(obj) = record.as_object_mut() {
1000                        obj.insert("added".to_string(), json!(true));
1001                    }
1002                    record
1003                },
1004            ))],
1005            Labels::for_named("test"),
1006        )
1007        .unwrap();
1008        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
1009        assert_eq!(out, vec![json!({"x": 1, "added": true})]);
1010    }
1011
1012    #[tokio::test]
1013    async fn usable_as_boxed_dyn_source() {
1014        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
1015        let wrapped: Box<dyn Source> = Box::new(
1016            TransformingSource::new(
1017                inner,
1018                vec![TransformStage::Map(RecordTransform::KeysCase {
1019                    mode: KeyCaseMode::Snake,
1020                    on_collision: Default::default(),
1021                    suffix_keys: Vec::new(),
1022                })],
1023                Labels::for_named("test"),
1024            )
1025            .unwrap(),
1026        );
1027        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
1028        assert_eq!(out, vec![json!({"foo_bar": 1})]);
1029    }
1030
1031    /// A source that emits a single page with the given records and bookmark.
1032    ///
1033    /// Only the bookmark-forwarding tests construct it, and those are gated on
1034    /// a transform feature, so a build without one compiles it unused.
1035    #[allow(dead_code)]
1036    struct OnePageSource {
1037        records: Vec<Value>,
1038        bookmark: Option<Value>,
1039    }
1040
1041    #[async_trait]
1042    impl Source for OnePageSource {
1043        async fn fetch_with_context(
1044            &self,
1045            _ctx: &HashMap<String, Value>,
1046        ) -> Result<Vec<Value>, FaucetError> {
1047            Ok(self.records.clone())
1048        }
1049        async fn fetch_with_context_incremental(
1050            &self,
1051            _ctx: &HashMap<String, Value>,
1052        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
1053            Ok((self.records.clone(), self.bookmark.clone()))
1054        }
1055        fn stream_pages<'a>(
1056            &'a self,
1057            _ctx: &'a HashMap<String, Value>,
1058            _batch_size: usize,
1059        ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
1060            let page = StreamPage {
1061                records: self.records.clone(),
1062                bookmark: self.bookmark.clone(),
1063            };
1064            Box::pin(async_stream::stream! { yield Ok(page); })
1065        }
1066    }
1067
1068    #[cfg(feature = "transform-explode")]
1069    fn explode_stage() -> TransformStage {
1070        TransformStage::Explode(crate::stage::ExplodeSpec {
1071            path: "items".to_owned(),
1072            prefix: None,
1073            separator: "_".to_owned(),
1074            on_missing: crate::stage::OnMissing::Drop,
1075        })
1076    }
1077
1078    /// Build N records each with a 10-element `items` array, so explode 10×s them.
1079    #[cfg(feature = "transform-explode")]
1080    fn explode_10x_records(n: usize) -> Vec<Value> {
1081        (0..n)
1082            .map(|i| {
1083                json!({
1084                    "id": i,
1085                    "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
1086                })
1087            })
1088            .collect()
1089    }
1090
1091    #[cfg(feature = "transform-explode")]
1092    #[tokio::test]
1093    async fn stream_pages_never_splits_a_bookmark_carrying_page() {
1094        let inner: Box<dyn Source> = Box::new(OnePageSource {
1095            records: explode_10x_records(100), // 100 → 1000 after explode
1096            bookmark: Some(json!("bm")),
1097        });
1098        let wrapped =
1099            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1100        let ctx = HashMap::new();
1101        let mut stream = wrapped.stream_pages(&ctx, 200);
1102        let mut sub_pages: Vec<StreamPage> = Vec::new();
1103        while let Some(p) = stream.next().await {
1104            sub_pages.push(p.unwrap());
1105        }
1106        assert_eq!(
1107            sub_pages.len(),
1108            1,
1109            "a bookmarked page stays one commit unit"
1110        );
1111        assert_eq!(sub_pages[0].records.len(), 1000);
1112        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1113    }
1114
1115    #[cfg(feature = "transform-explode")]
1116    #[tokio::test]
1117    async fn stream_pages_rechunks_a_grown_page_without_a_bookmark() {
1118        let inner: Box<dyn Source> = Box::new(OnePageSource {
1119            records: explode_10x_records(100),
1120            bookmark: None,
1121        });
1122        let wrapped =
1123            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1124        let ctx = HashMap::new();
1125        let mut stream = wrapped.stream_pages(&ctx, 200);
1126        let mut sub_pages: Vec<StreamPage> = Vec::new();
1127        while let Some(p) = stream.next().await {
1128            sub_pages.push(p.unwrap());
1129        }
1130        assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
1131        assert!(sub_pages.iter().all(|p| p.records.len() == 200));
1132        assert!(sub_pages.iter().all(|p| p.bookmark.is_none()));
1133    }
1134
1135    #[cfg(feature = "transform-explode")]
1136    #[tokio::test]
1137    async fn stream_pages_batch_size_zero_emits_one_page() {
1138        let inner: Box<dyn Source> = Box::new(OnePageSource {
1139            records: explode_10x_records(10), // 10 → 100 after explode
1140            bookmark: Some(json!("bm")),
1141        });
1142        let wrapped =
1143            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
1144        let ctx = HashMap::new();
1145        let mut stream = wrapped.stream_pages(&ctx, 0);
1146        let mut sub_pages: Vec<StreamPage> = Vec::new();
1147        while let Some(p) = stream.next().await {
1148            sub_pages.push(p.unwrap());
1149        }
1150        assert_eq!(sub_pages.len(), 1, "batch_size=0 means one sub-page");
1151        assert_eq!(sub_pages[0].records.len(), 100);
1152        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1153    }
1154
1155    #[cfg(feature = "transform-filter")]
1156    #[tokio::test]
1157    async fn stream_pages_filter_drops_all_still_yields_bookmark() {
1158        let inner: Box<dyn Source> = Box::new(OnePageSource {
1159            records: vec![json!({"deleted": true}), json!({"deleted": true})],
1160            bookmark: Some(json!("bm")),
1161        });
1162        let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
1163            path: "deleted".to_owned(),
1164            op: crate::stage::FilterOp::Ne,
1165            value: Some(json!(true)),
1166        });
1167        let wrapped =
1168            TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
1169        let ctx = HashMap::new();
1170        let mut stream = wrapped.stream_pages(&ctx, 100);
1171        let mut sub_pages: Vec<StreamPage> = Vec::new();
1172        while let Some(p) = stream.next().await {
1173            sub_pages.push(p.unwrap());
1174        }
1175        assert_eq!(sub_pages.len(), 1);
1176        assert!(sub_pages[0].records.is_empty());
1177        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
1178    }
1179}
1180
1181#[cfg(all(test, feature = "arrow"))]
1182mod columnar_tests {
1183    use super::*;
1184    use crate::columnar::{ColumnarPage, record_batch_to_values, values_to_record_batch_inferred};
1185    use crate::stage::TransformStage;
1186    use serde_json::json;
1187    use std::sync::Arc;
1188
1189    /// A source that emits one Arrow batch (columnar-capable).
1190    struct ColumnarMock(Vec<Value>);
1191    #[async_trait]
1192    impl Source for ColumnarMock {
1193        async fn fetch_with_context(
1194            &self,
1195            _ctx: &HashMap<String, Value>,
1196        ) -> Result<Vec<Value>, FaucetError> {
1197            Ok(self.0.clone())
1198        }
1199        fn supports_columnar(&self) -> bool {
1200            true
1201        }
1202        fn stream_batches<'a>(
1203            &'a self,
1204            _ctx: &'a HashMap<String, Value>,
1205            _bs: usize,
1206        ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'a>> {
1207            let batch = values_to_record_batch_inferred(&self.0).unwrap();
1208            Box::pin(async_stream::stream! {
1209                yield Ok(ColumnarPage { batch, bookmark: Some(json!("bm")) });
1210            })
1211        }
1212    }
1213
1214    /// A source with no columnar support (default `supports_columnar` = false).
1215    struct RowOnlyMock(Vec<Value>);
1216    #[async_trait]
1217    impl Source for RowOnlyMock {
1218        async fn fetch_with_context(
1219            &self,
1220            _ctx: &HashMap<String, Value>,
1221        ) -> Result<Vec<Value>, FaucetError> {
1222            Ok(self.0.clone())
1223        }
1224    }
1225
1226    /// An identity page stage + its identity batch form — enough to exercise
1227    /// the columnar wiring.
1228    fn identity_stage() -> (TransformStage, Option<crate::stage::PageFnBatchBox>) {
1229        let rows: crate::stage::PageFnBox = Arc::new(Ok);
1230        let batch: crate::stage::PageFnBatchBox = Arc::new(Ok);
1231        (TransformStage::PageFn(rows), Some(batch))
1232    }
1233
1234    #[tokio::test]
1235    async fn columnar_inner_plus_columnar_stage_is_supported_and_streams() {
1236        let inner: Box<dyn Source> =
1237            Box::new(ColumnarMock(vec![json!({"id": 1}), json!({"id": 2})]));
1238        let (stage, batch) = identity_stage();
1239        let wrapped = TransformingSource::new_with_batches(
1240            inner,
1241            vec![stage],
1242            vec![batch],
1243            Labels::for_named("t"),
1244        )
1245        .unwrap();
1246        assert!(wrapped.supports_columnar());
1247        let ctx = HashMap::new();
1248        let mut s = wrapped.stream_batches(&ctx, 0);
1249        let page = s.next().await.unwrap().unwrap();
1250        let rows = record_batch_to_values(&page.batch).unwrap();
1251        assert_eq!(rows.len(), 2);
1252        assert_eq!(page.bookmark, Some(json!("bm")));
1253    }
1254
1255    #[tokio::test]
1256    async fn columnar_path_feeds_row_accounts() {
1257        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![
1258            json!({"id": 1}),
1259            json!({"id": 2}),
1260            json!({"id": 3}),
1261        ]));
1262        let rows: crate::stage::PageFnBox = Arc::new(Ok);
1263        let keep_first: crate::stage::PageFnBatchBox = Arc::new(|b| Ok(b.slice(0, 1)));
1264        let accounts = Arc::new(crate::accounting::RowAccounts::default());
1265        let wrapped = TransformingSource::new_with_batches(
1266            inner,
1267            vec![TransformStage::PageFn(rows)],
1268            vec![Some(keep_first)],
1269            Labels::for_named("t"),
1270        )
1271        .unwrap()
1272        .with_row_accounts(Arc::clone(&accounts));
1273        let ctx = HashMap::new();
1274        let mut s = wrapped.stream_batches(&ctx, 0);
1275        while let Some(p) = s.next().await {
1276            p.unwrap();
1277        }
1278        let snap = accounts.snapshot();
1279        assert_eq!((snap.transform_in, snap.transform_out), (3, 1));
1280        assert_eq!(snap.filtered(), 2);
1281    }
1282
1283    #[tokio::test]
1284    async fn value_only_stage_disables_columnar() {
1285        // A `Map` stage has no Arrow batch form (batch_fn None) → the whole
1286        // chain drops off the columnar path even though the inner is columnar.
1287        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"FooBar": 1})]));
1288        let wrapped = TransformingSource::new(
1289            inner,
1290            vec![TransformStage::Map(
1291                crate::transform::RecordTransform::KeysCase {
1292                    mode: crate::transform::KeyCaseMode::Snake,
1293                    on_collision: crate::transform::KeyCollision::Error,
1294                    suffix_keys: Vec::new(),
1295                },
1296            )],
1297            Labels::for_named("t"),
1298        )
1299        .unwrap();
1300        assert!(!wrapped.supports_columnar());
1301    }
1302
1303    /// #636: a chain of *built-in vectorizable* transforms (no hand-supplied
1304    /// batch fns) over a columnar source keeps the fast path automatically —
1305    /// `new` now derives their Arrow kernels. This is the whole point: a
1306    /// `parquet → select/drop/set → parquet` run no longer drops to `Value`.
1307    #[cfg(all(feature = "transform-drop", feature = "transform-set"))]
1308    #[tokio::test]
1309    async fn built_in_vectorizable_transforms_keep_the_columnar_path() {
1310        use crate::transform::RecordTransform;
1311        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![
1312            json!({"id": 1, "name": "ada", "secret": "x"}),
1313            json!({"id": 2, "name": "grace", "secret": "y"}),
1314        ]));
1315        let mut set_vals = serde_json::Map::new();
1316        set_vals.insert("stage".into(), json!("prod"));
1317        let wrapped = TransformingSource::new(
1318            inner,
1319            vec![
1320                TransformStage::Map(RecordTransform::Drop {
1321                    fields: vec!["secret".into()],
1322                }),
1323                TransformStage::Map(RecordTransform::Set { values: set_vals }),
1324            ],
1325            Labels::for_named("t"),
1326        )
1327        .unwrap();
1328        assert!(
1329            wrapped.supports_columnar(),
1330            "a chain of vectorizable built-ins must stay columnar"
1331        );
1332        let ctx = HashMap::new();
1333        let mut s = wrapped.stream_batches(&ctx, 0);
1334        let page = s.next().await.unwrap().unwrap();
1335        let rows = record_batch_to_values(&page.batch).unwrap();
1336        assert_eq!(rows.len(), 2);
1337        // `drop` removed `secret`, `set` added `stage` — the transforms really
1338        // ran on the columnar path, not just passed through.
1339        assert!(rows[0].get("secret").is_none(), "drop ran: {:?}", rows[0]);
1340        assert_eq!(rows[0]["stage"], json!("prod"), "set ran: {:?}", rows[0]);
1341    }
1342
1343    /// One non-vectorizable transform anywhere in the chain holds the whole
1344    /// chain on the `Value` path — the vectorizable ones must not silently run
1345    /// columnar while the opaque one is skipped.
1346    #[cfg(all(feature = "transform-select", feature = "transform-flatten"))]
1347    #[tokio::test]
1348    async fn a_mixed_chain_with_one_opaque_transform_stays_on_value() {
1349        use crate::transform::RecordTransform;
1350        let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"id": 1})]));
1351        let wrapped = TransformingSource::new(
1352            inner,
1353            vec![
1354                TransformStage::Map(RecordTransform::Select {
1355                    fields: vec!["id".into()],
1356                }),
1357                // `flatten` has no kernel → None → whole chain off columnar.
1358                TransformStage::Map(RecordTransform::Flatten {
1359                    separator: "_".into(),
1360                }),
1361            ],
1362            Labels::for_named("t"),
1363        )
1364        .unwrap();
1365        assert!(!wrapped.supports_columnar());
1366    }
1367
1368    #[tokio::test]
1369    async fn columnar_stage_over_row_only_inner_is_disabled() {
1370        let inner: Box<dyn Source> = Box::new(RowOnlyMock(vec![json!({"id": 1})]));
1371        let (stage, batch) = identity_stage();
1372        let wrapped = TransformingSource::new_with_batches(
1373            inner,
1374            vec![stage],
1375            vec![batch],
1376            Labels::for_named("t"),
1377        )
1378        .unwrap();
1379        assert!(!wrapped.supports_columnar(), "inner is not columnar");
1380    }
1381
1382    #[tokio::test]
1383    async fn a_reshaping_stage_marks_the_declaration_reshaped() {
1384        struct Declares;
1385        #[async_trait::async_trait]
1386        impl crate::Source for Declares {
1387            async fn fetch_with_context(
1388                &self,
1389                _ctx: &HashMap<String, Value>,
1390            ) -> Result<Vec<Value>, FaucetError> {
1391                Ok(vec![])
1392            }
1393            async fn declared_columns(
1394                &self,
1395                _ctx: &HashMap<String, Value>,
1396            ) -> Result<Option<crate::DeclaredColumns>, FaucetError> {
1397                Ok(Some(crate::DeclaredColumns::new([
1398                    crate::DeclaredColumn::new("a", crate::LogicalType::Int64, true),
1399                ])))
1400            }
1401        }
1402        let ctx = HashMap::new();
1403        let none =
1404            TransformingSource::new(Box::new(Declares), vec![], Labels::for_named("t")).unwrap();
1405        let d = none.declared_columns(&ctx).await.unwrap().unwrap();
1406        assert!(d.is_complete());
1407        let mapped = TransformingSource::new(
1408            Box::new(Declares),
1409            vec![TransformStage::Custom(std::sync::Arc::new(|v| vec![v]))],
1410            Labels::for_named("t"),
1411        )
1412        .unwrap();
1413        let d = mapped.declared_columns(&ctx).await.unwrap().unwrap();
1414        assert!(!d.is_complete());
1415        assert!(d.get("a").is_some());
1416        let plain = TransformingSource::new(
1417            Box::new(super::tests::OnePage),
1418            vec![],
1419            Labels::for_named("t"),
1420        )
1421        .unwrap();
1422        assert!(plain.declared_columns(&ctx).await.unwrap().is_none());
1423    }
1424}