Skip to main content

faucet_core/
transforming_source.rs

1//! Wrap any [`Source`] with a fixed list of [`TransformStage`]s applied to
2//! every emitted record. The canonical way for library callers to attach
3//! stages (transforms wrapped via [`TransformStage::Map`], plus `Filter` /
4//! `Explode` / `Custom`); the CLI uses this same type internally.
5
6use crate::error::FaucetError;
7use crate::observability::{Labels, instrumented_apply_stages};
8use crate::pipeline::StreamPage;
9use crate::stage::{CompiledStage, TransformStage, compile_stage};
10use crate::traits::Source;
11use async_trait::async_trait;
12use futures::StreamExt;
13use futures_core::Stream;
14use serde_json::Value;
15use std::collections::HashMap;
16use std::pin::Pin;
17
18/// Source decorator that applies a fixed list of compiled stages to every
19/// record. Emits `faucet_transform_*` metrics per page via
20/// [`instrumented_apply_stages`].
21///
22/// # Example
23///
24/// ```no_run
25/// use faucet_core::{RecordTransform, Source, TransformingSource};
26/// use faucet_core::observability::Labels;
27/// use faucet_core::stage::TransformStage;
28/// use faucet_core::transform::KeyCaseMode;
29///
30/// # fn build_inner() -> Box<dyn Source> { unimplemented!() }
31/// let inner: Box<dyn Source> = build_inner();
32/// let wrapped = TransformingSource::new(
33///     inner,
34///     vec![TransformStage::Map(RecordTransform::KeysCase { mode: KeyCaseMode::Snake })],
35///     Labels::for_named("rest"),
36/// ).unwrap();
37/// ```
38pub struct TransformingSource {
39    inner: Box<dyn Source>,
40    stages: Vec<CompiledStage>,
41    labels: Labels,
42}
43
44impl TransformingSource {
45    /// Compile `stages` and wrap `inner`. Returns
46    /// [`FaucetError::Transform`] if any stage's compilation fails (e.g.
47    /// invalid regex in `RenameKeys`).
48    pub fn new(
49        inner: Box<dyn Source>,
50        stages: Vec<TransformStage>,
51        labels: Labels,
52    ) -> Result<Self, FaucetError> {
53        let compiled = stages
54            .iter()
55            .map(compile_stage)
56            .collect::<Result<Vec<_>, _>>()?;
57        Ok(Self {
58            inner,
59            stages: compiled,
60            labels,
61        })
62    }
63}
64
65#[async_trait]
66impl Source for TransformingSource {
67    async fn fetch_with_context(
68        &self,
69        ctx: &HashMap<String, Value>,
70    ) -> Result<Vec<Value>, FaucetError> {
71        let records = self.inner.fetch_with_context(ctx).await?;
72        instrumented_apply_stages(records, &self.stages, &self.labels)
73    }
74
75    async fn fetch_with_context_incremental(
76        &self,
77        ctx: &HashMap<String, Value>,
78    ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
79        let (records, bookmark) = self.inner.fetch_with_context_incremental(ctx).await?;
80        let transformed = instrumented_apply_stages(records, &self.stages, &self.labels)?;
81        Ok((transformed, bookmark))
82    }
83
84    fn stream_pages<'a>(
85        &'a self,
86        ctx: &'a HashMap<String, Value>,
87        batch_size: usize,
88    ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
89        Box::pin(async_stream::try_stream! {
90            let mut pages = self.inner.stream_pages(ctx, batch_size);
91            while let Some(page) = pages.next().await {
92                let page = page?;
93                let out = instrumented_apply_stages(
94                    page.records, &self.stages, &self.labels,
95                )?;
96                if out.is_empty() {
97                    yield StreamPage { records: vec![], bookmark: page.bookmark };
98                    continue;
99                }
100                if batch_size == 0 {
101                    yield StreamPage { records: out, bookmark: page.bookmark };
102                    continue;
103                }
104                let total = out.len();
105                let mut start = 0usize;
106                while start < total {
107                    let end = std::cmp::min(start + batch_size, total);
108                    let is_last = end == total;
109                    let chunk: Vec<Value> = out[start..end].to_vec();
110                    yield StreamPage {
111                        records: chunk,
112                        bookmark: if is_last { page.bookmark.clone() } else { None },
113                    };
114                    start = end;
115                }
116            }
117        })
118    }
119
120    fn state_key(&self) -> Option<String> {
121        self.inner.state_key()
122    }
123
124    async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
125        self.inner.apply_start_bookmark(bookmark).await
126    }
127
128    fn supports_exactly_once(&self) -> bool {
129        self.inner.supports_exactly_once()
130    }
131
132    fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
133        self.inner.replay_guarantee()
134    }
135
136    async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
137        self.inner.capture_resume_position().await
138    }
139
140    fn connector_name(&self) -> &'static str {
141        self.inner.connector_name()
142    }
143
144    fn dataset_uri(&self) -> String {
145        // Forward the wrapped connector's identity — without this, lineage and
146        // the Data Movement Catalog would see the default
147        // `<connector>://unknown` whenever transforms are attached.
148        self.inner.dataset_uri()
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::stage::TransformStage;
156    use crate::transform::{KeyCaseMode, RecordTransform};
157    use serde_json::json;
158    use std::sync::Arc;
159    use std::sync::atomic::{AtomicBool, Ordering};
160
161    struct MockSource(Vec<Value>);
162
163    #[async_trait]
164    impl Source for MockSource {
165        async fn fetch_with_context(
166            &self,
167            _ctx: &HashMap<String, Value>,
168        ) -> Result<Vec<Value>, FaucetError> {
169            Ok(self.0.clone())
170        }
171    }
172
173    #[tokio::test]
174    async fn fetch_with_context_transforms_records() {
175        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
176        let wrapped = TransformingSource::new(
177            inner,
178            vec![TransformStage::Map(RecordTransform::KeysCase {
179                mode: KeyCaseMode::Snake,
180            })],
181            Labels::for_named("test"),
182        )
183        .expect("compile succeeds");
184        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
185        assert_eq!(out, vec![json!({"foo_bar": 1})]);
186    }
187
188    struct IncrementalSource {
189        records: Vec<Value>,
190        bookmark: Value,
191    }
192
193    #[async_trait]
194    impl Source for IncrementalSource {
195        async fn fetch_with_context(
196            &self,
197            _ctx: &HashMap<String, Value>,
198        ) -> Result<Vec<Value>, FaucetError> {
199            Ok(self.records.clone())
200        }
201
202        async fn fetch_with_context_incremental(
203            &self,
204            _ctx: &HashMap<String, Value>,
205        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
206            Ok((self.records.clone(), Some(self.bookmark.clone())))
207        }
208    }
209
210    #[tokio::test]
211    async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
212        let inner: Box<dyn Source> = Box::new(IncrementalSource {
213            records: vec![json!({"FooBar": 1})],
214            bookmark: json!("2026-05-28T00:00:00Z"),
215        });
216        let wrapped = TransformingSource::new(
217            inner,
218            vec![TransformStage::Map(RecordTransform::KeysCase {
219                mode: KeyCaseMode::Snake,
220            })],
221            Labels::for_named("test"),
222        )
223        .unwrap();
224        let (records, bookmark) = wrapped
225            .fetch_with_context_incremental(&HashMap::new())
226            .await
227            .unwrap();
228        assert_eq!(records, vec![json!({"foo_bar": 1})]);
229        assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
230    }
231
232    /// Emits records as N predetermined pages with the bookmark only on the last.
233    /// Overrides `stream_pages` directly so the test catches whether the wrapper
234    /// delegates to the native streaming path (correct) or falls back to the
235    /// chunk-the-buffer default (wrong — the bug we're fixing).
236    struct PagedSource {
237        pages: Vec<Vec<Value>>,
238        final_bookmark: Value,
239    }
240
241    #[async_trait]
242    impl Source for PagedSource {
243        async fn fetch_with_context(
244            &self,
245            _ctx: &HashMap<String, Value>,
246        ) -> Result<Vec<Value>, FaucetError> {
247            Ok(self.pages.iter().flatten().cloned().collect())
248        }
249
250        fn stream_pages<'a>(
251            &'a self,
252            _ctx: &'a HashMap<String, Value>,
253            _batch_size: usize,
254        ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
255        {
256            let pages = self.pages.clone();
257            let bookmark = self.final_bookmark.clone();
258            Box::pin(async_stream::try_stream! {
259                let n = pages.len();
260                for (i, records) in pages.into_iter().enumerate() {
261                    let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
262                    yield StreamPage { records, bookmark: bm };
263                }
264            })
265        }
266    }
267
268    #[tokio::test]
269    async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
270        let inner: Box<dyn Source> = Box::new(PagedSource {
271            pages: vec![
272                vec![json!({"FooBar": 1})],
273                vec![json!({"FooBar": 2})],
274                vec![json!({"FooBar": 3})],
275            ],
276            final_bookmark: json!("v1"),
277        });
278        let wrapped = TransformingSource::new(
279            inner,
280            vec![TransformStage::Map(RecordTransform::KeysCase {
281                mode: KeyCaseMode::Snake,
282            })],
283            Labels::for_named("test"),
284        )
285        .unwrap();
286
287        let ctx = HashMap::new();
288        let mut stream = wrapped.stream_pages(&ctx, 1000);
289        let mut collected: Vec<StreamPage> = Vec::new();
290        while let Some(page) = stream.next().await {
291            collected.push(page.unwrap());
292        }
293
294        assert_eq!(collected.len(), 3);
295        assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
296        assert!(collected[0].bookmark.is_none());
297        assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
298        assert!(collected[1].bookmark.is_none());
299        assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
300        assert_eq!(collected[2].bookmark, Some(json!("v1")));
301    }
302
303    #[tokio::test]
304    async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
305        struct EmptyWithBookmark;
306        #[async_trait]
307        impl Source for EmptyWithBookmark {
308            async fn fetch_with_context(
309                &self,
310                _ctx: &HashMap<String, Value>,
311            ) -> Result<Vec<Value>, FaucetError> {
312                Ok(Vec::new())
313            }
314            fn stream_pages<'a>(
315                &'a self,
316                _ctx: &'a HashMap<String, Value>,
317                _batch_size: usize,
318            ) -> Pin<
319                Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
320            > {
321                Box::pin(async_stream::try_stream! {
322                    yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
323                })
324            }
325        }
326        let wrapped = TransformingSource::new(
327            Box::new(EmptyWithBookmark),
328            vec![TransformStage::Map(RecordTransform::KeysCase {
329                mode: KeyCaseMode::Snake,
330            })],
331            Labels::for_named("test"),
332        )
333        .unwrap();
334        let ctx = HashMap::new();
335        let mut stream = wrapped.stream_pages(&ctx, 1000);
336        let page = stream.next().await.unwrap().unwrap();
337        assert!(page.records.is_empty());
338        assert_eq!(page.bookmark, Some(json!("v1")));
339        assert!(stream.next().await.is_none());
340    }
341
342    struct InstrumentedSource {
343        started: Arc<AtomicBool>,
344    }
345
346    #[async_trait]
347    impl Source for InstrumentedSource {
348        async fn fetch_with_context(
349            &self,
350            _ctx: &HashMap<String, Value>,
351        ) -> Result<Vec<Value>, FaucetError> {
352            Ok(vec![])
353        }
354        fn connector_name(&self) -> &'static str {
355            "instrumented"
356        }
357        fn state_key(&self) -> Option<String> {
358            Some("instrumented::key".to_string())
359        }
360        async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
361            self.started.store(true, Ordering::Relaxed);
362            Ok(())
363        }
364        fn supports_exactly_once(&self) -> bool {
365            true
366        }
367        async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
368            Ok(Some(json!("captured")))
369        }
370    }
371
372    #[tokio::test]
373    async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
374        let started = Arc::new(AtomicBool::new(false));
375        let inner = InstrumentedSource {
376            started: started.clone(),
377        };
378        let wrapped = TransformingSource::new(
379            Box::new(inner),
380            vec![TransformStage::Map(RecordTransform::KeysCase {
381                mode: KeyCaseMode::Snake,
382            })],
383            Labels::for_named("test"),
384        )
385        .unwrap();
386        assert_eq!(wrapped.connector_name(), "instrumented");
387        assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
388        wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
389        assert!(started.load(Ordering::Relaxed));
390        // Exactly-once capabilities must survive the transform wrap — the
391        // pipeline's mechanism selection reads them through this layer.
392        assert!(wrapped.supports_exactly_once());
393        assert_eq!(
394            wrapped.replay_guarantee(),
395            crate::idempotency::ReplayGuarantee::Deterministic
396        );
397        assert_eq!(
398            wrapped.capture_resume_position().await.unwrap(),
399            Some(json!("captured"))
400        );
401    }
402
403    #[tokio::test]
404    async fn new_fails_fast_on_invalid_regex() {
405        let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
406        let result = TransformingSource::new(
407            inner,
408            vec![TransformStage::Map(RecordTransform::RenameKeys {
409                pattern: "[invalid".to_string(),
410                replacement: "x".to_string(),
411            })],
412            Labels::for_named("test"),
413        );
414        let err = match result {
415            Ok(_) => panic!("invalid regex must fail at new()"),
416            Err(e) => e,
417        };
418        assert!(matches!(err, FaucetError::Transform(_)));
419    }
420
421    #[tokio::test]
422    async fn custom_closure_transform_runs() {
423        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
424        let wrapped = TransformingSource::new(
425            inner,
426            vec![TransformStage::Map(RecordTransform::custom(
427                |mut record| {
428                    if let Some(obj) = record.as_object_mut() {
429                        obj.insert("added".to_string(), json!(true));
430                    }
431                    record
432                },
433            ))],
434            Labels::for_named("test"),
435        )
436        .unwrap();
437        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
438        assert_eq!(out, vec![json!({"x": 1, "added": true})]);
439    }
440
441    #[tokio::test]
442    async fn usable_as_boxed_dyn_source() {
443        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
444        let wrapped: Box<dyn Source> = Box::new(
445            TransformingSource::new(
446                inner,
447                vec![TransformStage::Map(RecordTransform::KeysCase {
448                    mode: KeyCaseMode::Snake,
449                })],
450                Labels::for_named("test"),
451            )
452            .unwrap(),
453        );
454        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
455        assert_eq!(out, vec![json!({"foo_bar": 1})]);
456    }
457
458    /// A source that emits a single page with the given records and bookmark.
459    struct OnePageSource {
460        records: Vec<Value>,
461        bookmark: Option<Value>,
462    }
463
464    #[async_trait]
465    impl Source for OnePageSource {
466        async fn fetch_with_context(
467            &self,
468            _ctx: &HashMap<String, Value>,
469        ) -> Result<Vec<Value>, FaucetError> {
470            Ok(self.records.clone())
471        }
472        async fn fetch_with_context_incremental(
473            &self,
474            _ctx: &HashMap<String, Value>,
475        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
476            Ok((self.records.clone(), self.bookmark.clone()))
477        }
478        fn stream_pages<'a>(
479            &'a self,
480            _ctx: &'a HashMap<String, Value>,
481            _batch_size: usize,
482        ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
483            let page = StreamPage {
484                records: self.records.clone(),
485                bookmark: self.bookmark.clone(),
486            };
487            Box::pin(async_stream::stream! { yield Ok(page); })
488        }
489    }
490
491    #[cfg(feature = "transform-explode")]
492    fn explode_stage() -> TransformStage {
493        TransformStage::Explode(crate::stage::ExplodeSpec {
494            path: "items".to_owned(),
495            prefix: None,
496            separator: "_".to_owned(),
497            on_missing: crate::stage::OnMissing::Drop,
498        })
499    }
500
501    /// Build N records each with a 10-element `items` array, so explode 10×s them.
502    #[cfg(feature = "transform-explode")]
503    fn explode_10x_records(n: usize) -> Vec<Value> {
504        (0..n)
505            .map(|i| {
506                json!({
507                    "id": i,
508                    "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
509                })
510            })
511            .collect()
512    }
513
514    #[cfg(feature = "transform-explode")]
515    #[tokio::test]
516    async fn stream_pages_rechunks_explosion_with_bookmark_on_last() {
517        let inner: Box<dyn Source> = Box::new(OnePageSource {
518            records: explode_10x_records(100), // 100 → 1000 after explode
519            bookmark: Some(json!("bm")),
520        });
521        let wrapped =
522            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
523        let ctx = HashMap::new();
524        let mut stream = wrapped.stream_pages(&ctx, 200);
525        let mut sub_pages: Vec<StreamPage> = Vec::new();
526        while let Some(p) = stream.next().await {
527            sub_pages.push(p.unwrap());
528        }
529        assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
530        for (i, p) in sub_pages.iter().enumerate() {
531            assert_eq!(p.records.len(), 200, "sub-page {i} should be size 200");
532            if i < 4 {
533                assert!(
534                    p.bookmark.is_none(),
535                    "non-final sub-page {i} carries no bookmark"
536                );
537            } else {
538                assert_eq!(p.bookmark, Some(json!("bm")), "final sub-page has bookmark");
539            }
540        }
541    }
542
543    #[cfg(feature = "transform-explode")]
544    #[tokio::test]
545    async fn stream_pages_batch_size_zero_emits_one_page() {
546        let inner: Box<dyn Source> = Box::new(OnePageSource {
547            records: explode_10x_records(10), // 10 → 100 after explode
548            bookmark: Some(json!("bm")),
549        });
550        let wrapped =
551            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
552        let ctx = HashMap::new();
553        let mut stream = wrapped.stream_pages(&ctx, 0);
554        let mut sub_pages: Vec<StreamPage> = Vec::new();
555        while let Some(p) = stream.next().await {
556            sub_pages.push(p.unwrap());
557        }
558        assert_eq!(sub_pages.len(), 1, "batch_size=0 means one sub-page");
559        assert_eq!(sub_pages[0].records.len(), 100);
560        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
561    }
562
563    #[cfg(feature = "transform-filter")]
564    #[tokio::test]
565    async fn stream_pages_filter_drops_all_still_yields_bookmark() {
566        let inner: Box<dyn Source> = Box::new(OnePageSource {
567            records: vec![json!({"deleted": true}), json!({"deleted": true})],
568            bookmark: Some(json!("bm")),
569        });
570        let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
571            path: "deleted".to_owned(),
572            op: crate::stage::FilterOp::Ne,
573            value: Some(json!(true)),
574        });
575        let wrapped =
576            TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
577        let ctx = HashMap::new();
578        let mut stream = wrapped.stream_pages(&ctx, 100);
579        let mut sub_pages: Vec<StreamPage> = Vec::new();
580        while let Some(p) = stream.next().await {
581            sub_pages.push(p.unwrap());
582        }
583        assert_eq!(sub_pages.len(), 1);
584        assert!(sub_pages[0].records.is_empty());
585        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
586    }
587}