1use 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
18pub struct TransformingSource {
39 inner: Box<dyn Source>,
40 stages: Vec<CompiledStage>,
41 labels: Labels,
42}
43
44impl TransformingSource {
45 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 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 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 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 #[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), 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), 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}