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 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 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 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 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 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 #[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), 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), 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}