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 connector_name(&self) -> &'static str {
129        self.inner.connector_name()
130    }
131
132    fn dataset_uri(&self) -> String {
133        // Forward the wrapped connector's identity — without this, lineage and
134        // the Data Movement Catalog would see the default
135        // `<connector>://unknown` whenever transforms are attached.
136        self.inner.dataset_uri()
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143    use crate::stage::TransformStage;
144    use crate::transform::{KeyCaseMode, RecordTransform};
145    use serde_json::json;
146    use std::sync::Arc;
147    use std::sync::atomic::{AtomicBool, Ordering};
148
149    struct MockSource(Vec<Value>);
150
151    #[async_trait]
152    impl Source for MockSource {
153        async fn fetch_with_context(
154            &self,
155            _ctx: &HashMap<String, Value>,
156        ) -> Result<Vec<Value>, FaucetError> {
157            Ok(self.0.clone())
158        }
159    }
160
161    #[tokio::test]
162    async fn fetch_with_context_transforms_records() {
163        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
164        let wrapped = TransformingSource::new(
165            inner,
166            vec![TransformStage::Map(RecordTransform::KeysCase {
167                mode: KeyCaseMode::Snake,
168            })],
169            Labels::for_named("test"),
170        )
171        .expect("compile succeeds");
172        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
173        assert_eq!(out, vec![json!({"foo_bar": 1})]);
174    }
175
176    struct IncrementalSource {
177        records: Vec<Value>,
178        bookmark: Value,
179    }
180
181    #[async_trait]
182    impl Source for IncrementalSource {
183        async fn fetch_with_context(
184            &self,
185            _ctx: &HashMap<String, Value>,
186        ) -> Result<Vec<Value>, FaucetError> {
187            Ok(self.records.clone())
188        }
189
190        async fn fetch_with_context_incremental(
191            &self,
192            _ctx: &HashMap<String, Value>,
193        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
194            Ok((self.records.clone(), Some(self.bookmark.clone())))
195        }
196    }
197
198    #[tokio::test]
199    async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
200        let inner: Box<dyn Source> = Box::new(IncrementalSource {
201            records: vec![json!({"FooBar": 1})],
202            bookmark: json!("2026-05-28T00:00:00Z"),
203        });
204        let wrapped = TransformingSource::new(
205            inner,
206            vec![TransformStage::Map(RecordTransform::KeysCase {
207                mode: KeyCaseMode::Snake,
208            })],
209            Labels::for_named("test"),
210        )
211        .unwrap();
212        let (records, bookmark) = wrapped
213            .fetch_with_context_incremental(&HashMap::new())
214            .await
215            .unwrap();
216        assert_eq!(records, vec![json!({"foo_bar": 1})]);
217        assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
218    }
219
220    /// Emits records as N predetermined pages with the bookmark only on the last.
221    /// Overrides `stream_pages` directly so the test catches whether the wrapper
222    /// delegates to the native streaming path (correct) or falls back to the
223    /// chunk-the-buffer default (wrong — the bug we're fixing).
224    struct PagedSource {
225        pages: Vec<Vec<Value>>,
226        final_bookmark: Value,
227    }
228
229    #[async_trait]
230    impl Source for PagedSource {
231        async fn fetch_with_context(
232            &self,
233            _ctx: &HashMap<String, Value>,
234        ) -> Result<Vec<Value>, FaucetError> {
235            Ok(self.pages.iter().flatten().cloned().collect())
236        }
237
238        fn stream_pages<'a>(
239            &'a self,
240            _ctx: &'a HashMap<String, Value>,
241            _batch_size: usize,
242        ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
243        {
244            let pages = self.pages.clone();
245            let bookmark = self.final_bookmark.clone();
246            Box::pin(async_stream::try_stream! {
247                let n = pages.len();
248                for (i, records) in pages.into_iter().enumerate() {
249                    let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
250                    yield StreamPage { records, bookmark: bm };
251                }
252            })
253        }
254    }
255
256    #[tokio::test]
257    async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
258        let inner: Box<dyn Source> = Box::new(PagedSource {
259            pages: vec![
260                vec![json!({"FooBar": 1})],
261                vec![json!({"FooBar": 2})],
262                vec![json!({"FooBar": 3})],
263            ],
264            final_bookmark: json!("v1"),
265        });
266        let wrapped = TransformingSource::new(
267            inner,
268            vec![TransformStage::Map(RecordTransform::KeysCase {
269                mode: KeyCaseMode::Snake,
270            })],
271            Labels::for_named("test"),
272        )
273        .unwrap();
274
275        let ctx = HashMap::new();
276        let mut stream = wrapped.stream_pages(&ctx, 1000);
277        let mut collected: Vec<StreamPage> = Vec::new();
278        while let Some(page) = stream.next().await {
279            collected.push(page.unwrap());
280        }
281
282        assert_eq!(collected.len(), 3);
283        assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
284        assert!(collected[0].bookmark.is_none());
285        assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
286        assert!(collected[1].bookmark.is_none());
287        assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
288        assert_eq!(collected[2].bookmark, Some(json!("v1")));
289    }
290
291    #[tokio::test]
292    async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
293        struct EmptyWithBookmark;
294        #[async_trait]
295        impl Source for EmptyWithBookmark {
296            async fn fetch_with_context(
297                &self,
298                _ctx: &HashMap<String, Value>,
299            ) -> Result<Vec<Value>, FaucetError> {
300                Ok(Vec::new())
301            }
302            fn stream_pages<'a>(
303                &'a self,
304                _ctx: &'a HashMap<String, Value>,
305                _batch_size: usize,
306            ) -> Pin<
307                Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
308            > {
309                Box::pin(async_stream::try_stream! {
310                    yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
311                })
312            }
313        }
314        let wrapped = TransformingSource::new(
315            Box::new(EmptyWithBookmark),
316            vec![TransformStage::Map(RecordTransform::KeysCase {
317                mode: KeyCaseMode::Snake,
318            })],
319            Labels::for_named("test"),
320        )
321        .unwrap();
322        let ctx = HashMap::new();
323        let mut stream = wrapped.stream_pages(&ctx, 1000);
324        let page = stream.next().await.unwrap().unwrap();
325        assert!(page.records.is_empty());
326        assert_eq!(page.bookmark, Some(json!("v1")));
327        assert!(stream.next().await.is_none());
328    }
329
330    struct InstrumentedSource {
331        started: Arc<AtomicBool>,
332    }
333
334    #[async_trait]
335    impl Source for InstrumentedSource {
336        async fn fetch_with_context(
337            &self,
338            _ctx: &HashMap<String, Value>,
339        ) -> Result<Vec<Value>, FaucetError> {
340            Ok(vec![])
341        }
342        fn connector_name(&self) -> &'static str {
343            "instrumented"
344        }
345        fn state_key(&self) -> Option<String> {
346            Some("instrumented::key".to_string())
347        }
348        async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
349            self.started.store(true, Ordering::Relaxed);
350            Ok(())
351        }
352    }
353
354    #[tokio::test]
355    async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
356        let started = Arc::new(AtomicBool::new(false));
357        let inner = InstrumentedSource {
358            started: started.clone(),
359        };
360        let wrapped = TransformingSource::new(
361            Box::new(inner),
362            vec![TransformStage::Map(RecordTransform::KeysCase {
363                mode: KeyCaseMode::Snake,
364            })],
365            Labels::for_named("test"),
366        )
367        .unwrap();
368        assert_eq!(wrapped.connector_name(), "instrumented");
369        assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
370        wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
371        assert!(started.load(Ordering::Relaxed));
372    }
373
374    #[tokio::test]
375    async fn new_fails_fast_on_invalid_regex() {
376        let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
377        let result = TransformingSource::new(
378            inner,
379            vec![TransformStage::Map(RecordTransform::RenameKeys {
380                pattern: "[invalid".to_string(),
381                replacement: "x".to_string(),
382            })],
383            Labels::for_named("test"),
384        );
385        let err = match result {
386            Ok(_) => panic!("invalid regex must fail at new()"),
387            Err(e) => e,
388        };
389        assert!(matches!(err, FaucetError::Transform(_)));
390    }
391
392    #[tokio::test]
393    async fn custom_closure_transform_runs() {
394        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
395        let wrapped = TransformingSource::new(
396            inner,
397            vec![TransformStage::Map(RecordTransform::custom(
398                |mut record| {
399                    if let Some(obj) = record.as_object_mut() {
400                        obj.insert("added".to_string(), json!(true));
401                    }
402                    record
403                },
404            ))],
405            Labels::for_named("test"),
406        )
407        .unwrap();
408        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
409        assert_eq!(out, vec![json!({"x": 1, "added": true})]);
410    }
411
412    #[tokio::test]
413    async fn usable_as_boxed_dyn_source() {
414        let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
415        let wrapped: Box<dyn Source> = Box::new(
416            TransformingSource::new(
417                inner,
418                vec![TransformStage::Map(RecordTransform::KeysCase {
419                    mode: KeyCaseMode::Snake,
420                })],
421                Labels::for_named("test"),
422            )
423            .unwrap(),
424        );
425        let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
426        assert_eq!(out, vec![json!({"foo_bar": 1})]);
427    }
428
429    /// A source that emits a single page with the given records and bookmark.
430    struct OnePageSource {
431        records: Vec<Value>,
432        bookmark: Option<Value>,
433    }
434
435    #[async_trait]
436    impl Source for OnePageSource {
437        async fn fetch_with_context(
438            &self,
439            _ctx: &HashMap<String, Value>,
440        ) -> Result<Vec<Value>, FaucetError> {
441            Ok(self.records.clone())
442        }
443        async fn fetch_with_context_incremental(
444            &self,
445            _ctx: &HashMap<String, Value>,
446        ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
447            Ok((self.records.clone(), self.bookmark.clone()))
448        }
449        fn stream_pages<'a>(
450            &'a self,
451            _ctx: &'a HashMap<String, Value>,
452            _batch_size: usize,
453        ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
454            let page = StreamPage {
455                records: self.records.clone(),
456                bookmark: self.bookmark.clone(),
457            };
458            Box::pin(async_stream::stream! { yield Ok(page); })
459        }
460    }
461
462    #[cfg(feature = "transform-explode")]
463    fn explode_stage() -> TransformStage {
464        TransformStage::Explode(crate::stage::ExplodeSpec {
465            path: "items".to_owned(),
466            prefix: None,
467            separator: "_".to_owned(),
468            on_missing: crate::stage::OnMissing::Drop,
469        })
470    }
471
472    /// Build N records each with a 10-element `items` array, so explode 10×s them.
473    #[cfg(feature = "transform-explode")]
474    fn explode_10x_records(n: usize) -> Vec<Value> {
475        (0..n)
476            .map(|i| {
477                json!({
478                    "id": i,
479                    "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
480                })
481            })
482            .collect()
483    }
484
485    #[cfg(feature = "transform-explode")]
486    #[tokio::test]
487    async fn stream_pages_rechunks_explosion_with_bookmark_on_last() {
488        let inner: Box<dyn Source> = Box::new(OnePageSource {
489            records: explode_10x_records(100), // 100 → 1000 after explode
490            bookmark: Some(json!("bm")),
491        });
492        let wrapped =
493            TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
494        let ctx = HashMap::new();
495        let mut stream = wrapped.stream_pages(&ctx, 200);
496        let mut sub_pages: Vec<StreamPage> = Vec::new();
497        while let Some(p) = stream.next().await {
498            sub_pages.push(p.unwrap());
499        }
500        assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
501        for (i, p) in sub_pages.iter().enumerate() {
502            assert_eq!(p.records.len(), 200, "sub-page {i} should be size 200");
503            if i < 4 {
504                assert!(
505                    p.bookmark.is_none(),
506                    "non-final sub-page {i} carries no bookmark"
507                );
508            } else {
509                assert_eq!(p.bookmark, Some(json!("bm")), "final sub-page has bookmark");
510            }
511        }
512    }
513
514    #[cfg(feature = "transform-explode")]
515    #[tokio::test]
516    async fn stream_pages_batch_size_zero_emits_one_page() {
517        let inner: Box<dyn Source> = Box::new(OnePageSource {
518            records: explode_10x_records(10), // 10 → 100 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, 0);
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(), 1, "batch_size=0 means one sub-page");
530        assert_eq!(sub_pages[0].records.len(), 100);
531        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
532    }
533
534    #[cfg(feature = "transform-filter")]
535    #[tokio::test]
536    async fn stream_pages_filter_drops_all_still_yields_bookmark() {
537        let inner: Box<dyn Source> = Box::new(OnePageSource {
538            records: vec![json!({"deleted": true}), json!({"deleted": true})],
539            bookmark: Some(json!("bm")),
540        });
541        let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
542            path: "deleted".to_owned(),
543            op: crate::stage::FilterOp::Ne,
544            value: Some(json!(true)),
545        });
546        let wrapped =
547            TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
548        let ctx = HashMap::new();
549        let mut stream = wrapped.stream_pages(&ctx, 100);
550        let mut sub_pages: Vec<StreamPage> = Vec::new();
551        while let Some(p) = stream.next().await {
552            sub_pages.push(p.unwrap());
553        }
554        assert_eq!(sub_pages.len(), 1);
555        assert!(sub_pages[0].records.is_empty());
556        assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
557    }
558}