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 #[cfg(feature = "arrow")]
48 batch_fns: Vec<Option<crate::stage::PageFnBatchBox>>,
49}
50
51impl TransformingSource {
52 pub fn new(
57 inner: Box<dyn Source>,
58 stages: Vec<TransformStage>,
59 labels: Labels,
60 ) -> Result<Self, FaucetError> {
61 let compiled = stages
62 .iter()
63 .map(compile_stage)
64 .collect::<Result<Vec<_>, _>>()?;
65 #[cfg(feature = "arrow")]
66 let n = compiled.len();
67 Ok(Self {
68 inner,
69 stages: compiled,
70 labels,
71 #[cfg(feature = "arrow")]
72 batch_fns: vec![None; n],
73 })
74 }
75
76 #[cfg(feature = "arrow")]
81 pub fn new_with_batches(
82 inner: Box<dyn Source>,
83 stages: Vec<TransformStage>,
84 batch_fns: Vec<Option<crate::stage::PageFnBatchBox>>,
85 labels: Labels,
86 ) -> Result<Self, FaucetError> {
87 if batch_fns.len() != stages.len() {
88 return Err(FaucetError::Transform(format!(
89 "TransformingSource::new_with_batches: {} batch fns for {} stages",
90 batch_fns.len(),
91 stages.len()
92 )));
93 }
94 let compiled = stages
95 .iter()
96 .map(compile_stage)
97 .collect::<Result<Vec<_>, _>>()?;
98 Ok(Self {
99 inner,
100 stages: compiled,
101 labels,
102 batch_fns,
103 })
104 }
105}
106
107#[async_trait]
108impl Source for TransformingSource {
109 async fn fetch_with_context(
110 &self,
111 ctx: &HashMap<String, Value>,
112 ) -> Result<Vec<Value>, FaucetError> {
113 let records = self.inner.fetch_with_context(ctx).await?;
114 instrumented_apply_stages(records, &self.stages, &self.labels)
115 }
116
117 async fn fetch_with_context_incremental(
118 &self,
119 ctx: &HashMap<String, Value>,
120 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
121 let (records, bookmark) = self.inner.fetch_with_context_incremental(ctx).await?;
122 let transformed = instrumented_apply_stages(records, &self.stages, &self.labels)?;
123 Ok((transformed, bookmark))
124 }
125
126 fn stream_pages<'a>(
127 &'a self,
128 ctx: &'a HashMap<String, Value>,
129 batch_size: usize,
130 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
131 Box::pin(async_stream::try_stream! {
132 let mut pages = self.inner.stream_pages(ctx, batch_size);
133 while let Some(page) = pages.next().await {
134 let page = page?;
135 let out = instrumented_apply_stages(
136 page.records, &self.stages, &self.labels,
137 )?;
138 if out.is_empty() {
139 yield StreamPage { records: vec![], bookmark: page.bookmark };
140 continue;
141 }
142 if batch_size == 0 {
143 yield StreamPage { records: out, bookmark: page.bookmark };
144 continue;
145 }
146 let total = out.len();
147 let mut start = 0usize;
148 while start < total {
149 let end = std::cmp::min(start + batch_size, total);
150 let is_last = end == total;
151 let chunk: Vec<Value> = out[start..end].to_vec();
152 yield StreamPage {
153 records: chunk,
154 bookmark: if is_last { page.bookmark.clone() } else { None },
155 };
156 start = end;
157 }
158 }
159 })
160 }
161
162 #[cfg(feature = "arrow")]
167 fn supports_columnar(&self) -> bool {
168 self.inner.supports_columnar()
169 && !self.batch_fns.is_empty()
170 && self.batch_fns.iter().all(Option::is_some)
171 }
172
173 #[cfg(feature = "arrow")]
178 fn stream_batches<'a>(
179 &'a self,
180 ctx: &'a HashMap<String, Value>,
181 batch_size: usize,
182 ) -> Pin<Box<dyn Stream<Item = Result<crate::columnar::ColumnarPage, FaucetError>> + Send + 'a>>
183 {
184 Box::pin(async_stream::try_stream! {
185 let mut pages = self.inner.stream_batches(ctx, batch_size);
186 while let Some(page) = pages.next().await {
187 let crate::columnar::ColumnarPage { mut batch, bookmark } = page?;
188 for bf in self.batch_fns.iter().flatten() {
189 batch = bf(batch)?;
190 }
191 yield crate::columnar::ColumnarPage { batch, bookmark };
192 }
193 })
194 }
195
196 fn state_key(&self) -> Option<String> {
197 self.inner.state_key()
198 }
199
200 async fn apply_start_bookmark(&self, bookmark: Value) -> Result<(), FaucetError> {
201 self.inner.apply_start_bookmark(bookmark).await
202 }
203
204 fn supports_exactly_once(&self) -> bool {
205 self.inner.supports_exactly_once()
206 }
207
208 fn replay_guarantee(&self) -> crate::idempotency::ReplayGuarantee {
209 self.inner.replay_guarantee()
210 }
211
212 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
213 self.inner.capture_resume_position().await
214 }
215
216 fn connector_name(&self) -> &'static str {
217 self.inner.connector_name()
218 }
219
220 fn dataset_uri(&self) -> String {
221 self.inner.dataset_uri()
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::stage::TransformStage;
232 use crate::transform::{KeyCaseMode, RecordTransform};
233 use serde_json::json;
234 use std::sync::Arc;
235 use std::sync::atomic::{AtomicBool, Ordering};
236
237 struct MockSource(Vec<Value>);
238
239 #[async_trait]
240 impl Source for MockSource {
241 async fn fetch_with_context(
242 &self,
243 _ctx: &HashMap<String, Value>,
244 ) -> Result<Vec<Value>, FaucetError> {
245 Ok(self.0.clone())
246 }
247 }
248
249 #[tokio::test]
250 async fn fetch_with_context_transforms_records() {
251 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
252 let wrapped = TransformingSource::new(
253 inner,
254 vec![TransformStage::Map(RecordTransform::KeysCase {
255 mode: KeyCaseMode::Snake,
256 })],
257 Labels::for_named("test"),
258 )
259 .expect("compile succeeds");
260 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
261 assert_eq!(out, vec![json!({"foo_bar": 1})]);
262 }
263
264 struct IncrementalSource {
265 records: Vec<Value>,
266 bookmark: Value,
267 }
268
269 #[async_trait]
270 impl Source for IncrementalSource {
271 async fn fetch_with_context(
272 &self,
273 _ctx: &HashMap<String, Value>,
274 ) -> Result<Vec<Value>, FaucetError> {
275 Ok(self.records.clone())
276 }
277
278 async fn fetch_with_context_incremental(
279 &self,
280 _ctx: &HashMap<String, Value>,
281 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
282 Ok((self.records.clone(), Some(self.bookmark.clone())))
283 }
284 }
285
286 #[tokio::test]
287 async fn fetch_with_context_incremental_transforms_and_preserves_bookmark() {
288 let inner: Box<dyn Source> = Box::new(IncrementalSource {
289 records: vec![json!({"FooBar": 1})],
290 bookmark: json!("2026-05-28T00:00:00Z"),
291 });
292 let wrapped = TransformingSource::new(
293 inner,
294 vec![TransformStage::Map(RecordTransform::KeysCase {
295 mode: KeyCaseMode::Snake,
296 })],
297 Labels::for_named("test"),
298 )
299 .unwrap();
300 let (records, bookmark) = wrapped
301 .fetch_with_context_incremental(&HashMap::new())
302 .await
303 .unwrap();
304 assert_eq!(records, vec![json!({"foo_bar": 1})]);
305 assert_eq!(bookmark, Some(json!("2026-05-28T00:00:00Z")));
306 }
307
308 struct PagedSource {
313 pages: Vec<Vec<Value>>,
314 final_bookmark: Value,
315 }
316
317 #[async_trait]
318 impl Source for PagedSource {
319 async fn fetch_with_context(
320 &self,
321 _ctx: &HashMap<String, Value>,
322 ) -> Result<Vec<Value>, FaucetError> {
323 Ok(self.pages.iter().flatten().cloned().collect())
324 }
325
326 fn stream_pages<'a>(
327 &'a self,
328 _ctx: &'a HashMap<String, Value>,
329 _batch_size: usize,
330 ) -> Pin<Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>>
331 {
332 let pages = self.pages.clone();
333 let bookmark = self.final_bookmark.clone();
334 Box::pin(async_stream::try_stream! {
335 let n = pages.len();
336 for (i, records) in pages.into_iter().enumerate() {
337 let bm = if i + 1 == n { Some(bookmark.clone()) } else { None };
338 yield StreamPage { records, bookmark: bm };
339 }
340 })
341 }
342 }
343
344 #[tokio::test]
345 async fn stream_pages_transforms_each_page_and_preserves_bookmarks() {
346 let inner: Box<dyn Source> = Box::new(PagedSource {
347 pages: vec![
348 vec![json!({"FooBar": 1})],
349 vec![json!({"FooBar": 2})],
350 vec![json!({"FooBar": 3})],
351 ],
352 final_bookmark: json!("v1"),
353 });
354 let wrapped = TransformingSource::new(
355 inner,
356 vec![TransformStage::Map(RecordTransform::KeysCase {
357 mode: KeyCaseMode::Snake,
358 })],
359 Labels::for_named("test"),
360 )
361 .unwrap();
362
363 let ctx = HashMap::new();
364 let mut stream = wrapped.stream_pages(&ctx, 1000);
365 let mut collected: Vec<StreamPage> = Vec::new();
366 while let Some(page) = stream.next().await {
367 collected.push(page.unwrap());
368 }
369
370 assert_eq!(collected.len(), 3);
371 assert_eq!(collected[0].records, vec![json!({"foo_bar": 1})]);
372 assert!(collected[0].bookmark.is_none());
373 assert_eq!(collected[1].records, vec![json!({"foo_bar": 2})]);
374 assert!(collected[1].bookmark.is_none());
375 assert_eq!(collected[2].records, vec![json!({"foo_bar": 3})]);
376 assert_eq!(collected[2].bookmark, Some(json!("v1")));
377 }
378
379 #[tokio::test]
380 async fn stream_pages_passes_through_empty_records_page_with_bookmark() {
381 struct EmptyWithBookmark;
382 #[async_trait]
383 impl Source for EmptyWithBookmark {
384 async fn fetch_with_context(
385 &self,
386 _ctx: &HashMap<String, Value>,
387 ) -> Result<Vec<Value>, FaucetError> {
388 Ok(Vec::new())
389 }
390 fn stream_pages<'a>(
391 &'a self,
392 _ctx: &'a HashMap<String, Value>,
393 _batch_size: usize,
394 ) -> Pin<
395 Box<dyn futures_core::Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>,
396 > {
397 Box::pin(async_stream::try_stream! {
398 yield StreamPage { records: Vec::new(), bookmark: Some(json!("v1")) };
399 })
400 }
401 }
402 let wrapped = TransformingSource::new(
403 Box::new(EmptyWithBookmark),
404 vec![TransformStage::Map(RecordTransform::KeysCase {
405 mode: KeyCaseMode::Snake,
406 })],
407 Labels::for_named("test"),
408 )
409 .unwrap();
410 let ctx = HashMap::new();
411 let mut stream = wrapped.stream_pages(&ctx, 1000);
412 let page = stream.next().await.unwrap().unwrap();
413 assert!(page.records.is_empty());
414 assert_eq!(page.bookmark, Some(json!("v1")));
415 assert!(stream.next().await.is_none());
416 }
417
418 struct InstrumentedSource {
419 started: Arc<AtomicBool>,
420 }
421
422 #[async_trait]
423 impl Source for InstrumentedSource {
424 async fn fetch_with_context(
425 &self,
426 _ctx: &HashMap<String, Value>,
427 ) -> Result<Vec<Value>, FaucetError> {
428 Ok(vec![])
429 }
430 fn connector_name(&self) -> &'static str {
431 "instrumented"
432 }
433 fn state_key(&self) -> Option<String> {
434 Some("instrumented::key".to_string())
435 }
436 async fn apply_start_bookmark(&self, _bookmark: Value) -> Result<(), FaucetError> {
437 self.started.store(true, Ordering::Relaxed);
438 Ok(())
439 }
440 fn supports_exactly_once(&self) -> bool {
441 true
442 }
443 async fn capture_resume_position(&self) -> Result<Option<Value>, FaucetError> {
444 Ok(Some(json!("captured")))
445 }
446 }
447
448 #[tokio::test]
449 async fn connector_name_state_key_and_start_bookmark_delegate_to_inner() {
450 let started = Arc::new(AtomicBool::new(false));
451 let inner = InstrumentedSource {
452 started: started.clone(),
453 };
454 let wrapped = TransformingSource::new(
455 Box::new(inner),
456 vec![TransformStage::Map(RecordTransform::KeysCase {
457 mode: KeyCaseMode::Snake,
458 })],
459 Labels::for_named("test"),
460 )
461 .unwrap();
462 assert_eq!(wrapped.connector_name(), "instrumented");
463 assert_eq!(wrapped.state_key(), Some("instrumented::key".to_string()));
464 wrapped.apply_start_bookmark(json!("bm")).await.unwrap();
465 assert!(started.load(Ordering::Relaxed));
466 assert!(wrapped.supports_exactly_once());
469 assert_eq!(
470 wrapped.replay_guarantee(),
471 crate::idempotency::ReplayGuarantee::Deterministic
472 );
473 assert_eq!(
474 wrapped.capture_resume_position().await.unwrap(),
475 Some(json!("captured"))
476 );
477 }
478
479 #[tokio::test]
480 async fn new_fails_fast_on_invalid_regex() {
481 let inner: Box<dyn Source> = Box::new(MockSource(vec![]));
482 let result = TransformingSource::new(
483 inner,
484 vec![TransformStage::Map(RecordTransform::RenameKeys {
485 pattern: "[invalid".to_string(),
486 replacement: "x".to_string(),
487 })],
488 Labels::for_named("test"),
489 );
490 let err = match result {
491 Ok(_) => panic!("invalid regex must fail at new()"),
492 Err(e) => e,
493 };
494 assert!(matches!(err, FaucetError::Transform(_)));
495 }
496
497 #[tokio::test]
498 async fn custom_closure_transform_runs() {
499 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"x": 1})]));
500 let wrapped = TransformingSource::new(
501 inner,
502 vec![TransformStage::Map(RecordTransform::custom(
503 |mut record| {
504 if let Some(obj) = record.as_object_mut() {
505 obj.insert("added".to_string(), json!(true));
506 }
507 record
508 },
509 ))],
510 Labels::for_named("test"),
511 )
512 .unwrap();
513 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
514 assert_eq!(out, vec![json!({"x": 1, "added": true})]);
515 }
516
517 #[tokio::test]
518 async fn usable_as_boxed_dyn_source() {
519 let inner: Box<dyn Source> = Box::new(MockSource(vec![json!({"FooBar": 1})]));
520 let wrapped: Box<dyn Source> = Box::new(
521 TransformingSource::new(
522 inner,
523 vec![TransformStage::Map(RecordTransform::KeysCase {
524 mode: KeyCaseMode::Snake,
525 })],
526 Labels::for_named("test"),
527 )
528 .unwrap(),
529 );
530 let out = wrapped.fetch_with_context(&HashMap::new()).await.unwrap();
531 assert_eq!(out, vec![json!({"foo_bar": 1})]);
532 }
533
534 struct OnePageSource {
536 records: Vec<Value>,
537 bookmark: Option<Value>,
538 }
539
540 #[async_trait]
541 impl Source for OnePageSource {
542 async fn fetch_with_context(
543 &self,
544 _ctx: &HashMap<String, Value>,
545 ) -> Result<Vec<Value>, FaucetError> {
546 Ok(self.records.clone())
547 }
548 async fn fetch_with_context_incremental(
549 &self,
550 _ctx: &HashMap<String, Value>,
551 ) -> Result<(Vec<Value>, Option<Value>), FaucetError> {
552 Ok((self.records.clone(), self.bookmark.clone()))
553 }
554 fn stream_pages<'a>(
555 &'a self,
556 _ctx: &'a HashMap<String, Value>,
557 _batch_size: usize,
558 ) -> Pin<Box<dyn Stream<Item = Result<StreamPage, FaucetError>> + Send + 'a>> {
559 let page = StreamPage {
560 records: self.records.clone(),
561 bookmark: self.bookmark.clone(),
562 };
563 Box::pin(async_stream::stream! { yield Ok(page); })
564 }
565 }
566
567 #[cfg(feature = "transform-explode")]
568 fn explode_stage() -> TransformStage {
569 TransformStage::Explode(crate::stage::ExplodeSpec {
570 path: "items".to_owned(),
571 prefix: None,
572 separator: "_".to_owned(),
573 on_missing: crate::stage::OnMissing::Drop,
574 })
575 }
576
577 #[cfg(feature = "transform-explode")]
579 fn explode_10x_records(n: usize) -> Vec<Value> {
580 (0..n)
581 .map(|i| {
582 json!({
583 "id": i,
584 "items": (0..10).map(|j| json!({"k": j})).collect::<Vec<_>>(),
585 })
586 })
587 .collect()
588 }
589
590 #[cfg(feature = "transform-explode")]
591 #[tokio::test]
592 async fn stream_pages_rechunks_explosion_with_bookmark_on_last() {
593 let inner: Box<dyn Source> = Box::new(OnePageSource {
594 records: explode_10x_records(100), bookmark: Some(json!("bm")),
596 });
597 let wrapped =
598 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
599 let ctx = HashMap::new();
600 let mut stream = wrapped.stream_pages(&ctx, 200);
601 let mut sub_pages: Vec<StreamPage> = Vec::new();
602 while let Some(p) = stream.next().await {
603 sub_pages.push(p.unwrap());
604 }
605 assert_eq!(sub_pages.len(), 5, "1000 records / 200 batch = 5 sub-pages");
606 for (i, p) in sub_pages.iter().enumerate() {
607 assert_eq!(p.records.len(), 200, "sub-page {i} should be size 200");
608 if i < 4 {
609 assert!(
610 p.bookmark.is_none(),
611 "non-final sub-page {i} carries no bookmark"
612 );
613 } else {
614 assert_eq!(p.bookmark, Some(json!("bm")), "final sub-page has bookmark");
615 }
616 }
617 }
618
619 #[cfg(feature = "transform-explode")]
620 #[tokio::test]
621 async fn stream_pages_batch_size_zero_emits_one_page() {
622 let inner: Box<dyn Source> = Box::new(OnePageSource {
623 records: explode_10x_records(10), bookmark: Some(json!("bm")),
625 });
626 let wrapped =
627 TransformingSource::new(inner, vec![explode_stage()], Labels::for_named("t")).unwrap();
628 let ctx = HashMap::new();
629 let mut stream = wrapped.stream_pages(&ctx, 0);
630 let mut sub_pages: Vec<StreamPage> = Vec::new();
631 while let Some(p) = stream.next().await {
632 sub_pages.push(p.unwrap());
633 }
634 assert_eq!(sub_pages.len(), 1, "batch_size=0 means one sub-page");
635 assert_eq!(sub_pages[0].records.len(), 100);
636 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
637 }
638
639 #[cfg(feature = "transform-filter")]
640 #[tokio::test]
641 async fn stream_pages_filter_drops_all_still_yields_bookmark() {
642 let inner: Box<dyn Source> = Box::new(OnePageSource {
643 records: vec![json!({"deleted": true}), json!({"deleted": true})],
644 bookmark: Some(json!("bm")),
645 });
646 let drop_all = TransformStage::Filter(crate::stage::FilterSpec {
647 path: "deleted".to_owned(),
648 op: crate::stage::FilterOp::Ne,
649 value: Some(json!(true)),
650 });
651 let wrapped =
652 TransformingSource::new(inner, vec![drop_all], Labels::for_named("t")).unwrap();
653 let ctx = HashMap::new();
654 let mut stream = wrapped.stream_pages(&ctx, 100);
655 let mut sub_pages: Vec<StreamPage> = Vec::new();
656 while let Some(p) = stream.next().await {
657 sub_pages.push(p.unwrap());
658 }
659 assert_eq!(sub_pages.len(), 1);
660 assert!(sub_pages[0].records.is_empty());
661 assert_eq!(sub_pages[0].bookmark, Some(json!("bm")));
662 }
663}
664
665#[cfg(all(test, feature = "arrow"))]
666mod columnar_tests {
667 use super::*;
668 use crate::columnar::{ColumnarPage, record_batch_to_values, values_to_record_batch_inferred};
669 use crate::stage::TransformStage;
670 use serde_json::json;
671 use std::sync::Arc;
672
673 struct ColumnarMock(Vec<Value>);
675 #[async_trait]
676 impl Source for ColumnarMock {
677 async fn fetch_with_context(
678 &self,
679 _ctx: &HashMap<String, Value>,
680 ) -> Result<Vec<Value>, FaucetError> {
681 Ok(self.0.clone())
682 }
683 fn supports_columnar(&self) -> bool {
684 true
685 }
686 fn stream_batches<'a>(
687 &'a self,
688 _ctx: &'a HashMap<String, Value>,
689 _bs: usize,
690 ) -> Pin<Box<dyn Stream<Item = Result<ColumnarPage, FaucetError>> + Send + 'a>> {
691 let batch = values_to_record_batch_inferred(&self.0).unwrap();
692 Box::pin(async_stream::stream! {
693 yield Ok(ColumnarPage { batch, bookmark: Some(json!("bm")) });
694 })
695 }
696 }
697
698 struct RowOnlyMock(Vec<Value>);
700 #[async_trait]
701 impl Source for RowOnlyMock {
702 async fn fetch_with_context(
703 &self,
704 _ctx: &HashMap<String, Value>,
705 ) -> Result<Vec<Value>, FaucetError> {
706 Ok(self.0.clone())
707 }
708 }
709
710 fn identity_stage() -> (TransformStage, Option<crate::stage::PageFnBatchBox>) {
713 let rows: crate::stage::PageFnBox = Arc::new(Ok);
714 let batch: crate::stage::PageFnBatchBox = Arc::new(Ok);
715 (TransformStage::PageFn(rows), Some(batch))
716 }
717
718 #[tokio::test]
719 async fn columnar_inner_plus_columnar_stage_is_supported_and_streams() {
720 let inner: Box<dyn Source> =
721 Box::new(ColumnarMock(vec![json!({"id": 1}), json!({"id": 2})]));
722 let (stage, batch) = identity_stage();
723 let wrapped = TransformingSource::new_with_batches(
724 inner,
725 vec![stage],
726 vec![batch],
727 Labels::for_named("t"),
728 )
729 .unwrap();
730 assert!(wrapped.supports_columnar());
731 let ctx = HashMap::new();
732 let mut s = wrapped.stream_batches(&ctx, 0);
733 let page = s.next().await.unwrap().unwrap();
734 let rows = record_batch_to_values(&page.batch).unwrap();
735 assert_eq!(rows.len(), 2);
736 assert_eq!(page.bookmark, Some(json!("bm")));
737 }
738
739 #[tokio::test]
740 async fn value_only_stage_disables_columnar() {
741 let inner: Box<dyn Source> = Box::new(ColumnarMock(vec![json!({"FooBar": 1})]));
744 let wrapped = TransformingSource::new(
745 inner,
746 vec![TransformStage::Map(
747 crate::transform::RecordTransform::KeysCase {
748 mode: crate::transform::KeyCaseMode::Snake,
749 },
750 )],
751 Labels::for_named("t"),
752 )
753 .unwrap();
754 assert!(!wrapped.supports_columnar());
755 }
756
757 #[tokio::test]
758 async fn columnar_stage_over_row_only_inner_is_disabled() {
759 let inner: Box<dyn Source> = Box::new(RowOnlyMock(vec![json!({"id": 1})]));
760 let (stage, batch) = identity_stage();
761 let wrapped = TransformingSource::new_with_batches(
762 inner,
763 vec![stage],
764 vec![batch],
765 Labels::for_named("t"),
766 )
767 .unwrap();
768 assert!(!wrapped.supports_columnar(), "inner is not columnar");
769 }
770}